Compare commits

..

1 Commits

Author SHA1 Message Date
c935eed4d9 copy of job-invoicing branch as of April 22 2026 2026-04-22 15:06:17 -04:00
881 changed files with 8501 additions and 895317 deletions

View File

@ -1,77 +0,0 @@
# 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]"

View File

@ -1,64 +0,0 @@
#!/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
View File

@ -1,19 +0,0 @@
# Dependencies
**/node_modules/
# Logs
*.log
*.rlog
npm-debug.log*
# Environment files
**/*.env
**/environment.env
# Build output
**/dist/
**/build/
# OS files
.DS_Store
Thumbs.db

View File

@ -1,6 +0,0 @@
CURRENT_FILE_PATH=src/locale/messages.xlf
TRANSLATED_FILE_PATH_ES=src/locale/messages.es.xlf
TRANSLATED_FILE_PATH_PT=src/locale/messages.pt.xlf
GOOGLE_LOCATION=global
GOOGLE_PROJECT_ID=predictive-fx-392018
GOOGLE_APPLICATION_CREDENTIALS=google-cloud.json

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,221 +0,0 @@
Event Category,Event Name,Event Description,Component Location,Parameter Name,Parameter Type,Parameter Description,Allowed Values,Example Value,Required/Optional,Business Purpose,Validation Rules
Job Management,job_created,User creates a new agricultural job,job.effects.ts,job_type,String,Type of agricultural job being performed,"spraying, seeding, fertilizing, harvesting, soil_testing",spraying,Required,Categorize jobs for operational insights,Must be from predefined list
Job Management,job_created,User creates a new agricultural job,job.effects.ts,field_size_acres,Number,Size of the field in acres,Positive numbers up to 10000,150.5,Required,Track job scale and pricing,Must be > 0 and <= 10000
Job Management,job_created,User creates a new agricultural job,job.effects.ts,crop_type,String,Type of crop being worked on,"corn, soybeans, wheat, cotton, alfalfa, other",corn,Required,Analyze crop-specific patterns,Required for all job events
Job Management,job_created,User creates a new agricultural job,job.effects.ts,client_id,String,Unique identifier for the client,Alphanumeric string,CLIENT_12345,Required,Track client relationships and revenue,Must be valid client ID
Job Management,job_created,User creates a new agricultural job,job.effects.ts,priority,String,Job priority level,"low, medium, high, urgent",high,Required,Optimize job scheduling,Must be from predefined list
Job Management,job_created,User creates a new agricultural job,job.effects.ts,equipment_type,String,Type of equipment used,"drone, ground_rig, aerial, manual, tractor",drone,Optional,Track equipment utilization,Must be from equipment catalog
Job Management,job_created,User creates a new agricultural job,job.effects.ts,weather_dependency,Boolean,Whether job depends on weather conditions,true or false,true,Optional,Plan weather-sensitive operations,Boolean validation
Job Management,job_created,User creates a new agricultural job,job.effects.ts,estimated_duration_hours,Number,Expected job duration in hours,Positive numbers,4.5,Optional,Resource planning and scheduling,Must be > 0 and <= 24
Job Management,job_updated,User modifies an existing job,job.effects.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID
Job Management,job_updated,User modifies an existing job,job.effects.ts,fields_modified,Array,List of fields changed in update,Array of strings,"[""priority"", ""crop_type""]",Required,Monitor update patterns,Must be valid field names
Job Management,job_updated,User modifies an existing job,job.effects.ts,change_magnitude,String,Magnitude of the changes made,"minor, major",minor,Optional,Track update impact,Must be from predefined levels
Job Management,job_updated,User modifies an existing job,job.effects.ts,edit_session_duration,Number,Time spent editing in minutes,Positive numbers,15.5,Optional,Monitor user efficiency,Must be > 0
Job Management,job_updated,User modifies an existing job,job.effects.ts,save_method,String,How the update was saved,"manual, auto_save",manual,Optional,Track save behavior,Must be from valid save methods
Job Management,job_deleted,User removes a job from system,job.effects.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID
Job Management,job_deleted,User removes a job from system,job.effects.ts,job_type,String,Type of agricultural job being performed,"spraying, seeding, fertilizing, harvesting, soil_testing",spraying,Required,Categorize jobs for operational insights,Must be from predefined list
Job Management,job_deleted,User removes a job from system,job.effects.ts,job_status,String,Current status of the job,"new, ready, downloaded, sprayed, invoiced",new,Required,Track job lifecycle states,Must be from valid status list
Job Management,job_deleted,User removes a job from system,job.effects.ts,deletion_reason,String,Reason for job deletion,"cancelled, duplicate, error, user_action",user_action,Optional,Track deletion patterns and causes,Must be from predefined reasons
Job Management,job_deleted,User removes a job from system,job.effects.ts,time_since_creation,Number,Time between creation and deletion in hours,Non-negative numbers,48.5,Optional,Monitor job lifecycle timing,Must be >= 0
Job Management,job_deleted,User removes a job from system,job.effects.ts,deletion_method,String,How job deletion was triggered,"button_click, bulk_action, api_call",button_click,Optional,Optimize deletion workflows,Must be from valid methods
Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID
Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,assignee_id,String,ID of person assigned to job,Alphanumeric string,USER_456,Required,Track assignment patterns,Must be valid user ID
Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,assignee_role,String,Role of assigned person,"pilot, operator, supervisor, manager",pilot,Required,Optimize role assignments,Must be from predefined roles
Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,assignment_method,String,How assignment was made,"manual, auto, bulk",manual,Required,Track assignment efficiency,Must be from predefined methods
Job Management,job_assigned,Job assigned to pilot or operator,job.effects.ts,assignment_lead_time_hours,Number,Hours between assignment and scheduled start,Non-negative numbers,24.0,Optional,Track planning efficiency,Must be >= 0
Job Management,job_status_changed,Job status transitions,job-edit.component.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID
Job Management,job_status_changed,Job status transitions,job-edit.component.ts,old_status,String,Previous status before change,"new, ready, downloaded, sprayed, invoiced",new,Required,Track status transition patterns,Must be from valid status list
Job Management,job_status_changed,Job status transitions,job-edit.component.ts,new_status,String,New status after change,"new, ready, downloaded, sprayed, invoiced",sprayed,Required,Track status transition patterns,Must be from valid status list
Job Management,job_status_changed,Job status transitions,job-edit.component.ts,status_change_reason,String,Reason for status change,"user_action, system_update, api_call, automation",user_action,Required,Understand status change drivers,Must be from valid reason types
Job Management,job_status_changed,Job status transitions,job-edit.component.ts,completion_time,Number,Time to complete job in hours,Positive numbers,4.2,Optional,Track job completion efficiency,Must be > 0 when status changed to completed
Job Management,job_status_changed,Job status transitions,job-edit.component.ts,efficiency_score,Number,Calculated efficiency percentage,Number 0-100,85.5,Optional,Monitor operational efficiency,Must be between 0 and 100
Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,view_type,String,Type of list view used,"table, grid, map, calendar",table,Required,Optimize UI preferences,Must be from available views
Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,total_jobs,Number,Total jobs available in system,Non-negative integer,45,Required,Monitor system usage,Must be >= 0
Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,displayed_jobs,Number,Number of jobs shown to user,Non-negative integer,20,Required,Track filtering effectiveness,Must be >= 0 and <= total_jobs
Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,client_filter_applied,Boolean,Whether client filter is active,true or false,true,Optional,Track client-specific viewing patterns,Boolean validation
Job List Operations,job_list_viewed,User accesses the jobs list interface,job-list.component.ts,reload_interval,Number,Auto-reload interval in minutes,Non-negative integer,5,Optional,Track user preference for data freshness,Must be >= 0
Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,filter_type,String,Type of filter applied,"status, date_range, client, crop_type, priority",status,Required,Improve filter functionality,Must be valid filter type
Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,results_before,Number,Results count before filter,Non-negative integer,45,Required,Measure filter effectiveness,Must be >= 0
Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,results_after,Number,Results count after filter,Non-negative integer,12,Required,Measure filter effectiveness,Must be >= 0 and <= results_before
Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,filter_value,String,Value of the applied filter,String,new,Optional,Track specific filter usage,Must be valid for filter type
Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,date_filter_type,String,Type of date filter applied,"today, week, month, quarter, custom",month,Optional,Track temporal filtering patterns,Must be from valid date types
Job List Operations,job_list_filtered,User applies filters to narrow job results,job-list.component.ts,custom_date_range,Array,Custom date range selected,Array of dates,"[""2024-01-01"", ""2024-01-31""]",Optional,Track custom date usage,Must be valid date range
Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,job_id,String,Unique job identifier,Alphanumeric string,JOB_001,Required,Track individual job lifecycle,Must be valid job ID
Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,selection_method,String,Method used to select job,"row_click, search_result, link_navigation",row_click,Required,Optimize selection UX,Must be from valid selection methods
Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,position_in_list,Number,Position of job in list when selected,Positive integer,3,Optional,Track selection patterns,Must be > 0
Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,job_type,String,Type of agricultural job being performed,"spraying, seeding, fertilizing, harvesting, soil_testing",spraying,Optional,Categorize jobs for operational insights,Must be from predefined list
Job List Operations,job_selected,User clicks/selects a specific job,job-list.component.ts,job_status,String,Current status of the job,"new, ready, downloaded, sprayed, invoiced",new,Optional,Track job lifecycle states,Must be from valid status list
Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,action_type,String,Type of bulk action performed,"duplicate, delete, assign, status_change, export",duplicate,Required,Track bulk operation patterns,Must be from valid action types
Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,job_count,Number,Number of jobs affected by bulk action,Positive integer,1,Required,Monitor bulk operation scale,Must be > 0
Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,job_ids,Array,List of job IDs affected by action,Array of strings,"[""JOB_001""]",Required,Track specific jobs in bulk operations,Must be valid job IDs
Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,execution_time,Number,Time taken to complete bulk action in seconds,Positive numbers,2.5,Optional,Monitor bulk operation performance,Must be > 0
Job List Operations,job_bulk_action,User performs action on multiple jobs,job-list.component.ts,success_rate,Number,Percentage of successful operations in bulk action,Number 0-100,100,Optional,Track bulk operation reliability,Must be between 0 and 100
File Upload Operations,file_upload_started,User initiates file upload process,"upload.component.ts, job-edit.component.ts",file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types
File Upload Operations,file_upload_started,User initiates file upload process,"upload.component.ts, job-edit.component.ts",file_size_mb,Number,File size in megabytes,Positive numbers,2.3,Required,Monitor upload performance,Must be > 0 and <= 100
File Upload Operations,file_upload_started,User initiates file upload process,"upload.component.ts, job-edit.component.ts",related_job_id,String,Job ID associated with file upload,Alphanumeric string,JOB_001,Optional,Track file-job relationships,Must be valid job ID when provided
File Upload Operations,file_upload_started,User initiates file upload process,"upload.component.ts, job-edit.component.ts",upload_source,String,Source of file upload,"drag_drop, file_picker, api",drag_drop,Optional,Track upload method preferences,Must be from valid upload sources
File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types
File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",processing_time_seconds,Number,Time to process file in seconds,Positive numbers,15.2,Required,Optimize processing performance,Must be > 0
File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",validation_status,String,File validation result,"passed, failed, warning",passed,Required,Monitor file quality,Must be from validation states
File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",data_quality_score,Number,Quality score of uploaded file data,Number 0-100,87.5,Optional,Monitor data quality trends,Must be between 0 and 100
File Upload Operations,file_upload_completed,File upload finishes successfully,"upload.component.ts, job-edit.component.ts, areas.component.ts",automation_enabled,Boolean,Whether automated processing was used,true or false,true,Optional,Track automation usage,Boolean validation
File Upload Operations,file_upload_failed,File upload encounters error,"upload.component.ts, job-edit.component.ts",file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types
File Upload Operations,file_upload_failed,File upload encounters error,"upload.component.ts, job-edit.component.ts",error_type,String,Type of upload error,"network_error, file_too_large, invalid_format, timeout, server_error",invalid_format,Required,Improve error handling,Must be from error catalog
File Upload Operations,file_upload_failed,File upload encounters error,"upload.component.ts, job-edit.component.ts",file_size_mb,Number,File size in megabytes,Positive numbers,2.3,Required,Monitor upload performance,Must be > 0 and <= 100
File Upload Operations,file_upload_failed,File upload encounters error,"upload.component.ts, job-edit.component.ts",retry_attempted,Boolean,Whether user attempted to retry upload,true or false,true,Optional,Monitor retry patterns,Boolean validation
File Upload Operations,file_validation_error,File validation fails with specific errors,"upload.component.ts, job-edit.component.ts",validation_error_type,String,Type of file validation error,"missing_coordinates, invalid_geometry, unsupported_format, file_corruption, size_limit_exceeded",missing_coordinates,Required,Categorize validation failures,Must be from validation error types
File Upload Operations,file_validation_error,File validation fails with specific errors,"upload.component.ts, job-edit.component.ts",error_details,String,Detailed error information,String,Invalid coordinate system detected,Required,Improve error messaging,Required for validation errors
File Upload Operations,file_validation_error,File validation fails with specific errors,"upload.component.ts, job-edit.component.ts",user_action,String,User action after validation error,"retry, cancel, ignore, edit",retry,Required,Track user response to errors,Must be from valid actions
File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types
File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,file_size_mb,Number,File size in megabytes,Positive numbers,2.3,Required,Monitor upload performance,Must be > 0 and <= 100
File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,deletion_reason,String,Reason for file deletion,"user_action, cleanup, replacement, error_correction",user_action,Required,Track file management patterns,Must be from predefined reasons
File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,confirmation_required,Boolean,Whether deletion required user confirmation,true or false,true,Required,Track UX patterns for file operations,Boolean validation
File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,related_job_id,String,Job ID associated with file upload,Alphanumeric string,JOB_001,Optional,Track file-job relationships,Must be valid job ID when provided
File Management Operations,file_deleted,User deletes an uploaded file,job-edit.component.ts,file_age_days,Number,Age of file in days when deleted,Non-negative integer,7,Optional,Monitor file lifecycle patterns,Must be >= 0
File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,file_type,String,Type of file being uploaded,"field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other",field_boundary,Required,Track file usage patterns,Must be from supported file types
File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,file_size_mb,Number,File size in megabytes,Positive numbers,2.3,Required,Monitor upload performance,Must be > 0 and <= 100
File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,download_method,String,Method used to download file,"direct_link, button_click, bulk_export",button_click,Required,Optimize download UX,Must be from valid download methods
File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,download_source,String,Source location of download action,"job_edit, file_manager, report_export",job_edit,Required,Track download context,Must be from valid source locations
File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,related_job_id,String,Job ID associated with file upload,Alphanumeric string,JOB_001,Optional,Track file-job relationships,Must be valid job ID when provided
File Management Operations,file_downloaded,User downloads a file,job-edit.component.ts,file_format,String,Format of downloaded file,"original, converted",original,Optional,Track format preferences,Must be valid format type
Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",upload_type,String,Type of library upload,"field_areas, tracked_areas",field_areas,Required,Track library content additions,Must be from valid upload types
Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",file_count,Number,Number of files uploaded,Positive integer,3,Required,Monitor upload volume,Must be > 0
Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",total_areas_uploaded,Number,Total number of areas added to library,Non-negative integer,12,Required,Track library growth,Must be >= 0
Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",duplicate_areas_found,Number,Number of duplicate areas detected,Non-negative integer,2,Optional,Monitor data quality,Must be >= 0
Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",failed_files,Number,Number of files that failed to process,Non-negative integer,0,Optional,Track processing reliability,Must be >= 0
Library Upload Operations,library_upload_completed,Areas/fields uploaded to library successfully,"areas.component.ts, track.component.ts",processing_method,String,Method used for processing,"automatic, manual_review",automatic,Optional,Track processing approaches,Must be from valid processing methods
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_type,String,Type of subscription purchased,AgMission package names (e.g. AgMission Essentials 1-5 or AgMission Enterprise 1-5),AgMission Essentials 3,Required,Track subscription tier adoption and revenue,Must be valid SUB_NAME value from common.ts
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_duration,String,Duration of subscription,"monthly, quarterly, annual",monthly,Required,Monitor subscription length preferences,Must be from valid duration options
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_price,Number,Price of subscription in USD,Positive numbers,99.99,Required,Track revenue per subscription,Must be > 0
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",previous_subscription_type,String,Previous subscription type before change,AgMission package names or none,AgMission Essentials 1,Optional,Track subscription transitions,Must be valid SUB_NAME value or none
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",payment_method,String,Method used for payment,"credit_card, bank_transfer, paypal, invoice",credit_card,Required,Optimize payment options,Must be from supported payment methods
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",billing_frequency,String,How often billing occurs,"monthly, quarterly, annual",monthly,Required,Track billing preferences,Must match subscription duration
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",promo_code,String,Promotional code used,Alphanumeric string,SAVE20,Optional,Track promotion effectiveness,Optional promotional code
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",discount_amount,Number,Discount applied in USD,Non-negative numbers,19.99,Optional,Monitor discount impact,Must be >= 0
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_start_date,String,Start date of subscription,ISO date string,2024-01-15,Required,Track subscription lifecycle,Must be valid ISO date
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",auto_renewal,Boolean,Whether subscription auto-renews,true or false,true,Required,Monitor auto-renewal adoption,Boolean validation
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",upgrade_from,String,Previous subscription tier when upgrading,AgMission package names,AgMission Essentials 1,Optional,Track upgrade patterns,Required when transaction is an upgrade
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",upgrade_to,String,New subscription tier when upgrading,AgMission package names,AgMission Essentials 3,Optional,Track upgrade patterns,Required when transaction is an upgrade
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",trial_conversion,Boolean,Whether purchase is converting from trial,true or false,true,Required,Monitor trial conversion rate,Boolean validation
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",subscription_value,Number,Annual contract value in USD,Positive numbers,1199.88,Required,Track customer lifetime value,Must be > 0
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",user_tenure_days,Number,Days since user first registered,Non-negative integer,45,Required,Analyze subscription timing patterns,Must be >= 0
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",service_type,String,Service category of subscription,"essential, enterprise, addon",essential,Optional,Categorize subscription types by service level,Must be from SERVICE_TYPE enum
E-commerce,subscription_purchased,User purchases or upgrades subscription,"subscription.effects.ts (updateSubscription$, checkoutTrial$)",is_trial,Boolean,Whether this is a trial subscription,true or false,false,Optional,Track trial vs paid subscriptions,Boolean validation
Performance,slow_page_load,Page loads slower than threshold,app.component.ts,page_url,String,URL of the page with slow load,String,/dashboard/jobs,Required,Identify performance bottlenecks,Must be valid URL path
Performance,slow_page_load,Page loads slower than threshold,app.component.ts,load_time,Number,Page load time in seconds,Positive numbers,8.5,Required,Monitor page performance,Must be > 0
Performance,slow_page_load,Page loads slower than threshold,app.component.ts,device_type,String,Type of device experiencing slow load,"desktop, mobile, tablet",desktop,Optional,Optimize for different devices,Must be from device categories
Performance,slow_page_load,Page loads slower than threshold,app.component.ts,connection_type,String,User's connection type,"wifi, cellular, ethernet, unknown",wifi,Optional,Understand connection impact,Must be from connection types
Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,api_endpoint,String,API endpoint with slow response,String,/api/v1/jobs,Required,Identify slow API endpoints,Must be valid API path
Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,response_time,Number,API response time in milliseconds,Positive numbers,3500,Required,Monitor API performance,Must be > 0
Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,request_size,Number,Size of API request in bytes,Non-negative integer,1024,Optional,Analyze request impact on performance,Must be >= 0
Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,response_size,Number,Size of API response in bytes,Non-negative integer,5120,Optional,Analyze response impact on performance,Must be >= 0
Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,cache_hit,Boolean,Whether response was served from cache,true or false,false,Optional,Monitor caching effectiveness,Boolean validation
Performance,api_response_slow,API calls exceed performance threshold,global-error.interceptor.ts,http_status,Number,HTTP status code of response,Valid HTTP status codes,200,Optional,Track response success patterns,Must be valid HTTP status code
Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,error_type,String,Type of HTTP error that occurred,"network_error, server_error, client_error, timeout, unknown_error",server_error,Required,Categorize HTTP errors for debugging and monitoring,Must be from predefined error types
Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,http_status_code,Number,HTTP status code returned by server,Integer 0-599,500,Required,Track specific HTTP error codes for debugging,Must be valid HTTP status code (0-599)
Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,request_method,String,HTTP method used for the request,"GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS",GET,Required,Track which HTTP methods encounter errors,Must be valid HTTP method
Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,request_url,String,Full URL of the failed request,Valid URL string,https://api.agmission.com/api/jobs,Required,Track specific endpoints experiencing errors,Must be valid URL format
Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,request_endpoint,String,API endpoint that failed,String,jobs,Required,Track which API endpoints have the most errors,Must be valid endpoint identifier
Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,response_time_ms,Number,Time taken for the request to fail in milliseconds,Non-negative integer,5000,Optional,Monitor request timing patterns for failed requests,Must be >= 0
Error Tracking,http_error,HTTP request errors automatically tracked by interceptor,global-error.interceptor.ts,affected_feature,String,Application feature affected by the error,"job_management, billing, reporting, file_management, user_management, authentication, customer_management, equipment_management, unknown",job_management,Optional,Track which features are most impacted by HTTP errors,Must be from predefined feature list
User Authentication,login,User logs into AgMission system,auth.service.ts,method,String,Authentication method used,"email, google, microsoft, sso",email,Required,Track authentication preferences and security,Must be from supported authentication methods
User Authentication,login,User logs into AgMission system,auth.service.ts,user_role,String,Role of the user performing the action,"admin, applicator, office_admin, client, officer, pilot, inspector, aircraft",applicator,Required,Segment analytics by user type and permissions,Must be from predefined role list
User Authentication,login,User logs into AgMission system,auth.service.ts,last_login_days_ago,Number,Days since user's last login,Non-negative numbers,7,Optional,Track user return patterns,Must be >= 0
User Authentication,logout,User logs out of system,auth.service.ts,session_duration_minutes,Number,Duration of user session in minutes,Positive integers,45,Required,Monitor user engagement and session patterns,Must be > 0
User Authentication,logout,User logs out of system,auth.service.ts,user_role,String,Role of the user performing the action,"admin, applicator, office_admin, client, officer, pilot, inspector, aircraft",applicator,Required,Segment analytics by user type and permissions,Must be from predefined role list
User Authentication,logout,User logs out of system,auth.service.ts,logout_method,String,How user logged out,"manual, timeout, forced",manual,Optional,Understand logout patterns and session management,Must be from predefined logout types
User Authentication,signup,User begins signup process,"signup-form.component.ts, signup-verify.component.ts",signup_method,String,Method used for account signup,"email, google, microsoft, invitation",email,Required,Track signup channel effectiveness,Must be from supported signup methods
User Authentication,signup,User begins signup process,"signup-form.component.ts, signup-verify.component.ts",user_type,String,Type of user signing up,"client, applicator, admin, office_admin",applicator,Required,Segment new user acquisition,Must be from predefined user types
User Authentication,signup,User begins signup process,"signup-form.component.ts, signup-verify.component.ts",source,String,Source of signup traffic,"landing_page, referral, advertisement, direct",landing_page,Optional,Track marketing channel effectiveness,Must be from valid traffic sources
User Authentication,signup,User begins signup process,"signup-form.component.ts, signup-verify.component.ts",company_name,String,Name of company during signup,String,AgriCorp Inc,Optional,Identify business customers,Required for business signups
User Authentication,signup_completed,User completes signup process,signup-form.component.ts,signup_duration_minutes,Number,Time taken to complete signup in minutes,Positive numbers,15.5,Required,Monitor signup flow efficiency,Must be > 0
User Authentication,signup_completed,User completes signup process,signup-form.component.ts,signup_method,String,Method used for account signup,"email, google, microsoft, invitation",email,Required,Track signup channel effectiveness,Must be from supported signup methods
User Authentication,signup_completed,User completes signup process,signup-form.component.ts,user_type,String,Type of user signing up,"client, applicator, admin, office_admin",applicator,Required,Segment new user acquisition,Must be from predefined user types
User Authentication,signup_completed,User completes signup process,signup-form.component.ts,verification_required,Boolean,Whether email verification was required,true or false,true,Required,Track verification requirements,Boolean validation
User Authentication,signup_completed,User completes signup process,signup-form.component.ts,profile_completed,Boolean,Whether user completed full profile setup,true or false,false,Optional,Monitor onboarding completion,Boolean validation
User Authentication,password_reset_requested,User requests password reset,auth.service.ts,request_method,String,How password reset was requested,"forgot_password_page, login_page, profile_page",forgot_password_page,Required,Track reset request patterns,Must be from valid request sources
User Authentication,password_reset_requested,User requests password reset,auth.service.ts,user_exists,Boolean,Whether user account exists for reset request,true or false,true,Required,Monitor reset request validity,Boolean validation
User Authentication,password_reset_requested,User requests password reset,auth.service.ts,email_address_hash,String,Hashed email address for privacy,String,abc123def456,Optional,Track verification requests while maintaining privacy,Must be valid hash when provided
User Authentication,password_reset_completed,Password reset process completed,"auth.service.ts, app.password-reset.component.ts",success,Boolean,Whether password reset was successful,true or false,true,Required,Track reset success rates,Boolean validation
User Authentication,password_reset_completed,Password reset process completed,"auth.service.ts, app.password-reset.component.ts",reset_token_age_minutes,Number,Age of reset token when used in minutes,Non-negative numbers,5,Required,Monitor token validity and timing,Must be >= 0
User Authentication,password_reset_completed,Password reset process completed,"auth.service.ts, app.password-reset.component.ts",failure_reason,String,Reason for password reset failure,"expired_token, invalid_token, weak_password, other",expired_token,Optional,Categorize reset failures for improvement,Must be from predefined failure reasons
User Authentication,email_verification_requested,User requests email verification,signup-verify.component.ts,request_method,String,How email verification was requested,"signup_form, verification_page, resend_request",verification_page,Required,Track verification request patterns,Must be from valid request sources
User Authentication,email_verification_requested,User requests email verification,signup-verify.component.ts,user_exists,Boolean,Whether user account exists for reset request,true or false,true,Required,Monitor reset request validity,Boolean validation
User Authentication,email_verification_requested,User requests email verification,signup-verify.component.ts,email_address_hash,String,Hashed email address for privacy,String,abc123def456,Optional,Track verification requests while maintaining privacy,Must be valid hash when provided
User Authentication,email_verification_completed,Email verification process completed,signup-verify.component.ts,success,Boolean,Whether password reset was successful,true or false,true,Required,Track reset success rates,Boolean validation
User Authentication,email_verification_completed,Email verification process completed,signup-verify.component.ts,verification_token_age_minutes,Number,Age of verification token when used in minutes,Non-negative numbers,30,Required,Monitor verification token validity and timing,Must be >= 0
User Authentication,email_verification_completed,Email verification process completed,signup-verify.component.ts,failure_reason,String,Reason for password reset failure,"expired_token, invalid_token, weak_password, other",expired_token,Optional,Categorize reset failures for improvement,Must be from predefined failure reasons
Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID
Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,total_amount,Number,Total invoice amount,Positive numbers,2500.00,Required,Track revenue and financial metrics,Must be > 0
Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,currency,String,Currency code for invoice amount,"USD, CAD, EUR",USD,Required,Track multi-currency operations,Must be valid ISO currency code
Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,creation_method,String,Method used to create invoice,"manual, auto_generated, template, recurring",manual,Required,Track invoice creation patterns,Must be from predefined methods
Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,due_date_days,Number,Days until invoice due date,Integer,30,Required,Track payment terms and cash flow,Must be >= 0
Invoice Management,invoice_created,User creates a new invoice,invoice-edit.component.ts,payment_terms,String,Payment terms for invoice,String,net_30,Optional,Analyze payment term preferences,Free text or predefined terms
Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID
Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,fields_modified,Array,List of fields changed in invoice update,Array of strings,"[""amount"", ""due_date""]",Required,Monitor invoice modification patterns,Must be valid field names
Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,modification_type,String,Primary type of modification made,"amount, due_date, jobs, customer, payment_terms",amount,Required,Categorize modification patterns,Must be from predefined types
Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,amount_change,Number,Change in invoice amount,Number (can be negative),-150.00,Optional,Track invoice adjustments,Can be positive or negative
Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,previous_status,String,Previous invoice status before update,"new, draft, open, paid, void, uncollectible",draft,Optional,Track status progression,Must be from valid status list
Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,current_status,String,Current invoice status after update,"new, draft, open, paid, void, uncollectible",open,Optional,Track status progression,Must be from valid status list
Invoice Management,invoice_updated,User modifies an existing invoice,invoice-edit.component.ts,edit_session_duration,Number,Time spent editing invoice in minutes,Positive numbers,15.5,Optional,Monitor user efficiency,Must be > 0
Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID
Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,invoice_status,String,Current status of invoice,"new, draft, open, paid, void, uncollectible",paid,Required,Track invoice lifecycle states,Must be from valid status list
Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,total_amount,Number,Total invoice amount,Positive numbers,2500.00,Required,Track revenue and financial metrics,Must be > 0
Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,deletion_reason,String,Reason for invoice deletion,"cancelled, duplicate, error, customer_request",cancelled,Required,Track deletion patterns and causes,Must be from predefined reasons
Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,days_since_creation,Number,Days between creation and deletion,Non-negative integer,7,Required,Monitor invoice lifecycle timing,Must be >= 0
Invoice Management,invoice_deleted,User removes an invoice from system,invoice-edit.component.ts,had_payments,Boolean,Whether invoice had any payments before deletion,true or false,false,Required,Track payment impact on deletions,Boolean validation
Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID
Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,old_status,String,Previous invoice status,"new, draft, open, paid, void, uncollectible",draft,Required,Track status transitions,Must be from valid status list
Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,new_status,String,New invoice status,"new, draft, open, paid, void, uncollectible",open,Required,Track status transitions,Must be from valid status list
Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,status_change_reason,String,Reason for status change,"user_action, payment_received, due_date_passed, automated",user_action,Required,Understand status change drivers,Must be from valid reason types
Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,total_amount,Number,Total invoice amount,Positive numbers,2500.00,Required,Track revenue and financial metrics,Must be > 0
Invoice Management,invoice_status_changed,Invoice status transitions,invoice-detail.component.ts,days_in_previous_status,Number,Days spent in previous status,Non-negative integer,5,Optional,Track status duration patterns,Must be >= 0
Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID
Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",payment_amount,Number,Amount of payment logged,Positive numbers,2500.00,Required,Track payment patterns,Must be > 0
Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",payment_method,String,Method used for payment,"cash, check, credit_card, bank_transfer, other",check,Required,Analyze payment preferences,Must be from valid payment methods
Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",payment_date,String,Date payment was received,ISO date string,2024-01-15,Required,Track payment timing,Must be valid date format
Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",remaining_balance,Number,Invoice balance after payment,Non-negative numbers,0.00,Required,Monitor collection completion,Must be >= 0
Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",days_to_payment,Number,Days from invoice creation to payment,Non-negative integer,15,Required,Analyze collection efficiency,Must be >= 0
Invoice Management,invoice_payment_logged,Payment recorded against invoice,"invoice-detail.component.ts, invoice-edit.component.ts",payment_reference,String,Reference number for payment,String,CHK_001,Optional,Track payment reconciliation,Optional reference identifier
Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,view_type,String,Type of list view used,"table, grid, map, calendar",table,Required,Optimize UI preferences,Must be from available views
Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,total_invoices,Number,Total number of invoices in system,Non-negative integer,120,Required,Monitor system usage and scale,Must be >= 0
Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,displayed_invoices,Number,Number of invoices shown to user,Non-negative integer,25,Required,Track pagination and filtering,Must be >= 0 and <= total_invoices
Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,date_range_applied,Boolean,Whether date range filter is active,true or false,true,Optional,Track temporal filtering usage,Boolean validation
Invoice List Operations,invoice_list_viewed,User accesses the invoices list interface,invoices-list.component.ts,status_filter_applied,Boolean,Whether status filter is active,true or false,false,Optional,Track status filtering usage,Boolean validation
Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,filter_type,String,Type of filter applied to invoice list,"status, date_range, client, amount_range, overdue",status,Required,Improve filter functionality,Must be valid filter type
Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,filter_value,String,Value of the applied filter,String,new,Required,Track specific filter usage,Must be valid for filter type
Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,results_before,Number,Results count before filter,Non-negative integer,45,Required,Measure filter effectiveness,Must be >= 0
Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,results_after,Number,Results count after filter,Non-negative integer,12,Required,Measure filter effectiveness,Must be >= 0 and <= results_before
Invoice List Operations,invoice_list_filtered,User applies filters to narrow invoice results,invoices-list.component.ts,multiple_filters_active,Boolean,Whether multiple filters are applied simultaneously,true or false,true,Optional,Track complex filtering patterns,Boolean validation
Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID
Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,selection_method,String,Method used to select invoice,"row_click, search_result, link_navigation, edit_button, view_button",edit_button,Required,Optimize selection UX,Must be from valid selection methods
Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,invoice_status,String,Current status of invoice,"new, draft, open, paid, void, uncollectible",paid,Required,Track invoice lifecycle states,Must be from valid status list
Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,invoice_amount,Number,Amount of selected/viewed invoice,Positive numbers,2500.00,Required,Track amount-based patterns,Must be > 0
Invoice List Operations,invoice_selected,User clicks/selects a specific invoice,invoices-list.component.ts,position_in_list,Number,Position of invoice in list when selected,Positive integer,3,Optional,Track selection patterns,Must be > 0
Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,action_type,String,Type of bulk action performed,"delete, mark_sent, mark_paid, export, print",export,Required,Track bulk operation patterns,Must be from valid action types
Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,invoice_count,Number,Number of invoices affected by bulk action,Positive integer,5,Required,Monitor bulk operation scale,Must be > 0
Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,invoice_ids,Array,List of invoice IDs affected by action,Array of strings,"[""INV_001"", ""INV_002""]",Required,Track specific invoices in bulk operations,Must be valid invoice IDs
Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,total_amount_affected,Number,Total amount of invoices affected by bulk action,Positive numbers,12500.00,Required,Track financial impact of bulk operations,Must be > 0
Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,execution_time,Number,Time taken to complete bulk action in seconds,Positive numbers,2.5,Optional,Monitor bulk operation performance,Must be > 0
Invoice List Operations,invoice_bulk_action,User performs action on multiple invoices,invoices-list.component.ts,success_rate,Number,Percentage of successful operations in bulk action,Number 0-100,100,Optional,Track bulk operation reliability,Must be between 0 and 100
Invoice Detail Operations,invoice_viewed,User opens and views invoice details,invoice-detail.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID
Invoice Detail Operations,invoice_viewed,User opens and views invoice details,invoice-detail.component.ts,invoice_status,String,Current status of invoice,"new, draft, open, paid, void, uncollectible",paid,Required,Track invoice lifecycle states,Must be from valid status list
Invoice Detail Operations,invoice_viewed,User opens and views invoice details,invoice-detail.component.ts,invoice_amount,Number,Amount of selected/viewed invoice,Positive numbers,2500.00,Required,Track amount-based patterns,Must be > 0
Invoice Detail Operations,invoice_viewed,User opens and views invoice details,invoice-detail.component.ts,view_source,String,Source of invoice view navigation,"list, direct_link, search, navigation",list,Required,Track navigation patterns,Must be from valid view sources
Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,invoice_id,String,Unique identifier for invoice,Alphanumeric string,INV_001,Required,Track individual invoice lifecycle,Must be valid invoice ID
Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,export_format,String,Format used for invoice export,"pdf, excel, csv, print, iif",csv,Required,Analyze export format preferences,Must be from supported formats
Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,invoice_amount,Number,Amount of selected/viewed invoice,Positive numbers,2500.00,Required,Track amount-based patterns,Must be > 0
Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,export_method,String,Method of export operation,"single, bulk",single,Required,Track export operation patterns,Must be from valid export methods
Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,includes_job_details,Boolean,Whether export includes detailed job information,true or false,true,Required,Track export content preferences,Boolean validation
Invoice Detail Operations,invoice_exported,User exports/prints invoice,invoice-detail.component.ts,file_size_kb,Number,Size of exported file in kilobytes,Positive numbers,150.5,Optional,Monitor export performance,Must be > 0
Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,client_id,String,Unique identifier for the client,Alphanumeric string,CLIENT_12345,Required,Track client relationships and revenue,Must be valid client ID
Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,settings_modified,Array,List of customer settings changed,Array of strings,"[""payment_terms"", ""automation""]",Required,Monitor settings usage patterns,Must be valid setting names
Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,automation_enabled,Boolean,Whether automation was enabled in settings,true or false,true,Optional,Track automation adoption,Boolean validation
Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,payment_terms_changed,Boolean,Whether payment terms were modified,true or false,false,Optional,Track payment term adjustments,Boolean validation
Invoice Settings Operations,customer_invoice_settings_updated,Customer invoice settings modified,customer-settings.component.ts,billing_preferences_updated,Boolean,Whether billing preferences were changed,true or false,true,Optional,Track billing customization,Boolean validation
Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,item_type,String,Type of costing item,"service, material, equipment, labor",service,Required,Categorize costing structures,Must be from predefined types
Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,unit_type,String,Unit basis for costing,"per_acre, per_hour, flat_rate, per_unit",per_acre,Required,Track pricing models,Must be from valid unit types
Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,base_rate,Number,Base rate for costing item,Positive numbers,25.00,Required,Monitor pricing strategies,Must be > 0
Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,action_type,String,Type of action performed on costing item,"created, updated, deleted",created,Required,Track costing item lifecycle,Must be from valid action types
Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,item_id,String,Unique identifier for costing item,Alphanumeric string,ITEM_001,Optional,Track individual costing items,Must be valid item ID when provided
Invoice Settings Operations,invoice_costing_item_managed,Costing items created/updated/deleted,costing-item.component.ts,affects_existing_invoices,Boolean,Whether change affects existing invoices,true or false,false,Optional,Track retroactive pricing impacts,Boolean validation
1 Event Category Event Name Event Description Component Location Parameter Name Parameter Type Parameter Description Allowed Values Example Value Required/Optional Business Purpose Validation Rules
2 Job Management job_created User creates a new agricultural job job.effects.ts job_type String Type of agricultural job being performed spraying, seeding, fertilizing, harvesting, soil_testing spraying Required Categorize jobs for operational insights Must be from predefined list
3 Job Management job_created User creates a new agricultural job job.effects.ts field_size_acres Number Size of the field in acres Positive numbers up to 10000 150.5 Required Track job scale and pricing Must be > 0 and <= 10000
4 Job Management job_created User creates a new agricultural job job.effects.ts crop_type String Type of crop being worked on corn, soybeans, wheat, cotton, alfalfa, other corn Required Analyze crop-specific patterns Required for all job events
5 Job Management job_created User creates a new agricultural job job.effects.ts client_id String Unique identifier for the client Alphanumeric string CLIENT_12345 Required Track client relationships and revenue Must be valid client ID
6 Job Management job_created User creates a new agricultural job job.effects.ts priority String Job priority level low, medium, high, urgent high Required Optimize job scheduling Must be from predefined list
7 Job Management job_created User creates a new agricultural job job.effects.ts equipment_type String Type of equipment used drone, ground_rig, aerial, manual, tractor drone Optional Track equipment utilization Must be from equipment catalog
8 Job Management job_created User creates a new agricultural job job.effects.ts weather_dependency Boolean Whether job depends on weather conditions true or false true Optional Plan weather-sensitive operations Boolean validation
9 Job Management job_created User creates a new agricultural job job.effects.ts estimated_duration_hours Number Expected job duration in hours Positive numbers 4.5 Optional Resource planning and scheduling Must be > 0 and <= 24
10 Job Management job_updated User modifies an existing job job.effects.ts job_id String Unique job identifier Alphanumeric string JOB_001 Required Track individual job lifecycle Must be valid job ID
11 Job Management job_updated User modifies an existing job job.effects.ts fields_modified Array List of fields changed in update Array of strings ["priority", "crop_type"] Required Monitor update patterns Must be valid field names
12 Job Management job_updated User modifies an existing job job.effects.ts change_magnitude String Magnitude of the changes made minor, major minor Optional Track update impact Must be from predefined levels
13 Job Management job_updated User modifies an existing job job.effects.ts edit_session_duration Number Time spent editing in minutes Positive numbers 15.5 Optional Monitor user efficiency Must be > 0
14 Job Management job_updated User modifies an existing job job.effects.ts save_method String How the update was saved manual, auto_save manual Optional Track save behavior Must be from valid save methods
15 Job Management job_deleted User removes a job from system job.effects.ts job_id String Unique job identifier Alphanumeric string JOB_001 Required Track individual job lifecycle Must be valid job ID
16 Job Management job_deleted User removes a job from system job.effects.ts job_type String Type of agricultural job being performed spraying, seeding, fertilizing, harvesting, soil_testing spraying Required Categorize jobs for operational insights Must be from predefined list
17 Job Management job_deleted User removes a job from system job.effects.ts job_status String Current status of the job new, ready, downloaded, sprayed, invoiced new Required Track job lifecycle states Must be from valid status list
18 Job Management job_deleted User removes a job from system job.effects.ts deletion_reason String Reason for job deletion cancelled, duplicate, error, user_action user_action Optional Track deletion patterns and causes Must be from predefined reasons
19 Job Management job_deleted User removes a job from system job.effects.ts time_since_creation Number Time between creation and deletion in hours Non-negative numbers 48.5 Optional Monitor job lifecycle timing Must be >= 0
20 Job Management job_deleted User removes a job from system job.effects.ts deletion_method String How job deletion was triggered button_click, bulk_action, api_call button_click Optional Optimize deletion workflows Must be from valid methods
21 Job Management job_assigned Job assigned to pilot or operator job.effects.ts job_id String Unique job identifier Alphanumeric string JOB_001 Required Track individual job lifecycle Must be valid job ID
22 Job Management job_assigned Job assigned to pilot or operator job.effects.ts assignee_id String ID of person assigned to job Alphanumeric string USER_456 Required Track assignment patterns Must be valid user ID
23 Job Management job_assigned Job assigned to pilot or operator job.effects.ts assignee_role String Role of assigned person pilot, operator, supervisor, manager pilot Required Optimize role assignments Must be from predefined roles
24 Job Management job_assigned Job assigned to pilot or operator job.effects.ts assignment_method String How assignment was made manual, auto, bulk manual Required Track assignment efficiency Must be from predefined methods
25 Job Management job_assigned Job assigned to pilot or operator job.effects.ts assignment_lead_time_hours Number Hours between assignment and scheduled start Non-negative numbers 24.0 Optional Track planning efficiency Must be >= 0
26 Job Management job_status_changed Job status transitions job-edit.component.ts job_id String Unique job identifier Alphanumeric string JOB_001 Required Track individual job lifecycle Must be valid job ID
27 Job Management job_status_changed Job status transitions job-edit.component.ts old_status String Previous status before change new, ready, downloaded, sprayed, invoiced new Required Track status transition patterns Must be from valid status list
28 Job Management job_status_changed Job status transitions job-edit.component.ts new_status String New status after change new, ready, downloaded, sprayed, invoiced sprayed Required Track status transition patterns Must be from valid status list
29 Job Management job_status_changed Job status transitions job-edit.component.ts status_change_reason String Reason for status change user_action, system_update, api_call, automation user_action Required Understand status change drivers Must be from valid reason types
30 Job Management job_status_changed Job status transitions job-edit.component.ts completion_time Number Time to complete job in hours Positive numbers 4.2 Optional Track job completion efficiency Must be > 0 when status changed to completed
31 Job Management job_status_changed Job status transitions job-edit.component.ts efficiency_score Number Calculated efficiency percentage Number 0-100 85.5 Optional Monitor operational efficiency Must be between 0 and 100
32 Job List Operations job_list_viewed User accesses the jobs list interface job-list.component.ts view_type String Type of list view used table, grid, map, calendar table Required Optimize UI preferences Must be from available views
33 Job List Operations job_list_viewed User accesses the jobs list interface job-list.component.ts total_jobs Number Total jobs available in system Non-negative integer 45 Required Monitor system usage Must be >= 0
34 Job List Operations job_list_viewed User accesses the jobs list interface job-list.component.ts displayed_jobs Number Number of jobs shown to user Non-negative integer 20 Required Track filtering effectiveness Must be >= 0 and <= total_jobs
35 Job List Operations job_list_viewed User accesses the jobs list interface job-list.component.ts client_filter_applied Boolean Whether client filter is active true or false true Optional Track client-specific viewing patterns Boolean validation
36 Job List Operations job_list_viewed User accesses the jobs list interface job-list.component.ts reload_interval Number Auto-reload interval in minutes Non-negative integer 5 Optional Track user preference for data freshness Must be >= 0
37 Job List Operations job_list_filtered User applies filters to narrow job results job-list.component.ts filter_type String Type of filter applied status, date_range, client, crop_type, priority status Required Improve filter functionality Must be valid filter type
38 Job List Operations job_list_filtered User applies filters to narrow job results job-list.component.ts results_before Number Results count before filter Non-negative integer 45 Required Measure filter effectiveness Must be >= 0
39 Job List Operations job_list_filtered User applies filters to narrow job results job-list.component.ts results_after Number Results count after filter Non-negative integer 12 Required Measure filter effectiveness Must be >= 0 and <= results_before
40 Job List Operations job_list_filtered User applies filters to narrow job results job-list.component.ts filter_value String Value of the applied filter String new Optional Track specific filter usage Must be valid for filter type
41 Job List Operations job_list_filtered User applies filters to narrow job results job-list.component.ts date_filter_type String Type of date filter applied today, week, month, quarter, custom month Optional Track temporal filtering patterns Must be from valid date types
42 Job List Operations job_list_filtered User applies filters to narrow job results job-list.component.ts custom_date_range Array Custom date range selected Array of dates ["2024-01-01", "2024-01-31"] Optional Track custom date usage Must be valid date range
43 Job List Operations job_selected User clicks/selects a specific job job-list.component.ts job_id String Unique job identifier Alphanumeric string JOB_001 Required Track individual job lifecycle Must be valid job ID
44 Job List Operations job_selected User clicks/selects a specific job job-list.component.ts selection_method String Method used to select job row_click, search_result, link_navigation row_click Required Optimize selection UX Must be from valid selection methods
45 Job List Operations job_selected User clicks/selects a specific job job-list.component.ts position_in_list Number Position of job in list when selected Positive integer 3 Optional Track selection patterns Must be > 0
46 Job List Operations job_selected User clicks/selects a specific job job-list.component.ts job_type String Type of agricultural job being performed spraying, seeding, fertilizing, harvesting, soil_testing spraying Optional Categorize jobs for operational insights Must be from predefined list
47 Job List Operations job_selected User clicks/selects a specific job job-list.component.ts job_status String Current status of the job new, ready, downloaded, sprayed, invoiced new Optional Track job lifecycle states Must be from valid status list
48 Job List Operations job_bulk_action User performs action on multiple jobs job-list.component.ts action_type String Type of bulk action performed duplicate, delete, assign, status_change, export duplicate Required Track bulk operation patterns Must be from valid action types
49 Job List Operations job_bulk_action User performs action on multiple jobs job-list.component.ts job_count Number Number of jobs affected by bulk action Positive integer 1 Required Monitor bulk operation scale Must be > 0
50 Job List Operations job_bulk_action User performs action on multiple jobs job-list.component.ts job_ids Array List of job IDs affected by action Array of strings ["JOB_001"] Required Track specific jobs in bulk operations Must be valid job IDs
51 Job List Operations job_bulk_action User performs action on multiple jobs job-list.component.ts execution_time Number Time taken to complete bulk action in seconds Positive numbers 2.5 Optional Monitor bulk operation performance Must be > 0
52 Job List Operations job_bulk_action User performs action on multiple jobs job-list.component.ts success_rate Number Percentage of successful operations in bulk action Number 0-100 100 Optional Track bulk operation reliability Must be between 0 and 100
53 File Upload Operations file_upload_started User initiates file upload process upload.component.ts, job-edit.component.ts file_type String Type of file being uploaded field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other field_boundary Required Track file usage patterns Must be from supported file types
54 File Upload Operations file_upload_started User initiates file upload process upload.component.ts, job-edit.component.ts file_size_mb Number File size in megabytes Positive numbers 2.3 Required Monitor upload performance Must be > 0 and <= 100
55 File Upload Operations file_upload_started User initiates file upload process upload.component.ts, job-edit.component.ts related_job_id String Job ID associated with file upload Alphanumeric string JOB_001 Optional Track file-job relationships Must be valid job ID when provided
56 File Upload Operations file_upload_started User initiates file upload process upload.component.ts, job-edit.component.ts upload_source String Source of file upload drag_drop, file_picker, api drag_drop Optional Track upload method preferences Must be from valid upload sources
57 File Upload Operations file_upload_completed File upload finishes successfully upload.component.ts, job-edit.component.ts, areas.component.ts file_type String Type of file being uploaded field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other field_boundary Required Track file usage patterns Must be from supported file types
58 File Upload Operations file_upload_completed File upload finishes successfully upload.component.ts, job-edit.component.ts, areas.component.ts processing_time_seconds Number Time to process file in seconds Positive numbers 15.2 Required Optimize processing performance Must be > 0
59 File Upload Operations file_upload_completed File upload finishes successfully upload.component.ts, job-edit.component.ts, areas.component.ts validation_status String File validation result passed, failed, warning passed Required Monitor file quality Must be from validation states
60 File Upload Operations file_upload_completed File upload finishes successfully upload.component.ts, job-edit.component.ts, areas.component.ts data_quality_score Number Quality score of uploaded file data Number 0-100 87.5 Optional Monitor data quality trends Must be between 0 and 100
61 File Upload Operations file_upload_completed File upload finishes successfully upload.component.ts, job-edit.component.ts, areas.component.ts automation_enabled Boolean Whether automated processing was used true or false true Optional Track automation usage Boolean validation
62 File Upload Operations file_upload_failed File upload encounters error upload.component.ts, job-edit.component.ts file_type String Type of file being uploaded field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other field_boundary Required Track file usage patterns Must be from supported file types
63 File Upload Operations file_upload_failed File upload encounters error upload.component.ts, job-edit.component.ts error_type String Type of upload error network_error, file_too_large, invalid_format, timeout, server_error invalid_format Required Improve error handling Must be from error catalog
64 File Upload Operations file_upload_failed File upload encounters error upload.component.ts, job-edit.component.ts file_size_mb Number File size in megabytes Positive numbers 2.3 Required Monitor upload performance Must be > 0 and <= 100
65 File Upload Operations file_upload_failed File upload encounters error upload.component.ts, job-edit.component.ts retry_attempted Boolean Whether user attempted to retry upload true or false true Optional Monitor retry patterns Boolean validation
66 File Upload Operations file_validation_error File validation fails with specific errors upload.component.ts, job-edit.component.ts validation_error_type String Type of file validation error missing_coordinates, invalid_geometry, unsupported_format, file_corruption, size_limit_exceeded missing_coordinates Required Categorize validation failures Must be from validation error types
67 File Upload Operations file_validation_error File validation fails with specific errors upload.component.ts, job-edit.component.ts error_details String Detailed error information String Invalid coordinate system detected Required Improve error messaging Required for validation errors
68 File Upload Operations file_validation_error File validation fails with specific errors upload.component.ts, job-edit.component.ts user_action String User action after validation error retry, cancel, ignore, edit retry Required Track user response to errors Must be from valid actions
69 File Management Operations file_deleted User deletes an uploaded file job-edit.component.ts file_type String Type of file being uploaded field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other field_boundary Required Track file usage patterns Must be from supported file types
70 File Management Operations file_deleted User deletes an uploaded file job-edit.component.ts file_size_mb Number File size in megabytes Positive numbers 2.3 Required Monitor upload performance Must be > 0 and <= 100
71 File Management Operations file_deleted User deletes an uploaded file job-edit.component.ts deletion_reason String Reason for file deletion user_action, cleanup, replacement, error_correction user_action Required Track file management patterns Must be from predefined reasons
72 File Management Operations file_deleted User deletes an uploaded file job-edit.component.ts confirmation_required Boolean Whether deletion required user confirmation true or false true Required Track UX patterns for file operations Boolean validation
73 File Management Operations file_deleted User deletes an uploaded file job-edit.component.ts related_job_id String Job ID associated with file upload Alphanumeric string JOB_001 Optional Track file-job relationships Must be valid job ID when provided
74 File Management Operations file_deleted User deletes an uploaded file job-edit.component.ts file_age_days Number Age of file in days when deleted Non-negative integer 7 Optional Monitor file lifecycle patterns Must be >= 0
75 File Management Operations file_downloaded User downloads a file job-edit.component.ts file_type String Type of file being uploaded field_boundary, prescription_map, application_report, soil_map, shape, geojson, kml, other field_boundary Required Track file usage patterns Must be from supported file types
76 File Management Operations file_downloaded User downloads a file job-edit.component.ts file_size_mb Number File size in megabytes Positive numbers 2.3 Required Monitor upload performance Must be > 0 and <= 100
77 File Management Operations file_downloaded User downloads a file job-edit.component.ts download_method String Method used to download file direct_link, button_click, bulk_export button_click Required Optimize download UX Must be from valid download methods
78 File Management Operations file_downloaded User downloads a file job-edit.component.ts download_source String Source location of download action job_edit, file_manager, report_export job_edit Required Track download context Must be from valid source locations
79 File Management Operations file_downloaded User downloads a file job-edit.component.ts related_job_id String Job ID associated with file upload Alphanumeric string JOB_001 Optional Track file-job relationships Must be valid job ID when provided
80 File Management Operations file_downloaded User downloads a file job-edit.component.ts file_format String Format of downloaded file original, converted original Optional Track format preferences Must be valid format type
81 Library Upload Operations library_upload_completed Areas/fields uploaded to library successfully areas.component.ts, track.component.ts upload_type String Type of library upload field_areas, tracked_areas field_areas Required Track library content additions Must be from valid upload types
82 Library Upload Operations library_upload_completed Areas/fields uploaded to library successfully areas.component.ts, track.component.ts file_count Number Number of files uploaded Positive integer 3 Required Monitor upload volume Must be > 0
83 Library Upload Operations library_upload_completed Areas/fields uploaded to library successfully areas.component.ts, track.component.ts total_areas_uploaded Number Total number of areas added to library Non-negative integer 12 Required Track library growth Must be >= 0
84 Library Upload Operations library_upload_completed Areas/fields uploaded to library successfully areas.component.ts, track.component.ts duplicate_areas_found Number Number of duplicate areas detected Non-negative integer 2 Optional Monitor data quality Must be >= 0
85 Library Upload Operations library_upload_completed Areas/fields uploaded to library successfully areas.component.ts, track.component.ts failed_files Number Number of files that failed to process Non-negative integer 0 Optional Track processing reliability Must be >= 0
86 Library Upload Operations library_upload_completed Areas/fields uploaded to library successfully areas.component.ts, track.component.ts processing_method String Method used for processing automatic, manual_review automatic Optional Track processing approaches Must be from valid processing methods
87 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) subscription_type String Type of subscription purchased AgMission package names (e.g. AgMission Essentials 1-5 or AgMission Enterprise 1-5) AgMission Essentials 3 Required Track subscription tier adoption and revenue Must be valid SUB_NAME value from common.ts
88 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) subscription_duration String Duration of subscription monthly, quarterly, annual monthly Required Monitor subscription length preferences Must be from valid duration options
89 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) subscription_price Number Price of subscription in USD Positive numbers 99.99 Required Track revenue per subscription Must be > 0
90 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) previous_subscription_type String Previous subscription type before change AgMission package names or none AgMission Essentials 1 Optional Track subscription transitions Must be valid SUB_NAME value or none
91 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) payment_method String Method used for payment credit_card, bank_transfer, paypal, invoice credit_card Required Optimize payment options Must be from supported payment methods
92 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) billing_frequency String How often billing occurs monthly, quarterly, annual monthly Required Track billing preferences Must match subscription duration
93 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) promo_code String Promotional code used Alphanumeric string SAVE20 Optional Track promotion effectiveness Optional promotional code
94 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) discount_amount Number Discount applied in USD Non-negative numbers 19.99 Optional Monitor discount impact Must be >= 0
95 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) subscription_start_date String Start date of subscription ISO date string 2024-01-15 Required Track subscription lifecycle Must be valid ISO date
96 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) auto_renewal Boolean Whether subscription auto-renews true or false true Required Monitor auto-renewal adoption Boolean validation
97 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) upgrade_from String Previous subscription tier when upgrading AgMission package names AgMission Essentials 1 Optional Track upgrade patterns Required when transaction is an upgrade
98 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) upgrade_to String New subscription tier when upgrading AgMission package names AgMission Essentials 3 Optional Track upgrade patterns Required when transaction is an upgrade
99 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) trial_conversion Boolean Whether purchase is converting from trial true or false true Required Monitor trial conversion rate Boolean validation
100 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) subscription_value Number Annual contract value in USD Positive numbers 1199.88 Required Track customer lifetime value Must be > 0
101 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) user_tenure_days Number Days since user first registered Non-negative integer 45 Required Analyze subscription timing patterns Must be >= 0
102 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) service_type String Service category of subscription essential, enterprise, addon essential Optional Categorize subscription types by service level Must be from SERVICE_TYPE enum
103 E-commerce subscription_purchased User purchases or upgrades subscription subscription.effects.ts (updateSubscription$, checkoutTrial$) is_trial Boolean Whether this is a trial subscription true or false false Optional Track trial vs paid subscriptions Boolean validation
104 Performance slow_page_load Page loads slower than threshold app.component.ts page_url String URL of the page with slow load String /dashboard/jobs Required Identify performance bottlenecks Must be valid URL path
105 Performance slow_page_load Page loads slower than threshold app.component.ts load_time Number Page load time in seconds Positive numbers 8.5 Required Monitor page performance Must be > 0
106 Performance slow_page_load Page loads slower than threshold app.component.ts device_type String Type of device experiencing slow load desktop, mobile, tablet desktop Optional Optimize for different devices Must be from device categories
107 Performance slow_page_load Page loads slower than threshold app.component.ts connection_type String User's connection type wifi, cellular, ethernet, unknown wifi Optional Understand connection impact Must be from connection types
108 Performance api_response_slow API calls exceed performance threshold global-error.interceptor.ts api_endpoint String API endpoint with slow response String /api/v1/jobs Required Identify slow API endpoints Must be valid API path
109 Performance api_response_slow API calls exceed performance threshold global-error.interceptor.ts response_time Number API response time in milliseconds Positive numbers 3500 Required Monitor API performance Must be > 0
110 Performance api_response_slow API calls exceed performance threshold global-error.interceptor.ts request_size Number Size of API request in bytes Non-negative integer 1024 Optional Analyze request impact on performance Must be >= 0
111 Performance api_response_slow API calls exceed performance threshold global-error.interceptor.ts response_size Number Size of API response in bytes Non-negative integer 5120 Optional Analyze response impact on performance Must be >= 0
112 Performance api_response_slow API calls exceed performance threshold global-error.interceptor.ts cache_hit Boolean Whether response was served from cache true or false false Optional Monitor caching effectiveness Boolean validation
113 Performance api_response_slow API calls exceed performance threshold global-error.interceptor.ts http_status Number HTTP status code of response Valid HTTP status codes 200 Optional Track response success patterns Must be valid HTTP status code
114 Error Tracking http_error HTTP request errors automatically tracked by interceptor global-error.interceptor.ts error_type String Type of HTTP error that occurred network_error, server_error, client_error, timeout, unknown_error server_error Required Categorize HTTP errors for debugging and monitoring Must be from predefined error types
115 Error Tracking http_error HTTP request errors automatically tracked by interceptor global-error.interceptor.ts http_status_code Number HTTP status code returned by server Integer 0-599 500 Required Track specific HTTP error codes for debugging Must be valid HTTP status code (0-599)
116 Error Tracking http_error HTTP request errors automatically tracked by interceptor global-error.interceptor.ts request_method String HTTP method used for the request GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS GET Required Track which HTTP methods encounter errors Must be valid HTTP method
117 Error Tracking http_error HTTP request errors automatically tracked by interceptor global-error.interceptor.ts request_url String Full URL of the failed request Valid URL string https://api.agmission.com/api/jobs Required Track specific endpoints experiencing errors Must be valid URL format
118 Error Tracking http_error HTTP request errors automatically tracked by interceptor global-error.interceptor.ts request_endpoint String API endpoint that failed String jobs Required Track which API endpoints have the most errors Must be valid endpoint identifier
119 Error Tracking http_error HTTP request errors automatically tracked by interceptor global-error.interceptor.ts response_time_ms Number Time taken for the request to fail in milliseconds Non-negative integer 5000 Optional Monitor request timing patterns for failed requests Must be >= 0
120 Error Tracking http_error HTTP request errors automatically tracked by interceptor global-error.interceptor.ts affected_feature String Application feature affected by the error job_management, billing, reporting, file_management, user_management, authentication, customer_management, equipment_management, unknown job_management Optional Track which features are most impacted by HTTP errors Must be from predefined feature list
121 User Authentication login User logs into AgMission system auth.service.ts method String Authentication method used email, google, microsoft, sso email Required Track authentication preferences and security Must be from supported authentication methods
122 User Authentication login User logs into AgMission system auth.service.ts user_role String Role of the user performing the action admin, applicator, office_admin, client, officer, pilot, inspector, aircraft applicator Required Segment analytics by user type and permissions Must be from predefined role list
123 User Authentication login User logs into AgMission system auth.service.ts last_login_days_ago Number Days since user's last login Non-negative numbers 7 Optional Track user return patterns Must be >= 0
124 User Authentication logout User logs out of system auth.service.ts session_duration_minutes Number Duration of user session in minutes Positive integers 45 Required Monitor user engagement and session patterns Must be > 0
125 User Authentication logout User logs out of system auth.service.ts user_role String Role of the user performing the action admin, applicator, office_admin, client, officer, pilot, inspector, aircraft applicator Required Segment analytics by user type and permissions Must be from predefined role list
126 User Authentication logout User logs out of system auth.service.ts logout_method String How user logged out manual, timeout, forced manual Optional Understand logout patterns and session management Must be from predefined logout types
127 User Authentication signup User begins signup process signup-form.component.ts, signup-verify.component.ts signup_method String Method used for account signup email, google, microsoft, invitation email Required Track signup channel effectiveness Must be from supported signup methods
128 User Authentication signup User begins signup process signup-form.component.ts, signup-verify.component.ts user_type String Type of user signing up client, applicator, admin, office_admin applicator Required Segment new user acquisition Must be from predefined user types
129 User Authentication signup User begins signup process signup-form.component.ts, signup-verify.component.ts source String Source of signup traffic landing_page, referral, advertisement, direct landing_page Optional Track marketing channel effectiveness Must be from valid traffic sources
130 User Authentication signup User begins signup process signup-form.component.ts, signup-verify.component.ts company_name String Name of company during signup String AgriCorp Inc Optional Identify business customers Required for business signups
131 User Authentication signup_completed User completes signup process signup-form.component.ts signup_duration_minutes Number Time taken to complete signup in minutes Positive numbers 15.5 Required Monitor signup flow efficiency Must be > 0
132 User Authentication signup_completed User completes signup process signup-form.component.ts signup_method String Method used for account signup email, google, microsoft, invitation email Required Track signup channel effectiveness Must be from supported signup methods
133 User Authentication signup_completed User completes signup process signup-form.component.ts user_type String Type of user signing up client, applicator, admin, office_admin applicator Required Segment new user acquisition Must be from predefined user types
134 User Authentication signup_completed User completes signup process signup-form.component.ts verification_required Boolean Whether email verification was required true or false true Required Track verification requirements Boolean validation
135 User Authentication signup_completed User completes signup process signup-form.component.ts profile_completed Boolean Whether user completed full profile setup true or false false Optional Monitor onboarding completion Boolean validation
136 User Authentication password_reset_requested User requests password reset auth.service.ts request_method String How password reset was requested forgot_password_page, login_page, profile_page forgot_password_page Required Track reset request patterns Must be from valid request sources
137 User Authentication password_reset_requested User requests password reset auth.service.ts user_exists Boolean Whether user account exists for reset request true or false true Required Monitor reset request validity Boolean validation
138 User Authentication password_reset_requested User requests password reset auth.service.ts email_address_hash String Hashed email address for privacy String abc123def456 Optional Track verification requests while maintaining privacy Must be valid hash when provided
139 User Authentication password_reset_completed Password reset process completed auth.service.ts, app.password-reset.component.ts success Boolean Whether password reset was successful true or false true Required Track reset success rates Boolean validation
140 User Authentication password_reset_completed Password reset process completed auth.service.ts, app.password-reset.component.ts reset_token_age_minutes Number Age of reset token when used in minutes Non-negative numbers 5 Required Monitor token validity and timing Must be >= 0
141 User Authentication password_reset_completed Password reset process completed auth.service.ts, app.password-reset.component.ts failure_reason String Reason for password reset failure expired_token, invalid_token, weak_password, other expired_token Optional Categorize reset failures for improvement Must be from predefined failure reasons
142 User Authentication email_verification_requested User requests email verification signup-verify.component.ts request_method String How email verification was requested signup_form, verification_page, resend_request verification_page Required Track verification request patterns Must be from valid request sources
143 User Authentication email_verification_requested User requests email verification signup-verify.component.ts user_exists Boolean Whether user account exists for reset request true or false true Required Monitor reset request validity Boolean validation
144 User Authentication email_verification_requested User requests email verification signup-verify.component.ts email_address_hash String Hashed email address for privacy String abc123def456 Optional Track verification requests while maintaining privacy Must be valid hash when provided
145 User Authentication email_verification_completed Email verification process completed signup-verify.component.ts success Boolean Whether password reset was successful true or false true Required Track reset success rates Boolean validation
146 User Authentication email_verification_completed Email verification process completed signup-verify.component.ts verification_token_age_minutes Number Age of verification token when used in minutes Non-negative numbers 30 Required Monitor verification token validity and timing Must be >= 0
147 User Authentication email_verification_completed Email verification process completed signup-verify.component.ts failure_reason String Reason for password reset failure expired_token, invalid_token, weak_password, other expired_token Optional Categorize reset failures for improvement Must be from predefined failure reasons
148 Invoice Management invoice_created User creates a new invoice invoice-edit.component.ts invoice_id String Unique identifier for invoice Alphanumeric string INV_001 Required Track individual invoice lifecycle Must be valid invoice ID
149 Invoice Management invoice_created User creates a new invoice invoice-edit.component.ts total_amount Number Total invoice amount Positive numbers 2500.00 Required Track revenue and financial metrics Must be > 0
150 Invoice Management invoice_created User creates a new invoice invoice-edit.component.ts currency String Currency code for invoice amount USD, CAD, EUR USD Required Track multi-currency operations Must be valid ISO currency code
151 Invoice Management invoice_created User creates a new invoice invoice-edit.component.ts creation_method String Method used to create invoice manual, auto_generated, template, recurring manual Required Track invoice creation patterns Must be from predefined methods
152 Invoice Management invoice_created User creates a new invoice invoice-edit.component.ts due_date_days Number Days until invoice due date Integer 30 Required Track payment terms and cash flow Must be >= 0
153 Invoice Management invoice_created User creates a new invoice invoice-edit.component.ts payment_terms String Payment terms for invoice String net_30 Optional Analyze payment term preferences Free text or predefined terms
154 Invoice Management invoice_updated User modifies an existing invoice invoice-edit.component.ts invoice_id String Unique identifier for invoice Alphanumeric string INV_001 Required Track individual invoice lifecycle Must be valid invoice ID
155 Invoice Management invoice_updated User modifies an existing invoice invoice-edit.component.ts fields_modified Array List of fields changed in invoice update Array of strings ["amount", "due_date"] Required Monitor invoice modification patterns Must be valid field names
156 Invoice Management invoice_updated User modifies an existing invoice invoice-edit.component.ts modification_type String Primary type of modification made amount, due_date, jobs, customer, payment_terms amount Required Categorize modification patterns Must be from predefined types
157 Invoice Management invoice_updated User modifies an existing invoice invoice-edit.component.ts amount_change Number Change in invoice amount Number (can be negative) -150.00 Optional Track invoice adjustments Can be positive or negative
158 Invoice Management invoice_updated User modifies an existing invoice invoice-edit.component.ts previous_status String Previous invoice status before update new, draft, open, paid, void, uncollectible draft Optional Track status progression Must be from valid status list
159 Invoice Management invoice_updated User modifies an existing invoice invoice-edit.component.ts current_status String Current invoice status after update new, draft, open, paid, void, uncollectible open Optional Track status progression Must be from valid status list
160 Invoice Management invoice_updated User modifies an existing invoice invoice-edit.component.ts edit_session_duration Number Time spent editing invoice in minutes Positive numbers 15.5 Optional Monitor user efficiency Must be > 0
161 Invoice Management invoice_deleted User removes an invoice from system invoice-edit.component.ts invoice_id String Unique identifier for invoice Alphanumeric string INV_001 Required Track individual invoice lifecycle Must be valid invoice ID
162 Invoice Management invoice_deleted User removes an invoice from system invoice-edit.component.ts invoice_status String Current status of invoice new, draft, open, paid, void, uncollectible paid Required Track invoice lifecycle states Must be from valid status list
163 Invoice Management invoice_deleted User removes an invoice from system invoice-edit.component.ts total_amount Number Total invoice amount Positive numbers 2500.00 Required Track revenue and financial metrics Must be > 0
164 Invoice Management invoice_deleted User removes an invoice from system invoice-edit.component.ts deletion_reason String Reason for invoice deletion cancelled, duplicate, error, customer_request cancelled Required Track deletion patterns and causes Must be from predefined reasons
165 Invoice Management invoice_deleted User removes an invoice from system invoice-edit.component.ts days_since_creation Number Days between creation and deletion Non-negative integer 7 Required Monitor invoice lifecycle timing Must be >= 0
166 Invoice Management invoice_deleted User removes an invoice from system invoice-edit.component.ts had_payments Boolean Whether invoice had any payments before deletion true or false false Required Track payment impact on deletions Boolean validation
167 Invoice Management invoice_status_changed Invoice status transitions invoice-detail.component.ts invoice_id String Unique identifier for invoice Alphanumeric string INV_001 Required Track individual invoice lifecycle Must be valid invoice ID
168 Invoice Management invoice_status_changed Invoice status transitions invoice-detail.component.ts old_status String Previous invoice status new, draft, open, paid, void, uncollectible draft Required Track status transitions Must be from valid status list
169 Invoice Management invoice_status_changed Invoice status transitions invoice-detail.component.ts new_status String New invoice status new, draft, open, paid, void, uncollectible open Required Track status transitions Must be from valid status list
170 Invoice Management invoice_status_changed Invoice status transitions invoice-detail.component.ts status_change_reason String Reason for status change user_action, payment_received, due_date_passed, automated user_action Required Understand status change drivers Must be from valid reason types
171 Invoice Management invoice_status_changed Invoice status transitions invoice-detail.component.ts total_amount Number Total invoice amount Positive numbers 2500.00 Required Track revenue and financial metrics Must be > 0
172 Invoice Management invoice_status_changed Invoice status transitions invoice-detail.component.ts days_in_previous_status Number Days spent in previous status Non-negative integer 5 Optional Track status duration patterns Must be >= 0
173 Invoice Management invoice_payment_logged Payment recorded against invoice invoice-detail.component.ts, invoice-edit.component.ts invoice_id String Unique identifier for invoice Alphanumeric string INV_001 Required Track individual invoice lifecycle Must be valid invoice ID
174 Invoice Management invoice_payment_logged Payment recorded against invoice invoice-detail.component.ts, invoice-edit.component.ts payment_amount Number Amount of payment logged Positive numbers 2500.00 Required Track payment patterns Must be > 0
175 Invoice Management invoice_payment_logged Payment recorded against invoice invoice-detail.component.ts, invoice-edit.component.ts payment_method String Method used for payment cash, check, credit_card, bank_transfer, other check Required Analyze payment preferences Must be from valid payment methods
176 Invoice Management invoice_payment_logged Payment recorded against invoice invoice-detail.component.ts, invoice-edit.component.ts payment_date String Date payment was received ISO date string 2024-01-15 Required Track payment timing Must be valid date format
177 Invoice Management invoice_payment_logged Payment recorded against invoice invoice-detail.component.ts, invoice-edit.component.ts remaining_balance Number Invoice balance after payment Non-negative numbers 0.00 Required Monitor collection completion Must be >= 0
178 Invoice Management invoice_payment_logged Payment recorded against invoice invoice-detail.component.ts, invoice-edit.component.ts days_to_payment Number Days from invoice creation to payment Non-negative integer 15 Required Analyze collection efficiency Must be >= 0
179 Invoice Management invoice_payment_logged Payment recorded against invoice invoice-detail.component.ts, invoice-edit.component.ts payment_reference String Reference number for payment String CHK_001 Optional Track payment reconciliation Optional reference identifier
180 Invoice List Operations invoice_list_viewed User accesses the invoices list interface invoices-list.component.ts view_type String Type of list view used table, grid, map, calendar table Required Optimize UI preferences Must be from available views
181 Invoice List Operations invoice_list_viewed User accesses the invoices list interface invoices-list.component.ts total_invoices Number Total number of invoices in system Non-negative integer 120 Required Monitor system usage and scale Must be >= 0
182 Invoice List Operations invoice_list_viewed User accesses the invoices list interface invoices-list.component.ts displayed_invoices Number Number of invoices shown to user Non-negative integer 25 Required Track pagination and filtering Must be >= 0 and <= total_invoices
183 Invoice List Operations invoice_list_viewed User accesses the invoices list interface invoices-list.component.ts date_range_applied Boolean Whether date range filter is active true or false true Optional Track temporal filtering usage Boolean validation
184 Invoice List Operations invoice_list_viewed User accesses the invoices list interface invoices-list.component.ts status_filter_applied Boolean Whether status filter is active true or false false Optional Track status filtering usage Boolean validation
185 Invoice List Operations invoice_list_filtered User applies filters to narrow invoice results invoices-list.component.ts filter_type String Type of filter applied to invoice list status, date_range, client, amount_range, overdue status Required Improve filter functionality Must be valid filter type
186 Invoice List Operations invoice_list_filtered User applies filters to narrow invoice results invoices-list.component.ts filter_value String Value of the applied filter String new Required Track specific filter usage Must be valid for filter type
187 Invoice List Operations invoice_list_filtered User applies filters to narrow invoice results invoices-list.component.ts results_before Number Results count before filter Non-negative integer 45 Required Measure filter effectiveness Must be >= 0
188 Invoice List Operations invoice_list_filtered User applies filters to narrow invoice results invoices-list.component.ts results_after Number Results count after filter Non-negative integer 12 Required Measure filter effectiveness Must be >= 0 and <= results_before
189 Invoice List Operations invoice_list_filtered User applies filters to narrow invoice results invoices-list.component.ts multiple_filters_active Boolean Whether multiple filters are applied simultaneously true or false true Optional Track complex filtering patterns Boolean validation
190 Invoice List Operations invoice_selected User clicks/selects a specific invoice invoices-list.component.ts invoice_id String Unique identifier for invoice Alphanumeric string INV_001 Required Track individual invoice lifecycle Must be valid invoice ID
191 Invoice List Operations invoice_selected User clicks/selects a specific invoice invoices-list.component.ts selection_method String Method used to select invoice row_click, search_result, link_navigation, edit_button, view_button edit_button Required Optimize selection UX Must be from valid selection methods
192 Invoice List Operations invoice_selected User clicks/selects a specific invoice invoices-list.component.ts invoice_status String Current status of invoice new, draft, open, paid, void, uncollectible paid Required Track invoice lifecycle states Must be from valid status list
193 Invoice List Operations invoice_selected User clicks/selects a specific invoice invoices-list.component.ts invoice_amount Number Amount of selected/viewed invoice Positive numbers 2500.00 Required Track amount-based patterns Must be > 0
194 Invoice List Operations invoice_selected User clicks/selects a specific invoice invoices-list.component.ts position_in_list Number Position of invoice in list when selected Positive integer 3 Optional Track selection patterns Must be > 0
195 Invoice List Operations invoice_bulk_action User performs action on multiple invoices invoices-list.component.ts action_type String Type of bulk action performed delete, mark_sent, mark_paid, export, print export Required Track bulk operation patterns Must be from valid action types
196 Invoice List Operations invoice_bulk_action User performs action on multiple invoices invoices-list.component.ts invoice_count Number Number of invoices affected by bulk action Positive integer 5 Required Monitor bulk operation scale Must be > 0
197 Invoice List Operations invoice_bulk_action User performs action on multiple invoices invoices-list.component.ts invoice_ids Array List of invoice IDs affected by action Array of strings ["INV_001", "INV_002"] Required Track specific invoices in bulk operations Must be valid invoice IDs
198 Invoice List Operations invoice_bulk_action User performs action on multiple invoices invoices-list.component.ts total_amount_affected Number Total amount of invoices affected by bulk action Positive numbers 12500.00 Required Track financial impact of bulk operations Must be > 0
199 Invoice List Operations invoice_bulk_action User performs action on multiple invoices invoices-list.component.ts execution_time Number Time taken to complete bulk action in seconds Positive numbers 2.5 Optional Monitor bulk operation performance Must be > 0
200 Invoice List Operations invoice_bulk_action User performs action on multiple invoices invoices-list.component.ts success_rate Number Percentage of successful operations in bulk action Number 0-100 100 Optional Track bulk operation reliability Must be between 0 and 100
201 Invoice Detail Operations invoice_viewed User opens and views invoice details invoice-detail.component.ts invoice_id String Unique identifier for invoice Alphanumeric string INV_001 Required Track individual invoice lifecycle Must be valid invoice ID
202 Invoice Detail Operations invoice_viewed User opens and views invoice details invoice-detail.component.ts invoice_status String Current status of invoice new, draft, open, paid, void, uncollectible paid Required Track invoice lifecycle states Must be from valid status list
203 Invoice Detail Operations invoice_viewed User opens and views invoice details invoice-detail.component.ts invoice_amount Number Amount of selected/viewed invoice Positive numbers 2500.00 Required Track amount-based patterns Must be > 0
204 Invoice Detail Operations invoice_viewed User opens and views invoice details invoice-detail.component.ts view_source String Source of invoice view navigation list, direct_link, search, navigation list Required Track navigation patterns Must be from valid view sources
205 Invoice Detail Operations invoice_exported User exports/prints invoice invoice-detail.component.ts invoice_id String Unique identifier for invoice Alphanumeric string INV_001 Required Track individual invoice lifecycle Must be valid invoice ID
206 Invoice Detail Operations invoice_exported User exports/prints invoice invoice-detail.component.ts export_format String Format used for invoice export pdf, excel, csv, print, iif csv Required Analyze export format preferences Must be from supported formats
207 Invoice Detail Operations invoice_exported User exports/prints invoice invoice-detail.component.ts invoice_amount Number Amount of selected/viewed invoice Positive numbers 2500.00 Required Track amount-based patterns Must be > 0
208 Invoice Detail Operations invoice_exported User exports/prints invoice invoice-detail.component.ts export_method String Method of export operation single, bulk single Required Track export operation patterns Must be from valid export methods
209 Invoice Detail Operations invoice_exported User exports/prints invoice invoice-detail.component.ts includes_job_details Boolean Whether export includes detailed job information true or false true Required Track export content preferences Boolean validation
210 Invoice Detail Operations invoice_exported User exports/prints invoice invoice-detail.component.ts file_size_kb Number Size of exported file in kilobytes Positive numbers 150.5 Optional Monitor export performance Must be > 0
211 Invoice Settings Operations customer_invoice_settings_updated Customer invoice settings modified customer-settings.component.ts client_id String Unique identifier for the client Alphanumeric string CLIENT_12345 Required Track client relationships and revenue Must be valid client ID
212 Invoice Settings Operations customer_invoice_settings_updated Customer invoice settings modified customer-settings.component.ts settings_modified Array List of customer settings changed Array of strings ["payment_terms", "automation"] Required Monitor settings usage patterns Must be valid setting names
213 Invoice Settings Operations customer_invoice_settings_updated Customer invoice settings modified customer-settings.component.ts automation_enabled Boolean Whether automation was enabled in settings true or false true Optional Track automation adoption Boolean validation
214 Invoice Settings Operations customer_invoice_settings_updated Customer invoice settings modified customer-settings.component.ts payment_terms_changed Boolean Whether payment terms were modified true or false false Optional Track payment term adjustments Boolean validation
215 Invoice Settings Operations customer_invoice_settings_updated Customer invoice settings modified customer-settings.component.ts billing_preferences_updated Boolean Whether billing preferences were changed true or false true Optional Track billing customization Boolean validation
216 Invoice Settings Operations invoice_costing_item_managed Costing items created/updated/deleted costing-item.component.ts item_type String Type of costing item service, material, equipment, labor service Required Categorize costing structures Must be from predefined types
217 Invoice Settings Operations invoice_costing_item_managed Costing items created/updated/deleted costing-item.component.ts unit_type String Unit basis for costing per_acre, per_hour, flat_rate, per_unit per_acre Required Track pricing models Must be from valid unit types
218 Invoice Settings Operations invoice_costing_item_managed Costing items created/updated/deleted costing-item.component.ts base_rate Number Base rate for costing item Positive numbers 25.00 Required Monitor pricing strategies Must be > 0
219 Invoice Settings Operations invoice_costing_item_managed Costing items created/updated/deleted costing-item.component.ts action_type String Type of action performed on costing item created, updated, deleted created Required Track costing item lifecycle Must be from valid action types
220 Invoice Settings Operations invoice_costing_item_managed Costing items created/updated/deleted costing-item.component.ts item_id String Unique identifier for costing item Alphanumeric string ITEM_001 Optional Track individual costing items Must be valid item ID when provided
221 Invoice Settings Operations invoice_costing_item_managed Costing items created/updated/deleted costing-item.component.ts affects_existing_invoices Boolean Whether change affects existing invoices true or false false Optional Track retroactive pricing impacts Boolean validation

View File

@ -103,8 +103,7 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "12kb", "maximumWarning": "6kb"
"maximumError": "18kb"
} }
] ]
}, },
@ -165,34 +164,29 @@
"tsConfig": "src/tsconfig.spec.json", "tsConfig": "src/tsconfig.spec.json",
"karmaConfig": "src/karma.conf.js", "karmaConfig": "src/karma.conf.js",
"scripts": [ "scripts": [
"node_modules/rbush/rbush.min.js", "node_modules/leaflet/dist/leaflet.js",
"src/assets/js/turf.min.js" "src/assets/js/leaflet-draw/leaflet.draw-src.js",
"src/assets/js/L.Path.Drag.js",
"src/assets/js/Leaflet.draw.drag-src.js",
"src/assets/js/leaflet-measure/leaflet-measure-path.js",
"src/assets/js/leaflet.circle.topolygon.js",
"src/assets/js/turf.min.js",
"src/assets/js/leaflet-corridor.js",
"src/assets/js/utm.js",
"src/assets/js/L.Control.MapCenterCoord.js",
"src/assets/js/leaflet.polylineDecorator.js"
], ],
"styles": [ "styles": [
"node_modules/leaflet/dist/leaflet.css", "node_modules/leaflet/dist/leaflet.css",
"src/assets/js/leaflet-draw/leaflet.draw.css", "src/assets/js/leaflet-draw/leaflet.draw.css",
"src/assets/js/leaflet-measure/leaflet-measure-path.css", "src/assets/js/leaflet-measure/leaflet-measure-path.css",
"node_modules/primeng/resources/primeng.min.css",
"node_modules/nanoscroller/bin/css/nanoscroller.css",
"src/assets/js/L.Control.MapCenterCoord.css", "src/assets/js/L.Control.MapCenterCoord.css",
"src/assets/js/Leaflet.AgmIcon.css",
"src/assets/js/Leaflet.AgmACIcon.css",
"node_modules/primeng-lts/resources/primeng.min.css",
"node_modules/@fullcalendar/core/main.min.css",
"node_modules/@fullcalendar/daygrid/main.min.css",
"node_modules/@fullcalendar/timegrid/main.min.css",
"node_modules/quill/dist/quill.snow.css",
"src/styles.scss" "src/styles.scss"
], ],
"assets": [ "assets": [
"src/assets/js/L.Control.MapCenterCoord.css", "src/assets",
"src/assets/js/L.Control.MapCenterCoord.js",
"src/assets/js/utm.js",
"src/assets/js/Leaflet.GoogleMutant.js",
"src/assets/js/leaflet-corridor.js",
"src/assets/js/sti-rpt/",
"src/assets/theme/theme-green.min.css",
"src/assets/layout/css/layout-green.min.css",
"src/assets/layout/fonts/",
"src/assets/images/",
{ {
"glob": "**/*", "glob": "**/*",
"input": "node_modules/leaflet/dist/images", "input": "node_modules/leaflet/dist/images",

View File

@ -1,382 +0,0 @@
# Promo Display Logic — services Screen
**Component**: `src/app/profile/manage-services/manage-services.component.ts`
**Route**: `/profile/services`
**Last Updated**: March 18, 2026
---
## Table of Contents
- [Overview](#overview)
- [Data Flow](#data-flow)
- [Key State](#key-state)
- [activePromos Map Construction](#activepromos-map-construction)
- [Button Labels and confirmServices Flow](#button-labels-and-confirmservices-flow)
- [Create New Subscription Plan](#create-new-subscription-plan)
- [Create Trial Subscription Plan](#create-trial-subscription-plan)
- [Checkout Promo Display after Each Flow](#checkout-promo-display-after-each-flow)
- [getPromoForLookupKey the Core Gate](#getpromoforlookupkey-the-core-gate)
- [isAllPackagesPromo Package-Wide Banner Logic](#isallpackagespromo-package-wide-banner-logic)
- [Template Rendering Logic](#template-rendering-logic)
- [Promo Price Calculation](#promo-price-calculation)
- [ESS_1 Legacy Special Handling](#ess_1-legacy-special-handling)
- [Promo Display Components](#promo-display-components)
- [Quick Reference Table](#quick-reference-table)
---
## Overview
The `/services` screen ("Choose Your Plan") shows packages and addons with promotional pricing when applicable. Promo data comes from the authenticated `GET /api/activePromos` endpoint (v3.0+), which already filters by customer eligibility server-side. The client only needs to apply display-mode gating (available vs. subscribed) on top.
---
## Data Flow
```mermaid
flowchart TD
A([ngOnInit]) --> B[dispatch FetchSubPlans]
A --> C[loadActivePromos]
A --> D[loadPromoMode]
B --> E["populates essPkgs, addons via Redux store"]
C --> F["GET /api/activePromos<br/>Auth required - v3.0"]
F --> G["Server filters by customer eligibility<br/>eligibility: all / new_only / renew_only"]
G --> H["Returns only eligible promos"]
H --> I[buildActivePromosMap]
I --> J[(activePromos Map)]
D --> F2["getCurrentMode()"]
F2 --> K["promoMode: enabled or disabled"]
J --> L[Template rendering]
K --> L
E --> L
```
> **v3.0 (Jan 2026):** `/api/activePromos` requires authentication and returns **only the promos the current customer is eligible for**. The client does **not** need to re-check eligibility — the returned list is already filtered.
---
## Key State
| Property | Source | Purpose |
|---|---|---|
| `activePromos` | `GET /api/activePromos` | Map of eligible promos for current user |
| `promoMode` | `currentMode.mode` from same response | Global kill switch (`'enabled'` or `'disabled'`) |
| `subs` | Redux `getSubscriptions` | Current Stripe subscriptions for this user |
| `isTrial` | Redux `getSubIntentState``subIntent.mode === Mode.TRIALING` | Whether checkout intent is a trial sign-up |
---
## activePromos Map Construction
`loadActivePromos()` builds a flat `Map<string, ActivePromo>` using three key patterns based on what fields each promo has:
```mermaid
flowchart TD
A([Promo from /api/activePromos]) --> B{Has priceKey?}
B -->|Yes| C["activePromos.set(priceKey, promo)<br/>e.g. 'ess_1_1' -> promo"]
B -->|No| D{Has type?}
D -->|Yes - package or addon| E["activePromos.set('package_all' or 'addon_all', promo)"]
D -->|No - universal promo| F["activePromos.set('package_all', promo)<br/>activePromos.set('addon_all', promo)"]
```
**Lookup at render time:**
```mermaid
flowchart LR
A["getPromoForLookupKey('ess_1_1', 'package')"]
A --> B["activePromos.get('ess_1_1')"]
B -->|found| C([return exact promo])
B -->|not found| D["activePromos.get('package_all')"]
D -->|found| E([return type-wide promo])
D -->|not found| F([return null])
```
---
## Button Labels and confirmServices Flow
The confirm button in `#btnSection` uses a computed `confirmLabel` getter:
```
isNewSub = !originalSel.selPkg && !(originalSel.selAddons.length > 0)
confirmLabel =
isNewSub && isTrial → "Create Trial Subscription Plan"
isNewSub && !isTrial → "Create New Subscription Plan"
otherwise → "Confirm"
```
`isNewSub` is true when the user has **no** existing package and no existing addon subscriptions — i.e. they are subscribing for the first time. `isTrial` comes from Redux `subIntent.mode === Mode.TRIALING`.
### Create New Subscription Plan
Triggered when `isNewSub=true` and `isTrial=false`. Full flow from button click through promo display in checkout:
```mermaid
flowchart TD
BTN([Click Create New Subscription Plan]) --> CS[confirmServices]
CS --> ISNEW{isNewSub?}
ISNEW -->|Yes| REGDIRECT[dispatchStartBillingInfo<br/>mode = Mode.REGULAR]
ISNEW -->|No - existing sub change| CONFIRM[Confirm dialog<br/>then dispatchStartBillingInfo<br/>mode = Mode.REGULAR]
REGDIRECT --> SBI[StartBillingInfo dispatched<br/>prorateTS = DateUtils.currUTC]
CONFIRM --> SBI
SBI --> NAV([Navigate to /checkout])
NAV --> INIT[initPage]
INIT --> ISTRIALCK{isTrial?}
ISTRIALCK -->|No - regular| INVOICES[Fetch upcoming invoices<br/>calcChkoutPayment]
INVOICES --> CAP[checkApplicablePromos]
CAP --> GATE["getPromoForLookupKey<br/>hasAnyPackageSub? NO<br/>exact or type-wide match?"]
GATE -->|promo found| PROMODISPLAY([Show promo badge + discounted price])
GATE -->|no match| NORMALPRICE([Regular price])
NAV --> LAP[loadActivePromos async]
LAP --> CAP2[checkApplicablePromos again<br/>with real activePromos]
CAP2 --> PROMODISPLAY2([Promo display updated if match])
```
### Create Trial Subscription Plan
Triggered when `isNewSub=true` and `isTrial=true`. The trial flow adds `trialEnd` timestamps to the selected package and addons, then navigates to checkout:
```mermaid
flowchart TD
BTN([Click Create Trial Subscription Plan]) --> CS[confirmServices]
CS --> TRIALS[Read membership.trials<br/>Calculate trialEndDate]
TRIALS --> PKGTRIALEND["selPkg = { ...currSel.selPkg,<br/>trialEnd: trialEndDate }"]
PKGTRIALEND --> ADDONTRIALEND["selAddons = addons.map<br/>addon.trialEnd = trialEndDate"]
ADDONTRIALEND --> ISNEW{isNewSub?}
ISNEW -->|Yes| TRIALDIRECT[dispatchStartBillingInfo<br/>mode = Mode.TRIALING<br/>prorateTS = null]
ISNEW -->|No - existing trial change| TRIALCONFIRM[Confirm dialog<br/>then dispatchStartBillingInfo<br/>mode = Mode.TRIALING]
TRIALDIRECT --> SBI[StartBillingInfo dispatched]
TRIALCONFIRM --> SBI
SBI --> NAV([Navigate to /checkout])
NAV --> INIT[initPage]
INIT --> ISTRIALCK{isTrial = true}
ISTRIALCK --> TRIALITEMS["createTrialItems<br/>from selPkg + selAddons"]
TRIALITEMS --> CTIP1["checkTrialItemPromos<br/>activePromos EMPTY at this point<br/>totalPromoSavings = 0"]
CTIP1 --> AMOUNT1["amount.total = grossTotal - 0<br/>STALE - full price"]
NAV --> LAP[loadActivePromos async]
LAP --> PROMOMAP[activePromos Map built<br/>from /api/activePromos response]
PROMOMAP --> CTIP2["checkTrialItemPromos<br/>activePromos NOW loaded"]
CTIP2 --> PROMOFOUND{promo in activePromos<br/>for this lookupKey?}
PROMOFOUND -->|Yes - e.g. ess_1_1 eligibility=all| SAVINGS["totalPromoSavings recalculated<br/>paymentPromos populated"]
SAVINGS --> AMOUNTFIX["amount.total = grossTotal - totalPromoSavings<br/>UpdateAmount dispatched"]
AMOUNTFIX --> PROMODISPLAY([Show promo badge + discounted price])
PROMOFOUND -->|No match| NORMALPRICE([Full trial price - no promo])
```
### Checkout Promo Display after Each Flow
```mermaid
flowchart LR
REG([Regular flow<br/>Mode.REGULAR]) --> REGPATH["checkApplicablePromos<br/>uses chkoutPmt.lineItems<br/>gate: hasAnyPackageSub"]
REGPATH --> REGPROMO([paymentPromos map<br/>promo badges + savings])
TRIAL([Trial flow<br/>Mode.TRIALING]) --> TRIALPATH["checkTrialItemPromos<br/>uses trialItems<br/>no subscription gate<br/>looks up activePromos directly"]
TRIALPATH --> TRIALPROMO(["paymentPromos map<br/>promo badges + discounted trial total<br/>eligibility=all promos shown"])
```
**Key difference**: `checkApplicablePromos` gates on `hasAnyPackageSubscription` because it works with real invoice line items that could include existing subscriptions. `checkTrialItemPromos` skips that gate — the items are the trial package/addons only, and the server already applied eligibility filtering to `/activePromos`.
---
## getPromoForLookupKey the Core Gate
This is the single method called by the template for every package and addon row. It returns an `ActivePromo` to display or `null` to show nothing.
Signature: `getPromoForLookupKey(lookupKey, type, mode = 'available')`
```mermaid
flowchart TD
START([getPromoForLookupKey]) --> A{promoMode === disabled?}
A -->|Yes| NULL1([return null - global kill switch])
A -->|No| B[getUserSubscriptionForLookupKey]
B --> C{User has sub for this item<br/>AND status === trialing?}
C -->|Yes| NULL2([return null - trial IS the promo])
C -->|No| D{mode === available<br/>AND userHasThis?}
D -->|Yes| NULL3([return null - item already subscribed])
D -->|No| E{mode === subscribed<br/>AND NOT userHasThis?}
E -->|Yes| NULL4([return null - item not subscribed])
E -->|No| F{mode === subscribed?}
F -->|Yes| G{promoDetails.hasPromo === true?}
G -->|Yes| CONV([return convertPromoDetailsToActivePromo])
G -->|No| NULL5([return null - no fallback for subscribed mode])
F -->|No - mode is available| H["activePromos.get(lookupKey)"]
H -->|found| EXACT([return exact-match promo])
H -->|not found| I["activePromos.get(type_all)"]
I -->|found| TYPE([return type-wide promo])
I -->|not found| NULL6([return null])
```
### Why isTrial does NOT suppress promos
Prior to v3.0, the flag `isTrial` (set when checkout intent mode is `TRIALING`) blocked all available promos. This was removed because:
- Since v3.0, the server already evaluates `eligibility` before returning promos. If a promo with `eligibility: 'all'` is returned (e.g. `ess_1_1` with a `$400 OFF` offer), it means the server has confirmed this user qualifies.
- A trial user looking at `/services` to decide whether to subscribe with auto-renewal **should** see that promo — it is the incentive to convert.
- The existing guard `status === 'trialing'` (step above) still correctly hides promos on any subscription row where the user is actively in a trial.
---
## isAllPackagesPromo Package-Wide Banner Logic
Controls whether the green promo banner above the packages table is shown.
```mermaid
flowchart TD
START([isAllPackagesPromo]) --> A{essPkgs empty?}
A -->|Yes| NULL1([return null])
A -->|No| B{User has ANY existing<br/>package subscription?}
B -->|Yes| NULL2([return null - banner only for new subscribers])
B -->|No| C["activePromos.get('package_all')"]
C -->|found| RET1([return type-wide promo])
C -->|not found| D[Map each pkg to its activePromo]
D --> E{All packages have<br/>an individual promo?}
E -->|No| NULL3([return null])
E -->|Yes| F{All promos share same<br/>discountType + discountValue?}
F -->|No| NULL4([return null])
F -->|Yes| RET2([return the shared promo])
```
> The banner is intentionally shown **only** to brand-new subscribers (no existing package subscription). Returning subscribers see promo state per-row instead.
---
## Template Rendering Logic
### Package Row Structure
```mermaid
flowchart TD
ROW([Package row rendered]) --> LEGACY{isLegacyEss1?}
LEGACY -->|Yes| LEGACYLABEL[Show legacy notice label<br/>Promo display suppressed]
LEGACY -->|No| SUB{isUserSubscribed?}
SUB -->|Yes - subscribed| ACTIVE["getPromoForLookupKey(key, package, subscribed)"]
ACTIVE -->|promo found| ACTIVELABEL["agm-active-promo-label<br/>Active Promo: DISCOUNT"]
ACTIVE -->|null| NOTHING1[No promo label]
SUB -->|No - not subscribed| AVAIL["getPromoForLookupKey(key, package, available)"]
AVAIL -->|promo found| AVAILABLELABEL["Promo name text<br/>e.g. AgMission Essentials 1 Plus"]
AVAIL -->|null| NOTHING2[No promo label]
ROW --> PRICECOL[Price column]
PRICECOL --> PROMOCHECK["getPromoForLookupKey(key, package)<br/>mode defaults to available"]
PROMOCHECK -->|promo found| CROSSEDPRICE["original-price crossed out<br/>promo-price shown<br/>Valid until date below"]
PROMOCHECK -->|null| REGULARPRICE[Regular price only]
```
### Addon Row Structure
Same dual-mode structure as packages, with **no banner** at the top (per P2-D wireframe). Both Unit Price and Total Price columns independently call `getPromoForLookupKey` in `available` mode.
```mermaid
flowchart TD
ADDONROW([Addon row rendered]) --> SUBSCHECK{isUserSubscribed?}
SUBSCHECK -->|Yes| A2["getPromoForLookupKey(key, addon, subscribed)"]
A2 -->|promo found| A2L[agm-active-promo-label]
A2 -->|null| A2N[No promo label]
SUBSCHECK -->|No| A3["getPromoForLookupKey(key, addon, available)"]
A3 -->|promo found| A3L[Promo name text below addon name]
A3 -->|null| A3N[No promo label]
ADDONROW --> UNITPRICE[Unit Price column]
UNITPRICE --> UP["getPromoForLookupKey(key, addon)"]
UP -->|promo| UPPROMO[crossed price + promo price]
UP -->|null| UPREGULAR[Regular unit price]
ADDONROW --> TOTALPRICE[Total Price column]
TOTALPRICE --> TP["getPromoForLookupKey(key, addon)"]
TP -->|promo| TPPROMO["crossed total + promo total<br/>Valid until date below"]
TP -->|null| TPREGULAR[Regular total price]
```
---
## Promo Price Calculation
All math is delegated to `SubscriptionService.calculateDiscountedAmount(originalCents, promo)`:
```mermaid
flowchart TD
CALC([calculateDiscountedAmount]) --> A{discountType}
A -->|free OR discountValue === 100| ZERO([return 0])
A -->|percent| PCT["return round(original x 1 - value/100)"]
A -->|fixed| FIXED["return max(0, original - value)<br/>value is already in cents"]
```
For addons: `calculatePromoTotal(addon, promo)` = `calculatePromoPrice(addon.price, promo) × quantity`.
---
## ESS_1 Legacy Special Handling
```mermaid
flowchart TD
PKG([Package item]) --> SHOW{shouldShowPackage}
SHOW --> ESS1{lookupKey === ess_1?}
ESS1 -->|Yes| HASLEGACY{hasLegacyEss1Subscription?}
HASLEGACY -->|Yes - user has active or trialing ESS_1| VISIBLE([Show row])
HASLEGACY -->|No| HIDDEN([Hide row])
ESS1 -->|No - ess_1_1 or others| VISIBLE2([Always show row])
VISIBLE --> PROMOCHECK{isLegacyEss1?}
PROMOCHECK -->|Yes - ess_1 row| LEGACYNOTICE[Show legacy notice<br/>Promo display suppressed]
PROMOCHECK -->|No| NORMALPROMO[Normal dual-mode promo display]
```
`isLegacyEss1(lookupKey)`: returns `true` only when `lookupKey === 'ess_1'` AND the user has a legacy ESS_1 subscription. Promo display is suppressed on ESS_1 rows even if a matching global promo exists.
---
## Promo Display Components
| Component | Selector | Used for | Shows |
|---|---|---|---|
| `ActivePromoLabelComponent` | `agm-active-promo-label` | Subscribed users with `promoDetails.hasPromo` | Active Promo: DISCOUNT |
| `ConstraintMessageComponent` | `agm-constraint-message severity="promo"` | Package-wide banner via `isAllPackagesPromo()` | Green info box with promo message and valid-until date |
| Raw template | `div.available-promo` | Available promo name below package or addon name | Promo name text |
| Raw template | `div.price-with-promo` | Price column when promo available | Crossed-out price, promo price, valid-until date |
---
## Quick Reference Table
| User type | Item state | mode arg | Result |
|---|---|---|---|
| Any, `PROMO_MODE=disabled` | any | any | No promo shown |
| Any | `status=trialing` subscription | any | No promo shown |
| Not subscribed | available item | `available` | Promo shown if in `activePromos` |
| Already subscribed | own subscription | `subscribed` | Promo shown if `promoDetails.hasPromo` |
| Trial user (`isTrial=true`) | not yet subscribed | `available` | Promo shown if in `activePromos` |
| Legacy ESS_1 subscriber | `ess_1` row | any | Promo suppressed, legacy notice shown |
| User with existing package sub | package-wide banner | `isAllPackagesPromo` | Banner hidden |
> **Trial users CAN see available promos.** Server-side eligibility filtering (v3.0) ensures only qualifying promos are returned. The `isTrial` flag from Redux intent no longer suppresses promo display — only active `status='trialing'` subscriptions suppress promos on their own row.

View File

@ -1,333 +0,0 @@
# Notification Deep-Links
Reference for handling external deep-link URLs sent in customer notification emails
(e.g. "Manage your subscription", "Update payment method").
---
## Table of Contents
1. [Overview](#1-overview)
2. [Registered Routes](#2-registered-routes)
3. [How It Works — Full Flow](#3-how-it-works--full-flow)
- [Authenticated user](#authenticated-user)
- [Unauthenticated user](#unauthenticated-user)
4. [Key Files](#4-key-files)
5. [Adding a New Notification URL](#5-adding-a-new-notification-url)
6. [Design Decisions](#6-design-decisions)
---
## 1. Overview
Notification emails link customers to top-level URLs such as `/#/manage-subscription`.
These URLs must:
- **Not require authentication themselves** — the customer may be logged out
- **Skip the shell layout** — they live outside `AppMainComponent`
- **Redirect instantly** — no blank-page flash, no component rendered
- **Show a contextual notice** on the login screen when authentication is required
All of this is handled by a single `NotificationRedirectGuard` combined with route `data`.
Adding a new notification URL requires **one route entry and zero new files**.
---
## 2. Registered Routes
All notification routes are declared at the top level in `app-routing.module.ts`,
outside the `AppMainComponent` shell:
```
/#/manage-subscription → /profile/myservices (or /profile/services if no subs)
/#/update-pm → /profile/payment-method-list
/#/update-bill-address → /profile/billing-address
```
Each route in source:
```typescript
// app-routing.module.ts
{
path: 'manage-subscription',
component: PageNotFoundComponent, // never rendered — guard always redirects
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'myservices'],
redirectToNoSubs: ['profile', 'services'],
loginNotice: $localize`@@manageSubLoginNotice:Please log in with your Master account to manage subscriptions.`
}
},
{
path: 'update-pm',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'payment-method-list'],
loginNotice: $localize`@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
}
},
{
path: 'update-bill-address',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'billing-address'],
loginNotice: $localize`@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
}
},
```
> `PageNotFoundComponent` is the placeholder — it is already declared in `AppModule`
> and is never displayed because the guard always returns a `UrlTree` before any
> component activates.
---
## 3. How It Works — Full Flow
### Authenticated user
```
User clicks link in email
Browser opens /#/manage-subscription
Angular router matches route
NotificationRedirectGuard.canActivate()
├─ authSvc.loggedIn = true
├─[redirectToNoSubs defined AND master AND no subs]
│ └──→ UrlTree: /profile/services
└─[all other authenticated cases]
└──→ UrlTree: /profile/myservices
AppMainComponent loads normally
/profile/myservices renders
```
The guard returns a `UrlTree` **synchronously** (auth state is rehydrated from
`sessionStorage` before any guard runs). Angular processes the redirect before
deactivating the current route or activating any component — no blank page, no
shell teardown.
---
### Unauthenticated user
```
User clicks link in email (not logged in)
Browser opens /#/manage-subscription
NotificationRedirectGuard.canActivate()
├─ authSvc.loggedIn = false
└──→ UrlTree: /login?returnUrl=manage-subscription
&loginNotice=<i18n message>
LoginComponent constructor reads queryParams
nav.extractedUrl.queryParams['loginNotice']
Pushes { severity: 'info', detail: loginNotice }
into this.msgs → rendered by <p-messages>
┌──────────────────────────────────────────┐
│ [AgMission logo] │
│ │
Please log in with your Master │
│ account to manage subscriptions. │
│ │
│ Username ________________________
│ Password ________________________
│ [ LOGIN ] │
└──────────────────────────────────────────┘
User logs in
authActions.LoginSuccess dispatched
AuthEffects.navigateDefault()
router.parseUrl(router.url)
.queryParams['returnUrl']
→ 'manage-subscription'
window.location.replace('/#/manage-subscription')
NotificationRedirectGuard runs again
(now authenticated)
Redirects to /profile/myservices
```
---
## 4. Key Files
### `src/app/domain/guards/notification-redirect.guard.ts`
The single guard that handles all notification deep-links.
**Route `data` contract:**
| Field | Type | Required | Description |
|---|---|---|---|
| `redirectTo` | `string[]` | Yes | Router path segments for authenticated users |
| `redirectToNoSubs` | `string[]` | No | Alternate path when master account has no subscriptions |
| `loginNotice` | `string` | No | Message shown in the `<p-messages>` bar on the login screen |
```typescript
canActivate(route: ActivatedRouteSnapshot): UrlTree {
const { redirectTo, redirectToNoSubs } = route.data;
if (!this.authSvc.loggedIn) {
const { loginNotice } = route.data;
return this.router.createUrlTree(['/login'], {
queryParams: {
returnUrl: route.url.map(s => s.path).join('/'),
...(loginNotice ? { loginNotice } : {})
}
});
}
const isMaster = !this.authSvc.user?.parent;
if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) {
return this.router.createUrlTree(redirectToNoSubs);
}
return this.router.createUrlTree(redirectTo);
}
```
---
### `src/app/auth/effects/auth.effects.ts``navigateDefault()`
After a successful login, reads `returnUrl` from the current router URL and
replaces the browser location to trigger the notification route again
(now authenticated):
```typescript
private navigateDefault(lang) {
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home';
window.location.replace((lang === 'en' ? hash : `/${lang}${hash}`) + returnUrl);
}
```
Uses `router.parseUrl()` instead of string splitting — correctly handles all
URL encodings.
If no `returnUrl` is present (normal login), falls back to `'home'` as before.
---
### `src/app/auth/login/login.component.ts` — constructor
Generic `loginNotice` handling — no hardcoded route names:
```typescript
const nav = this.router.getCurrentNavigation();
if (nav) {
const msgs: any[] = [];
if (nav.extras?.state?.changedPwd) {
msgs.push({ severity: 'info', summary: '', detail: globals.pwdChangedOk });
}
const loginNotice =
nav.finalUrl?.queryParams?.['loginNotice'] ??
nav.extractedUrl?.queryParams?.['loginNotice'];
if (loginNotice) {
msgs.push({ severity: 'info', summary: '', detail: loginNotice });
}
if (msgs.length) this.msgs = msgs;
}
```
Reads from `getCurrentNavigation()` — the only safe place to read query params
on a login redirect since the router replaces the URL before `ngOnInit` runs.
---
## 5. Adding a New Notification URL
**Zero new files required.** Add one entry to `app-routing.module.ts`:
```typescript
{
path: 'renew-subscription', // URL path: /#/renew-subscription
component: PageNotFoundComponent, // never rendered
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'checkout'], // authenticated destination
// redirectToNoSubs: ['profile', 'services'], // optional alternate
loginNotice: $localize`:@@renewSubLoginNotice:Please log in with your Master account to renew your subscription.`
}
},
```
That's it. The guard, the login notice, and the post-login redirect all work
automatically.
**Checklist:**
- [ ] Add route entry with `redirectTo` (required) and optionally `redirectToNoSubs` / `loginNotice`
- [ ] Add the `loginNotice` i18n key to all locale `.xlf` translation files if the app is translated
- [ ] Provide the deep-link URL to the notifications/email team: `https://app.agmission.com/#/renew-subscription`
---
## 6. Design Decisions
### Why a guard returning `UrlTree` instead of a redirect component?
A component that calls `router.navigate()` in `ngOnInit` causes:
- The current shell (`AppMainComponent`) to deactivate and re-activate — visible flicker
- A blank template to render briefly while `ngOnInit` executes
A guard returning a `UrlTree` is processed by Angular **before any component is
activated or deactivated**. The redirect is invisible and instantaneous.
### Why route `data` instead of a guard per URL?
One guard file per URL creates N files for N routes with identical logic.
Putting the routing targets in `data` makes the guard a pure engine
and the route definition the configuration — consistent with Angular's
`canActivate`+`data` idiom used throughout the app (e.g. `RoleGuard` + `data.roles`).
### Why `PageNotFoundComponent` as the placeholder?
Angular requires a `component` on every non-lazy route. The component is never
rendered (the guard always redirects), so any already-declared component works.
`PageNotFoundComponent` is the most semantically appropriate fallback if the guard
ever fails to redirect for an unexpected reason.
### Why `window.location.replace()` instead of `router.navigate()` in `navigateDefault`?
This was the pre-existing pattern to prevent the login page from appearing in the
browser's Back history after authentication. `location.replace` replaces the
current history entry rather than pushing a new one.
### Sub-accounts vs. Master accounts
Subscription management (`/profile/myservices`) is only meaningful for master
accounts, but sub-accounts (users with `user.parent` set) are redirected there
too — the manage-subscription view enforces its own access rules once loaded.
`redirectToNoSubs` is only evaluated for master accounts with zero subscriptions,
sending them to the `/profile/services` plan picker instead.

View File

@ -1,576 +0,0 @@
# Subscription Display Reference
Quick overview of how subscriptions, pricing, and promos are displayed across the entire flow — from checkout through confirmation and the account management page.
---
## Table of Contents
1. [At a Glance — Full User Journey](#1-at-a-glance--full-user-journey)
2. [Page Structure Overview](#2-page-structure-overview)
3. [Checkout Flow (3 Stages)](#3-checkout-flow--3-stages)
- [Stage 1 — Enter Payment](#stage-1--enter-payment-checkoutcomponent)
- [Stage 2 — Review & Submit](#stage-2--review--submit-checkout-reviewcomponent)
- [Stage 3 — Confirmation](#stage-3--confirmation-checkout-confirmcomponent)
4. [Manage Subscription Page](#4-manage-subscription-page-myservices)
- [Subscription State Decision Tree](#subscription-state-decision-tree)
- [Case-by-Case Pricing Display](#case-by-case-pricing-display)
5. [Shared Pricing Components](#5-shared-pricing-components)
- [`<payment-amount>` Template Guide](#payment-amount-template-guide)
- [`<payment-summary>` Mode Guide](#payment-summary-mode-guide)
6. [Canada Tax Logic](#6-canada-tax-logic)
7. [Conditional Label Reference](#7-conditional-label-reference)
---
## 1. At a Glance — Full User Journey
```
User selects a plan
┌───────────────────┐ ┌─────────────────────┐
│ Regular Purchase │ │ Trial Signup │
│ /checkout │ │ /checkout (isTrial) │
└────────┬──────────┘ └──────────┬───────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ │
│ Trial only Trial + "continue
│ (no card yet) after trial" checked
│ │ │
▼ ▼ ▼
Stage 1: Stage 1: Stage 1:
Payment form $0.00 total After-trial price
(Templates 1) (Template 5) (Template 7)
│ │ │
└──────────────┴───────────────────────────┘
Stage 2: Review
payment-summary REGULAR
(Template 2 — tax/discount/total)
┌───────────────┼───────────────┐
│ │ │
TRIALING CONTINUE_TRIAL REGULAR
Stage 3: Stage 3: Stage 3:
Template 5 Template 7 Template 2
($0 confirm) (after-trial (full receipt)
confirm)
/myservices (manage-subscription)
Displays live subscription state
for all owned packages/addons
```
---
## 2. Page Structure Overview
### `/checkout` — Stage 1
```
┌──────────────────────────────────────────────────────┐
│ CHECKOUT │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ [Regular purchase — no refund] │ │
│ │ │ │
│ │ payment-info (new line items) │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ [IF promo active] → Template 1 │ │ │
│ │ │ [ELSE] → coupon input field │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ ── Credit card form ── │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┬─────────────────────────────┐ │
│ │ [With refund] │ │ │
│ │ Payment column │ Refund column │ │
│ │ payment-info │ payment-info (refund items)│ │
│ │ Template 1 / │ Template 1 │ │
│ │ coupon input │ │ │
│ └──────────────────┴─────────────────────────────┘ │
│ ── Credit card form ── │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ CHECKOUT (trial — start only, isTrial=true) │
│ │
│ Trial Information │
│ payment-info (trial items) │
│ │
│ [IF promo]: 🎁 Total Promo Savings: -$X.XX │
│ After Trial Total: $X.XX ← * │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Template 5: "Free trial until DATE" │ │
│ │ Total: $0.00 US │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ☐ I want to continue the service after trial end │
│ └─(checked)→ credit card form appears │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ CHECKOUT (trial → continuing, isContAftTrialEnd) │
│ │
│ ⚠ Your trial is active until [DATE]. You will be │
│ charged on that date. │
│ │
│ Your Subscription After Trial Ends │
│ payment-info (trial items) │
│ │
│ [IF promo]: 🎁 Total Promo Savings: -$X.XX │
│ After Trial Total *: $X.XX │
│ │
│ Total *: $X.XX │
│ [Canada only]: Plus Applicable Tax │
│ │
│ ── Credit card form ── │
│ │
│ * label changes in Canada (see §6) │
└──────────────────────────────────────────────────────┘
```
### `/checkout-review` — Stage 2
```
┌──────────────────────────────────────────────────────┐
│ REVIEW AND SUBMIT │
│ │
│ ✓ (success icon) │
│ │
│ payment-summary [mode]="REGULAR" │
│ ┌────────────────────────┬───────────────────────┐ │
│ │ Payment Information │ Card Info │ │
│ │ ───────────────── │ ───────────────── │ │
│ │ Template 2: │ •••• 4242 │ │
│ │ Total Excl. Tax $X.XX │ Visa Exp 12/27 │ │
│ │ Tax $X.XX │ │ │
│ │ [Discount] -$X.XX │ [Edit button] │ │
│ │ [Plan Refund]-$X.XX │ │ │
│ │ ────── │ │ │
│ │ Total $X.XX │ │ │
│ └────────────────────────┴───────────────────────┘ │
│ │
│ [Error states: PAST_DUE / CARD_DECLINED / etc. │
│ → error banner above payment-summary] │
│ │
│ [ SUBMIT ] │
└──────────────────────────────────────────────────────┘
```
### `/checkout-confirm` — Stage 3
```
┌──────────────────────────┐
│ mode = TRIALING │
├──────────────────────────┤
│ ✓ Trial subscription │
│ is active │
│ │
│ payment-summary TRIALING │
│ Trial Information │
│ payment-info (items) │
│ Template 5: $0.00 │
└──────────────────────────┘
┌──────────────────────────┐
│ mode = CONTINUE_TRIAL │
├──────────────────────────┤
│ ✓ Continuation setup │
│ complete │
│ │
│ payment-summary │
│ CONTINUE_TRIAL │
│ [showApplicableTax= │
│ authSvc.isCanada] │
│ ───────────────────── │
│ Trial Information │
│ ⚠ constraint-message │
│ payment-info (items) │
│ Template 7: │
│ 🎁 Promo Savings $X │
│ Total (Before Tax) * │
│ Plus Applicable Tax * │
│ Card: •••• 4242 │
└──────────────────────────┘
┌──────────────────────────┐
│ mode = REGULAR │
├──────────────────────────┤
│ ✓ Subscription active │
│ [promo note if promo] │
│ │
│ Template 2: │
│ Total Excl. Tax $X.XX │
│ Tax $X.XX │
│ [Discount] -$X.XX │
│ [Plan Refund] -$X.XX │
│ Total $X.XX │
│ │
│ Card: •••• 4242 Visa │
└──────────────────────────┘
* label changes in Canada (see §6)
```
---
## 3. Checkout Flow — 3 Stages
### Stage 1 — Enter Payment (`checkout.component`)
#### Decision tree
```
checkout.component
├─[isTrial = false]────────────────── Regular purchase
│ │
│ ├─[hasRefund = false]─────── Single column
│ │ payment-info (items)
│ │ [promo?] Template 1 : coupon input
│ │ credit card form
│ │
│ └─[hasRefund = true]──────── Two columns
│ Payment col │ Refund col
│ items+T1 │ items+T1
│ credit card form below
└─[isTrial = true]─────────────────── Trial purchase
├─[isContAftTrialEnd = false]── Trial-start only
│ Trial info + items
│ [promo?] 🎁 savings + After Trial Total *
│ Template 5 ($0.00)
│ Checkbox: continue after trial?
│ └─(checked) → isContAftTrialEnd = true
└─[isContAftTrialEnd = true]─── Trial + continue
Constraint banner
After-trial items
[promo?] 🎁 savings + After Trial Total *
Total * / Plus Applicable Tax (CA)
Credit card form
* = "After Trial Total (Before Tax)" / "Total (Before Tax)" in Canada
```
---
### Stage 2 — Review & Submit (`checkout-review.component`)
Always renders `<payment-summary [mode]="REGULAR">`**Template 2** inside.
```
Template 2 layout:
┌─[IF promoSavings > 0]────────────────────────────────┐
│ 🎁 Total Promo Savings: -$X.XX │
│ [IF creditAmount > 0] │
│ 🔄 Plan Refund: -$X.XX │
│ Tax: $X.XX │
│ Total: $X.XX │
└──────────────────────────────────────────────────────┘
┌─[ELSE — no promo]────────────────────────────────────┐
│ Total Excluding Tax: $X.XX │
│ Tax: $X.XX │
│ [IF discount.amountOff] │
│ (Discount): -$X.XX │
│ [IF creditAmount > 0] │
│ 🔄 Plan Refund: -$X.XX │
│ Total: $X.XX │
└──────────────────────────────────────────────────────┘
```
---
### Stage 3 — Confirmation (`checkout-confirm.component`)
```
Mode selection:
TRIALING ──────────────────→ payment-summary TRIALING
→ Template 5 ($0.00 + trial msg)
CONTINUE_TRIAL ────────────→ payment-summary CONTINUE_TRIAL
[showApplicableTax]="isCanada"
→ Template 7 (after-trial totals)
REGULAR (default) ─────────→ Template 2 (full tax + total receipt)
```
---
## 4. Manage Subscription Page (`/myservices`)
### Subscription State Decision Tree
```
manage-subscription: for each pkg in subscriptions
├─[TRIALING]───────────────────────────────────────────────────
│ Trial Ends: [DATE]
│ │
│ ├─[No promo — Case 2A]
│ │ After Trial: $X.XX/year
│ │
│ ├─[Promo + will continue — Case 2C]
│ │ Regular Price: $X.XX/year ← context
│ │ Paid Price: $X.XX + (save $X.XX)
│ │ [IF time-limited] After Promo Ends: $X.XX/year
│ │
│ └─[Promo + cancel at end — Case 2D]
│ Regular Price: $X.XX/year
│ (no paid price shown — trial will cancel)
├─[ACTIVE + hasActivePromo(pkg)]─────────────────────────────
│ │
│ ├─[isRenewalPromo — Case 2B]
│ │ 🏷 Discount badge
│ │ getRenewalPromoMessage() (e.g. "Renew by X and save!")
│ │
│ └─[existingPromo — Case 3]
│ Regular Price: $X.XX/year ← context
│ Paid Price: $X.XX + (save $X.XX)
│ [IF time-limited] After Promo Ends: $X.XX/year
├─[ACTIVE — no promo]────────────────────────────────────────
│ Paid Price: $X.XX/year
├─[CANCELED]─────────────────────────────────────────────────
│ Ended On: [DATE]
│ Previous Price: $X.XX/year
└─[PAST_DUE / INCOMPLETE]────────────────────────────────────
Paid Price: $X.XX/year
Next Bill Date: [DATE]
```
### Case-by-Case Pricing Display
Each subscription card shows a **Pricing Section** and a **Details Section**:
```
┌─────────────────────────────────────────────────┐
│ 📦 Package Name [STATUS BADGE] │
│ ───────────────────────────────────────────── │
│ PRICING SECTION (varies by case — see below) │
│ ───────────────────────────────────────────── │
│ DETAILS SECTION (always shown): │
│ Max Vehicles: N Aircraft │
│ Max Acres: N,000 / Unlimited │
│ Billing Cycle: Yearly │
│ Payment Method: Visa •••• 4242 │
│ Next Bill Date: Jan 1, 2027 ─┐ ACTIVE / │
│ Next Bill Amt: $X.XX ┘ TRIALING │
│ ───────────────────────────────────────────── │
│ [Promo section if applicable — see below] │
│ ───────────────────────────────────────────── │
│ [ MANAGE ] [ CANCEL / REACTIVATE ] │
└─────────────────────────────────────────────────┘
```
#### Pricing section — by case
```
CASE 2A — Trial, no promo
Trial Ends: Jan 10, 2026
After Trial: $995.00/year
CASE 2B — Active, renewal incentive promo (cancel_at_period_end)
🏷 [badge] "Renew by Jan 10, 2026 and save 50%!"
CASE 2C — Trial, promo applied, will continue after trial
Trial Ends: Jan 10, 2026
Regular Price: $995.00/year ← full price for context
Paid Price: $497.50/year (save $497.50)
After Promo Ends: $995.00/year ← only if time-limited promo
CASE 2D — Trial, promo applied, cancel at end
Trial Ends: Jan 10, 2026
Regular Price: $995.00/year
CASE 3 — Active, promo applied on subscription
Regular Price: $995.00/year ← full price for context
Paid Price: $497.50/year (save $497.50)
After Promo Ends: $995.00/year ← only if time-limited promo
ACTIVE (no promo)
Paid Price: $995.00/year
CANCELED
Ended On: Dec 31, 2025
Previous Price: $995.00/year
PAST_DUE / INCOMPLETE
Paid Price: $995.00/year
Next Bill Date: Jan 10, 2026
```
#### Promo details block (ACTIVE non-renewal promos)
```
─────────────────────────────────────────────────
🏷 Percentage Off | Amount Off | Forever
Discount: 50% off or $497.50 off
Duration: For N months | One time | Forever
[IF expires]: Promo Expires: Dec 31, 2026 (N days left)
─────────────────────────────────────────────────
[IF pendingPromo]:
⏳ Pending Promo (from next billing cycle):
Discount / Duration / Savings
─────────────────────────────────────────────────
```
---
## 5. Shared Pricing Components
### `<payment-amount>` Template Guide
The component is template-switched via `[template]="N"`.
```
Template Used In Renders
───────── ─────────────────────────────────── ──────────────────────────────────────────────
1 checkout (with active promo) Tax + discount + promo savings + Total
2 checkout-review, checkout-confirm Full grid: Excl.Tax / Tax / Discount / Total
3 (reserved) Subtotal ─── Tax ─── Discount ─── Total
4 (reserved) Coupon/discount line only
5 trial start confirm, TRIALING mode Trial msg + Total: $0.00
6 (reserved) "Will be charged after trial" note + Total
7 CONTINUE_TRIAL confirm, isContAftTrial Promo savings + Total (Before Tax)* + Tax note
```
#### Template 1 layout
```
Tax: $X.XX US
[IF %off] 50% off: ($X.XX) US
[IF $off] ($ off): ($X.XX) US
[IF promo] 🎁 Total Promo Savings: -$X.XX US
Total: $X.XX US
```
#### Template 2 layout
```
┌─[IF promoSavings > 0]───────────────────────────────┐
│ 🎁 Total Promo Savings: -$X.XX │
│ [Plan Refund]: -$X.XX (if > 0) │
│ Tax: $X.XX │
│ Total: $X.XX │
└─────────────────────────────────────────────────────┘
┌─[ELSE]──────────────────────────────────────────────┐
│ Total Excluding Tax: $X.XX │
│ Tax: $X.XX │
│ [(Discount)]: ($X.XX) (if $off) │
│ [Plan Refund]: -$X.XX (if > 0) │
│ Total: $X.XX │
└─────────────────────────────────────────────────────┘
```
#### Template 5 layout
```
[msg] "Free trial until Jan 10, 2026"
Total: $0.00 US
```
#### Template 7 layout
```
[IF promoSavings > 0]
🎁 Total Promo Savings: -$X.XX US
[!Canada] Total: $X.XX US
[Canada] Total (Before Tax): $X.XX US
Plus Applicable Tax ← only when totalAmount > 0
```
---
### `<payment-summary>` Mode Guide
A mode-driven wrapper that picks the layout and calls `<payment-amount>` with the right template.
```
Mode Template Used Shows Card? showApplicableTax driven by
─────────────── ───────────── ─────────── ──────────────────────────────
REGULAR 2 yes N/A (Template 2 has no tax toggle)
TRIALING 5 no N/A
CONTINUE_TRIAL 7 yes [showApplicableTax] input → isCanada
```
**Inputs:**
| Input | Type | Purpose |
|---|---|---|
| `mode` | `Mode` | `REGULAR`, `TRIALING`, or `CONTINUE_TRIAL` |
| `card` | `Card` | Credit card info for display |
| `payment` | `PaidAmount` | `{ total, totalTax, totalExcludingTax, discount, refundAmount }` |
| `trialItems` | `TrialItem[]` | Line items for trial subscriptions |
| `promoSavings` | `number` | Total promo discount |
| `showApplicableTax` | `boolean` | Passed down to `<payment-amount>` (Template 7) |
| `editable` | `boolean` | Shows Edit button in REGULAR mode |
| `promos` | `Map<string, any>` | Promo badge data for `<payment-info>` |
---
## 6. Canada Tax Logic
```typescript
// auth.service.ts
get isCanada(): boolean {
return this.user?.country === 'CA'; // populated from login response
}
```
### Where `isCanada` propagates
```
AuthService.isCanada
├── checkout.component (readonly authSvc exposed to template)
│ ├── "Total (Before Tax):" label [isContAftTrialEnd block]
│ ├── "After Trial Total (Before Tax):" label
│ └── "Plus Applicable Tax" div
└── checkout-confirm.component (readonly authSvc)
└── <payment-summary [showApplicableTax]="authSvc.isCanada">
└── <payment-amount [showApplicableTax]="showApplicableTax">
└── Template 7 tax toggle + "Plus Applicable Tax" note
```
### Label changes by country
```
Non-Canada Canada
───────────────────── ─────────────────────────────
Total label Total: Total (Before Tax):
After-trial total label After Trial Total: After Trial Total (Before Tax):
Tax line (hidden) Plus Applicable Tax
```
---
## 7. Conditional Label Reference
| Label | Component | Renders when |
|---|---|---|
| **Total:** | All templates, default | `!showApplicableTax` |
| **Total (Before Tax):** | Template 7, `checkout.html` | Canada (`showApplicableTax = true`) |
| **Total Excluding Tax:** | Template 2, no-promo branch | Non-promo path (always) |
| **Tax:** | Templates 1, 2, 3 | Tax data available |
| **Plus Applicable Tax** | Template 7, `checkout.html` | Canada + `totalAmount > 0` |
| **After Trial Total:** | `payment-summary #trial`, `checkout.html` | `!showApplicableTax` + `promoSavings > 0` |
| **After Trial Total (Before Tax):** | `payment-summary #trial`, `checkout.html` | Canada + `promoSavings > 0` |
| **🎁 Total Promo Savings:** | Templates 2, 7; inline in checkout | `promoSavings > 0` |
| **🔄 Plan Refund:** | Template 2 | `creditAmount > 0` |
| **Regular Price:** | manage-subscription Cases 2C, 2D, 3 | Has promo applied |
| **Paid Price:** | manage-subscription Cases 2C, 3, ACTIVE, PAST_DUE | Active promo or non-promo active |
| **After Promo Ends:** | manage-subscription Cases 2C, 3 | `showAfterPromoEnds(pkg)` — time-limited promo |
| **Next Period Amount:** | manage-subscription | `nextBillAmounts[key]` loaded |

View File

@ -5,8 +5,7 @@
"angular-cli": {}, "angular-cli": {},
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start-cert": "ng serve --ssl true --sslKey ~/ssl/server.key --sslCert ~/ssl/server.crt --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck", "start": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
"start": "CHOKIDAR_USEPOLLING=true ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
"start-es": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=es", "start-es": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=es",
"start-pt": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=pt", "start-pt": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=pt",
"build": "ng build", "build": "ng build",
@ -21,7 +20,6 @@
"i18n-merge-w": "(for %i in (pt es) do (xliffmerge --profile xliffmerge.json en %i))", "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": "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", "sync-i18n-w": "npm run build-prep && npm run i18n-extract-w && npm run i18n-merge-w",
"pre-translate": "npx translation start && npm run sync-i18n && npx translation translate && npx translation cleanup",
"build-prod": "ng build --prod --localize && cp -R dist/en/* dist/ && rm -R dist/en", "build-prod": "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" "build-prod-window": "ng build --prod --localize && xcopy /E /Y dist\\en\\* dist\\ && rmdir /S /Q dist\\en"
}, },
@ -47,13 +45,12 @@
"@ngrx/entity": "^9.2.0", "@ngrx/entity": "^9.2.0",
"@ngrx/store": "^9.2.0", "@ngrx/store": "^9.2.0",
"@ngrx/store-devtools": "^9.2.0", "@ngrx/store-devtools": "^9.2.0",
"@stripe/stripe-js": "1.46.0",
"angular-resizable-element": "^3.3.2", "angular-resizable-element": "^3.3.2",
"angular-svg-icon": "^7.2.1", "angular-svg-icon": "^7.2.1",
"chart.js": "^2.9.3", "chart.js": "^2.9.3",
"classlist.js": "^1.1.20150312", "classlist.js": "^1.1.20150312",
"clone-deep": "^4.0.0", "clone-deep": "^4.0.0",
"esri-leaflet": "3.0.10", "esri-leaflet": "^3.0.1",
"file-saver": "^1.3.8", "file-saver": "^1.3.8",
"geodesy": "^1.1.3", "geodesy": "^1.1.3",
"intl": "^1.2.5", "intl": "^1.2.5",
@ -73,41 +70,32 @@
"@angular/compiler-cli": "9.1.13", "@angular/compiler-cli": "9.1.13",
"@angular/language-service": "9.1.13", "@angular/language-service": "9.1.13",
"@locl/cli": "^1.0.0", "@locl/cli": "^1.0.0",
"@types/esri-leaflet": "2.1.9", "@types/esri-leaflet": "^2.1.6",
"@types/file-saver": "^1.3.1", "@types/file-saver": "^1.3.1",
"@types/geodesy": "^1.1.3", "@types/geodesy": "^1.1.3",
"@types/jasmine": "^2.8.16", "@types/jasmine": "^2.8.16",
"@types/jasminewd2": "2.0.3", "@types/jasminewd2": "2.0.3",
"@types/leaflet": "1.9.4", "@types/leaflet": "^1.5.17",
"@types/leaflet-draw": "^0.4.14", "@types/leaflet-draw": "^0.4.14",
"@types/node": "12.12.29", "@types/node": "12.11.1",
"ajv": "6.12.2", "ajv": "6.12.2",
"codelyzer": "5.2.1", "codelyzer": "5.1.2",
"jasmine-core": "4.6.0", "jasmine-core": "3.5.0",
"jasmine-spec-reporter": "4.2.1", "jasmine-spec-reporter": "4.2.1",
"karma": "4.4.1", "karma": "4.4.1",
"karma-chrome-launcher": "3.1.0", "karma-chrome-launcher": "2.2.0",
"karma-cli": "2.0.0", "karma-cli": "1.0.1",
"karma-coverage-istanbul-reporter": "2.1.1", "karma-coverage-istanbul-reporter": "2.0.4",
"karma-jasmine": "2.0.1", "karma-jasmine": "2.0.1",
"karma-jasmine-html-reporter": "1.5.2", "karma-jasmine-html-reporter": "1.4.2",
"ngx-i18nsupport": "^0.17.1", "protractor": "5.4.1",
"protractor": "5.4.3",
"rxjs-tslint": "0.1.8", "rxjs-tslint": "0.1.8",
"ts-node": "8.3.0", "ts-node": "7.0.1",
"tslint": "5.20.1", "tslint": "5.20.1",
"typescript": "3.8.3" "typescript": "3.8.3"
}, },
"resolutions": { "resolutions": {
"serialize-javascript": "^2.1.1", "serialize-javascript": "^2.1.1",
"tree-kill": "^1.2.2" "tree-kill": "^1.2.2"
},
"overrides": {
"@locl/cli": {
"@angular/compiler": "9.1.13",
"@angular/core": "9.1.13",
"@angular/localize": "9.1.13"
},
"websocket-driver": "0.7.3"
} }
} }

View File

@ -1,18 +1,18 @@
{ {
"/api/*": { "/api/*": {
"target": "https://127.0.0.1:4100", "target": "https://127.0.0.1:4000",
"secure": false, "secure": false,
"changeOrigin": false, "changeOrigin": false,
"logLevel": "debug" "logLevel": "debug"
}, },
"/uploads/*": { "/uploads/*": {
"target": "https://127.0.0.1:4100", "target": "https://127.0.0.1:4000",
"secure": false, "secure": false,
"changeOrigin": false, "changeOrigin": false,
"logLevel": "debug" "logLevel": "debug"
}, },
"/es/uploads/*": { "/es/uploads/*": {
"target": "https://127.0.0.1:4100", "target": "https://127.0.0.1:4000",
"pathRewrite": { "pathRewrite": {
"/es/uploads/": "/uploads/" "/es/uploads/": "/uploads/"
}, },
@ -21,7 +21,7 @@
"logLevel": "debug" "logLevel": "debug"
}, },
"/pt/uploads/*": { "/pt/uploads/*": {
"target": "https://127.0.0.1:4100", "target": "https://127.0.0.1:4000",
"pathRewrite": { "pathRewrite": {
"/pt/uploads/": "/uploads/" "/pt/uploads/": "/uploads/"
}, },

View File

@ -1,410 +0,0 @@
/* Satloc Integration Styles */
.satloc-integration-fields {
margin-top: 15px;
padding: 15px;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.satloc-integration-fields .ui-g {
width: 100%;
}
.satloc-integration-fields .form-row {
margin-bottom: 15px;
min-height: 60px;
/* Prevent jumping when validation messages appear */
}
.satloc-integration-fields .form-row input {
width: 100%;
box-sizing: border-box;
}
.satloc-integration-fields .form-row label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #495057;
min-height: 20px;
/* Consistent label height */
}
.satloc-integration-fields .ui-message {
margin-top: 5px;
min-height: 20px;
/* Consistent error message height */
}
.satloc-connection-status {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
background-color: #ffffff;
border: 1px solid #dee2e6;
}
.connection-loading {
display: flex;
align-items: center;
color: #0c5460;
margin-bottom: 10px;
}
.connection-error {
margin-bottom: 10px;
}
.connection-success {
margin-bottom: 10px;
}
.connection-details {
margin-top: 5px;
}
.connection-details small {
color: #6c757d;
font-size: 0.875rem;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
font-size: 0.875rem;
}
.status-badge i {
margin-right: 5px;
}
.status-active {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-inactive {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* ============================================================================
FORM FIELD SPACING AND LAYOUT - UX AUDIT COMPLIANCE
============================================================================ */
/* Consistent form field structure */
.form-row {
margin-bottom: 24px;
/* Increased from 15px for better visual separation */
display: flex;
flex-direction: column;
}
/* Standardized field labels */
.field-label {
display: flex;
align-items: center;
margin-bottom: 8px;
/* Consistent spacing between label and input */
font-weight: 500;
color: #495057;
font-size: 14px;
line-height: 1.4;
/* UX audit recommendation for readability */
}
/* Inline constraint message (beside label) */
.field-label .inline-constraint {
margin-left: 6px;
/* Small gap between label text and icon */
}
/* Override constraint wrapper width for inline display */
.field-label .inline-constraint ::ng-deep .agm-constraint-wrapper {
display: inline-block;
width: auto;
/* Override default 100% width */
vertical-align: middle;
}
/* Standardized field input containers */
.field-input {
margin-bottom: 8px;
/* Space between input and any messages */
}
/* Standardized message spacing */
.field-message {
margin-top: 8px !important;
/* Override inline styles for consistency */
}
/* Test Connection Section Specific Styling */
.test-connection-section {
padding-top: 16px;
border-top: 1px solid #e9ecef;
/* Visual separator */
}
.test-connection-controls {
display: flex;
align-items: center;
gap: 12px;
/* Consistent spacing between button and status indicators */
margin-bottom: 8px;
}
/* Loading indicator standardization */
.loading-indicator {
margin-top: 8px;
font-size: 12px;
color: #666;
display: flex;
align-items: center;
gap: 6px;
}
/* Responsive spacing adjustments */
@media (max-width: 768px) {
.form-row {
margin-bottom: 20px;
/* Slightly reduced for mobile */
}
.test-connection-section {
margin-top: 24px;
padding-top: 12px;
}
.test-connection-controls {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
/* Button spacing */
.p-button {
margin-right: 8px;
}
.p-button:last-child {
margin-right: 0;
}
/* Error message styling */
.p-error {
display: block;
margin-top: 5px;
color: #dc3545;
font-size: 0.875rem;
}
/* Loading spinner center alignment */
.text-center {
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.satloc-integration-fields {
padding: 10px;
}
.satloc-connection-status {
padding: 8px;
}
}
/* Connection Status Badge - Circular design similar to topbar-badge */
.connection-status-badge {
display: inline-block;
width: 24px;
height: 24px;
border-radius: 50%;
text-align: center;
line-height: 24px;
font-size: 12px;
border: 2px solid;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.connection-status-badge:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.connection-status-badge.success {
background-color: #28a745;
color: white;
border-color: #1e7e34;
}
.connection-status-badge.error {
background-color: #dc3545;
color: white;
border-color: #c82333;
}
.connection-status-badge.warning {
background-color: #ffc107;
color: #212529;
border-color: #e0a800;
}
/* ============================================================================ */
/* PHASE 3: SAVE CREDENTIALS DIALOG STYLES */
/* ============================================================================ */
/* Dialog content container */
.dialog-content {
padding: 1rem 0;
font-family: "Roboto", "Helvetica Neue", sans-serif;
color: #212121;
}
/* Success message styling */
.success-message {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
padding: 1rem;
background-color: #E8F5E9;
border-left: 4px solid #4CAF50;
border-radius: 3px;
font-size: 1rem;
color: #2E7D32;
}
.success-message i {
font-size: 1.5rem;
margin-right: 0.75rem;
color: #4CAF50;
}
/* Save prompt paragraph */
.dialog-content>p {
margin: 0 0 1.5rem 0;
font-size: 1rem;
line-height: 1.5;
color: #212121;
}
/* Button styling overrides for dialog footer */
::ng-deep .ui-dialog-footer .ui-button-secondary {
background-color: #757575;
border-color: #757575;
color: #ffffff;
transition: all 0.2s ease;
}
::ng-deep .ui-dialog-footer .ui-button-secondary:hover {
background-color: #616161;
border-color: #616161;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
::ng-deep .ui-dialog-footer .ui-button-success {
background-color: #4CAF50;
border-color: #4CAF50;
color: #ffffff;
transition: all 0.2s ease;
}
::ng-deep .ui-dialog-footer .ui-button-success:hover {
background-color: #2E7D32;
border-color: #2E7D32;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* Responsive adjustments for mobile */
@media (max-width: 768px) {
.dialog-content {
padding: 0.75rem 0;
}
.success-message {
padding: 0.75rem;
font-size: 0.9rem;
}
.success-message i {
font-size: 1.25rem;
margin-right: 0.5rem;
}
.dialog-content>p {
font-size: 0.9rem;
}
}
/* ============================================================================
* PHASE 4: POST-SAVE VALIDATION STYLING
* ============================================================================ */
/* Post-save validation message spacing */
.post-save-message {
margin-top: 16px;
margin-bottom: 12px;
}
/* Post-save validation progress indicator */
.validation-progress {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
margin-top: 16px;
background-color: #f5f5f5;
border-radius: 3px;
border-left: 4px solid #4CAF50;
/* AgMission primary green */
}
.validation-progress i {
font-size: 1.125rem;
color: #4CAF50;
/* AgMission primary green */
}
.validation-progress span {
font-size: 0.95rem;
color: #212121;
/* AgMission text color */
font-family: "Roboto", "Helvetica Neue", sans-serif;
}
/* Mobile responsive adjustments for post-save validation */
@media (max-width: 768px) {
.post-save-message {
margin-top: 12px;
margin-bottom: 8px;
}
.validation-progress {
padding: 10px 12px;
margin-top: 12px;
}
.validation-progress i {
font-size: 1rem;
}
.validation-progress span {
font-size: 0.875rem;
}
}

View File

@ -8,17 +8,10 @@
<user-profile-form formControlName="profile" [focusOnFirst]="isNew"></user-profile-form> <user-profile-form formControlName="profile" [focusOnFirst]="isNew"></user-profile-form>
</div> </div>
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row"> <div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<label for="accountType" class="field-label"> <span style="margin-right:12px">
<ng-container i18n="@@accountType">Account Type</ng-container> <ng-container i18n="@@accountType">Account Type</ng-container>:
<!-- Account Type Disabled Feedback - Icon inline with label (detached mode) --> </span>
<agm-constraint-message #accountTypeConstraint *ngIf="shouldShowAccountTypeDisabledMessage" <p-dropdown name="type" formControlName="kind" [options]="kinds" [style]="{'min-width': '120px'}">
[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"> <ng-template let-type pTemplate="item">
<span> <span>
<strong>{{ type.label }}</strong> <strong>{{ type.label }}</strong>
@ -26,151 +19,13 @@
</ng-template> </ng-template>
</p-dropdown> </p-dropdown>
</div> </div>
<div class="ui-g-12 ui-g-nopad form-row ui-fluid" style="padding-top: 0px">
<!-- Account Type message appears below dropdown (detached content) --> <agm-account-editor formControlName="account" [isNew]="isNew" [required]="true" [showActive]="true"></agm-account-editor>
<div *ngIf="shouldShowAccountTypeDisabledMessage" class="field-message">
<ng-container *ngTemplateOutlet="accountTypeConstraint?.detachedContentTemplate"></ng-container>
</div> </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"> <div class="ui-g-12 toolbar padtop1 ui-fluid">
<button pButton [disabled]="form.invalid" type="button" style="width:auto" <button pButton [disabled]="form.invalid" type="button" style="width:auto"
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save" [icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save" (click)="saveAccount(); false"></button>
(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>
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back"
(click)="goBack()" [label]="globals.back"></button>
</div> </div>
</div> </div>
</form> </form>

View File

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

View File

@ -7,9 +7,8 @@ import { User } from '../models/user.model';
import * as fromUsers from '../reducers'; import * as fromUsers from '../reducers';
import * as userActions from '../actions/account.actions'; import * as userActions from '../actions/account.actions';
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global'; import { RoleIds, globals } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component'; import { BaseComp } from '@app/shared/base/base.component';
import { Utils } from '@app/shared/utils';
@Component({ @Component({
@ -18,11 +17,8 @@ import { Utils } from '@app/shared/utils';
styleUrls: ['./account-list.component.css'] styleUrls: ['./account-list.component.css']
}) })
export class AccountListComponent extends BaseComp implements OnInit, OnDestroy { export class AccountListComponent extends BaseComp implements OnInit, OnDestroy {
readonly resolveFieldData = Utils.resolveFieldData;
readonly KIND = 'kind';
readonly ACTIVE = OperationalStatus.ACTIVE;
accounts: Array<User>; accounts: Array<User>;
isLoading: boolean;
currAcc: User; currAcc: User;
cols: any[]; cols: any[];
userFilter: string; userFilter: string;
@ -39,8 +35,8 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
this.cols = [ this.cols = [
{ field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains' }, { field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains' },
{ field: 'username', header: globals.userName, filtered: true, filterMatchMode: 'contains' }, { field: 'username', header: globals.userName, filtered: true, filterMatchMode: 'contains' },
{ field: this.KIND, header: $localize`:@@type:Type`, width: '10%' }, { field: 'kind', header: $localize`:@@type:Type`, width: '10%' },
{ field: this.ACTIVE, header: globals.active, width: '6%' }, { field: 'active', header: globals.active, width: '6%' },
{ field: 'phone', header: globals.phone + ' ' + $localize`:@@Num:N°`, width: '10%', filtered: true, filterMatchMode: 'contains' }, { field: 'phone', header: globals.phone + ' ' + $localize`:@@Num:N°`, width: '10%', filtered: true, filterMatchMode: 'contains' },
{ field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains' } { field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains' }
]; ];
@ -52,10 +48,11 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
ngOnInit() { ngOnInit() {
this.sub$ = this.store.select(fromUsers.getAllUsers).subscribe(users => this.accounts = users); 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( this.sub$.add(this.store.select(fromUsers.getSelectedUser).subscribe(
(acc) => this.currAcc = acc (acc) => this.currAcc = acc
)); ));
// Always fetch the fresh list of accounts
this.store.dispatch(new userActions.Fetch()); this.store.dispatch(new userActions.Fetch());
} }
@ -71,13 +68,6 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
return (this.currAcc && this.currAcc._id !== '0'); 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() { newAccount() {
this.router.navigate(['account', '0'], { relativeTo: this.route }); this.router.navigate(['account', '0'], { relativeTo: this.route });
} }
@ -88,19 +78,8 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
deleteAccount() { deleteAccount() {
if (!this.currAcc) { return; } 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({ this.confirmSvc.confirm({
header: header, message: globals.confirmDeleteThing.replace('#thing#', globals.account),
message: message,
acceptLabel: globals.yes,
rejectLabel: globals.no,
accept: () => { accept: () => {
this.store.dispatch(new userActions.Delete(this.currAcc)); this.store.dispatch(new userActions.Delete(this.currAcc));
this.currAcc = null; this.currAcc = null;

View File

@ -7,7 +7,6 @@ import { CheckboxModule } from 'primeng/checkbox';
import { AutoCompleteModule } from 'primeng/autocomplete'; import { AutoCompleteModule } from 'primeng/autocomplete';
import { ToolbarModule } from 'primeng/toolbar'; import { ToolbarModule } from 'primeng/toolbar';
import { InputSwitchModule } from 'primeng/inputswitch'; import { InputSwitchModule } from 'primeng/inputswitch';
import { TooltipModule } from 'primeng/tooltip';
import { TableModule } from 'primeng/table'; import { TableModule } from 'primeng/table';
import { CalendarModule } from 'primeng/calendar'; import { CalendarModule } from 'primeng/calendar';
@ -24,7 +23,7 @@ import { AccountEditComponent } from './account-edit/account-edit.component';
import { AccountsGuard } from './account.guard'; import { AccountsGuard } from './account.guard';
import { AccountEffects } from './effects/account.effects'; import { AccountEffects } from './effects/account.effects';
import { FEATURE_KEY, reducer } from './reducers/users.reducer'; import { FEATURE_KEY, reducer } from './reducers/users-reducer';
@NgModule({ @NgModule({
imports: [ imports: [
@ -38,7 +37,6 @@ import { FEATURE_KEY, reducer } from './reducers/users.reducer';
ToolbarModule, ToolbarModule,
SplitButtonModule, SplitButtonModule,
TableModule, TableModule,
TooltipModule,
StoreModule.forFeature(FEATURE_KEY, reducer), StoreModule.forFeature(FEATURE_KEY, reducer),
EffectsModule.forFeature([AccountEffects]), EffectsModule.forFeature([AccountEffects]),

View File

@ -22,12 +22,7 @@ export const CREATE = '[USERS] Create a user';
export class Create implements Action { export class Create implements Action {
type: typeof CREATE = CREATE; 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 const CREATE_SUCCESS = '[USERS] Create user success';
export class CreateSuccess implements Action { export class CreateSuccess implements Action {
@ -44,12 +39,7 @@ export const UPDATE = '[USERS] Update user';
export class Update implements Action { export class Update implements Action {
type: typeof UPDATE = UPDATE; 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 const UPDATE_SUCCESS = '[USERS] Update user success';
export class UpdateSuccess implements Action { export class UpdateSuccess implements Action {

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects'; import { Actions, Effect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { map, switchMap, catchError, repeat } from 'rxjs/operators'; import { map, switchMap, catchError } from 'rxjs/operators';
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
@ -9,9 +9,7 @@ import * as userActions from '../actions/account.actions';
import { UserService } from '@app/domain/services/user.service'; import { UserService } from '@app/domain/services/user.service';
import { AuthService } from '@app/domain/services/auth.service'; import { AuthService } from '@app/domain/services/auth.service';
import { AppMessageService } from '@app/shared/app-message.service'; import { AppMessageService } from '@app/shared/app-message.service';
import { PartnerService } from '@app/partners/services/partner.service'; import { globals } from '@app/shared/global';
import { PartnerSystemUser } from '@app/accounts/models/user.model';
import { RoleIds, globals, KnownPartnerCodes } from '@app/shared/global';
@Injectable() @Injectable()
export class AccountEffects { export class AccountEffects {
@ -19,8 +17,7 @@ export class AccountEffects {
private readonly actions$: Actions, private readonly actions$: Actions,
private readonly userSvc: UserService, private readonly userSvc: UserService,
private readonly authSvc: AuthService, private readonly authSvc: AuthService,
private readonly msgSvc: AppMessageService, private readonly msgSvc: AppMessageService
private readonly partnerSvc: PartnerService
) { ) {
} }
@ -28,250 +25,55 @@ export class AccountEffects {
loadUsers$: Observable<Action> = this.actions$.pipe( loadUsers$: Observable<Action> = this.actions$.pipe(
ofType<userActions.Fetch>(userActions.FETCH), ofType<userActions.Fetch>(userActions.FETCH),
switchMap(() => 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( this.userSvc.loadUsers({ byPuid: this.authSvc.user.parent }).pipe(
map(users => new userActions.FetchSuccess(users)) map(users => new userActions.FetchSuccess(users)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.accounts));
return of(new userActions.FetchError());
})
)
) )
),
catchError(err => this.handleUserOperationError(err, 'load')),
repeat()
); );
@Effect() @Effect()
createUser$: Observable<Action> = this.actions$.pipe( createUser$: Observable<Action> = this.actions$.pipe(
ofType<userActions.Create>(userActions.CREATE), ofType<userActions.Create>(userActions.CREATE),
switchMap(({ payload }) => { switchMap(({ payload }) =>
// Extract user data and partner config from payload this.userSvc.saveUser(payload).pipe(
const { partnerConfig, ...userData } = payload; map((user) => new userActions.CreateSuccess(user)),
catchError(err => {
// For partner system users, create them directly through PartnerService this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.account));
if (partnerConfig && partnerConfig.vendorSystemType) { return of(new userActions.CreateFailed())
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() @Effect()
updateUser$: Observable<Action> = this.actions$.pipe( updateUser$: Observable<Action> = this.actions$.pipe(
ofType<userActions.Update>(userActions.UPDATE), ofType<userActions.Update>(userActions.UPDATE),
switchMap(({ payload }) => { switchMap(({ payload }) =>
// Extract user data and partner config from payload this.userSvc.saveUser(payload).pipe(
const { partnerConfig, ...userData } = payload; map(() => new userActions.UpdateSuccess(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 => { catchError(err => {
console.error('Partner cleanup failed:', err); this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.account));
// User update succeeded, cleanup failed is not critical return of(new userActions.UpdateFailed());
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() @Effect()
deleteUser$: Observable<Action> = this.actions$.pipe( deleteUser$: Observable<Action> = this.actions$.pipe(
ofType<userActions.Delete>(userActions.DELETE), ofType<userActions.Delete>(userActions.DELETE),
switchMap(({ payload }) => { switchMap(({ payload }) =>
// Check if the user is a PARTNER_SYSTEM_USER this.userSvc.deleteUser(payload).pipe(
if (payload.kind === RoleIds.PARTNER_SYSTEM_USER) { map(() => new userActions.DeleteSuccess(payload)),
// Backend only disables partner system users (sets active=false), it does NOT remove them. catchError(err => {
// Dispatch UpdateSuccess so the store reflects the disabled state in-place rather than this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.account));
// removing the row — which would cause it to reappear on the next reload. return of(new userActions.UpdateFailed())
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());
}
}
}

View File

@ -1,5 +1,4 @@
import { Address } from '@app/domain/models/subscription.model'; import { RoleIds } from '@app/shared/global';
import { RoleIds, OperationalStatusType } from '@app/shared/global';
interface RoleArray { interface RoleArray {
[index: number]: string; [index: number]: string;
@ -10,105 +9,23 @@ export interface User {
username?: string; username?: string;
password?: string; password?: string;
name?: string; name?: string;
address?: string | null; address?: string;
country?: string; country?: string;
phone?: string | null; Country?: any;
email?: string | null; phone?: string;
email?: string;
kind: string; kind: string;
roles?: RoleArray; roles?: RoleArray;
active?: boolean; active?: boolean;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date;
parent?: any; 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) => { export const createNewUser = (parentId?: string, kind: String = RoleIds.APP_ADM) => {
const user = <User>{ const user = <User>{
_id: '0', _id: '0',
kind: kind, kind: kind,
active: kind == RoleIds.DEVICE ? false : true, active: true,
parent: parentId parent: parentId
}; };
return user; return user;

View File

@ -3,7 +3,7 @@ import {
createFeatureSelector, createFeatureSelector,
} from '@ngrx/store'; } from '@ngrx/store';
import * as fromUsers from './users.reducer'; import * as fromUsers from './users-reducer';
/** /**
* The createFeatureSelector function selects a piece of state from the root of the state object. * The createFeatureSelector function selects a piece of state from the root of the state object.

View File

@ -28,9 +28,6 @@ export function reducer(
switch (action.type) { switch (action.type) {
case actions.FETCH: 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.CREATE:
case actions.UPDATE: case actions.UPDATE:
case actions.DELETE: case actions.DELETE:

View File

@ -1,32 +0,0 @@
import { Plan, Status } from "@app/domain/models/subscription.model";
import { Action } from "@ngrx/store";
export const FETCH_SUB_PLANS = '[SUB_PLANS] Fetch subscription plans';
export class FetchSubPlans implements Action {
type: typeof FETCH_SUB_PLANS = FETCH_SUB_PLANS;
constructor() { }
}
export const FETCH_SUB_PLANS_SUCCESS = '[SUB_PLANS] Fetch subscription plans success';
export class FetchSubPlansSuccess implements Action {
type: typeof FETCH_SUB_PLANS_SUCCESS = FETCH_SUB_PLANS_SUCCESS;
constructor(readonly payload: Plan) { }
}
export const FETCH_SUB_PLANS_FAILED = '[SUB_PLANS] Fetch subscription plans failed';
export class FetchSubPlansFailed implements Action {
type: typeof FETCH_SUB_PLANS_FAILED = FETCH_SUB_PLANS_FAILED;
constructor(readonly payload: Status) { }
}
export const RESET_SUB_PLANS = '[SUB_PLANS] Reset subscription plans';
export class ResetSubPlans implements Action {
type: typeof RESET_SUB_PLANS = RESET_SUB_PLANS;
constructor() { }
}
export type SubPlansAction =
| FetchSubPlans
| FetchSubPlansSuccess
| FetchSubPlansFailed
| ResetSubPlans

View File

@ -1,561 +0,0 @@
import { UserModel } from '@app/auth/models/user.model';
import { BillingInfo, Card, Invoice, StripeSubscription, SubscriptionPackage, RefreshPackage, CreatePaymentMethodPackage, Status, UnpaidPackage, PastDue, Unpaid, SubscriptionIntent, Incomplete, ConfirmPackage, PaidAmount, Coupon, TrialPmtPkg, Trial, PaymentMethod, PMPkgEdit, PMPkgAdd, Plan } from '@app/domain/models/subscription.model';
import { Mode } from '@app/profile/common';
import { Action } from '@ngrx/store';
// shared actions
export const GOTO_MY_SERVICES = '[SUBSCRIPTION Nav] Navigate to my services page';
export class GotoMyServices implements Action {
type: typeof GOTO_MY_SERVICES = GOTO_MY_SERVICES;
}
export const GOTO_PAYMENT_HISTORY = '[SUBSCRIPTION Nav] Navigate to payment history page';
export class GotoPaymentHistory implements Action {
type: typeof GOTO_PAYMENT_HISTORY = GOTO_PAYMENT_HISTORY;
}
export const GOTO_PAYMENT_DETAIL = '[SUBSCRIPTION Nav] Navigate to payment detail page';
export class GotoPaymentDetail implements Action {
type: typeof GOTO_PAYMENT_DETAIL = GOTO_PAYMENT_DETAIL;
constructor(readonly payload: { paymentId: string }) { }
}
export const GOTO_SERVICES = '[SUBSCRIPTION Nav] Navigate to services page';
export class GotoServices implements Action {
type: typeof GOTO_SERVICES = GOTO_SERVICES;
}
export const GOTO_BILLING_ADDRESS = '[SUBSCRIPTION Nav] Navigate to billing address page';
export class GotoBillingAddress implements Action {
type: typeof GOTO_BILLING_ADDRESS = GOTO_BILLING_ADDRESS;
}
export const GOTO_CHECK_OUT = '[SUBSCRIPTION Nav] Navigate to checkout page';
export class GotoCheckout implements Action {
type: typeof GOTO_CHECK_OUT = GOTO_CHECK_OUT;
}
export const GOTO_CHECK_OUT_REVIEW = '[SUBSCRIPTION Nav] Navigate to checkout review page';
export class GotoCheckoutReview implements Action {
type: typeof GOTO_CHECK_OUT_REVIEW = GOTO_CHECK_OUT_REVIEW;
}
export const GOTO_CHECK_OUT_CONFIRM = '[SUBSCRIPTION Nav] Navigate to checkout confirm page';
export class GotoCheckoutConfirm implements Action {
type: typeof GOTO_CHECK_OUT_CONFIRM = GOTO_CHECK_OUT_CONFIRM;
}
export const GOTO_HOME = '[SUBSCRIPTION Nav] Navigate to home page';
export class GotoHome implements Action {
type: typeof GOTO_HOME = GOTO_HOME;
}
export const GOTO_USAGE_DETAIL = '[SUBSCRIPTION Nav] Navigate to usage details';
export class GotoUsageDetail implements Action {
type: typeof GOTO_USAGE_DETAIL = GOTO_USAGE_DETAIL;
}
export const GOTO_AIRCRAFT_LIST = '[SUBSCRIPTION Nav] Navigate to aircraft list';
export class GotoAircraftList implements Action {
type: typeof GOTO_AIRCRAFT_LIST = GOTO_AIRCRAFT_LIST;
}
export const COMPOUND = '[SUBSCRIPTION Compound] Execute multiple actions sequentially';
export class Compound implements Action {
type: typeof COMPOUND = COMPOUND;
constructor(readonly payload: Action[]) { }
}
// In session actions
export const INIT_SUBSCRIPTION = '[SUBSCRIPTION Manage subscription] Initialize subscriptions contents';
export class InitSubscription implements Action {
type: typeof INIT_SUBSCRIPTION = INIT_SUBSCRIPTION;
constructor(readonly payload: { custId: string }) { }
}
export const START_MY_SERVICES = '[SUBSCRIPTION Making payment intent session] Initialize my services stage';
export class StartMyServices implements Action {
type: typeof START_MY_SERVICES = START_MY_SERVICES;
constructor(readonly payload: { custId: string }) { }
}
export const CREATE_NEW_BILLING = '[SUBSCRIPTION Making payment intent session] Initialize subscription intent with a new account';
export class CreateNewBilling implements Action {
type: typeof CREATE_NEW_BILLING = CREATE_NEW_BILLING;
constructor(readonly payload: SubscriptionIntent) { }
}
export const START_BILLING_INFO = '[SUBSCRIPTION Making payment intent session] Start billing info stage';
export class StartBillingInfo implements Action {
type: typeof START_BILLING_INFO = START_BILLING_INFO;
constructor(readonly payload: any) { }
}
export const START_BILLING_INFO_SUCCESS = '[SUBSCRIPTION Making payment intent session] Start billing info stage, success';
export class StartBillingInfoSuccess implements Action {
type: typeof START_BILLING_INFO_SUCCESS = START_BILLING_INFO_SUCCESS;
constructor(readonly payload: SubscriptionIntent) { }
}
export const CREATE_SUBSCRIPTION_INTENT_FAILED = '[SUBSCRIPTION Making payment intent session] Create subscription intent failed';
export class CreateSubscriptionIntentFailed implements Action {
type: typeof CREATE_SUBSCRIPTION_INTENT_FAILED = CREATE_SUBSCRIPTION_INTENT_FAILED;
constructor(readonly payload: Status) { }
}
export const START_CHECKOUT = '[SUBSCRIPTION Making payment intent session] Start checkout stage';
export class StartCheckout implements Action {
type: typeof START_CHECKOUT = START_CHECKOUT;
constructor(readonly payload: { billingInfo: BillingInfo; subIntentPkg: SubscriptionIntent }) { }
}
export const START_CHECKOUT_SUCCESS = '[SUBSCRIPTION Making payment intent session] Start checkout stage success';
export class StartCheckoutSuccess implements Action {
type: typeof START_CHECKOUT_SUCCESS = START_CHECKOUT_SUCCESS;
constructor(readonly payload: SubscriptionIntent) { }
}
export const UPDATE_BILLING_ADDRESS_SUCCESS = '[SUBSCRIPTION Making payment intent session] Update billing address success';
export class UpdateBillingAddressSuccess implements Action {
type: typeof UPDATE_BILLING_ADDRESS_SUCCESS = UPDATE_BILLING_ADDRESS_SUCCESS;
constructor(readonly payload: BillingInfo) { }
}
export const CREATE_PAYMENT_METHOD = '[SUBSCRIPTION Making payment intent session] Create payment method';
export class CreatePaymentMethod implements Action {
type: typeof CREATE_PAYMENT_METHOD = CREATE_PAYMENT_METHOD;
constructor(readonly payload: CreatePaymentMethodPackage) { }
}
export const CREATE_PAYMENT_METHOD_FAILED = '[SUBSCRIPTION Making payment intent session] Create payment method failed';
export class CreatePaymentMethodFailed implements Action {
type: typeof CREATE_PAYMENT_METHOD_FAILED = CREATE_PAYMENT_METHOD_FAILED;
constructor(readonly payload: Status) { }
}
export const CHECK_OUT = '[SUBSCRIPTION Making payment intent session] Checkout';
export class Checkout implements Action {
type: typeof CHECK_OUT = CHECK_OUT;
constructor(readonly payload: Card) { }
}
export const UPDATE_SUBSCRIPTION = '[SUBSCRIPTION Making payment intent session] Update subscription';
export class UpdateSubscription implements Action {
type: typeof UPDATE_SUBSCRIPTION = UPDATE_SUBSCRIPTION;
constructor(readonly payload: SubscriptionPackage) { }
}
export const CANCEL_SUBSCRIPTION = '[SUBSCRIPTION Making payment intent session] Cancel subscription attempt';
export class CancelSubscription implements Action {
type: typeof CANCEL_SUBSCRIPTION = CANCEL_SUBSCRIPTION;
constructor(readonly payload: SubscriptionIntent) { }
}
export const SET_SUBSCRIPTION_INTENT_PREV_STAGE = '[SUBSCRIPTION Making payment intent session] Set subscription intent previous stage';
export class SetSubscriptionIntentPrevStage implements Action {
type: typeof SET_SUBSCRIPTION_INTENT_PREV_STAGE = SET_SUBSCRIPTION_INTENT_PREV_STAGE;
constructor(readonly payload: string) { }
}
export const UPDATE_SUBSCRIPTION_INTENT_STATUS = '[SUBSCRIPTION Making payment intent session] Update subscription intent status';
export class UpdateSubscriptionIntentStatus implements Action {
type: typeof UPDATE_SUBSCRIPTION_INTENT_STATUS = UPDATE_SUBSCRIPTION_INTENT_STATUS;
constructor(readonly payload: Status) { }
}
export const CLEAR_SUBSCRIPTION_INTENT_STATUS = '[SUBSCRIPTION Making payment intent session] Clear subscription intent status';
export class ClearSubscriptionIntentStatus implements Action {
type: typeof CLEAR_SUBSCRIPTION_INTENT_STATUS = CLEAR_SUBSCRIPTION_INTENT_STATUS;
constructor() { }
}
export const RESET_SUBSCRIPTION_INTENT = '[SUBSCRIPTION Making payment intent session] Reset subscription intent to initial state';
export class ResetSubscriptionIntent implements Action {
type: typeof RESET_SUBSCRIPTION_INTENT = RESET_SUBSCRIPTION_INTENT;
}
export const CLEAR_PREV_STAGE = '[SUBSCRIPTION Making payment intent session] Set up intent default subIntent stage';
export class ClearPrevStage implements Action {
type: typeof CLEAR_PREV_STAGE = CLEAR_PREV_STAGE;
}
export const UPDATE_AMOUNT = '[SUBSCRIPTION Making payment intent session] Update payment amount';
export class UpdateAmount implements Action {
type: typeof UPDATE_AMOUNT = UPDATE_AMOUNT;
constructor(readonly payload: PaidAmount) { }
}
export const UPDATE_PROMO_SAVINGS = '[SUBSCRIPTION Making payment intent session] Update promo savings';
export class UpdatePromoSavings implements Action {
type: typeof UPDATE_PROMO_SAVINGS = UPDATE_PROMO_SAVINGS;
constructor(readonly payload: number) { }
}
// Resolving payment session actions
export const FETCH_LATEST_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription';
export class FetchLatestSubscription implements Action {
type: typeof FETCH_LATEST_SUBSCRIPTION = FETCH_LATEST_SUBSCRIPTION;
constructor(readonly payload: { custId: string }) { }
}
export const FETCH_LATEST_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription success';
export class FetchLatestSubscriptionSuccess implements Action {
type: typeof FETCH_LATEST_SUBSCRIPTION_SUCCESS = FETCH_LATEST_SUBSCRIPTION_SUCCESS;
constructor(readonly payload: Plan) { }
}
export const POLL_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Poll for unpaid subscription';
export class PollUnpaidSubscription implements Action {
type: typeof POLL_UNPAID_SUBSCRIPTION = POLL_UNPAID_SUBSCRIPTION;
constructor(readonly payload: { custId: string }) { }
}
export const POLL_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Poll for unpaid success';
export class PollUnpaidSubscriptionSuccess implements Action {
type: typeof POLL_UNPAID_SUBSCRIPTION_SUCCESS = POLL_UNPAID_SUBSCRIPTION_SUCCESS;
constructor(readonly payload: Plan) { }
}
export const CANCEL_POLL_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Cancel polling for latest subscriptions';
export class CancelPollSubscription implements Action {
type: typeof CANCEL_POLL_SUBSCRIPTION = CANCEL_POLL_SUBSCRIPTION;
constructor() { }
}
export const RESET_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Reset subscription to initial state';
export class ResetSubscription implements Action {
type: typeof RESET_SUBSCRIPTION = RESET_SUBSCRIPTION;
}
export const REFRESH_SUBSCRIPTION_INTENT = '[SUBSCRIPTION Resolving payment session] Refesh unresolved subscription';
export class RefeshSubscriptionIntent implements Action {
type: typeof REFRESH_SUBSCRIPTION_INTENT = REFRESH_SUBSCRIPTION_INTENT;
constructor(readonly payload: RefreshPackage) { }
}
export const REFRESH_SUBSCRIPTION_INTENT_SUCCESS = '[SUBSCRIPTION Resolving payment session] Refesh unresolved subscription success';
export class RefeshSubscriptionIntentSuccess implements Action {
type: typeof REFRESH_SUBSCRIPTION_INTENT_SUCCESS = REFRESH_SUBSCRIPTION_INTENT_SUCCESS;
constructor(readonly payload: SubscriptionIntent) { }
}
export const REQUIRE_ACTION = '[SUBSCRIPTION Resolving payment session] Require action on payment method';
export class RequireAction implements Action {
type: typeof REQUIRE_ACTION = REQUIRE_ACTION;
constructor(readonly payload: ConfirmPackage[]) { }
}
export const RESOLVE_PAYMENT = '[SUBSCRIPTION Resolving payment session] Resolve payment outstanding subsriptions';
export class ResolvePayment implements Action {
type: typeof RESOLVE_PAYMENT = RESOLVE_PAYMENT;
}
export const SHOW_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Show unpaid subscription list';
export class ShowUnpaidSubscription implements Action {
type: typeof SHOW_UNPAID_SUBSCRIPTION = SHOW_UNPAID_SUBSCRIPTION;
constructor() { }
}
export const RESUME_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch unpaid subscriptions';
export class ResumeUnpaidSubscription implements Action {
type: typeof RESUME_UNPAID_SUBSCRIPTION = RESUME_UNPAID_SUBSCRIPTION;
constructor(readonly payload: {
unpaidInvoices: Invoice[],
name: string,
authUser: UserModel
}) { }
}
export const RESUME_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Fetch unpaid subscriptions success';
export class ResumeUnpaidSubscriptionSuccess implements Action {
type: typeof RESUME_UNPAID_SUBSCRIPTION_SUCCESS = RESUME_UNPAID_SUBSCRIPTION_SUCCESS;
constructor(readonly payload: {
unpaid: Unpaid,
subscriptions: StripeSubscription[],
status: Status
}) { }
}
export const PAY_UNPAID_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Pay unpaid subscription';
export class PayUnpaidSubscription implements Action {
type: typeof PAY_UNPAID_SUBSCRIPTION = PAY_UNPAID_SUBSCRIPTION;
constructor(readonly payload: UnpaidPackage) { }
}
export const CONFIRM = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and pastdue payments';
export class Confirm implements Action {
type: typeof CONFIRM = CONFIRM;
constructor(readonly payload: ConfirmPackage) { }
}
export const COMPLETE_PAYMENT = '[SUBSCRIPTION Resolving payment session] Completed resolving unresolved invoices';
export class CompletePayment implements Action {
type: typeof COMPLETE_PAYMENT = COMPLETE_PAYMENT;
}
export const UPDATE_PAST_DUE = '[SUBSCRIPTION Resolving payment session] Retry past due payment';
export class UpdatePastDue implements Action {
type: typeof UPDATE_PAST_DUE = UPDATE_PAST_DUE;
constructor(readonly payload: PastDue) { }
}
export const UPDATE_INCOMPLETE = '[SUBSCRIPTION Resolving payment session] Retry incomplete Payment';
export class UpdateIncomplete implements Action {
type: typeof UPDATE_INCOMPLETE = UPDATE_INCOMPLETE;
constructor(readonly payload: Incomplete) { }
}
export const UPDATE_UNPAID = '[SUBSCRIPTION Resolving payment session] Update unpaid';
export class UpdateUnpaid implements Action {
type: typeof UPDATE_UNPAID = UPDATE_UNPAID;
constructor(readonly payload: Unpaid) { }
}
export const UPDATE_SUBSCRIPTION_STATUS = '[SUBSCRIPTION] Update subscriptionstatus';
export class UpdateSubscriptionStatus implements Action {
type: typeof UPDATE_SUBSCRIPTION_STATUS = UPDATE_SUBSCRIPTION_STATUS;
constructor(readonly payload: Status) { }
}
export const CLEAR_SUBSCRIPTION_STATUS = '[SUBSCRIPTION] Clear subscriptionstatus';
export class ClearSubscriptionStatus implements Action {
type: typeof CLEAR_SUBSCRIPTION_STATUS = CLEAR_SUBSCRIPTION_STATUS;
constructor() { }
}
export const CLEAR_SUBSCRIPTION = '[SUBSCRIPTION] Clear subscriptions';
export class ClearSubscription implements Action {
type: typeof CLEAR_SUBSCRIPTION = CLEAR_SUBSCRIPTION;
constructor() { }
}
export const LOAD_STRIPE = '[SUBSCRIPTION] load stripe api';
export class LoadStripe implements Action {
type: typeof LOAD_STRIPE = LOAD_STRIPE;
constructor() { }
}
export const LOAD_STRIPE_SUCCESS = '[SUBSCRIPTION] load stripe api success';
export class LoadStripeSuccess implements Action {
type: typeof LOAD_STRIPE_SUCCESS = LOAD_STRIPE_SUCCESS;
constructor() { }
}
export const LOAD_STRIPE_FAILED = '[SUBSCRIPTION] load stripe api failed';
export class LoadStripeFailed implements Action {
type: typeof LOAD_STRIPE_FAILED = LOAD_STRIPE_FAILED;
constructor(readonly payload: Status) { }
}
export const APPLY_DISCOUNT_PREVIEW = '[SUBSCRIPTION] Apply discount preview';
export class ApplyDiscountPreview implements Action {
type: typeof APPLY_DISCOUNT_PREVIEW = APPLY_DISCOUNT_PREVIEW;
constructor(readonly payload: { subIntentPkg: SubscriptionIntent, coupon?: string }) { }
}
export const APPLY_DISCOUNT_PREVIEW_SUCCESS = '[SUBSCRIPTION] Apply discount preview success';
export class ApplyDiscountPreviewSuccess implements Action {
type: typeof APPLY_DISCOUNT_PREVIEW_SUCCESS = APPLY_DISCOUNT_PREVIEW_SUCCESS;
constructor(readonly payload: { coupons: Coupon[], amount: PaidAmount }) { }
}
export const APPLY_DISCOUNT_PREVIEW_FAILED = '[SUBSCRIPTION] Apply discount preview failed';
export class ApplyDiscountPreviewFailed implements Action {
type: typeof APPLY_DISCOUNT_PREVIEW_FAILED = APPLY_DISCOUNT_PREVIEW_FAILED;
constructor(readonly payload: Status) { }
}
// Payment method actions
export const FETCH_PAYMENT_METHOD_LIST = '[SUBSCRIPTION] Fetch payment methods';
export class FetchPaymentMethodList implements Action {
type: typeof FETCH_PAYMENT_METHOD_LIST = FETCH_PAYMENT_METHOD_LIST;
}
export const FETCH_PAYMENT_METHOD_LIST_SUCCESS = '[SUBSCRIPTION] Fetch payment methods success';
export class FetchPaymentMethodListSuccess implements Action {
type: typeof FETCH_PAYMENT_METHOD_LIST_SUCCESS = FETCH_PAYMENT_METHOD_LIST_SUCCESS;
constructor(readonly payload: { paymentMethods: PaymentMethod[] }) { }
}
export const FETCH_DEFAULT_PM = '[SUBSCRIPTION] Fetch default payment methods';
export class FetchDefaultPm implements Action {
type: typeof FETCH_DEFAULT_PM = FETCH_DEFAULT_PM;
}
export const FETCH_DEFAULT_PM_SUCCESS = '[SUBSCRIPTION] Fetch default payment methods success';
export class FetchDefaultPmSuccess implements Action {
type: typeof FETCH_DEFAULT_PM_SUCCESS = FETCH_DEFAULT_PM_SUCCESS;
constructor(readonly payload: { defPM: PaymentMethod }) { }
}
export const EDIT_PM = '[SUBSCRIPTION] Edit payment method';
export class EditPM implements Action {
type: typeof EDIT_PM = EDIT_PM;
constructor(readonly payload: PMPkgEdit) { }
}
export const EDIT_PM_SUCCESS = '[SUBSCRIPTION] Edit payment method success';
export class EditPMSuccess implements Action {
type: typeof EDIT_PM_SUCCESS = EDIT_PM_SUCCESS;
constructor(readonly payload: PaymentMethod) { }
}
export const ADD_PM = '[SUBSCRIPTION] Add payment method';
export class AddPM implements Action {
type: typeof ADD_PM = ADD_PM;
constructor(readonly payload: PMPkgAdd) { }
}
export const ADD_PM_SUCCESS = '[SUBSCRIPTION] Add payment method success';
export class AddPMSuccess implements Action {
type: typeof ADD_PM_SUCCESS = ADD_PM_SUCCESS;
constructor(readonly payload: PaymentMethod) { }
}
export const DELETE_PM = '[SUBSCRIPTION] Delete payment method';
export class DeletePM implements Action {
type: typeof DELETE_PM = DELETE_PM;
constructor(readonly payload: string) { }
}
export const DELETE_PM_SUCCESS = '[SUBSCRIPTION] Delete payment method success';
export class DeletePMSuccess implements Action {
type: typeof DELETE_PM_SUCCESS = DELETE_PM_SUCCESS;
constructor(readonly payload: string) { }
}
export const CHANGE_PM = '[SUBSCRIPTION] Change default payment method';
export class ChangePM implements Action {
type: typeof CHANGE_PM = CHANGE_PM;
constructor(readonly payload: { custId: string, pmId: string }) { }
}
export const CHANGE_PM_SUCCESS = '[SUBSCRIPTION] Change default payment method success';
export class ChangePMSuccess implements Action {
type: typeof CHANGE_PM_SUCCESS = CHANGE_PM_SUCCESS;
constructor(readonly payload: { defPM: PaymentMethod }) { }
}
// Payment success
export const CONFIRM_ACTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and require action success';
export class ConfirmActionSuccess implements Action {
type: typeof CONFIRM_ACTION_SUCCESS = CONFIRM_ACTION_SUCCESS;
constructor(readonly payload: Plan) { }
}
export const CONFIRM_PAYMENT_SUCCESS = '[SUBSCRIPTION Resolving payment session] Confirm card payment for incomplete and require payment method success';
export class ConfirmPaymentSuccess implements Action {
type: typeof CONFIRM_PAYMENT_SUCCESS = CONFIRM_PAYMENT_SUCCESS;
constructor(readonly payload: Plan) { }
}
export const PAY_UNPAID_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Resolving payment session] Pay unpaid subscription success';
export class PayUnpaidSubscriptionSuccess implements Action {
type: typeof PAY_UNPAID_SUBSCRIPTION_SUCCESS = PAY_UNPAID_SUBSCRIPTION_SUCCESS;
constructor(readonly payload: Plan) { }
}
export const UPDATE_SUBSCRIPTION_SUCCESS = '[SUBSCRIPTION Making payment intent session] Update subscription success';
export class UpdateSubscriptionSuccess implements Action {
type: typeof UPDATE_SUBSCRIPTION_SUCCESS = UPDATE_SUBSCRIPTION_SUCCESS;
constructor(readonly payload: Plan) { }
}
// trial subscription section
export const SET_TRIAL_MODE = '[SUBSCRIPTION Making payment intent session] Starting a trial subscription';
export class SetMode implements Action {
type: typeof SET_TRIAL_MODE = SET_TRIAL_MODE;
constructor(readonly payload: Mode) { }
}
export const UPDATE_TRIAL = '[SUBSCRIPTION] Update trials object';
export class UpdateTrial implements Action {
type: typeof UPDATE_TRIAL = UPDATE_TRIAL;
constructor(readonly payload: Trial) { }
}
export const CHECK_OUT_TRIAL = '[SUBSCRIPTION Making payment intent session] Checkout a trial subscription';
export class CheckoutTrial implements Action {
type: typeof CHECK_OUT_TRIAL = CHECK_OUT_TRIAL;
constructor(readonly payload: TrialPmtPkg) { }
}
export const CHECK_OUT_TRIAL_SUCCESS = '[SUBSCRIPTION Making payment intent session] Checkout a trial subscription success';
export class CheckoutTrialSuccess implements Action {
type: typeof CHECK_OUT_TRIAL_SUCCESS = CHECK_OUT_TRIAL_SUCCESS;
constructor(readonly payload: { card?: Card, subs: StripeSubscription[], amount?: PaidAmount }) { }
}
export type SubscriptionIntentAction =
| CompletePayment
| ConfirmActionSuccess
| ConfirmPaymentSuccess
| Checkout
| StartBillingInfo
| StartBillingInfoSuccess
| CreateSubscriptionIntentFailed
| CreatePaymentMethodFailed
| ClearSubscriptionIntentStatus
| GotoBillingAddress
| GotoCheckout
| GotoCheckoutReview
| GotoServices
| PayUnpaidSubscriptionSuccess
| RefeshSubscriptionIntent
| RefeshSubscriptionIntentSuccess
| ResetSubscriptionIntent
| SetSubscriptionIntentPrevStage
| UpdateSubscriptionIntentStatus
| StartCheckout
| StartCheckoutSuccess
| UpdateBillingAddressSuccess
| UpdateSubscriptionSuccess
| UpdateAmount
| UpdatePromoSavings
| ClearPrevStage
| GotoUsageDetail
| LoadStripe
| LoadStripeFailed
| ApplyDiscountPreviewSuccess
| ApplyDiscountPreviewFailed
| SetMode
| CheckoutTrialSuccess
export type SubscriptionAction =
| CompletePayment
| Confirm
| ConfirmActionSuccess
| ConfirmPaymentSuccess
| ClearSubscriptionStatus
| ClearSubscription
| GotoCheckout
| GotoServices
| FetchLatestSubscriptionSuccess
| PollUnpaidSubscriptionSuccess
| PayUnpaidSubscriptionSuccess
| UpdateUnpaid
| UpdatePastDue
| UpdateIncomplete
| ResetSubscription
| ResumeUnpaidSubscription
| ResumeUnpaidSubscriptionSuccess
| StartCheckoutSuccess
| UpdateSubscriptionStatus
| UpdateSubscription
| UpdateSubscriptionSuccess
| StartBillingInfoSuccess
| FetchPaymentMethodList
| FetchPaymentMethodListSuccess
| FetchDefaultPm
| FetchDefaultPmSuccess
| EditPM
| EditPMSuccess
| AddPM
| AddPMSuccess
| DeletePMSuccess
| ChangePMSuccess
| CheckoutTrialSuccess
| UpdateTrial

View File

@ -1,23 +1,20 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { PageNotFoundComponent } from './page-not-found.component'; import { PageNotFoundComponent } from './page-not-found.component';
import { AuthGuard } from './domain/guards/auth.guard'; import { AuthGuard } from './domain/guards/auth.guard';
import { DashboardComponent } from './dashboard/dashboard.component'; import { DashboardComponent } from './dashboard/dashboard.component';
import { ReportComponent } from './report.component'; import { ReportComponent } from './report.component';
import { AppMainComponent } from './app.main.component'; import { AppMainComponent } from './app.main.component';
import { AppPreloader } from './app-preloader'; import { AppPreloader } from './app-preloader';
import { AppPasswordResetComp } from './pages/app.password-reset.component'; 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 { SettingsGuard } from './domain/guards/settings-guard.service';
import { MembershipResolver } from './domain/resolvers/membership-resolver';
const routes: Routes = [ const routes: Routes = [
{ path: '', redirectTo: '/home', pathMatch: 'full' }, { path: '', redirectTo: '/home', pathMatch: 'full' },
{ {
path: '', component: AppMainComponent, path: '', component: AppMainComponent,
resolve: {
membership: MembershipResolver
},
children: [ children: [
{ {
path: 'home', path: 'home',
@ -31,15 +28,16 @@ const routes: Routes = [
path: 'customers', path: 'customers',
loadChildren: () => import('./customers/customer.module').then(m => m.CustomersModule), loadChildren: () => import('./customers/customer.module').then(m => m.CustomersModule),
}, },
{
path: 'partners',
loadChildren: () => import('./partners/partners.module').then(m => m.PartnersModule),
},
{ {
path: 'profile', path: 'profile',
loadChildren: () => import('./profile/profile.module').then((m) => m.ProfileModule), loadChildren: () => import('./profile/profile.module').then((m) => m.ProfileModule),
runGuardsAndResolvers: 'always', // runGuardsAndResolvers: "always",
}, },
// {
// path: 'membership',
// loadChildren: () => import('./subscription/membership.module').then((m) => m.MembershipModule),
// // runGuardsAndResolvers: "always",
// },
{ {
path: 'billing', path: 'billing',
loadChildren: () => import('./billing/billing.module').then(m => m.BillingModule), loadChildren: () => import('./billing/billing.module').then(m => m.BillingModule),
@ -80,16 +78,6 @@ const routes: Routes = [
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
data: { preload: true } data: { preload: true }
}, },
{
path: 'partner-customers',
loadChildren: () => import('./partner-customers/partner-customers.module').then(m => m.PartnerCustomersModule),
runGuardsAndResolvers: 'always'
},
{
path: 'settings',
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
runGuardsAndResolvers: 'always'
},
], ],
}, },
{ {
@ -115,39 +103,8 @@ const routes: Routes = [
roles: null roles: null
}, },
}, },
{
path: 'signup',
loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule)
},
{
path: 'manage-subscription',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'myservices'],
redirectToNoSubs: ['profile', 'services'],
loginNotice: $localize`:Login notice for manage-subscription link@@manageSubLoginNotice:Please log in with your Master account to manage your subscriptions.`
}
},
{
path: 'update-pm',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'payment-method-list'],
loginNotice: $localize`:Login notice for update-pm link@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
}
},
{
path: 'update-bill-address',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'billing-address'],
loginNotice: $localize`:Login notice for update-bill-address link@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
}
},
{ path: '**', component: PageNotFoundComponent }, { path: '**', component: PageNotFoundComponent },
// { path: '/denied', component: AccessDeniedComponent },
]; ];
@NgModule({ @NgModule({
@ -166,6 +123,6 @@ const routes: Routes = [
exports: [ exports: [
RouterModule RouterModule
], ],
providers: [AppPreloader, MembershipResolver], providers: [AppPreloader],
}) })
export class AppRoutingModule { } export class AppRoutingModule { }

View File

@ -0,0 +1,36 @@
/* tslint:disable:no-unused-variable */
import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { AppTopbarComponent } from './app.topbar.component';
import { AppInlineProfileComponent } from './app.profile.component';
import { AppFooterComponent } from './app.footer.component';
import { AppBreadcrumbComponent } from './app.breadcrumb.component';
import { AppMenuComponent, AppSubMenuComponent } from './app.menu.component';
import { BreadcrumbService } from './breadcrumb.service';
import { ScrollPanelModule} from 'primeng/primeng';
describe('AppComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ RouterTestingModule, ScrollPanelModule ],
declarations: [ AppComponent,
AppTopbarComponent,
AppMenuComponent,
AppSubMenuComponent,
AppFooterComponent,
AppBreadcrumbComponent,
AppInlineProfileComponent
],
providers: [BreadcrumbService]
});
TestBed.compileComponents();
});
it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
});

View File

@ -1,9 +1,8 @@
import { Component, OnInit, OnDestroy, HostBinding } from '@angular/core'; import { Component, OnInit, OnDestroy, HostBinding } from '@angular/core';
import * as L from 'leaflet'; import * as L from 'leaflet';
import { globals, Roles, RoleIds, ProdTypes, ProdType, vehTypes, VehType, MatType, matTypes } from './shared/global'; import { globals, Roles, RoleIds, ProdTypes, ProdType, vehTypes, VehType, MatType, matTypes } from './shared/global';
import { filter } from 'rxjs/operators';
import { NavigationEnd, NavigationError, NavigationCancel, NavigationStart } from '@angular/router'; import { NavigationEnd } from '@angular/router';
import { environment } from '@environments/environment'; import { environment } from '@environments/environment';
import { BaseComp } from './shared/base/base.component'; import { BaseComp } from './shared/base/base.component';
@ -19,11 +18,6 @@ export class AppComponent extends BaseComp implements OnInit, OnDestroy {
@HostBinding('@.disabled') @HostBinding('@.disabled')
public animationsDisabled = L.Browser.mobile; // Disable Web Animation as it is not turn on as default in IOS 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() { get showFooter() {
return location.href.indexOf('/login') != -1; return location.href.indexOf('/login') != -1;
} }
@ -31,450 +25,24 @@ export class AppComponent extends BaseComp implements OnInit, OnDestroy {
constructor() { constructor() {
super(); super();
this["name"] = "AppComp"; 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() { 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() { ngOnDestroy() {

View File

@ -28,23 +28,14 @@
<app-topbar></app-topbar> <app-topbar></app-topbar>
<div class="layout-menu" [ngClass]="{'layout-menu-dark':darkMenu}" (click)="onMenuClick($event)"> <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> <app-menu></app-menu>
</div> </div>
<div class="layout-main"> <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"> <div class="layout-content">
<ng-container *ngIf="canDisplayTrial()"> <router-outlet></router-outlet>
<trial-message [trials]="membership.trials" [isTrialDays]="isTrialDays()" [canDisplayAcceptTrial]="canDisplayAcceptTrial()" (accept)="accept()">
</trial-message>
</ng-container> <router-outlet></router-outlet>
<agm-footer *ngIf="showFooter" [showLang]="!isAdmin"></agm-footer> <agm-footer *ngIf="showFooter" [showLang]="!isAdmin"></agm-footer>
</div> </div>
</div> </div>

View File

@ -1,22 +1,14 @@
import { Component, AfterViewInit, ElementRef, ViewChild, OnDestroy, OnInit, NgZone, ChangeDetectorRef, AfterViewChecked } from '@angular/core'; import { Component, AfterViewInit, ElementRef, ViewChild, OnDestroy, OnInit, NgZone } from '@angular/core';
import { MenuService } from './app.menu.service'; import { MenuService } from './app.menu.service';
import { AuthService } from './domain/services/auth.service'; import { AuthService } from './domain/services/auth.service';
import { ActivatedRoute, Router } from '@angular/router'; import { Router } from '@angular/router';
import { ConfirmationService } from 'primeng-lts/api'; import { ConfirmationService } from 'primeng-lts/api';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { Observable, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import cloneDeep from 'clone-deep'; import cloneDeep from 'clone-deep';
import { globals } from './shared/global'; import { globals } from './shared/global';
import { AppConfigService } from './domain/services/app-config.service'; import { AppConfigService } from './domain/services/app-config.service';
import { IAppConfig } from './domain/models/appconfig.model'; import { IAppConfig } from './domain/models/appconfig.model';
import { Compound, FetchLatestSubscriptionSuccess, GotoServices, SetMode } from './actions/subscription.actions';
import { Mode, SUB } from './profile/common';
import { Store } from '@ngrx/store';
import { IMembership, UserModel } from './auth/models/user.model';
import { ExpiryWarning } from './domain/models/subscription.model';
import { buildExpiryWarningMessage } from './app.profile.component';
import * as fromStore from '../../src/app/reducers/index';
enum MenuOrientation { enum MenuOrientation {
STATIC, STATIC,
@ -29,7 +21,7 @@ enum MenuOrientation {
selector: 'app-main', selector: 'app-main',
templateUrl: './app.main.component.html' templateUrl: './app.main.component.html'
}) })
export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, AfterViewChecked { export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit {
layoutCompact = true; layoutCompact = true;
@ -74,46 +66,27 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
rippleMouseDownListener: any; rippleMouseDownListener: any;
settings: IAppConfig; settings: IAppConfig;
membership: IMembership;
user$: Observable<UserModel>;
expiryWarning$: Observable<ExpiryWarning | null>;
constructor( constructor(
public readonly zone: NgZone, public readonly zone: NgZone,
private router: Router, private router: Router,
private readonly sanitizer: DomSanitizer, private readonly sanitizer: DomSanitizer,
private readonly menuService: MenuService, private readonly menuService: MenuService,
private readonly authSvc: AuthService, private readonly authSvc: AuthService,
private readonly appConfSvc: AppConfigService, private readonly appConfSvc: AppConfigService,
private readonly confirmSvc: ConfirmationService, private readonly confirmSvc: ConfirmationService) {
private readonly route: ActivatedRoute,
private readonly store: Store,
private cdr: ChangeDetectorRef
) {
this.membership = this.route.snapshot.data['membership'];
this.settings = cloneDeep(this.appConfSvc.settings); this.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() { ngOnInit() {
this.zone.runOutsideAngular(() => { this.bindRipple(); }); this.zone.runOutsideAngular(() => { this.bindRipple(); });
if (/*!this.authSvc.isBillable &&*/ !this.settings.noPopup) if (!this.authSvc.isBillable && !this.settings.noPopup)
this.showPaidPopup(); this.showPaidPopup();
} }
getExpiryWarningMessage(warning: ExpiryWarning): string {
return buildExpiryWarningMessage(warning);
}
onNavigateToManageSubscription(): void {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
bindRipple() { bindRipple() {
this.rippleInitListener = this.init.bind(this); this.rippleInitListener = this.init.bind(this);
document.addEventListener('DOMContentLoaded', this.rippleInitListener); document.addEventListener('DOMContentLoaded', this.rippleInitListener);
@ -229,13 +202,6 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
ngAfterViewInit() { ngAfterViewInit() {
this.layoutContainer = this.layourContainerViewChild.nativeElement as HTMLDivElement; this.layoutContainer = this.layourContainerViewChild.nativeElement as HTMLDivElement;
if (this.membership) {
setTimeout(() => this.store.dispatch(new FetchLatestSubscriptionSuccess({ membership: this.membership })), 100);
}
}
ngAfterViewChecked() {
this.cdr.detectChanges();
} }
onLayoutClick() { onLayoutClick() {
@ -417,39 +383,20 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
return this.authSvc.isAdmin; return this.authSvc.isAdmin;
} }
get isApplicator() {
return this.authSvc.isApplicator;
}
get shouldShowPaidMsg() { get shouldShowPaidMsg() {
return false; // Disable paid notification popup for now return (!this.authSvc.isBillable && !this.authSvc.isAdmin && !this.authSvc.isClientUser && !this.authSvc.isInspector);
return (/*!this.authSvc.isBillable && */!this.authSvc.isAdmin && !this.authSvc.isClientUser && !this.authSvc.isInspector);
} }
showPaidPopup() { showPaidPopup() {
if (!this.shouldShowPaidMsg) return false; // Skip paid notification popup for now if (!this.shouldShowPaidMsg) return; // Skip paid notification popup for now
// let msgHtml = $localize`:Paid start time notification popup message@@paidInformMsg:<p>Dear Agmission Customers,</p> let msgHtml = $localize`:Paid start time notification popup message@@paidInformMsg:<p>Dear Agmission Customers,</p>
// <p>Ag-Mission will become a paid service on Saturday, July 15th 2023. <p>Ag-Mission will become a paid service on Saturday, July 15th 2023.
// Please contact Ag-Nav Inc. at 1-800-99-AGNAV, or email joset@agnav.com at your earliest convenience. Please contact Ag-Nav Inc. at 1-800-99-AGNAV, or email joset@agnav.com at your earliest convenience.
// </p> </p>
// <p>Current Ag-Mission users will get the rest of 2023 at the lowest “Unlimited” tier price, and 10% off for 2024; with a special discount for users that subscribe in advance for 2024.</p> <p>Current Ag-Mission users will get the rest of 2023 at the lowest Unlimited tier price, and 10% off for 2024; with a special discount for users that subscribe in advance for 2024.</p>
// <p>For more information, please <a target="_blank" href="https://www.agnav.com/platinum/wp-content/uploads/2023/06/general-ag-mission-presentation.pdf">CLICK HERE</a> to view the features presentation.</p> <p>For more information, please <a target="_blank" href="https://www.agnav.com/platinum/wp-content/uploads/2023/06/general-ag-mission-presentation.pdf">CLICK HERE</a> to view the features presentation.</p>
// <p>If you have any questions please do not hesitate to call us or email <a target="_blank" href="mailto:general@agnav.com">general@agnav.com</a></p> <p>If you have any questions please do not hesitate to call us or email <a target="_blank" href="mailto:general@agnav.com">general@agnav.com</a></p>
// `;
let msgHtml = `<h2 class="message-header">Important Notice</h2>
<p>Dear Ag-Mission Users,</p>
<p>We sincerely apologize for the recent disruptions in accessing Ag-Mission and retrieving data. Our main server in New Jersey has been experiencing intermittent downtime since Monday, and the automatic rollover to the backup server encountered data migration challenges.</p>
<p>Our team is working diligently to resolve these issues and restore data access. We anticipate the process may take a couple of days to complete. We appreciate your patience and understanding as we work to resolve this matter as quickly as possible.</p>
<h3 style="padding:0;margin:0">Action Required</h3>
<ul style="margin:0; display: table">
<li style="padding:0;margin:0">Update your Platinum units to software version 2.21.3 via the AgNav website to ensure continued functionality and compatibility with AgMission.</li>
<li style="padding:0;margin:0">If youre using Aircraft Job Assignment feature, all aircraft must be activated under Entities in your master account provided by AgNav. (The number of aircraft allowed is based on your selected package).</li>
</ul>
<p style="margin-top:14px">Thank you for your continued support.<br/>
Best regards,<br>
Ag-Mission Team</p>
`; `;
this.confirmSvc.confirm({ this.confirmSvc.confirm({
@ -465,26 +412,4 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
} }
}); });
} }
canDisplayTrial() {
// this.membership is populated synchronously by the route resolver, before the
// NgRx auth store is hydrated (FetchLatestSubscriptionSuccess fires 100ms later).
// Using it here prevents the banner from flashing on F5 reload for users who
// already have subscriptions (including trialing ones).
if (this.membership?.subscriptions?.length > 0) return false;
return this.authSvc.canDisplayTrial(this.membership?.trials);
}
accept() {
if (this.router.url.includes(SUB.SERVICES)) return this.store.dispatch(new SetMode(Mode.TRIALING));
return this.store.dispatch(new Compound([new SetMode(Mode.TRIALING), new GotoServices()]));
}
isTrialDays() {
return this.authSvc.isTrialDays(this.membership?.trials);
}
canDisplayAcceptTrial() {
return this.authSvc.canAcceptTrial(this.router.url);
}
} }

View File

@ -1,12 +1,10 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { AppMainComponent } from './app.main.component'; import { AppMainComponent } from './app.main.component';
import { AuthService } from './domain/services/auth.service'; import { AuthService } from './domain/services/auth.service';
import { RoleIds } from './shared/global'; import { RoleIds } from './shared/global';
import { MenuItem } from 'primeng/api'; import { MenuItem } from 'primeng/api';
import { Store } from '@ngrx/store';
import { selectSubLimit } from './reducers';
import { FetchSubPlans } from './actions/sub-plans.actions';
import { SubKeys } from './profile/common';
@Component({ @Component({
selector: 'app-menu', selector: 'app-menu',
@ -17,175 +15,82 @@ import { SubKeys } from './profile/common';
` `
}) })
export class AppMenuComponent implements OnInit { export class AppMenuComponent implements OnInit {
model: any[] = []; /**
Convention: every root item with children must have routerLink for selected check after page reloaded
**/
model: any[];
constructor( constructor(
public readonly app: AppMainComponent, public readonly app: AppMainComponent,
private readonly authSvc: AuthService, private readonly authSvc: AuthService) {
private readonly store: Store<{}>
) { } }
ngOnInit() { ngOnInit() {
if (this.authSvc.hasRole([RoleIds.ADMIN])) {
this.creatAdminMenu()
} else if (this.authSvc.isPartner) {
this.createPartnerMenu();
} else {
this.createUserMenu();
}
}
creatAdminMenu() {
const mItems: MenuItem[] = [
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
{
id: 'settings',
label: $localize`:@@settings:Settings`, icon: 'settings',
routerLink: ['/settings'],
items: [
{ id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] }
]
},
];
this.model = mItems;
}
createPartnerMenu() {
const mItems: MenuItem[] = [
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
{
id: 'partner-customers',
label: $localize`:@@partnerCustomers:Partner Customers`,
icon: 'business',
routerLink: ['/partner-customers']
},
{
id: 'Help',
label: $localize`:@@help:Help`, icon: 'help_outline',
items: [{
label: $localize`:@@trainingVideos:Training Videos`,
icon: 'video_library',
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
target: '_blank'
}]
}
];
this.model = mItems;
}
createUserMenu() {
this.store.select(selectSubLimit).subscribe({
next: (subLimit) => {
const hasSubLimit = !!subLimit && (Object.keys(subLimit.package || {}).length > 0 || Object.keys(subLimit.addon || {}).length > 0);
const mItems: MenuItem[] = [ const mItems: MenuItem[] = [
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] } { id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] }
]; ];
if (hasSubLimit) { if (this.authSvc.hasRole([RoleIds.ADMIN])) {
this.addSubItems(mItems, subLimit);
}
this.model = mItems;
},
error: (err) => {
console.log(err);
}
});
this.store.dispatch(new FetchSubPlans());
}
private addSubItems(mItems: MenuItem[], subLimit: any) {
const hasTracking = subLimit?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle > 0;
const hasPackage = Object.keys(subLimit.package || {}).length > 0;
const hasOnlyTracking = !hasPackage && hasTracking;
const hasOnlyPackage = hasPackage && !hasTracking;
if (hasOnlyTracking) {
this.addOnlyTrackingItems(mItems);
} else if (hasOnlyPackage) {
this.addOnlyPackageItems(mItems);
} else if (hasPackage && hasTracking) {
this.addFullAccessItems(mItems);
}
mItems.push( mItems.push(
{ ...[
id: 'Help', { id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
label: $localize`:@@help:Help`, icon: 'help_outline', { label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
items: [{ // { label: $localize`:@@reports:Reports`, icon: 'print', routerLink: ['/reports'] },
label: $localize`:@@trainingVideos:Training Videos`, ]);
icon: 'video_library', } else {
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV', if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
target: '_blank' mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] });
}]
}
)
} }
private addOnlyTrackingItems(mItems: MenuItem[]) { if (!this.authSvc.isClientUser) {
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) { mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] });
mItems.push( }
{ mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] });
id: 'entities',
label: $localize`:@@entities:Entities`, icon: 'library_books', if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.PILOT, RoleIds.OFFICER])) {
routerLink: ['/entities'], mItems.push({
items: [{ label: $localize`:@@aircraft:Aircraft`, icon: 'airplanemode_active', routerLink: ['/entities/aircraft'] }] id: 'invoice',
}, label: $localize`:@@invoices:Invoices`, icon: 'receipt_long',
{ routerLink: ['/invoices'],
id: 'tools',
label: $localize`:@@tools:Tools`, icon: 'extension',
routerLink: ['/tools'],
items: [ items: [
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
] ]
});
} }
); const invoice = mItems.filter(i => i.id === 'invoice');
if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.OFFICER, RoleIds.PILOT])) {
invoice[0].items.push(...[
{ id: 'view', label: $localize`:@@viewInvoice:View Invoices`, icon: 'list', routerLink: ['/invoices'] }
]);
} }
if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) { if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] }); invoice[0].items.push(...[
{ id: 'view', label: $localize`:@@viewEditInvoice:View/Edit Invoices`, icon: 'list', routerLink: ['/invoices'] }
]);
} }
if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT])) {
invoice[0].items.push(...[
{
id: 'costing-item',
label: $localize`:@@costingItems:Costing Items`,
icon: 'payments',
routerLink: ['/invoices/costing-items']
},
]);
} }
if (invoice && invoice.length && this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
private addOnlyPackageItems(mItems: MenuItem[]) { invoice[0].items.push(...[
if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) { {
mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] }); id: 'settings',
label: $localize`:@@invoiceSettings:Invoice Settings`,
icon: 'settings',
routerLink: ['/invoices/settings']
},
]);
} }
if (!this.authSvc.isClientUser) {
mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] });
}
mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] });
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) { if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
this.addEntitiesAndToolsItems(mItems);
}
this.addInvoiceItems(mItems);
}
private addFullAccessItems(mItems: MenuItem[]) {
if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM])) {
mItems.push({ id: 'accounts', label: $localize`:@@accounts:Accounts`, icon: 'assignment_ind', routerLink: ['/accounts'] });
}
if (!this.authSvc.isClientUser) {
mItems.push({ id: 'clients', label: $localize`:@@clients:Clients`, icon: 'people', routerLink: ['/clients'] });
}
mItems.push({ id: 'jobs', label: $localize`:@@jobs:Jobs`, icon: 'assignment', routerLink: ['/jobs'] });
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
this.addEntitiesAndToolsItems(mItems);
}
if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) {
mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] });
}
this.addInvoiceItems(mItems);
}
private addEntitiesAndToolsItems(mItems: MenuItem[]) {
mItems.push( mItems.push(
...[
{ {
id: 'entities', id: 'entities',
label: $localize`:@@entities:Entities`, icon: 'library_books', label: $localize`:@@entities:Entities`, icon: 'library_books',
@ -208,34 +113,74 @@ export class AppMenuComponent implements OnInit {
routerLink: ['/tools'], routerLink: ['/tools'],
items: [ items: [
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] }, { 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'] }
] ]
} }
); ]);
} }
private addInvoiceItems(mItems: MenuItem[]) { if (!this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR])) {
if (this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.OFFICER])) { mItems.push({ id: 'track', label: $localize`:@@tracking:Tracking`, icon: 'track_changes', routerLink: ['/track'] });
mItems.push({
id: 'invoice',
label: $localize`:@@invoices:Invoices`, icon: 'receipt_long',
routerLink: ['/invoices'],
items: []
});
} }
const invoice = mItems.find(i => i.id === 'invoice');
if (invoice) { const tools = mItems.filter(i => i.id === 'tools');
if (this.authSvc.hasRole([RoleIds.APP_ADM, RoleIds.CLIENT, RoleIds.OFFICER])) { if (tools && tools.length) {
invoice.items.push({ id: 'view', label: $localize`:@@viewInvoice:View Invoices`, icon: 'list', routerLink: ['/invoices'] }); tools[0].items.push({ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] });
} }
if (this.authSvc.hasRole([RoleIds.APP])) {
invoice.items.push( }
{ id: 'view', label: $localize`:@@viewEditInvoice:View/Edit Invoices`, icon: 'list', routerLink: ['/invoices'] }, this.model = mItems;
{ id: 'costing-item', label: $localize`:@@costingItems:Costing Items`, icon: 'payments', routerLink: ['/invoices/costing-items'] }, // let validLinks = [];
{ id: 'settings', label: $localize`:@@invoiceSettings:Invoice Settings`, icon: 'settings', routerLink: ['/invoices/settings'] } // this.getValidLinks(this.router.config, this.router.config[0], validLinks);
); // this.updateMenuItem(this.model, validLinks);
}
/*
private getValidLinks(routes: Route[], parent: Route, links: string[]) {
for (const route of routes) {
if (!route.children || route.children.length === 0) {
if (!route.data || (route.data && (!route.data.roles || this.authService.hasRole(route.data.roles)))) {
if (route.path !== '**') {
let combinedPath = parent ? `/${parent.path}/${route.path}` : route.path;
combinedPath = combinedPath.replace('/:id', '');
if (!combinedPath.startsWith('/')) {
combinedPath = '/' + combinedPath;
}
if (combinedPath.length > 1 && combinedPath.endsWith('/')) {
combinedPath = combinedPath.slice(0, combinedPath.length - 1);
}
links.push(combinedPath);
}
}
} else {
this.getValidLinks(route.children, route, links);
parent = null;
}
}
}
private updateMenuItem(mnuItems: MenuItem[], validLinks: string[]) {
for (const it of mnuItems) {
if (it.items) {
// this.updateMenuItem(it.items, validLinks);
let visible = false;
// it.items.forEach(el => {
// if (!el.hasOwnProperty('visible') || el.visible) {
// visible = true;
// return;
// }
// });
if (!visible) {
it.visible = false; // hide menu item which has none sub-items allowed to be shown
}
} else {
if (it.routerLink && it.routerLink.length > 0) {
if (validLinks.indexOf(it.routerLink[0]) === -1) {
it.visible = false;
} }
} }
} }
} }
}
*/
}

View File

@ -1,4 +1,4 @@
import { APP_INITIALIZER, NgModule, TRANSLATIONS, LOCALE_ID, TRANSLATIONS_FORMAT, Injector } from '@angular/core'; import { NgModule, TRANSLATIONS, LOCALE_ID, TRANSLATIONS_FORMAT, Injector } from '@angular/core';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@ -10,7 +10,6 @@ import { ButtonModule } from 'primeng/button';
import { MenuModule } from 'primeng/menu'; import { MenuModule } from 'primeng/menu';
import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { DialogModule } from 'primeng/dialog';
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
import { ConfirmationService, MessageService } from 'primeng/api'; import { ConfirmationService, MessageService } from 'primeng/api';
import { ToastModule } from 'primeng/toast'; import { ToastModule } from 'primeng/toast';
@ -22,6 +21,7 @@ import { AppMainComponent } from './app.main.component';
import { AppMenuComponent } from './app.menu.component'; import { AppMenuComponent } from './app.menu.component';
import { AppMenuitemComponent } from './app.menuitem.component'; import { AppMenuitemComponent } from './app.menuitem.component';
import { AppTopbarComponent } from './app.topbar.component'; import { AppTopbarComponent } from './app.topbar.component';
import { AppFooterComponent } from './app.footer.component';
import { AppInlineProfileComponent } from './app.profile.component'; import { AppInlineProfileComponent } from './app.profile.component';
import { DashboardComponent } from './dashboard/dashboard.component'; import { DashboardComponent } from './dashboard/dashboard.component';
@ -52,10 +52,8 @@ import { AppConfigService } from './domain/services/app-config.service';
import { AuthInterceptor } from './domain/services/auth-interceptor.service'; import { AuthInterceptor } from './domain/services/auth-interceptor.service';
import { SettingsGuard } from './domain/guards/settings-guard.service'; import { SettingsGuard } from './domain/guards/settings-guard.service';
import { LanguageSwicherComponent } from './language-swicher.component';
import { AppEffects } from './effects/app.effects'; import { AppEffects } from './effects/app.effects';
import { SubPlansEffects } from './effects/sub-plans.effects';
import { Utils } from './shared/utils'; import { Utils } from './shared/utils';
import { AppInjector } from './app-injector'; import { AppInjector } from './app-injector';
import { BaseComp } from './shared/base/base.component'; import { BaseComp } from './shared/base/base.component';
@ -67,11 +65,8 @@ import { GlobalModule } from './shared/global.module';
import { HttpCancelService } from './domain/services/httpcancel.service'; import { HttpCancelService } from './domain/services/httpcancel.service';
import { ManageHttpInterceptor } from './domain/services/managehttp.interceptor.service'; import { ManageHttpInterceptor } from './domain/services/managehttp.interceptor.service';
import { InvoiceService } from '@app/domain/services/invoice.service'; import { InvoiceService } from '@app/domain/services/invoice.service';
import '@app/shared/number.extension'; import '@app/shared/number.extension';
import { RoutingEffects } from './effects/routing.effects';
import { SubscriptionEffects } from './effects/subscription.effects';
import { AppSharedModule } from './shared/app-shared.module';
import { GlobalErrorInterceptor } from './domain/services/global-error.interceptor';
// Use the require method provided by webpack // Use the require method provided by webpack
declare const require; declare const require;
@ -83,11 +78,17 @@ export function translationsFactory(locale: string) {
return require(`raw-loader!../locale/messages.${locale}.xlf`).default; return require(`raw-loader!../locale/messages.${locale}.xlf`).default;
} }
// export function loadSetting(appInitService: AppConfig) {
// return (): Promise<any> => {
// return appInitService.load();
// }
// }
@NgModule({ @NgModule({
imports: [ imports: [
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule, BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule, InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule,
MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, AppSharedModule, MessagesModule, ToastModule, ConfirmDialogModule, DropdownModule, CheckboxModule,
// The store that defines our app state // The store that defines our app state
StoreModule.forRoot(reducers, { StoreModule.forRoot(reducers, {
metaReducers, metaReducers,
@ -101,7 +102,7 @@ export function translationsFactory(locale: string) {
// Must instrument after importing StoreModule // Must instrument after importing StoreModule
StoreDevtoolsModule.instrument({ name: 'AgMission', maxAge: 15, logOnly: environment.production }), StoreDevtoolsModule.instrument({ name: 'AgMission', maxAge: 15, logOnly: environment.production }),
AppRoutingModule, AppRoutingModule,
EffectsModule.forRoot([AppEffects, SubPlansEffects, RoutingEffects, SubscriptionEffects]), EffectsModule.forRoot([AppEffects]),
], ],
declarations: [ declarations: [
BaseComp, BaseComp,
@ -115,10 +116,14 @@ export function translationsFactory(locale: string) {
AppMenuitemComponent, AppMenuitemComponent,
AppInlineProfileComponent, AppInlineProfileComponent,
AppTopbarComponent, AppTopbarComponent,
AppFooterComponent,
LanguageSwicherComponent,
ReportComponent, ReportComponent,
AppPasswordResetComp AppPasswordResetComp,
], ],
providers: [ providers: [
// AppConfig,
// { provide: APP_INITIALIZER, useFactory: loadSetting, deps: [AppConfig], multi: true },
{ {
provide: TRANSLATIONS, provide: TRANSLATIONS,
useFactory: translationsFactory, useFactory: translationsFactory,
@ -133,11 +138,6 @@ export function translationsFactory(locale: string) {
useClass: AuthInterceptor, useClass: AuthInterceptor,
multi: true multi: true
}, },
{
provide: HTTP_INTERCEPTORS,
useClass: GlobalErrorInterceptor,
multi: true
},
{ {
provide: ActionsSubject, useClass: AppDispatcher provide: ActionsSubject, useClass: AppDispatcher
}, },
@ -149,8 +149,11 @@ export function translationsFactory(locale: string) {
exports: [], exports: [],
entryComponents: [] entryComponents: []
}) })
export class AppModule { export class AppModule {
// Diagnostic only: inspect router configuration
// constructor(router: Router) {
// console.log('Routes: ', JSON.stringify(router.config, undefined, 2));
// }
constructor(private readonly injector: Injector) { constructor(private readonly injector: Injector) {
AppInjector.setInjector(injector); AppInjector.setInjector(injector);
} }

View File

@ -1,22 +0,0 @@
.account-summary-info {
padding-top: 0.5em;
color: #fff;
font-size: 0.95rem;
font-weight: 500;
text-align: right;
}
.account-summary-info .account-username {
margin-right: 0.5em;
}
.account-summary-info .account-type {
margin-right: 0.5em;
font-style: italic;
opacity: 0.85;
}
.account-summary-info .account-contact {
color: #ffd700;
opacity: 0.9;
}

View File

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

View File

@ -1,138 +1,81 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'; import { AppMainComponent } from './app.main.component';
import { of } from 'rxjs'; import { Component } from '@angular/core';
import { catchError } from 'rxjs/operators'; import { trigger, state, transition, style, animate } from '@angular/animations';
import { globals } from './shared/global';
import { UserModel } from './auth/models/user.model';
import { UserService } from './domain/services/user.service';
import { ExpiryWarning } from './domain/models/subscription.model';
export function buildExpiryWarningMessage(expiryWarning: ExpiryWarning | null): string { import { Store } from '@ngrx/store';
if (!expiryWarning) return ''; import * as authActions from './auth/actions/auth.actions';
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({ @Component({
selector: "app-inline-profile", selector: "app-inline-profile",
templateUrl: "./app.profile.component.html", template: `
styleUrls: ['./app.profile.component.css'] <div class="profile" [ngClass]="{'profile-expanded':active}">
<a href="#" (click)="onClick($event)">
<img class="profile-image" src="assets/layout/images/avatar.png" />
<span class="profile-name">Jane Williams</span>
<i class="material-icons">keyboard_arrow_down</i>
</a>
</div>
<ul class="ultima-menu profile-menu" [@menu]="active ? 'visible' : 'hidden'">
<li role="menuitem">
<a href="#" class="ripplelink" [attr.tabindex]="!active ? '-1' : null">
<i class="material-icons">person</i>
<span>Profile</span>
</a>
</li>
<!--<li role="menuitem">
<a href="#" class="ripplelink" [attr.tabindex]="!active ? '-1' : null">
<i class="material-icons">security</i>
<span>Privacy</span>
</a>
</li>
<li role="menuitem">
<a href="#" class="ripplelink" [attr.tabindex]="!active ? '-1' : null">
<i class="material-icons">settings_application</i>
<span>Settings</span>
</a>
</li>-->
<li role="menuitem">
<a
href="#"class="ripplelink" [attr.tabindex]="!active ? '-1' : null" (click)="onLogout($event)"
>
<i class="material-icons">power_settings_new</i>
<span>Logout</span>
</a>
</li>
</ul>
`,
animations: [
trigger('menu', [
state('hidden', style({
height: '0px'
})),
state('visible', style({
height: '*'
})),
transition('visible => hidden', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)')),
transition('hidden => visible', animate('400ms cubic-bezier(0.86, 0, 0.07, 1)'))
])
],
}) })
export class AppInlineProfileComponent { export class AppInlineProfileComponent {
readonly globals = globals; active: boolean;
@Input() user: UserModel; constructor(
@Input() expiryWarning: ExpiryWarning | null; private readonly store: Store<{}>,
@Output() navigateToSubscription = new EventEmitter<void>(); public readonly app: AppMainComponent) {}
showMasterPopup = false; onClick(event) {
masterInfo: { username: string; contact?: string; name?: string; phone?: string; email?: string } | null = null; this.active = !this.active;
private masterInfoFetchedAt: number | null = null; // setTimeout(() => {
private readonly MASTER_INFO_TTL_MS = 2 * 60 * 1000; // re-fetch after 2 minutes // this.app.layoutMenuScrollerViewChild.moveBar();
// }, 450);
constructor(readonly userSvc: UserService) { } event.preventDefault();
getAccountType(user: UserModel): string {
return this.userSvc.getAccountType(user);
} }
getWarningMessage(): string { switchProfile() {}
return buildExpiryWarningMessage(this.expiryWarning); onLogout(e) {
} this.store.dispatch(new authActions.Logout());
e.preventDefault();
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;
});
} }
} }

View File

@ -1,68 +0,0 @@
<div class="topbar clearfix">
<div class="topbar-left">
<div class="agm-logo"></div>
</div>
<div *ngIf="user$ | async as user" class="topbar-right" style="display: flex; justify-content: flex-end;">
<app-inline-profile [user]="user" [expiryWarning]="expiryWarning$ | async"
(navigateToSubscription)="onNavigateToManageSubscription()"></app-inline-profile>
<a id="menu-button" href="#" (click)="app.onMenuButtonClick($event)">
<i></i>
</a>
<a id="topbar-menu-button" href="#" (click)="app.onTopbarMenuButtonClick($event)">
<i class="topbar-icon material-icons animated">menu</i>
<span *ngIf="!app.isAdmin" class="topbar-badge animated">1</span>
</a>
<ul class="topbar-items animated fadeInDown" [ngClass]="{ 'topbar-items-visible': app.topbarMenuActive }">
<li #profile class="profile-item" *ngIf="app.profileMode === 'top' || app.isHorizontal()"
[ngClass]="{ 'active-top-menu': app.activeTopbarItem === profile }">
<a href="#" (click)="app.onTopbarItemClick($event, profile)">
<i class="topbar-icon material-icons">apps</i>
<span class="topbar-item-name">Profile</span>
</a>
<ul class="ultima-menu animated fadeInDown">
<li role="menuitem">
<a href="javascript:void(0)" (click)="updateUserProfile(user._id)">
<i class="material-icons">person</i>
<span i18n="@@profile">Profile</span>
</a>
</li>
<ng-container *ngIf="app.isApplicator">
<li role="menuitem">
<a href="javascript:void(0)" (click)="manageServices()">
<i class="material-icons">widgets</i>
<span i18n="@@services">Services</span>
</a>
</li>
<li role="menuitem">
<a href="javascript:void(0)" (click)="manageBilling()">
<i class="material-icons">card_membership</i>
<span i18n="@@billing">Billing</span>
</a>
</li>
<li role="menuitem">
<a href="javascript:void(0)" (click)="manageContact(user)">
<i class="material-icons">contact_mail</i>
<span i18n="@@contact">Contact</span>
</a>
</li>
</ng-container>
<li role="menuitem">
<a href="javascript:void(0)" (click)="onLogout($event)">
<i class="material-icons">power_settings_new</i>
<span i18n="@@signOut">Sign out</span>
</a>
</li>
</ul>
</li>
<!-- <li #notifications id="top_notification">
<a href="#" *ngIf="app.shouldShowPaidMsg" (click)="app.onTopbarSubItemClick($event, notifications)">
<i class="topbar-icon medium material-icons animated swing">notifications</i>
<span class="topbar-badge animated">1</span>
<span class="topbar-item-name" i18n="@@notifications">Notifications</span>
</a>
</li> -->
</ul>
</div>
</div>

View File

@ -1,89 +1,91 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnDestroy } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Observable, Subscription, combineLatest } from 'rxjs';
import { first, filter, switchMap, map } from 'rxjs/operators'; import { Subscription } from 'rxjs';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppMainComponent } from './app.main.component'; import { AppMainComponent } from './app.main.component';
import * as authActions from './auth/actions/auth.actions'; import * as authActions from './auth/actions/auth.actions';
import * as fromStore from '../../src/app/reducers/index'; import * as fromStore from '../../src/app/reducers/index';
import { UserModel } from './auth/models/user.model';
import { ExpiryWarning } from './domain/models/subscription.model';
import { SUB } from './profile/common';
import { UserService } from './domain/services/user.service';
@Component({ @Component({
selector: 'app-topbar', selector: 'app-topbar',
templateUrl: './app.topbar.component.html' template: `
<div class="topbar clearfix">
<div class="topbar-left">
<div class="agm-logo"></div>
</div>
<div class="topbar-right">
<a id="menu-button" href="#" (click)="app.onMenuButtonClick($event)">
<i></i>
</a>
<a id="topbar-menu-button"href="#" (click)="app.onTopbarMenuButtonClick($event)">
<!-- <i class="material-icons">menu</i> -->
<i class="topbar-icon material-icons animated">menu</i>
<span *ngIf="!app.isAdmin" class="topbar-badge animated">1</span>
</a>
<ul class="topbar-items animated fadeInDown" [ngClass]="{ 'topbar-items-visible': app.topbarMenuActive }">
<li #profile class="profile-item"*ngIf="app.profileMode === 'top' || app.isHorizontal()"
[ngClass]="{ 'active-top-menu': app.activeTopbarItem === profile }">
<a href="#" (click)="app.onTopbarItemClick($event, profile)">
<i class="topbar-icon material-icons">apps</i>
<span class="topbar-item-name">Profile</span>
</a>
<ul class="ultima-menu animated fadeInDown">
<li role="menuitem">
<a href="javascript:void(0)" (click)="updateUserProfile()">
<i class="material-icons">person</i>
<span i18n="@@profile">Profile</span>
</a>
</li>
<!-- <li role="menuitem">
<a href="javascript:void(0)" (click)="manageServices()">
<i class="material-icons">widgets</i>
<span i18n="@@services">Services</span>
</a>
</li>
<li role="menuitem">
<a href="javascript:void(0)" (click)="manageBilling()">
<i class="material-icons">card_membership</i>
<span i18n="@@billingNContact">Billing & Contact</span>
</a>
</li> -->
<li role="menuitem">
<a href="javascript:void(0)" (click)="onLogout($event)">
<i class="material-icons">power_settings_new</i>
<span i18n="@@signOut">Sign out</span>
</a>
</li>
</ul>
</li>
<li #notifications id="top_notification">
<a href="#" *ngIf="app.shouldShowPaidMsg" (click)="app.onTopbarSubItemClick($event, notifications)">
<i class="topbar-icon medium material-icons animated swing">notifications</i>
<span class="topbar-badge animated">1</span>
<span class="topbar-item-name" i18n="@@notifications">Notifications</span>
</a>
</li>
</ul>
</div>
</div>
`,
}) })
export class AppTopbarComponent implements OnInit, OnDestroy { export class AppTopbarComponent implements OnDestroy {
user$: Observable<UserModel>; _user: any;
expiryWarning$: Observable<ExpiryWarning | null>; private sub$: Subscription;
private sub$ = new Subscription(); private user$ = this.store.select(fromStore.selectAuthUser);
constructor( constructor(
public readonly app: AppMainComponent, public readonly app: AppMainComponent,
private readonly store: Store<{}>, private readonly store: Store<{}>,
private readonly router: Router, private router: Router
private readonly userSvc: UserService
) { ) {
this.user$ = this.store.select(fromStore.selectAuthUser); this.sub$ = this.user$.subscribe((user) => (this._user = user));
this.expiryWarning$ = combineLatest([
this.store.select(fromStore.selectExpiryWarning),
this.store.select(fromStore.selectNoSubsWarning)
]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs));
}
ngOnInit(): void {
// Fetch fresh user data from server on component init (page load/reload)
// This ensures header displays current data even if changed externally
this.sub$.add(
this.user$.pipe(
first(), // Only run once on init
filter(user => !!user && !!user._id), // Only if user exists
switchMap(user => this.userSvc.getUser(user._id, { view: 'profile' }))
).subscribe(freshUser => {
if (freshUser) {
this.store.dispatch(new authActions.RefreshUserData({
user: this.mapUserToUserModel(freshUser)
}));
}
})
);
}
ngOnDestroy(): void {
this.sub$.unsubscribe();
}
/**
* Map User (from API) to UserModel (for store)
* Only maps fields that should be refreshed from server
*/
private mapUserToUserModel(user: any): UserModel {
return {
_id: user._id,
name: user.name || '',
username: user.username || '',
roles: user.roles || [],
parent: user.parent || '',
lang: user.lang || 'en',
pre: user.pre || 0,
billable: user.billable,
membership: user.membership,
contact: user.contact || ''
};
}
manageServices() {
return this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
manageBilling() {
this.router.navigate([SUB.PROFILE, SUB.PM_HISTORY]);
}
manageContact(user) {
this.router.navigate([SUB.PROFILE, SUB.BILL_ADR_LIST]);
} }
onLogout(e) { onLogout(e) {
@ -91,15 +93,19 @@ export class AppTopbarComponent implements OnInit, OnDestroy {
e.preventDefault(); e.preventDefault();
} }
updateUserProfile(userId: string) { updateUserProfile() {
this.router.navigate([SUB.PROFILE, 'edit', userId]); this.router.navigate(['profile', this._user._id]);
} }
/** manageServices() {
* Navigate to manage subscription page this.router.navigate(['profile/myservices', this._user._id]);
* Triggered by subscription expiry notification click }
*/
onNavigateToManageSubscription(): void { manageBilling() {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]); this.router.navigate(['profile/mybills', this._user._id]);
}
ngOnDestroy(): void {
if (this.sub$) this.sub$.unsubscribe();
} }
} }

View File

@ -1,7 +1,6 @@
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { Authenticate } from '../models/auth.model'; import { Authenticate } from '../models/auth.model';
import { UserModel } from '../models/user.model'; import { UserModel } from '../models/user.model';
import { Plan } from '@app/domain/models/subscription.model';
export const LOGIN = '[Login Page] Login'; export const LOGIN = '[Login Page] Login';
export class Login implements Action { export class Login implements Action {
@ -37,17 +36,9 @@ export class LogoutComplete implements Action {
} }
export const REFRESH_USER_DATA = '[Auth] Refresh User Data';
export class RefreshUserData implements Action {
readonly type: typeof REFRESH_USER_DATA = REFRESH_USER_DATA;
constructor(public payload: { user: UserModel }) { }
}
export type All = export type All =
| Login | Login
| LoginSuccess | LoginSuccess
| LoginFailed | LoginFailed
| Logout | Logout
| LogoutComplete | LogoutComplete;
| RefreshUserData;

View File

@ -3,13 +3,16 @@ import { Router } from '@angular/router';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { Actions, Effect, ofType } from '@ngrx/effects'; import { Actions, Effect, ofType } from '@ngrx/effects';
import { map, exhaustMap, catchError, tap } from 'rxjs/operators'; import { map, exhaustMap, catchError, tap } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import * as authActions from '../actions/auth.actions'; import * as authActions from '../actions/auth.actions';
import * as clientActions from '@app/client/actions/client.actions'; import * as clientActions from '@app/client/actions/client.actions';
import { ClientService } from '@app/domain/services/client.service'; import { ClientService } from '@app/domain/services/client.service';
import { AuthService } from '@app/domain/services/auth.service'; import { AuthService } from '@app/domain/services/auth.service';
import { globals } from '@app/shared/global'; import { globals } from '@app/shared/global';
@Injectable() @Injectable()
export class AuthEffects { export class AuthEffects {
@Effect() @Effect()
@ -17,17 +20,17 @@ export class AuthEffects {
.pipe( .pipe(
ofType<authActions.Login>(authActions.LOGIN), ofType<authActions.Login>(authActions.LOGIN),
map(action => action.payload), map(action => action.payload),
exhaustMap(auth => { exhaustMap(auth =>
return this.authSvc.login(auth).pipe( this.authSvc.login(auth).pipe(
map(user => { map(user => {
return new authActions.LoginSuccess({ user }) return new authActions.LoginSuccess({ user: user });
}), }),
catchError(err => { catchError(err => {
const errTag = (err.error && err.error.error) ? err.error.error['.tag'] : err.message || ''; const errTag = (err.error && err.error.error) ? err.error.error['.tag'] : err.message || '';
return of(new authActions.LoginFailed(globals.apiErrorMsg(errTag))); return of(new authActions.LoginFailed(globals.apiErrorMsg(errTag)));
}), }),
) ),
}) ),
); );
@Effect({ dispatch: false }) @Effect({ dispatch: false })
@ -50,9 +53,8 @@ export class AuthEffects {
private navigateDefault(lang) { private navigateDefault(lang) {
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/'; 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 // Replace the current page with the next target url => prevent Back to previous
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + returnUrl); window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + 'home');
} }
@Effect() @Effect()

View File

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

View File

@ -1,7 +1,5 @@
import { Component, OnInit, OnDestroy, ViewChild, isDevMode } from '@angular/core'; import { Component, OnInit, OnDestroy, ViewChild, isDevMode } from '@angular/core';
import { ReCaptcha2Component } from 'ngx-captcha'; import { ReCaptcha2Component } from 'ngx-captcha';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { Authenticate } from '../models/auth.model'; import { Authenticate } from '../models/auth.model';
import * as authActions from '../actions/auth.actions'; import * as authActions from '../actions/auth.actions';
@ -38,57 +36,23 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
public captchaSuccess = false; public captchaSuccess = false;
private _lastVerReqAt: number = 0; 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( constructor(
) { ) {
super(); super();
this['name'] = "LoginComp"; this['name'] = "LoginComp";
const nav = this.router.getCurrentNavigation(); if (this.router.getCurrentNavigation()) {
if (nav) { const routeSate = this.router.getCurrentNavigation().extras && this.router.getCurrentNavigation().extras.state;
const msgs: any[] = []; if (routeSate && routeSate.changedPwd) {
const state = nav.extras?.state; this.msgs = [{ severity: 'info', summary: '', detail: globals.pwdChangedOk }];
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() { ngOnInit() {
this.lang = this.authSvc.locale; 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.useReCaptcha && (
this.sub$.add(this.appActions.ofTypes([authActions.LOGIN_FAILED]).subscribe(action => { this.sub$.add(this.appActions.ofTypes([authActions.LOGIN_FAILED]).subscribe(action => {
this.captchaElem.resetCaptcha(); this.captchaElem.resetCaptcha();
@ -109,22 +73,6 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
return StringUtils.isEmpty(this.model.username) ? globals.usernameReqVal : globals.usernameInvalidVal; 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 { 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 // Verify user reponse token with server side within 2 minutes according to GG Ref: https://developers.google.com/recaptcha/docs/verify
this._lastVerReqAt = Date.now(); this._lastVerReqAt = Date.now();

View File

@ -1,5 +1,3 @@
import { AGNavSubscription, Trial } from "@app/domain/models/subscription.model";
export interface UserModel { export interface UserModel {
_id: string; _id: string;
name: string; name: string;
@ -9,19 +7,11 @@ export interface UserModel {
lang: string; lang: string;
pre: number; pre: number;
billable?: boolean; billable?: boolean;
membership?: IMembership, membership?: IMembership
contact: string;
country?: string;
partner?: string;
} }
export interface IMembership { export interface IMembership {
custId: string; status: string;
endOfPeriod?: Number; endPeriod: number,
subscriptions?: AGNavSubscription[]; subTier: string; // 'essential', 'enterprise'
trials?: Trial;
customLimits?: {
maxVehicles?: number | null;
maxAcres?: number | null;
};
} }

View File

@ -3,7 +3,7 @@
<br /> <br />
<div class="flex-row" style="justify-content: center;"> <div class="flex-row" style="justify-content: center;">
<div> <div>
<span style="padding-right: 12px;font-weight: bold;"><ng-container i18n="@@from">From</ng-container></span> <span style="padding-right: 12px;font-weight: bold;">From</span>
<p-calendar [showIcon]="true" [(ngModel)]="fromDate" (onSelect)="onDateSelected('from', $event)" [inputStyle]="{'width':'90px'}" placeholder="From Month" dateFormat="mm/yy" view="month" [readonlyInput]="true"></p-calendar> <p-calendar [showIcon]="true" [(ngModel)]="fromDate" (onSelect)="onDateSelected('from', $event)" [inputStyle]="{'width':'90px'}" placeholder="From Month" dateFormat="mm/yy" view="month" [readonlyInput]="true"></p-calendar>
</div> </div>
<div> <div>
@ -23,7 +23,7 @@
<ng-template pTemplate="caption"> <ng-template pTemplate="caption">
<div class="ui-g ui-g-nopad"> <div class="ui-g ui-g-nopad">
<div class="ui-g-6 ui-g-nopad" style="text-align: left"> <div class="ui-g-6 ui-g-nopad" style="text-align: left">
<span class="table-caption-1" style="line-height: 1.35em;"><ng-container i18n="@@sprayOverview">Customer Spray Overview</ng-container></span> <span class="table-caption-1" style="line-height: 1.35em;">Customer Spray Overview</span>
</div> </div>
<div class="ui-g-6 ui-g-nopad" style="text-align: right"> <div class="ui-g-6 ui-g-nopad" style="text-align: right">
<div style="display:inline-flex"> <div style="display:inline-flex">
@ -57,12 +57,12 @@
<ng-template pTemplate="footer" let-columns> <ng-template pTemplate="footer" let-columns>
<tr> <tr>
<td *ngFor="let col of columns" [ngSwitch]="col.field"> <td *ngFor="let col of columns" [ngSwitch]="col.field">
<span *ngSwitchCase="'customer'" style="font-weight: bold;"><ng-container i18n="@@totalSpray">Total Spray</ng-container></span> <span *ngSwitchCase="'customer'" style="font-weight: bold;">Total Spray</span>
<div *ngSwitchDefault> <div *ngSwitchDefault>
<span *ngIf="totals[col.field] == 0; else usage">{{totals[col.field]}}</span> <span *ngIf="totals[col.field] == 0; else usage">{{totals[col.field]}}</span>
<ng-template #usage> <ng-template #usage>
<div>{{totals[col.field] | number:'1.1-1':'en'}}&nbsp;ha</div> <div>{{totals[col.field] | number:'1.1-1':'en'}} ha</div>
<div>{{haToAcres(totals[col.field]) | number:'1.1-1':'en'}}&nbsp;ac</div> <div>{{haToAcres(totals[col.field]) | number:'1.1-1':'en'}} ac</div>
</ng-template> </ng-template>
</div> </div>
</td> </td>

View File

@ -17,7 +17,7 @@ import { ToastModule } from 'primeng/toast';
import { StoreModule } from '@ngrx/store'; import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import { ClientEffects } from './effects/client.effects'; import { ClientEffects } from './effects/client.effects';
import * as fromClients from './reducers/clients.reducer'; import * as fromClients from './reducers/clients-reducer';
import { ClientListComponent } from './client-list/client-list.component'; import { ClientListComponent } from './client-list/client-list.component';
import { ClientsRoutingModule } from './client-routing.module'; import { ClientsRoutingModule } from './client-routing.module';

View File

@ -25,7 +25,7 @@ export class ClientEffects {
loadClients$: Observable<Action> = this.actions$.pipe( loadClients$: Observable<Action> = this.actions$.pipe(
ofType<clientActions.Fetch>(clientActions.FETCH), ofType<clientActions.Fetch>(clientActions.FETCH),
switchMap(() => switchMap(() =>
this.clientSvc.loadClients({ byPuid: this.authSvc.user.parent }).pipe( this.clientSvc.loadClients({ byUserId: this.authSvc.user.parent }).pipe(
map(clients => new clientActions.FetchSuccess(clients)), map(clients => new clientActions.FetchSuccess(clients)),
catchError(err => { catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients)); this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients));

View File

@ -3,64 +3,31 @@ import {
createFeatureSelector, createFeatureSelector,
} from '@ngrx/store'; } from '@ngrx/store';
import * as fromClients from './clients.reducer'; import * as fromClients from './clients-reducer';
export const getClientsState = createFeatureSelector<fromClients.State>(fromClients.FEATURE_KEY); export const getClientsState = createFeatureSelector<fromClients.State>(fromClients.FEATURE_KEY);
// Safe wrapper to handle undefined state during lazy module loading
export const getClientsStateOrInitial = createSelector(
getClientsState,
(state) => {
if (!state) {
return {
ids: [],
entities: {},
loading: false,
loaded: false,
selectedId: null
};
}
return state;
}
);
export const getSelectedClientId = createSelector( export const getSelectedClientId = createSelector(
getClientsStateOrInitial, getClientsState,
fromClients.getSelectedId fromClients.getSelectedId
); );
export const isLoading = createSelector( export const isLoading = createSelector(
getClientsStateOrInitial, getClientsState,
fromClients.getIsLoading fromClients.getIsLoading
); );
export const isLoaded = createSelector( export const isLoaded = createSelector(
getClientsStateOrInitial, getClientsState,
fromClients.getIsLoaded fromClients.getIsLoaded
); );
// Entity selectors wrapped for safety during lazy loading export const {
const entitySelectors = fromClients.adapter.getSelectors(getClientsStateOrInitial); selectIds: getClientsIds,
selectEntities: getClientEntities,
export const getClientsIds = createSelector( selectAll: getAllClients,
entitySelectors.selectIds, selectTotal: getTotalClients,
(ids) => ids || [] } = fromClients.adapter.getSelectors(getClientsState);
);
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( export const getSelectedClient = createSelector(
getClientEntities, getClientEntities,

View File

@ -1,128 +0,0 @@
.theme-color {
color: #4caf50;
}
ul {
padding-inline-start: 20px;
}
/* Partner Selection Integration Styles */
.partner-option {
display: flex;
align-items: center;
padding: 8px 0;
}
.partner-info {
display: flex;
flex-direction: column;
}
.partner-name {
font-weight: 500;
color: #333;
}
.partner-description {
font-size: 0.85em;
color: #666;
margin-top: 2px;
}
.partner-selected {
display: flex;
align-items: center;
}
.partner-config-section {
margin-top: 20px;
padding: 20px;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.partner-config-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.partner-config-header h3 {
margin: 0;
color: #333;
font-size: 1.1em;
}
.partner-loading {
display: flex;
align-items: center;
color: #007bff;
padding: 10px;
background-color: #e7f3ff;
border: 1px solid #b3d9ff;
border-radius: 4px;
margin-bottom: 15px;
}
.partner-error {
display: flex;
align-items: center;
color: #dc3545;
padding: 10px;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-bottom: 15px;
}
.satloc-config-section {
padding: 15px;
background-color: #ffffff;
border: 1px solid #ddd;
border-radius: 4px;
}
.config-description {
margin-bottom: 15px;
color: #666;
font-size: 0.9em;
}
.config-placeholder {
display: flex;
align-items: center;
padding: 15px;
background-color: #e8f4fd;
border: 1px solid #b3d9ff;
border-radius: 4px;
color: #0c5aa6;
}
/* Common label span for form fields */
.form-label-span {
margin-right: 12px;
}
/* Responsive Design */
@media (max-width: 768px) {
.partner-config-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.partner-option {
padding: 12px 0;
}
.partner-config-section {
padding: 15px;
}
}
.partner-selected>span {
font-weight: 600;
}

View File

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

View File

@ -1,45 +1,33 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormGroup, FormBuilder } from '@angular/forms'; import { FormGroup, FormBuilder } from '@angular/forms';
import { SelectItem } from 'primeng/api'; import { SelectItem } from 'primeng/api';
import { Customer, Partner } from '../models/customer.model';
import { Customer } from '../models/customer.model';
import * as customerActions from '../actions/customer.actions'; import * as customerActions from '../actions/customer.actions';
import { UserService } from '@app/domain/services/user.service'; 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 { BaseComp } from '@app/shared/base/base.component';
import { GC, RoleIds, globals, Labels } from '@app/shared/global'; import { RoleIds, globals } from '@app/shared/global';
import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model';
import { SubStripe, SubTexts } from '@app/profile/common';
import { IMembership } from '@app/auth/models/user.model';
import { DateUtils } from '@app/shared/utils';
@Component({ @Component({
selector: 'agm-customer-edit', selector: 'agm-customer-edit',
templateUrl: './customer-edit.component.html', templateUrl: './customer-edit.component.html',
styleUrls: ['./customer-edit.component.css'] styleUrls: ['./customer-edit.component.css']
}) })
export class CustomerEditComponent extends BaseComp implements OnInit { export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy {
readonly globals = globals; readonly globals = globals;
readonly SubTexts = SubTexts;
readonly Labels = Labels;
form: FormGroup; form: FormGroup;
selectedItem: Customer; selectedItem: Customer;
premiumLevels: SelectItem[]; premiumLevels: SelectItem[];
msgs = []; msgs = [];
trialDays: number[];
trialSubs: AGNavSubscription[];
paidSubs: AGNavSubscription[];
trials: Trial;
membership: IMembership;
lang;
// Partner Selection Properties
partnerOptions: SelectItem[] = [];
partnerLoading = false;
partnerError: string | null = null;
private _customer: Customer; private _customer: Customer;
get customer(): Customer { return this._customer; } get customer(): Customer { return this._customer; }
set customer(customer: Customer) { set customer(customer: Customer) {
@ -50,12 +38,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password }, account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password },
premium: this.selectedItem.premium, premium: this.selectedItem.premium,
billable: this.selectedItem.billable, billable: this.selectedItem.billable,
trials: this.selectedItem.membership?.trials,
partner: this.selectedItem.partner || null
}); });
// Set partner selection based on customer.partner field, or null if not set
// Form control will be updated by loadPartners() method
} }
private _isNew: boolean; private _isNew: boolean;
@ -66,8 +49,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
constructor( constructor(
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly userSvc: UserService, private readonly userSvc: UserService,
private readonly partnerSvc: PartnerService,
private readonly fb: FormBuilder private readonly fb: FormBuilder,
) { ) {
super(); super();
this.premiumLevels = [ this.premiumLevels = [
@ -81,13 +64,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
account: [], account: [],
premium: [], premium: [],
billable: [], billable: [],
trials: [],
// Partner form control
partner: [null]
}); });
this.lang = this.authSvc.locale;
} }
ngOnInit() { ngOnInit() {
@ -97,86 +74,23 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
if (customer) { if (customer) {
this._isNew = (customer._id === '0'); this._isNew = (customer._id === '0');
this.customer = customer; this.customer = customer;
this.membership = this.customer?.membership;
if (this.membership) {
this.trials = this.membership.trials;
this.trialSubs = this.membership.subscriptions?.filter((sub) => sub.status === SubStripe.TRIALING) || [];
this.paidSubs = this.membership.subscriptions?.filter((sub) => sub.status !== SubStripe.TRIALING) || [];
}
// Load partners from service
this.loadPartners();
} }
}); });
this.sub$.add(this.appActions.ofTypes([customerActions.CREATE_SUCCESS, customerActions.UPDATE_SUCCESS]) this.sub$.add(this.appActions.ofTypes([customerActions.CREATE_SUCCESS, customerActions.UPDATE_SUCCESS])
.subscribe((action) => { .subscribe((action) => {
this.store.dispatch(new customerActions.Select(action['payload'])); this.store.dispatch(new customerActions.Select(action['payload']));
this.goBack(); this.goBack();
})); }));
this.trialDays = this.appConf.settings.trialDays;
} }
hasLastEndedTrial() {
return this.authSvc.hasLastEndedTrial(this.trials);
}
hasPaidSubs() {
return this.paidSubs?.length > 0;
}
hasTrialSubs() {
return this.trialSubs?.length > 0;
}
saveCustomer() { saveCustomer() {
if (!this.form || !this.form.value || !this.form.valid) return; if (!this.form || !this.form.value || !this.form.valid) return;
this.msgs = []; this.msgs = [];
let custObj;
const updateTrialMembship = (membership?) => {
// Get trials value from form control (includes disabled controls via ControlValueAccessor)
const trialsControl = this.form.get('trials');
const trialsValue = trialsControl ? trialsControl.value : null;
if (trialsValue?.selected) {
const trials: Trial = { ...trialsValue };
delete trials.selected;
// If type is null, but trialDays or byDate exist, set type accordingly
if (trials.type == null) {
if (trials.trialDays && trials.trialDays > 0) {
trials.type = GC.DAYS;
} else if (trials.byDate) {
trials.type = GC.BYDATE;
}
}
if (trials.type === GC.BYDATE) {
trials.trialDays = 0;
} else {
trials.byDate = null;
}
trials.startDate = DateUtils.tsToDate(DateUtils.currUTC());
return membership
? { ...membership, trials }
: { trials };
} else {
return membership
? { ...membership, trials: { ...membership.trials, type: null } }
: { trials: { type: null } };
}
}
custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
{ premium: this.form.value.premium || false },
{ billable: this.form.value.billable || false },
{ partner: this.form.value.partner || null });
this.membership
? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) })
: custObj = Object.assign(custObj, { membership: updateTrialMembship() });
const custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
{ premium: this.form.value.premium || false }, { billable: this.form.value.billable || false });
this.store.dispatch(this._isNew ? new customerActions.Create(custObj) : new customerActions.Update(custObj)); this.store.dispatch(this._isNew ? new customerActions.Create(custObj) : new customerActions.Update(custObj));
} }
@ -205,64 +119,6 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
this.router.navigate(['../', { id: this.customer._id }]); this.router.navigate(['../', { id: this.customer._id }]);
} }
toTimestamp(date: Date): number {
return DateUtils.dateToTS(date);
}
// Partner Methods
private loadPartners(): void {
this.partnerLoading = true;
this.partnerError = null;
this.partnerSvc.getPartners().subscribe({
next: (partners: Partner[]) => {
// Create dropdown options starting with "None" option for AgNav direct customers
this.partnerOptions = [
{
label: Labels.NONE_AGNAV_DIRECT_CUSTOMER,
value: null // null value indicates AgNav direct customer
},
// Add active partners
...partners
.filter(partner => partner.active) // Only show active partners
.map(partner => ({
label: partner.name,
value: partner
}))
];
// Set selectedPartner based on existing customer partner
if (this.customer?.partner && this.partnerOptions.length > 0) {
// Find the partner in options that matches the customer's current partner _id
const matchingOption = this.partnerOptions.find(option =>
option.value && option.value._id === this.customer.partner._id
);
if (matchingOption) {
this.form.patchValue({ partner: matchingOption.value });
}
} else if (!this.customer?.partner) {
// If no partner is set, default to "None" (AgNav direct customer)
this.form.patchValue({ partner: null });
}
this.partnerLoading = false;
},
error: (error) => {
this.partnerError = Labels.FAILED_TO_LOAD_PARTNERS;
this.partnerLoading = false;
console.error('Error loading partners:', error);
}
});
}
onPartnerChange(selectedPartner: Partner | null): void {
this.partnerError = null;
// Update customer partner field
if (this.customer) {
this.customer.partner = selectedPartner;
}
}
ngOnDestroy() { ngOnDestroy() {
super.ngOnDestroy(); super.ngOnDestroy();
} }

View File

@ -3,15 +3,7 @@
<div class="card"> <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"> <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"> <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> <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>
<ng-template pTemplate="header" let-columns> <ng-template pTemplate="header" let-columns>
<tr> <tr>
@ -26,32 +18,26 @@
<i class="ui-icon-search"></i> <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> </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="['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> <span *ngSwitchDefault></span>
</th> </th>
</tr> </tr>
</ng-template> </ng-template>
<ng-template pTemplate="body" let-cust>
<ng-template pTemplate="body" let-rowData let-columns="columns"> <tr [pSelectableRow]="cust">
<tr [pSelectableRow]="rowData"> <td>{{cust.name}}</td>
<td *ngFor="let col of columns" [ngSwitch]="col.field"> <td>{{cust.username}}</td>
<span class="ui-column-title">{{col.header}}</span> <td>{{cust.contact}}</td>
<span *ngSwitchCase="CREATED">{{rowData[col.field] | date:'shortDate'}}</span> <td>{{cust.totalJobs}}</td>
<span *ngSwitchCase="ACTIVE"> <td>{{cust.createdAt | date:'shortDate' }}</td>
<p-checkbox [ngModel]="rowData[ACTIVE]" disabled binary="true"></p-checkbox> <td style="text-align: center">
</span> <p-checkbox [ngModel]="cust.billable" disabled binary="true"></p-checkbox>
<span *ngSwitchCase="BILLABLE"> </td>
<p-checkbox [ngModel]="rowData[BILLABLE]" disabled binary="true"></p-checkbox> <td style="text-align: center">
</span> <p-checkbox [ngModel]="cust.active" disabled binary="true"></p-checkbox>
<span *ngSwitchCase="PARTNER">{{rowData[col.field]?.name}}</span>
<span *ngSwitchDefault>{{rowData[col.field]}}</span>
</td> </td>
</tr> </tr>
</ng-template> </ng-template>
<ng-template pTemplate="paginatorleft" let-state> <ng-template pTemplate="paginatorleft" let-state>
{{ state.totalRecords | i18nPlural: totalItems }} {{ state.totalRecords | i18nPlural: totalItems }}
</ng-template> </ng-template>

View File

@ -7,7 +7,7 @@ import { Table } from 'primeng/table';
import { Customer } from '../models/customer.model'; import { Customer } from '../models/customer.model';
import * as fromCustomers from '../reducers'; import * as fromCustomers from '../reducers';
import * as customerActions from '../actions/customer.actions'; import * as customerActions from '../actions/customer.actions';
import { globals, OperationalStatus } from '@app/shared/global'; import { globals } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component'; import { BaseComp } from '@app/shared/base/base.component';
@ -17,11 +17,6 @@ import { BaseComp } from '@app/shared/base/base.component';
styleUrls: ['./customer-list.component.css'] styleUrls: ['./customer-list.component.css']
}) })
export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy { export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy {
readonly CREATED = 'createdAt';
readonly ACTIVE = OperationalStatus.ACTIVE;
readonly BILLABLE = 'billable';
readonly PARTNER = 'partner';
readonly PARTNER_NAME = 'partnerName';
customers: Array<Customer>; customers: Array<Customer>;
curCust: Customer; curCust: Customer;
@ -29,10 +24,8 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
@ViewChild("dt") dt: Table; @ViewChild("dt") dt: Table;
statuses: SelectItem[]; statuses: SelectItem[];
partners: SelectItem[];
cols: any[]; cols: any[];
totalItems; totalItems;
isSelfSignup = false;
constructor( constructor(
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
@ -51,52 +44,23 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
{ field: "username", header: globals.userName, filtered: true, filterMatchMode: 'contains' }, { field: "username", header: globals.userName, filtered: true, filterMatchMode: 'contains' },
{ field: "contact", header: globals.contact }, { field: "contact", header: globals.contact },
{ field: "totalJobs", header: globals.jobs, width: '5%', filtered: false }, { field: "totalJobs", header: globals.jobs, width: '5%', filtered: false },
{ field: this.CREATED, header: globals.from, width: '6%' }, { field: "createdAt", header: globals.from, width: '6%' },
{ field: this.BILLABLE, header: "Billable", width: '9%' }, // { field: "email", header: globals.email, filtered: true, filterMatchMode: 'contains' },
{ field: this.ACTIVE, header: globals.active, width: '9%' }, { field: "billable", header: "Billable", width: '9%'},
{ field: this.PARTNER_NAME, header: globals.partner, width: '9%' } { field: "active", header: globals.active, width: '9%' },
]; ];
} }
ngOnInit() { ngOnInit() {
const saved = localStorage.getItem('isSelfSignup'); this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(
this.isSelfSignup = saved === 'true'; (customers) => this.customers = customers);
this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
this.setCustomersAndPartners(customers);
});
this.sub$.add(this.store.select(fromCustomers.getSelectedCustomer).subscribe(cust => { this.sub$.add(this.store.select(fromCustomers.getSelectedCustomer).subscribe(cust => {
this.curCust = cust; this.curCust = cust;
})); }));
this.store.dispatch(new customerActions.Fetch()); 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) { onRowSelect(event) {
this.store.dispatch(new customerActions.Select(event.data)); this.store.dispatch(new customerActions.Select(event.data));
} }
@ -124,6 +88,10 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
}); });
} }
billableOverview() {
}
ngOnDestroy() { ngOnDestroy() {
super.ngOnDestroy(); super.ngOnDestroy();
} }

View File

@ -24,7 +24,7 @@ export class CustomerResolver implements Resolve<Customer> {
if (id === '0') { if (id === '0') {
return createNewCustomer(); return createNewCustomer();
} else { } else {
return this.customerService.getCustomer(id, 'edit').pipe( return this.customerService.getCustomer(id).pipe(
map((cust) => { map((cust) => {
if (cust) { if (cust) {
return cust; return cust;

View File

@ -19,6 +19,7 @@ const routes: Routes = [
roles: [RoleIds.ADMIN] roles: [RoleIds.ADMIN]
}, },
canActivate: [AuthGuard], canActivate: [AuthGuard],
// canActivateChild: [AuthGuard],
children: [ children: [
{ {
path: '', path: '',
@ -32,7 +33,7 @@ const routes: Routes = [
component: CustomerEditComponent, component: CustomerEditComponent,
data: { data: {
roles: [RoleIds.ADMIN] roles: [RoleIds.ADMIN]
}, }, // canDeactivate: [CanDeactivateGuard],
resolve: [CustomerResolver] resolve: [CustomerResolver]
}, },
] ]

View File

@ -17,14 +17,13 @@ import { AppSharedModule } from '../shared/app-shared.module';
import { StoreModule } from '@ngrx/store'; import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import * as fromCustomers from './reducers/customers.reducer'; import * as fromCustomers from './reducers/customers-reducer';
import { CustomerEffects } from './effects/customer.effects'; import { CustomerEffects } from './effects/customer.effects';
import { CustomerListComponent } from './customer-list/customer-list.component'; import { CustomerListComponent } from './customer-list/customer-list.component';
import { CustomerEditComponent } from './customer-edit/customer-edit.component'; import { CustomerEditComponent } from './customer-edit/customer-edit.component';
import { CustomersRoutingModule } from './customer-routing.module'; import { CustomersRoutingModule } from './customer-routing.module';
import { CustomerMgtComponent } from './customer-mgt.component'; import { CustomerMgtComponent } from './customer-mgt.component';
import { TrialComponent } from './trial/trial.component';
@NgModule({ @NgModule({
imports: [ imports: [
@ -46,7 +45,7 @@ import { TrialComponent } from './trial/trial.component';
EffectsModule.forFeature([CustomerEffects]), EffectsModule.forFeature([CustomerEffects]),
CustomersRoutingModule CustomersRoutingModule
], ],
declarations: [CustomerMgtComponent, CustomerListComponent, CustomerEditComponent, TrialComponent], declarations: [CustomerMgtComponent, CustomerListComponent, CustomerEditComponent],
providers: [], providers: [],
schemas: [ schemas: [
CUSTOM_ELEMENTS_SCHEMA CUSTOM_ELEMENTS_SCHEMA

View File

@ -1,31 +1,17 @@
import { RoleIds } from '@app/shared/global'; import { RoleIds } from '@app/shared/global';
import { createNewUser, User } from '@app/accounts/models/user.model'; import { createNewUser, User } from '@app/accounts/models/user.model';
import { IMembership } from '@app/auth/models/user.model';
export interface Customer extends User { export interface Customer extends User {
contact?: string; contact?: string;
fax?: string; fax?: string;
premium: number; premium: number;
billable?: boolean; billable?: boolean;
totalJobs?: number;
membership: IMembership,
partner?: Partner;
selfSignup?: boolean;
}
export interface Partner { totalJobs?: number; // extension field for GUI
_id: string;
name: string;
description: string;
kind: string; // Required to match User interface
active?: boolean;
createdAt?: string;
updatedAt?: string;
} }
export const createNewCustomer = () => { export const createNewCustomer = () => {
const customer = createNewUser(null, RoleIds.APP) as Customer; const customer = <Customer>createNewUser(null, RoleIds.APP);
customer.premium = 0; customer.premium = 0;
customer.membership = {} as IMembership; // Initialize required membership property
return customer; return customer;
} }

View File

@ -3,7 +3,7 @@ import {
createFeatureSelector, createFeatureSelector,
} from '@ngrx/store'; } from '@ngrx/store';
import * as fromCustomers from './customers.reducer'; import * as fromCustomers from './customers-reducer';
export const getCustomersState = createFeatureSelector<fromCustomers.State>(fromCustomers.FEATURE_KEY); export const getCustomersState = createFeatureSelector<fromCustomers.State>(fromCustomers.FEATURE_KEY);

View File

@ -1,8 +0,0 @@
.trial-row {
margin-top: 1em;
padding-left: 0;
}
#day-label {
padding-top: 2px;
}

View File

@ -1,36 +0,0 @@
<div [formGroup]="form">
<div class="ui-g-12 trial-row">
<p-checkbox binary="true" label="Trial" formControlName="selected" (onChange)="change()"></p-checkbox>
<ng-container *ngIf="form.get('selected').value">
<span style="margin-left: 1em; margin-right: 1em;"><p-radioButton [value]="DAYS" label="Number of days" formControlName="type"></p-radioButton></span>
<p-radioButton [value]="BYDATE" label="By date" formControlName="type"></p-radioButton>
</ng-container>
</div>
<ng-container *ngIf="form.get('selected').value">
<div class="ui-g-12 trial-row">
<ng-container *ngIf="form.get('type').value === DAYS; else byDate">
<span style="margin-right:12px"><ng-container i18n="@@endInDays">End in days</ng-container>:</span>
<p-dropdown formControlName="trialDays" [filter]="true" editable="true" [style]="{'max-width': '100px'}" [options]="dayItems" [showClear]="true" maxlength="6" [panelStyle]="{'width': '50px'}" (keyup.enter)="change()" (onChange)="change()">
<ng-template let-item pTemplate="item">
<div class="ui-g">
<div id="day-label" class="ui-g-8 no-pad">{{item.label}}</div>
<div class="ui-g-4 no-pad" style="text-align: center;"><button class="no-pad" style="border: unset; background: none;" (click)="remove(item)"><i class="pi pi-times"></i></button></div>
</div>
</ng-template>
</p-dropdown>
<ng-container *ngIf="error">
<span class="ui-message ui-messages-error ui-corner-all">{{error}}</span>
</ng-container>
</ng-container>
<ng-template #byDate>
<span style="margin-right:12px"><ng-container i18n="@@endDate">End date</ng-container>:</span>
<p-calendar [(ngModel)]="toDate" [ngModelOptions]="{standalone: true}" [locale]="locale" [dateFormat]="locale.dateFormat" [showIcon]="true" [inputStyle]="{'width':'120px'}" [minDate]="calMinDate" [maxDate]="calMaxDate" [name]="BYDATE" (onSelect)="changeCal()" [disabled]="disable"></p-calendar>
<ng-container *ngIf="error">
<span class="ui-message ui-messages-error ui-corner-all">{{error}}</span>
</ng-container>
</ng-template>
</div>
</ng-container>
</div>

View File

@ -1,201 +0,0 @@
import { AfterContentInit, Component, HostListener, Input, OnDestroy, OnInit, forwardRef } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Trial } from '@app/domain/models/subscription.model';
import { BaseComp } from '@app/shared/base/base.component';
import { GC } from '@app/shared/global';
import { DateUtils } from '@app/shared/utils';
import { SelectItem } from 'primeng-lts/api';
const MIN_DAYS = 7;
@Component({
selector: 'trial',
templateUrl: './trial.component.html',
styleUrls: ['./trial.component.css'],
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TrialComponent), multi: true },
{ provide: NG_VALIDATORS, useExisting: forwardRef(() => TrialComponent), multi: true }
]
})
export class TrialComponent extends BaseComp implements OnDestroy, OnInit, AfterContentInit {
@Input() trialDays: number[];
@Input() trials: Trial;
@Input() disable: boolean;
DAYS = GC.DAYS;
BYDATE = GC.BYDATE;
dayItems: SelectItem[];
form: FormGroup;
toDate: Date;
error: string;
calMinDate: Date;
calMaxDate: Date;
onChange: any = () => { };
onTouched: any = () => { };
constructor(
private readonly fb: FormBuilder
) {
super();
const ONE_YEAR = 1;
this.calMinDate = new Date();
this.calMinDate.setDate(this.calMinDate.getDate() + MIN_DAYS);
this.calMinDate.setHours(0, 0, 0);
this.calMaxDate = new Date();
this.calMaxDate.setFullYear(this.calMaxDate.getFullYear() + ONE_YEAR);
}
get value() {
// CRITICAL: Use getRawValue() to include disabled controls (selected, type, trialDays)
return this.form.getRawValue();
}
set value(val) {
this.writeValue(val);
this.onChange(val);
this.onTouched(val);
}
ngOnInit(): void {
this.form = this.fb.group({
selected: new FormControl({ value: false, disabled: this.disable }),
type: new FormControl({ value: '', disabled: this.disable }),
startDate: [],
lastEndDate: [],
lastStartDate: [],
trialDays: new FormControl({ value: '', disabled: this.disable }),
byDate: []
});
this.dayItems = this.trialDays?.map((day) => ({ label: `${day}`, value: day }));
// CRITICAL FIX: Use getRawValue() to include disabled controls in onChange callback
this.sub$.add(this.form.valueChanges.subscribe(() => {
const rawValue = this.form.getRawValue();
this.onChange(rawValue);
this.onTouched(rawValue);
}));
}
ngAfterContentInit() {
// Check if user has valid trial configuration OR component is disabled (has active trial subscriptions)
const hasExistingTrial = (this.trials?.type && (this.trials.trialDays >= MIN_DAYS || this.trials.byDate))
|| (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate));
if (hasExistingTrial) {
if (this.trials?.type === this.BYDATE && this.trials.byDate) {
this.toDate = new Date(this.trials.byDate);
this.form.patchValue({ ...this.trials, selected: true });
} else if (this.trials?.type === this.DAYS && this.trials.trialDays >= MIN_DAYS) {
this.form.patchValue({ ...this.trials, selected: true });
} else if (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate)) {
// User has active trial subscriptions (disable=true) - always set selected=true
// This prevents accidental trial disable when admin edits customer with active trials
// Determine correct type from trial configuration (byDate takes precedence over trialDays)
const trialType = this.trials?.byDate ? this.BYDATE : this.DAYS;
if (trialType === this.BYDATE) {
this.toDate = new Date(this.trials.byDate);
}
// When controls are disabled, patchValue() ignores them - must use setValue() directly
this.form.get('selected').setValue(true);
this.form.get('type').setValue(trialType);
this.form.get('trialDays').setValue(this.trials.trialDays);
this.form.patchValue({
startDate: this.trials.startDate,
lastEndDate: this.trials.lastEndDate,
lastStartDate: this.trials.lastStartDate,
byDate: this.trials.byDate
});
} else {
this.form.patchValue({ ...this.trials });
}
}
}
writeValue(val): void {
if (val) {
this.form.patchValue(val);
}
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
validate() {
this.checkAndDisplayErr();
const isTrial = this.form.value.selected;
const isInValid = !this.isValidTrialDays() && !this.isValidBydate();
return isTrial ? isInValid ? { trials: { valid: false } } : null : null;
}
isValidTrialDays() {
return this.form.value.type === this.DAYS && this.form.value.trialDays && !isNaN(this.form.value.trialDays) && this.form.value.trialDays >= MIN_DAYS;
}
isValidBydate() {
return this.form.value.type === this.BYDATE && this.form.value.byDate;
}
change() {
const noPrevTrial = !this.form.value.type;
if (noPrevTrial) {
this.form.patchValue({ type: this.DAYS });
}
const trialDays = this.form.value.trialDays;
const notExistItem = this.dayItems?.every((item) => item.value != trialDays);
if (this.isValidTrialDays() && notExistItem) {
this.dayItems.push({ label: `${trialDays}`, value: Number(trialDays) });
this.dayItems.sort((a, b) => a.value - b.value);
this.appConf.saveTrialDays(this.dayItems?.map((item) => item.value));
}
this.form.updateValueAndValidity();
}
changeCal() {
this.form.patchValue({
...this.trials,
type: this.BYDATE,
byDate: DateUtils.tsToDate(
DateUtils.endUtcTS(
DateUtils.dateToTS(this.toDate)))
});
this.form.updateValueAndValidity();
}
checkAndDisplayErr() {
this.error = '';
if (this.form.value.selected && this.form.value.type === this.BYDATE) {
if (!this.toDate) {
return this.error = `Please fill in end date MM/DD/YYYY from <the mininum ${MIN_DAYS} days ahead>`;
}
} else if (this.form.value.selected && this.form.value.type === this.DAYS) {
if (!this.form.value.trialDays) {
return this.error = 'Please fill in the number of end days';
} else if (isNaN(this.form.value.trialDays) || this.form.value.trialDays < MIN_DAYS) {
return this.error = `Number of days must be number greater than <the mininum ${MIN_DAYS} days ahead>.`;
}
}
return this.error;
}
remove(item) {
this.dayItems = this.dayItems?.filter((day) => day.label !== item.label);
this.appConf.saveTrialDays(this.dayItems?.map((item) => item.value));
}
@HostListener('document:keydown.enter', ['$event']) onKeydownHandler(e: KeyboardEvent) {
const target = <HTMLInputElement>e.target;
if (target.name === this.BYDATE) this.changeCal();
}
ngOnDestroy(): void {
super.ngOnDestroy();
}
}

View File

@ -1,3 +0,0 @@
.pure-white {
color: #FFFFFF;
}

View File

@ -1,9 +1,5 @@
<div class="ui-g"> <div class="ui-g">
<ng-container [ngTemplateOutlet]="disclaimerSection"></ng-container> <div class="ui-g-12">
</div>
<ng-template #disclaimerSection>
<section class="ui-g-12">
<div class="card card-title"> <div class="card card-title">
<h2 i18n="Welcome to Agmission header@@welcome">Welcome to AgMission</h2> <h2 i18n="Welcome to Agmission header@@welcome">Welcome to AgMission</h2>
<br /> <br />
@ -13,5 +9,5 @@
<p>BY ACCESSING AND USING THE APPLICATION, YOU AGREE TO THE TERMS AND CONDITIONS EXPRESSED UPON.</p> <p>BY ACCESSING AND USING THE APPLICATION, YOU AGREE TO THE TERMS AND CONDITIONS EXPRESSED UPON.</p>
</ng-container> </ng-container>
</div> </div>
</section> </div>
</ng-template> </div>

View File

@ -1,11 +1,15 @@
import { Component } from '@angular/core'; import { Component, OnInit } from '@angular/core';
@Component({ @Component({
selector: 'agm-dashboard', selector: 'agm-dashboard',
templateUrl: './dashboard.component.html', templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.css'] styleUrls: ['./dashboard.component.css']
}) })
export class DashboardComponent { export class DashboardComponent implements OnInit {
constructor() { } constructor() { }
ngOnInit() {
}
} }

View File

@ -1,137 +1,78 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, Route } from '@angular/router'; import {
import { Observable, of } from 'rxjs'; CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, Route,
import { take, map, catchError, switchMap } from 'rxjs/operators'; } from '@angular/router';
import { Observable } from 'rxjs';
import { take, map } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import * as fromStore from '../../reducers'; import * as fromStore from '../../reducers';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { SUB, SubStripe } from '@app/profile/common';
import { FEATURE_KEY } from '@app/profile/reducers';
import { Status, StripeSubscription } from '../models/subscription.model';
import { SubscriptionService } from '../services/subscription.service';
import { AC } from '@app/shared/global';
import { RouterUtilsService } from '@app/shared/router-utils.service';
import { ClearSubscriptionStatus } from '@app/actions/subscription.actions';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate, CanActivateChild { export class AuthGuard implements CanActivate, CanActivateChild {
constructor( constructor(
private readonly store: Store<{}>, private readonly store: Store<{}>,
private readonly authSvc: AuthService, private authService: AuthService,
private readonly router: Router, private router: Router) {
private readonly subSvc: SubscriptionService,
private readonly routerUtils: RouterUtilsService
) { }
canLoad(route: Route): boolean {
return this.checkRoles(route?.data?.roles);
} }
canActivate(route: ActivatedRouteSnapshot, routerState: RouterStateSnapshot): Observable<boolean> { canLoad(route: Route): boolean {
let subs: StripeSubscription[], status: Status; const url = `/${route.path}`;
return this.store.select(fromStore.getSubscriptionState).pipe( return this.checkRoles(url, route.data.roles || null);
switchMap((subState) => { }
subs = subState?.entries;
status = subState?.status; canActivate(
return this.store.select(fromStore.selectIsLoggedIn) route: ActivatedRouteSnapshot,
}), routerState: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
take(1),
return this.checkStoreAuth().pipe(
// mergeMap((storeAuth) => {
// if (storeAuth)
// return of(true);
// return this.checkApiAuth();
// }),
map(storeOrApiAuth => { map(storeOrApiAuth => {
const LOCAL_TEMP_FLAG = 'requiredSubAttention'; if (!storeOrApiAuth) {
const TEMP_FLAG_VALUE = 'true';
const hasAllowedRoles = this.checkRoles(route);
const hasNotAuth = !storeOrApiAuth;
if (hasNotAuth) {
this.router.navigate(['/login'], { replaceUrl: true }); this.router.navigate(['/login'], { replaceUrl: true });
return false; return false;
} }
return this.checkRoles(routerState.url, route);
// Early exit for partner users - they bypass all subscription checks
if (this.authSvc.isPartner) {
return hasAllowedRoles;
}
const requiresResolution = (): boolean => {
const hasUnresolvedSubs = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.UNPAID) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.subSvc.hasInValTaxLoc(subs);
if (hasUnresolvedSubs && hasAllowedRoles) {
const hasReqAttentionFlag = localStorage.getItem(LOCAL_TEMP_FLAG) === TEMP_FLAG_VALUE;
const isNavToProfile = routerState.url.includes(SUB.PROFILE);
if (isNavToProfile) {
if (hasReqAttentionFlag) {
localStorage.removeItem(LOCAL_TEMP_FLAG);
}
return true;
}
if (!hasReqAttentionFlag) {
const profile = JSON.parse(sessionStorage.getItem(FEATURE_KEY));
const isFirstLoggin = !profile?.usage || Object.keys(profile?.usage).length === 0;
if (isFirstLoggin) {
localStorage.setItem(LOCAL_TEMP_FLAG, TEMP_FLAG_VALUE);
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES], { replaceUrl: true });
return true;
}
const accountNotLocked = this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE);
if (accountNotLocked) {
return true;
}
}
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES], { replaceUrl: true });
return;
}
if (this.subSvc.isUnderReview(status)) {
const fromPath = this.routerUtils.getCurrentUrl().split('/').pop();
if (routerState.url.includes(AC)) return true;
if (fromPath.includes(AC)) {
this.store.dispatch(new ClearSubscriptionStatus());
return true;
};
this.router.navigate(['entities', AC], { replaceUrl: true });
return;
}
}
const shouldNavToServices = () => {
const trials = this.authSvc.user?.membership?.trials;
if (routerState.url.includes(SUB.SERVICES)
|| (routerState.url.includes('home') && !!trials?.type)) return true;
const canNavToServices = !this.authSvc.isAdmin
&& !this.authSvc.hasSubs()
&& (!trials?.type || (!trials?.byDate && trials?.trialDays === 0));
if (canNavToServices) {
this.router.navigate([SUB.PROFILE, SUB.SERVICES]);
return;
}
}
const canNav = requiresResolution() || shouldNavToServices() || hasAllowedRoles;
return canNav;
}), }),
catchError(err => {
console.log(err);
return of(false)
})
); );
} }
canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | Observable<boolean> | Promise<boolean> { canActivateChild(
childRoute: ActivatedRouteSnapshot,
state: RouterStateSnapshot): boolean | Observable<boolean> | Promise<boolean> {
return this.canActivate(childRoute, state); return this.canActivate(childRoute, state);
} }
checkRoles(route: ActivatedRouteSnapshot): boolean { checkStoreAuth() {
const hasRoles = !!route?.data?.roles; return this.store.select(fromStore.selectIsLoggedIn).pipe(take(1));
if (hasRoles) { }
const hasAllRoles = '*' === route.data.roles;
const hasAccessedByRoles = hasAllRoles || this.authSvc.hasRole(route.data.roles); // checkApiAuth() {
return hasAccessedByRoles; // return this.authService.check().pipe(
// map(user => !!user),
// catchError(() => of(false))
// );
// }
checkRoles(url: string, route: ActivatedRouteSnapshot): boolean {
if (route.data.roles) {
if ('*' === route.data.roles || this.authService.hasRole(route.data.roles)) {
return true;
} else { } else {
return false;
}
}
else
return true; return true;
} }
}
} }

View File

@ -1,53 +0,0 @@
import { Injectable } from '@angular/core';
import { CanActivate, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { SUB } from '@app/profile/common';
/**
* Generic guard for notification deep-link URLs (e.g. /manage-subscription, /update-pm).
* All routing logic is declared in the route's `data` no new guard file needed per URL.
*
* Route data shape:
* data: {
* // Required: where to send an authenticated user
* redirectTo: string[];
*
* // Optional: alternate destination when master account has no subscriptions
* redirectToNoSubs?: string[];
*
* // Optional: i18n message shown in the login <p-messages> bar
* loginNotice?: string;
* }
*
* Adding a new notification URL = one route entry, zero new files.
*/
@Injectable({ providedIn: 'root' })
export class NotificationRedirectGuard implements CanActivate {
constructor(
private readonly authSvc: AuthService,
private readonly router: Router
) { }
canActivate(route: ActivatedRouteSnapshot): UrlTree {
const { redirectTo, redirectToNoSubs } = route.data as {
redirectTo: string[];
redirectToNoSubs?: string[];
};
if (!this.authSvc.loggedIn) {
const { loginNotice } = route.data as { loginNotice?: string };
return this.router.createUrlTree(['/login'], {
queryParams: {
returnUrl: route.url.map(s => s.path).join('/'),
...(loginNotice ? { loginNotice } : {})
}
});
}
const isMaster = !this.authSvc.user?.parent;
if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) {
return this.router.createUrlTree(redirectToNoSubs);
}
return this.router.createUrlTree(redirectTo);
}
}

View File

@ -1,25 +0,0 @@
import { Injectable } from '@angular/core';
import {
CanActivate, ActivatedRouteSnapshot
} from '@angular/router';
import { AuthService } from '../services/auth.service';
@Injectable({ providedIn: 'root' })
export class RoleGuard implements CanActivate {
constructor(
private authService: AuthService) {
}
canActivate(route: ActivatedRouteSnapshot): boolean {
if (route.data.roles) {
if ('*' === route.data.roles || this.authService.hasRole(route.data.roles)) {
return true;
} else {
return false;
}
}
else
return true;
}
}

View File

@ -10,19 +10,7 @@ export class SettingsGuard implements CanActivate {
constructor(private readonly appCnf: AppConfigService) { } constructor(private readonly appCnf: AppConfigService) { }
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> { 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. // Make sure to load the config whenever the module is accessed.
const loadResult = this.appCnf.load(); return 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;
} }
} }

View File

@ -1,12 +0,0 @@
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { SubscriptionService } from '../services/subscription.service';
@Injectable({ providedIn: 'root' })
export class StripeLoadGuard implements CanActivate {
constructor(private readonly subSvc: SubscriptionService) { }
canActivate(): Promise<boolean> {
return this.subSvc.loadStripePromise().then(() => true).catch(() => false);
}
}

View File

@ -1,117 +0,0 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { SubscriptionService } from '../services/subscription.service';
import { catchError, map } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import { StripeSubscription } from '../models/subscription.model';
import { SUB, SubStripe } from '@app/profile/common';
import { AppMessageService } from '@app/shared/app-message.service';
import { AC, globals } from '@app/shared/global';
import { RouterUtilsService } from '@app/shared/router-utils.service';
/**
This guards against unresolved subscriptions when the user is subscribing to a new subscription in the
main flow (services <->billing-detail<->checkout<->checkout-review<->checkout-confirm). When the user has an unresolved subscription, the guard forces user to enter the resolving subscription flow.
*/
@Injectable({ providedIn: 'root' })
export class SubscriptionGuard implements CanActivate {
constructor(
private readonly authSvc: AuthService,
private readonly subSvc: SubscriptionService,
private readonly router: Router,
private readonly msgSvc: AppMessageService,
private readonly routerUtils: RouterUtilsService
) { }
canActivate(activatedRoute: ActivatedRouteSnapshot): Observable<boolean> {
return this.subSvc.fetchSubscriptions(this.authSvc.user?.membership?.custId).pipe(
map((subs) => {
const fromPath = this.routerUtils.getCurrentUrl().split('/').pop();
const toPath = activatedRoute.url[0]?.path;
const hasLatestSubs = subs?.length > 0;
const hasUnresolvedSubs = this.hasUnresolvedSubs(subs);
const hasAllActiveSubs = hasLatestSubs && !hasUnresolvedSubs;
const isSubscribing = (!hasLatestSubs || hasAllActiveSubs)
&& (this.isInMainFlow(fromPath, toPath)
|| this.isPageReload(fromPath, toPath));
const isResolvingSubs = hasUnresolvedSubs
&& (this.isInResolvingFlow(fromPath, toPath)
|| this.isPageReload(fromPath, toPath));
const viewPayment = this.isPaymentPage(toPath);
const viewPaymentMethod = this.isPaymentMethodPage(toPath);
const viewAC = this.isACPage(toPath) && !hasUnresolvedSubs;
const canNavigate = isSubscribing || viewPayment || viewPaymentMethod || viewAC || true;
const shouldNavigateToMyServices = hasUnresolvedSubs && !isResolvingSubs;
if (canNavigate) {
return true;
} else if (shouldNavigateToMyServices) {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
} else if (isResolvingSubs) {
return true;
} else {
this.router.navigate(['/', SUB.HOME]);
}
}),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subscription));
return of(false);
})
);
}
private isInMainFlow(fromPath: string, toPath: string): boolean {
return (fromPath === SUB.SERVICES && toPath === SUB.BILL_ADR) ||
(fromPath === SUB.BILL_ADR && toPath === SUB.CHKOUT) ||
(fromPath === SUB.CHKOUT && toPath === SUB.BILL_ADR) ||
(fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_REV) ||
(fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT_CONF) ||
(fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT) ||
this.isTrialFLow(fromPath, toPath);
}
private isInResolvingFlow(fromPath: string, toPath: string): boolean {
return (fromPath === SUB.UNPAID_SUB && toPath === SUB.BILL_ADR) ||
(fromPath === SUB.BILL_ADR && toPath === SUB.CHKOUT) ||
(fromPath === SUB.BILL_ADR && toPath === SUB.UNPAID_SUB) ||
(fromPath === SUB.CHKOUT && toPath === SUB.BILL_ADR) ||
(fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_REV) ||
(fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT) ||
(fromPath === SUB.CHKOUT_REV && toPath === SUB.CHKOUT_CONF) ||
(fromPath === SUB.MY_SERVICES && toPath === SUB.CHKOUT_REV) ||
(fromPath === SUB.MY_SERVICES && toPath === SUB.UNPAID_SUB);
}
private isTrialFLow(fromPath: string, toPath: string): boolean {
return (fromPath === SUB.CHKOUT && toPath === SUB.CHKOUT_CONF) ||
(fromPath === SUB.MY_SERVICES && toPath === SUB.BILL_ADR);
}
private isPageReload(fromPath: string, toPath: string): boolean {
return (!fromPath && (toPath === SUB.BILL_ADR || toPath === SUB.CHKOUT || toPath === SUB.CHKOUT_REV || toPath === SUB.CHKOUT_CONF));
}
private hasUnresolvedSubs(subs: StripeSubscription[]): boolean {
return subs?.some(sub => sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE || sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE);
}
private isPaymentPage(path: string): boolean {
return path === SUB.PM_HISTORY || path === SUB.PM_DETAIL;
}
private isPaymentMethodPage(path: string): boolean {
return path === SUB.PM_LIST;
}
private isACPage(path: string): boolean {
return path === AC;
}
}

View File

@ -1,15 +0,0 @@
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { SubType } from '@app/profile/common';
@Injectable({ providedIn: 'root' })
export class UsageDetailGuard implements CanActivate {
constructor(private authService: AuthService) { }
canActivate(): boolean {
const hasPkg = this.authService.user?.membership?.subscriptions?.some((sub) => sub.type === SubType.PACKAGE);
if (hasPkg) return true;
}
}

View File

@ -23,7 +23,4 @@ export interface IAppConfig {
}; };
noPopup: boolean; noPopup: boolean;
trialDays: [number];
/** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */
promoMinExpiryDays?: number;
} }

View File

@ -45,7 +45,7 @@ export class PlayRecord {
// Output 3 // Output 3
areaName: string; areaName: string;
totLnLength: number; totLnLength: number;
applicRate: number; // Applic. Rate: Application rate in Gals/Acre or Liters/Ha. Value is read from the Q file or the job. applicRate: number; // Applic. Rate: Application rate in Gals/Acre or Liters/Ha. Value is ead from the Q file or the job.
mappedArea: number; mappedArea: number;
overSprayed: number; overSprayed: number;
pilotName: string; pilotName: string;

View File

@ -1,735 +0,0 @@
import { IMembership } from '@app/auth/models/user.model';
import { InvType, Mode } from '@app/profile/common';
import { StripeCardCvcElement, StripeCardElement, StripeCardExpiryElement, StripeCardNumberElement } from '@stripe/stripe-js';
export type PriceUsd = string | number;
export interface Price {
type: string;
lookupKey: string;
priceUSD: PriceUsd,
level: number;
maxVehicles: number;
maxAcres: number;
}
export interface BasePackage {
price: PriceUsd;
quantity?: number;
metadata?: {
tier: string;
level?: string;
maxAcres?: string;
maxVehicles?: string;
}
}
export interface Addon extends BasePackage {
priceId?: string;
name?: string;
desc: string;
lookupKey: string;
trialEnd?: number;
interval?: string; // Billing interval ('year' or 'month')
}
export interface Package extends BasePackage {
priceId?: string;
desc: string;
maxVehicles?: number;
Vehicles?: string;
maxAcres?: string;
lookupKey: string;
level?: number;
trialEnd?: number;
interval?: string; // Billing interval ('year' or 'month')
}
export interface Address {
_id?: string;
name?: string;
valid?: boolean;
city?: string;
country: string;
line1: string;
line2?: string | null;
postalCode?: string;
state?: string,
isBilling?: boolean;
}
export interface Card {
pmId?: string;
brand: string;
country: string;
exp_month: number;
exp_year: number;
last4: string;
defaultPM: boolean;
}
export interface BillingInfo {
applicatorId: string;
name: string;
address
email?: string;
}
export interface PaymentMethod {
id: string;
created: number;
card: Card;
billing_details: BillingInfo;
}
export interface InvoicePackage {
custId: string;
package: string;
addons: BasePackage[];
prorateTS?: number; // Optional: only needed for proration calculations
coupon?: string;
}
export interface Line {
id: string;
amount: number;
amount_excluding_tax: number;
period: {
end: number;
start: number
};
description: string;
subscription: string;
quantity: number;
plan: {
id: string;
amount: number;
}
price: {
lookup_key: string;
}
// Issue 4 - Proration credit detection
proration?: boolean; // True for proration credits (unused subscription time)
type?: string; // 'subscription', 'invoiceitem', etc.
}
/**
* Describes a deferred promo that will apply from the next billing period.
* Shape is identical to the inline `promoDetails` object on subscriptions,
* with two additional discriminant flags. Backend builds this from
* subscription.metadata.pending_coupon_id (r975+).
*/
export interface PendingPromoDetails {
isPending: true;
appliesToNextPeriod: true;
name: string;
discountDisplay: string; // 'FREE', '50% OFF', '$9.99 OFF'
percentOff: number | null;
amountOff: number | null; // cents
currency: string | null;
duration: string | null; // 'forever' | 'once' | 'repeating'
durationInMonths: number | null;
expiresAt: null;
discountEndsAt: null;
daysRemaining: null;
daysUntilDiscountEnds: null;
isTimeLimited: false;
}
export interface Invoice {
id: string;
subscription: string;
tax: number;
total: number;
object: string;
subtotal_excluding_tax: number;
lines?: {
data: Line[]
}
number?: number;
subtotal?: number;
total_tax_amounts?: Taxable[]
subscription_proration_date?: number;
total_excluding_tax?: number;
created?: number;
paid?: boolean;
status?: string;
amount_due?: number;
amount_paid?: number;
hosted_invoice_url?: string;
invoice_pdf?: string;
customer_name?: string;
customer_email?: string;
customer_address?: {
city: string;
country: string;
line1: string;
postal_code: string;
state: string;
};
attempted?: boolean;
type: InvType.INVOICE;
discount?: {
coupon: Coupon
};
/** Invoice period type: "current" (immediate billing) or "next" (future billing cycle) */
period_type?: string;
/** Flag indicating if this invoice has promotional pricing applied */
has_promo?: boolean;
/** @deprecated r975: field no longer populated by backend. Use pendingPromoDetails instead. */
promo_coupon?: string;
/** Unix timestamp (seconds) — when the next charge is collected. Use * 1000 for a JS Date. (r975+) */
next_billing_date?: number;
/** Present when a deferred 100% FREE promo is scheduled for next billing period (r975+). */
pendingPromoDetails?: PendingPromoDetails;
/** Discount amounts applied on this invoice. Each entry is { amount (cents), discount (Stripe discount ID) }. */
total_discount_amounts?: { amount: number; discount: string }[];
}
export interface Charge {
id: string;
object: string;
amount: number;
amount_refunded: number;
created: number;
paid: boolean;
refunded: boolean;
receipt_url: string;
invoice: string;
type: InvType.CHARGE;
}
export type Payment = Invoice | Charge;
export interface Taxable {
amount: string;
taxable_amount: string;
}
export interface PaidAmount {
totalExcludingTax: number;
totalTax: number;
total: number;
discount?: Discount;
refundAmount?: number;
}
export interface Discount {
amountOff: number;
percentOff?: number;
}
export interface SubscriptionIntent {
applicatorId: string;
custId: string;
selPkg: Package;
selAddons: Addon[];
orgPkg?: Package;
orgAddons?: Addon[];
upcomingInvoices?: Invoice[];
billingInfo?: BillingInfo;
paymentMethods?: PaymentMethod[];
card?: Card;
prorateTS?: number;
amount?: PaidAmount;
isNewAccount?: boolean;
coupons?: Coupon[];
mode: Mode;
subIds?: string[];
promoSavings?: number; // Total promo discount in cents (calculated in checkout)
}
export interface SubscriptionPackage {
stage?: string;
card?: Card;
pmId?: string;
defaultPM: boolean;
package: string;
addons: BasePackage[];
applicatorId?: string;
prorateTS?: number;
coupon?: string;
trial?: number;
}
export interface CustChargePkg {
custId: string;
refunded?: boolean;
status?: string;
limit?: number;
}
export interface SubscriptionPaymentMethod {
subIds: string[];
pmId: string;
}
export interface PaymentIntent {
id: string;
status: string;
client_secret: string;
customer: string;
payment_method: string;
source: string;
last_payment_error: {
payment_method: PaymentMethod;
source: Card
};
}
export interface LatestInvoice {
subscription: string;
id: string;
object: string;
customer_address?: {
city: string;
country: string;
line1: string;
line2: string;
postal_code: string;
state: string;
},
customer_name?: string;
period_start: number;
period_end: number;
status: string;
payment_intent: PaymentIntent;
tax: number;
total: number;
total_excluding_tax: number;
subtotal: number;
subtotal_excluding_tax: number;
type: InvType.INVOICE;
last_finalization_error: {
code: string;
type: string;
message: string;
};
automatic_tax: {
enabled: boolean;
status: string;
}
}
export interface PastDue {
invoices: LatestInvoice[];
numOfRetries: number;
}
export interface Incomplete {
invoices: LatestInvoice[];
requiresAction: boolean;
requiresPM: boolean;
numOfRetries: number;
subscriptions?: StripeSubscription[]
}
export interface Unpaid {
invoices: Invoice[];
numOfRetries: number;
}
export interface StripeSubscription {
id: string;
status: string;
latest_invoice: LatestInvoice;
items: {
data: {
quantity: number;
price: {
lookup_key: string;
metadata?: {
maxVehicles?: string;
maxAcres?: string;
tier?: string;
level?: string;
};
}
}[];
};
current_period_end: number;
current_period_start: number;
default_payment_method: string;
default_source: string;
metadata?: {
type: string;
scheduleId?: string;
promoId?: string;
};
cancel_at_period_end: boolean;
discount?: {
coupon: Coupon
}
trial_end?: number;
quantity: number;
// ✅ r962+ promoDetails enhancement (includes amountOff/percentOff)
promoDetails?: {
hasPromo: boolean;
name: string;
discountDisplay: string;
expiresAt: string | null;
discountEndsAt: string | null;
daysRemaining: number | null;
daysUntilDiscountEnds: number | null;
isTimeLimited: boolean;
durationInMonths: number | null;
duration: string | null;
percentOff: number | null;
amountOff: number | null;
currency: string | null;
};
}
/**
* Warning notification for subscriptions expiring within 7 days
*
* Data source: GET /api/subscription?custId={custId}
* Verified: 2025-11-10 via /server_test/subscription-data-verification.js
*
* Use case: Display topbar notification when subscription expires in 1-7 days
* Shows: Package name and/or addon names that are expiring with their individual expiry dates
*/
export interface ExpiryWarning {
/** Subscription ID from Stripe */
id: string;
/** Subscription type - 'package', 'addon', or 'both' */
type: 'package' | 'addon' | 'both';
/** Subscription status from Stripe (trialing, active, etc.) */
status: string;
/** Calculated days until earliest subscription expires */
daysUntilExpiry: number;
/** If true, subscription will expire and NOT auto-renew */
cancelAtPeriodEnd: boolean;
/** Unix timestamp when earliest subscription period ends */
periodEnd: number;
/** True if subscription status is 'trialing' */
isTrial: boolean;
/** True if subscription will auto-renew (inverse of cancelAtPeriodEnd) */
willAutoRenew: boolean;
/** Package expiry details if package is expiring */
package?: {
name: string;
lookupKey: string;
daysUntilExpiry: number;
periodEnd: number;
willAutoRenew: boolean;
isTrial: boolean;
isCanceled: boolean;
};
/** Array of addon expiry details if addons are expiring */
addons?: Array<{
name: string;
lookupKey: string;
daysUntilExpiry: number;
periodEnd: number;
willAutoRenew: boolean;
isTrial: boolean;
isCanceled: boolean;
}>;
/** True when sub-account has no active subscriptions */
noSubs?: boolean;
}
export interface AGNavSubscription {
id: string;
status: string;
items: BasePackage[];
periodEnd: number;
periodStart: number;
type: string;
cancelAtPeriodEnd: boolean;
trial_end?: number;
promoDetails?: {
hasPromo: boolean;
name: string;
discountDisplay: string;
expiresAt: string | null;
discountEndsAt: string | null;
daysRemaining: number | null;
daysUntilDiscountEnds: number | null;
isTimeLimited: boolean;
durationInMonths: number | null;
duration: string | null;
percentOff: number | null;
amountOff: number | null;
currency: string | null;
};
/**
* Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
* Built from subscription.metadata.pending_coupon_id by addPromoDetailsToSubscription().
* Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
*/
pendingPromoDetails?: PendingPromoDetails;
}
export interface AGNavSubscriptionShort {
id: string;
lookupKey: PriceUsd;
status: string;
periodEnd: number;
cancelAtPeriodEnd: boolean;
quantity: number;
paymentMethod: string;
trialEnd?: number;
promoDetails?: {
hasPromo: boolean;
name: string;
discountDisplay: string;
expiresAt: string | null;
discountEndsAt: string | null;
daysRemaining: number | null;
daysUntilDiscountEnds: number | null;
isTimeLimited: boolean;
durationInMonths: number | null;
duration: string | null;
percentOff: number | null;
amountOff: number | null;
currency: string | null;
};
/**
* Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
* Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
*/
pendingPromoDetails?: PendingPromoDetails;
}
export interface Status {
code: string;
message?: string;
}
export interface RefreshPackage {
applicatorId: string;
custId: string;
prevStage: string;
stage: string;
card: Card;
}
export interface Unresolved {
type: string;
reason?: string;
numOfRetries: number;
invoices: LatestInvoice[];
}
export interface ConfirmPackage {
custId: string;
stripePkgs: {
clientSecret: string;
pmId: string;
}[];
subIds: string[];
unresolved: Unresolved;
applicatorId: string;
stage?: string;
}
export interface CreatePaymentMethodPackage {
card: StripeCardElement;
billing_details: {
name: string;
address: Address;
};
defaultPM: boolean;
}
export interface UnpaidPackage {
pmId: string;
invIds: string[];
unpaid?: Unpaid;
card?: Card;
custId?: string;
applicatorId?: string;
}
export interface UnpaidSubscription {
lookupKey: PriceUsd;
id: string;
tax: number;
total: number;
subtotal_excluding_tax: number;
}
export interface Aircraft {
numOfVehicle: number
}
export interface Acre {
currUsage: number;
limit: number | null; // null = unlimited acres for current subscription packages
overLimit: boolean;
}
export interface SubLimit {
package?: { [i: string]: Limit };
addon?: { [i: string]: Limit };
}
export interface Plan extends SubLimit {
subscriptions?: StripeSubscription[];
membership?: IMembership;
}
export interface Limit {
acre: Acre;
airCraft: Aircraft;
}
export interface BillPeriod {
custId: string;
periodEnd: number;
periodStart: number;
subId: string;
lookupKey: string;
}
export interface JobUsage {
createdAt: string;
jobId: number;
ttSprArea: number;
totalSprayed: number;
updateDate: string;
}
export interface Usage {
ttArea: number;
numOfAC: number;
jobUsages: JobUsage[]
}
export interface UsagePackage {
byPuid: string;
fromTS?: number;
toTS?: number;
}
export interface UsageDetail {
periodEnd: number;
periodStart: number;
dayPercentage: number,
dayLeft: number;
maxAcre: string;
ttArea: number;
acrePercentage: number;
jobUsages?: JobUsage[];
billPeriods?: BillPeriod[];
}
export interface TSRange {
minTS: number;
maxTS: number;
fromTS: number;
toTS: number;
}
export interface LineItem {
description: string;
tax: number;
amount: number;
}
export interface TotalLine {
totalTax: number;
totalAmount: number;
lineItems: LineItem[];
discount?: Discount;
}
export interface CheckoutPayment {
payment: TotalLine;
refund?: TotalLine;
}
export interface Coupon {
id: string;
name: string;
amount_off: number;
percent_off: number;
redeem_by: string;
times_redeemed: number;
valid: true;
}
export interface Trial {
selected?: boolean,
type: string;
startDate: Date,
lastStartDate: Date,
lastEndDate: Date,
trialDays: number,
byDate: Date
}
export interface TrialPmtPkg {
package: string;
addons: BasePackage[];
pmtMethod?: {
newPmtMeth?: CreatePaymentMethodPackage;
exPmtMeth?: Card
},
mode: Mode;
subIds?: string[];
amount?: PaidAmount;
}
export interface TrialItem {
description: string;
amount: PriceUsd;
trialEnd?: number;
quantity: number;
price?: {
lookup_key: string;
unit_amount: number;
}
}
export interface StripeCard {
cardNumber: StripeCardNumberElement;
cardExpiry: StripeCardExpiryElement;
cardCvc: StripeCardCvcElement;
}
export interface CardExp {
expMonth: number;
expYear: number;
}
export interface PMPkgEdit {
pmId: string,
name?: string,
card?: CardExp,
setDefault?: boolean
}
export interface PMPkgAdd {
name: string;
card: StripeCardElement;
setDefault?: boolean;
}
export interface PkgValid {
isValid: boolean;
status?: Status;
}
export interface BillingInfoPackage { billingInfo?: BillingInfo, isNewAccount?: boolean }

View File

@ -1,27 +0,0 @@
import { Injectable } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
import { CustomerService } from '../services/customer.service';
import { IMembership } from '@app/auth/models/user.model';
@Injectable()
export class MembershipResolver implements Resolve<IMembership> {
constructor(
private readonly custSvc: CustomerService,
private readonly authSvc: AuthService
) { }
resolve(): Observable<IMembership> {
const id = this.authSvc.user?.parent || this.authSvc.user._id;
return this.custSvc.getCustomer(id).pipe(
map((cust) => {
const membership = cust?.membership;
if (membership) {
return membership;
}
}),
first())
}
}

View File

@ -1,45 +0,0 @@
import { Injectable } from '@angular/core';
import { Router, ActivatedRouteSnapshot, Resolve } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs';
import { map, first, switchMap } from 'rxjs/operators';
import { User } from '@app/accounts/models/user.model';
import { UserService } from '@app/domain/services/user.service';
export interface UserWithParentUsername {
user: User;
parentUsername?: string;
}
@Injectable()
export class ProfileResolver implements Resolve<UserWithParentUsername> {
constructor(
private readonly router: Router,
private readonly userService: UserService
) { }
resolve(route: ActivatedRouteSnapshot): Observable<UserWithParentUsername> {
const id = route.paramMap.get('id');
// view:'edit' → backend returns editable profile fields (name, phone, email, contact,
// address, kind, active, username, password) but excludes membership/subscription data
// which the form never needs and is expensive to populate.
return this.userService.getUser(id, { view: 'edit' }).pipe(
switchMap(user => {
if (!user) {
this.router.navigate(['/profile']);
return of(null);
}
if (user.parent) {
return this.userService.getUser(user.parent, { view: 'profile' }).pipe(
map(parentUser => ({ user, parentUsername: parentUser?.username })),
first()
);
} else {
return of({ user });
}
}),
first()
);
}
}

View File

@ -1,35 +0,0 @@
import { Injectable } from '@angular/core';
import { Router, Resolve } from '@angular/router';
import { Observable } from 'rxjs';
import { map, first, switchMap } from 'rxjs/operators';
import { User } from '@app/accounts/models/user.model';
import { UserService } from '@app/domain/services/user.service';
import { Store } from '@ngrx/store';
import { selectAuthUser } from '@app/reducers';
import { UserModel } from '@app/auth/models/user.model';
@Injectable({ providedIn: 'root' })
export class UserResolver implements Resolve<User> {
constructor(
private readonly userService: UserService,
private readonly router: Router,
private readonly store: Store<{}>,
) { }
resolve(): Observable<User> {
return this.store.select(selectAuthUser).pipe(
switchMap((authUser: UserModel) => {
return this.userService.getUser(authUser._id, { withAddresses: true })
}),
map((user: User) => {
if (user) {
return user;
} else {
this.router.navigate(['/profile']);
}
}),
first()
)
}
}

View File

@ -1,236 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, of, BehaviorSubject } from 'rxjs';
import { shareReplay, map, catchError, switchMap, tap } from 'rxjs/operators';
import { PromoTranslationService } from './promo-translation.service';
/**
* Active Promo interface matching backend GET /api/activePromos response
* Note: couponId is intentionally NOT included (server-side only for security)
*/
export interface ActivePromo {
type: 'package' | 'addon';
priceKey: string; // e.g., 'ess_1', 'addon_1'
validUntil: string; // ISO date string
name: string; // Display name (fallback)
nameKey?: string; // i18n key e.g., 'PROMO_ADDON_FREE'
descriptionKey?: string; // i18n key e.g., 'PROMO_ADDON_FREE_DESC'
discountType: 'free' | 'percent' | 'fixed';
discountValue: number; // 100 for free, 50 for 50%, 500 for $5.00
// Optional expiry fields for time-limited promos (r948+ promoDetails)
isTimeLimited?: boolean; // True if promo has expiry date
daysRemaining?: number | null; // Days until expiry (null if not time-limited)
isRenewalPromo?: boolean; // True if this is a Case 2B renewal offer (subscription has no promo, showing available promo)
}
/**
* Response interface for /api/activePromos endpoint
* Changed in r949 to include currentMode metadata
*/
interface ActivePromoResponse {
promos: ActivePromo[];
currentMode: {
mode: 'enabled' | 'disabled';
description: string;
isActive: boolean;
};
}
/**
* Service for fetching active subscription promos from the backend.
* Used to display promo labels in manage-services and manage-subscription components.
*
* The backend returns only enabled promos with future validUntil dates,
* without exposing sensitive couponId (coupon application happens server-side).
*/
@Injectable({
providedIn: 'root'
})
export class ActivePromoService {
private readonly BASE_URL = '/activePromos';
// Use BehaviorSubject to trigger fresh API calls when needed
private refreshTrigger$ = new BehaviorSubject<number>(0);
private readonly activePromos$: Observable<ActivePromo[]>;
// Store currentMode for components to use (optional feature)
private currentModeSubject$ = new BehaviorSubject<{
mode: string;
description: string;
isActive: boolean;
} | null>(null);
constructor(
private readonly http: HttpClient,
private readonly promoTranslationSvc: PromoTranslationService
) {
// Create observable that refreshes when refreshTrigger$ emits
this.activePromos$ = this.refreshTrigger$.pipe(
switchMap(() => {
return this.http.get<ActivePromoResponse>(this.BASE_URL).pipe(
map(response => {
// Store currentMode for components to use
this.currentModeSubject$.next(response.currentMode);
return response.promos; // Extract promos array
}),
catchError(error => this.handleActivePromosError(error))
);
}),
shareReplay(1) // Cache until next refresh
);
}
/**
* Handle errors from /api/activePromos endpoint
* Returns empty promos with disabled mode to prevent component crashes
*
* Error Handling:
* - 401 Unauthorized: Token expired or invalid (global interceptor handles logout)
* - 403 Forbidden: User doesn't have permission
* - 0 or 500+: Network or server errors
* - Other: Unexpected errors
*/
private handleActivePromosError(error: HttpErrorResponse): Observable<ActivePromo[]> {
// 401 Unauthorized - Token expired or invalid
if (error.status === 401) {
console.error('[ActivePromoService] Authentication failed (401)', error);
// User will be redirected to login by global HTTP interceptor
// Return empty promos to prevent component errors
this.currentModeSubject$.next(this.getDisabledPromoMode('Authentication required'));
return of([]);
}
// 403 Forbidden - User doesn't have permission
if (error.status === 403) {
console.error('[ActivePromoService] Access denied (403)', error);
this.currentModeSubject$.next(this.getDisabledPromoMode('Access denied'));
return of([]);
}
// Network errors or server errors
if (error.status === 0 || error.status >= 500) {
console.error('[ActivePromoService] Network or server error', error);
this.currentModeSubject$.next(this.getDisabledPromoMode('Service unavailable'));
return of([]);
}
// Other errors - return empty to prevent crashes
console.error('[ActivePromoService] Unexpected error', error);
this.currentModeSubject$.next(this.getDisabledPromoMode('Promotions unavailable'));
return of([]);
}
/**
* Get disabled promo mode object for error states
*/
private getDisabledPromoMode(description: string): {
mode: string;
description: string;
isActive: boolean;
} {
return {
mode: 'disabled',
isActive: false,
description: description
};
}
/**
* Force refresh of promo data from server
* Invalidates cache and makes fresh API call
*/
refresh(): void {
this.refreshTrigger$.next(Date.now());
}
/**
* Get all active promos (cached until refresh)
*/
getActivePromos(): Observable<ActivePromo[]> {
return this.activePromos$;
}
/**
* Get promo for a specific priceKey (e.g., 'ess_1', 'addon_1')
*/
getPromoForPriceKey(priceKey: string): Observable<ActivePromo | undefined> {
return this.activePromos$.pipe(
map(promos => promos.find(p => p.priceKey === priceKey))
);
}
/**
* Check if a priceKey has an active promo
*/
hasPromo(priceKey: string): Observable<boolean> {
return this.activePromos$.pipe(
map(promos => promos.some(p => p.priceKey === priceKey))
);
}
/**
* Get active promos with translated names (convenience method)
*/
getActivePromosWithTranslations(): Observable<(ActivePromo & { translatedName: string; translatedDescription: string })[]> {
return this.activePromos$.pipe(
map(promos => promos.map(promo => ({
...promo,
translatedName: this.promoTranslationSvc.getPromoName(promo),
translatedDescription: this.promoTranslationSvc.getPromoDescription(promo)
})))
);
}
/**
* Get current promo mode info
* Returns null if not yet loaded
*
* Use this to check if promotions are globally enabled:
* - mode='enabled': Promotions active and should be displayed
* - mode='disabled': Promotions disabled (hide promo banners)
*
* @example
* // In component:
* this.activePromoSvc.getCurrentMode().subscribe(mode => {
* if (mode && !mode.isActive) {
* this.showPromoBanners = false; // Hide banners when mode='disabled'
* }
* });
*/
getCurrentMode(): Observable<{
mode: string;
description: string;
isActive: boolean;
} | null> {
return this.currentModeSubject$.asObservable();
}
/**
* Format promo display text with translation support
*/
formatPromoDisplayText(promo: ActivePromo): string {
const translatedName = this.promoTranslationSvc.getPromoName(promo);
return `${translatedName} - ${this.formatPromoDiscount(promo)}`;
}
/**
* Format promo discount for display
* Returns: "FREE", "50% OFF", "$10 OFF"
*/
formatPromoDiscount(promo: ActivePromo): string {
if (!promo) return '';
switch (promo.discountType) {
case 'free':
return $localize`:Promo label for free items@@promoFree:FREE`;
case 'percent':
return `${promo.discountValue}% ` + $localize`:Promo label suffix@@promoOff:OFF`;
case 'fixed':
// discountValue is in cents, convert to dollars
const dollars = promo.discountValue / 100;
return `$${dollars} ` + $localize`:Promo label suffix@@promoOff:OFF`;
default:
return promo.name || '';
}
}
}

View File

@ -6,7 +6,7 @@ import { environment } from '@environments/environment';
import { AppMessageService } from '@app/shared/app-message.service'; import { AppMessageService } from '@app/shared/app-message.service';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { MatType } from '@app/shared/global'; import { MatType } from '@app/shared/global';
import { catchError, debounceTime, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { of } from 'rxjs'; import { of } from 'rxjs';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@ -59,23 +59,13 @@ export class AppConfigService {
return this.http.get<IAppConfig>("/appConfig").pipe( return this.http.get<IAppConfig>("/appConfig").pipe(
map(res => { map(res => {
if (!environment.production) if (!environment.production)
console.log("AppConfigService: App config loaded successfully!", res); console.log("App config loaded !");
this.checkAndSetDefault(res); this.checkAndSetDefault(res);
return true; return true;
}), }),
catchError(err => { 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.'); 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);
}) })
); );
} }
@ -155,19 +145,4 @@ export class AppConfigService {
}); });
}); });
} }
saveTrialDays(trialDays: Number[]) {
return new Promise((resolve, reject) => {
this.http.post("/appConfig", { trialDays }, { params: new HttpParams().set('loader', 'false') }).subscribe({
next: (res: { trialDays: Number[] }) => {
this.settings = { ...this._settings, trialDays: res.trialDays };
resolve(true);
},
error: (err) => {
this.appMsgSvc.addFailedMsg('Could not save AppConfig. Please retry or contact Agnav.');
reject(`Could not save AppConfig !': ${JSON.stringify(err)}`);
}
});
});
}
} }

View File

@ -1,5 +1,6 @@
import { Injectable, Injector } from '@angular/core'; import { Injectable, Injector } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, HttpHeaders } from '@angular/common/http'; import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, HttpHeaders } from '@angular/common/http';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators'; import { catchError, finalize, map } from 'rxjs/operators';
@ -49,11 +50,20 @@ export class AuthInterceptor implements HttpInterceptor {
if (showLoading) { if (showLoading) {
this.loaderSvc.show(); this.loaderSvc.show();
this.requests.push(authReq); this.requests.push(authReq);
// console.log("Num of loading reqs:", this.requests.length);
} }
// Pass on the cloned request instead of the original request. // Pass on the cloned request instead of the original request.
return next.handle(authReq).pipe( return next.handle(authReq).pipe(
map((event: HttpEvent<any>) => { map((event: HttpEvent<any>) => {
// if (event instanceof HttpResponse) {
// const agmTk = event.headers.get('Agm-TK');
// if ((agmTk && this.authSvc.token) && (this.authSvc.token.t != agmTk)) {
// const newTk = this.authSvc.token;
// newTk.t = agmTk;
// this.authSvc.token = newTk;
// }
// }
return event; // Should always return the response event untouched as metioned in 'HttpEvents' section at https://angular.io/guide/http return event; // Should always return the response event untouched as metioned in 'HttpEvents' section at https://angular.io/guide/http
}), }),
catchError(err => this.onCatch(err, req)), catchError(err => this.onCatch(err, req)),
@ -64,18 +74,12 @@ export class AuthInterceptor implements HttpInterceptor {
removeRequest(req: HttpRequest<any>) { removeRequest(req: HttpRequest<any>) {
const i = this.requests.indexOf(req); const i = this.requests.indexOf(req);
(i >= 0) && (this.requests.splice(i, 1)); (i >= 0) && (this.requests.splice(i, 1));
this.loaderSvc.loading$.next(this.requests.length > 0);
const val = Boolean(this.requests.length > 0); // console.log("Num of loading reqs:", this.requests.length);
this.loaderSvc.loading$.next(val);
} }
private onCatch(err: any, req: HttpRequest<any>): Observable<any> { private onCatch(err: any, req: HttpRequest<any>): Observable<any> {
// Don't logout on partner API errors - these are partner credential tests, not user session errors if ([401, 403].indexOf(err.status) != -1 && !req.url.endsWith('/login')) { // JWT expired or invalid token responded from BE, force logOut
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)); this.store.dispatch(new authActions.Logout(true));
} }
return throwError(err); return throwError(err);

View File

@ -2,27 +2,20 @@ import { Injectable, OnDestroy, Inject, LOCALE_ID } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, throwError, Subscription } from 'rxjs'; import { Observable, of, throwError, Subscription } from 'rxjs';
import { exhaustMap, tap, catchError } from 'rxjs/operators'; import { exhaustMap } from 'rxjs/operators';
import { DateUtils, Utils } from '../../shared/utils'; import { Utils } from '../../shared/utils';
import { RoleIds } from '../../shared/global'; import { RoleIds } from '../../shared/global';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import * as fromStore from '../../reducers'; import * as fromStore from '../../reducers';
import { UserModel } from '../../auth/models/user.model'; import { UserModel } from '../../auth/models/user.model';
import { Authenticate } from '../../auth/models/auth.model'; import { Authenticate } from '../../auth/models/auth.model';
import { AGNavSubscription, PriceUsd, Trial } from '../models/subscription.model';
import { Mode, SUB, SubStripe, SubType } from '@app/profile/common';
import { SubscriptionService } from './subscription.service';
import { GAService } from '../../shared/ga.service';
import { GAAnalyticsHelpersService } from '../../shared/ga.analytics-helpers.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService implements OnDestroy { export class AuthService implements OnDestroy {
private _user: UserModel; private _user: any;
private _sessionStartTime: number; get user(): any {
get user(): UserModel {
return this._user; return this._user;
} }
@ -52,9 +45,6 @@ export class AuthService implements OnDestroy {
@Inject(LOCALE_ID) private localeId: string, @Inject(LOCALE_ID) private localeId: string,
private readonly store: Store<{}>, private readonly store: Store<{}>,
private readonly http: HttpClient, private readonly http: HttpClient,
private subSvc: SubscriptionService,
private readonly gaService: GAService,
private readonly gaHelpers: GAAnalyticsHelpersService,
) { ) {
this._locale = Utils.getLang(this.localeId) || 'en'; this._locale = Utils.getLang(this.localeId) || 'en';
this._tk = JSON.parse(sessionStorage.getItem('cT')); this._tk = JSON.parse(sessionStorage.getItem('cT'));
@ -73,10 +63,6 @@ export class AuthService implements OnDestroy {
return this.hasRole([RoleIds.APP]); return this.hasRole([RoleIds.APP]);
} }
get isAppAdm() {
return this.hasRole([RoleIds.APP_ADM]);
}
get isClientUser() { get isClientUser() {
return this.hasRole([RoleIds.CLIENT]); return this.hasRole([RoleIds.CLIENT]);
} }
@ -89,14 +75,6 @@ export class AuthService implements OnDestroy {
return this.hasRole([RoleIds.INSPECTOR]); return this.hasRole([RoleIds.INSPECTOR]);
} }
get isPartner(): boolean {
return this.hasRole([RoleIds.PARTNER]);
}
hasSubsWithStatus(status: string) {
return this.user?.membership?.subscriptions?.some((sub) => sub.status === `${status}`);
}
getAuthHeader(): string { getAuthHeader(): string {
return this.user && this.token ? 'Bearer ' + this.token.t : ''; return this.user && this.token ? 'Bearer ' + this.token.t : '';
} }
@ -106,32 +84,7 @@ export class AuthService implements OnDestroy {
} }
hasRole(roles: string[]): boolean { hasRole(roles: string[]): boolean {
return this.loggedIn && (roles && Utils.containsAny(roles, this.user?.roles)); return this.loggedIn && (roles && Utils.containsAny(roles, this.user.roles));
}
hasAppRoleAndSub(): boolean {
return this.isApplicator && this.hasSubs();
}
hasSubs() {
return this.user?.membership?.subscriptions?.length > 0;
}
getSub(lookupKey: string): AGNavSubscription {
if (this.hasSubs) return this.user?.membership?.subscriptions?.find((sub) => sub.items?.some((item) => item.price === lookupKey));
}
getCurLookupKey(type: SubType.PACKAGE | SubType.ADDON): PriceUsd {
// Use centralized utility methods
const subscriptions = this.user?.membership?.subscriptions;
switch (type) {
case SubType.PACKAGE:
return this.subSvc.getCurrentPackageLookupKey(subscriptions) || '';
case SubType.ADDON:
return this.subSvc.getCurrentAddonLookupKey(subscriptions) || '';
default:
throw new Error('Unsupported type');
}
} }
get isPlanner() { get isPlanner() {
@ -142,10 +95,6 @@ export class AuthService implements OnDestroy {
return (this.user && this.user.billable); return (this.user && this.user.billable);
} }
get isCanada(): boolean {
return this.user?.country === 'CA';
}
/** /**
* Parent user, to mange items under an applicator user * Parent user, to mange items under an applicator user
*/ */
@ -169,45 +118,18 @@ export class AuthService implements OnDestroy {
throwError('invalid_account'); throwError('invalid_account');
// Store username and jwt token in local storage to keep user logged in between page refreshes // Store username and jwt token in local storage to keep user logged in between page refreshes
const user = <UserModel>{ _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'], membership: res['membership'], contact: res['contact'] || '', country: res['country'] || '' }; const user = <UserModel>{ _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'] };
this._user = user;
this.token = { t: res['token'], rt: res['rt'] }; 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); return of(user);
}), })
); );
} }
logout(gotoLogin: boolean = true): Observable<boolean> { 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(); sessionStorage.clear();
localStorage.removeItem('requiredSubAttention');
this._user = null; this._user = null;
this._tk = null; this._tk = null;
this._sessionStartTime = null;
return of(true); return of(true);
} }
@ -220,110 +142,15 @@ export class AuthService implements OnDestroy {
} }
mailPwdReset(ops) { mailPwdReset(ops) {
return this.http.post('/users/mailPwdReset', ops).pipe( return this.http.post('/users/mailPwdReset', ops);
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);
})
);
} }
validateResetPassword(ops) { resetPassword(ops) {
return this.http.post('/users/resetPassword/validate', ops); return this.http.get(`/users/resetPassword/${ops.id}/${ops.token}`);
} }
changePassword(ops) { changePassword(ops) {
return this.http.post('/users/resetPassword', ops).pipe( return this.http.post('/users/resetPassword', ops);
tap(response => {
// Track password reset completion
this.gaService.trackPasswordResetCompleted({
success: true,
reset_token_age_minutes: 0, // Token age info not available in current implementation
platform: 'web'
});
}),
catchError(error => {
// Track password reset completion failure
this.gaService.trackPasswordResetCompleted({
success: false,
reset_token_age_minutes: 0, // Token age info not available
failure_reason: 'other',
platform: 'web'
});
return throwError(error);
})
);
}
get trials() {
return this.user?.membership?.trials;
}
hasActiveTrial(trials: Trial) {
if (!trials || !this.hasSubsWithStatus(SubStripe.TRIALING)) return false;
return trials.lastStartDate && DateUtils.currUTC() <= DateUtils.dateToTS(new Date(trials.lastEndDate))
|| this.hasSubsWithStatus(SubStripe.TRIALING);
}
hasLastEndedTrial(trials: Trial) {
if (!trials) return false;
return trials.lastStartDate && trials.lastEndDate;
}
isTrialDays(trials: Trial) {
return trials?.trialDays > 1;
}
hasValidTrialOffer(trials: Trial) {
return !!trials.byDate || this.isTrialDays(trials);
}
validateTrial(trials: Trial) {
if (!trials || !trials.type) return false;
let isWithinTrialPeriod: boolean = false;
if (this.hasValidTrialOffer(trials)) {
if (trials.byDate) {
isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(new Date(trials.byDate));
} else if (this.isTrialDays(trials)) {
const trialEndDate = new Date(trials.startDate);
trialEndDate.setDate(trialEndDate.getDate() + trials.trialDays);
isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(trialEndDate);
}
}
return this.hasRole([RoleIds.APP])
&& !this.hasSubs()
&& isWithinTrialPeriod;
}
canDisplayTrial(trials: Trial) {
return this.validateTrial(trials);
}
canAcceptTrial(url: string) {
return !url.includes(SUB.MY_SERVICES)
&& this.subSvc.subMode !== Mode.TRIALING;
}
get canActivateVehicle() {
return this.isApplicator;
}
get canAccessInvoice() {
return this.isApplicator;
} }
ngOnDestroy(): void { ngOnDestroy(): void {

View File

@ -46,13 +46,10 @@ export class ClientService {
return this.http.delete<Client>(`${this.clientURL}/${client._id}`); return this.http.delete<Client>(`${this.clientURL}/${client._id}`);
} }
searchWithSettings(byPuid: string): Observable<Client[]> {
return this.http.post<Client[]>(`${this.clientURL}/searchWithSettings`, { byPuid });
}
} }
export interface LoadClientOps { export interface LoadClientOps {
byPuid: string; byUserId: string;
} }
export interface ClientWithSetting extends Client { export interface ClientWithSetting extends Client {

View File

@ -1,14 +1,19 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Customer } from '../../customers/models/customer.model'; import { Customer } from '../../customers/models/customer.model';
import { Store } from '@ngrx/store';
@Injectable() @Injectable()
export class CustomerService { export class CustomerService {
private readonly customerURL = '/customers'; private readonly customerURL = '/customers';
constructor( constructor(
private store: Store<{}>,
private http: HttpClient private http: HttpClient
) { ) {
} }
@ -17,9 +22,8 @@ export class CustomerService {
return this.http.get<Customer[]>(this.customerURL); return this.http.get<Customer[]>(this.customerURL);
} }
getCustomer(id: string, view?: string): Observable<Customer> { getCustomer(id: string): Observable<Customer> {
const url = view ? `${this.customerURL}/${id}?view=${view}` : `${this.customerURL}/${id}`; return this.http.get<Customer>(`${this.customerURL}/${id}`);
return this.http.get<Customer>(url);
} }
saveCustomer(customer: Customer): Observable<Customer> { saveCustomer(customer: Customer): Observable<Customer> {

View File

@ -1,166 +0,0 @@
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse,
HttpResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { AppMessageService } from '@app/shared/app-message.service';
import { globals } from '@app/shared/global';
import { environment } from '@environments/environment';
import { AppInjector } from '@app/app-injector';
import { GAService } from '@app/shared/ga.service';
@Injectable()
export class GlobalErrorInterceptor implements HttpInterceptor {
private failedAttempts = 0;
private gaSvc: GAService;
constructor(private readonly msgSvc: AppMessageService) {
// Use AppInjector to get GAService to avoid circular dependency
this.gaSvc = AppInjector.getInjector().get(GAService);
}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const startTime = Date.now();
return next.handle(req).pipe(
tap(event => {
// Track successful but slow API responses
if (event instanceof HttpResponse) {
const responseTime = Date.now() - startTime;
// Track slow API responses (threshold: 2 seconds)
if (responseTime > 2000) {
this.trackSlowApiResponse(req, event, responseTime);
}
}
}),
catchError((error: HttpErrorResponse) => {
const responseTime = Date.now() - startTime;
// Track HTTP error event
this.trackHttpError(error, req, responseTime);
if (error.status >= 500 && error.status < 600) {
this.failedAttempts++;
if (this.failedAttempts >= environment.failedRqAttempts) {
this.msgSvc.addFailedMsg(globals.server500Err);
this.failedAttempts = 0; // Reset counter after showing the error
}
}
return throwError(error);
})
);
}
private trackHttpError(error: HttpErrorResponse, req: HttpRequest<any>, responseTime: number): void {
const errorType = this.categorizeError(error);
const endpoint = this.extractEndpoint(req.url);
this.gaSvc.trackEvent('http_error', {
platform: 'web',
error_type: errorType,
http_status_code: error.status || 0,
error_message: error.message || 'Unknown HTTP error',
request_method: req.method as any,
request_url: req.url,
request_endpoint: endpoint,
response_time_ms: responseTime,
affected_feature: this.extractFeature(endpoint)
});
}
private trackSlowApiResponse(req: HttpRequest<any>, response: HttpResponse<any>, responseTime: number): void {
const endpoint = this.extractEndpoint(req.url);
this.gaSvc.trackEvent('api_response_slow', {
platform: 'web',
api_endpoint: endpoint,
response_time_ms: responseTime,
payload_size: this.getPayloadSize(response),
cache_hit: this.isCacheHit(response)
});
}
private categorizeError(error: HttpErrorResponse): 'network_error' | 'server_error' | 'client_error' | 'timeout' | 'unknown_error' {
if (error.status === 0 || error.status === -1) {
return 'network_error';
}
if (error.status >= 500) {
return 'server_error';
}
if (error.status >= 400 && error.status < 500) {
return 'client_error';
}
if (error.status === 408 || error.message?.includes('timeout')) {
return 'timeout';
}
return 'unknown_error';
}
private extractEndpoint(url: string): string {
try {
const pathname = new URL(url, window.location.origin).pathname;
return pathname.replace(/^\/api/, '').split('/')[1] || 'unknown';
} catch {
return url.split('/')[1] || 'unknown';
}
}
private extractFeature(endpoint: string): string {
const featureMap: { [key: string]: string } = {
'jobs': 'job_management',
'invoices': 'billing',
'reports': 'reporting',
'files': 'file_management',
'users': 'user_management',
'auth': 'authentication',
'customers': 'customer_management',
'equipment': 'equipment_management'
};
return featureMap[endpoint] || 'unknown';
}
private getPayloadSize(response: HttpResponse<any>): number | undefined {
try {
const contentLength = response.headers.get('content-length');
if (contentLength) {
return parseInt(contentLength, 10);
}
// Fallback: estimate payload size from response body
if (response.body) {
const bodyString = JSON.stringify(response.body);
return new Blob([bodyString]).size;
}
} catch (error) {
// Ignore errors in payload size calculation
}
return undefined;
}
private isCacheHit(response: HttpResponse<any>): boolean | undefined {
// Check common cache indicators in response headers
const cacheControl = response.headers.get('cache-control');
const etag = response.headers.get('etag');
const lastModified = response.headers.get('last-modified');
const xCache = response.headers.get('x-cache');
// Check for explicit cache hit indicators
if (xCache?.includes('HIT')) {
return true;
}
// If response has cache headers but no explicit hit indicator, likely cached
if (cacheControl && (etag || lastModified)) {
return true;
}
return undefined; // Unknown cache status
}
}

View File

@ -158,7 +158,7 @@ export class InvoiceService {
return jobCostings.reduce((acc, jobCosting) => { return jobCostings.reduce((acc, jobCosting) => {
const items = jobCosting?.costings?.items?.map(item => ({ const items = jobCosting?.costings?.items?.map(item => ({
job: jobCosting.job, id: jobCosting.job,
jobName: jobCosting.name, jobName: jobCosting.name,
costingName: item.name, costingName: item.name,
quantity: item.quantity, quantity: item.quantity,
@ -197,17 +197,15 @@ export class InvoiceService {
}; };
private calculateSubTotal(client: Client, totalJobAmount?: number): number { private calculateSubTotal(client: Client, totalJobAmount?: number): number {
const split = client?.split ? +client.split : 100;
return totalJobAmount return totalJobAmount
? totalJobAmount * (Number(split) / 100) ? totalJobAmount * (Number(client.split) / 100)
: client.subTotal : client.subTotal
? Number(client.subTotal) ? Number(client.subTotal)
: 0; : 0;
} }
private calculateDiscounted(subTotal: number, client: Client): number { private calculateDiscounted(subTotal: number, client: Client): number {
const discount = client?.discount ? +client.discount : 0; return subTotal * (+client.discount / 100);
return subTotal * (discount / 100);
} }
private calculateTotalExcludingTax(subTotal: number, discounted: number): number { private calculateTotalExcludingTax(subTotal: number, discounted: number): number {
@ -215,8 +213,7 @@ export class InvoiceService {
} }
private calculateTaxed(totalExcludingTax: number, client: Client): number { private calculateTaxed(totalExcludingTax: number, client: Client): number {
const taxRate = client?.taxRate ? +client.taxRate : 0; return totalExcludingTax * (+client.taxRate / 100);
return totalExcludingTax * (taxRate / 100);
} }
private calculateTotal(totalExcludingTax: number, taxed: number): number { private calculateTotal(totalExcludingTax: number, taxed: number): number {
@ -250,6 +247,10 @@ export class InvoiceService {
}; };
const payment = this.calculateClientPayment(client, subTotalAfterSplit); const payment = this.calculateClientPayment(client, subTotalAfterSplit);
if (print.client.paymentTerm) {
print.paymentTerm = print.client.paymentTerm;
}
return { return {
...print, ...print,
jobItems, jobItems,

View File

@ -4,6 +4,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model'; import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model';
import { AppFile } from '../models/shared.model'; import { AppFile } from '../models/shared.model';
import { UpdateJobOps } from '../../job/actions/job.actions'; import { UpdateJobOps } from '../../job/actions/job.actions';
@ -14,26 +15,13 @@ export class JobService {
private readonly jobURL = '/jobs'; private readonly jobURL = '/jobs';
constructor( constructor(
private store: Store<{}>,
private http: HttpClient private http: HttpClient
) { ) {
} }
loadJobs(ops: any): Observable<IJob[]> { loadJobs(ops: any): Observable<IJob[]> {
let _ops = new HttpParams() const _ops = new HttpParams().set('clientId', ops && ops.clientId).set('jpo', (ops && ops.jobsByPilot) || 'false');
.set('clientId', ops?.clientId || '')
.set('jpo', ops?.jobsByPilot || 'false')
.set('status', ops?.status || '');
if (ops?.byTime?.length === 2) {
for (const time of ops.byTime) {
if (time) {
_ops = _ops.append('byTime', time.toISOString());
}
}
} else {
_ops = _ops.append('byTime', ops?.byTime[0] || '');
}
return this.http.get<IJob[]>(this.jobURL, { params: _ops }); return this.http.get<IJob[]>(this.jobURL, { params: _ops });
} }
@ -117,10 +105,6 @@ export class JobService {
return this.http.post(`${this.jobURL}/deleteAppFile`, options, { params: new HttpParams().set('loader', 'false') }); return this.http.post(`${this.jobURL}/deleteAppFile`, options, { params: new HttpParams().set('loader', 'false') });
} }
fetchInvReadyJobs(excludeIds?: string[]): Observable<IJob[]> {
return this.http.post<IJob[]>(`${this.jobURL}/fetchInvReadyJobs`, { excludeIds });
}
downloadAppFile(fname) { downloadAppFile(fname) {
let httpParams = new HttpParams().set('file', fname); let httpParams = new HttpParams().set('file', fname);
return this.http.get('/exports/downloadAppfile', { params: httpParams, responseType: 'arraybuffer' }).pipe( return this.http.get('/exports/downloadAppfile', { params: httpParams, responseType: 'arraybuffer' }).pipe(
@ -187,24 +171,8 @@ export class JobService {
return this.http.post<any>(`${this.jobURL}/appFiles`, { jobId: jobId }); return this.http.post<any>(`${this.jobURL}/appFiles`, { jobId: jobId });
} }
getFilesData(fileId: string, params?: { getFilesData(ids) {
limit?: number, return this.http.post<any>(`${this.jobURL}/filesdata`, { fileIds: ids });
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);
} }
} }

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Router, NavigationStart } from '@angular/router'; import { Router, ActivationEnd } from '@angular/router';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -11,33 +11,18 @@ import { HttpCancelService } from './httpcancel.service';
@Injectable() @Injectable()
export class ManageHttpInterceptor implements HttpInterceptor { export class ManageHttpInterceptor implements HttpInterceptor {
private currentUrl: string = '';
constructor(private readonly router: Router, private readonly httpCancelService: HttpCancelService) { constructor(private readonly router: Router, private readonly httpCancelService: HttpCancelService) {
router.events.subscribe(event => { router.events.subscribe(event => {
// Only cancel on actual route changes, not during guard/resolver execution // An event triggered at the end of the activation part of the Resolve phase of routing.
if (event instanceof NavigationStart) { if (event instanceof ActivationEnd) {
// Check if this is actually a new route, not just a reload or guard execution // Cancel pending calls
if (this.currentUrl && event.url !== this.currentUrl) {
this.httpCancelService.cancelPendingRequests(); this.httpCancelService.cancelPendingRequests();
} }
this.currentUrl = event.url;
}
}); });
} }
intercept<T>(req: HttpRequest<T>, next: HttpHandler): Observable<HttpEvent<T>> { 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())) return next.handle(req).pipe(takeUntil(this.httpCancelService.onCancelPendingRequests()))
} }
} }

View File

@ -1,37 +0,0 @@
import { Injectable } from '@angular/core';
import { PromoLabels } from 'src/app/profile/common';
import { ActivePromo } from './active-promo.service';
@Injectable({
providedIn: 'root'
})
export class PromoTranslationService {
/**
* Get translated promo name with fallback to static name
*/
getPromoName(promo: ActivePromo): string {
if (promo.nameKey && PromoLabels[promo.nameKey]) {
return PromoLabels[promo.nameKey];
}
return promo.name; // Fallback to static name
}
/**
* Get translated promo description with fallback to static name
*/
getPromoDescription(promo: ActivePromo): string {
if (promo.descriptionKey && PromoLabels[promo.descriptionKey]) {
return PromoLabels[promo.descriptionKey];
}
// Fallback: use name as description if no descriptionKey translation
return this.getPromoName(promo);
}
/**
* Check if promo has translation keys available
*/
hasTranslation(promo: ActivePromo): boolean {
return !!(promo.nameKey && PromoLabels[promo.nameKey]);
}
}

View File

@ -3,36 +3,27 @@ import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { UserModel } from '../../auth/models/user.model';
import { User } from '../../accounts/models/user.model'; import { User } from '../../accounts/models/user.model';
import { Roles } from '../../shared/global';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class UserService { export class UserService {
private readonly userURL = '/users'; private readonly userURL = '/users';
constructor(private http: HttpClient) { constructor(
private store: Store<{}>,
private http: HttpClient
) {
} }
loadUsers(options: LoadUserOptions): Observable<User[]> { loadUsers(options: LoadUserOptions): Observable<User[]> {
return this.http.post<User[]>(this.userURL + '/search', options); return this.http.post<User[]>(this.userURL + '/search', options);
} }
getUser(id: string, ops?: { withAddresses?: boolean; view?: 'profile' | 'edit' | 'billing' }): Observable<User> { getUser(id: string): Observable<User> {
let url = `${this.userURL}/${id}`; return this.http.get<User>(`${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> { userNameExists(userName: string): Observable<boolean> {
@ -64,29 +55,6 @@ export class UserService {
return this.http.post<User>(`${this.userURL}/getUserDetail`, { username: username }); 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 { export interface LoadUserOptions {

View File

@ -1,7 +1,9 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { StatusChange, Vehicle } from '../../entities/models/vehicle.model';
import { Vehicle } from '../../entities/models/vehicle.model';
@Injectable() @Injectable()
export class VehicleService { export class VehicleService {
@ -41,9 +43,6 @@ export class VehicleService {
return this.http.post<boolean>(`${this.vehicleURL}/unitIdExists`, { unitId: unitId }); return this.http.post<boolean>(`${this.vehicleURL}/unitIdExists`, { unitId: unitId });
} }
updateVehicles(vehicles : Vehicle[]) {
return this.http.post<Vehicle[]>(`${this.vehicleURL}/update`, vehicles);
}
} }
export interface LoadVehicleOptions { export interface LoadVehicleOptions {

View File

@ -1,91 +0,0 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Action } from '@ngrx/store';
import { Effect, Actions, ofType } from '@ngrx/effects';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import * as subActions from '@app/actions/subscription.actions';
import { SUB } from '../profile/common';
import { AC } from '@app/shared/global';
@Injectable()
export class RoutingEffects {
constructor(
private router: Router,
private actions$: Actions
) { }
@Effect({ dispatch: false })
gotoMyservices$: Observable<Action> = this.actions$.pipe(
ofType<subActions.GotoMyServices>(subActions.GOTO_MY_SERVICES),
tap(() => this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]).then(() => window.location.reload()))
);
@Effect({ dispatch: false })
gotoServices$: Observable<Action> = this.actions$.pipe(
ofType<subActions.GotoServices>(subActions.GOTO_SERVICES),
tap(() => this.router.navigate([SUB.PROFILE, SUB.SERVICES]))
);
@Effect({ dispatch: false })
gotPaymentHistory$: Observable<Action> = this.actions$.pipe(
ofType<subActions.GotoPaymentHistory>(subActions.GOTO_PAYMENT_HISTORY),
tap(() => this.router.navigate([SUB.PROFILE, SUB.PM_HISTORY]))
);
@Effect({ dispatch: false })
gotPaymentDetail$: Observable<Action> = this.actions$.pipe(
ofType<subActions.GotoPaymentDetail>(subActions.GOTO_PAYMENT_DETAIL),
tap((action: subActions.GotoPaymentDetail) => this.router.navigate([SUB.PROFILE, SUB.PM_DETAIL, action.payload.paymentId]))
);
@Effect({ dispatch: false })
gotoUnpaidSub$: Observable<Action> = this.actions$.pipe(
ofType<subActions.ShowUnpaidSubscription>(subActions.SHOW_UNPAID_SUBSCRIPTION),
tap(() => this.router.navigate([SUB.PROFILE, SUB.UNPAID_SUB]))
);
@Effect({ dispatch: false })
gotoBillingAddr$: Observable<Action> = this.actions$.pipe(
ofType<subActions.StartBillingInfoSuccess | subActions.GotoBillingAddress>(subActions.START_BILLING_INFO_SUCCESS, subActions.GOTO_BILLING_ADDRESS),
tap(() => this.router.navigate([SUB.PROFILE, SUB.BILL_ADR]))
);
@Effect({ dispatch: false })
gotoCheckout$: Observable<Action> = this.actions$.pipe(
ofType<subActions.UpdateBillingAddressSuccess | subActions.GotoCheckout | subActions.StartCheckoutSuccess>(subActions.UPDATE_BILLING_ADDRESS_SUCCESS, subActions.GOTO_CHECK_OUT, subActions.START_CHECKOUT_SUCCESS),
tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT]))
);
@Effect({ dispatch: false })
gotoCheckoutReview$: Observable<Action> = this.actions$.pipe(
ofType<subActions.Checkout | subActions.ResolvePayment | subActions.GotoCheckoutReview>(subActions.CHECK_OUT, subActions.RESOLVE_PAYMENT, subActions.GOTO_CHECK_OUT_REVIEW),
tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT_REV]))
);
@Effect({ dispatch: false })
gotoCheckoutConfirm$: Observable<Action> = this.actions$.pipe(
ofType<subActions.GotoCheckoutConfirm | subActions.PayUnpaidSubscriptionSuccess | subActions.ConfirmActionSuccess | subActions.ConfirmPaymentSuccess | subActions.CheckoutTrialSuccess>(subActions.GOTO_CHECK_OUT_CONFIRM, subActions.PAY_UNPAID_SUBSCRIPTION_SUCCESS, subActions.CONFIRM_ACTION_SUCCESS, subActions.CONFIRM_PAYMENT_SUCCESS,
subActions.CHECK_OUT_TRIAL_SUCCESS),
tap(() => this.router.navigate([SUB.PROFILE, SUB.CHKOUT_CONF]))
);
@Effect({ dispatch: false })
gotoHome$: Observable<Action> = this.actions$.pipe(
ofType<subActions.GotoHome>(subActions.GOTO_HOME),
tap(() => this.router.navigate(['/', SUB.HOME]))
);
@Effect({ dispatch: false })
gotoUsageDetail$: Observable<Action> = this.actions$.pipe(
ofType<subActions.GotoUsageDetail>(subActions.GOTO_USAGE_DETAIL),
tap(() => this.router.navigate([SUB.PROFILE, SUB.USAGE_DETAIL]))
);
@Effect({ dispatch: false })
gotoAircraftList$: Observable<Action> = this.actions$.pipe(
ofType<subActions.GotoAircraftList>(subActions.GOTO_AIRCRAFT_LIST),
tap(() => this.router.navigate(['entities', AC]))
);
}

View File

@ -1,282 +0,0 @@
import { Injectable } from '@angular/core';
import { Action } from '@ngrx/store';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { SubscriptionService } from '@app/domain/services/subscription.service';
import { Observable, of } from 'rxjs';
import * as subPlansActions from '@app/actions/sub-plans.actions'
import { catchError, delay, exhaustMap, filter, repeat, retryWhen, switchMap, take } from 'rxjs/operators';
import { subPlans, SubAppErr, handleErr, SubKeys, TRACKING, PACKAGE_ACTIVE, createSubStatus, SUB, SubType, DELAY, TAKE, EMPTY } from '../profile/common';
import { AuthService } from '@app/domain/services/auth.service';
import { StripeSubscription, Usage } from '@app/domain/models/subscription.model';
import { AppMessageService } from '@app/shared/app-message.service';
import { globals } from '@app/shared/global';
import { VehicleService } from '@app/domain/services/vehicle.service';
import { FetchLatestSubscriptionSuccess, GotoAircraftList, UpdateSubscriptionStatus } from '@app/actions/subscription.actions';
import { Vehicle } from '@app/entities/models/vehicle.model';
import { CustomerService } from '@app/domain/services/customer.service';
import { Router } from '@angular/router';
@Injectable()
export class SubPlansEffects {
constructor(
private readonly actions$: Actions,
private readonly subSvc: SubscriptionService,
private readonly authSvc: AuthService,
private readonly msgSvc: AppMessageService,
private readonly vehSvc: VehicleService,
private readonly custSvc: CustomerService,
private readonly router: Router
) { }
@Effect()
refreshSubPlans$: Observable<Action> = this.actions$.pipe(
ofType<subPlansActions.FetchSubPlans>(subPlansActions.FETCH_SUB_PLANS),
exhaustMap((action: subPlansActions.FetchSubPlans) => {
let usage: Usage;
let subscriptions: StripeSubscription[];
let vehicles: Vehicle[];
let sortedPrices: any[];
return this.subSvc.getPrices().pipe(
filter(prices => prices?.length > 0),
switchMap(prices => {
sortedPrices = [...prices].sort((a, b) => a.level - b.level);
let lastEffectiveMax = 0;
const userSubscriptions = this.authSvc.user?.membership?.subscriptions;
const userCustomLimits = this.authSvc.user?.membership?.customLimits;
// Use centralized utility to get current lookup key
const currentLookupKey = this.subSvc.getCurrentPackageLookupKey(userSubscriptions);
// Get latest package subscription for custom limits checks
const latestPackageSub = this.subSvc.getLatestSubscription(userSubscriptions, SubType.PACKAGE);
sortedPrices.forEach((price, indx) => {
if (price) {
const plan = subPlans[price.lookupKey] || {};
const isCurrentSubscription = price.lookupKey === currentLookupKey;
// Use centralized utility to check for custom limits
const hasCustomLimit = isCurrentSubscription &&
latestPackageSub &&
this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits);
// Use centralized utility to get effective vehicle limit
// For current subscription: Use API + custom limits
// For other packages: Use API data from price.maxVehicles
const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub
? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits)
: price.maxVehicles;
// Use centralized utility to get effective acres limit (SAME PATTERN AS VEHICLES)
// Treat empty string as null for proper "Unlimited" display
const effectiveMaxAcres = isCurrentSubscription && latestPackageSub
? this.subSvc.getEffectiveAcresLimit(latestPackageSub, userCustomLimits)
: null;
plan.price = price.priceUSD || plan.price;
plan.desc = plan.desc?.replace('#price#', this.subSvc.formatCurrency(price.priceUSD)) || plan.desc;
// Current subscription: use effectiveMaxVehicles (respects custom limits).
// All other plans: use price.maxVehicles from Stripe API.
if (isCurrentSubscription) {
if (effectiveMaxVehicles != null) {
plan.maxVehicles = effectiveMaxVehicles;
}
} else if (price.maxVehicles !== null && price.maxVehicles !== undefined) {
plan.maxVehicles = Math.abs(price.maxVehicles);
}
// Apply effective acres limit
// For current subscription: Use MongoDB session data (effectiveMaxAcres) even if null
// For other packages: Use Stripe API data (price.maxAcres)
// IMPORTANT: Only update if we have a valid value to prevent race condition
if (isCurrentSubscription) {
if (effectiveMaxAcres !== null && effectiveMaxAcres !== undefined) {
plan.maxAcres = Number(effectiveMaxAcres);
}
// Don't set to null - preserve existing value to avoid race condition
} else {
if (price.maxAcres !== null && price.maxAcres !== undefined) {
plan.maxAcres = Number(price.maxAcres);
}
// Don't set to null - preserve existing value to avoid race condition
}
plan.level = price.level || plan.level;
plan.type = price.type || plan.type;
if (effectiveMaxVehicles) {
if (hasCustomLimit && isCurrentSubscription) {
plan.Vehicles = `1-${effectiveMaxVehicles}`;
lastEffectiveMax = price.maxVehicles;
} else {
plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles);
lastEffectiveMax = effectiveMaxVehicles;
}
} else {
lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0;
}
subPlans[price.lookupKey] = plan;
}
});
const byPuid = this.authSvc.user?.parent || this.authSvc.user?._id;
return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, byPuid);
}),
switchMap(_usage => {
usage = _usage;
return this.subSvc.fetchSubscriptions(this.authSvc.user?.membership?.custId);
}),
switchMap(_subs => {
subscriptions = _subs;
return this.vehSvc.loadVehicles({ byUserId: this.authSvc.user?.parent }).pipe(
catchError(() => of([]))
);
}),
switchMap(_vehicles => {
vehicles = _vehicles;
const id = this.authSvc.user?.parent || this.authSvc.user._id;
return this.custSvc.getCustomer(id);
}),
switchMap(cust => {
const curSubPlan = this.subSvc.createSubPlan(subscriptions, cust?.membership, usage);
const getNumVehs = (type: string) => vehicles?.filter((veh) => veh[type] === true).length || 0;
const trkVehicles = getNumVehs(TRACKING);
const pkgActiveVehicles = getNumVehs(PACKAGE_ACTIVE);
const needReview = cust?.needReview;
if (subscriptions?.length === 0) {
const actions: Action[] = [
new subPlansActions.ResetSubPlans(),
new subPlansActions.FetchSubPlansSuccess(curSubPlan)
];
if (cust?.membership) {
// Transform membership to preserve trial_end and promoDetails (Case 2C fix)
actions.push(new FetchLatestSubscriptionSuccess({
subscriptions,
membership: this.subSvc.updateMembShip(subscriptions, cust?.membership)
}));
}
const currentUrl = this.router.url;
const isHome = currentUrl === '/' || currentUrl.includes(`/${SUB.HOME}`);
const isProfileRoute = currentUrl.includes(`/${SUB.PROFILE}`);
if (!isHome && !isProfileRoute) {
this.router.navigate([`/${SUB.PROFILE}/${SUB.SERVICES}`]);
}
return of(...actions);
}
// Use centralized utility to get current lookup key from latest subscription
const freshSubscriptions = cust?.membership?.subscriptions;
const freshCurrentLookupKey = this.subSvc.getCurrentPackageLookupKey(freshSubscriptions) ||
this.authSvc.getCurLookupKey(SubType.PACKAGE);
const staleCurrentLookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE);
// Always update the current plan's maxVehicles with fresh customer data
// (first block used stale authSvc cache — customLimits may not have been loaded yet)
const freshLatestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE);
const freshUserCustomLimits = cust?.membership?.customLimits;
if (freshCurrentLookupKey && freshLatestPackageSub && subPlans[freshCurrentLookupKey]) {
const freshEffective = this.subSvc.getEffectiveVehicleLimit(freshLatestPackageSub, freshUserCustomLimits);
if (freshEffective != null) {
subPlans[freshCurrentLookupKey].maxVehicles = freshEffective;
}
}
if (freshCurrentLookupKey && freshCurrentLookupKey !== staleCurrentLookupKey) {
let lastEffectiveMax = 0;
const userCustomLimits = cust?.membership?.customLimits;
// Get latest package subscription for custom limits checks
const latestPackageSub = this.subSvc.getLatestSubscription(freshSubscriptions, SubType.PACKAGE);
sortedPrices.forEach((price, indx) => {
if (price) {
const plan = subPlans[price.lookupKey];
if (plan) {
const isCurrentSubscription = price.lookupKey === freshCurrentLookupKey;
// Use centralized utility to check for custom limits
const hasCustomLimit = isCurrentSubscription &&
latestPackageSub &&
this.subSvc.hasCustomLimits(latestPackageSub, userCustomLimits);
// Use centralized utility to get effective vehicle limit
const effectiveMaxVehicles = isCurrentSubscription && latestPackageSub
? this.subSvc.getEffectiveVehicleLimit(latestPackageSub, userCustomLimits)
: price.maxVehicles;
// Current subscription: use effectiveMaxVehicles (respects custom limits).
// All other plans: use price.maxVehicles from Stripe API.
if (isCurrentSubscription) {
if (effectiveMaxVehicles != null) {
plan.maxVehicles = effectiveMaxVehicles;
}
} else if (price.maxVehicles !== null && price.maxVehicles !== undefined) {
plan.maxVehicles = Math.abs(price.maxVehicles);
}
if (effectiveMaxVehicles) {
if (hasCustomLimit && isCurrentSubscription) {
plan.Vehicles = `1-${effectiveMaxVehicles}`;
lastEffectiveMax = price.maxVehicles;
} else {
plan.Vehicles = this.subSvc.toVehRange(lastEffectiveMax, effectiveMaxVehicles);
lastEffectiveMax = effectiveMaxVehicles;
}
} else {
lastEffectiveMax = price.maxVehicles || effectiveMaxVehicles || 0;
}
}
}
});
}
const isCurTrkVehAboveLimit = trkVehicles > curSubPlan?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle;
const isCurActiveVehAboveLimit = pkgActiveVehicles > curSubPlan?.package?.[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.airCraft?.numOfVehicle;
const actions: Action[] = [
new subPlansActions.FetchSubPlansSuccess(curSubPlan)
];
if (cust?.membership) {
// Transform membership to preserve trial_end and promoDetails (Case 2C fix)
actions.unshift(new FetchLatestSubscriptionSuccess({
subscriptions,
membership: this.subSvc.updateMembShip(subscriptions, cust?.membership)
}));
}
if (isCurActiveVehAboveLimit || isCurTrkVehAboveLimit || needReview) {
actions.push(
new UpdateSubscriptionStatus(createSubStatus(SUB.AC_REVIEW)),
new GotoAircraftList()
);
}
return of(...actions);
})
)
}),
retryWhen(errors => errors.pipe(
delay(DELAY),
take(TAKE)
)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subPlans));
return handleErr<Observable<Action>>({
error: err, opt: {
extra: SubAppErr.FETCH_SUB_PLANS_ERR
}
});
}),
repeat()
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { Action } from "@ngrx/store"; import { Action } from "@ngrx/store";
import { StatusChange, Vehicle } from "../models/vehicle.model"; import { Vehicle } from "../models/vehicle.model";
export const FETCH = '[VEHICLES] Fetch vehilces'; export const FETCH = '[VEHICLES] Fetch vehilces';
export class Fetch implements Action { export class Fetch implements Action {
@ -38,6 +38,7 @@ export class CreateFailed implements Action {
export const UPDATE = '[VEHICLES] Update a vehilce'; export const UPDATE = '[VEHICLES] Update a vehilce';
export class Update implements Action { export class Update implements Action {
type: typeof UPDATE = UPDATE; type: typeof UPDATE = UPDATE;
constructor(readonly payload: Vehicle) { } constructor(readonly payload: Vehicle) { }
} }
export const UPDATE_SUCCESS = '[VEHICLES] Update vehilce success'; export const UPDATE_SUCCESS = '[VEHICLES] Update vehilce success';
@ -71,35 +72,13 @@ export class DeleteError implements Action {
export const SELECT = '[PILOTS] Select a pilot'; export const SELECT = '[PILOTS] Select a pilot';
export class Select implements Action { export class Select implements Action {
type: typeof SELECT = SELECT; type: typeof SELECT = SELECT;
constructor(readonly payload: Vehicle) { } constructor(readonly payload: Vehicle) { }
} }
interface UpdateVehiclesPayload {
vehicles: Vehicle[];
type?: string;
}
export const UPDATE_VEHICLES = '[VEHICLES] Update a vehicles';
export class UpdateVehicles implements Action {
type: typeof UPDATE_VEHICLES = UPDATE_VEHICLES;
constructor(readonly payload: UpdateVehiclesPayload) { }
}
export const UPDATE_VEHICLES_SUCCESS = '[VEHICLES] Update a vehicles success';
export class UpdateVehiclesSuccess implements Action {
type: typeof UPDATE_VEHICLES_SUCCESS = UPDATE_VEHICLES_SUCCESS;
constructor(readonly payload: UpdateVehiclesPayload) { }
}
export const UPDATE_VEHICLES_FAILED = '[VEHICLES] Update a vehicles failed';
export class UpdateVehiclesFailed implements Action {
type: typeof UPDATE_VEHICLES_FAILED = UPDATE_VEHICLES_FAILED;
}
export type All = export type All =
| Fetch | FetchSuccess | FetchError | Fetch | FetchSuccess | FetchError
| Create | CreateSuccess | CreateFailed | Create | CreateSuccess | CreateFailed
| Update | UpdateSuccess | UpdateFailed | Update | UpdateSuccess | UpdateFailed
| Delete | DeleteSuccess | DeleteError | Delete | DeleteSuccess | DeleteError
| Select | Select
| UpdateVehicles | UpdateVehiclesSuccess | UpdateVehiclesFailed;

View File

@ -23,11 +23,10 @@
<ng-template pTemplate="body" let-rowData let-columns="columns"> <ng-template pTemplate="body" let-rowData let-columns="columns">
<tr [pSelectableRow]="rowData"> <tr [pSelectableRow]="rowData">
<td *ngFor="let col of columns" [ngSwitch]="col.field"> <td *ngFor="let col of columns" [ngSwitch]="col.field">
<span class="ui-column-title">{{col.header}}</span> <div *ngSwitchCase="'color'">
<span *ngSwitchCase="'color'">
<div class="color-box" [ngStyle]="{ 'background-color': rowData[col.field] }"></div> <div class="color-box" [ngStyle]="{ 'background-color': rowData[col.field] }"></div>
<span style="vertical-align:middle; margin-left: .5em">{{ GC.colors[rowData[col.field]] }}</span> <span style="vertical-align:middle; margin-left: .5em">{{ GC.colors[rowData[col.field]] }}</span>
</span> </div>
<span *ngSwitchDefault>{{ rowData[col.field] }}</span> <span *ngSwitchDefault>{{ rowData[col.field] }}</span>
</td> </td>
</tr> </tr>

View File

@ -2,7 +2,9 @@ import { Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects'; import { Actions, Effect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { map, switchMap, catchError } from 'rxjs/operators'; import { map, switchMap, catchError } from 'rxjs/operators';
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import * as vehicleActions from '../actions/vehicle.actions'; import * as vehicleActions from '../actions/vehicle.actions';
import { AuthService } from '@app/domain/services/auth.service'; import { AuthService } from '@app/domain/services/auth.service';
import { VehicleService } from '@app/domain/services/vehicle.service'; import { VehicleService } from '@app/domain/services/vehicle.service';
@ -40,14 +42,7 @@ export class VehicleEffects {
this.vehilceSvc.saveVehicle(payload).pipe( this.vehilceSvc.saveVehicle(payload).pipe(
map((aircraft) => new vehicleActions.CreateSuccess(aircraft)), map((aircraft) => new vehicleActions.CreateSuccess(aircraft)),
catchError(err => { catchError(err => {
let msg; this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft));
const subErrs = ['reached_vehicles_limit', 'reached_area_limit', 'subscription_not_found', 'pkg_subscription_not_found'];
if (subErrs.some((subErr) => subErr === err['error']['error']['.tag'])) {
msg = globals.apiErrorMsg(err['error']['error']['.tag'] || '')
} else {
msg = globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft)
}
this.msgSvc.addFailedMsg(msg);
return of(new vehicleActions.CreateFailed()) return of(new vehicleActions.CreateFailed())
}) })
) )
@ -57,19 +52,15 @@ export class VehicleEffects {
@Effect() @Effect()
updateVehicle$: Observable<Action> = this.actions$.pipe( updateVehicle$: Observable<Action> = this.actions$.pipe(
ofType<vehicleActions.Update>(vehicleActions.UPDATE), ofType<vehicleActions.Update>(vehicleActions.UPDATE),
switchMap(({ payload }) => { switchMap(({ payload }) =>
if (!payload.active) { this.vehilceSvc.saveVehicle(payload).pipe(
payload.pkgActive = false;
payload.tracking = false;
}
return this.vehilceSvc.updateVehicles([payload]).pipe(
map(() => new vehicleActions.UpdateSuccess(payload)), map(() => new vehicleActions.UpdateSuccess(payload)),
catchError(err => { catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft)); this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft));
return of(new vehicleActions.UpdateFailed()); return of(new vehicleActions.UpdateFailed());
}) })
) )
}) )
); );
@Effect() @Effect()
@ -88,18 +79,4 @@ export class VehicleEffects {
) )
) )
); );
@Effect()
updateVehicles$: Observable<Action> = this.actions$.pipe(
ofType<vehicleActions.UpdateVehicles>(vehicleActions.UPDATE_VEHICLES),
switchMap(({ payload }) =>
this.vehilceSvc.updateVehicles(payload.vehicles).pipe(
map(() => new vehicleActions.UpdateVehiclesSuccess(payload)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.aircraft));
return of(new vehicleActions.UpdateVehiclesFailed());
})
)
)
);
} }

View File

@ -3,7 +3,7 @@ import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../domain/guards/auth.guard'; import { AuthGuard } from '../domain/guards/auth.guard';
import { AC, RoleIds } from '../shared/global'; import { RoleIds } from '../shared/global';
import { ProductListComponent } from './product/product-list/product-list.component'; import { ProductListComponent } from './product/product-list/product-list.component';
import { PilotListComponent } from './pilot/pilot-list/pilot-list.component'; import { PilotListComponent } from './pilot/pilot-list/pilot-list.component';
import { EntitiesMgtComponent } from './entities-mgt.component'; import { EntitiesMgtComponent } from './entities-mgt.component';
@ -14,8 +14,6 @@ import { VehicleEditComponent } from './vehicle/vehicle-edit/vehicle-edit.compon
import { VehicleResolver } from './vehicle-resolver.service'; import { VehicleResolver } from './vehicle-resolver.service';
import { CropListComponent } from './crop/crop-list/crop-list.component'; import { CropListComponent } from './crop/crop-list/crop-list.component';
import { CropsLoadGuard } from '../domain/guards/crops-load.guard'; import { CropsLoadGuard } from '../domain/guards/crops-load.guard';
import { SubscriptionGuard } from '@app/domain/guards/subscription.guard';
import { UserResolver } from '@app/domain/resolvers/user-resolver';
const routes: Routes = [ const routes: Routes = [
{ {
@ -55,17 +53,13 @@ const routes: Routes = [
] ]
}, },
{ {
path: AC, path: 'aircraft',
canActivate: [SubscriptionGuard],
data: { data: {
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.INSPECTOR, RoleIds.PILOT, RoleIds.CLIENT] roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.INSPECTOR, RoleIds.PILOT, RoleIds.CLIENT]
}, },
children: [ children: [
{ {
path: '', component: VehicleListComponent, path: '', component: VehicleListComponent,
resolve: {
user: UserResolver
}
}, },
{ {
path: ':id', component: VehicleEditComponent, path: ':id', component: VehicleEditComponent,

View File

@ -7,7 +7,6 @@ import { AutoCompleteModule } from 'primeng/autocomplete';
import { InputSwitchModule } from 'primeng/inputswitch'; import { InputSwitchModule } from 'primeng/inputswitch';
import { SplitButtonModule } from 'primeng/splitbutton'; import { SplitButtonModule } from 'primeng/splitbutton';
import { TableModule } from 'primeng/table'; import { TableModule } from 'primeng/table';
import { MessagesModule } from 'primeng/messages';
import { StoreModule } from '@ngrx/store'; import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
@ -17,7 +16,6 @@ import { VehicleEffects } from './effects/vehicle.effects';
import { reducers, FEATURE_KEY } from './reducers'; import { reducers, FEATURE_KEY } from './reducers';
import { AppSharedModule } from '../shared/app-shared.module'; import { AppSharedModule } from '../shared/app-shared.module';
import { PopupTooltipModule } from '../shared/popup-tooltip/popup-tooltip.module';
import { EntitiesRoutingModule } from './entities-routing.module'; import { EntitiesRoutingModule } from './entities-routing.module';
import { EntitiesMgtComponent } from './entities-mgt.component'; import { EntitiesMgtComponent } from './entities-mgt.component';
@ -28,7 +26,6 @@ import { PilotEditComponent } from './pilot/pilot-edit/pilot-edit.component';
import { PilotService } from '../domain/services/pilot.service'; import { PilotService } from '../domain/services/pilot.service';
import { PilotResolver } from './pilot-resolver.service'; import { PilotResolver } from './pilot-resolver.service';
import { VehicleEditComponent } from './vehicle/vehicle-edit/vehicle-edit.component'; 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 { VehicleResolver } from './vehicle-resolver.service';
import { VehicleService } from '../domain/services/vehicle.service'; import { VehicleService } from '../domain/services/vehicle.service';
import { CropEffects } from './effects/crop.effects'; import { CropEffects } from './effects/crop.effects';
@ -38,7 +35,6 @@ import { CropListComponent } from './crop/crop-list/crop-list.component';
@NgModule({ @NgModule({
imports: [ imports: [
AppSharedModule, AppSharedModule,
PopupTooltipModule,
DialogModule, DialogModule,
ConfirmDialogModule, ConfirmDialogModule,
CheckboxModule, CheckboxModule,
@ -46,13 +42,12 @@ import { CropListComponent } from './crop/crop-list/crop-list.component';
InputSwitchModule, InputSwitchModule,
SplitButtonModule, SplitButtonModule,
TableModule, TableModule,
MessagesModule,
StoreModule.forFeature(FEATURE_KEY, reducers), StoreModule.forFeature(FEATURE_KEY, reducers),
EffectsModule.forFeature([PilotEffects, ProductEffects, VehicleEffects, CropEffects]), EffectsModule.forFeature([PilotEffects, ProductEffects, VehicleEffects, CropEffects]),
EntitiesRoutingModule EntitiesRoutingModule
], ],
declarations: [EntitiesMgtComponent, ProductListComponent, PilotListComponent, VehicleListComponent, PilotEditComponent, VehicleEditComponent, VehiclePartnerIntegrationComponent, CropListComponent], declarations: [EntitiesMgtComponent, ProductListComponent, PilotListComponent, VehicleListComponent, PilotEditComponent, VehicleEditComponent, CropListComponent],
providers: [PilotService, PilotResolver, VehicleService, CropService, VehicleResolver], providers: [PilotService, PilotResolver, VehicleService, CropService, VehicleResolver],
schemas: [ schemas: [
CUSTOM_ELEMENTS_SCHEMA CUSTOM_ELEMENTS_SCHEMA

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