Compare commits

..

8 Commits

576 changed files with 849928 additions and 4783 deletions

View File

@ -0,0 +1,77 @@
# Gitea Actions Sync git repository to SVN
# Equivalent of .circleci/config.yml, translated to GitHub Actions syntax
# which Gitea Actions supports natively.
#
# Prerequisites (set as repository Secrets in Gitea → Settings → Secrets):
# SVN_USERNAME SVN commit username
# SVN_PASSWORD SVN commit password
# SVN_REPO_URL Base SVN repo URL (e.g. https://svn.example.com/repos/myproject)
name: Sync to SVN
on:
push:
branches:
- master
jobs:
push-to-svn:
runs-on: self-hosted
steps:
- name: Checkout git repository
uses: actions/checkout@v4
# SVN and rsync are pre-installed in the container image, but the
# apt-get call is kept so the workflow works on a stock runner too.
- name: Install SVN and rsync
run: |
if ! command -v svn &>/dev/null || ! command -v rsync &>/dev/null; then
sudo apt-get update -qq && sudo apt-get install -y subversion rsync
fi
- name: Verify SVN credentials are set
run: |
if [ -z "${{ secrets.SVN_USERNAME }}" ]; then echo "ERROR: SVN_USERNAME secret is not set" && exit 1; fi
if [ -z "${{ secrets.SVN_PASSWORD }}" ]; then echo "ERROR: SVN_PASSWORD secret is not set" && exit 1; fi
if [ -z "${{ secrets.SVN_REPO_URL }}" ]; then echo "ERROR: SVN_REPO_URL secret is not set" && exit 1; fi
echo "All SVN secrets are set."
- name: Checkout SVN branch
run: |
svn checkout \
--username "${{ secrets.SVN_USERNAME }}" \
--password "${{ secrets.SVN_PASSWORD }}" \
--no-auth-cache \
--non-interactive \
--trust-server-cert \
"${{ secrets.SVN_REPO_URL }}/branches/data-export-api-copy" svn-branch
- name: Sync files to SVN working copy
run: |
rsync -a --delete \
--exclude='.git/' \
--exclude='.gitea/' \
--exclude='.svn/' \
--exclude='svn-branch/' \
. svn-branch/
- name: Stage and commit to SVN
run: |
cd svn-branch
# Add all new/unversioned files and directories in one pass
svn add --force .
# Delete files removed from git (handles paths with spaces).
# '|| true' prevents pipefail aborting when grep finds no '!' lines.
svn status | grep '^!' | while IFS= read -r line; do
svn delete "${line:8}@"
done || true
# Attempt commit; svn commit exits 0 silently when nothing changed
svn commit \
--username "${{ secrets.SVN_USERNAME }}" \
--password "${{ secrets.SVN_PASSWORD }}" \
--no-auth-cache \
--non-interactive \
--trust-server-cert \
-m "Gitea CI sync from commit ${{ github.sha }} [ci skip]"

64
.githooks/pre-commit Normal file
View File

@ -0,0 +1,64 @@
#!/bin/sh
branch=$(git rev-parse --abbrev-ref HEAD)
# Allow master and development
if [ "$branch" = "master" ] || [ "$branch" = "development" ]; then
exit 0
fi
# Validate feature/* and bugfix/* branches
case "$branch" in
feature/*|bugfix/*)
name="${branch#*/}"
# Name must not be empty
if [ -z "$name" ]; then
echo "ERROR: Branch '$branch' has an empty name after the prefix."
exit 1
fi
# Only letters, digits, and dashes allowed
if echo "$name" | grep -qE '[^a-zA-Z0-9-]'; then
echo "ERROR: Branch '$branch' contains invalid characters."
echo " Only letters, digits, and dashes are allowed after the prefix."
exit 1
fi
# No leading or trailing dashes
if echo "$name" | grep -qE '^-|-$'; then
echo "ERROR: Branch '$branch' must not start or end with a dash."
exit 1
fi
# No consecutive dashes
if echo "$name" | grep -q -- '--'; then
echo "ERROR: Branch '$branch' must not contain consecutive dashes."
exit 1
fi
# Max 25 characters excluding dashes
name_no_dashes=$(echo "$name" | tr -d '-')
char_count=${#name_no_dashes}
if [ "$char_count" -gt 25 ]; then
echo "ERROR: Branch '$branch': the name after the prefix is $char_count characters (excluding dashes), max is 25."
exit 1
fi
exit 0
;;
esac
echo "ERROR: Branch name '$branch' does not follow the naming convention."
echo ""
echo " Allowed formats:"
echo " master"
echo " development"
echo " feature/{name}"
echo " bugfix/{name}"
echo ""
echo " Rules for feature/bugfix names:"
echo " - Letters, digits, and dashes only (no spaces)"
echo " - No leading, trailing, or consecutive dashes"
echo " - Max 20 characters excluding dashes"
exit 1

19
.gitignore vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,24 +8,169 @@
<user-profile-form formControlName="profile" [focusOnFirst]="isNew"></user-profile-form>
</div>
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<span style="margin-right:12px">
<ng-container i18n="@@accountType">Account Type</ng-container>:
</span>
<p-dropdown name="type" formControlName="kind" [options]="kinds" [style]="{'min-width': '120px'}">
<ng-template let-type pTemplate="item">
<span>
<strong>{{ type.label }}</strong>
<label for="accountType" class="field-label">
<ng-container i18n="@@accountType">Account Type</ng-container>
<!-- Account Type Disabled Feedback - Icon inline with label (detached mode) -->
<agm-constraint-message #accountTypeConstraint *ngIf="shouldShowAccountTypeDisabledMessage"
[collapsible]="true" [detached]="true" [message]="accountTypeConstraintMessage"
[title]="accountTypeConstraintTitle" severity="info" icon="pi-info-circle" class="inline-constraint">
</agm-constraint-message>
</label>
<div class="field-input">
<p-dropdown name="type" formControlName="kind" [options]="kinds" [style]="{'min-width': '120px'}"
(onChange)="onAccountTypeChange($event.value)">
<ng-template let-type pTemplate="item">
<span>
<strong>{{ type.label }}</strong>
</span>
</ng-template>
</p-dropdown>
</div>
<!-- Account Type message appears below dropdown (detached content) -->
<div *ngIf="shouldShowAccountTypeDisabledMessage" class="field-message">
<ng-container *ngTemplateOutlet="accountTypeConstraint?.detachedContentTemplate"></ng-container>
</div>
</div>
<!-- Vendor Selection (conditionally shown) -->
<div *ngIf="showVendorOptions" class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<!-- Show label and dropdown only when vendors are available -->
<ng-container *ngIf="availableVendorOptions.length > 1">
<label for="{{VENDOR_SYSTEM_FIELD}}" class="field-label">
<ng-container i18n="@@vendorSystemLabel">Partner System</ng-container>
<!-- Vendor System Disabled Feedback - Icon inline with label (detached mode) -->
<agm-constraint-message #vendorSystemConstraint *ngIf="shouldShowVendorSystemDisabledMessage"
[collapsible]="true" [detached]="true" [message]="vendorSystemConstraintMessage"
[title]="vendorSystemConstraintTitle" severity="info" icon="pi-info-circle" class="inline-constraint">
</agm-constraint-message>
</label>
<div class="field-input">
<p-dropdown name="{{VENDOR_SYSTEM_FIELD}}" formControlName="{{VENDOR_SYSTEM_FIELD}}"
[options]="availableVendorOptions" [style]="{'min-width': '120px'}"
(onChange)="onVendorChange($event.value)"
[class.ng-invalid]="form.get(VENDOR_SYSTEM_FIELD)?.invalid && form.get(VENDOR_SYSTEM_FIELD)?.touched">
</p-dropdown>
</div>
<!-- Validation error for when dropdown is visible -->
<div *ngIf="form.get(VENDOR_SYSTEM_FIELD)?.invalid && form.get(VENDOR_SYSTEM_FIELD)?.touched"
class="ui-message ui-messages-error field-message">
<div *ngIf="form.get(VENDOR_SYSTEM_FIELD)?.hasError('required')" i18n="@@vendorSelectionRequired">
Partner System
selection is required for partner system accounts</div>
</div>
<!-- Vendor System message appears below dropdown (detached content) -->
<div *ngIf="shouldShowVendorSystemDisabledMessage" class="field-message">
<ng-container *ngTemplateOutlet="vendorSystemConstraint?.detachedContentTemplate"></ng-container>
</div>
</ng-container>
<!-- Vendor loading indicator -->
<div *ngIf="vendorsLoading" class="loading-indicator">
<i class="fa fa-spinner fa-spin"></i> {{ Labels.LOADING_VENDOR_OPTIONS }}
</div>
<!-- Constraint message when no vendors available (replaces dropdown) -->
<agm-constraint-message *ngIf="availableVendorOptions.length <= 1 && !vendorsLoading" [collapsible]="true"
[message]="Labels.NO_AVAILABLE_VENDORS_MESSAGE" [title]="Labels.NO_AVAILABLE_VENDORS_TITLE"
severity="info" icon="pi-info-circle" class="field-message">
</agm-constraint-message>
</div>
<!-- Test Connection Section (conditionally shown for all partner systems) -->
<div *ngIf="showVendorOptions && selectedVendor && selectedVendor !== ''"
class="ui-g-12 test-connection-section">
<div class="test-connection-controls">
<button *ngIf="!isNew" pButton type="button" icon="ui-icon-link" [label]="Labels.TEST_CONNECTION"
i18n-pTooltip="@@testPartnerConnection" pTooltip="Test partner connection with current credentials"
tooltipPosition="bottom" [disabled]="satlocLoading" (click)="onTestPartnerConnection()">
</button>
<!-- Connection Status Icons -->
<div *ngIf="!isNew && !satlocLoading">
<!-- Success/Active Status -->
<span *ngIf="satlocIntegration.status === 'active'" class="connection-status-badge success"
i18n-pTooltip="@@connectionActive" pTooltip="Connection is active and working"
tooltipPosition="right">
<i class="ui-icon-check"></i>
</span>
</ng-template>
</p-dropdown>
<!-- 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">
<agm-account-editor formControlName="account" [isNew]="isNew" [required]="true" [showActive]="true"></agm-account-editor>
<div class="ui-g-12 ui-g-nopad form-row ui-fluid" style="padding-top: 0px; margin-top: 16px;">
<agm-account-editor #accountEditor formControlName="account" [isNew]="isNew" [required]="true"
[showActive]="true" [isPartnerSystemUser]="isCurrentAccountPartnerSystemUser"
[showAccountConstraint]="shouldShowAccountTypeDisabledMessage"
[accountConstraintMessage]="accountTypeConstraintMessage"
[accountConstraintTitle]="accountTypeConstraintTitle">
</agm-account-editor>
</div>
<!-- Account constraint message appears below account-editor (detached content) -->
<div *ngIf="shouldShowAccountTypeDisabledMessage" class="ui-g-12">
<ng-container *ngTemplateOutlet="accountEditor?.accountConstraint?.detachedContentTemplate"></ng-container>
</div>
<div class="ui-g-12 toolbar padtop1 ui-fluid">
<button pButton [disabled]="form.invalid" type="button" style="width:auto"
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save" (click)="saveAccount(); false"></button>
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back" (click)="goBack()" [label]="globals.back"></button>
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
(click)="saveAccount(); false"></button>
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back"
(click)="goBack()" [label]="globals.back"></button>
</div>
</div>
</form>

View File

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

View File

@ -7,7 +7,7 @@ import { User } from '../models/user.model';
import * as fromUsers from '../reducers';
import * as userActions from '../actions/account.actions';
import { RoleIds, globals } from '@app/shared/global';
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component';
import { Utils } from '@app/shared/utils';
@ -20,8 +20,9 @@ import { Utils } from '@app/shared/utils';
export class AccountListComponent extends BaseComp implements OnInit, OnDestroy {
readonly resolveFieldData = Utils.resolveFieldData;
readonly KIND = 'kind';
readonly ACTIVE = 'active';
readonly ACTIVE = OperationalStatus.ACTIVE;
accounts: Array<User>;
isLoading: boolean;
currAcc: User;
cols: any[];
userFilter: string;
@ -51,11 +52,10 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
ngOnInit() {
this.sub$ = this.store.select(fromUsers.getAllUsers).subscribe(users => this.accounts = users);
this.sub$.add(this.store.select(fromUsers.getIsLoading).subscribe(loading => this.isLoading = loading));
this.sub$.add(this.store.select(fromUsers.getSelectedUser).subscribe(
(acc) => this.currAcc = acc
));
// Always fetch the fresh list of accounts
this.store.dispatch(new userActions.Fetch());
}
@ -71,6 +71,13 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
return (this.currAcc && this.currAcc._id !== '0');
}
get canDelete() {
// WI-2: Soft lock - Allow deletion of all account types including vendor accounts
// Previously: blocked PARTNER_SYSTEM_USER accounts
// Now: allowed with warning confirmation dialog (see deleteAccount)
return this.canEdit;
}
newAccount() {
this.router.navigate(['account', '0'], { relativeTo: this.route });
}
@ -81,8 +88,19 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
deleteAccount() {
if (!this.currAcc) { return; }
// WI-2: Soft lock - Show special warning for vendor accounts
const isVendorAccount = this.currAcc?.kind === RoleIds.PARTNER_SYSTEM_USER;
const message = isVendorAccount
? Labels.VENDOR_DELETE_CONFIRM_MESSAGE
: globals.confirmDeleteThing.replace('#thing#', globals.account);
const header = isVendorAccount ? Labels.VENDOR_DELETE_CONFIRM_TITLE : undefined;
this.confirmSvc.confirm({
message: globals.confirmDeleteThing.replace('#thing#', globals.account),
header: header,
message: message,
acceptLabel: globals.yes,
rejectLabel: globals.no,
accept: () => {
this.store.dispatch(new userActions.Delete(this.currAcc));
this.currAcc = null;

View File

@ -7,6 +7,7 @@ import { CheckboxModule } from 'primeng/checkbox';
import { AutoCompleteModule } from 'primeng/autocomplete';
import { ToolbarModule } from 'primeng/toolbar';
import { InputSwitchModule } from 'primeng/inputswitch';
import { TooltipModule } from 'primeng/tooltip';
import { TableModule } from 'primeng/table';
import { CalendarModule } from 'primeng/calendar';
@ -37,6 +38,7 @@ import { FEATURE_KEY, reducer } from './reducers/users.reducer';
ToolbarModule,
SplitButtonModule,
TableModule,
TooltipModule,
StoreModule.forFeature(FEATURE_KEY, reducer),
EffectsModule.forFeature([AccountEffects]),

View File

@ -22,7 +22,12 @@ export const CREATE = '[USERS] Create a user';
export class Create implements Action {
type: typeof CREATE = CREATE;
constructor(readonly payload: User) { }
constructor(readonly payload: User & {
partnerConfig?: {
vendorSystemType: string;
vendorConfiguration: any;
};
}) { }
}
export const CREATE_SUCCESS = '[USERS] Create user success';
export class CreateSuccess implements Action {
@ -39,7 +44,12 @@ export const UPDATE = '[USERS] Update user';
export class Update implements Action {
type: typeof UPDATE = UPDATE;
constructor(readonly payload: User) { }
constructor(readonly payload: User & {
partnerConfig?: {
vendorSystemType: string;
vendorConfiguration: any;
};
}) { }
}
export const UPDATE_SUCCESS = '[USERS] Update user success';
export class UpdateSuccess implements Action {

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
import { map, switchMap, catchError } from 'rxjs/operators';
import { map, switchMap, catchError, repeat } from 'rxjs/operators';
import { Action } from '@ngrx/store';
@ -9,7 +9,9 @@ import * as userActions from '../actions/account.actions';
import { UserService } from '@app/domain/services/user.service';
import { AuthService } from '@app/domain/services/auth.service';
import { AppMessageService } from '@app/shared/app-message.service';
import { globals } from '@app/shared/global';
import { PartnerService } from '@app/partners/services/partner.service';
import { PartnerSystemUser } from '@app/accounts/models/user.model';
import { RoleIds, globals, KnownPartnerCodes } from '@app/shared/global';
@Injectable()
export class AccountEffects {
@ -17,7 +19,8 @@ export class AccountEffects {
private readonly actions$: Actions,
private readonly userSvc: UserService,
private readonly authSvc: AuthService,
private readonly msgSvc: AppMessageService
private readonly msgSvc: AppMessageService,
private readonly partnerSvc: PartnerService
) {
}
@ -25,55 +28,250 @@ export class AccountEffects {
loadUsers$: Observable<Action> = this.actions$.pipe(
ofType<userActions.Fetch>(userActions.FETCH),
switchMap(() =>
// All account types (including PARTNER_SYSTEM_USER) are returned by the backend
// /api/users/search endpoint — no separate /api/partners/systemUsers call needed.
this.userSvc.loadUsers({ byPuid: this.authSvc.user.parent }).pipe(
map(users => new userActions.FetchSuccess(users)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.accounts));
return of(new userActions.FetchError());
})
map(users => new userActions.FetchSuccess(users))
)
)
),
catchError(err => this.handleUserOperationError(err, 'load')),
repeat()
);
@Effect()
createUser$: Observable<Action> = this.actions$.pipe(
ofType<userActions.Create>(userActions.CREATE),
switchMap(({ payload }) =>
this.userSvc.saveUser(payload).pipe(
map((user) => new userActions.CreateSuccess(user)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.account));
return of(new userActions.CreateFailed())
})
)
)
switchMap(({ payload }) => {
// Extract user data and partner config from payload
const { partnerConfig, ...userData } = payload;
// For partner system users, create them directly through PartnerService
if (partnerConfig && partnerConfig.vendorSystemType) {
return this.createPartnerSystemUser(userData, partnerConfig);
}
// For regular users, use UserService directly
return this.userSvc.saveUser(userData).pipe(
map((savedUser) => new userActions.CreateSuccess(savedUser))
);
}),
catchError(err => this.handleUserOperationError(err, 'create')),
repeat()
);
@Effect()
updateUser$: Observable<Action> = this.actions$.pipe(
ofType<userActions.Update>(userActions.UPDATE),
switchMap(({ payload }) =>
this.userSvc.saveUser(payload).pipe(
map(() => new userActions.UpdateSuccess(payload)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.account));
return of(new userActions.UpdateFailed());
})
)
)
switchMap(({ payload }) => {
// Extract user data and partner config from payload
const { partnerConfig, ...userData } = payload;
// Case 1: User WITHOUT partner - use UserService directly + cleanup
if (!partnerConfig || !partnerConfig.vendorSystemType) {
return this.userSvc.saveUser(userData).pipe(
switchMap((savedUser) => {
// Clean up any existing partner system users for non-partner accounts
return this.cleanupPartnerSystemUsers(userData._id).pipe(
map(() => new userActions.UpdateSuccess(savedUser)),
catchError(err => {
console.error('Partner cleanup failed:', err);
// User update succeeded, cleanup failed is not critical
return of(new userActions.UpdateSuccess(savedUser));
})
);
})
);
}
// Case 2: User WITH partner - use PartnerService workflow completely
return this.updatePartnerUserWorkflow(userData, partnerConfig).pipe(
map((savedUser) => new userActions.UpdateSuccess(savedUser))
);
}),
catchError(err => this.handleUserOperationError(err, 'save')),
repeat()
);
@Effect()
deleteUser$: Observable<Action> = this.actions$.pipe(
ofType<userActions.Delete>(userActions.DELETE),
switchMap(({ payload }) =>
this.userSvc.deleteUser(payload).pipe(
map(() => new userActions.DeleteSuccess(payload)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.account));
return of(new userActions.UpdateFailed())
})
)
)
switchMap(({ payload }) => {
// Check if the user is a PARTNER_SYSTEM_USER
if (payload.kind === RoleIds.PARTNER_SYSTEM_USER) {
// Backend only disables partner system users (sets active=false), it does NOT remove them.
// Dispatch UpdateSuccess so the store reflects the disabled state in-place rather than
// removing the row — which would cause it to reappear on the next reload.
return this.partnerSvc.deleteSystemUser(payload._id).pipe(
map(() => new userActions.UpdateSuccess({ ...payload, active: false }))
);
} else {
// Use UserService for regular users
return this.userSvc.deleteUser(payload).pipe(
map(() => new userActions.DeleteSuccess(payload))
);
}
}),
catchError(err => this.handleUserOperationError(err, 'delete')),
repeat()
);
// Partner user workflow methods - use PartnerService exclusively
private createPartnerSystemUser(userData: any, partnerConfig: any): Observable<Action> {
// Get partner ID based on vendor type
return this.getPartnerByVendorType(partnerConfig.vendorSystemType).pipe(
switchMap(partnerId => {
if (!partnerId) {
throw new Error(`Failed to get partner for vendor type: ${partnerConfig.vendorSystemType}`);
}
// Create vendor-specific system user data
const createData = this.buildPartnerSystemUserData(userData, partnerConfig, partnerId);
return this.partnerSvc.createSystemUser(createData).pipe(
map((systemUser) => {
// ✅ FIX: Return the created system user with customerId/partnerId for post-save validation
// Merge the saved systemUser data with original userData to preserve all fields
return new userActions.CreateSuccess({
...userData,
...systemUser,
// Ensure we have the IDs for post-save validation
customer: systemUser.customer || createData.customerId,
partner: systemUser.partner || createData.partnerId
});
})
);
})
);
}
private updatePartnerUserWorkflow(userData: any, partnerConfig: any): Observable<any> {
// Use getSystemUserById to directly fetch the partner system user
return this.partnerSvc.getSystemUserById(userData._id).pipe(
switchMap(existingSystemUser => {
if (existingSystemUser) {
// Update existing partner system user with backend-compatible structure
const updateData = this.buildPartnerSystemUserData(userData, partnerConfig, existingSystemUser.partner._id);
return this.partnerSvc.updateSystemUser(existingSystemUser._id!, updateData).pipe(
map(() => userData) // Return the user data
);
} else {
// Partner system user doesn't exist, return error
throw new Error('Partner system user not found for update');
}
})
);
}
/**
* Build partner system user data structure based on vendor type
* This method can be extended to support additional vendors
*/
private buildPartnerSystemUserData(userData: any, partnerConfig: any, partnerId: string): any {
return {
partnerId: partnerId,
customerId: userData.parent, // AgMission customer (main applicator account)
username: userData.username,
password: userData.password,
name: userData.name,
active: userData.active,
email: userData.email,
address: userData.address,
phone: userData.phone,
companyId: partnerConfig.vendorConfiguration.companyId || null,
apiKey: partnerConfig.vendorConfiguration.apiKey || null,
apiSecret: partnerConfig.vendorConfiguration.apiSecret || null
// NOTE: metadata intentionally omitted — partner identity is carried by
// partnerId (ObjectId). metadata.vendor was a fragile frontend-derived
// copy that could silently diverge from the partner document.
};
}
/**
* Get partner ID by vendor type
* This method can be extended to support additional vendors
*/
private getPartnerByVendorType(vendorType: string): Observable<string | null> {
return this.partnerSvc.getPartners().pipe(
map((partners: any[]) => {
let partner = null;
switch (vendorType) {
case KnownPartnerCodes.SATLOC:
partner = partners.find(p =>
p.partnerCode === KnownPartnerCodes.SATLOC.toUpperCase() ||
p.name?.toLowerCase().includes(KnownPartnerCodes.SATLOC)
);
break;
// Add additional vendors here as needed
// case 'other_vendor':
// partner = partners.find(p =>
// p.partnerCode === 'OTHER_VENDOR' ||
// p.name?.toLowerCase().includes('other_vendor')
// );
// break;
default:
// Fallback: try to find partner by name or code matching vendor type
partner = partners.find(p =>
p.partnerCode?.toLowerCase() === vendorType.toLowerCase() ||
p.name?.toLowerCase().includes(vendorType.toLowerCase())
);
break;
}
return partner ? partner._id : null;
}),
catchError(() => of(null))
);
}
private cleanupPartnerSystemUsers(userId: string): Observable<any> {
return this.partnerSvc.getSystemUsersForCustomer(userId).pipe(
switchMap((systemUsers: PartnerSystemUser[]) => {
if (systemUsers.length === 0) {
return of(null);
}
// Delete all system users for this customer
const deleteOperations = systemUsers.map(systemUser =>
this.partnerSvc.deleteSystemUser(systemUser._id!).pipe(
catchError(error => {
console.error('Failed to delete partner system user:', error);
return of(null);
})
)
);
// Wait for all delete operations to complete
return of(...deleteOperations);
}),
catchError(error => {
console.error('Failed to load partner system users for cleanup:', error);
return of(null);
})
);
}
// Centralized error handler for user operations following subscription.effects pattern
private handleUserOperationError(err: any, operation: 'create' | 'save' | 'delete' | 'load'): Observable<Action> {
const actionVerb = operation === 'create' ? globals.create :
operation === 'save' ? globals.save :
operation === 'delete' ? globals.delete : globals.load;
// For load operation, use 'accounts' (plural), for others use 'account' (singular)
const thingName = operation === 'load' ? globals.accounts : globals.account;
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', actionVerb).replace('#thing#', thingName));
if (operation === 'create') {
return of(new userActions.CreateFailed());
} else if (operation === 'save') {
return of(new userActions.UpdateFailed());
} else if (operation === 'delete') {
return of(new userActions.UpdateFailed()); // Note: There's no DeleteFailed action, using UpdateFailed
} else {
return of(new userActions.FetchError());
}
}
}

View File

@ -1,5 +1,5 @@
import { Address } from '@app/domain/models/subscription.model';
import { RoleIds } from '@app/shared/global';
import { RoleIds, OperationalStatusType } from '@app/shared/global';
interface RoleArray {
[index: number]: string;
@ -10,20 +10,98 @@ export interface User {
username?: string;
password?: string;
name?: string;
address?: string;
address?: string | null;
country?: string;
Country?: any;
phone?: string;
email?: string;
phone?: string | null;
email?: string | null;
kind: string;
roles?: RoleArray;
active?: boolean;
createdAt?: Date;
updatedAt?: Date;
parent?: any;
contact?: string;
addresses?: Address[];
billAddress?;
needReview?: boolean;
// Optional partner system fields (present for partner system users)
customer?: string | { _id: string; username: string; name: string; kind: string; };
partner?: string | { _id: string; name: string; kind: string; };
}
// PartnerSystemUser extends User with partner-specific fields
export interface PartnerSystemUser extends User {
// Partner relationships (populated objects from backend via .populate())
// NOTE: backend uses .lean() so the 'customer' Mongoose virtual is NOT present.
// 'parent' is populated as { _id, username, name, kind } in API responses.
partner: {
_id: string;
name: string;
partnerCode?: string;
kind: string;
};
// 'customer' virtual from Mongoose is NOT returned by .lean(). Use 'parent' instead.
customer?: {
_id: string;
username: string;
name: string;
kind: string;
};
// Partner system credentials
partnerUserId?: string; // User ID in partner system
partnerUsername?: string; // Username in partner system
companyId?: string | null; // Company ID in partner system
// Access credentials (encrypted in production)
apiKey?: string | null;
apiSecret?: string | null;
// Status and metadata
lastLoginAt?: Date;
lastSyncAt?: Date;
syncStatus?: OperationalStatusType;
// Partner-specific metadata (contains vendor config)
metadata?: {
vendor?: string;
satlocUrl?: string;
satlocUsername?: string;
satlocPassword?: string;
[key: string]: any;
};
// Additional fields from backend response
address?: string | null;
email?: string | null;
phone?: string | null;
}
export interface SatlocConnectionResult {
success: boolean;
message?: string;
error?: string;
connectionTime?: number;
serverInfo?: {
version?: string;
capabilities?: string[];
};
account_info?: SatlocAccountInfo;
}
export interface SatlocAccountInfo {
company_name: string;
aircraft_count: number;
api_version: string;
}
export interface SatlocIntegration {
enabled: boolean;
status: OperationalStatusType;
account_info: SatlocAccountInfo | null;
credentials_stored: boolean;
last_error: string | null;
}
export const createNewUser = (parentId?: string, kind: String = RoleIds.APP_ADM) => {

View File

@ -28,6 +28,9 @@ export function reducer(
switch (action.type) {
case actions.FETCH:
// Clear stale entities immediately so the list never shows old data while loading.
return adapter.removeAll({ ...state, loading: true });
case actions.CREATE:
case actions.UPDATE:
case actions.DELETE:

View File

@ -185,6 +185,12 @@ export class UpdateAmount implements Action {
constructor(readonly payload: PaidAmount) { }
}
export const UPDATE_PROMO_SAVINGS = '[SUBSCRIPTION Making payment intent session] Update promo savings';
export class UpdatePromoSavings implements Action {
type: typeof UPDATE_PROMO_SAVINGS = UPDATE_PROMO_SAVINGS;
constructor(readonly payload: number) { }
}
// Resolving payment session actions
export const FETCH_LATEST_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription';
export class FetchLatestSubscription implements Action {
@ -506,6 +512,7 @@ export type SubscriptionIntentAction =
| UpdateBillingAddressSuccess
| UpdateSubscriptionSuccess
| UpdateAmount
| UpdatePromoSavings
| ClearPrevStage
| GotoUsageDetail
| LoadStripe

View File

@ -7,6 +7,7 @@ import { ReportComponent } from './report.component';
import { AppMainComponent } from './app.main.component';
import { AppPreloader } from './app-preloader';
import { AppPasswordResetComp } from './pages/app.password-reset.component';
import { NotificationRedirectGuard } from './domain/guards/notification-redirect.guard';
import { SettingsGuard } from './domain/guards/settings-guard.service';
import { MembershipResolver } from './domain/resolvers/membership-resolver';
@ -30,6 +31,10 @@ const routes: Routes = [
path: 'customers',
loadChildren: () => import('./customers/customer.module').then(m => m.CustomersModule),
},
{
path: 'partners',
loadChildren: () => import('./partners/partners.module').then(m => m.PartnersModule),
},
{
path: 'profile',
loadChildren: () => import('./profile/profile.module').then((m) => m.ProfileModule),
@ -75,6 +80,16 @@ const routes: Routes = [
runGuardsAndResolvers: 'always',
data: { preload: true }
},
{
path: 'partner-customers',
loadChildren: () => import('./partner-customers/partner-customers.module').then(m => m.PartnerCustomersModule),
runGuardsAndResolvers: 'always'
},
{
path: 'settings',
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
runGuardsAndResolvers: 'always'
},
],
},
{
@ -104,6 +119,34 @@ const routes: Routes = [
path: 'signup',
loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule)
},
{
path: 'manage-subscription',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'myservices'],
redirectToNoSubs: ['profile', 'services'],
loginNotice: $localize`:Login notice for manage-subscription link@@manageSubLoginNotice:Please log in with your Master account to manage your subscriptions.`
}
},
{
path: 'update-pm',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'payment-method-list'],
loginNotice: $localize`:Login notice for update-pm link@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
}
},
{
path: 'update-bill-address',
component: PageNotFoundComponent,
canActivate: [NotificationRedirectGuard],
data: {
redirectTo: ['profile', 'billing-address'],
loginNotice: $localize`:Login notice for update-bill-address link@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
}
},
{ path: '**', component: PageNotFoundComponent },
];

View File

@ -38,7 +38,7 @@ export class AppComponent extends BaseComp implements OnInit, OnDestroy {
this.gaSvc.initialize();
if (!environment.production) {
console.log('GA4 Service initialized:', this.gaSvc.isInitialized());
!environment.production && console.log('GA4 Service initialized:', this.gaSvc.isInitialized());
}
// Track session start

View File

@ -32,12 +32,19 @@
</div>
<div class="layout-main">
<div class="content-expiry-banner" *ngIf="expiryWarning$ | async as warning">
<span class="expiry-warning"
[class.warning]="!warning.willAutoRenew"
[class.info]="warning.willAutoRenew"
(click)="onNavigateToManageSubscription()">
{{ getExpiryWarningMessage(warning) }}
</span>
</div>
<div class="layout-content">
<ng-container *ngIf="canDisplayTrial()">
<trial-message [trials]="membership.trials" [isTrialDays]="isTrialDays()" [canDisplayAcceptTrial]="canDisplayAcceptTrial()" (accept)="accept()">
</trial-message>
</ng-container>
<router-outlet></router-outlet>
</ng-container> <router-outlet></router-outlet>
<agm-footer *ngIf="showFooter" [showLang]="!isAdmin"></agm-footer>
</div>
</div>

View File

@ -4,7 +4,8 @@ import { AuthService } from './domain/services/auth.service';
import { ActivatedRoute, Router } from '@angular/router';
import { ConfirmationService } from 'primeng-lts/api';
import { DomSanitizer } from '@angular/platform-browser';
import { Observable, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import cloneDeep from 'clone-deep';
import { globals } from './shared/global';
import { AppConfigService } from './domain/services/app-config.service';
@ -12,7 +13,10 @@ import { IAppConfig } from './domain/models/appconfig.model';
import { Compound, FetchLatestSubscriptionSuccess, GotoServices, SetMode } from './actions/subscription.actions';
import { Mode, SUB } from './profile/common';
import { Store } from '@ngrx/store';
import { IMembership } from './auth/models/user.model';
import { IMembership, UserModel } from './auth/models/user.model';
import { ExpiryWarning } from './domain/models/subscription.model';
import { buildExpiryWarningMessage } from './app.profile.component';
import * as fromStore from '../../src/app/reducers/index';
enum MenuOrientation {
STATIC,
@ -71,6 +75,8 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
settings: IAppConfig;
membership: IMembership;
user$: Observable<UserModel>;
expiryWarning$: Observable<ExpiryWarning | null>;
constructor(
public readonly zone: NgZone,
@ -86,6 +92,11 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
) {
this.membership = this.route.snapshot.data['membership'];
this.settings = cloneDeep(this.appConfSvc.settings);
this.user$ = this.store.select(fromStore.selectAuthUser);
this.expiryWarning$ = combineLatest([
this.store.select(fromStore.selectExpiryWarning),
this.store.select(fromStore.selectNoSubsWarning)
]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs));
}
ngOnInit() {
@ -95,6 +106,14 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
this.showPaidPopup();
}
getExpiryWarningMessage(warning: ExpiryWarning): string {
return buildExpiryWarningMessage(warning);
}
onNavigateToManageSubscription(): void {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
bindRipple() {
this.rippleInitListener = this.init.bind(this);
document.addEventListener('DOMContentLoaded', this.rippleInitListener);
@ -448,6 +467,11 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
}
canDisplayTrial() {
// this.membership is populated synchronously by the route resolver, before the
// NgRx auth store is hydrated (FetchLatestSubscriptionSuccess fires 100ms later).
// Using it here prevents the banner from flashing on F5 reload for users who
// already have subscriptions (including trialing ones).
if (this.membership?.subscriptions?.length > 0) return false;
return this.authSvc.canDisplayTrial(this.membership?.trials);
}

View File

@ -28,6 +28,8 @@ export class AppMenuComponent implements OnInit {
ngOnInit() {
if (this.authSvc.hasRole([RoleIds.ADMIN])) {
this.creatAdminMenu()
} else if (this.authSvc.isPartner) {
this.createPartnerMenu();
} else {
this.createUserMenu();
}
@ -37,7 +39,39 @@ export class AppMenuComponent implements OnInit {
const mItems: MenuItem[] = [
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
{
id: 'settings',
label: $localize`:@@settings:Settings`, icon: 'settings',
routerLink: ['/settings'],
items: [
{ id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] }
]
},
];
this.model = mItems;
}
createPartnerMenu() {
const mItems: MenuItem[] = [
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
{
id: 'partner-customers',
label: $localize`:@@partnerCustomers:Partner Customers`,
icon: 'business',
routerLink: ['/partner-customers']
},
{
id: 'Help',
label: $localize`:@@help:Help`, icon: 'help_outline',
items: [{
label: $localize`:@@trainingVideos:Training Videos`,
icon: 'video_library',
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
target: '_blank'
}]
}
];
this.model = mItems;
}
@ -174,7 +208,8 @@ export class AppMenuComponent implements OnInit {
routerLink: ['/tools'],
items: [
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] }
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
]
}
);

View File

@ -10,6 +10,7 @@ import { ButtonModule } from 'primeng/button';
import { MenuModule } from 'primeng/menu';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { DialogModule } from 'primeng/dialog';
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ToastModule } from 'primeng/toast';
@ -86,7 +87,7 @@ export function translationsFactory(locale: string) {
imports: [
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule,
MessagesModule, ToastModule, ConfirmDialogModule, DropdownModule, CheckboxModule, AppSharedModule,
MessagesModule, ToastModule, ConfirmDialogModule, DialogModule, DropdownModule, CheckboxModule, AppSharedModule,
// The store that defines our app state
StoreModule.forRoot(reducers, {
metaReducers,

View File

@ -3,6 +3,7 @@
color: #fff;
font-size: 0.95rem;
font-weight: 500;
text-align: right;
}
.account-summary-info .account-username {
@ -19,24 +20,3 @@
color: #ffd700;
opacity: 0.9;
}
@media (max-width: 1024px) {
.account-summary-info {
font-size: 0.85rem;
padding-top: 0.25em;
text-align: left;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
margin-right: 2em;
}
.account-summary-info .account-username,
.account-summary-info .account-type,
.account-summary-info .account-contact {
margin-right: 0;
display: block;
font-size: 1em;
}
}

View File

@ -2,4 +2,35 @@
<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,7 +1,76 @@
import { Component, Input } from '@angular/core';
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { globals } from './shared/global';
import { UserModel } from './auth/models/user.model';
import { UserService } from './domain/services/user.service';
import { ExpiryWarning } from './domain/models/subscription.model';
export function buildExpiryWarningMessage(expiryWarning: ExpiryWarning | null): string {
if (!expiryWarning) return '';
if (expiryWarning.noSubs) {
return $localize`:No subscription warning@@noSubsWarning:No current AgMission service subscribed` +
' - ' + $localize`:Renew@@renewLabel:Renew`;
}
const messages: string[] = [];
const daysLabel = (days: number) =>
days === 0
? $localize`:Expiring today@@today:today`
: `${$localize`:In@@in:in`} ${days} ${$localize`:Days@@days:days`}`;
if (expiryWarning.package) {
const pkg = expiryWarning.package;
const days = pkg.daysUntilExpiry;
const willRenew = pkg.willAutoRenew;
const isTrial = pkg.isTrial;
const isCanceled = pkg.isCanceled;
if (isCanceled) {
messages.push(`${pkg.name} ${$localize`:Package canceled@@pkgCanceled:canceled - access ended`} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
} else if (isTrial) {
if (willRenew) {
messages.push(`${pkg.name} ${$localize`:Trial renewing@@pkgTrialRenewing:trial ends`} ${daysLabel(days)} - ${$localize`:Will auto-renew@@willAutoRenew:will Auto-Renew`}`);
} else {
messages.push(`${pkg.name} ${$localize`:Trial expiring@@pkgTrialExpiring:trial ends`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
}
} else {
if (willRenew) {
messages.push(`${pkg.name} ${$localize`:Package renewing@@pkgRenewing:renews`} ${daysLabel(days)}`);
} else {
messages.push(`${pkg.name} ${$localize`:Package expiring@@pkgExpiring:expires`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
}
}
}
if (expiryWarning.addons && expiryWarning.addons.length > 0) {
expiryWarning.addons.forEach(addon => {
const days = addon.daysUntilExpiry;
const willRenew = addon.willAutoRenew;
const isTrial = addon.isTrial;
const isCanceled = addon.isCanceled;
if (isCanceled) {
messages.push(`${addon.name} ${$localize`:Addon canceled@@addonCanceled:canceled - access ended`} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
} else if (isTrial) {
if (willRenew) {
messages.push(`${addon.name} ${$localize`:Addon trial renewing@@addonTrialRenewing:trial ends`} ${daysLabel(days)} - ${$localize`:Will auto-renew@@willAutoRenew:will Auto-Renew`}`);
} else {
messages.push(`${addon.name} ${$localize`:Addon trial expiring@@addonTrialExpiring:trial ends`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
}
} else {
if (willRenew) {
messages.push(`${addon.name} ${$localize`:Addon renewing@@addonRenewing:renews`} ${daysLabel(days)}`);
} else {
messages.push(`${addon.name} ${$localize`:Addon expiring@@addonExpiring:expires`} ${daysLabel(days)} - ${$localize`:Renew now@@renewNow:Renew Now`}`);
}
}
});
}
return messages.join('; ');
}
@Component({
selector: "app-inline-profile",
@ -12,10 +81,58 @@ export class AppInlineProfileComponent {
readonly globals = globals;
@Input() user: UserModel;
@Input() expiryWarning: ExpiryWarning | null;
@Output() navigateToSubscription = new EventEmitter<void>();
showMasterPopup = false;
masterInfo: { username: string; contact?: string; name?: string; phone?: string; email?: string } | null = null;
private masterInfoFetchedAt: number | null = null;
private readonly MASTER_INFO_TTL_MS = 2 * 60 * 1000; // re-fetch after 2 minutes
constructor(readonly userSvc: UserService) { }
getAccountType(user: UserModel): string {
return this.userSvc.getAccountType(user);
}
getWarningMessage(): string {
return buildExpiryWarningMessage(this.expiryWarning);
}
onWarningClick(): void {
// Always navigate to subscription for all accounts
this.navigateToSubscription.emit();
// Show master-account info popup only for sub-accounts:
// skip if no parent, or parent is the same as this user (self-referencing master)
const parentId = this.user?.parent;
if (!parentId || parentId === this.user._id) return;
const now = Date.now();
const isFresh = this.masterInfoFetchedAt !== null && (now - this.masterInfoFetchedAt) < this.MASTER_INFO_TTL_MS;
if (isFresh) {
this.showMasterPopup = true;
return;
}
this.userSvc.getUser(parentId, { view: 'profile' }).pipe(
catchError(() => of(null))
).subscribe(master => {
if (master) {
this.masterInfo = {
username: master.username ?? '',
contact: master.contact,
name: master.name,
phone: master.phone,
email: master.email,
};
} else {
// Fallback: show whatever the parent field holds (may be a populated object)
const p = this.user.parent;
this.masterInfo = {
username: (typeof p === 'object' && p?.username) ? p.username : '',
};
}
this.masterInfoFetchedAt = Date.now();
this.showMasterPopup = true;
});
}
}

View File

@ -3,7 +3,8 @@
<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"></app-inline-profile>
<app-inline-profile [user]="user" [expiryWarning]="expiryWarning$ | async"
(navigateToSubscription)="onNavigateToManageSubscription()"></app-inline-profile>
<a id="menu-button" href="#" (click)="app.onMenuButtonClick($event)">
<i></i>
</a>
@ -13,7 +14,8 @@
<span *ngIf="!app.isAdmin" class="topbar-badge animated">1</span>
</a>
<ul class="topbar-items animated fadeInDown" [ngClass]="{ 'topbar-items-visible': app.topbarMenuActive }">
<li #profile class="profile-item" *ngIf="app.profileMode === 'top' || app.isHorizontal()" [ngClass]="{ 'active-top-menu': app.activeTopbarItem === profile }">
<li #profile class="profile-item" *ngIf="app.profileMode === 'top' || app.isHorizontal()"
[ngClass]="{ 'active-top-menu': app.activeTopbarItem === profile }">
<a href="#" (click)="app.onTopbarItemClick($event, profile)">
<i class="topbar-icon material-icons">apps</i>
<span class="topbar-item-name">Profile</span>

View File

@ -1,26 +1,77 @@
import { Component } from '@angular/core';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { Observable, Subscription, combineLatest } from 'rxjs';
import { first, filter, switchMap, map } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppMainComponent } from './app.main.component';
import * as authActions from './auth/actions/auth.actions';
import * as fromStore from '../../src/app/reducers/index';
import { UserModel } from './auth/models/user.model';
import { ExpiryWarning } from './domain/models/subscription.model';
import { SUB } from './profile/common';
import { UserService } from './domain/services/user.service';
@Component({
selector: 'app-topbar',
templateUrl: './app.topbar.component.html'
})
export class AppTopbarComponent {
user$: Observable<UserModel>
export class AppTopbarComponent implements OnInit, OnDestroy {
user$: Observable<UserModel>;
expiryWarning$: Observable<ExpiryWarning | null>;
private sub$ = new Subscription();
constructor(
public readonly app: AppMainComponent,
private readonly store: Store<{}>,
private readonly router: Router
private readonly router: Router,
private readonly userSvc: UserService
) {
this.user$ = this.store.select(fromStore.selectAuthUser);
this.expiryWarning$ = combineLatest([
this.store.select(fromStore.selectExpiryWarning),
this.store.select(fromStore.selectNoSubsWarning)
]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs));
}
ngOnInit(): void {
// Fetch fresh user data from server on component init (page load/reload)
// This ensures header displays current data even if changed externally
this.sub$.add(
this.user$.pipe(
first(), // Only run once on init
filter(user => !!user && !!user._id), // Only if user exists
switchMap(user => this.userSvc.getUser(user._id, { view: 'profile' }))
).subscribe(freshUser => {
if (freshUser) {
this.store.dispatch(new authActions.RefreshUserData({
user: this.mapUserToUserModel(freshUser)
}));
}
})
);
}
ngOnDestroy(): void {
this.sub$.unsubscribe();
}
/**
* Map User (from API) to UserModel (for store)
* Only maps fields that should be refreshed from server
*/
private mapUserToUserModel(user: any): UserModel {
return {
_id: user._id,
name: user.name || '',
username: user.username || '',
roles: user.roles || [],
parent: user.parent || '',
lang: user.lang || 'en',
pre: user.pre || 0,
billable: user.billable,
membership: user.membership,
contact: user.contact || ''
};
}
manageServices() {
@ -43,4 +94,12 @@ export class AppTopbarComponent {
updateUserProfile(userId: string) {
this.router.navigate([SUB.PROFILE, 'edit', userId]);
}
/**
* Navigate to manage subscription page
* Triggered by subscription expiry notification click
*/
onNavigateToManageSubscription(): void {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
}

View File

@ -37,9 +37,17 @@ export class LogoutComplete implements Action {
}
export const REFRESH_USER_DATA = '[Auth] Refresh User Data';
export class RefreshUserData implements Action {
readonly type: typeof REFRESH_USER_DATA = REFRESH_USER_DATA;
constructor(public payload: { user: UserModel }) { }
}
export type All =
| Login
| LoginSuccess
| LoginFailed
| Logout
| LogoutComplete;
| LogoutComplete
| RefreshUserData;

View File

@ -50,8 +50,9 @@ export class AuthEffects {
private navigateDefault(lang) {
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home';
// Replace the current page with the next target url => prevent Back to previous
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + 'home');
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + returnUrl);
}
@Effect()

View File

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

View File

@ -1,5 +1,7 @@
import { Component, OnInit, OnDestroy, ViewChild, isDevMode } from '@angular/core';
import { ReCaptcha2Component } from 'ngx-captcha';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { Authenticate } from '../models/auth.model';
import * as authActions from '../actions/auth.actions';
@ -36,23 +38,57 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
public captchaSuccess = false;
private _lastVerReqAt: number = 0;
// Debounced validation to prevent flash error on Chrome autofill
public showUsernameError = false;
public showPasswordError = false;
private usernameValidation$ = new Subject<boolean>();
private passwordValidation$ = new Subject<boolean>();
constructor(
) {
super();
this['name'] = "LoginComp";
if (this.router.getCurrentNavigation()) {
const routeSate = this.router.getCurrentNavigation().extras && this.router.getCurrentNavigation().extras.state;
if (routeSate && routeSate.changedPwd) {
this.msgs = [{ severity: 'info', summary: '', detail: globals.pwdChangedOk }];
const nav = this.router.getCurrentNavigation();
if (nav) {
const msgs: any[] = [];
const state = nav.extras?.state;
if (state?.changedPwd) {
msgs.push({ severity: 'info', summary: '', detail: globals.pwdChangedOk });
}
const returnUrl = nav.finalUrl?.queryParams?.['returnUrl'] ?? nav.extractedUrl?.queryParams?.['returnUrl'];
const loginNotice = nav.finalUrl?.queryParams?.['loginNotice'] ?? nav.extractedUrl?.queryParams?.['loginNotice'];
if (loginNotice) {
msgs.push({ severity: 'info', summary: '', detail: loginNotice });
}
if (msgs.length) this.msgs = msgs;
}
}
ngOnInit() {
this.lang = this.authSvc.locale;
// Debounce username validation by 100ms to handle Chrome autofill race condition
this.sub$.add(
this.usernameValidation$.pipe(
debounceTime(100),
distinctUntilChanged()
).subscribe(showError => {
this.showUsernameError = showError;
})
);
// Debounce password validation by 100ms to handle Chrome autofill race condition
this.sub$.add(
this.passwordValidation$.pipe(
debounceTime(100),
distinctUntilChanged()
).subscribe(showError => {
this.showPasswordError = showError;
})
);
this.useReCaptcha && (
this.sub$.add(this.appActions.ofTypes([authActions.LOGIN_FAILED]).subscribe(action => {
this.captchaElem.resetCaptcha();
@ -73,6 +109,22 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
return StringUtils.isEmpty(this.model.username) ? globals.usernameReqVal : globals.usernameInvalidVal;
}
/**
* Emits username validation state with debounce to prevent flash on Chrome autofill.
* Called on input and blur events.
*/
onUsernameValidation(invalid: boolean, dirty: boolean, touched: boolean) {
this.usernameValidation$.next(invalid && (dirty || touched));
}
/**
* Emits password validation state with debounce to prevent flash on Chrome autofill.
* Called on input and blur events.
*/
onPasswordValidation(invalid: boolean, dirty: boolean, touched: boolean) {
this.passwordValidation$.next(invalid && (dirty || touched));
}
handleSuccess(captchaResp: string): void {
// Verify user reponse token with server side within 2 minutes according to GG Ref: https://developers.google.com/recaptcha/docs/verify
this._lastVerReqAt = Date.now();

View File

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

View File

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

View File

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

View File

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

View File

@ -2,11 +2,12 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormGroup, FormBuilder } from '@angular/forms';
import { SelectItem } from 'primeng/api';
import { Customer } from '../models/customer.model';
import { Customer, Partner } from '../models/customer.model';
import * as customerActions from '../actions/customer.actions';
import { UserService } from '@app/domain/services/user.service';
import { PartnerService } from '@app/partners/services/partner.service';
import { BaseComp } from '@app/shared/base/base.component';
import { GC, RoleIds, globals } from '@app/shared/global';
import { GC, RoleIds, globals, Labels } from '@app/shared/global';
import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model';
import { SubStripe, SubTexts } from '@app/profile/common';
import { IMembership } from '@app/auth/models/user.model';
@ -17,9 +18,10 @@ import { DateUtils } from '@app/shared/utils';
templateUrl: './customer-edit.component.html',
styleUrls: ['./customer-edit.component.css']
})
export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy {
export class CustomerEditComponent extends BaseComp implements OnInit {
readonly globals = globals;
readonly SubTexts = SubTexts;
readonly Labels = Labels;
form: FormGroup;
selectedItem: Customer;
@ -33,6 +35,11 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
membership: IMembership;
lang;
// Partner Selection Properties
partnerOptions: SelectItem[] = [];
partnerLoading = false;
partnerError: string | null = null;
private _customer: Customer;
get customer(): Customer { return this._customer; }
set customer(customer: Customer) {
@ -43,8 +50,12 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password },
premium: this.selectedItem.premium,
billable: this.selectedItem.billable,
trials: this.selectedItem.membership?.trials
trials: this.selectedItem.membership?.trials,
partner: this.selectedItem.partner || null
});
// Set partner selection based on customer.partner field, or null if not set
// Form control will be updated by loadPartners() method
}
private _isNew: boolean;
@ -55,6 +66,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
constructor(
private readonly route: ActivatedRoute,
private readonly userSvc: UserService,
private readonly partnerSvc: PartnerService,
private readonly fb: FormBuilder
) {
super();
@ -69,9 +81,13 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
account: [],
premium: [],
billable: [],
trials: []
trials: [],
// Partner form control
partner: [null]
});
this.lang = this.authSvc.locale;
}
ngOnInit() {
@ -88,6 +104,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
this.trialSubs = this.membership.subscriptions?.filter((sub) => sub.status === SubStripe.TRIALING) || [];
this.paidSubs = this.membership.subscriptions?.filter((sub) => sub.status !== SubStripe.TRIALING) || [];
}
// Load partners from service
this.loadPartners();
}
});
@ -118,10 +136,14 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
let custObj;
const updateTrialMembship = (membership?) => {
const trialsValue = this.form.value.trials;
// Get trials value from form control (includes disabled controls via ControlValueAccessor)
const trialsControl = this.form.get('trials');
const trialsValue = trialsControl ? trialsControl.value : null;
if (trialsValue?.selected) {
const trials: Trial = { ...trialsValue };
delete trials.selected;
// If type is null, but trialDays or byDate exist, set type accordingly
if (trials.type == null) {
if (trials.trialDays && trials.trialDays > 0) {
@ -148,7 +170,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
{ premium: this.form.value.premium || false },
{ billable: this.form.value.billable || false });
{ billable: this.form.value.billable || false },
{ partner: this.form.value.partner || null });
this.membership
? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) })
@ -186,6 +209,60 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
return DateUtils.dateToTS(date);
}
// Partner Methods
private loadPartners(): void {
this.partnerLoading = true;
this.partnerError = null;
this.partnerSvc.getPartners().subscribe({
next: (partners: Partner[]) => {
// Create dropdown options starting with "None" option for AgNav direct customers
this.partnerOptions = [
{
label: Labels.NONE_AGNAV_DIRECT_CUSTOMER,
value: null // null value indicates AgNav direct customer
},
// Add active partners
...partners
.filter(partner => partner.active) // Only show active partners
.map(partner => ({
label: partner.name,
value: partner
}))
];
// Set selectedPartner based on existing customer partner
if (this.customer?.partner && this.partnerOptions.length > 0) {
// Find the partner in options that matches the customer's current partner _id
const matchingOption = this.partnerOptions.find(option =>
option.value && option.value._id === this.customer.partner._id
);
if (matchingOption) {
this.form.patchValue({ partner: matchingOption.value });
}
} else if (!this.customer?.partner) {
// If no partner is set, default to "None" (AgNav direct customer)
this.form.patchValue({ partner: null });
}
this.partnerLoading = false;
},
error: (error) => {
this.partnerError = Labels.FAILED_TO_LOAD_PARTNERS;
this.partnerLoading = false;
console.error('Error loading partners:', error);
}
});
}
onPartnerChange(selectedPartner: Partner | null): void {
this.partnerError = null;
// Update customer partner field
if (this.customer) {
this.customer.partner = selectedPartner;
}
}
ngOnDestroy() {
super.ngOnDestroy();
}

View File

@ -7,7 +7,7 @@ import { Table } from 'primeng/table';
import { Customer } from '../models/customer.model';
import * as fromCustomers from '../reducers';
import * as customerActions from '../actions/customer.actions';
import { globals } from '@app/shared/global';
import { globals, OperationalStatus } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component';
@ -18,7 +18,7 @@ import { BaseComp } from '@app/shared/base/base.component';
})
export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy {
readonly CREATED = 'createdAt';
readonly ACTIVE = 'active';
readonly ACTIVE = OperationalStatus.ACTIVE;
readonly BILLABLE = 'billable';
readonly PARTNER = 'partner';
readonly PARTNER_NAME = 'partnerName';

View File

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

View File

@ -16,11 +16,16 @@ export interface Customer extends User {
export interface Partner {
_id: string;
name: string;
active: boolean;
description: string;
kind: string; // Required to match User interface
active?: boolean;
createdAt?: string;
updatedAt?: string;
}
export const createNewCustomer = () => {
const customer = <Customer>createNewUser(null, RoleIds.APP);
const customer = createNewUser(null, RoleIds.APP) as Customer;
customer.premium = 0;
customer.membership = {} as IMembership; // Initialize required membership property
return customer;
}

View File

@ -47,7 +47,8 @@ export class TrialComponent extends BaseComp implements OnDestroy, OnInit, After
}
get value() {
return this.form.value;
// CRITICAL: Use getRawValue() to include disabled controls (selected, type, trialDays)
return this.form.getRawValue();
}
set value(val) {
@ -68,20 +69,43 @@ export class TrialComponent extends BaseComp implements OnDestroy, OnInit, After
});
this.dayItems = this.trialDays?.map((day) => ({ label: `${day}`, value: day }));
this.sub$.add(this.form.valueChanges.subscribe((val) => {
this.onChange(val);
this.onTouched(val);
// CRITICAL FIX: Use getRawValue() to include disabled controls in onChange callback
this.sub$.add(this.form.valueChanges.subscribe(() => {
const rawValue = this.form.getRawValue();
this.onChange(rawValue);
this.onTouched(rawValue);
}));
}
ngAfterContentInit() {
const hasExistingTrial = this.trials?.type && (this.trials.trialDays >= MIN_DAYS || this.trials.byDate);
// Check if user has valid trial configuration OR component is disabled (has active trial subscriptions)
const hasExistingTrial = (this.trials?.type && (this.trials.trialDays >= MIN_DAYS || this.trials.byDate))
|| (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate));
if (hasExistingTrial) {
if (this.trials.type === this.BYDATE && this.trials.byDate) {
if (this.trials?.type === this.BYDATE && this.trials.byDate) {
this.toDate = new Date(this.trials.byDate);
this.form.patchValue({ ...this.trials, selected: true });
} else if (this.trials.type === this.DAYS && this.trials.trialDays >= MIN_DAYS) {
} else if (this.trials?.type === this.DAYS && this.trials.trialDays >= MIN_DAYS) {
this.form.patchValue({ ...this.trials, selected: true });
} else if (this.disable && (this.trials?.trialDays >= MIN_DAYS || this.trials?.byDate)) {
// User has active trial subscriptions (disable=true) - always set selected=true
// This prevents accidental trial disable when admin edits customer with active trials
// Determine correct type from trial configuration (byDate takes precedence over trialDays)
const trialType = this.trials?.byDate ? this.BYDATE : this.DAYS;
if (trialType === this.BYDATE) {
this.toDate = new Date(this.trials.byDate);
}
// When controls are disabled, patchValue() ignores them - must use setValue() directly
this.form.get('selected').setValue(true);
this.form.get('type').setValue(trialType);
this.form.get('trialDays').setValue(this.trials.trialDays);
this.form.patchValue({
startDate: this.trials.startDate,
lastEndDate: this.trials.lastEndDate,
lastStartDate: this.trials.lastStartDate,
byDate: this.trials.byDate
});
} else {
this.form.patchValue({ ...this.trials });
}

View File

@ -46,6 +46,12 @@ export class AuthGuard implements CanActivate, CanActivateChild {
this.router.navigate(['/login'], { replaceUrl: true });
return false;
}
// Early exit for partner users - they bypass all subscription checks
if (this.authSvc.isPartner) {
return hasAllowedRoles;
}
const requiresResolution = (): boolean => {
const hasUnresolvedSubs = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.UNPAID) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.subSvc.hasInValTaxLoc(subs);
if (hasUnresolvedSubs && hasAllowedRoles) {

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ export interface Addon extends BasePackage {
desc: string;
lookupKey: string;
trialEnd?: number;
interval?: string; // Billing interval ('year' or 'month')
}
export interface Package extends BasePackage {
@ -41,6 +42,7 @@ export interface Package extends BasePackage {
lookupKey: string;
level?: number;
trialEnd?: number;
interval?: string; // Billing interval ('year' or 'month')
}
export interface Address {
@ -84,7 +86,7 @@ export interface InvoicePackage {
custId: string;
package: string;
addons: BasePackage[];
prorateTS: number;
prorateTS?: number; // Optional: only needed for proration calculations
coupon?: string;
}
@ -106,6 +108,32 @@ export interface Line {
price: {
lookup_key: string;
}
// Issue 4 - Proration credit detection
proration?: boolean; // True for proration credits (unused subscription time)
type?: string; // 'subscription', 'invoiceitem', etc.
}
/**
* Describes a deferred promo that will apply from the next billing period.
* Shape is identical to the inline `promoDetails` object on subscriptions,
* with two additional discriminant flags. Backend builds this from
* subscription.metadata.pending_coupon_id (r975+).
*/
export interface PendingPromoDetails {
isPending: true;
appliesToNextPeriod: true;
name: string;
discountDisplay: string; // 'FREE', '50% OFF', '$9.99 OFF'
percentOff: number | null;
amountOff: number | null; // cents
currency: string | null;
duration: string | null; // 'forever' | 'once' | 'repeating'
durationInMonths: number | null;
expiresAt: null;
discountEndsAt: null;
daysRemaining: null;
daysUntilDiscountEnds: null;
isTimeLimited: false;
}
export interface Invoice {
@ -144,6 +172,18 @@ export interface Invoice {
discount?: {
coupon: Coupon
};
/** Invoice period type: "current" (immediate billing) or "next" (future billing cycle) */
period_type?: string;
/** Flag indicating if this invoice has promotional pricing applied */
has_promo?: boolean;
/** @deprecated r975: field no longer populated by backend. Use pendingPromoDetails instead. */
promo_coupon?: string;
/** Unix timestamp (seconds) — when the next charge is collected. Use * 1000 for a JS Date. (r975+) */
next_billing_date?: number;
/** Present when a deferred 100% FREE promo is scheduled for next billing period (r975+). */
pendingPromoDetails?: PendingPromoDetails;
/** Discount amounts applied on this invoice. Each entry is { amount (cents), discount (Stripe discount ID) }. */
total_discount_amounts?: { amount: number; discount: string }[];
}
export interface Charge {
@ -171,6 +211,7 @@ export interface PaidAmount {
totalTax: number;
total: number;
discount?: Discount;
refundAmount?: number;
}
export interface Discount {
@ -195,6 +236,7 @@ export interface SubscriptionIntent {
coupons?: Coupon[];
mode: Mode;
subIds?: string[];
promoSavings?: number; // Total promo discount in cents (calculated in checkout)
}
export interface SubscriptionPackage {
@ -297,6 +339,12 @@ export interface StripeSubscription {
quantity: number;
price: {
lookup_key: string;
metadata?: {
maxVehicles?: string;
maxAcres?: string;
tier?: string;
level?: string;
};
}
}[];
};
@ -304,8 +352,10 @@ export interface StripeSubscription {
current_period_start: number;
default_payment_method: string;
default_source: string;
metadata: {
metadata?: {
type: string;
scheduleId?: string;
promoId?: string;
};
cancel_at_period_end: boolean;
discount?: {
@ -313,6 +363,82 @@ export interface StripeSubscription {
}
trial_end?: number;
quantity: number;
// ✅ r962+ promoDetails enhancement (includes amountOff/percentOff)
promoDetails?: {
hasPromo: boolean;
name: string;
discountDisplay: string;
expiresAt: string | null;
discountEndsAt: string | null;
daysRemaining: number | null;
daysUntilDiscountEnds: number | null;
isTimeLimited: boolean;
durationInMonths: number | null;
duration: string | null;
percentOff: number | null;
amountOff: number | null;
currency: string | null;
};
}
/**
* Warning notification for subscriptions expiring within 7 days
*
* Data source: GET /api/subscription?custId={custId}
* Verified: 2025-11-10 via /server_test/subscription-data-verification.js
*
* Use case: Display topbar notification when subscription expires in 1-7 days
* Shows: Package name and/or addon names that are expiring with their individual expiry dates
*/
export interface ExpiryWarning {
/** Subscription ID from Stripe */
id: string;
/** Subscription type - 'package', 'addon', or 'both' */
type: 'package' | 'addon' | 'both';
/** Subscription status from Stripe (trialing, active, etc.) */
status: string;
/** Calculated days until earliest subscription expires */
daysUntilExpiry: number;
/** If true, subscription will expire and NOT auto-renew */
cancelAtPeriodEnd: boolean;
/** Unix timestamp when earliest subscription period ends */
periodEnd: number;
/** True if subscription status is 'trialing' */
isTrial: boolean;
/** True if subscription will auto-renew (inverse of cancelAtPeriodEnd) */
willAutoRenew: boolean;
/** Package expiry details if package is expiring */
package?: {
name: string;
lookupKey: string;
daysUntilExpiry: number;
periodEnd: number;
willAutoRenew: boolean;
isTrial: boolean;
isCanceled: boolean;
};
/** Array of addon expiry details if addons are expiring */
addons?: Array<{
name: string;
lookupKey: string;
daysUntilExpiry: number;
periodEnd: number;
willAutoRenew: boolean;
isTrial: boolean;
isCanceled: boolean;
}>;
/** True when sub-account has no active subscriptions */
noSubs?: boolean;
}
export interface AGNavSubscription {
@ -323,6 +449,28 @@ export interface AGNavSubscription {
periodStart: number;
type: string;
cancelAtPeriodEnd: boolean;
trial_end?: number;
promoDetails?: {
hasPromo: boolean;
name: string;
discountDisplay: string;
expiresAt: string | null;
discountEndsAt: string | null;
daysRemaining: number | null;
daysUntilDiscountEnds: number | null;
isTimeLimited: boolean;
durationInMonths: number | null;
duration: string | null;
percentOff: number | null;
amountOff: number | null;
currency: string | null;
};
/**
* Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
* Built from subscription.metadata.pending_coupon_id by addPromoDetailsToSubscription().
* Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
*/
pendingPromoDetails?: PendingPromoDetails;
}
export interface AGNavSubscriptionShort {
@ -333,6 +481,27 @@ export interface AGNavSubscriptionShort {
cancelAtPeriodEnd: boolean;
quantity: number;
paymentMethod: string;
trialEnd?: number;
promoDetails?: {
hasPromo: boolean;
name: string;
discountDisplay: string;
expiresAt: string | null;
discountEndsAt: string | null;
daysRemaining: number | null;
daysUntilDiscountEnds: number | null;
isTimeLimited: boolean;
durationInMonths: number | null;
duration: string | null;
percentOff: number | null;
amountOff: number | null;
currency: string | null;
};
/**
* Present when a deferred 100% FREE promo is scheduled for next billing period (r975+).
* Absent (undefined) when no deferred promo is active or subscription is cancel_at_period_end.
*/
pendingPromoDetails?: PendingPromoDetails;
}
export interface Status {
@ -364,6 +533,7 @@ export interface ConfirmPackage {
subIds: string[];
unresolved: Unresolved;
applicatorId: string;
stage?: string;
}
export interface CreatePaymentMethodPackage {
@ -398,7 +568,7 @@ export interface Aircraft {
export interface Acre {
currUsage: number;
limit: number;
limit: number | null; // null = unlimited acres for current subscription packages
overLimit: boolean;
}

View File

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

View File

@ -21,14 +21,17 @@ export class ProfileResolver implements Resolve<UserWithParentUsername> {
resolve(route: ActivatedRouteSnapshot): Observable<UserWithParentUsername> {
const id = route.paramMap.get('id');
return this.userService.getUser(id).pipe(
// 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).pipe(
return this.userService.getUser(user.parent, { view: 'profile' }).pipe(
map(parentUser => ({ user, parentUsername: parentUser?.username })),
first()
);

View File

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

View File

@ -70,7 +70,12 @@ export class AuthInterceptor implements HttpInterceptor {
}
private onCatch(err: any, req: HttpRequest<any>): Observable<any> {
if ([401, 403].indexOf(err.status) != -1 && !req.url.endsWith('/login')) { // JWT expired or invalid token responded from BE, force logOut
// Don't logout on partner API errors - these are partner credential tests, not user session errors
const isPartnerApiError = req.url.includes('/partners/systemUsers/testAuth')
|| req.url.includes('/partners/aircraft');
if ([401, 403].indexOf(err.status) != -1 && !req.url.endsWith('/login') && !isPartnerApiError) {
// JWT expired or invalid token responded from BE, force logOut
this.store.dispatch(new authActions.Logout(true));
}
return throwError(err);

View File

@ -89,6 +89,10 @@ export class AuthService implements OnDestroy {
return this.hasRole([RoleIds.INSPECTOR]);
}
get isPartner(): boolean {
return this.hasRole([RoleIds.PARTNER]);
}
hasSubsWithStatus(status: string) {
return this.user?.membership?.subscriptions?.some((sub) => sub.status === `${status}`);
}
@ -118,18 +122,16 @@ export class AuthService implements OnDestroy {
}
getCurLookupKey(type: SubType.PACKAGE | SubType.ADDON): PriceUsd {
let lookupKey: PriceUsd;
// Use centralized utility methods
const subscriptions = this.user?.membership?.subscriptions;
switch (type) {
case SubType.PACKAGE:
lookupKey = this.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.PACKAGE)?.items?.[0].price || '';
break;
return this.subSvc.getCurrentPackageLookupKey(subscriptions) || '';
case SubType.ADDON:
lookupKey = this.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.ADDON)?.items?.[0].price || '';
break;
return this.subSvc.getCurrentAddonLookupKey(subscriptions) || '';
default:
throw new Error('Unsupported type');
}
return lookupKey;
}
get isPlanner() {
@ -140,6 +142,10 @@ export class AuthService implements OnDestroy {
return (this.user && this.user.billable);
}
get isCanada(): boolean {
return this.user?.country === 'CA';
}
/**
* Parent user, to mange items under an applicator user
*/
@ -163,7 +169,7 @@ export class AuthService implements OnDestroy {
throwError('invalid_account');
// Store username and jwt token in local storage to keep user logged in between page refreshes
const user = <UserModel>{ _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'], membership: res['membership'], contact: res['contact'] || '' };
const user = <UserModel>{ _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'], membership: res['membership'], contact: res['contact'] || '', country: res['country'] || '' };
this._user = user;
this.token = { t: res['token'], rt: res['rt'] };
@ -298,14 +304,13 @@ export class AuthService implements OnDestroy {
isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(trialEndDate);
}
}
return !this.hasRole([RoleIds.ADMIN])
return this.hasRole([RoleIds.APP])
&& !this.hasSubs()
&& isWithinTrialPeriod;
}
canDisplayTrial(trials: Trial) {
return this.validateTrial(trials)
&& this.subSvc.subMode !== Mode.REGULAR;
return this.validateTrial(trials);
}
canAcceptTrial(url: string) {

View File

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

View File

@ -187,8 +187,24 @@ export class JobService {
return this.http.post<any>(`${this.jobURL}/appFiles`, { jobId: jobId });
}
getFilesData(ids) {
return this.http.post<any>(`${this.jobURL}/filesdata`, { fileIds: ids });
getFilesData(fileId: string, params?: {
limit?: number,
startingAfter?: string,
endingBefore?: string,
returnAll?: boolean
}) {
const body: any = {
fileId: fileId
};
if (params) {
if (params.limit !== undefined) body.limit = params.limit;
if (params.startingAfter !== undefined) body.startingAfter = params.startingAfter;
if (params.endingBefore !== undefined) body.endingBefore = params.endingBefore;
if (params.returnAll !== undefined) body.returnAll = params.returnAll;
}
return this.http.post<any>(`${this.jobURL}/filesdata`, body);
}
}

View File

@ -1,30 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PartnerService {
private readonly apiURL = '/partners';
constructor(private readonly http: HttpClient) { }
getPartners(): Observable<any> {
return this.http.get<any>(this.apiURL);
}
createPartner(partner: any): Observable<any> {
return this.http.post<any>(this.apiURL, partner);
}
getPartnerById(id: string | number): Observable<any> {
return this.http.get<any>(`${this.apiURL}/${id}`);
}
updatePartner(id: string | number, partner: any): Observable<any> {
return this.http.put<any>(`${this.apiURL}/${id}`, partner);
}
deletePartner(id: string | number): Observable<any> {
return this.http.delete<any>(`${this.apiURL}/${id}`);
}
}

View File

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

View File

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

View File

@ -1,11 +1,12 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, Subject, Subscription } from 'rxjs';
import { Price, InvoicePackage, Address, Invoice, SubscriptionPackage, StripeSubscription, PaymentMethod, UnpaidPackage, SubscriptionPaymentMethod, Charge, PaidAmount, AGNavSubscriptionShort, CustChargePkg, Usage, BillPeriod, UsagePackage, CheckoutPayment, Coupon, PMPkgEdit, PriceUsd, Acre, AGNavSubscription, Plan, Status, BillingInfoPackage, Package, Addon, TrialItem } from '@app/domain/models/subscription.model';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable, of, Subject, Subscription, throwError } from 'rxjs';
import { Price, InvoicePackage, Address, Invoice, SubscriptionPackage, StripeSubscription, PaymentMethod, UnpaidPackage, SubscriptionPaymentMethod, Charge, PaidAmount, AGNavSubscriptionShort, CustChargePkg, Usage, BillPeriod, UsagePackage, CheckoutPayment, Coupon, PMPkgEdit, PriceUsd, Acre, AGNavSubscription, Plan, Status, BillingInfoPackage, Package, Addon, TrialItem, ExpiryWarning } from '@app/domain/models/subscription.model';
import { loadStripe, Stripe, StripeCardElement } from '@stripe/stripe-js';
import { DateUtils, UnitUtils, Utils } from '@app/shared/utils';
import { Mode, SUB, SubKeys, SubStripe, SubTexts, SubType, subPlans } from '@app/profile/common';
import { map, switchMap } from 'rxjs/operators';
import { Mode, SUB, SubKeys, SubStripe, SubTexts, SubType, subPlans, UNLIMITED } from '@app/profile/common';
import { map, switchMap, tap, catchError } from 'rxjs/operators';
import { IMembership } from '@app/auth/models/user.model';
import { Store } from '@ngrx/store';
import { getSubIntentMode } from '@app/reducers';
@ -88,6 +89,26 @@ export class SubscriptionService {
return this.http.post<StripeSubscription[]>(`${BASE_URL}/update`, subPkg);
}
/**
* Check subscription status (for polling after 3DS completion per r944)
*
* @param subscriptionId Stripe subscription ID (sub_xxxxx)
* @returns Observable with subscription status from Stripe
*/
checkSubscriptionStatus(subscriptionId: string): Observable<any> {
if (!subscriptionId || !subscriptionId.startsWith('sub_')) {
console.error('❌ Invalid subscription ID:', subscriptionId);
return throwError(new Error('Invalid subscription ID'));
}
return this.http.get(`${BASE_URL}/status/${subscriptionId}`).pipe(
catchError((error) => {
console.error('❌ Status check error:', error);
return throwError(error);
})
);
}
fetchSubscriptions(custId: string): Observable<StripeSubscription[]> {
return this.http.get<StripeSubscription[]>(`${BASE_URL}?custId=${custId}&billInfo=true`);
}
@ -155,11 +176,77 @@ export class SubscriptionService {
editSub(subsSettings: { subId: string, cancelAtPeriodEnd: boolean }[]): Observable<StripeSubscription[]> {
return this.http.post<StripeSubscription[]>(`${BASE_URL}/setSubsSettings`, {
subsSettings
}).pipe(
map(subs => this.normalizeSubscriptionStructure(subs))
);
}
/**
* Normalize simplified backend subscription structure to full Stripe structure
* Backend returns simplified format from _toMembershipSubscription():
* - items: [] (flat array)
* - periodEnd/periodStart instead of current_period_end/current_period_start
* - cancelAtPeriodEnd instead of cancel_at_period_end
* This method transforms it to match the full Stripe API structure expected by frontend
*/
private normalizeSubscriptionStructure(subs: any[]): StripeSubscription[] {
return subs.map(sub => {
// If already in full format, return as-is
if (sub.items?.data) {
return sub;
}
// Transform simplified format to full Stripe structure
return {
id: sub.id,
object: 'subscription',
status: sub.status,
current_period_start: sub.periodStart || sub.current_period_start,
current_period_end: sub.periodEnd || sub.current_period_end,
cancel_at_period_end: sub.cancelAtPeriodEnd !== undefined ? sub.cancelAtPeriodEnd : sub.cancel_at_period_end,
cancel_at: sub.cancelAt || sub.cancel_at,
trial_end: sub.trialEnd || sub.trial_end,
metadata: {
type: sub.type,
scheduleId: sub.scheduleId,
...(sub.metadata || {})
},
items: {
object: 'list',
data: (sub.items || []).map(item => ({
object: 'subscription_item',
price: {
lookup_key: typeof item.price === 'string' ? item.price : item.price?.lookup_key,
metadata: item.metadata || {},
recurring: sub.recurring || { interval: 'month', interval_count: 1 }
},
quantity: item.quantity || 1
}))
},
// Preserve recurring info
plan: sub.recurring ? {
interval: sub.recurring.interval,
interval_count: sub.recurring.intervalCount || sub.recurring.interval_count
} : undefined,
// Fill in optional fields that may not be present
latest_invoice: undefined,
default_payment_method: undefined,
default_source: undefined,
quantity: undefined
} as StripeSubscription;
});
}
getCoupon(coupon: string): Observable<Coupon> {
return this.http.get<Coupon>(`${BASE_URL}/getCoupon/${coupon}`);
getCoupon(coupon: string, priceKeys?: string[]): Observable<Coupon> {
let url = `${BASE_URL}/getCoupon/${coupon}`;
// Add price keys as query params for product restriction validation
if (priceKeys && priceKeys.length > 0) {
const params = new HttpParams().set('priceKeys', priceKeys.join(','));
return this.http.get<Coupon>(url, { params });
}
return this.http.get<Coupon>(url);
}
editPM(custId: string, pkg: PMPkgEdit): Observable<PaymentMethod> {
@ -181,7 +268,167 @@ export class SubscriptionService {
});
}
// Utils
// ============================================================================
// SUBSCRIPTION STATE HELPERS
// ============================================================================
/**
* Determine if subscription will cancel at period end.
* @param sub - StripeSubscription object
* @returns true if subscription will cancel at period end
*/
willSubscriptionCancel(sub: StripeSubscription): boolean {
return sub?.cancel_at_period_end ?? false;
}
/**
* Get the cancellation date for a subscription.
* @param sub - StripeSubscription object
* @returns Date when subscription will cancel, or null if not canceling
*/
getCancellationDate(sub: StripeSubscription): Date | null {
if (sub?.cancel_at_period_end && sub?.current_period_end) {
return new Date(sub.current_period_end * 1000);
}
return null;
}
/**
* Check if subscription has a promo applied.
* Checks for promoId in metadata OR discount coupon presence.
* @param sub - StripeSubscription object
* @returns true if subscription has an active promo
*/
hasSubscriptionPromo(sub: StripeSubscription): boolean {
return !!sub?.metadata?.promoId || !!sub?.discount;
}
/**
* Get promo display info from subscription promoDetails (r955+).
* @param sub - StripeSubscription object
* @returns Promo info or null if no promo
* @since r955 - Updated to use promoDetails instead of deprecated discount field
*/
getSubscriptionPromoDiscount(sub: StripeSubscription): { name: string; percentOff?: number; amountOff?: number } | null {
if (!sub?.promoDetails?.hasPromo) return null;
// Parse discount value from discountDisplay (e.g., "50% OFF" or "FREE")
const discountDisplay = sub.promoDetails.discountDisplay;
const percentMatch = discountDisplay?.match(/(\d+)%/);
const percentOff = percentMatch ? parseInt(percentMatch[1]) : (discountDisplay?.includes('FREE') ? 100 : null);
return {
name: sub.promoDetails.name || 'Promo',
percentOff: percentOff || undefined,
amountOff: undefined // Backend no longer provides amount_off
};
}
/**
* Calculate total promo savings from line items and active promos.
* This is the SINGLE SOURCE OF TRUTH for promo savings calculations.
*
* CALCULATION ORDER (CRITICAL - WI-2804):
* 1. Apply discount at native billing interval (monthly = monthly, annual = annual)
* 2. No annualization - show what customer actually pays
*
* This matches Stripe's actual billing behavior and non-promo display format.
* Uses Stripe lineItems (cents-based) for precision and consistency.
*
* @param lineItems - Stripe invoice line items (payment or refund)
* @param promos - Map of lookup_key to ActivePromo objects
* @returns Total promo savings in cents (at native billing interval)
*
* @example
* // In checkout component:
* const savings = this.subSvc.calculatePromoSavings(
* this.chkoutPmt?.payment?.lineItems,
* this.paymentPromos
* );
*
* // Example: ESS_2 + Addon + 50% promo
* // ESS_2 (annual): $2,495 × 50% = $1,247.50 savings (annual)
* // Addon (monthly): $49.95 × 50% = $24.97 savings (monthly, not annualized)
* // Total savings: $1,272.47 (mixed interval - show separately)
*/
calculatePromoSavings(lineItems: any[], promos: Map<string, any>): number {
if (!lineItems || lineItems.length === 0 || !promos || promos.size === 0) {
return 0;
}
let totalSavings = 0;
lineItems.forEach((item: any) => {
// Skip proration credit lines — these are refunds for old quantities where
// the user already benefited from the promo on a prior invoice.
// Identified by: proration=true AND credited_items is not null.
if (item.proration && item.proration_details?.credited_items != null) {
return;
}
const lookupKey = item.price?.lookup_key;
const promo = promos.get(lookupKey);
if (promo && item.price?.unit_amount) {
// Get original amount at native billing interval
const originalAmount = item.price.unit_amount * (item.quantity || 1);
let savings = 0;
// Calculate savings at native interval (no annualization)
if (promo.discountType === 'free' || promo.discountValue === 100) {
savings = originalAmount; // 100% off
} else if (promo.discountType === 'percent') {
savings = Math.round(originalAmount * (promo.discountValue / 100));
} else if (promo.discountType === 'fixed') {
// discountValue is already in cents (e.g., 15000 = $150.00)
// item.price.unit_amount is also in cents
// Cap discount at original amount to prevent negative prices
savings = Math.min(originalAmount, promo.discountValue);
}
totalSavings += savings;
}
});
return totalSavings;
}
/**
* Calculate discounted amount for a single item with promo applied
* CENTRALIZED METHOD - All components should use this instead of duplicating logic
*
* @param originalAmount - Original price in cents (e.g., 99500 = $995.00)
* @param promo - ActivePromo object
* @returns Discounted amount in cents
*
* @example
* const promo = { discountType: 'fixed', discountValue: 15000 }; // $150 OFF
* const discounted = calculateDiscountedAmount(99500, promo);
* // Returns: 84500 ($845.00)
*/
calculateDiscountedAmount(originalAmount: number, promo: any): number {
if (!promo || !originalAmount) {
return originalAmount;
}
// Calculate savings based on promo type
if (promo.discountType === 'free' || promo.discountValue === 100) {
return 0; // 100% off
} else if (promo.discountType === 'percent') {
return Math.round(originalAmount * (1 - promo.discountValue / 100));
} else if (promo.discountType === 'fixed') {
// discountValue is already in cents (e.g., 15000 = $150.00)
// Cap discount at original amount to prevent negative prices
return Math.max(0, originalAmount - promo.discountValue);
}
return originalAmount;
}
// ============================================================================
// SUBSCRIPTION STATUS UTILS
// ============================================================================
hasSubsWithStatus(subs: StripeSubscription[], status: string): boolean {
return subs?.some((sub) => sub?.status === `${status}`);
}
@ -191,7 +438,16 @@ export class SubscriptionService {
}
isRequireAction(subs: StripeSubscription[]): boolean {
return subs?.some((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION);
// CRITICAL: Backend returns 3DS requirements in multiple possible formats:
// 1. Standard Stripe format: latest_invoice.payment_intent.status === 'requires_action'
// 2. Pre-3DS state: latest_invoice.payment_intent.status === 'requires_confirmation' (needs confirmation which may trigger 3DS)
// 3. Backend's Direct Pattern format: requires_action === true (flat structure with client_secret)
// We must check all three to handle 3DS authentication correctly (r942 implementation)
return subs?.some((sub) =>
sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION ||
sub?.latest_invoice?.payment_intent?.status === 'requires_confirmation' ||
(sub as any)?.requires_action === true
);
}
getReqPmSubscription(subs: StripeSubscription[]): StripeSubscription {
@ -200,7 +456,12 @@ export class SubscriptionService {
getReqActionSubscription(subs: StripeSubscription[]): StripeSubscription {
SubStripe.REQUIRE_ACTION
return subs?.find((sub) => sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION);
// CRITICAL: Check all formats for 3DS requirement (see isRequireAction comment)
return subs?.find((sub) =>
sub?.latest_invoice?.payment_intent?.status === SubStripe.REQUIRE_ACTION ||
sub?.latest_invoice?.payment_intent?.status === 'requires_confirmation' ||
(sub as any)?.requires_action === true
);
}
atCheckoutReviewStage(): boolean {
@ -275,12 +536,16 @@ export class SubscriptionService {
private calcInvoiceWithProrate(invoices: Invoice[], coupon?: Coupon): CheckoutPayment {
let lines = [];
invoices.map((inv) => lines = lines.concat(inv?.lines?.data?.filter((line) => line?.period?.start === inv?.subscription_proration_date)));
const pmtLines = lines.filter((line) => line.amount >= 0);
const refLines = lines.filter((line) => line.amount < 0);
const isRefundLine = (line: any) =>
line?.parent?.subscription_item_details?.proration_details?.credited_items != null ||
line?.proration_details?.credited_items != null;
const rfdLines = lines.filter(isRefundLine);
const pmtLines = lines.filter(line => !isRefundLine(line));
let pmt: CheckoutPayment;
const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(pmtLines));
if (refLines.length > 0) {
const refTotalTax = this.calcTotalAmount(this.extractLineTax(refLines));
if (rfdLines.length > 0) {
const refTotalTax = this.calcTotalAmount(this.extractLineTax(rfdLines));
pmt = {
payment: {
lineItems: pmtLines,
@ -288,8 +553,8 @@ export class SubscriptionService {
totalTax: pmtTotalTax
},
refund: {
lineItems: refLines,
totalAmount: this.calcTotalAmount(refLines) + refTotalTax,
lineItems: rfdLines,
totalAmount: this.calcTotalAmount(rfdLines) + refTotalTax,
totalTax: refTotalTax
}
};
@ -311,18 +576,23 @@ export class SubscriptionService {
calcChkoutPayment(invoices: Invoice[], opt?: Option): CheckoutPayment {
if (Utils.isEmptyArray(invoices)) return { payment: { totalAmount: 0, totalTax: 0, lineItems: [] } };
const prorateInvs = invoices.filter((inv) => inv?.lines?.data?.some((line) => line?.period?.start === inv?.subscription_proration_date));
const hasNoProrate = prorateInvs.length === 0;
const hasUnResolvedInvoice = opt?.subscriptions?.some((sub) =>
sub.status === SubStripe.UNPAID ||
sub.status === SubStripe.INCOMPLETE ||
sub.status === SubStripe.PAST_DUE ||
sub.status === SubStripe.OVERDUE
) || hasNoProrate;
if (hasUnResolvedInvoice) {
const hasUnresolvedSub = opt?.subscriptions?.some((sub) =>
sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE ||
sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE
);
if (hasUnresolvedSub) {
return this.calcInvoice(invoices, opt?.coupon);
}
return this.calcInvoiceWithProrate(invoices, opt?.coupon);
const prorateInvs = invoices.filter((inv) =>
inv?.lines?.data?.some((line) => line?.period?.start === inv?.subscription_proration_date)
);
if (prorateInvs.length > 0) {
return this.calcInvoiceWithProrate(invoices, opt?.coupon);
}
// No proration + all subs active = clean upcoming invoice (e.g., deferred promo's Invoice[1])
return this.calcInvoice(invoices, opt?.coupon);
}
calcAmount(invoices: Invoice[], opt?: Option): PaidAmount {
@ -401,8 +671,44 @@ export class SubscriptionService {
return DEFAULT_CURRENCY;
}
/**
* Convert maxAcres value to user-friendly display string
*
* **Zero Value Policy**: In agricultural context, "0 acres" doesn't make literal sense.
* Zero is used internally to mean "no restriction on acreage."
*
* Display Rules:
* - 0, null, undefined, empty string "Unlimited"
* - Values < 1000 Display as-is (e.g., "123")
* - Values >= 1000 Display in thousands (e.g., "50K" for 50000)
*
* @param maxAcres - Maximum acres value from API (Stripe metadata or custom limits)
* @returns Display string ("Unlimited", "123", or "50K")
*
* @example
* convMaxAcre(0) "Unlimited" // Zero = no restriction
* convMaxAcre(null) "Unlimited" // Not set = unlimited
* convMaxAcre(123) "123" // Small values display as-is
* convMaxAcre(50000) "50K" // Large values in thousands
* convMaxAcre('') "Unlimited" // Empty string = unlimited
*
* Frontend/Backend Coordination:
* - Backend returns literal values from Stripe or custom limits
* - Frontend interprets 0 as "Unlimited" for display
* - This separation allows backend to store raw data while frontend
* provides user-friendly interpretation
*
* Related Policy:
* - maxVehicles: Zero displayed literally ("0 Aircraft" is valid restriction)
* - maxAcres: Zero displayed as "Unlimited" (no literal "0 acres" in farming)
*
* See: Task 02 - Document Zero Handling Policy
*/
convMaxAcre(maxAcres: number | string): string {
if (!maxAcres) return '';
// Display "Unlimited" for null, undefined, empty string, or 0
if (!maxAcres || maxAcres === 0 || maxAcres === '' || maxAcres === '0') {
return UNLIMITED;
}
const THOUSAND = 1000;
const maxAcrToK = +maxAcres / THOUSAND;
return maxAcrToK > 0 ? `${maxAcrToK}K` : maxAcres.toString();
@ -418,24 +724,67 @@ export class SubscriptionService {
);
}
/**
* Infer subscription type ('package' or 'addon') from a Stripe API subscription object.
* Subscriptions created via the app set `metadata.type` explicitly.
* Subscriptions created directly in the Stripe Dashboard have `metadata: {}`, so we
* fall back to inspecting the price lookup_key (addon keys start with 'addon_').
* This is the single source of truth used everywhere StripeSubscription type is needed.
*/
private inferStripeSubType(sub: StripeSubscription): string {
if (sub.metadata?.type) return sub.metadata.type;
const lookupKey = sub.items?.data?.[0]?.price?.lookup_key ?? '';
return lookupKey.startsWith('addon_') ? SubType.ADDON : SubType.PACKAGE;
}
updateMembShip(subscriptions: StripeSubscription[], membership: IMembership): IMembership {
if (Utils.isEmptyArray(subscriptions)) return membership;
return {
...membership,
endOfPeriod: subscriptions?.find((sub) => sub.metadata.type === SubType.PACKAGE)?.latest_invoice.period_end ||
subscriptions?.find((sub) => sub.metadata.type === SubType.ADDON)?.latest_invoice.period_end,
subscriptions: subscriptions?.map((sub) => ({
// NOTE: This method works with StripeSubscription (from Stripe API) which has different field names
// (current_period_end vs periodEnd). The centralized utilities work with AGNavSubscription.
// We keep this logic here as it's specific to Stripe API response transformation.
// Find all package subscriptions and get the latest one by current_period_end
const pkgSubs = subscriptions?.filter((sub) => this.inferStripeSubType(sub) === SubType.PACKAGE);
const latestPkg = pkgSubs?.reduce((acc, curr) => {
return (curr.current_period_end > acc.current_period_end) ? curr : acc;
}, pkgSubs?.[0]);
// Find all addon subscriptions and get the latest one by current_period_end
const addonSubs = subscriptions?.filter((sub) => this.inferStripeSubType(sub) === SubType.ADDON);
const latestAddon = addonSubs?.reduce((acc, curr) => {
return (curr.current_period_end > acc.current_period_end) ? curr : acc;
}, addonSubs?.[0]);
const transformedSubscriptions = subscriptions?.map((sub) => {
const transformed = {
id: sub.id,
periodEnd: sub.current_period_end,
periodStart: sub.current_period_start,
status: sub.status,
items: sub.items.data?.map((item) => ({
price: item.price.lookup_key,
quantity: item.quantity
quantity: item.quantity,
metadata: {
tier: item.price.metadata?.tier || '', // Ensure tier is always defined
level: item.price.metadata?.level,
maxAcres: item.price.metadata?.maxAcres,
maxVehicles: item.price.metadata?.maxVehicles
}
})),
type: sub.metadata.type,
cancelAtPeriodEnd: sub.cancel_at_period_end
}))
type: this.inferStripeSubType(sub),
cancelAtPeriodEnd: sub.cancel_at_period_end,
trial_end: sub.trial_end,
promoDetails: sub.promoDetails
};
return transformed;
});
return {
...membership,
endOfPeriod: latestPkg?.latest_invoice.period_end || latestAddon?.latest_invoice.period_end,
subscriptions: transformedSubscriptions
};
}
@ -446,9 +795,13 @@ export class SubscriptionService {
return { subscriptions, membership, package: {}, addon: {} };
}
const getSubscriptionItem = (type: SubType) =>
membership.subscriptions.find(sub => sub.type === type
&& (sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING))?.items[0];
// Use subscriptions parameter (from Stripe API with custom limits override)
// instead of membership.subscriptions (from MongoDB without override)
const getSubscriptionItem = (type: SubType) => {
const subscription = subscriptions.find(sub => sub.metadata?.type === type
&& (sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING));
return subscription?.items?.data?.[0];
};
const createAcrePlan = (currUsage: number, limit: number): Acre => ({
currUsage,
@ -458,12 +811,28 @@ export class SubscriptionService {
const pkg = getSubscriptionItem(SubType.PACKAGE);
const addon = getSubscriptionItem(SubType.ADDON);
const pkgPrice = pkg?.price;
const pkgPrice = pkg?.price?.lookup_key;
const maxAcres = isNaN(+pkg?.metadata?.maxAcres) ? 0 : +pkg?.metadata?.maxAcres;
const acre = createAcrePlan(UnitUtils.haToArea(usage.ttArea, true), maxAcres || subPlans[pkgPrice]?.maxAcres);
const maxVehicles = isNaN(+pkg?.metadata?.maxVehicles) ? 0 : +pkg?.metadata?.maxVehicles;
const pkgNumVeh = maxVehicles || subPlans[pkgPrice]?.maxVehicles || 0;
// ✅ FIX (2026-01-27): Read metadata from MongoDB session data instead of Stripe API
// MongoDB is source of truth for subscription metadata changes (updated by admin/backend)
// Stripe API caches metadata and doesn't sync with MongoDB direct updates
// Priority: MongoDB membership.subscriptions > Stripe API subscriptions > hardcoded fallback
const mongoSubscription = membership?.subscriptions?.find(sub =>
sub.type === SubType.PACKAGE &&
(sub.status === 'active' || sub.status === 'trialing')
);
const mongoMetadata = mongoSubscription?.items?.[0]?.metadata;
// ✅ FIX (2026-01-27): Use getEffectiveAcresLimit() for consistent empty string handling
// Empty string "" in metadata was converting to 0, triggering fallback to hardcoded 50000
// getEffectiveAcresLimit() properly handles: "" → null, null → null, "0" → null (all = Unlimited)
const effectiveMaxAcres = this.getEffectiveAcresLimit(mongoSubscription, membership?.customLimits);
const acre = createAcrePlan(UnitUtils.haToArea(usage.ttArea, true), effectiveMaxAcres);
// ✅ FIX (2026-01-28): Use getEffectiveVehicleLimit() for consistent customLimits handling
// Same pattern as maxAcres fix (2026-01-27) - ensures customLimits override metadata
const effectiveMaxVehicles = this.getEffectiveVehicleLimit(mongoSubscription, membership?.customLimits);
const pkgNumVeh = effectiveMaxVehicles || 0;
const trackNumVeh = addon?.quantity || 0;
const packagePlan = pkg ? {
@ -497,14 +866,26 @@ export class SubscriptionService {
fmtSubMsg(text: string, key: PriceUsd, vehicle: { trkQuantity?: number, pkgQuantity?: number }): string {
return text?.replace('#pkg#', subPlans[key].name)
.replace('#quantity#', `${vehicle.trkQuantity}` || '')
.replace('#maxAC#', `${vehicle.pkgQuantity}` || '') || '';
.replace('#quantity#', `${vehicle.trkQuantity ?? ''}`)
.replace('#maxAC#', `${vehicle.pkgQuantity ?? ''}`) || '';
}
toVehRange(precedingMax: number, maxVehicles: number): string {
const MIN = 1;
const MAX = 10;
const lowerRange = precedingMax + MIN;
const lowerRange = precedingMax ? (precedingMax + MIN) : MIN;
// For custom limits (precedingMax === 0), show range from 1 to custom limit
if (precedingMax === 0 && maxVehicles > MIN) {
return `${MIN}-${maxVehicles}`;
}
// If range collapses to single value (e.g., "2-2"), show just the number
if (lowerRange === maxVehicles) {
return `${maxVehicles}`;
}
// Normal tier-based range calculation
return maxVehicles > MIN && maxVehicles <= MAX
? lowerRange ? `${lowerRange}-${maxVehicles}`
: `${maxVehicles}`
@ -537,7 +918,8 @@ export class SubscriptionService {
}
});
} else {
return this.userSvc.getUser(applicatorId).pipe(
// Fallback to user info if no billing address exists - this is based on the assumption that if there's no billing address in addresses, use the default legacy address.
return this.userSvc.getUser(applicatorId, { view: 'billing' }).pipe(
map((user) => {
return billingInfoPackage = {
isNewAccount: true,
@ -552,7 +934,7 @@ export class SubscriptionService {
postal_code: ''
}
}
}
};
})
);
}
@ -565,6 +947,7 @@ export class SubscriptionService {
description: addon.desc,
amount: +addon.price * addon.quantity,
quantity: addon.quantity,
trialEnd: addon.trialEnd, // Populate trialEnd from addon (for extended trial display)
price: {
lookup_key: addon.lookupKey,
unit_amount: +addon.price
@ -576,6 +959,7 @@ export class SubscriptionService {
description: selPkg.desc,
amount: +selPkg.price,
quantity: 1,
trialEnd: selPkg.trialEnd, // Populate trialEnd from package (for extended trial display)
price: {
lookup_key: selPkg.lookupKey,
unit_amount: +selPkg.price
@ -645,6 +1029,200 @@ export class SubscriptionService {
);
}
// ============================================================================
// SUBSCRIPTION UTILITY METHODS - SINGLE SOURCE OF TRUTH
// ============================================================================
/**
* Find latest subscription by periodEnd for given type
* This is the canonical utility for non-store contexts (services, standalone functions)
*
* @param subscriptions - Array of AGNav subscriptions
* @param type - Subscription type (PACKAGE or ADDON)
* @returns Latest subscription or null if none found
*
* @example
* const latest = this.subSvc.getLatestSubscription(user.membership.subscriptions, SubType.PACKAGE);
*/
getLatestSubscription(
subscriptions: AGNavSubscription[],
type: SubType
): AGNavSubscription | null {
if (!subscriptions || subscriptions.length === 0) return null;
const filtered = subscriptions.filter(sub => sub.type === type);
if (filtered.length === 0) return null;
return filtered.reduce((acc, curr) =>
(curr.periodEnd > acc.periodEnd) ? curr : acc, filtered[0]
);
}
/**
* Get lookup key from latest package subscription
* Replaces duplicated logic across auth.service, effects, components
*
* @param subscriptions - Array of AGNav subscriptions
* @returns Lookup key (price ID) or null
*
* @example
* const lookupKey = this.subSvc.getCurrentPackageLookupKey(user.membership.subscriptions);
*/
getCurrentPackageLookupKey(subscriptions: AGNavSubscription[]): PriceUsd | null {
const latest = this.getLatestSubscription(subscriptions, SubType.PACKAGE);
return latest?.items?.[0]?.price || null;
}
/**
* Get lookup key from latest addon subscription
*
* @param subscriptions - Array of AGNav subscriptions
* @returns Addon lookup key or null
*/
getCurrentAddonLookupKey(subscriptions: AGNavSubscription[]): PriceUsd | null {
const latest = this.getLatestSubscription(subscriptions, SubType.ADDON);
return latest?.items?.[0]?.price || null;
}
/**
* Get effective vehicle limit (custom limits override plan limits)
* This is the authoritative calculation for max vehicles
*
* @param subscription - Current subscription
* @param customLimits - User custom limits
* @returns Effective max vehicles or null
*
* @example
* const maxVehicles = this.subSvc.getEffectiveVehicleLimit(latestSub, user.membership.customLimits);
*/
getEffectiveVehicleLimit(
subscription: AGNavSubscription,
customLimits?: { maxVehicles?: number; maxAcres?: number }
): number | null {
if (!subscription) return null;
const planMaxVehicles = Math.abs(Number(subscription.items?.[0]?.metadata?.maxVehicles));
const customMax = customLimits?.maxVehicles ? Math.abs(customLimits.maxVehicles) : null;
// Custom limits override plan limits
return customMax || planMaxVehicles || null;
}
/**
* Get effective acres limit (custom limits override plan limits)
*
* @param subscription - Current subscription
* @param customLimits - User custom limits
* @returns Effective max acres or null
*/
getEffectiveAcresLimit(
subscription: AGNavSubscription,
customLimits?: { maxVehicles?: number; maxAcres?: number }
): number | null {
if (!subscription) return null;
const planMaxAcres = Number(subscription.items?.[0]?.metadata?.maxAcres);
// Custom limits override plan limits
// Treat empty string as null for proper "Unlimited" display
const customLimit = customLimits?.maxAcres;
const effectiveCustomLimit = (customLimit !== null && customLimit !== undefined && customLimit !== 0)
? customLimit
: null;
const effectivePlanLimit = (planMaxAcres !== null && planMaxAcres !== undefined && planMaxAcres !== 0 && !isNaN(planMaxAcres))
? planMaxAcres
: null;
return effectiveCustomLimit || effectivePlanLimit || null;
}
/**
* Check if subscription has custom limits applied
* Custom limits are considered "applied" when they differ from plan defaults
*
* @param subscription - Current subscription
* @param customLimits - User custom limits
* @returns True if custom limits differ from plan limits
*
* @example
* const hasCustom = this.subSvc.hasCustomLimits(latestSub, user.membership.customLimits);
*/
hasCustomLimits(
subscription: AGNavSubscription,
customLimits?: { maxVehicles?: number; maxAcres?: number }
): boolean {
if (!subscription || !customLimits) return false;
const planMaxVehicles = Number(subscription.items?.[0]?.metadata?.maxVehicles);
const planMaxAcres = Number(subscription.items?.[0]?.metadata?.maxAcres);
// Check if either vehicle or acres custom limits differ from plan
const vehicleLimitsDiffer = customLimits.maxVehicles && customLimits.maxVehicles !== planMaxVehicles;
const acresLimitsDiffer = customLimits.maxAcres && customLimits.maxAcres !== planMaxAcres;
return vehicleLimitsDiffer || acresLimitsDiffer;
}
// ============================================================================
// SUBSCRIPTION EXPIRY WARNING
// ============================================================================
/**
* Calculate expiry warning from subscription data
*
* Returns warning if subscription expires in 1-7 days, null otherwise.
* Only triggers for package subscriptions (not addons).
*
* Data Source: GET /api/subscription?custId={custId}
* Verified: 2025-11-10 via /server_test/subscription-data-verification.js
*
* @param subscription - StripeSubscription from /api/subscription endpoint
* @returns ExpiryWarning if criteria met, null otherwise
*
* @example
* const subscriptions = await this.getSubscriptions(custId).toPromise();
* const warnings = subscriptions
* .map(sub => this.calculateExpiryWarning(sub))
* .filter(w => w !== null);
*/
calculateExpiryWarning(subscription: StripeSubscription): ExpiryWarning | null {
// Validate required fields (based on Phase 1 verification)
if (!subscription?.current_period_end || !subscription?.metadata?.type) {
console.warn('calculateExpiryWarning: Missing required fields', {
id: subscription?.id,
has_period_end: !!subscription?.current_period_end,
has_metadata_type: !!subscription?.metadata?.type
});
return null;
}
const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
const secondsUntilExpiry = subscription.current_period_end - now;
const daysUntilExpiry = Math.floor(secondsUntilExpiry / 86400);
// Only warn for subscriptions expiring in 0-expiryWarningDays days (inclusive of today)
if (daysUntilExpiry < 0 || daysUntilExpiry > environment.expiryWarningDays) {
return null;
}
// Only show warnings for package subscriptions (not addons)
if (subscription.metadata?.type !== 'package') {
return null;
}
return {
id: subscription.id,
type: subscription.metadata?.type as 'package' | 'addon',
status: subscription.status,
daysUntilExpiry,
cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false,
periodEnd: subscription.current_period_end,
isTrial: subscription.status === 'trialing',
willAutoRenew: !subscription.cancel_at_period_end
};
}
ngOnDestroy(): void {
if (this.sub$) this.sub$.unsubscribe();
}

View File

@ -20,10 +20,17 @@ export class UserService {
return this.http.post<User[]>(this.userURL + '/search', options);
}
getUser(id: string, ops?: { withAddresses?: boolean }): Observable<User> {
getUser(id: string, ops?: { withAddresses?: boolean; view?: 'profile' | 'edit' | 'billing' }): Observable<User> {
let url = `${this.userURL}/${id}`;
if (ops && ops.withAddresses !== undefined) {
url += `?withAddresses=${ops.withAddresses}`;
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);
}

View File

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

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { from, interval, Observable, of } from 'rxjs';
import { map, switchMap, catchError, take, takeUntil, tap, startWith, debounceTime, concatMap, repeat } from 'rxjs/operators';
import { from, interval, Observable, of, forkJoin, throwError } from 'rxjs';
import { map, switchMap, catchError, take, takeUntil, tap, startWith, debounceTime, concatMap, repeat, takeWhile } from 'rxjs/operators';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Action, Store } from '@ngrx/store';
import * as subAction from '@app/actions/subscription.actions';
@ -8,12 +8,11 @@ import { SubscriptionService } from '@app/domain/services/subscription.service';
import { Addon, Address, ConfirmPackage, Invoice, InvoicePackage, PaymentMethod, StripeSubscription, SubscriptionIntent, Card, SubscriptionPackage, Coupon, BillingInfo, TrialPmtPkg, BillingInfoPackage } from '@app/domain/models/subscription.model';
import { PaymentIntentResult, PaymentMethodResult } from '@stripe/stripe-js';
import { UserModel } from '@app/auth/models/user.model';
import { createSubStatus, handleErr, SubAppErr, SUB, SubStripe, Mode, SubKeys, SUB_NAME, SERVICE_TYPE } from '@app/profile/common';
import { createSubStatus, handleErr, SubAppErr, SUB, SubStripe, Mode, SERVICE_TYPE, PromoErrors } from '@app/profile/common';
import { DateUtils, Utils } from '@app/shared/utils'
import { AuthService } from '@app/domain/services/auth.service';
import { ResetSubPlans } from '@app/actions/sub-plans.actions';
import { CustomerService } from '@app/domain/services/customer.service';
import { environment } from '@environments/environment';
import { Customer } from '@app/customers/models/customer.model';
import { GAService } from '@app/shared/ga.service';
import { GAAnalyticsHelpersService } from '@app/shared/ga.analytics-helpers.service';
@ -54,7 +53,38 @@ export class SubscriptionEffects {
fetchLatestSub$: Observable<Action> = this.actions$.pipe(
ofType<subAction.FetchLatestSubscription>(subAction.FETCH_LATEST_SUBSCRIPTION),
switchMap((action: subAction.FetchLatestSubscription) => this.subSvc.fetchSubscriptions(action.payload.custId)),
map((subscriptions) => new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) })),
map((subscriptions) => {
// Debug: Log ALL raw backend response data
console.log('🔍 fetchLatestSub$ - EFFECT TRIGGERED:', {
hasSubscriptions: !!subscriptions,
count: subscriptions?.length || 0,
allStatuses: subscriptions?.map(sub => ({ id: sub.id, status: sub.status })) || [],
firstSubFull: subscriptions?.[0] || null
});
// Debug: Log raw backend response for trial subscriptions
const trialSubs = subscriptions?.filter(sub => sub.status === 'trialing');
console.log('🔍 fetchLatestSub$ - Trial filter result:', {
trialCount: trialSubs?.length || 0,
hasTrialSubs: trialSubs && trialSubs.length > 0
});
if (trialSubs && trialSubs.length > 0) {
console.log('🔍 fetchLatestSub$ - RAW Backend API Response (trial subscriptions):', {
count: trialSubs.length,
firstSub: {
id: trialSubs[0].id,
status: trialSubs[0].status,
trial_end: trialSubs[0].trial_end,
promoDetails: trialSubs[0].promoDetails,
has_trial_end_key: 'trial_end' in trialSubs[0],
has_promoDetails_key: 'promoDetails' in trialSubs[0]
}
});
}
return new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) });
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })),
repeat()
);
@ -285,7 +315,24 @@ export class SubscriptionEffects {
// Track successful trial checkout
this.trackSubscriptionPurchase(subs, { payload: updatePkgPayload });
return of(new subAction.CheckoutTrialSuccess({ subs }), new subAction.UpdateTrial(cust.membership.trials))
// Fetch card data from customer's default payment method
return this.subSvc.getPaymentMethodList(cust.membership.custId).pipe(
map((paymentMethods: PaymentMethod[]) => {
const defaultPM = paymentMethods?.find(pm => pm.id === subs?.[0]?.default_payment_method);
const card: Card | undefined = defaultPM ? {
pmId: defaultPM.id,
brand: defaultPM.card?.brand,
country: defaultPM.card?.country,
exp_month: defaultPM.card?.exp_month,
exp_year: defaultPM.card?.exp_year,
last4: defaultPM.card?.last4,
defaultPM: true
} : undefined;
return [new subAction.CheckoutTrialSuccess({ subs, card }), new subAction.UpdateTrial(cust.membership.trials)];
}),
switchMap((actions) => of(...actions))
);
})
);
@ -356,7 +403,10 @@ export class SubscriptionEffects {
let invoicePkg: InvoicePackage = { custId: action.payload.subIntentPkg.custId, package: action.payload.subIntentPkg.selPkg?.lookupKey, addons: action.payload.subIntentPkg.selAddons?.map((addon: Addon) => ({ price: addon?.lookupKey, quantity: addon?.quantity })), prorateTS: action.payload.subIntentPkg.prorateTS };
if (action.payload.coupon) {
return this.subSvc.getCoupon(action.payload.coupon).pipe(
// Extract price keys for product restriction validation
const priceKeys = this._collectPriceKeys(action.payload.subIntentPkg);
return this.subSvc.getCoupon(action.payload.coupon, priceKeys).pipe(
switchMap((coupon: Coupon) => {
if (!coupon.valid) {
return handleErr<Observable<Action>>({ error: '', opt: { extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR } });
@ -372,10 +422,72 @@ export class SubscriptionEffects {
map((res) => new subAction.ApplyDiscountPreviewSuccess({ amount: this.subSvc.calcAmount(res), coupons: [] }))
);
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR, msg: err?.error?.raw?.message } })),
catchError((err) => {
// Handle promo_invalid_coupon error specifically
// Error structure: err.error.error[".tag"] and err.error.error.message
const errorTag = err?.error?.error?.[".tag"];
const errorMessage = err?.error?.error?.message;
if (errorTag === 'promo_invalid_coupon') {
// Match specific error message to user-friendly label from PromoErrors
let displayMessage = PromoErrors.PROMO_INVALID_COUPON; // Default
if (errorMessage?.includes('first-time customers')) {
displayMessage = PromoErrors.PROMO_FIRST_TIME_ONLY;
} else if (errorMessage?.includes('not available for this customer')) {
displayMessage = PromoErrors.PROMO_RESTRICTED_CUSTOMER;
} else if (errorMessage?.includes('not applicable to the selected products') || errorMessage?.includes('restricted to specific products')) {
displayMessage = PromoErrors.PROMO_RESTRICTED_PRODUCT;
} else if (errorMessage?.includes('expired')) {
displayMessage = PromoErrors.PROMO_EXPIRED;
} else if (errorMessage?.includes('maximum redemption') || errorMessage?.includes('reached max')) {
displayMessage = PromoErrors.PROMO_MAX_REDEMPTIONS;
}
return handleErr<Observable<Action>>({
error: err,
opt: {
extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR,
msg: displayMessage
}
});
}
// Fallback to generic error handling (for other error types)
return handleErr<Observable<Action>>({
error: err,
opt: {
extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR,
msg: err?.error?.message || err?.error?.raw?.message // Try new format first, fallback to old
}
});
}),
repeat()
);
/**
* Collect price keys from subscription intent package for product validation
* @param subIntentPkg Subscription intent package containing selected package and addons
* @returns Array of price lookup keys (e.g., ['ess_3', 'addon_1'])
*/
private _collectPriceKeys(subIntentPkg: any): string[] {
const keys: string[] = [];
// Add selected package
if (subIntentPkg?.selPkg?.lookupKey) {
keys.push(subIntentPkg.selPkg.lookupKey);
}
// Add selected addons
subIntentPkg?.selAddons?.forEach(addon => {
if (addon?.lookupKey) {
keys.push(addon.lookupKey);
}
});
return keys;
}
// Checkout-review stage
private finalizeConfirm({ action, results, confirmPkg }) {
return switchMap((subscriptions: StripeSubscription[]) => {
@ -391,6 +503,26 @@ export class SubscriptionEffects {
const lastPaymentErr: any = error?.payment_intent?.last_payment_error;
const card: Card = lastPaymentErr?.payment_method?.card || lastPaymentErr?.source;
// Check for card decline at checkout-review stage
const isCardDeclineError = error?.code === 'card_declined' || error?.decline_code === 'generic_decline';
const isCheckoutReviewStage = confirmPkg?.stage === SUB.CHKOUT_REV;
if (isCardDeclineError && isCheckoutReviewStage) {
// Stay at checkout-review with error message - no navigation
return of(
new subAction.UpdateIncomplete({
invoices: action.payload.unresolved?.invoices,
requiresAction: false,
requiresPM: true,
numOfRetries: ++action.payload.unresolved.numOfRetries,
subscriptions
}),
new subAction.UpdateSubscriptionStatus(
createSubStatus(SubStripe.CARD_DECLINED, { card })
)
);
}
if (isPastdueType) {
return of(
new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card })),
@ -441,20 +573,68 @@ export class SubscriptionEffects {
return of(action.payload).pipe(
switchMap((_confirmPkg: ConfirmPackage) => {
confirmPkg = _confirmPkg;
const confirmations$ = confirmPkg?.stripePkgs?.map((pkg) => Utils.demethodize(this.subSvc.stripe.confirmCardPayment)(pkg?.clientSecret, { payment_method: pkg?.pmId }));
const confirmations$ = confirmPkg?.stripePkgs?.map((pkg) => {
return Utils.demethodize(this.subSvc.stripe.confirmCardPayment)(pkg?.clientSecret, { payment_method: pkg?.pmId });
});
const promiseChain = Utils.createPromiseChain<PaymentIntentResult>(confirmations$)
return from(promiseChain);
}),
switchMap((results: PaymentIntentResult[]) => {
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
this.finalizeConfirm({ action, results, confirmPkg })
// Check for errors in 3DS confirmation
const hasErrors = results?.some((result) => !!result?.error);
if (hasErrors) {
// If there are errors, proceed without polling
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
this.finalizeConfirm({ action, results, confirmPkg })
);
}
// ============================================================
// NEW: 3DS SUCCESS → START POLLING (r944 requirement)
// ============================================================
// PaymentIntent is 'succeeded' but subscription still 'incomplete'
// Must wait 1-3 seconds for Stripe to charge and activate
const subscriptionIds = action.payload.subIds;
if (!subscriptionIds || subscriptionIds.length === 0) {
console.error('❌ No subscription IDs provided for polling');
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
this.finalizeConfirm({ action, results, confirmPkg })
);
}
// Poll EACH subscription until active
const pollingObservables = subscriptionIds.map((subId) =>
this.pollSubscriptionStatus(subId, 10, 500)
);
return forkJoin(pollingObservables).pipe(
switchMap((polledSubscriptions) => {
// All subscriptions activated successfully
// Now fetch full subscription data and finalize
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
this.finalizeConfirm({ action, results, confirmPkg })
);
}),
catchError((pollingError) => {
console.error('❌ Polling failed:', pollingError);
// Even if polling fails, try to fetch subscriptions and proceed
// The subscription might have activated despite polling timeout
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
this.finalizeConfirm({ action, results, confirmPkg })
);
})
);
})
);
})
);
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.CONF_ERR } })),
catchError((err) => {
console.error('🔴 CONFIRM EFFECT ERROR', err);
return handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.CONF_ERR } });
}),
repeat()
);
@ -476,7 +656,33 @@ export class SubscriptionEffects {
if (hasIncompleteSub) {
const req3dsVerf = this.subSvc.isRequireAction(subscriptions);
const reqPm = this.subSvc.isRequirePaymentMethod(subscriptions);
let latestInvoices = subscriptions?.map((sub) => sub?.latest_invoice);
// CRITICAL: Transform backend's flat 3DS response structure into expected nested format
// Backend (r942 Direct Pattern) returns: { requires_action: true, client_secret: 'pi_xxx', payment_intent_id: 'pi_xxx' }
// Frontend expects: { latest_invoice: { payment_intent: { status: 'requires_action', client_secret: 'pi_xxx' } } }
const transformedSubscriptions = subscriptions?.map(sub => {
// If backend returned flat structure with client_secret at top level, transform it
if ((sub as any)?.requires_action && (sub as any)?.client_secret && !sub?.latest_invoice?.payment_intent?.client_secret) {
return {
...sub,
latest_invoice: {
...(sub.latest_invoice || {} as any),
id: sub.latest_invoice?.id || (sub as any).payment_intent_id || `inv_temp_${Date.now()}`,
status: 'open',
subscription: sub.id,
payment_intent: {
...(sub.latest_invoice?.payment_intent || {} as any),
id: (sub as any).payment_intent_id,
status: 'requires_action',
client_secret: (sub as any).client_secret
} as any
} as any
};
}
return sub;
});
let latestInvoices = transformedSubscriptions?.map((sub) => sub?.latest_invoice) as any[];
const hasLatestInvoices = latestInvoices?.length > 0;
if (hasLatestInvoices) {
if (req3dsVerf) {
@ -487,6 +693,13 @@ export class SubscriptionEffects {
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }),
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
);
} else {
// Not at checkout review stage - return incomplete status without navigation
return of(
new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_ACTION, { card })),
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }),
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
);
}
} else if (reqPm) {
const atChkoutRevStage = action.payload.stage === SUB.CHKOUT_REV;
@ -496,7 +709,19 @@ export class SubscriptionEffects {
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: false, requiresPM: true, numOfRetries: 0, subscriptions }),
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
);
} else {
// Not at checkout review stage - return incomplete status without navigation
return of(
new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQUIRE_PAYMENT_METHOD, { card })),
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: false, requiresPM: true, numOfRetries: 0, subscriptions }),
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
);
}
} else {
// Incomplete but neither requires action nor payment method - return success
return of(
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
);
}
} else {
return handleErr<Observable<Action>>({ opt: { extra: SubAppErr.NO_INVOICES_ERR } });
@ -508,7 +733,7 @@ export class SubscriptionEffects {
return of(new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage)), new subAction.GotoCheckoutConfirm());
}
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { card } }))
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { card, extra: SubAppErr.UPDATE_SUB_ERR } }))
);
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.UPDATE_SUB_ERR } })),
@ -690,7 +915,6 @@ export class SubscriptionEffects {
switchMap((action: subAction.InitSubscription) => {
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
switchMap((subscriptions: StripeSubscription[]) => {
const hasNoSubs = subscriptions?.length === 0;
const hasUnpaidSubs = subscriptions?.some((sub) => sub?.status === SubStripe.UNPAID);
const hasPastDueSubs = subscriptions?.some((sub) => sub?.status === SubStripe.PAST_DUE) || subscriptions?.some((sub) => sub?.status === SubStripe.OVERDUE);
@ -722,6 +946,9 @@ export class SubscriptionEffects {
}
if (hasNoSubs) {
// Only reset the subscription-page state. Auth membership is NOT touched here
// auth.reducer RESET_SUBSCRIPTION returns state unchanged so the expiry-warning
// banner remains visible when Stripe returns empty (e.g. transient API errors).
return of(
new ResetSubPlans(),
new subAction.ResetSubscription());
@ -737,7 +964,8 @@ export class SubscriptionEffects {
new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQ_LOC_INPUT))
);
}
return of(new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.authSvc.user?.membership }));
return of(new subAction.FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.authSvc.user?.membership) }));
}))
}),
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })),
@ -956,13 +1184,8 @@ export class SubscriptionEffects {
const subscriptionPrice = packageInfo?.amount ? packageInfo.amount / 100 : 0;
const interval = packageInfo?.interval || 'month';
// Track addon purchases as placeholder (log for now)
// Track addon purchases as placeholder
if (serviceType === SERVICE_TYPE.ADDON) {
console.log('Addon purchase detected:', {
addon_type: subscriptionType,
addon_price: subscriptionPrice,
user_id: user._id
});
// TODO: Implement addon tracking event when requirements are defined
return;
}
@ -994,4 +1217,101 @@ export class SubscriptionEffects {
console.warn('Failed to track subscription purchase:', error);
}
}
/**
* Poll subscription status until it becomes 'active' or timeout (r944 requirement)
*
* After 3DS completion:
* - PaymentIntent status becomes 'succeeded' immediately
* - Subscription status stays 'incomplete' for 1-3 seconds
* - Stripe charges card in background
* - This method waits for subscription to become 'active'
*
* @param subscriptionId Stripe subscription ID (sub_xxxxx)
* @param maxAttempts Maximum polling attempts (default 10 = 5 seconds)
* @param intervalMs Delay between attempts in milliseconds (default 500ms)
* @returns Observable with final subscription status or error
*/
private pollSubscriptionStatus(
subscriptionId: string,
maxAttempts: number = 10,
intervalMs: number = 500
): Observable<any> {
let attempts = 0;
return interval(intervalMs).pipe(
startWith(0), // Start immediately (no initial delay)
switchMap(() => {
attempts++;
return this.subSvc.checkSubscriptionStatus(subscriptionId).pipe(
map(response => ({
subscription: response,
attempts,
error: null
})),
catchError(err => {
console.error(`❌ Polling error on attempt ${attempts}:`, err);
return of({
subscription: null,
attempts,
error: err
});
})
);
}),
// Evaluation logic - when to stop polling
tap(({ subscription, attempts, error }: any) => {
if (error) {
console.error(`❌ Status check failed:`, error);
}
}),
// Stop conditions
takeWhile(({ subscription, attempts, error }: any) => {
// Stop if subscription is active (SUCCESS)
if (subscription?.status === 'active') {
return false;
}
// Stop if subscription failed or canceled
if (subscription?.status === 'incomplete_expired' ||
subscription?.status === 'canceled') {
console.error(`❌ Subscription ${subscription.status} during polling`);
throw new Error(`Subscription ${subscription.status} - cannot proceed`);
}
// Stop if max attempts reached (TIMEOUT)
if (attempts >= maxAttempts) {
console.error(`❌ Polling timeout after ${attempts} attempts (${attempts * intervalMs}ms)`);
throw new Error(
`Subscription did not activate within ${maxAttempts * intervalMs / 1000} seconds. ` +
`Please check your subscription status in Stripe Dashboard.`
);
}
// Stop if API error occurred
if (error) {
throw new Error(`Status check failed: ${error.message || 'Unknown error'}`);
}
// Continue polling for incomplete or past_due
return true;
}, true), // inclusive: true - emit the final value before completing
// Extract subscription from result
map(({ subscription }) => subscription),
// Take only the first successful result (when active) or error
take(1),
// Error handling
catchError(err => {
console.error(`❌ Polling failed:`, err);
return throwError(err);
})
);
}
}

View File

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

View File

@ -1,8 +1,9 @@
import { createNewUser, User } from '@app/accounts/models/user.model';
import { RoleIds } from '@app/shared/global';
import { RoleIds, SourceSystemType, OperationalStatusType, SystemOrPartnerType } from '@app/shared/global';
export interface Vehicle extends User {
vehicleType: number;
tailNumber?: string; // Common tail number field for all aircraft
unitId?: string;
orgUnitId?: string; // used for unique validation at clientSide only
model?: string;
@ -12,17 +13,82 @@ export interface Vehicle extends User {
trackonDate?: Date;
pkgActive?: boolean;
pkgActiveDate?: Date;
// Partner integration properties (legacy - for frontend compatibility)
partnerSystem?: SourceSystemType; // System identifier
partnerAircraftId?: string; // Partner system's aircraft ID
partnerAircraftData?: PartnerAircraftData;
// Backend-compatible partner info structure (matches backend schema)
partnerInfo?: {
partner?: string; // Partner ObjectId reference
partnerAircraftId?: string; // Partner aircraft/vehicle ID in external system
systemType?: string; // System type for agnav native systems (platinum, titanium, g4, etc.)
// NEW: Direct partner identification fields (from assignments_post response)
name?: string; // Partner display name (e.g., "satloc")
partnerCode?: string; // Partner code identifier (e.g., "SATLOC") - top-level for assignments_post
metadata?: {
partnerSystem?: string; // Partner system name
partnerCode?: string; // Partner code identifier (legacy nested location)
aircraftData?: any; // Aircraft data from partner system
syncStatus?: OperationalStatusType;
lastSync?: string | null; // ISO date string
connectionStatus?: OperationalStatusType;
};
};
}
// Aircraft Assignment Item interface for job assignment pickList
export interface AircraftAssignmentItem {
_id: string;
name: string;
username?: string; // Aircraft account username for AgNav aircraft
active: boolean;
pkgActive?: boolean;
tailNumber?: string;
// Partner system information derived from partnerInfo
partnerSystem?: SystemOrPartnerType;
sourceSystem?: SystemOrPartnerType; // System identifier for UI display and sorting
// Partner information
partnerId?: string; // Partner ID from partnerInfo.partner
partnerName?: string; // Partner name resolved from partner service
// Partner authentication validation state
authValidation?: {
isValidating: boolean;
authenticationValid: boolean;
accountExists: boolean;
validationError: string | null;
canMoveToTarget: boolean;
};
partnerCode?: string; // Partner code for display
satlocData?: {
satlocId?: string;
tailNumber: string;
aircraftType?: string;
lastSync?: Date;
syncStatus: OperationalStatusType;
};
}
export interface PartnerAircraftData {
id: string;
tailNumber: string;
partnerSystem: string;
syncStatus?: OperationalStatusType;
lastSync?: Date;
connectionStatus?: OperationalStatusType;
}
export interface StatusChange {
ids: { [i: string]: string[] };
type: string;
deActivate?: {[i: string]: boolean};
deActivate?: { [i: string]: boolean };
}
export const createNewVehicle = (parentId: string) => {
const vehicle = <Vehicle>createNewUser(parentId, RoleIds.DEVICE);
vehicle.vehicleType = 0;
vehicle.tailNumber = '';
return vehicle;
}

View File

@ -0,0 +1,155 @@
/* Partner Integration Styles */
.partner-aircraft-section {
padding: 12px;
border: 1px solid #bdbdbd;
/* dividerColor - AgMission borders */
border-radius: 4px;
background-color: #ffffff;
/* contentBgColor - AgMission content background */
}
.partner-aircraft-section h4 {
color: #212121;
/* textColor - AgMission primary text */
font-size: 1rem;
font-weight: 600;
}
/* Tail Number Constraint Message Styling */
.md-inputfield+agm-constraint-message {
margin-top: 8px;
}
.loading-indicator {
display: flex;
align-items: center;
color: #03A9F4;
/* blue - AgMission info color */
margin-bottom: 8px;
}
.loading-indicator i {
margin-right: 6px;
}
.aircraft-id {
color: #757575;
/* textSecondaryColor - AgMission secondary text */
font-size: 0.85em;
}
.no-aircraft-message {
display: flex;
align-items: center;
padding: 10px;
background-color: #E1F5FE;
/* Light blue background for info messages */
border: 1px solid #03A9F4;
/* blue - AgMission info border */
border-radius: 4px;
color: #0277BD;
/* blueHover - AgMission darker blue for text */
margin-bottom: 8px;
}
.no-aircraft-message i {
margin-right: 6px;
font-size: 1.1em;
}
.selected-aircraft-info {
padding: 12px;
background-color: #ffffff;
/* contentBgColor - AgMission content background */
border: 1px solid #bdbdbd;
/* dividerColor - AgMission borders */
border-radius: 4px;
}
.selected-aircraft-info h5 {
color: #212121;
/* textColor - AgMission primary text */
font-size: 0.95rem;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
font-size: 0.875rem;
}
.status-ready {
background-color: #E8F5E8;
/* Light green background */
color: #2E7D32;
/* primaryDarkColor - AgMission dark green */
border: 1px solid #4CAF50;
/* primaryColor - AgMission main green */
}
.status-error {
background-color: #FFEBEE;
/* Light red background */
color: #C62828;
/* redHover - AgMission dark red */
border: 1px solid #F44336;
/* red - AgMission error color */
}
.status-loading {
background-color: #FFF8E1;
/* Light amber background */
color: #FF8F00;
/* amberHover - AgMission dark amber */
border: 1px solid #FFC107;
/* amber - AgMission warning color */
}
.error-message {
margin-top: 8px;
}
.form-row {
margin-bottom: 15px;
}
/* Input with inline constraint message */
.input-with-inline-constraint {
display: flex;
align-items: flex-start;
gap: 6px;
/* AgMission standard spacing */
}
.input-with-inline-constraint .md-inputfield {
flex: 1;
/* Input takes remaining space */
}
/* Inline constraint beside input - vertically aligned with input field center */
.input-with-inline-constraint .inline-constraint {
margin-top: -2px;
/* Shift icon upward to align with input box vertical center */
}
.input-with-inline-constraint .inline-constraint ::ng-deep .agm-constraint-wrapper {
display: inline-block;
width: auto;
vertical-align: middle;
}
/* Responsive design */
@media (max-width: 768px) {
.partner-aircraft-section {
padding: 10px;
}
/* Stack input and icon vertically on very small screens if needed */
.input-with-inline-constraint {
flex-wrap: wrap;
}
}

View File

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

View File

@ -1,59 +1,115 @@
import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core';
import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { SelectItem } from 'primeng/api';
import { Vehicle } from '../../models/vehicle.model';
import * as vehicleActions from '../../actions/vehicle.actions';
import { StringUtils } from '@app/shared/utils';
import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component';
import { globals, VehType, vehTypes } from '@app/shared/global';
import { SelectItem } from 'primeng/api';
import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component';
import { globals, VehType, vehTypes, SystemTypes, SourceSystem, OperationalStatus, Labels } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component';
import { selectLimit } from '@app/reducers';
import { Limit } from '@app/domain/models/subscription.model';
import { SubKeys, SubType } from '@app/profile/common';
import { PartnerIntegrationData, VehiclePartnerIntegrationComponent } from '../vehicle-partner-integration/vehicle-partner-integration.component';
// ============================================================================
// COMPONENT
// ============================================================================
@Component({
selector: 'agm-vehicle-edit',
templateUrl: './vehicle-edit.component.html',
styles: []
styleUrls: ['./vehicle-edit.component.css']
})
export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
// ============================================================================
// CONSTANTS & READONLY PROPERTIES
// ============================================================================
readonly globals = globals;
readonly SourceSystem = SourceSystem;
readonly Labels = Labels;
// ============================================================================
// CORE VEHICLE PROPERTIES
// ============================================================================
selectedItem: Vehicle;
orgUnitId: string;
// Core vehicle form options
acTypes: SelectItem[];
acColors: SelectItem[];
// Partner integration state (managed by child component)
private partnerData: PartnerIntegrationData | null = null;
partnerValidationState: boolean = true; // Default to valid for basic aircraft
// Return message handling from account-edit
connectionTestMessage: string | null = null;
connectionTestSuccess: boolean | null = null;
pendingAuthenticationSuccess: boolean = false; // Flag to update partner auth state after ViewInit
// ============================================================================
// VIEW CHILDREN & UI STATE
// ============================================================================
@ViewChild('vehicleName') vehicleName: ElementRef;
@ViewChild('account') accEditor: AccountEditorComponent;
@ViewChild('partnerIntegration') partnerIntegration: VehiclePartnerIntegrationComponent;
@ViewChild('tailNumberConstraint') tailNumberConstraint: ConstraintMessageComponent;
hasTracking: boolean;
private _vehicle: Vehicle;
get vehicle(): Vehicle { return this._vehicle; }
set vehicle(vehicle: Vehicle) {
this._vehicle = vehicle;
this.selectedItem = Object.assign({}, vehicle); // create a clone object to work on the editor
// ============================================================================
// VEHICLE MANAGEMENT PROPERTIES
// ============================================================================
if (!this.isNew && this.selectedItem.unitId)
this.orgUnitId = this.selectedItem.unitId;
private _vehicle: Vehicle;
private _isNew: boolean;
get vehicle(): Vehicle {
return this._vehicle;
}
set vehicle(vehicle: Vehicle) {
this._vehicle = vehicle;
this.selectedItem = Object.assign({}, vehicle);
// For new vehicles, ensure active defaults to true
// Check vehicle._id directly since _isNew is set later
if (vehicle._id === '0') {
// For new vehicles, active should always default to true
this.selectedItem.active = true;
}
if (!this.isNew && this.selectedItem.unitId) {
this.orgUnitId = this.selectedItem.unitId;
}
}
private _isNew: boolean;
get isNew(): boolean {
return this._isNew;
}
get user() {
return this.selectedItem.username ? ({ username: this.selectedItem.username, password: this.selectedItem.password }) : null;
return this.selectedItem.username ?
{ username: this.selectedItem.username, password: this.selectedItem.password } :
null;
}
// ============================================================================
// CONSTRUCTOR
// ============================================================================
constructor(
private readonly route: ActivatedRoute,
private readonly cdr: ChangeDetectorRef
) {
super();
this.acTypes = [
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
@ -65,36 +121,84 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
{ label: globals.lime, value: 'lime' },
{ label: globals.yellow, value: 'yellow' },
{ label: globals.orange, value: 'orange' },
{ label: globals.purple, value: 'purple' },
{ label: globals.purple, value: 'purple' }
];
}
ngOnInit() {
this.sub$ = this.route.data
.subscribe((data) => {
const vehicle = data[0] as Vehicle || null;
if (vehicle) {
this.vehicle = vehicle;
this._isNew = (this.vehicle._id === '0');
}
});
this.sub$.add(this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS])
.subscribe((action) => {
this.store.dispatch(new vehicleActions.Select(action['payload']));
this.goBack();
}));
// ============================================================================
// LIFECYCLE METHODS
// ============================================================================
this.sub$.add(this.store.select(selectLimit(SubType.ADDON))
.subscribe((addon) => {
const tracking: Limit = addon?.[SubKeys.TRACKING];
this.hasTracking = tracking?.airCraft?.numOfVehicle > 0;
}));
ngOnInit() {
// Handle query parameters for return navigation messages
this.sub$ = this.route.queryParams.subscribe(params => {
if (params['connectionTestResult']) {
this.connectionTestSuccess = params['connectionTestResult'] === 'success';
this.connectionTestMessage = params['message'];
if (this.connectionTestSuccess) {
console.log('Account authentication successful:', this.connectionTestMessage);
// Set flag to update partner auth state after ViewInit
this.pendingAuthenticationSuccess = true;
// Optionally show success message to user
if (this.msgSvc) {
this.msgSvc.addSuccessMsg(this.connectionTestMessage);
}
} else {
console.error('Account authentication failed:', this.connectionTestMessage);
// Show error message to user
if (this.msgSvc) {
this.msgSvc.addFailedMsg(this.connectionTestMessage);
}
}
// Clear query parameters to prevent message from showing again
// Wait longer (15 seconds) to allow partner aircraft API call to complete
// Navigation during API call can cancel the HTTP request
setTimeout(() => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: {},
replaceUrl: true
});
}, 15000); // Wait 15 seconds for API call to complete
}
});
// Route data subscription
this.sub$.add(this.route.data.subscribe((data) => {
const vehicle = data[0] as Vehicle || null;
if (vehicle) {
this.vehicle = vehicle;
this._isNew = (this.vehicle._id === '0');
}
}));
// Vehicle actions subscription
this.sub$.add(
this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS])
.subscribe((action) => {
const savedVehicle = action['payload'];
this.store.dispatch(new vehicleActions.Select(savedVehicle));
this.goBack(savedVehicle);
})
);
// Tracking subscription
this.sub$.add(
this.store.select(selectLimit(SubType.ADDON))
.subscribe((addon) => {
const tracking: Limit = addon?.[SubKeys.TRACKING];
this.hasTracking = tracking?.airCraft?.numOfVehicle > 0;
})
);
}
ngAfterViewInit(): void {
// Auto-focus vehicle name field for new vehicles
const timer = setInterval(() => {
if (this.selectedItem && StringUtils.isEmpty(this.selectedItem.name)) {
if (this.vehicleName.nativeElement) {
if (this.vehicleName && this.vehicleName.nativeElement) {
this.vehicleName.nativeElement.focus();
clearInterval(timer);
}
@ -102,9 +206,153 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
clearInterval(timer);
}
}, 500);
setTimeout(() => { clearInterval(timer); }, 1500);
setTimeout(() => clearInterval(timer), 1500);
// Handle pending authentication success from query params
if (this.pendingAuthenticationSuccess && this.partnerIntegration) {
console.log('Applying pending authentication success to partner integration');
this.partnerIntegration.updateAuthenticationSuccess();
this.pendingAuthenticationSuccess = false;
}
// Check for stored form data from Account Does Not Exist flow and restore it
if (this.partnerIntegration) {
const storedFormData = this.partnerIntegration.getStoredFormData();
if (storedFormData) {
console.log('Restoring vehicle form data after account creation:', storedFormData);
this.restoreFormData(storedFormData);
}
}
}
ngOnDestroy() {
super.ngOnDestroy();
}
// ============================================================================
// PARTNER INTEGRATION EVENT HANDLERS
// ============================================================================
onPartnerDataChange(partnerData: PartnerIntegrationData): void {
this.partnerData = partnerData;
// Update vehicle tail number if partner aircraft is selected
if (partnerData.tailNumber) {
this.selectedItem.tailNumber = partnerData.tailNumber;
}
}
onPartnerValidationStateChange(isValid: boolean): void {
this.partnerValidationState = isValid;
}
/**
* Get constraint message details when partner validation is invalid
*/
getPartnerConstraintDetails(): { title: string; message: string } | null {
// Only show partner-specific constraint messages
// Basic form validation (like aircraft name) is handled by Angular forms
// Check partner validation state
if (!this.partnerValidationState && this.partnerIntegration) {
const integration = this.partnerIntegration;
// If partner system selected, check integration requirements
if (integration.isPartnerSystemSelected) {
// If partner validation failed
if (!integration.partnerValidation.accountExists || !integration.partnerValidation.authenticationValid) {
return null; // These are handled by the partner integration component
}
// If no aircraft selected
if (!integration.selectedPartnerAircraft) {
return {
title: this.Labels.AIRCRAFT_SELECTION_REQUIRED_TITLE,
message: this.Labels.AIRCRAFT_SELECTION_REQUIRED_MESSAGE
};
}
// If Satloc partner and no system type selected
if (integration.isSatlocPartnerSelected && !integration.selectedSystemType) {
return {
title: this.Labels.SYSTEM_TYPE_REQUIRED_TITLE,
message: this.Labels.SYSTEM_TYPE_REQUIRED_MESSAGE
};
}
// General integration incomplete message
return {
title: this.Labels.PARTNER_INTEGRATION_INCOMPLETE_TITLE,
message: this.Labels.PARTNER_INTEGRATION_INCOMPLETE_MESSAGE
};
}
}
return null;
}
/**
* Check if account fields are incomplete (username/password missing)
*/
isAccountIncomplete(): boolean {
if (!this.accEditor) {
// If no account editor, consider account incomplete
return true;
}
const accountValue = this.accEditor.value;
if (!accountValue) {
return true;
}
const hasUsername = accountValue?.username && accountValue.username.trim() !== '';
const hasPassword = accountValue?.password && accountValue.password.trim() !== '';
const isActive = accountValue?.active === true;
// Account is incomplete if username, password, or active status is missing
return !hasUsername || !hasPassword || !isActive;
}
/**
* Get appropriate severity for constraint message
*/
getConstraintSeverity(constraintDetails: { title: string; message: string }): string {
// Account incomplete is informational (allows saving)
if (constraintDetails.title === this.Labels.ACCOUNT_INCOMPLETE_TITLE) {
return 'info';
}
// All other constraints are warnings (block saving)
return 'warning';
}
/**
* Get appropriate icon for constraint message
*/
getConstraintIcon(constraintDetails: { title: string; message: string }): string {
// Account incomplete uses info icon
if (constraintDetails.title === this.Labels.ACCOUNT_INCOMPLETE_TITLE) {
return 'pi-info-circle';
}
// All other constraints use warning icon (using ui-icon-warning as pi-exclamation-triangle has rendering issues)
return 'ui-icon-warning';
}
/**
* Get current account editor data for form data preservation
* This method is called by the vehicle-partner-integration component
* when navigating to account creation
*/
getAccountEditorData(): any {
if (this.accEditor && this.accEditor.valid) {
return this.accEditor.value;
}
return null;
}
// ============================================================================
// VEHICLE SAVE OPERATIONS
// ============================================================================
saveVehicle() {
if (this.accEditor) {
const acc = this.accEditor.value;
@ -112,21 +360,233 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
this.selectedItem.password = acc.password;
this.selectedItem.active = acc.active;
}
if (this.selectedItem?.tracking && !this.selectedItem?.unitId) {
this.selectedItem.tracking = false;
}
this.store.dispatch(this._isNew ? new vehicleActions.Create(this.selectedItem) : new vehicleActions.Update(this.selectedItem));
this.preparePartnerDataForBackend();
this.store.dispatch(this._isNew ?
new vehicleActions.Create(this.selectedItem) :
new vehicleActions.Update(this.selectedItem)
);
}
goBack() {
this.router.navigate(['/entities/aircraft/', { id: this.vehicle._id }]);
private preparePartnerDataForBackend(): void {
if (this.partnerData && this.partnerData.selectedPartner && this.partnerData.selectedPartner !== SourceSystem.AGNAV && this.partnerData.selectedPartnerData) {
this.selectedItem.partnerInfo = {
partner: this.partnerData.selectedPartnerData._id!,
partnerAircraftId: this.partnerData.selectedPartnerAircraft || null,
systemType: this.partnerData.systemType || SystemTypes.PLATINUM, // Include system type from partner integration
metadata: {
partnerSystem: this.partnerData.selectedPartnerData.name,
partnerCode: this.partnerData.selectedPartnerData.partnerCode,
aircraftData: this.partnerData.selectedPartnerAircraftDetails,
syncStatus: this.partnerData.selectedPartnerAircraftDetails ? OperationalStatus.PENDING : null,
lastSync: null,
connectionStatus: OperationalStatus.CONNECTED
}
};
if (this.partnerData.selectedPartnerAircraftDetails?.tailNumber) {
this.selectedItem.tailNumber = this.partnerData.selectedPartnerAircraftDetails.tailNumber;
}
// Clean up legacy properties
delete this.selectedItem.partnerSystem;
delete this.selectedItem.partnerAircraftId;
delete this.selectedItem.partnerAircraftData;
} else {
this.selectedItem.partnerInfo = {
partner: null,
partnerAircraftId: null,
systemType: this.partnerData?.systemType || SystemTypes.PLATINUM, // Preserve system type even for AgNav
metadata: null
};
// Clean up legacy properties
delete this.selectedItem.partnerSystem;
delete this.selectedItem.partnerAircraftId;
delete this.selectedItem.partnerAircraftData;
}
}
goBack(savedVehicle?: any) {
// Use savedVehicle if provided (from CREATE_SUCCESS), otherwise use this.vehicle
const vehicleToCheck = savedVehicle || this.vehicle;
// If this was a newly created vehicle that was successfully saved, add query param to show tooltip
const vehicleSuccessfullySaved = vehicleToCheck?._id && vehicleToCheck._id !== '0';
if (this.isNew && vehicleSuccessfullySaved) {
this.router.navigate(['/entities/aircraft'], {
queryParams: { newVehicleCreated: vehicleToCheck._id }
});
} else {
this.router.navigate(['/entities/aircraft']);
}
}
// ============================================================================
// FORM DATA RESTORATION HELPERS
// ============================================================================
/**
* Restore vehicle form data after Account Does Not Exist flow
* @param formData The stored form data to restore
*/
private restoreFormData(formData: any): void {
if (!formData || !this.selectedItem) {
return;
}
try {
// Restore basic vehicle properties
if (formData.name) this.selectedItem.name = formData.name;
if (formData.vehicleType !== undefined) this.selectedItem.vehicleType = formData.vehicleType;
if (formData.model) this.selectedItem.model = formData.model;
if (formData.tailNumber) this.selectedItem.tailNumber = formData.tailNumber;
if (formData.unitId) this.selectedItem.unitId = formData.unitId;
if (formData.desc) this.selectedItem.desc = formData.desc;
if (formData.color) this.selectedItem.color = formData.color;
// Restore account credentials to vehicle object (for backward compatibility)
if (formData.username) this.selectedItem.username = formData.username;
if (formData.password) this.selectedItem.password = formData.password;
if (formData.active !== undefined) this.selectedItem.active = formData.active;
// Restore account editor form data if available and component is ready
if (formData.accountEditor && this.accEditor) {
// Use a timeout to ensure the account editor is fully initialized
setTimeout(() => {
if (this.accEditor) {
this.accEditor.writeValue(formData.accountEditor);
console.log('Restored account editor data:', formData.accountEditor);
}
}, 100);
}
// Trigger change detection to update the UI
this.cdr.detectChanges();
console.log('Successfully restored vehicle form data');
} catch (error) {
console.error('Error restoring form data:', error);
}
}
// ============================================================================
// COMPUTED PROPERTIES
// ============================================================================
get canActivateVehicle() {
return this.authSvc.canActivateVehicle;
}
ngOnDestroy() {
super.ngOnDestroy();
get isPartnerSystemSelected(): boolean {
return this.partnerData?.selectedPartner !== null && this.partnerData?.selectedPartner !== SourceSystem.AGNAV;
}
get canEditPartnerFields(): boolean {
return this.partnerData?.partnerValidation?.accountExists &&
this.partnerData?.partnerValidation?.authenticationValid &&
!this.partnerData?.partnerValidation?.isValidating;
}
get canEditBasicFields(): boolean {
return !this.partnerData?.partnerValidation?.isValidating;
}
get canSaveVehicle(): boolean {
if (!this.selectedItem?.name || this.selectedItem.name.trim() === '') {
return false;
}
const basicFieldsValid = this.selectedItem?.name?.trim() &&
this.selectedItem?.model &&
this.selectedItem?.vehicleType !== undefined;
// Check account editor validity
const accountValid = !this.accEditor || this.accEditor.form?.valid;
if (!this.isPartnerSystemSelected) {
return basicFieldsValid && accountValid;
}
if (!this.partnerData?.partnerValidation?.accountExists || !this.partnerData?.partnerValidation?.authenticationValid) {
return basicFieldsValid && accountValid;
}
return basicFieldsValid && accountValid && !!this.partnerData?.selectedPartnerAircraft;
}
get saveButtonTooltip(): string {
if (this.isPartnerSystemSelected) {
if (!this.partnerData?.partnerValidation?.accountExists) {
return Labels.SAVE_TOOLTIP_NO_ACCOUNT;
}
if (!this.partnerData?.partnerValidation?.authenticationValid) {
return Labels.SAVE_TOOLTIP_AUTH_FAILED;
}
const partnerName = this.partnerData?.selectedPartnerData?.name || Labels.GENERIC_PARTNER;
return `${Labels.SAVE_TOOLTIP_BASE_MESSAGE} ${partnerName} ${Labels.SAVE_TOOLTIP_INTEGRATION_SUFFIX}`;
}
return Labels.SAVE_TOOLTIP_NATIVE;
}
get partnerSystemName(): string {
return this.partnerData?.selectedPartnerData?.name || 'partner system';
}
/**
* Check if account editor is valid
* For partner systems, account editor is hidden, so validation is skipped
*/
get isAccountValid(): boolean {
if (this.isPartnerSystemSelected) {
return true; // No account editor for partner systems
}
return !this.accEditor || this.accEditor.form?.valid;
}
// ============================================================================
// PARTNER VEHICLE ACTIVATION METHODS
// ============================================================================
/**
* Determines if a partner system vehicle can be activated
* Requires: partner account exists, authentication valid, and aircraft selected
*/
canActivatePartnerVehicle(): boolean {
if (!this.isPartnerSystemSelected) {
return false;
}
return this.partnerData?.partnerValidation?.accountExists === true &&
this.partnerData?.partnerValidation?.authenticationValid === true &&
!!this.partnerData?.selectedPartnerAircraft;
}
/**
* Returns constraint message explaining why partner vehicle cannot be activated
*/
getPartnerActivationConstraintMessage(): string {
if (!this.isPartnerSystemSelected) {
return '';
}
if (!this.partnerData?.partnerValidation?.accountExists) {
return Labels.PARTNER_ACCOUNT_REQUIRED_FOR_ACTIVATION;
}
if (!this.partnerData?.partnerValidation?.authenticationValid) {
return Labels.PARTNER_AUTH_REQUIRED_FOR_ACTIVATION;
}
if (!this.partnerData?.selectedPartnerAircraft) {
return Labels.PARTNER_AIRCRAFT_REQUIRED_FOR_ACTIVATION;
}
return '';
}
}

View File

@ -1,3 +1,292 @@
.highlight-btn{
background-color: green;
.highlight-btn {
background-color: #4CAF50;
/* $primaryColor */
}
/* Custom Aircraft Review Message - Enhanced UX Layout */
.aircraft-review-container {
background-color: #FFF8E1;
/* Warning background - AgMission amber light */
border: 1px solid #FFC107;
/* $amber */
border-radius: 6px;
margin: 0.75rem 0;
padding: 1rem 1.25rem;
display: flex;
align-items: flex-start;
/* Top-align for better text flow */
gap: 0.875rem;
/* Optimized spacing for visual hierarchy */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
/* Subtle depth */
}
.aircraft-review-container .pi-info-circle {
color: #FF8F00 !important;
/* $amberHover - dark warning color for good contrast */
font-size: 1.375em !important;
flex-shrink: 0;
margin-top: 0.125rem;
/* Optical alignment with text baseline */
line-height: 1;
}
.aircraft-review-message {
color: #212121 !important;
/* $textColor - high contrast text */
font-weight: 500;
font-size: 1rem;
line-height: 1.4;
/* Optimized line height for readability */
flex: 1;
margin: 0;
/* Remove default margins for precise control */
}
/* Custom Generic Error Message - Enhanced UX Layout */
.generic-error-container {
background-color: #FFEBEE;
/* Error background - AgMission red light */
border: 1px solid #F44336;
/* $red */
border-radius: 6px;
margin: 0.75rem 0;
padding: 1rem 1.25rem;
display: flex;
align-items: flex-start;
/* Top-align for better text flow */
gap: 0.875rem;
/* Consistent spacing with warning */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
/* Subtle depth */
}
.generic-error-container .pi-exclamation-triangle {
color: #C62828 !important;
/* $redHover - dark error red for good contrast */
font-size: 1.375em !important;
flex-shrink: 0;
margin-top: 0.125rem;
/* Optical alignment with text baseline */
line-height: 1;
}
.generic-error-message {
color: #C62828 !important;
/* $redHover - dark error text for contrast */
font-weight: 500;
font-size: 1rem;
line-height: 1.4;
/* Consistent line height */
flex: 1;
margin: 0;
/* Remove default margins for precise control */
}
/* Generic Message Enhanced Styling */
:host ::ng-deep generic-message {
display: block;
margin: 0.5rem 0;
}
:host ::ng-deep generic-message .icon-message {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.75rem;
}
/* Button Styling for Aircraft Review */
:host ::ng-deep generic-message .amber-btn {
background-color: #FFC107;
/* $amber */
border-color: #FF8F00;
/* $amberHover */
color: #212121;
/* $textColor for good contrast on yellow */
font-weight: 600;
padding: 0.75rem 1.5rem;
min-height: 44px;
/* Accessibility: Touch target size */
border-radius: 4px;
transition: all 0.2s ease;
}
:host ::ng-deep generic-message .amber-btn:hover {
background-color: #FF8F00;
/* $amberHover */
border-color: #f9a825;
/* $accentLightColor */
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* System Type Display Styles */
.system-type-display {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-height: 44px;
/* Accessibility: Larger touch targets */
}
/* Responsive Design - Improved Accessibility */
@media (max-width: 768px) {
.system-type-display {
display: inline-flex;
/* Keep inline for proper alignment with ui-column-title */
gap: 6px;
align-items: flex-start;
vertical-align: top;
}
.partner-code-badge {
font-size: 0.8rem;
/* Maintain readability on mobile */
padding: 3px 6px;
min-width: 60px;
}
.auth-status-indicator {
font-size: 0.8rem;
margin-left: 4px;
padding: 3px 6px;
min-width: 28px;
min-height: 32px;
/* Maintain touch target on mobile */
}
.auth-status-indicator i {
font-size: 0.75rem;
}
}
@media (max-width: 480px) {
.system-type-display {
gap: 4px;
}
.partner-badge {
font-size: 0.75rem;
padding: 2px 6px;
letter-spacing: 0.3px;
}
.partner-code-badge {
font-size: 0.75rem;
padding: 2px 5px;
min-width: 50px;
}
.auth-status-indicator {
font-size: 0.75rem;
min-width: 24px;
}
}
/* Table Column Specific Styles - Enhanced for Accessibility */
.ui-table .ui-table-tbody>tr>td .system-type-display {
min-width: 140px;
/* Increased for better layout */
padding: 4px 0;
}
/* Improved Focus Management for Table Cells */
:host ::ng-deep .ui-table .ui-table-tbody>tr>td:focus-within {
outline: 2px solid #4CAF50;
/* $primaryColor for focus */
outline-offset: 1px;
}
/* Dark Theme Support - Enhanced Contrast */
@media (prefers-color-scheme: dark) {
.partner-code-badge {
background-color: #757575;
/* $grayBgColor */
color: #ffffff;
/* $primaryTextColor */
border-color: #bdbdbd;
/* $dividerColor */
}
.partner-code-badge:hover,
.partner-code-badge:focus {
background-color: #616161;
/* Darker gray */
border-color: #e8e8e8;
/* $hoverBgColor */
}
.auth-status-indicator.auth-valid {
color: #A5D6A7;
/* $primaryLightColor */
background-color: rgba(165, 214, 167, 0.2);
border-color: rgba(165, 214, 167, 0.4);
}
.auth-status-indicator.auth-invalid {
color: #EF5350;
/* Lighter red for dark theme */
background-color: rgba(239, 83, 80, 0.2);
border-color: rgba(239, 83, 80, 0.4);
}
.auth-status-indicator.auth-validating {
color: #f9a825;
/* $accentLightColor */
background-color: rgba(249, 168, 37, 0.2);
border-color: rgba(249, 168, 37, 0.4);
}
}
/* ============================================================================
PACKAGE ACTIVATION TOOLTIP STYLES
============================================================================ */
/* Highlight effect for package checkbox when tooltip is shown */
.package-activation-highlight .p-checkbox-box {
border: 2px solid #FFC107 !important;
/* $amber */
box-shadow: 0 0 8px rgba(255, 193, 7, 0.4) !important;
/* $amber with transparency */
animation: pulseGlow 2s ease-in-out infinite;
}
@keyframes pulseGlow {
0% {
box-shadow: 0 0 8px rgba(255, 193, 7, 0.4);
/* $amber */
}
50% {
box-shadow: 0 0 16px rgba(255, 193, 7, 0.6);
/* $amber */
}
100% {
box-shadow: 0 0 8px rgba(255, 193, 7, 0.4);
/* $amber */
}
}
/* ============================================================================
AIRCRAFT REVIEW BANNER NO-CHANGES CONFIRM BUTTON
============================================================================ */
/* Flex column body: stacks message text + button vertically inside the flex row.
Takes the flex:1 growth previously on .aircraft-review-message. */
.aircraft-review-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
/* Center message text and button horizontally */
text-align: center;
gap: 10px;
}
/* No custom CSS needed for the confirm button - uses .highlight-btn
(already defined at top of this file) with PrimeNG default button layout.
This matches the existing toolbar button pattern (ui-icon-* + pButton). */

View File

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

View File

@ -5,18 +5,23 @@ import { ConfirmationService, SelectItem } from 'primeng/api';
import { Vehicle } from '../../models/vehicle.model';
import * as vehicleActions from '../../actions/vehicle.actions';
import * as fromEntity from '../../reducers';
import { RoleIds, globals, vehTypes, VehType } from '@app/shared/global';
import { RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
import { DateUtils, Utils } from '@app/shared/utils';
import { BaseComp } from '@app/shared/base/base.component';
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
import { BadgeFactoryService } from '@app/shared/services/badge-factory.service';
import { BadgeConfig } from '@app/shared/badge/badge-config.model';
import { getSubIntentState, getSubscriptionStatus, selectLimit } from '@app/reducers';
import { SUB, SubAppErr, SubTexts, SubType, createSubStatus, SubKeys, ACTIVE, TRACKING, hasVendorErr } from '@app/profile/common';
import { Limit, Status } from '@app/domain/models/subscription.model';
import { map, switchMap, tap } from 'rxjs/operators';
import { map, switchMap, take } from 'rxjs/operators';
import { ClearSubscriptionStatus, Compound, GotoMyServices } from '@app/actions/subscription.actions';
import { SubscriptionService } from '@app/domain/services/subscription.service';
import { FetchSubPlans } from '@app/actions/sub-plans.actions';
import { User } from '@app/accounts/models/user.model';
import { UserService } from '@app/domain/services/user.service';
import { PartnerService } from '@app/partners/services/partner.service';
import { PopupTooltipService } from '@app/shared/popup-tooltip/popup-tooltip.service';
const HIGHLIGHT = 'highlight-btn';
@ -38,6 +43,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
readonly COLOR = 'color';
readonly MODEL = 'model';
readonly UNIT_ID = 'unitId';
readonly SOURCE_SYSTEM = 'sourceSystem';
vehicles: Vehicle[] = [];
vehiclesChanged = false;
@ -64,11 +70,39 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
vendorErr: boolean;
user: User;
// Track if we're in review aircraft flow (from checkout/manage-subscription)
private isReviewAircraftFlow = false;
// ============================================================================
// NEW VEHICLE TOOLTIP STATE
// ============================================================================
// Track newly created vehicle to show package activation tooltip
newVehicleId: string | null = null;
packageTooltipShown: boolean = false;
// ============================================================================
// PARTNER AUTHENTICATION STATUS CACHING
// ============================================================================
// Cache authentication status per partner to avoid repeated API calls
private partnerAuthCache = new Map<string, {
isAuthenticated: boolean;
isValidating: boolean;
lastChecked: Date;
error?: string;
}>();
// Per-partner debounce timers for authentication checks
private authCheckTimers = new Map<string, any>();
BASE_FIELDS = [
{ field: 'name', header: globals.name, filtered: true },
{ field: 'tailNumber', header: $localize`:@@tailNumber:Tail Number`, filtered: true },
{ field: this.VEHICLE_TYPE, header: $localize`:@@type:Type` },
{ field: this.MODEL, header: $localize`:@@model:Model` },
{ field: this.ACTIVE, header: globals.active, width: '5%' },
{ field: this.SOURCE_SYSTEM, header: $localize`:@@systemType:System Type` }, // NEW COLUMN
];
PACKAGE_ACTIVE_FIELDS = [
@ -96,6 +130,10 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
private readonly confirmService: ConfirmationService,
private readonly subSvc: SubscriptionService,
private readonly userSvc: UserService,
private readonly partnerService: PartnerService,
private readonly partnerUtils: PartnerUtilsService,
private readonly badgeFactory: BadgeFactoryService,
private readonly popupTooltipService: PopupTooltipService
) {
super();
this.acTypes = [
@ -108,6 +146,15 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
ngOnInit() {
this.user = this.route.snapshot.data['user'];
this.clearNeedReview();
// Check for newly created vehicle query parameter
this.checkForNewVehicleTooltip();
// Track if we're in review aircraft flow via query param
this.route.queryParams.pipe(take(1)).subscribe((params) => {
this.isReviewAircraftFlow = params['reviewFlow'] === 'true';
});
this.initVehList();
this.initStatus();
this.store.dispatch(new Compound([
@ -121,23 +168,363 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
this.user.needReview = false;
this.userSvc.saveUser(this.user).subscribe({
error: (err) => {
console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
});
}
}
// ============================================================================
// NEW VEHICLE TOOLTIP METHODS
// ============================================================================
/**
* Check for newly created vehicle and prepare to show package activation tooltip
*/
checkForNewVehicleTooltip() {
this.sub$.add(
this.route.queryParams.subscribe(params => {
if (params.newVehicleCreated && !this.packageTooltipShown) {
this.newVehicleId = params.newVehicleCreated;
// Remove the query parameter to clean up the URL
this.router.navigate([], {
relativeTo: this.route,
queryParams: {},
replaceUrl: true
});
}
})
);
}
/**
* Show package activation tooltip for newly created vehicle
* Only shown if vehicle meets all eligibility requirements
*/
showPackageActivationTooltip(vehicleId: string) {
if (!this.newVehicleId || this.newVehicleId !== vehicleId || this.packageTooltipShown) {
return;
}
// Check if vehicle is eligible for tooltip
if (!this.shouldShowAircraftReadyTooltip(vehicleId)) {
// EDGE CASE: Check if partner auth is still validating
const vehicle = this.vehicles.find(v => v._id === vehicleId);
if (vehicle?.partnerInfo?.partner && !this.partnerUtils.isNativeSystem(vehicle.partnerInfo.partner)) {
const authStatus = this.getPartnerAuthStatus(vehicle.partnerInfo.partner);
if (authStatus.isValidating) {
// Wait for auth validation to complete
let retryAttempts = 0;
const maxRetries = 10; // Max 5 seconds (10 * 500ms)
const checkInterval = setInterval(() => {
retryAttempts++;
const updatedStatus = this.getPartnerAuthStatus(vehicle.partnerInfo.partner);
if (!updatedStatus.isValidating || retryAttempts >= maxRetries) {
clearInterval(checkInterval);
if (retryAttempts >= maxRetries) {
return;
}
// Re-check eligibility after auth completes
if (this.shouldShowAircraftReadyTooltip(vehicleId)) {
this.showPackageActivationTooltip(vehicleId);
}
}
}, 500); // Check every 500ms
return; // Don't show tooltip yet
}
}
return; // Don't show tooltip if requirements not met
}
// Set flag immediately to prevent duplicate calls during setTimeout delay
this.packageTooltipShown = true;
// Wait for DOM updates and table rendering
setTimeout(() => {
// Find the package checkbox using the ID we added to the template
const checkboxContainer = document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement;
if (checkboxContainer) {
// Get the actual checkbox element for precise positioning
const checkboxBox = checkboxContainer.querySelector('.p-checkbox-box') as HTMLElement ||
checkboxContainer.querySelector('.ui-chkbox-box') as HTMLElement ||
checkboxContainer.querySelector('.p-checkbox') as HTMLElement;
// Use the visual checkbox box if found, otherwise the container
const targetElement = checkboxBox || checkboxContainer;
if (targetElement) {
// Add highlight class for visual emphasis
checkboxContainer.classList.add('package-activation-highlight');
// Position tooltip on the left side of the package active column
const isMobile = window.innerWidth <= 768;
const preferredPosition = isMobile ? 'bottom' : 'left'; // Changed from 'right' to 'left'
// Check if package can be activated and customize tooltip accordingly
const canActivate = this.canActivatePackageForVehicle(vehicleId);
const tooltipConfig = this.getPackageActivationTooltipConfig(vehicleId, canActivate);
const tooltipRef = this.popupTooltipService.showActionReminder(
tooltipConfig.message,
tooltipConfig.actionText,
targetElement,
{
position: preferredPosition,
autoHide: false, // Keep visible until user takes action
title: tooltipConfig.title,
severity: tooltipConfig.severity
}
);
// Subscribe to action button click
if (tooltipRef && tooltipRef.instance) {
tooltipRef.instance.actionClicked.subscribe(() => {
this.handlePackageActivationAction(vehicleId, canActivate);
});
}
}
}
}, 500); // Allow time for DOM rendering
}
/**
* Check if package can be activated for the given vehicle
*/
canActivatePackageForVehicle(vehicleId: string): boolean {
if (!this.pkgLimit) {
return true; // No limits, can activate
}
const activePackageVehicles = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles);
const maxAllowed = this.pkgLimit?.airCraft?.numOfVehicle || 0;
return activePackageVehicles.length < maxAllowed;
}
/**
* Check if vehicle has proper account credentials
* Required for showing "Aircraft Ready" tooltip
*
* Note: Passwords are not returned from backend for security.
* For newly created vehicles, we assume password was set because
* backend validation would fail without it.
*
* @param vehicleId Vehicle ID to check
* @returns true if vehicle has username and active=true
*/
hasProperAccountCredentials(vehicleId: string): boolean {
const vehicle = this.vehicles.find(v => v._id === vehicleId);
if (!vehicle) {
return false;
}
// Partner aircraft: Only require active status (no username/password needed)
if (vehicle.partnerInfo?.partner && !this.partnerUtils.isNativeSystem(vehicle.partnerInfo.partner)) {
return vehicle.active === true;
}
// Native AgMission aircraft: Require username and active status
const hasUsername = vehicle.username && vehicle.username.trim() !== '';
const isActive = vehicle.active === true;
// Note: Password field is not included in backend response for security
// For newly created vehicles (via newVehicleCreated query param),
// we assume password was provided since backend validates this
// Both username and active must be present for native aircraft
return hasUsername && isActive;
}
/**
* Check if vehicle has valid partner authentication (if applicable)
* For AgNav native systems, returns true immediately
* For partner systems, checks cached authentication status
*
* @param vehicleId Vehicle ID to check
* @returns true if partner auth is valid or not required
*/
hasValidPartnerAuthForVehicle(vehicleId: string): boolean {
const vehicle = this.vehicles.find(v => v._id === vehicleId);
if (!vehicle) {
return false;
}
// Use existing partner authentication validation
return this.hasValidPartnerAuth(vehicle);
}
/**
* Check if vehicle is eligible to show "Aircraft Ready" tooltip
*
* Requirements:
* 1. Has proper account credentials (username + active=true)
* 2. Package limit has not been reached
* 3. Partner authentication is valid (if applicable)
*
* @param vehicleId Vehicle ID to check
* @returns true if all conditions are met
*/
shouldShowAircraftReadyTooltip(vehicleId: string): boolean {
// Requirement 1: Check account credentials
const hasCredentials = this.hasProperAccountCredentials(vehicleId);
if (!hasCredentials) {
return false;
}
// Requirement 2: Check package limit
const canActivate = this.canActivatePackageForVehicle(vehicleId);
if (!canActivate) {
return false;
}
// Requirement 3: Check partner authentication (if applicable)
const hasValidAuth = this.hasValidPartnerAuthForVehicle(vehicleId);
if (!hasValidAuth) {
return false;
}
// All checks passed
return true;
}
/**
* Get tooltip configuration based on whether package can be activated
*/
getPackageActivationTooltipConfig(vehicleId: string, canActivate: boolean) {
if (canActivate) {
return {
title: Labels.AIRCRAFT_READY_TITLE,
message: Labels.PACKAGE_ACTIVATION_REMINDER,
actionText: Labels.ACTIVATE_PACKAGE_ACTION,
severity: 'warning' as const
};
} else {
const activeCount = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles).length;
const maxAllowed = this.pkgLimit?.airCraft?.numOfVehicle || 0;
return {
title: Labels.PACKAGE_LIMIT_REACHED_TITLE,
message: `${Labels.PACKAGE_LIMIT_REACHED_MESSAGE} (${activeCount}/${maxAllowed})`,
actionText: Labels.MANAGE_PACKAGE_LIMIT_ACTION,
severity: 'error' as const
};
}
}
/**
* Handle the action button click in the tooltip
*/
handlePackageActivationAction(vehicleId: string, canActivate: boolean) {
if (canActivate) {
// Activate package for this vehicle
this.activatePackageForVehicle(vehicleId);
} else {
// Show options for managing package limits
this.showPackageLimitOptions(vehicleId); // Pass vehicleId for consistent positioning
}
}
/**
* Activate package for the specified vehicle
*/
activatePackageForVehicle(vehicleId: string) {
const vehicle = this.vehicles.find(v => v._id === vehicleId);
if (vehicle && !vehicle.pkgActive) {
// Simulate checkbox change
vehicle.pkgActive = true;
this.vehSelChange(vehicle, this.PACKAGE_ACTIVE);
// Trigger backend update directly without confirmation dialog
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles }));
// Hide tooltip and show success feedback
this.popupTooltipService.hideAll();
// Show success message after a brief delay to allow for update processing
setTimeout(() => {
// Use consistent positioning with package activation tooltip
const isMobile = window.innerWidth <= 768;
const successPosition = isMobile ? 'bottom' : 'left';
this.popupTooltipService.showSuccess(
Labels.PACKAGE_ACTIVATED_SUCCESS,
document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement,
{
autoHide: true,
autoHideDelay: 3000,
title: Labels.SUCCESS_TITLE,
position: successPosition // Use consistent positioning
}
);
}, 300);
}
}
/**
* Show options for managing package limits
*/
showPackageLimitOptions(vehicleId: string) {
// Hide current tooltip
this.popupTooltipService.hideAll();
// Use consistent positioning with package activation tooltip
const isMobile = window.innerWidth <= 768;
const warningPosition = isMobile ? 'bottom' : 'left';
// Find the same target element (checkbox) for consistent positioning
const targetElement = document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement;
// For now, show constraint message component or navigate to upgrade
// This could be enhanced to show a modal with specific options
this.popupTooltipService.showWarning(
Labels.PACKAGE_LIMIT_UPGRADE_MESSAGE,
targetElement, // Use the same target as other tooltips
{
autoHide: true,
autoHideDelay: 5000,
title: Labels.UPGRADE_REQUIRED_TITLE,
position: warningPosition // Use consistent positioning
}
);
}
initVehList() {
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
map((vehicles) => {
this.vehicles = vehicles;
this.vehSelLastUpdated = this.createVehSelections(vehicles);
this.vehiclesChanged = this.isVehSelChanged();
// Show package activation tooltip after vehicles are loaded and view is initialized
if (this.newVehicleId && vehicles.length > 0) {
setTimeout(() => {
this.showPackageActivationTooltip(this.newVehicleId);
}, 500);
}
// Call resolveVehicleList when vehicles are loaded to ensure aircraft limits are enforced
if (vehicles && vehicles.length > 0) {
this.resolveVehicleList();
// Show package activation tooltip for newly created vehicle after table renders
if (this.newVehicleId && !this.packageTooltipShown) {
// Use setTimeout to ensure the table is fully rendered
setTimeout(() => {
this.showPackageActivationTooltip(this.newVehicleId!);
}, 500);
}
}
})
).subscribe({
error: (err) => {
console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
});
@ -181,7 +568,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
}))
).subscribe({
error: (err) => {
console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
}));
@ -205,6 +591,21 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
rowData[this.PACKAGE_ACTIVE_DATE] = currentUTCDate;
}
// Hide package activation tooltip when user activates package for newly created vehicle
if (type === this.PACKAGE_ACTIVE && rowData[this.PACKAGE_ACTIVE] &&
this.newVehicleId === rowData._id && this.packageTooltipShown) {
this.popupTooltipService.hideAll();
// Remove highlight class
const checkboxContainer = document.querySelector(`#package-checkbox-${rowData._id}`) as HTMLElement;
if (checkboxContainer) {
checkboxContainer.classList.remove('package-activation-highlight');
}
this.newVehicleId = null; // Reset to prevent showing again
this.packageTooltipShown = false;
}
if (!this.vehSelCurrent) this.vehSelCurrent = this.createVehSelections(this.vehicles);
this.vehSelCurrent[rowData[this.ID]][type] = rowData[type];
this.vehiclesChanged = this.isVehSelChanged();
@ -223,11 +624,20 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
}
private resolveVehicleList() {
// Ensure we have both vehicles data and limits before proceeding
if (!this.vehicles || this.vehicles.length === 0) {
return;
}
if (!this.pkgLimit && !this.trkLimit) {
return;
}
const trkVehicles = this.getVehicles(this.TRACKING, this.vehicles);
const pkgActiveVehs = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles);
const isTrkVehicleAboveLimit = trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle;
const isPkgActiveVehicleAboveLimit = pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle;
const isTrkVehicleAboveLimit = this.trkLimit && trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle;
const isPkgActiveVehicleAboveLimit = this.pkgLimit && pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle;
if (isTrkVehicleAboveLimit || isPkgActiveVehicleAboveLimit) {
this.vehicles = this.vehicles.map((veh) => ({
@ -235,6 +645,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
tracking: isTrkVehicleAboveLimit ? trkVehicles.slice(0, this.trkLimit?.airCraft?.numOfVehicle).some((trkVeh) => trkVeh._id === veh._id) : veh.tracking,
pkgActive: isPkgActiveVehicleAboveLimit ? pkgActiveVehs.slice(0, this.pkgLimit?.airCraft?.numOfVehicle).some((pkgActiveVeh) => pkgActiveVeh._id === veh._id) : veh.pkgActive
}));
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles, type: SUB.AC_REVIEW }));
}
}
@ -259,7 +670,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
})
).subscribe({
error: (err) => {
console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
})
@ -306,6 +716,284 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
this.store.dispatch(new vehicleActions.Select(this.currVehicle));
}
/**
* Get partner display name for badge
*/
getPartnerDisplayName(vehicle: Vehicle): string {
// Check if vehicle has partner integration data
if (vehicle.partnerInfo?.metadata?.partnerSystem) {
return vehicle.partnerInfo.metadata.partnerSystem;
}
// Default to AgNav brand name (non-translatable) if no partner info exists
return Labels.AGNAV_BRAND_NAME;
}
/**
* Get source system for vehicle (used for SOURCE_SYSTEM column)
*/
getSourceSystem(vehicle: Vehicle): string {
return vehicle.partnerInfo?.metadata?.partnerSystem || SourceSystem.AGNAV;
}
/**
* Badge Configuration Methods (using BadgeFactoryService)
* Uses configuration-driven badge component for consistent styling
*/
/**
* Get badge configuration for partner name (system type badge)
*/
getPartnerNameBadge(vehicle: Vehicle): BadgeConfig {
const sourceSystem = this.getSourceSystem(vehicle);
const partnerName = this.getPartnerDisplayName(vehicle);
const badge = this.badgeFactory.createSystemBadge(sourceSystem, partnerName);
// Override tooltip with component-specific tooltip logic
return {
...badge,
tooltip: this.getPartnerTooltip(vehicle)
};
}
/**
* Get badge configuration for authentication status (icon only)
*/
getAuthStatusBadge(vehicle: Vehicle): BadgeConfig {
const partnerId = vehicle.partnerInfo?.partner;
const authStatus = partnerId ? this.getPartnerAuthStatus(partnerId) : null;
const badge = this.badgeFactory.createAuthStatusBadge(
authStatus?.isAuthenticated || false,
authStatus?.isValidating || false,
partnerId || null
);
// Override tooltip with component-specific tooltip logic
return {
...badge,
tooltip: this.getPartnerAuthTooltip(vehicle)
};
}
/**
* Get badge configuration for partner code (tail number badge)
*/
getPartnerCodeBadge(vehicle: Vehicle): BadgeConfig {
const badge = this.badgeFactory.createPartnerCodeBadge(vehicle.tailNumber || '');
// Override tooltip with component-specific tooltip logic
return {
...badge,
tooltip: this.getPartnerCodeTooltip(vehicle)
};
}
/**
* Get tooltip text for partner information
*/
getPartnerTooltip(vehicle: Vehicle): string {
if (!vehicle.partnerInfo?.metadata?.partnerSystem) {
return Labels.AGMISSION_NATIVE_SYSTEM;
}
const partnerName = vehicle.partnerInfo.metadata.partnerSystem;
const lastSync = vehicle.partnerInfo.metadata.lastSync
? this.formatDate(new Date(vehicle.partnerInfo.metadata.lastSync))
: Labels.NEVER;
return `${partnerName} - ${Labels.LAST_SYNC_PREFIX} ${lastSync}`;
}
/**
* Get tooltip text for partner code (tail number)
*/
getPartnerCodeTooltip(vehicle: Vehicle): string {
return `${Labels.TAIL_NUMBER_PREFIX} ${vehicle.tailNumber}`;
}
// ============================================================================
// PARTNER AUTHENTICATION STATUS METHODS
// ============================================================================
/**
* Get authentication status for a partner system (cached)
*/
getPartnerAuthStatus(partnerId: string): { isAuthenticated: boolean; isValidating: boolean; error?: string } {
const cached = this.partnerAuthCache.get(partnerId);
if (!cached) {
// Start validation for this partner if not in cache
this.schedulePartnerAuthCheck(partnerId);
return { isAuthenticated: false, isValidating: true };
}
// Return cached status
return {
isAuthenticated: cached.isAuthenticated,
isValidating: cached.isValidating,
error: cached.error
};
}
/**
* Schedule authentication check for a partner (debounced per partner)
*/
private schedulePartnerAuthCheck(partnerId: string): void {
// Mark as validating
this.partnerAuthCache.set(partnerId, {
isAuthenticated: false,
isValidating: true,
lastChecked: new Date()
});
// Clear existing timer for this specific partner
const existingTimer = this.authCheckTimers.get(partnerId);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Schedule check for this specific partner after short delay
const timer = setTimeout(() => {
this.performPartnerAuthCheck(partnerId);
}, 100);
// Store the timer for this partner
this.authCheckTimers.set(partnerId, timer);
}
/**
* Perform authentication check for a specific partner using centralized service method
*/
private async performPartnerAuthCheck(partnerId: string): Promise<void> {
try {
const currentCustomerId = this.authSvc.byPUserId;
if (!currentCustomerId) {
console.warn('No current customer ID available for partner auth check');
return;
}
// Use centralized validation method
const result = await this.partnerService.validatePartnerAuthentication(
currentCustomerId,
partnerId
);
// Cache the result with appropriate error mapping
this.partnerAuthCache.set(partnerId, {
isAuthenticated: result.isValid,
isValidating: false,
lastChecked: new Date(),
error: result.isValid ? undefined : this.mapAuthErrorMessage(result.errorMessage)
});
} catch (error) {
console.error(`Error checking partner ${partnerId} authentication:`, error);
// Cache the error
this.partnerAuthCache.set(partnerId, {
isAuthenticated: false,
isValidating: false,
lastChecked: new Date(),
error: error.message || Labels.UNKNOWN_ERROR
});
} finally {
// Clean up the timer for this partner
this.authCheckTimers.delete(partnerId);
}
}
/**
* Map authentication error messages from centralized service to user-friendly labels
*/
private mapAuthErrorMessage(errorMessage?: string): string {
if (!errorMessage) {
return Labels.AUTHENTICATION_FAILED_SHORT;
}
if (errorMessage.includes('No system users found')) {
return Labels.NO_SYSTEM_ACCOUNT_FOUND;
}
if (errorMessage.includes('credentials are missing')) {
return Labels.MISSING_CREDENTIALS;
}
if (errorMessage.includes('Authentication test failed')) {
return Labels.AUTHENTICATION_FAILED_SHORT;
}
// Default to authentication failed for any other error
return Labels.AUTHENTICATION_FAILED_SHORT;
}
/**
* Check if a partner system has valid authentication
*/
hasValidPartnerAuth(vehicle: Vehicle): boolean {
const partnerId = vehicle.partnerInfo?.partner;
if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
return true; // AgNav doesn't need external auth
}
const authStatus = this.getPartnerAuthStatus(partnerId);
return authStatus.isAuthenticated;
}
/**
* Check if a partner system is currently validating authentication
*/
isPartnerAuthValidating(vehicle: Vehicle): boolean {
const partnerId = vehicle.partnerInfo?.partner;
if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
return false; // AgNav doesn't need validation
}
const authStatus = this.getPartnerAuthStatus(partnerId);
return authStatus.isValidating;
}
/**
* Get authentication status icon class for partner
*/
getPartnerAuthIconClass(vehicle: Vehicle): string {
const partnerId = vehicle.partnerInfo?.partner;
if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
return 'ui-icon-check'; // AgNav is always valid
}
const authStatus = this.getPartnerAuthStatus(partnerId);
if (authStatus.isValidating) {
return 'pi pi-spin pi-spinner';
} else if (authStatus.isAuthenticated) {
return 'ui-icon-vpn-key';
} else {
return 'ui-icon-warning';
}
}
/**
* Get authentication status tooltip for partner
*/
getPartnerAuthTooltip(vehicle: Vehicle): string {
const partnerId = vehicle.partnerInfo?.partner;
if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
return Labels.AGMISSION_NATIVE_SYSTEM;
}
const authStatus = this.getPartnerAuthStatus(partnerId);
const partnerName = vehicle.partnerInfo?.metadata?.partnerSystem || Labels.PARTNER_SYSTEM_DEFAULT;
if (authStatus.isValidating) {
return `${partnerName}: ${Labels.VALIDATING_AUTHENTICATION}`;
} else if (authStatus.isAuthenticated) {
return `${partnerName}: ${Labels.AUTHENTICATION_VALID}`;
} else {
return `${partnerName}: ${Labels.AUTHENTICATION_FAILED_WITH_ERROR} ${authStatus.error || Labels.UNKNOWN_ERROR}`;
}
}
newVehicle() {
this.router.navigate(['.', '0'], { relativeTo: this.route });
}
@ -331,6 +1019,11 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
accept: () => {
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles }));
this.updateBtn?.nativeElement.classList.remove(HIGHLIGHT);
// Navigate to service overview if in review aircraft flow
if (this.isReviewAircraftFlow) {
this.router.navigate(['/profile/myservices']);
}
}
});
}
@ -350,15 +1043,34 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
&& !this.vendorErr;
}
isAircraftReviewStatus(): boolean {
return this.subSvc.isStatusMatchingCode(this.status, SUB.AC_REVIEW);
}
gotoMySubs() {
this.store.dispatch(new GotoMyServices());
}
/**
* Called when user confirms no aircraft changes are needed during review flow.
* Navigates directly via Router (no reload) unlike GotoMyServices which
* always calls window.location.reload() after navigation.
*/
noChangesToReview(): void {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
get canActivateVehicle() {
return this.authSvc.canActivateVehicle;
}
ngOnDestroy() {
// Clear all partner-specific authentication check timers
this.authCheckTimers.forEach((timer) => {
clearTimeout(timer);
});
this.authCheckTimers.clear();
this.store.dispatch(new ClearSubscriptionStatus());
super.ngOnDestroy();
}

View File

@ -0,0 +1,468 @@
/* Partner Integration Component Styles - AgMission Theme Compliance */
/* Host element typography foundation - AgMission standards */
:host {
font-family: "Roboto", "Helvetica Neue", sans-serif;
/* $fontFamily - AgMission standard */
line-height: 1.5;
/* $lineHeight - AgMission standard */
letter-spacing: 0.25px;
/* $letterSpacing - AgMission standard */
}
.partner-validation-section {
margin-top: 12px;
}
/* ============================================================================
INTEGRATION STEPS INDICATOR - AgMission Project Color Compliance
============================================================================ */
.integration-steps {
display: flex;
align-items: center;
margin: 16px 0 24px 0;
padding: 12px;
background: #ffffff;
/* contentBgColor - AgMission content background */
border-radius: 3px;
/* AgMission standard border radius */
border: 1px solid #bdbdbd;
/* dividerColor - AgMission borders */
}
.step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
text-align: center;
min-width: 120px;
}
.step-indicator {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
font-weight: bold;
font-size: 14px;
transition: all 0.3s ease;
border: 2px solid #bdbdbd;
/* dividerColor - AgMission neutral border */
background: #ffffff;
/* contentBgColor - AgMission white background */
color: #757575;
/* textSecondaryColor - AgMission secondary text */
font-family: "Roboto", "Helvetica Neue", sans-serif;
/* $fontFamily - AgMission standard */
line-height: 1.5;
/* $lineHeight - AgMission standard */
letter-spacing: 0.25px;
/* $letterSpacing - AgMission standard */
}
.step.active .step-indicator {
border-color: #03A9F4;
/* blue - AgMission info color */
background: #03A9F4;
/* blue - AgMission info color */
color: #ffffff;
/* primaryTextColor - white text on colored backgrounds */
}
.step.completed .step-indicator {
border-color: #4CAF50;
/* primaryColor - AgMission main green */
background: #4CAF50;
/* primaryColor - AgMission main green */
color: #ffffff;
/* primaryTextColor - white text on colored backgrounds */
}
.step-label {
font-size: 12px;
font-weight: 500;
color: #757575;
/* textSecondaryColor - AgMission secondary text */
line-height: 1.3;
max-width: 100px;
font-family: "Roboto", "Helvetica Neue", sans-serif;
/* $fontFamily - AgMission standard */
letter-spacing: 0.25px;
/* $letterSpacing - AgMission standard */
}
.step.active .step-label {
color: #03A9F4;
/* blue - AgMission info color */
}
.step.completed .step-label {
color: #4CAF50;
/* primaryColor - AgMission main green */
}
.step-connector {
flex: 0 0 auto;
height: 2px;
width: 40px;
background: #bdbdbd;
/* dividerColor - AgMission neutral */
margin: 0 8px;
border-radius: 1px;
}
.step.completed+.step-connector {
background: #4CAF50;
/* primaryColor - AgMission main green */
}
.step.active+.step-connector {
background: linear-gradient(to right, #4CAF50 50%, #bdbdbd 50%);
/* Green to neutral gradient */
}
/* ============================================================================
PARTNER SYSTEM INDICATORS - AgMission Project Color Compliance
============================================================================ */
.partner-selection-with-indicator {
display: flex;
align-items: center;
gap: 4px;
width: fit-content;
}
.partner-selection-with-indicator p-dropdown {
flex-shrink: 0;
}
.partner-selection-with-indicator p-dropdown .ui-dropdown {
margin-right: 0 !important;
}
.success-indicator {
color: #4CAF50 !important;
/* primaryColor - AgMission main green */
font-size: 1.2rem;
opacity: 1;
animation: fadeInScale 0.3s ease-in;
margin-left: 0 !important;
margin-right: 0 !important;
}
.loading-indicator {
color: #03A9F4;
/* blue - AgMission info color */
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 4px;
font-family: "Roboto", "Helvetica Neue", sans-serif;
/* $fontFamily - AgMission standard */
letter-spacing: 0.25px;
/* $letterSpacing - AgMission standard */
margin-left: 0 !important;
margin-right: 0 !important;
}
.loading-indicator i {
font-size: 1.1rem;
}
/* ============================================================================
VALIDATION LOADING INDICATOR - AgMission Project Color Compliance
============================================================================ */
.validation-loading-indicator {
color: #03A9F4 !important;
/* blue - AgMission info color */
margin-left: 0 !important;
margin-right: 0 !important;
font-size: 1.1rem;
}
/* ============================================================================
AIRCRAFT INFORMATION PANEL - AgMission Project Color Compliance
============================================================================ */
.enhanced-aircraft-info-panel {
background: linear-gradient(135deg, #E8F5E8 0%, #ffffff 100%);
/* Light green to white gradient matching AgMission success styling */
border: 1px solid #4CAF50;
/* primaryColor - AgMission main green */
border-radius: 3px;
/* AgMission standard border radius - matches constraint-message */
padding: 12px 16px;
/* Matches constraint-message content padding */
margin: 12px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
/* Matches constraint-message shadow */
transition: all 0.3s ease-in-out;
/* Matches constraint-message transition */
position: relative;
overflow: hidden;
}
.enhanced-aircraft-info-panel:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
/* Matches constraint-message hover shadow */
transform: translateY(-1px);
/* Matches constraint-message hover effect */
}
.aircraft-info-content {
display: flex;
align-items: flex-start;
gap: 12px;
/* Matches constraint-message content gap */
}
.aircraft-info-icon {
font-size: 1.125rem;
/* Matches constraint-message icon size: 18px */
color: #4CAF50;
/* primaryColor - AgMission main green */
margin-top: 2px;
/* Matches constraint-message icon alignment */
flex-shrink: 0;
}
.aircraft-info-text {
flex: 1;
min-width: 0;
/* Matches constraint-message text container */
}
.aircraft-info-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 8px;
}
.aircraft-info-title {
font-size: 0.875rem;
/* 14px - matches constraint-message title size */
font-weight: 500;
/* Matches constraint-message title weight */
color: #2E7D32;
/* primaryDarkColor - darker green for headers */
font-family: "Roboto", "Helvetica Neue", sans-serif;
/* $fontFamily - AgMission standard */
line-height: 1.5;
/* Matches constraint-message line height */
letter-spacing: 0.25px;
/* $letterSpacing - AgMission standard */
margin-bottom: 4px;
/* Matches constraint-message title margin */
}
.aircraft-details {
display: flex;
flex-direction: column;
gap: 6px;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
/* 13px - matches constraint-message description size */
line-height: 1.5;
/* Matches constraint-message line height */
font-family: "Roboto", "Helvetica Neue", sans-serif;
/* $fontFamily - AgMission standard */
letter-spacing: 0.25px;
/* $letterSpacing - AgMission standard */
color: #212121;
/* textColor - matches constraint-message description */
margin: 0;
word-wrap: break-word;
/* Matches constraint-message description */
}
.detail-row strong {
color: #2E7D32;
/* primaryDarkColor - darker green for labels */
font-weight: 500;
min-width: 80px;
}
.system-type-row {
align-items: flex-start;
}
.system-type-value {
color: #4CAF50;
/* primaryColor - AgMission success color for selected system type */
font-weight: 600;
/* Bold text for emphasis */
}
.system-type-pending {
color: #f39c12;
/* Warning color */
font-style: italic;
display: flex;
align-items: center;
gap: 4px;
}
/* ============================================================================
DISABLED PREVIEW STYLING
============================================================================ */
.preview-disabled {
opacity: 0.6;
}
/* ============================================================================
ANIMATIONS
============================================================================ */
@keyframes fadeInScale {
0% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* ============================================================================
RESPONSIVE DESIGN - Mobile and Tablet
============================================================================ */
@media (max-width: 768px) {
.integration-steps {
flex-direction: column;
gap: 16px;
padding: 16px 12px;
}
.step {
min-width: auto;
flex-direction: row;
align-items: center;
gap: 12px;
justify-content: flex-start;
text-align: left;
}
.step-indicator {
margin-bottom: 0;
width: 28px;
height: 28px;
font-size: 12px;
}
.step-label {
font-size: 14px;
max-width: none;
}
.step-connector {
display: none;
}
.enhanced-aircraft-info-panel {
padding: 10px 12px;
/* Matches constraint-message mobile padding */
margin: 8px 0;
/* Matches constraint-message mobile margin */
max-width: 100%;
/* Matches constraint-message mobile width */
}
.aircraft-info-content {
gap: 10px;
/* Matches constraint-message mobile gap */
}
.aircraft-info-icon {
font-size: 1rem;
/* 16px - matches constraint-message mobile icon size */
}
.aircraft-info-title {
font-size: 0.8125rem;
/* 13px - matches constraint-message mobile title size */
}
.detail-row {
font-size: 0.75rem;
/* 12px - matches constraint-message mobile description size */
}
}
@media (max-width: 480px) {
.integration-steps {
padding: 12px 8px;
}
.step-indicator {
width: 24px;
height: 24px;
font-size: 11px;
}
.step-label {
font-size: 13px;
}
.partner-selection-with-indicator {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.enhanced-aircraft-info-panel {
padding: 10px;
margin: 8px 0;
}
.aircraft-info-icon {
font-size: 1rem;
/* Consistent with tablet size */
}
.aircraft-info-title {
font-size: 0.75rem;
/* Smaller for mobile screens */
}
.detail-row {
font-size: 0.7rem;
/* Smaller for mobile screens */
}
}
/* ============================================================================
INPUT FIELD WITH INLINE CONSTRAINT - Detached Mode Pattern
============================================================================ */
.input-with-inline-constraint {
display: flex;
align-items: center;
gap: 8px;
}
.input-with-inline-constraint label {
white-space: nowrap;
}
/* Position the constraint trigger button closer to dropdown */
.input-with-inline-constraint ::ng-deep .agm-constraint-trigger {
margin-right: 32px;
}

View File

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

View File

@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class VendorGuard implements CanActivate {
canActivate(
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return true;
}
}

View File

@ -0,0 +1,393 @@
/* Job Assignment Component Styles - AgMission Theme Compliance */
/* Host element typography foundation - AgMission standards */
/* These properties cascade to all child elements, reducing repetition */
:host {
font-family: "Roboto", "Helvetica Neue", sans-serif;
/* $fontFamily - AgMission standard */
line-height: 1.5;
/* $lineHeight - AgMission standard */
letter-spacing: 0.25px;
/* $letterSpacing - AgMission standard */
}
.job-assignment-container {
margin-top: 20px;
}
/* Aircraft Item Styling */
.aircraft-item {
display: flex;
align-items: center;
border-bottom: 1px solid #bdbdbd;
/* $dividerColor */
position: relative;
}
.aircraft-icon {
color: #03A9F4;
/* $blue - info states */
font-size: 16px;
}
.aircraft-name {
flex: 1;
font-weight: 500;
cursor: pointer;
}
/* Aircraft Tooltip Styling */
:host ::ng-deep .aircraft-tooltip-enhanced {
max-width: 350px;
white-space: pre-line;
line-height: 1.4;
font-size: 13px;
background: #2E7D32;
/* $primaryDarkColor */
color: #ffffff;
/* $primaryTextColor */
border: 1px solid #4CAF50;
/* $primaryColor */
border-radius: 3px;
/* AgMission standard border radius */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 12px 14px;
}
:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-text {
background: transparent;
color: inherit;
border: none;
padding: 0;
}
:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-arrow::before {
border-top-color: #2E7D32;
/* $primaryDarkColor */
}
.aircraft-details {
margin-top: 4px;
font-size: 0.85rem;
color: #757575;
/* $textSecondaryColor */
}
.sync-status {
margin-left: 8px;
}
/* Download Options Info */
.download-options-info {
display: flex;
align-items: center;
margin-top: 4px;
font-size: 0.85rem;
color: #757575;
/* $textSecondaryColor */
}
.download-options-info .pi {
margin-right: 4px;
color: #4CAF50;
/* $primaryColor - success indicator */
}
/* Assignment Status Styling */
.assignment-status-section {
background: #ffffff;
/* $contentBgColor */
border-radius: 3px;
/* AgMission standard border radius */
padding: 16px;
border: 1px solid #bdbdbd;
/* $dividerColor */
}
.assignment-status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.assignment-status-header h4 {
font-size: 1.25rem;
font-weight: 600;
color: #212121;
/* $textColor - matches other page labels */
margin: 0;
}
.assignment-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.status-control-btn,
.clear-status-btn {
padding: 8px 12px !important;
font-size: 14px !important;
min-width: 44px !important;
min-height: 44px !important;
border-radius: 3px !important;
/* AgMission standard border radius */
}
.status-control-btn:focus,
.clear-status-btn:focus {
outline: 2px solid #03A9F4;
/* $blue - info states */
outline-offset: 2px;
}
.polling-status-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: #E1F5FE;
/* Light blue background for info */
border: 1px solid #03A9F4;
/* $blue */
border-radius: 3px;
/* AgMission standard border radius */
margin-bottom: 12px;
font-size: 0.95rem;
color: #0277BD;
/* $blueHover */
}
.assignment-progress {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: #FFF8E1;
/* Light amber background for progress */
border: 1px solid #FFC107;
/* $amber */
border-radius: 3px;
/* AgMission standard border radius */
margin-bottom: 12px;
font-weight: 600;
font-size: 0.95rem;
color: #FF8F00;
/* $amberHover */
}
.assignment-error-summary {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: #FFEBEE;
/* Light red background for error */
border: 1px solid #F44336;
/* $red */
border-radius: 3px;
/* AgMission standard border radius */
margin-bottom: 12px;
color: #C62828;
/* $redHover */
font-weight: 600;
font-size: 0.95rem;
}
/* Assignment Status Table Styling */
.assignment-status-table {
margin-top: 12px;
border-radius: 3px;
/* AgMission standard border radius */
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.assignment-status-table .ui-table-thead th {
background-color: #e8e8e8;
/* $hoverBgColor */
border-bottom: 2px solid #bdbdbd;
/* $dividerColor */
color: #212121;
/* $textColor */
font-weight: 600;
font-size: 0.9rem;
padding: 12px 8px;
}
.assignment-status-table .ui-table-tbody>tr {
border-left: 4px solid transparent;
transition: background-color 0.2s ease;
}
.assignment-status-table .ui-table-tbody>tr:hover {
background-color: #e8e8e8;
/* $hoverBgColor */
}
.assignment-status-table .ui-table-tbody>tr>td {
padding: 12px 8px;
font-size: 0.9rem;
border-bottom: 1px solid #bdbdbd;
/* $dividerColor */
}
.assignment-status-table .status-row-new {
border-left-color: #4527A0;
/* $accentDarkColor - new assignments */
}
.assignment-status-table .status-row-downloaded {
border-left-color: #f9a825;
/* $accentLightColor - downloaded assignments */
}
.assignment-status-table .status-row-uploaded {
border-left-color: #2E7D32;
/* $primaryDarkColor - uploaded/completed assignments */
}
.assignment-status-table .status-row-error {
border-left-color: #F44336;
/* Semantic red - error states */
}
.aircraft-cell {
display: flex;
align-items: center;
gap: 8px;
}
.aircraft-cell .pi {
font-size: 16px;
color: #03A9F4;
/* $blue - info states */
}
.aircraft-cell .aircraft-name {
font-weight: 600;
color: #212121;
/* $textColor */
}
/* Status message and error details - AgMission Typography */
.status-message {
font-weight: 500;
margin-top: 6px;
color: #212121;
/* $textColor */
font-size: 0.9rem;
}
.status-error-details {
margin-top: 6px;
color: #757575;
/* $textSecondaryColor */
font-size: 0.85rem;
font-style: italic;
}
.status-timestamp {
font-size: 0.9rem;
color: #757575;
/* $textSecondaryColor */
font-weight: 500;
}
.status-actions {
display: flex;
justify-content: center;
align-items: center;
}
.status-indicator-text {
display: flex;
align-items: center;
gap: 6px;
color: #757575;
/* $textSecondaryColor */
font-size: 0.9rem;
font-weight: 500;
}
.assignment-action-button {
font-size: 12px !important;
min-height: 32px !important;
}
.empty-message {
text-align: center;
padding: 24px;
color: #757575;
/* $textSecondaryColor */
font-style: italic;
font-size: 1rem;
}
/* Screen reader only content */
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* Focus improvements for accessibility */
.assignment-status-table tbody tr:focus-within {
outline: 2px solid #03A9F4;
/* $blue - info states */
outline-offset: 2px;
}
/* Responsive design */
@media (max-width: 768px) {
.assignment-status-section {
padding: 12px;
}
.assignment-status-table .ui-table-thead {
display: none;
}
.assignment-status-table .ui-table-tbody>tr>td {
display: block;
border: none;
border-bottom: 1px solid #bdbdbd;
/* $dividerColor */
padding: 12px 8px;
font-size: 0.9rem;
}
.assignment-status-table .ui-table-tbody>tr>td:before {
content: attr(data-label) ": ";
font-weight: 600;
display: inline-block;
width: 120px;
color: #212121;
/* $textColor */
}
.assignment-header-actions {
flex-wrap: wrap;
gap: 6px;
}
.status-control-btn,
.clear-status-btn {
padding: 6px 10px !important;
font-size: 12px !important;
min-width: 40px !important;
min-height: 40px !important;
}
.polling-status-indicator {
padding: 8px 12px;
font-size: 0.85rem;
}
}

View File

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

View File

@ -0,0 +1,822 @@
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
import { Observable, Subject, timer, interval } from 'rxjs';
import { switchMap, takeUntil, retryWhen, delayWhen, startWith } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { MenuItem, SelectItem } from 'primeng/api';
import { IUIJob } from '../models/job.model';
import * as fromEntity from '@app/entities/reducers';
import * as jobActions from '../actions/job.actions';
import { JobService } from '@app/domain/services/job.service';
import { PartnerService } from '@app/partners/services/partner.service';
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
import { BadgeFactoryService } from '@app/shared/services/badge-factory.service';
import { AuthService } from '@app/domain/services/auth.service';
import { AircraftAssignmentItem } from '@app/entities/models/vehicle.model';
import { Partner } from '@app/partners/models/partner.model';
import { BadgeConfig } from '@app/shared/badge/badge-config.model';
import { BaseComp } from '@app/shared/base/base.component';
import { SourceSystem, OperationalStatus, AssignStatus, AssignStatusType, Labels, globals, KnownPartnerCodes, SystemOrPartnerType } from '@app/shared/global';
// ============================================================================
// INTERFACES
// ============================================================================
// Assignment Status Tracking Interface
interface AssignmentStatus {
aircraftId: string;
aircraftName: string;
sourceSystem: SystemOrPartnerType; // Track source system for badge display
state: AssignStatusType; // Using AssignStatus values
message: string;
timestamp: Date;
errorDetails?: string;
}
/**
* Job Assignment Component
*
* Supports both AgNav and partner aircraft assignment to jobs.
*
* Partner Info Response Structure (from assignments_post):
* - AgNav vehicles: No partnerInfo field
* - Partner vehicles: { partnerInfo: { name: "satloc", partnerCode: "SATLOC" } }
*/
@Component({
selector: 'agm-job-assignment',
templateUrl: './job-assignment.component.html',
styleUrls: ['./job-assignment.component.css']
})
export class JobAssignmentComponent extends BaseComp implements OnInit, OnDestroy {
// Template readonly objects for direct usage
readonly SourceSystem = SourceSystem;
readonly KnownPartnerCodes = KnownPartnerCodes;
readonly OperationalStatus = OperationalStatus;
readonly Labels = Labels;
// Inputs from parent component
@Input() job: IUIJob;
@Input() isArchived: boolean = false;
@Input() canDownload: boolean = false;
@Input() dlOps: SelectItem[] = [];
// Outputs to parent component
@Output() assignmentComplete = new EventEmitter<any>();
// Assignment-related properties
srcUsers: AircraftAssignmentItem[] = [];
tarUsers: AircraftAssignmentItem[] = [];
allAircraft: AircraftAssignmentItem[] = []; // Store all aircraft data
// Assignment Status Tracking
assignmentStatuses: AssignmentStatus[] = [];
isAssignmentInProgress = false;
assignmentErrorMsg: string | null = null;
// Assignment Status Polling
isPollingAssignments = false;
private stoppedAssignmentPoll: Subject<boolean>;
// Partner caching for performance
private partnersCache = new Map<string, Partner>();
constructor(
protected store: Store<fromEntity.EntityState>,
private jobSvc: JobService,
private partnerSvc: PartnerService,
private partnerUtils: PartnerUtilsService,
private badgeFactory: BadgeFactoryService,
protected authSvc: AuthService,
private cdr: ChangeDetectorRef
) {
super(cdr);
}
ngOnInit(): void {
this.stoppedAssignmentPoll = new Subject();
// Load partners for aircraft display
this.loadPartners();
// Load aircraft data
this.loadAircraftData();
}
ngOnDestroy(): void {
// Stop assignment status polling
this.stopAssignmentStatusPolling();
super.ngOnDestroy();
}
/**
* Load partners and cache them for performance
*/
private loadPartners(): void {
this.partnerSvc.getPartners().subscribe({
next: (partners) => {
this.partnersCache.clear();
partners.forEach(partner => {
this.partnersCache.set(partner._id, partner);
});
// Refresh aircraft data now that partners are loaded
this.refreshAircraftDisplay();
},
error: (error) => {
console.error(globals.consoleFailedToLoadPartners, error);
}
});
}
/**
* Get partner from cache by ID
*/
private getPartner(partnerId: string): Partner | null {
return this.partnersCache.get(partnerId) || null;
}
/**
* Load aircraft data from backend API for job assignment
*/
private loadAircraftData(): void {
if (!this.job || !this.job._id) {
return;
}
// Use backend API to get assignment data with partnerInfo
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
next: (assignmentData) => {
this.updateAircraftAssignmentData(assignmentData);
},
error: (error) => {
console.error(globals.consoleFailedToLoadExistingAssignments, error);
}
});
}
/**
* Update aircraft assignment data from backend API response
*/
private updateAircraftAssignmentData(assignmentData: any): void {
// Process available users
let availableAircraft: AircraftAssignmentItem[] = [];
if (assignmentData.avUsers && assignmentData.avUsers.length > 0) {
availableAircraft = assignmentData.avUsers.map(user => this.convertBackendUserToAssignmentItem(user));
}
// Process assigned users
let assignedAircraft: AircraftAssignmentItem[] = [];
if (assignmentData.asUsers && assignmentData.asUsers.length > 0) {
assignedAircraft = assignmentData.asUsers.map(user => this.convertBackendUserToAssignmentItem(user));
}
// Combine all aircraft for sorting
this.allAircraft = this.sortAircraftBySource([...availableAircraft, ...assignedAircraft]);
// Set source users (available) and target users (assigned)
this.srcUsers = availableAircraft;
this.tarUsers = assignedAircraft;
// Always update assignment status to reflect current state (including when empty)
this.updateAssignmentStatus(assignmentData);
// Start polling if there are assigned aircraft to track
if (assignedAircraft.length > 0 && !this.isPollingAssignments) {
this.startAssignmentStatusPolling();
} else if (assignedAircraft.length === 0 && this.isPollingAssignments) {
// Stop polling if no assignments
this.stopAssignmentStatusPolling();
}
}
/**
* Refresh aircraft display after partners are loaded
*/
private refreshAircraftDisplay(): void {
// Reload aircraft data from backend API now that partners are cached
// This ensures partner names are resolved correctly
if (!this.job || !this.job._id) {
return;
}
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
next: (assignmentData) => {
this.updateAircraftAssignmentData(assignmentData);
// Trigger change detection to update the UI with partner names
this.cdr.detectChanges();
},
error: (error) => {
console.error(globals.consoleFailedToLoadExistingAssignments, error);
}
});
}
/**
* Convert backend user object (from assignments API) to AircraftAssignmentItem
*/
private convertBackendUserToAssignmentItem(user: any): AircraftAssignmentItem {
// Determine partner information from partnerInfo
let partnerId: string | undefined;
let partnerName: string | undefined;
let partnerCode: string | undefined;
let sourceSystem: SystemOrPartnerType = SourceSystem.AGNAV; // default
// Check for partnerInfo.partnerCode (from assignments_post response)
if (user.partnerInfo?.partnerCode) {
partnerCode = user.partnerInfo.partnerCode;
partnerName = user.partnerInfo.name;
// Find partner by partnerCode to get partner ID
const partner = Array.from(this.partnersCache.values()).find(p =>
p.partnerCode?.toUpperCase() === partnerCode!.toUpperCase()
);
if (partner) {
partnerId = partner._id;
// Use partner ID as sourceSystem for all partner aircraft
sourceSystem = partnerId as SystemOrPartnerType;
}
}
const assignmentItem: AircraftAssignmentItem = {
_id: user.uid, // Backend returns uid instead of _id
name: user.name,
active: user.active || false,
pkgActive: user.pkgActive || false,
tailNumber: user.tailNumber,
username: user.username, // Add username for tooltip display
partnerSystem: sourceSystem,
sourceSystem: sourceSystem,
partnerId: partnerId,
partnerName: partnerName,
partnerCode: partnerCode
};
// Add partner-specific data for all partner aircraft (not just SATLOC)
if (!this.partnerUtils.isNativeSystem(sourceSystem)) {
const partnerObj = partnerId ? this.getPartner(partnerId) : null;
// For backward compatibility, keep satlocData for SATLOC aircraft
if (partnerObj && this.partnerUtils.isSatlocPartner(partnerObj)) {
assignmentItem.satlocData = {
tailNumber: user.tailNumber || Labels.N_A,
syncStatus: user.partnerInfo?.metadata?.syncStatus || OperationalStatus.PENDING
};
}
}
return assignmentItem;
}
/**
* Get partner display name for aircraft
*/
getPartnerDisplayName(aircraft: AircraftAssignmentItem): string {
if (aircraft.partnerName) {
return aircraft.partnerName;
}
return Labels.AGNAV_BRAND_NAME; // Use consistent non-translatable brand name
}
/**
* Get partner display name from source system (for assignment status table)
*/
getPartnerDisplayNameFromSource(sourceSystem: SystemOrPartnerType): string {
if (this.partnerUtils.isNativeSystem(sourceSystem)) {
return Labels.AGNAV_BRAND_NAME;
}
const partner = this.getPartner(sourceSystem);
return partner ? partner.name : sourceSystem.toString();
}
/**
* Get simplified tooltip text for aircraft
* - For AgNav: name <br> username
* - For Partner: partner name <br> tailNumber
* - Adds warning if package is not active
*/
getAircraftTooltip(aircraft: AircraftAssignmentItem): string {
let tooltip = '';
// For AgNav aircraft: show name and username
if (aircraft.sourceSystem === SourceSystem.AGNAV) {
if (aircraft.username) {
tooltip = `${aircraft.name}<br>${aircraft.username}`;
} else {
tooltip = aircraft.name;
}
} else {
// For Partner aircraft: show partner name and tail number
const partnerName = this.getPartnerDisplayName(aircraft);
if (aircraft.tailNumber) {
tooltip = `${partnerName}<br>${aircraft.tailNumber}`;
} else {
tooltip = partnerName;
}
}
// Add package inactive warning if applicable
if (!aircraft.pkgActive) {
tooltip += `<br><span style="color: orange;">⚠️ ${Labels.PACKAGE_INACTIVE}</span>`;
}
return tooltip;
}
/**
* Check if aircraft can be assigned to job
*/
canAssignAircraft(aircraft: AircraftAssignmentItem): boolean {
// Only check package status - no authentication constraints
return aircraft.pkgActive === true;
}
// ============================================================================
// AIRCRAFT SORTING UTILITIES
// ============================================================================
/**
* Sort aircraft by source system (AgNav first, then all partners alphabetically)
*/
private sortAircraftBySource(aircraft: AircraftAssignmentItem[]): AircraftAssignmentItem[] {
return aircraft.sort((a, b) => {
// Primary sort: AgNav first, then all partner systems
const aIsNative = this.partnerUtils.isNativeSystem(a.sourceSystem);
const bIsNative = this.partnerUtils.isNativeSystem(b.sourceSystem);
if (aIsNative && !bIsNative) return -1; // AgNav comes first
if (!aIsNative && bIsNative) return 1; // AgNav comes first
// If both are partners or both are native, sort by partner name then aircraft name
if (!aIsNative && !bIsNative) {
// Both are partners - sort by partner name first
const aPartnerName = a.partnerName || Labels.UNKNOWN_PARTNER;
const bPartnerName = b.partnerName || Labels.UNKNOWN_PARTNER;
const partnerCompare = aPartnerName.localeCompare(bPartnerName);
if (partnerCompare !== 0) return partnerCompare;
}
// Secondary sort: Alphabetical by aircraft name within each source group
return a.name.localeCompare(b.name);
});
}
/**
* Main assignment method - handles both AgNav and partner aircraft
*/
assignJob(): void {
if (!this.job) {
return;
}
// Transform aircraft data to backend API format
// Backend expects 'uid' property, but frontend model uses '_id'
const formattedAsUsers = this.tarUsers.map(aircraft => {
// Base assignment data - ALL aircraft need uid for backend
const assignmentData: any = {
uid: aircraft._id, // Required for all aircraft types - backend uses this for 'user' field
name: aircraft.name
};
// Add partner-specific data for Satloc aircraft
if (aircraft.sourceSystem === KnownPartnerCodes.SATLOC && aircraft.satlocData) {
assignmentData.partnerAircraftId = aircraft.satlocData.satlocId || aircraft._id;
assignmentData.notes = `${Labels.SATLOC_AIRCRAFT_PREFIX} ${aircraft.satlocData.tailNumber}`;
assignmentData.jobName = this.job.name;
}
return assignmentData;
});
const formattedAvUsers = this.srcUsers.map(aircraft => ({
uid: aircraft._id,
name: aircraft.name
}));
const assignment: jobActions.AssignInfo = {
jobId: this.job._id,
dlOp: this.job.dlOp,
avUsers: formattedAvUsers,
asUsers: formattedAsUsers
};
this.store.dispatch(new jobActions.Assign(assignment));
// Start polling immediately if assigning aircraft (to track assignment progress)
if (formattedAsUsers.length > 0 && !this.isPollingAssignments) {
this.startAssignmentStatusPolling();
}
}
// ============================================================================
// ASSIGNMENT STATUS POLLING
// ============================================================================
/**
* Start polling assignment status from backend API
*/
private startAssignmentStatusPolling(): void {
if (this.isPollingAssignments) {
return; // Already polling
}
this.isPollingAssignments = true;
this.stoppedAssignmentPoll.next(false);
const polling$ = this.pollAssignmentStatus().subscribe({
next: (assignmentData) => {
this.updateAssignmentStatus(assignmentData);
},
error: (error) => {
console.error(globals.consoleAssignmentStatusPollingError, error);
this.isPollingAssignments = false;
// Reset assignment progress flag on polling error to prevent UI from being stuck
if (this.isAssignmentInProgress) {
this.isAssignmentInProgress = false;
}
}
});
// Add subscription to component's subscription manager
this.sub$.add(polling$);
}
private stopAssignmentStatusPolling(): void {
if (this.stoppedAssignmentPoll) {
this.stoppedAssignmentPoll.next(true);
this.isPollingAssignments = false;
}
}
private pollAssignmentStatus(): Observable<any> {
return interval(10000).pipe( // Poll every 10 seconds for real status updates
startWith(1000), // Start after 1 second
switchMap(() => this.jobSvc.getAssignments({ 'jobId': this.job._id })),
takeUntil(this.stoppedAssignmentPoll),
retryWhen(errors =>
errors.pipe(
delayWhen(val => timer(15 * 1000)) // Retry after 15 seconds on error
)
)
);
}
private updateAssignmentStatus(assignmentData: any): void {
// Clear assignment statuses if no assigned users (all unassigned)
if (!assignmentData || !assignmentData.asUsers || assignmentData.asUsers.length === 0) {
this.assignmentStatuses = [];
return;
}
// Update assignment statuses with real assignment data
this.assignmentStatuses = assignmentData.asUsers.map((assignedAircraft) => {
const existingStatus = this.assignmentStatuses.find(s => s.aircraftId === assignedAircraft.uid);
// Use the assignStatus field from the backend data
const backendStatus = assignedAircraft.assignStatus !== undefined ? assignedAircraft.assignStatus : AssignStatus.NEW;
// Generate status object from real assignment data
const statusUpdate = this.generateAssignmentStatusFromBackend(assignedAircraft, backendStatus);
// If there's existing status, preserve timestamp if status hasn't changed
if (existingStatus) {
return {
...statusUpdate,
// Preserve timestamp if status hasn't changed
timestamp: existingStatus.state === statusUpdate.state ? existingStatus.timestamp : new Date()
};
}
return statusUpdate;
});
// Check if all assignments have completed (no longer in NEW/pending status)
this.checkAssignmentProgress();
}
/**
* Check assignment progress and update isAssignmentInProgress flag
*/
private checkAssignmentProgress(): void {
if (this.assignmentStatuses.length === 0) {
// No assignments to track
this.isAssignmentInProgress = false;
return;
}
// Check if any assignments are still in NEW (pending) status
const hasNewAssignments = this.assignmentStatuses.some(status => status.state === AssignStatus.NEW);
if (!hasNewAssignments && this.isAssignmentInProgress) {
// All assignments have completed (no longer in NEW status)
this.isAssignmentInProgress = false;
}
}
private generateAssignmentStatusFromBackend(assignedAircraft: any, backendStatus: number): AssignmentStatus {
const statusMessages = {
[AssignStatus.NEW]: globals.assignmentInProgress,
[AssignStatus.DOWNLOADED]: globals.assignmentDownloaded,
[AssignStatus.UPLOADED]: globals.assignmentCompleted,
[AssignStatus.ERROR]: globals.assignmentFailed
};
// Find the aircraft in tarUsers or allAircraft to get sourceSystem
const aircraft = this.tarUsers.find(a => a._id === assignedAircraft.uid) ||
this.allAircraft.find(a => a._id === assignedAircraft.uid);
const sourceSystem = aircraft?.sourceSystem || SourceSystem.AGNAV;
return {
aircraftId: assignedAircraft.uid,
aircraftName: assignedAircraft.name,
sourceSystem: sourceSystem,
state: backendStatus as AssignStatusType,
message: statusMessages[backendStatus] || globals.unknownStatus,
timestamp: new Date(),
// Use actual error details from backend if available
errorDetails: backendStatus === AssignStatus.ERROR ? assignedAircraft.errorDetails : undefined
};
}
// ============================================================================
// AIRCRAFT SELECTION HANDLERS
// ============================================================================
/**
* Handle aircraft selection/click - validate package status only
*/
async onAircraftSelect(aircraft: AircraftAssignmentItem, event: Event): Promise<void> {
// Only validate if this is in the source list (available aircraft)
const isInSourceList = this.srcUsers.some(ac => ac._id === aircraft._id);
if (!isInSourceList) {
return;
}
// Check package status (applies to all aircraft)
if (!aircraft.pkgActive) {
// Package not enabled - visual feedback already provided via red highlighting and tooltip
return;
}
// No authentication validation - allow all aircraft with active packages to be assigned
}
/**
* Aircraft movement event handlers
*/
async onMoveToTarget(event: any): Promise<void> {
// Get the aircraft that were just moved
const movedAircraft = event.items || [];
// Validate each moved aircraft for package status only
for (const aircraft of movedAircraft) {
let shouldMoveBack = false;
let reason = '';
// Check package active status - only constraint remaining
if (!aircraft.pkgActive) {
shouldMoveBack = true;
reason = Labels.PACKAGE_NOT_ENABLED_REASON;
}
// Move aircraft back to source if validation failed
if (shouldMoveBack) {
const aircraftIndex = this.tarUsers.findIndex(ac => ac._id === aircraft._id);
if (aircraftIndex !== -1) {
this.tarUsers.splice(aircraftIndex, 1);
this.srcUsers.push(aircraft);
}
}
}
// Apply sorting to maintain AgNav-first order
this.tarUsers = this.sortAircraftBySource(this.tarUsers);
this.srcUsers = this.sortAircraftBySource(this.srcUsers);
}
onMoveToSource(event: any): void {
// Apply sorting to maintain AgNav-first order
this.srcUsers = this.sortAircraftBySource(this.srcUsers);
this.tarUsers = this.sortAircraftBySource(this.tarUsers);
}
/**
* UI Helper Methods (unified badge system)
* Uses BadgeFactoryService to create configuration-driven badges
*/
/**
* Get badge configuration for aircraft source system (picklist)
*/
getAircraftSystemBadge(aircraft: AircraftAssignmentItem): BadgeConfig {
return this.badgeFactory.createSystemBadge(
aircraft.sourceSystem,
this.getPartnerDisplayName(aircraft)
);
}
/**
* Get badge configuration for assignment status source system (status table)
*/
getStatusSystemBadge(sourceSystem: SystemOrPartnerType): BadgeConfig {
return this.badgeFactory.createSystemBadge(
sourceSystem,
this.getPartnerDisplayNameFromSource(sourceSystem)
);
}
/**
* Get badge configuration for assignment status (status table)
*/
getAssignmentStatusBadge(status: AssignmentStatus): BadgeConfig {
return this.badgeFactory.createAssignmentStatusBadge(
status.state,
status.message
);
}
getRefreshStatusTooltip(): string {
return Labels.MANUALLY_REFRESH_ASSIGNMENT_STATUS;
}
getAssignButtonTooltip(): string {
if (this.isArchived) {
return Labels.ASSIGN_BUTTON_ARCHIVED_TOOLTIP;
}
if (!this.canDownload) {
return Labels.ASSIGN_BUTTON_NO_BOUNDARY_TOOLTIP;
}
return Labels.ASSIGN_BUTTON_READY_TOOLTIP;
}
getPickListSourceTooltip(): string {
return Labels.PICK_LIST_SOURCE_TOOLTIP;
}
getPickListTargetTooltip(): string {
return Labels.PICK_LIST_TARGET_TOOLTIP;
}
getDownloadOptionsTooltip(): string {
return Labels.DOWNLOAD_OPTIONS_DROPDOWN_TOOLTIP;
}
getStatusTableTooltip(): string {
return Labels.ASSIGNMENT_STATUS_TABLE_TOOLTIP;
}
getStatusIconTooltip(status: AssignmentStatus): string {
switch (status.state) {
case AssignStatus.NEW:
return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP;
case AssignStatus.DOWNLOADED:
return Labels.ASSIGNMENT_STATUS_DOWNLOADED_TOOLTIP;
case AssignStatus.UPLOADED:
return Labels.ASSIGNMENT_STATUS_UPLOADED_TOOLTIP;
case AssignStatus.ERROR:
return Labels.ASSIGNMENT_STATUS_ERROR_TOOLTIP;
default:
return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP; // Default to new/pending
}
}
/**
* Assignment Status UI Methods
*/
refreshAssignmentStatus(): void {
if (!this.job || !this.job._id) {
console.warn(globals.consoleCannotRefreshAssignmentStatus);
return;
}
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
next: (assignmentData) => {
// Update both assignment status AND aircraft lists
this.updateAircraftAssignmentData(assignmentData);
},
error: (error) => {
console.error(globals.consoleFailedToRefreshAssignmentStatus, error);
this.msgSvc.addFailedMsg(globals.failedToRefreshAssignmentStatus);
}
});
}
/**
* Assignment Status Action Methods
*/
getUnifiedActionOptions(status: AssignmentStatus): MenuItem[] {
const commonActions = [
{
label: globals.clearStatus,
icon: 'ui-icon-clear',
command: () => this.clearSingleStatus(status.aircraftId)
}
];
if (status.state === AssignStatus.ERROR) {
return [
...commonActions,
{
separator: true
},
{
label: globals.resetToAvailable,
icon: 'ui-icon-arrow-back',
command: () => this.resetAircraftToAvailable(status.aircraftId)
}
];
}
if (status.state === AssignStatus.UPLOADED) {
return [
...commonActions
];
}
// Default actions for any other states
return commonActions;
}
clearSingleStatus(aircraftId: string): void {
this.assignmentStatuses = this.assignmentStatuses.filter(
status => status.aircraftId !== aircraftId
);
}
resetAircraftToAvailable(aircraftId: string): void {
// Find the aircraft in assigned list
const aircraft = this.tarUsers.find(a => a._id === aircraftId);
if (!aircraft) return;
// Move aircraft back to available list
this.tarUsers = this.tarUsers.filter(a => a._id !== aircraftId);
// Add to source list only if package active, meets auth requirements, and not already there
// Partner aircraft: Only require active package
// Native aircraft: Require active package AND credentials (matches backend filter: username: { $nin: [null, ''] })
const isPartner = !this.partnerUtils.isNativeSystem(aircraft.sourceSystem);
const hasCredentials = aircraft.username && aircraft.username !== '';
const meetsAuthRequirements = isPartner || hasCredentials;
if (aircraft.pkgActive === true && meetsAuthRequirements && !this.srcUsers.find(a => a._id === aircraftId)) {
this.srcUsers.push(aircraft);
// Apply sorting to maintain AgNav-first order
this.srcUsers = this.sortAircraftBySource(this.srcUsers);
}
// Clear the status
this.clearSingleStatus(aircraftId);
}
/**
* Status helper methods for template
*/
getStatusIcon(status: AssignmentStatus): string {
switch (status.state) {
case AssignStatus.NEW:
return 'pi-spin pi-spinner';
case AssignStatus.DOWNLOADED:
return 'pi-download';
case AssignStatus.UPLOADED:
return 'pi-check-circle';
case AssignStatus.ERROR:
return 'pi-times-circle';
default:
return 'pi-question-circle';
}
}
isStatusNew(status: AssignmentStatus): boolean {
return status.state === AssignStatus.NEW;
}
/**
* Check if a specific aircraft's assignment is in progress
*/
isAircraftAssignmentInProgress(status: AssignmentStatus): boolean {
return status.state === AssignStatus.NEW;
}
getStatusCssClass(status: AssignmentStatus): string {
switch (status.state) {
case AssignStatus.NEW:
return 'new';
case AssignStatus.DOWNLOADED:
return 'downloaded';
case AssignStatus.UPLOADED:
return 'uploaded';
case AssignStatus.ERROR:
return 'error';
default:
return 'unknown';
}
}
// ============================================================================
}

View File

@ -1,3 +1,732 @@
.sprayed-value {
margin-top: .25em;
}
/* Aircraft Item Layout */
.aircraft-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 4px;
min-height: 40px;
}
.aircraft-name {
flex: 1;
margin-right: 8px;
font-weight: 500;
}
.aircraft-icon {
color: #007ad9;
font-size: 14px;
}
/* Satloc-specific Details */
.satloc-details {
margin-top: 4px;
font-size: 11px;
color: #666;
}
.tail-number {
background-color: #f5f5f5;
padding: 1px 4px;
border-radius: 3px;
margin-right: 6px;
font-family: monospace;
}
/* Sync Status Indicators */
.sync-status {
margin-left: 4px;
}
.sync-status-active {
color: #4caf50;
}
.sync-status-pending {
color: #ff9800;
}
.sync-status-error {
color: #f44336;
}
/* Package Status */
.package-inactive {
margin-left: 4px;
}
/* Hover Effects */
.aircraft-item:hover {
background-color: #f8f9fa;
border-radius: 4px;
}
/* Aircraft item hover effects are now handled by global badge system */
/* Download Options Styling */
.download-options-info {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 4px 8px;
background-color: #e8f4fd;
border: 1px solid #bbdefb;
border-radius: 4px;
font-size: 0.8rem;
color: #1976d2;
font-weight: 500;
}
.download-options-info .pi {
font-size: 0.9rem;
color: #1976d2;
}
/* Responsive adjustments for download options */
@media (max-width: 768px) {
.download-options-info {
font-size: 0.75rem;
padding: 3px 6px;
}
.download-options-info .pi {
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.download-options-info {
margin-top: 2px;
padding: 2px 4px;
}
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.aircraft-item {
flex-direction: column;
align-items: flex-start;
padding: 6px 4px;
}
.satloc-details {
margin-top: 2px;
}
}
/* Round Split Button Styling with ::ng-deep */
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton {
width: 32px !important;
height: 32px !important;
}
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button {
border-radius: 50% !important;
width: 32px !important;
height: 32px !important;
padding: 0 !important;
min-width: auto !important;
background-color: #6c757d !important;
border-color: #6c757d !important;
color: white !important;
}
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:hover {
background-color: #5a6268 !important;
border-color: #545b62 !important;
}
/* Hide the main button, show only dropdown arrow */
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:first-child {
display: none !important;
}
/* Style the dropdown arrow button to be round */
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
border-radius: 50% !important;
width: 32px !important;
height: 32px !important;
border-left: none !important;
padding: 0 !important;
background-color: #6c757d !important;
border-color: #6c757d !important;
color: white !important;
}
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton:hover {
background-color: #5a6268 !important;
border-color: #545b62 !important;
}
/* Override PrimeNG corner classes */
:host ::ng-deep .assignment-action-button.slim .ui-corner-right {
border-radius: 50% !important;
}
/* Center the dropdown arrow icon */
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-left {
margin: 0 !important;
font-size: 0.8rem !important;
}
/* Assignment Status Display Styles (Update 1.1.4a) */
.assignment-status-section {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 16px;
background-color: #fafafa;
margin-top: 15px;
}
.assignment-status-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
gap: 16px;
}
.assignment-status-header h4 {
margin: 0;
color: #333;
font-size: 1.1rem;
font-weight: 600;
}
.clear-status-btn {
padding: 4px 8px !important;
min-width: auto !important;
font-size: 0.8rem !important;
background-color: #ffc107 !important;
color: #666 !important;
}
.clear-status-btn:hover {
background-color: #e0e0e0 !important;
color: #333 !important;
}
.assignment-progress {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background-color: #e3f2fd;
border: 1px solid #bbdefb;
border-radius: 4px;
margin-bottom: 12px;
color: #1976d2;
font-weight: 500;
}
.assignment-progress .pi-spinner {
font-size: 1.2rem;
}
/* Assignment Status Polling Indicator */
.polling-status-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 4px;
margin-bottom: 12px;
color: #1565c0;
font-size: 0.9rem;
font-weight: 500;
}
.polling-status-indicator .pi-refresh {
font-size: 1rem;
color: #1976d2;
}
.polling-status-indicator small {
margin-left: auto;
color: #424242;
font-weight: 400;
}
.assignment-error-summary {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background-color: #ffebee;
border: 1px solid #ffcdd2;
border-radius: 4px;
margin-bottom: 12px;
color: #c62828;
font-weight: 500;
}
.assignment-error-summary .pi {
font-size: 1.2rem;
}
.status-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-radius: 6px;
border: 1px solid #e0e0e0;
background-color: white;
transition: all 0.2s ease;
}
.status-item:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.status-icon {
flex-shrink: 0;
width: 24px;
display: flex;
justify-content: center;
align-items: center;
margin-top: 2px;
}
.status-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.status-aircraft-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.status-aircraft-info .aircraft-name {
font-weight: 600;
color: #333;
font-size: 0.95rem;
}
.status-timestamp {
font-size: 0.8rem;
color: #666;
white-space: nowrap;
}
.status-message {
font-size: 0.9rem;
color: #555;
}
.status-error-details {
font-size: 0.8rem;
color: #999;
font-style: italic;
}
/* Status actions column centering and sizing */
.status-actions {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center !important;
width: 100% !important;
margin: 0 auto !important;
}
/* Assignment Status Table Actions column centering */
.assignment-status-table .status-actions {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
max-width: 56px !important;
margin: 0 auto !important;
}
/* Ensure the assignment action button container is centered */
.assignment-action-button.slim {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
.retry-btn {
padding: 6px 12px !important;
font-size: 0.8rem !important;
min-width: auto !important;
background-color: #ff9800 !important;
border: 1px solid #f57c00 !important;
color: white !important;
}
.retry-btn:hover:not(:disabled) {
background-color: #f57c00 !important;
border-color: #ef6c00 !important;
}
.retry-btn:disabled {
opacity: 0.6 !important;
cursor: not-allowed !important;
}
/* Enhanced Status Actions Menu Items */
.p-menu .p-menuitem-link {
font-size: 0.9rem !important;
padding: 8px 12px !important;
}
.p-menu .p-menuitem-icon {
margin-right: 8px !important;
font-size: 0.85rem !important;
}
/* Split Button Menu Positioning */
.status-actions .p-splitbutton .p-menu {
min-width: 180px;
margin-top: 2px;
}
/* Position dropdown menu slightly to the left to prevent cutoff at screen edge */
:host ::ng-deep .assignment-action-button.slim .ui-menu {
transform: translateX(-120px) !important;
margin-top: 2px !important;
min-width: 180px !important;
}
/* Alternative positioning for PrimeNG p-menu */
:host ::ng-deep .assignment-action-button.slim .p-menu {
transform: translateX(-120px) !important;
margin-top: 2px !important;
min-width: 180px !important;
}
/* Responsive adjustments for split buttons */
@media (max-width: 768px) {
.retry-split-button .p-button,
.status-split-button .p-button {
padding: 4px 8px !important;
font-size: 0.75rem !important;
}
.status-actions .p-splitbutton .p-menu {
min-width: 160px;
}
}
/* Status State Specific Styles */
.status-pending .status-icon {
color: #ff9800;
}
.status-retrying .status-icon {
color: #ff9800;
}
.status-success {
border-color: #c8e6c9;
background-color: #f1f8e9;
}
.status-success .status-icon {
color: #4caf50;
}
.status-error {
border-color: #ffcdd2;
background-color: #ffebee;
}
.status-error .status-icon {
color: #f44336;
}
/* Responsive Design for Assignment Status */
@media (max-width: 768px) {
.assignment-status-section {
padding: 12px;
}
.assignment-status-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.assignment-status-header h4 {
font-size: 1rem;
}
.status-item {
padding: 10px;
gap: 8px;
}
.status-aircraft-info {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.status-aircraft-info .aircraft-name {
font-size: 0.9rem;
}
.status-timestamp {
font-size: 0.75rem;
}
.status-message {
font-size: 0.85rem;
}
.retry-btn {
padding: 4px 8px !important;
font-size: 0.75rem !important;
}
}
@media (max-width: 480px) {
.status-item {
flex-direction: column;
gap: 8px;
}
.status-icon {
align-self: flex-start;
}
.status-actions {
align-self: flex-end;
max-width: 48px !important;
}
.assignment-progress {
padding: 8px;
}
.assignment-error-summary {
padding: 8px;
}
}
/* Assignment Status Table Styling (Update 1.1.4b) */
.assignment-status-table {
margin-top: 10px;
}
.assignment-status-table .ui-table-tbody>tr {
border-left: 4px solid transparent;
}
.assignment-status-table .status-row-pending {
border-left-color: #2196f3;
}
.assignment-status-table .status-row-success {
border-left-color: #4caf50;
}
.assignment-status-table .status-row-error {
border-left-color: #f44336;
}
.assignment-status-table .status-row-retrying {
border-left-color: #ff9800;
}
.aircraft-cell {
display: flex;
align-items: center;
gap: 8px;
}
.aircraft-cell .pi {
font-size: 0.9rem;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
width: fit-content;
}
.status-badge-pending {
background-color: #e3f2fd;
color: #1976d2;
}
.status-badge-success {
background-color: #e8f5e8;
color: #2e7d32;
}
.status-badge-error {
background-color: #ffebee;
color: #c62828;
}
.status-badge-retrying {
background-color: #fff3e0;
color: #f57c00;
}
.status-message {
font-weight: 500;
}
.status-error-details {
margin-top: 4px;
color: #666;
}
.status-timestamp {
font-size: 0.85rem;
color: #666;
}
.empty-message {
text-align: center;
padding: 20px;
color: #666;
font-style: italic;
}
/* Responsive design for table */
@media (max-width: 768px) {
.assignment-status-table .ui-table-thead {
display: none;
}
.assignment-status-table .ui-table-tbody>tr>td {
display: block;
border: none;
border-bottom: 1px solid #ddd;
padding: 6px;
}
.assignment-status-table .ui-table-tbody>tr>td:before {
content: attr(data-label) ": ";
font-weight: bold;
display: inline-block;
width: 80px;
}
}
/* Update 1.1.6a: Slim Split Button Actions Styling */
/* Slim Split Button for Assignment Status Table */
.assignment-action-button.slim .ui-splitbutton {
width: 32px !important;
height: 32px !important;
}
.assignment-action-button.slim .ui-splitbutton .ui-button {
width: 32px !important;
height: 32px !important;
border-radius: 50% !important;
padding: 0 !important;
min-width: auto !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
background-color: #6c757d !important;
border-color: #6c757d !important;
color: white !important;
}
.assignment-action-button.slim .ui-splitbutton .ui-button:hover {
background-color: #5a6268 !important;
border-color: #545b62 !important;
}
.assignment-action-button.slim .ui-splitbutton .ui-button:focus {
outline: none !important;
box-shadow: 0 0 0 2px rgba(108, 117, 125, 0.5) !important;
}
/* Hide the left button for slim style - enhanced for assignment status */
.assignment-action-button.slim .ui-splitbutton .ui-button:first-child {
display: none !important;
}
/* Style the dropdown arrow button */
.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
width: 32px !important;
height: 32px !important;
border-radius: 50% !important;
border-left: none !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-primary {
margin: 0 !important;
font-size: 1rem !important;
}
/* Status indicator text for pending/retrying states */
.status-indicator-text {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: #666;
font-style: italic;
}
.status-indicator-text .pi {
font-size: 0.9rem;
}
.status-indicator-text .pi-spin {
color: #ff9800;
}
.status-indicator-text .pi-clock {
color: #2196f3;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.assignment-action-button.slim .ui-splitbutton {
width: 28px !important;
height: 28px !important;
}
.assignment-action-button.slim .ui-splitbutton .ui-button,
.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
width: 28px !important;
height: 28px !important;
}
.status-indicator-text {
font-size: 0.75rem;
}
}

View File

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

View File

@ -41,7 +41,6 @@ import { selectLimit } from '@app/reducers';
import { Acre } from '@app/domain/models/subscription.model';
import { SUB, SubTexts, SubType } from '@app/profile/common';
@Component({
selector: 'agm-job-edit',
templateUrl: './job-edit.component.html',
@ -105,9 +104,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
grpedProds: SelectItemGroup[] = [];
srcUsers: any[];
tarUsers: any[];
uploadUrl = '/imports/uploadJob';
uploadedFiles = [];
dlLogs = [];
@ -405,9 +401,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
this.resetNewEntities();
this.checkOKDl();
}));
this.sub$.add(this.appActions.ofType(jobActions.ASSIGN_SUCCESS).subscribe((action) => {
this._job['dlOp'] = this.selectedItem.dlOp;
}));
this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).pipe(take(1)).subscribe((pkg) => {
this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre;
@ -441,9 +434,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
if (this.isEdit) {
this.getUploadedFiles();
this.getLogs();
if (this.isPlanner) {
this.getAssignments();
}
}
}, 500);
@ -487,15 +477,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
});
}
private getAssignments() {
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe((res) => {
if (res) {
this.srcUsers = !Utils.isEmptyArray(res.avUsers) ? res.avUsers.filter(u => u.pkgActive) : [];
this.tarUsers = res.asUsers;
}
});
}
private getAppRateUnits(isUS: boolean) {
if (isUS) {
this.rateUnits = [
@ -550,19 +531,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
return valid;
}
onMoveToActiveList(items) {
if (items && items.length) {
const inactiveACList = items.filter(i => i.active === false);
if (inactiveACList.length) {
this.tarUsers = [...this.tarUsers, ...inactiveACList];
this.srcUsers = this.srcUsers.filter(u => u.active === true);
let errMsg = $localize`:@@cannotUnAssignInactiveVehicles:Cannot unassign inactive Aircraft`;
errMsg += ':[ ' + (inactiveACList.map(u => u.name)).join(',') + ' ]';
this.msgSvc.addFailedMsg(errMsg);
}
}
}
getUserToolTip(user) {
if (Utils.isEmptyObj(user)) return '';
let userTT = user.username;
@ -787,23 +755,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
}
}
downLoadJob(type: number) {
this.doDownLoadJob(type);
}
private doDownLoadJob(type) {
// TODO: Need to be handled in effects ???
this.jobSvc.downloadJob({ jobId: this.selectedItem._id, type: type }).subscribe(
(res) => {
try {
saveAs(res, `${this.selectedItem.name}_${this.selectedItem._id}.zip`);
} catch (error) {
alert('Sorry. Your browser does not support this feature !');
}
this.getLogs();
});
}
editJobMap(id?: number) {
this.router.navigate(
[
@ -1056,18 +1007,51 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
});
}
assignJob() {
if (!this.job) {
return;
}
// Assignment functionality moved to job-assignment component
const assignment = <jobActions.AssignInfo>{
jobId: this.job._id,
dlOp: this.selectedItem.dlOp,
avUsers: this.srcUsers,
asUsers: this.tarUsers
};
this.store.dispatch(new jobActions.Assign(assignment));
downLoadJob(type: number) {
this.doDownLoadJob(type);
}
private doDownLoadJob(type) {
// TODO: Need to be handled in effects ???
this.jobSvc.downloadJob({ jobId: this.selectedItem._id, type: type }).subscribe(
(data) => {
this.okDl = true;
try {
saveAs(data, this.selectedItem.name + '.zip');
// Track job download using GA4 convention
this.gaSvc.trackFileDownloaded({
file_type: 'prescription_map',
file_size_mb: 0, // Size not available from response
related_job_id: this.selectedItem._id?.toString(),
download_method: 'button_click',
file_format: 'original',
download_source: 'job_edit',
platform: 'web'
});
} catch (error) {
console.error('Download failed:', error);
alert('Sorry. Your browser does not support this feature !');
}
},
(error) => {
console.error('Download job failed:', error);
this.msgSvc.addFailedMsg('Failed to download job');
}
);
}
// Event handlers for job assignment component
onAssignmentComplete(event: any): void {
console.log('Assignment completed:', event);
// Handle assignment completion if needed
}
onAssignmentError(error: any): void {
console.error('Assignment error:', error);
// Handle assignment error if needed
}
downloadAppfile(data) {
@ -1389,10 +1373,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
this.addingNewCropJob = evt == 1;
}
ngOnDestroy(): void {
super.ngOnDestroy();
}
/**
* Parse file size string to megabytes for GA4 tracking
*/

View File

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

View File

@ -162,7 +162,20 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}));
this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).subscribe((pkg) => {
if (pkg) this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre;
if (pkg) {
const lookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE);
// If lookup key is empty (user data not loaded yet), find first package key
let effectiveLookupKey = lookupKey;
if (!lookupKey && pkg) {
const packageKeys = Object.keys(pkg);
if (packageKeys.length > 0) {
effectiveLookupKey = packageKeys[0]; // Use first available package
}
}
this.acre = pkg[effectiveLookupKey]?.acre;
}
}));
}
@ -289,6 +302,9 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
get canAddNew(): boolean {
// Check subscription package loaded (!!this.acre) and not over limit
// Note: With unlimited acres (limit: null), overLimit will always be false,
// but keep this check for defensive programming in case limited plans return
return !!this.acre && !this.acre.overLimit;
}

View File

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

View File

@ -26,7 +26,7 @@ import { IJob, Area, WayPoint, ITEM, BufferZone, RptOption, WeatherInfo, defWeat
import * as jobActions from '../actions/job.actions';
import { UpdateJobOps } from '../actions/job.actions';
import { RoleIds, globals, DRAW, KEY_CODE, PANE, GC, MatType, RateUnit } from '@app/shared/global';
import { RoleIds, globals, DRAW, KEY_CODE, PANE, GC, MatType, RateUnit, SysDataTypes, MatType2 } from '@app/shared/global';
import { LengthUnitPipe } from '@app/shared/pipes/length-unit.pipe';
import { JobService } from '@app/domain/services/job.service';
import { ObstacleService } from '@app/domain/services/obstacle.service';
@ -179,6 +179,13 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
private lastPlayUnit;
// {<fileId/Name>: { <file meta object> }}, <file meta object> = { data: [], other fields }
filesDataSet = <any>{};
// Track pagination state per file
private fileDataPagination: Map<string, {
hasMore: boolean;
startingAfter: string | null;
loading: boolean;
allLoaded: boolean;
}> = new Map();
playIdx: number = -1;
centerPlayPos: boolean = false;
playMarker: any;
@ -230,6 +237,14 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
return this.job;
}
get isPlayingAgNavFile(): boolean {
return Boolean(this.playingFile && this.playingFile?.file?.meta && this.playingFile?.file?.meta?.type === SysDataTypes.AGNAV);
}
get isPlayingSatLocFile(): boolean {
return Boolean(this.playingFile && this.playingFile?.file?.meta && this.playingFile?.file?.meta?.type === SysDataTypes.SATLOC);
}
protected postUpdateDrawToolTips(type: DRAW) {
switch (type) {
case DRAW.BUFFER:
@ -310,7 +325,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
private readonly weatherSvc: WeatherService,
private readonly ngZone: NgZone,
protected cdRef: ChangeDetectorRef,
) {
super(cdRef);
@ -591,7 +605,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
label: globals.sprayZone, icon: '', command: () => {
this.confirmSvc.confirm({
header: SubTexts.textUpgradeSub,
message: $localize`:@@upgradeSprayZone:You have exceeded allowable acre limit. Please upgrade your subscription to enable this feature.`,
message: $localize`:@@upgradeSprayZone:You have exceeded the permitted limit for the maximum applicable area. Please upgrade your subscription to enable this feature.`,
accept: () => {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
@ -2628,6 +2642,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this.map && this.map.removeLayer(this.playMarker);
this.playMarker = null;
}
}
private findRefZone() {
@ -2685,7 +2700,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
if (!file.meta) {
file.meta = { appRate: this.job.appRate, rateUnit: this.job.appRateUnit, hasQfile: false, useFC: false };
} else {
file.meta.useFC = (file.meta.fcType && file.meta.fcType.length && !file.meta.fcType.match(/none/i)) ? true : false;
file.meta.useFC = (file.meta.fcType && typeof file.meta.fcType === 'string' && file.meta.fcType.length && !file.meta.fcType.match(/none/i)) ? true : false;
file.rateUnit = file.meta.appRateUnitStr ? UnitUtils.rateStringToCode(file.meta.appRateUnitStr, this.isUS) : this.job.appRateUnit;
}
}
@ -2700,6 +2715,8 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this.selDataFiles = [];
this.dataFiles = [];
this.filesDataSet = {};
// Reset loaded file pagination data
this.fileDataPagination.clear();
}
private updatePlayRecord(idx: number, forward: boolean) {
@ -2711,8 +2728,13 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
const newRec = new PlayRecord();
newRec.timeGPS = this.curPlayLoc.gpsTime;
if (newRec.timeGPS)
newRec.timeLocal = DateUtils.msToTime(newRec.timeGPS * 1000, this.localTz);
if (newRec.timeGPS) {
if (this.isPlayingAgNavFile)
newRec.timeLocal = DateUtils.msToTime(newRec.timeGPS * 1000, this.localTz);
else {
newRec.timeLocal = DateUtils.gpsTimeToLocalISO(this.curPlayLoc.gpsTime, this.curPlayLoc.gmtOffset || 0);
}
}
newRec.lat = this.curPlayLoc.lat;
newRec.lon = this.curPlayLoc.lon;
@ -2742,26 +2764,24 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
newRec.flowRateRq = this.curPlayLoc.lminReq;
// If an FC used => assign the flowControl field w/ the value from the Qfile
if (file.meta.fcType && file.meta.fcType.trim().length && !file.meta.fcType.match(/none/i))
newRec.flowControl = file.meta.fcType;
newRec.flowControl = file.meta?.fcName && !file?.meta?.fcName.match(/none/i) ? file.meta.fcName : 'No FC';
if (this.curPlayLoc.sprayStat) {
newRec.flowRateAp = this.curPlayLoc.lminApp; // Apply for Liquid
// if (file.meta && file.meta.appRate && (!file.meta.useFC || !this.curPlayLoc.lminApp)) {
if (file.meta && file.meta.appRate && (!file.meta.useFC || !this.curPlayLoc.lminApp)) {
if (file.meta?.appRate && (!file.meta?.useFC || !this.curPlayLoc.lminApp)) {
const uniRate = UnitUtils.toRateUnit(file.meta.appRate, file.rateUnit, false);
newRec.appRateAp = uniRate.value;
newRec.rateUnit = uniRate.unit; // Expected in Metrics
newRec.appRateAp = uniRate.value;
} else {
if (this.playMatType === MatType.LIQUID) {
newRec.appRateAp = UnitUtils.appRateFromFlowRate(newRec.flowRateAp, this.curPlayLoc.swath, newRec.speed);
newRec.rateUnit = RateUnit.LPH;
newRec.appRateAp = UnitUtils.appRateFromFlowRate(newRec.flowRateAp, this.curPlayLoc.swath, newRec.speed);
}
else {
newRec.appRateAp = this.curPlayLoc.lminApp;
newRec.rateUnit = RateUnit.KGPH;
newRec.appRateAp = this.curPlayLoc.lminApp;
}
}
this.lastPlayUnit = newRec.rateUnit;
@ -2771,25 +2791,36 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
newRec.bmPressure = this.curPlayLoc.psi || 0.0;
if (file.meta.sprCoverage && file.meta.sprCoverage.length === 3)
if (file.meta?.sprCoverage && file.meta.sprCoverage.length === 3)
newRec.area = file.meta.sprCoverage[1]; // Current spray zone area size in metric, ha
newRec.swathWidth = this.curPlayLoc.swath;
newRec.sprOnLag = file.meta.sprOnLag || 0;
newRec.sprOffLag = file.meta.sprOffLag || 0;
newRec.pulsesPLiter = file.meta.pulsesPerLit;
newRec.sprOnLag = file.meta?.sprOnLag || 0;
newRec.sprOffLag = file.meta?.sprOffLag || 0;
newRec.pulsesPLiter = file.meta?.pulsesPerLit;
// For Output 3
newRec.areaName = file.meta.areaOrZone;
newRec.areaName = file.meta?.areaOrZone;
newRec.totLnLength = this.totLnLength; // Skipped, not necessary. It requires reading all gridlines from files
const matType = this.playingFile?.file?.meta?.matType;
// Planned/Target Application Rate
if ((file.meta && file.meta.appRate !== 0)) {
newRec.applicRate = file.meta.appRate;
newRec.applicRateUnit = file.rateUnit;
} else {
newRec.applicRate = this.job.appRate;
if (this.isPlayingAgNavFile) {
if ((file.meta && !isNaN(file.meta?.appRate) && file.meta?.appRate !== 0)) {
newRec.applicRate = file.meta.appRate;
newRec.applicRateUnit = file.rateUnit;
} else {
newRec.applicRateUnit = this.job.appRateUnit;
newRec.applicRate = this.job.appRate;
}
}
else if (!isNaN(this.curPlayLoc?.lhaReq) && matType) {
newRec.applicRateUnit = matType === MatType2.WET ? RateUnit.LPH : RateUnit.KGPH;
newRec.applicRate = this.curPlayLoc.lhaReq;
}
else {
newRec.applicRateUnit = this.job.appRateUnit;
newRec.applicRate = this.job.appRate;
}
//
@ -2797,8 +2828,8 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
const sprTot = UnitUtils.toArea(this.areaSprTot.total, this.isUS);
// (AreaSprTot - Mapped Area)/Mapped Area * 100. If value is negative, it indicates undersprayed or area not finished
newRec.overSprayed = ((sprTot - newRec.mappedArea) / newRec.mappedArea) * 100;
newRec.pilotName = file.meta.operator;
newRec.overSprayed = newRec.mappedArea ? ((sprTot - newRec.mappedArea) / newRec.mappedArea) * 100 : 0;
newRec.pilotName = file.meta?.operator;
if (!newRec.pilotName && this.job.operator)
newRec.pilotName = this.job.operator.name;
@ -2905,14 +2936,21 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
if (cb) cb();
};
const fid = this.selDataFiles[nextFileIdx].fid;
if (!this.filesDataSet[fid].loaded) {
this.jobSvc.getFilesData([fid]).subscribe(filesdata => {
if (filesdata.length) {
this.filesDataSet[fid].data = filesdata[0].data;
this.filesDataSet[fid].loaded = true;
}
setNextFile(nextFileIdx, cb);
// Initialize pagination tracking if not exists
if (!this.fileDataPagination.has(fid)) {
this.fileDataPagination.set(fid, {
hasMore: true,
startingAfter: null,
loading: false,
allLoaded: false
});
}
const pagination = this.fileDataPagination.get(fid);
if (!this.filesDataSet[fid].loaded || !pagination.allLoaded) {
this.loadFileDataWithPagination(fid, () => setNextFile(nextFileIdx, cb));
} else {
setNextFile(nextFileIdx, cb);
}
@ -2922,6 +2960,80 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
}
}
private async loadFileDataWithPagination(fileId: string, callback?: Function) {
const pagination = this.fileDataPagination.get(fileId);
if (pagination.loading) return;
// Initialize file data array if not exists
if (!this.filesDataSet[fileId]) {
this.filesDataSet[fileId] = { data: [], loaded: false };
}
pagination.loading = true;
let hasMore = true;
try {
while (hasMore) {
const params: any = {};
if (pagination.startingAfter) {
params.startingAfter = pagination.startingAfter;
}
// console.log(`Loading page for ${fileId}, cursor: ${pagination.startingAfter}`);
const result = await this.jobSvc.getFilesData(fileId, params).toPromise();
// console.log(`Response for ${fileId}:`, {
// dataLength: result?.data?.length,
// hasMore: result?.hasMore,
// startingAfter: result?.startingAfter
// });
if (result && result.data && result.data.length > 0) {
// Append data to existing array
this.filesDataSet[fileId].data.push(...result.data);
// Check if there's more data
hasMore = result.hasMore === true;
if (hasMore && result.startingAfter) {
// Update cursor for next page
pagination.startingAfter = result.startingAfter;
} else {
hasMore = false;
}
} else {
// No more data or empty response
hasMore = false;
}
}
// All data loaded
pagination.allLoaded = true;
pagination.loading = false;
pagination.hasMore = false;
this.filesDataSet[fileId].loaded = true;
if (callback) callback();
} catch (error) {
pagination.loading = false;
console.error('Error loading file data:', error);
if (callback) callback();
}
}
private resetFilePagination(fileId: string) {
if (this.filesDataSet[fileId]) {
this.filesDataSet[fileId].data = [];
this.filesDataSet[fileId].loaded = false;
}
this.fileDataPagination.set(fileId, {
hasMore: true,
startingAfter: null,
loading: false,
allLoaded: false
});
}
private createLine(locs = [], isSpray: boolean) {
locs = locs || [];
let line, ops;
@ -3187,7 +3299,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this._player = new PlayBack(this.atRecord.bind(this));
this.player.speed = this.playSpd;
if ((this.job.appRateUnit == 2 || this.job.appRateUnit == 4))
if ((this.job.appRateUnit == RateUnit.LBPA || this.job.appRateUnit == RateUnit.KGPH))
this.playMatType = MatType.DRY;
this.playIdx = -1;
this.totLnLength = 0;
@ -3204,6 +3316,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this.player.speed = e.value;
});
}
onTzChange(e) {
if (!e) return;
if (this.curPlayRec) {

View File

@ -33,9 +33,10 @@ import { JobMgtComponent } from './job-mgt.component';
import { AppSharedModule } from '../shared/app-shared.module';
import { JobListComponent } from './job-list/job-list.component';
import { JobEditComponent } from './job-edit/job-edit.component';
import { JobAssignmentComponent } from './job-assignment/job-assignment.component';
import { JobMapEditComponent } from './job-map-edit/job-map-edit.component';
import { JobsRoutingModule } from './job-routing.module';
import {InvoicesModule} from '@app/invoices/invoices.module';
import { InvoicesModule } from '@app/invoices/invoices.module';
@NgModule({
imports: [
@ -50,7 +51,7 @@ import {InvoicesModule} from '@app/invoices/invoices.module';
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
EffectsModule.forFeature([JobEffects]), InvoicesModule,
],
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobMapEditComponent],
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
providers: [DatePipe],
schemas: [
CUSTOM_ELEMENTS_SCHEMA

View File

@ -10,26 +10,60 @@ import { IUIJob } from '../models/job.model';
export const getJobsState = createFeatureSelector<fromJobs.State>(fromJobs.FEATURE_KEY);
export const getSelectedJobId = createSelector(
// Safe wrapper that handles undefined state during lazy module loading
// This prevents "Cannot read properties of undefined (reading 'ids')" error
export const getJobsStateOrInitial = createSelector(
getJobsState,
(state) => {
if (!state) {
return {
ids: [],
entities: {},
loading: false,
loaded: false,
selectedId: null
} as fromJobs.State;
}
return state;
}
);
export const getSelectedJobId = createSelector(
getJobsStateOrInitial,
fromJobs.getSelectedId
);
export const getIsLoading = createSelector(
getJobsState,
getJobsStateOrInitial,
fromJobs.getIsLoading
);
export const getIsLoaded = createSelector(
getJobsState,
getJobsStateOrInitial,
fromJobs.getIsLoaded
);
export const {
selectIds: getJobsIds,
selectEntities: getJobEntities,
selectAll: getAllJobs,
selectTotal: getTotalJobs,
} = fromJobs.adapter.getSelectors(getJobsState);
// Use safe wrapper with adapter selectors to prevent undefined access
const entitySelectors = fromJobs.adapter.getSelectors(getJobsStateOrInitial);
export const getJobsIds = createSelector(
entitySelectors.selectIds,
(ids) => ids || []
);
export const getJobEntities = createSelector(
entitySelectors.selectEntities,
(entities) => entities || {}
);
export const getAllJobs = createSelector(
entitySelectors.selectAll,
(jobs) => jobs || []
);
export const getTotalJobs = createSelector(
entitySelectors.selectTotal,
(total) => total || 0
);
export const getSelectedJob = createSelector(
getJobEntities,

View File

@ -0,0 +1,71 @@
// Interface for package information from subscription data
export interface PackageInfo {
packageName: string;
status: string;
startDate: Date;
endDate: Date;
recurring: boolean;
}
// Interface that matches the backend API response
export interface PartnerCustomerApiResponse {
_id: string;
name: string;
email: string;
username: string;
contact?: string;
active: boolean;
country?: string;
createdAt: Date;
updatedAt: Date;
packageInfo: PackageInfo[];
}
// Frontend model for display (transformed from API response)
export interface PartnerCustomer {
_id?: string;
name: string;
contactName: string;
phone: string;
email: string;
package: string; // Customer's service package type
partnerId?: string; // Reference to the partner account
createdAt?: Date;
updatedAt?: Date;
active?: boolean;
username?: string;
country?: string;
}
// Transform function to convert API response to display model
export function transformPartnerCustomer(apiResponse: PartnerCustomerApiResponse): PartnerCustomer {
// Extract the primary package name from packageInfo
const primaryPackage = apiResponse.packageInfo && apiResponse.packageInfo.length > 0
? apiResponse.packageInfo[0].packageName || 'No Package'
: 'No Package';
return {
_id: apiResponse._id,
name: apiResponse.name,
contactName: apiResponse.contact || apiResponse.name, // Use contact or fallback to name
phone: '', // Not provided by backend - could be added later
email: apiResponse.email,
package: primaryPackage,
createdAt: apiResponse.createdAt,
updatedAt: apiResponse.updatedAt,
active: apiResponse.active,
username: apiResponse.username,
country: apiResponse.country
};
}
export function createNewPartnerCustomer(partnerId: string): PartnerCustomer {
return {
name: '',
contactName: '',
phone: '',
email: '',
package: '',
partnerId: partnerId
};
}

View File

@ -0,0 +1 @@
/* Partner Customer List Component Styles */

View File

@ -0,0 +1,52 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card">
<p-table #dt [value]="partnerCustomers" [columns]="cols" [paginator]="true" [rows]="15" [pageLinks]="5"
[rowsPerPageOptions]="[10, 15, 30]" [alwaysShowPaginator]="true" dataKey="_id" [resetPageOnSort]="false"
stateStorage="session" stateKey="pctb-ops" [responsive]="true" [loading]="loading">
<ng-template pTemplate="caption">
<div class="ui-g ui-g-nopad">
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
<span class="table-caption-1" i18n="@@partnerCustomersTableCaption">Partner Customers</span>
</div>
<div class="ui-g-6 ui-g-nopad" style="text-align: right">
<button pButton type="button" icon="ui-icon-refresh" class="ui-button-secondary"
(click)="reloadCustomers()" pTooltip="Refresh customer list"
i18n-pTooltip="@@refreshCustomerList"></button>
</div>
</div>
</ng-template>
<ng-template pTemplate="header" let-columns>
<tr>
<th *ngFor="let col of columns" [pSortableColumn]="col.field" [width]="col.width">
{{col.header}}
<p-sortIcon [field]="col.field"></p-sortIcon>
</th>
</tr>
<tr>
<th *ngFor="let col of columns">
<input *ngIf="col.filtered" pInputText type="text"
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[placeholder]="searchPlaceholder + ' ' + col.header" class="ui-column-filter ui-fluid">
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-customer let-columns="columns">
<tr>
<td *ngFor="let col of columns">
<span class="ui-column-title">{{col.header}}</span>
<!-- Special handling for package column to show descriptive names -->
<span *ngIf="col.field === 'package'">{{getPackageName(customer.package)}}</span>
<!-- Default display for other columns -->
<span *ngIf="col.field !== 'package'">{{customer[col.field]}}</span>
</td>
</tr>
</ng-template>
</p-table>
</div>
</div>
</div>

View File

@ -0,0 +1,77 @@
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
import { Table } from 'primeng/table';
import { PartnerCustomer } from '../models/partner-customer.model';
import { PartnerCustomerService } from '../services/partner-customer.service';
import { globals, Labels } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component';
import { AppMessageService } from '@app/shared/app-message.service';
import { getPackageDisplayName } from '@app/profile/common';
@Component({
selector: 'agm-partner-customer-list',
templateUrl: './partner-customer-list.component.html',
styleUrls: ['./partner-customer-list.component.css']
})
export class PartnerCustomerListComponent extends BaseComp implements OnInit, OnDestroy {
partnerCustomers: PartnerCustomer[] = [];
loading = false;
cols: any[];
// Translation constants for template access
readonly searchPlaceholder = Labels.SEARCH_PLACEHOLDER;
@ViewChild('dt') dt: Table;
constructor(
private readonly partnerCustSvc: PartnerCustomerService,
protected readonly msgSvc: AppMessageService
) {
super();
this.cols = [
{ field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains', width: '23%' },
{ field: 'contactName', header: $localize`:Contact name column header@@contactName:Contact Name`, filtered: true, filterMatchMode: 'contains', width: '23%' },
{ field: 'phone', header: globals.phone, filtered: true, filterMatchMode: 'contains', width: '16%' },
{ field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains', width: '18%' },
{ field: 'package', header: globals.package, filtered: true, filterMatchMode: 'contains', width: '20%' }
];
}
ngOnInit() {
this.loadPartnerCustomers();
}
loadPartnerCustomers() {
this.loading = true;
this.partnerCustSvc.getPartnerCustomers().subscribe({
next: (customers) => {
this.partnerCustomers = customers;
this.loading = false;
},
error: (error) => {
this.msgSvc.addFailedMsg(Labels.ERROR_LOADING_PARTNER_CUSTOMERS);
this.loading = false;
}
});
}
reloadCustomers() {
this.loadPartnerCustomers();
}
/**
* Get display name for package lookup key
* @param lookupKey - Package lookup key (e.g., 'ess_1', 'ent_2')
* @returns Descriptive name (e.g., 'Essential 1', 'Enterprise 2')
*/
getPackageName(lookupKey: string): string {
return getPackageDisplayName(lookupKey);
}
ngOnDestroy() {
super.ngOnDestroy();
}
}

View File

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
// A child routing component for Partner Customer Feature
@Component({
template: `
<router-outlet></router-outlet>
`
})
export class PartnerCustomerMgtComponent {
constructor() { }
}

View File

@ -0,0 +1,30 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { PartnerCustomerListComponent } from './partner-customer-list/partner-customer-list.component';
import { PartnerCustomerMgtComponent } from './partner-customer-mgt.component';
import { AuthGuard } from '../domain/guards/auth.guard';
import { RoleIds } from '../shared/global';
const routes: Routes = [
{
path: '',
component: PartnerCustomerMgtComponent,
data: {
roles: [RoleIds.VENDOR],
},
canActivate: [],
children: [
{
path: '',
component: PartnerCustomerListComponent
}
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class PartnerCustomersRoutingModule { }

View File

@ -0,0 +1,29 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
// PrimeNG imports
import { TableModule } from 'primeng/table';
import { InputTextModule } from 'primeng/inputtext';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { TooltipModule } from 'primeng/tooltip';
import { ButtonModule } from 'primeng/button';
import { PartnerCustomersRoutingModule } from './partner-customers-routing.module';
import { PartnerCustomerListComponent } from './partner-customer-list/partner-customer-list.component';
import { PartnerCustomerMgtComponent } from './partner-customer-mgt.component';
@NgModule({
declarations: [PartnerCustomerListComponent, PartnerCustomerMgtComponent],
imports: [
CommonModule,
PartnerCustomersRoutingModule,
// PrimeNG modules
TableModule,
InputTextModule,
ProgressSpinnerModule,
TooltipModule,
ButtonModule
]
})
export class PartnerCustomersModule { }

View File

@ -0,0 +1,34 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PartnerCustomer, PartnerCustomerApiResponse, transformPartnerCustomer } from '../models/partner-customer.model';
import { AuthService } from '../../domain/services/auth.service';
@Injectable({
providedIn: 'root'
})
export class PartnerCustomerService {
private readonly apiUrl = '/partners';
constructor(
private readonly http: HttpClient,
private readonly authSvc: AuthService
) { }
/**
* Get partner customers for a specific partner
* @param partnerId Optional partner ID. If not provided, defaults to current authenticated user's ID
* @returns Observable array of partner customers
*/
getPartnerCustomers(partnerId?: string): Observable<PartnerCustomer[]> {
const targetPartnerId = partnerId || this.authSvc.user._id;
return this.http.get<PartnerCustomerApiResponse[]>(`${this.apiUrl}/customers`, {
params: { partnerId: targetPartnerId }
}).pipe(
map(apiResponse => apiResponse.map(customer => transformPartnerCustomer(customer)))
);
}
}

View File

@ -0,0 +1,81 @@
import { createAction, props } from '@ngrx/store';
import { Partner } from '../models/partner.model';
// Load Actions
export const loadPartners = createAction('[Partner] Load Partners');
export const loadPartnersSuccess = createAction(
'[Partner] Load Partners Success',
props<{ partners: Partner[] }>()
);
export const loadPartnersFailure = createAction(
'[Partner] Load Partners Failure',
props<{ error: string }>()
);
// Load Single Partner Actions
export const loadPartner = createAction(
'[Partner] Load Partner',
props<{ id: string }>()
);
export const loadPartnerSuccess = createAction(
'[Partner] Load Partner Success',
props<{ partner: Partner }>()
);
export const loadPartnerFailure = createAction(
'[Partner] Load Partner Failure',
props<{ error: string }>()
);
// Create Actions
export const createPartner = createAction(
'[Partner] Create Partner',
props<{ partner: Partner }>()
);
export const createPartnerSuccess = createAction(
'[Partner] Create Partner Success',
props<{ partner: Partner }>()
);
export const createPartnerFailure = createAction(
'[Partner] Create Partner Failure',
props<{ error: string }>()
);
// Update Actions
export const updatePartner = createAction(
'[Partner] Update Partner',
props<{ id: string; partner: Partner }>()
);
export const updatePartnerSuccess = createAction(
'[Partner] Update Partner Success',
props<{ partner: Partner }>()
);
export const updatePartnerFailure = createAction(
'[Partner] Update Partner Failure',
props<{ error: string }>()
);
// Delete Actions
export const deletePartner = createAction(
'[Partner] Delete Partner',
props<{ id: string }>()
);
export const deletePartnerSuccess = createAction(
'[Partner] Delete Partner Success',
props<{ id: string }>()
);
export const deletePartnerFailure = createAction(
'[Partner] Delete Partner Failure',
props<{ error: string }>()
);
// UI Actions
export const selectPartner = createAction(
'[Partner] Select Partner',
props<{ partner: Partner | null }>()
);
export const clearPartnerError = createAction('[Partner] Clear Error');
export const setPartnerLoading = createAction(
'[Partner] Set Loading',
props<{ loading: boolean }>()
);

View File

@ -0,0 +1,158 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError, tap, repeat } from 'rxjs/operators';
import { MessageService } from 'primeng/api';
import { PartnerService } from '../services';
import * as PartnerActions from '../actions/partner.actions';
@Injectable()
export class PartnerEffects {
constructor(
private actions$: Actions,
private partnerService: PartnerService,
private messageService: MessageService,
private router: Router
) { }
loadPartners$ = createEffect(() =>
this.actions$.pipe(
ofType(PartnerActions.loadPartners),
tap(() => console.log('PartnerEffects: loadPartners action received')),
mergeMap(() =>
this.partnerService.getPartners().pipe(
tap(partners => console.log('PartnerEffects: service returned partners:', partners)),
map(partners => PartnerActions.loadPartnersSuccess({ partners })),
catchError(error => {
console.error('PartnerEffects: error loading partners:', error);
return of(PartnerActions.loadPartnersFailure({ error: error.message }));
})
)
),
repeat()
)
);
loadPartner$ = createEffect(() =>
this.actions$.pipe(
ofType(PartnerActions.loadPartner),
mergeMap(action =>
this.partnerService.getPartnerById(action.id).pipe(
map(partner => PartnerActions.loadPartnerSuccess({ partner })),
catchError(error => of(PartnerActions.loadPartnerFailure({ error: error.message })))
)
),
repeat()
)
);
createPartner$ = createEffect(() =>
this.actions$.pipe(
ofType(PartnerActions.createPartner),
mergeMap(action =>
this.partnerService.createPartner(action.partner).pipe(
map(partner => PartnerActions.createPartnerSuccess({ partner })),
catchError(error => of(PartnerActions.createPartnerFailure({ error: error.message })))
)
),
repeat()
)
);
updatePartner$ = createEffect(() =>
this.actions$.pipe(
ofType(PartnerActions.updatePartner),
mergeMap(action =>
this.partnerService.updatePartner(action.id, action.partner).pipe(
map(partner => PartnerActions.updatePartnerSuccess({ partner })),
catchError(error => of(PartnerActions.updatePartnerFailure({ error: error.message })))
)
),
repeat()
)
);
deletePartner$ = createEffect(() =>
this.actions$.pipe(
ofType(PartnerActions.deletePartner),
mergeMap(action =>
this.partnerService.deletePartner(action.id).pipe(
map(() => PartnerActions.deletePartnerSuccess({ id: action.id })),
catchError(error => of(PartnerActions.deletePartnerFailure({ error: error.message })))
)
),
repeat()
)
);
// Success Effects with Toast Messages
createPartnerSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(PartnerActions.createPartnerSuccess),
tap(action => {
this.messageService.add({
severity: 'success',
summary: 'Success',
detail: `Partner "${action.partner.name}" created successfully`
});
// Navigate back to partner list after successful creation
this.router.navigate(['/partners']);
})
),
{ dispatch: false }
);
updatePartnerSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(PartnerActions.updatePartnerSuccess),
tap(action => {
this.messageService.add({
severity: 'success',
summary: 'Success',
detail: `Partner "${action.partner.name}" updated successfully`
});
// Navigate back to partner list after successful update
this.router.navigate(['/partners']);
})
),
{ dispatch: false }
);
deletePartnerSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(PartnerActions.deletePartnerSuccess),
tap(() => {
this.messageService.add({
severity: 'success',
summary: 'Success',
detail: 'Partner deleted successfully'
});
})
),
{ dispatch: false }
);
// Error Effects with Toast Messages
partnerFailure$ = createEffect(() =>
this.actions$.pipe(
ofType(
PartnerActions.loadPartnersFailure,
PartnerActions.loadPartnerFailure,
PartnerActions.createPartnerFailure,
PartnerActions.updatePartnerFailure,
PartnerActions.deletePartnerFailure
),
tap(action => {
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: action.error || 'An error occurred'
});
})
),
{ dispatch: false }
);
}

View File

@ -0,0 +1,67 @@
import { User } from '@app/accounts/models/user.model';
import { RoleIds } from '@app/shared/global';
export interface Partner extends User {
// Partner-specific fields (extending User base fields)
partnerCode?: string; // e.g., "SATLOC", "AGIDRONEX" - unique identifier
// User base fields are inherited:
// _id, username, password, name, email, phone, kind, active, createdAt, updatedAt, etc.
}
export function createNewPartner(): Partner {
return {
_id: '0', // Required from User interface
name: '',
partnerCode: '',
email: '',
phone: '',
username: '',
kind: RoleIds.PARTNER, // Set to PARTNER role by default
active: true
};
}
// Mock data for development and testing
export const mockPartners: Partner[] = [
{
_id: '1',
name: 'AgTech Solutions Inc.',
kind: RoleIds.PARTNER,
active: true,
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-01-15')
},
{
_id: '2',
name: 'FarmData Analytics',
kind: RoleIds.PARTNER,
active: true,
createdAt: new Date('2024-02-01'),
updatedAt: new Date('2024-02-15')
},
{
_id: '3',
name: 'Crop Monitoring Systems',
kind: RoleIds.PARTNER,
active: false,
createdAt: new Date('2024-01-20'),
updatedAt: new Date('2024-03-01')
},
{
_id: '4',
name: 'Irrigation Tech Corp',
kind: RoleIds.PARTNER,
active: true,
createdAt: new Date('2024-03-10'),
updatedAt: new Date('2024-03-10')
},
{
_id: '5',
name: 'Soil Health Innovations',
kind: RoleIds.PARTNER,
active: true,
createdAt: new Date('2024-02-20'),
updatedAt: new Date('2024-03-05')
}
];

View File

@ -0,0 +1,19 @@
/* Partner Edit Component Styles */
/* ============================================================================
PARTNER CODE CONSTRAINT - INLINE ICON (Detached Mode)
============================================================================
Similar to Tail Number pattern in vehicle-edit. Icon appears beside input
field, message content renders below via *ngTemplateOutlet projection.
========================================================================= */
.input-with-inline-constraint {
display: flex;
align-items: flex-start;
gap: 6px;
}
.input-with-inline-constraint .inline-constraint {
margin-top: -2px;
/* Visual debugger recommendation - align with input center */
}

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