Compare commits
8 Commits
feature/su
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 30c4bc3c3e | |||
| a39ce2800f | |||
| cd8f954584 | |||
| 4d39ac2595 | |||
| e1d68734f0 | |||
| 354d468968 | |||
| 3cfb81adfe | |||
| 14f83f0008 |
77
.gitea/workflows/sync-to-svn.yaml
Normal file
77
.gitea/workflows/sync-to-svn.yaml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# Gitea Actions – Sync git repository to SVN
|
||||||
|
# Equivalent of .circleci/config.yml, translated to GitHub Actions syntax
|
||||||
|
# which Gitea Actions supports natively.
|
||||||
|
#
|
||||||
|
# Prerequisites (set as repository Secrets in Gitea → Settings → Secrets):
|
||||||
|
# SVN_USERNAME – SVN commit username
|
||||||
|
# SVN_PASSWORD – SVN commit password
|
||||||
|
# SVN_REPO_URL – Base SVN repo URL (e.g. https://svn.example.com/repos/myproject)
|
||||||
|
|
||||||
|
name: Sync to SVN
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push-to-svn:
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout git repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# SVN and rsync are pre-installed in the container image, but the
|
||||||
|
# apt-get call is kept so the workflow works on a stock runner too.
|
||||||
|
- name: Install SVN and rsync
|
||||||
|
run: |
|
||||||
|
if ! command -v svn &>/dev/null || ! command -v rsync &>/dev/null; then
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y subversion rsync
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Verify SVN credentials are set
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.SVN_USERNAME }}" ]; then echo "ERROR: SVN_USERNAME secret is not set" && exit 1; fi
|
||||||
|
if [ -z "${{ secrets.SVN_PASSWORD }}" ]; then echo "ERROR: SVN_PASSWORD secret is not set" && exit 1; fi
|
||||||
|
if [ -z "${{ secrets.SVN_REPO_URL }}" ]; then echo "ERROR: SVN_REPO_URL secret is not set" && exit 1; fi
|
||||||
|
echo "All SVN secrets are set."
|
||||||
|
|
||||||
|
- name: Checkout SVN branch
|
||||||
|
run: |
|
||||||
|
svn checkout \
|
||||||
|
--username "${{ secrets.SVN_USERNAME }}" \
|
||||||
|
--password "${{ secrets.SVN_PASSWORD }}" \
|
||||||
|
--no-auth-cache \
|
||||||
|
--non-interactive \
|
||||||
|
--trust-server-cert \
|
||||||
|
"${{ secrets.SVN_REPO_URL }}/branches/data-export-api-copy" svn-branch
|
||||||
|
|
||||||
|
- name: Sync files to SVN working copy
|
||||||
|
run: |
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='.gitea/' \
|
||||||
|
--exclude='.svn/' \
|
||||||
|
--exclude='svn-branch/' \
|
||||||
|
. svn-branch/
|
||||||
|
- name: Stage and commit to SVN
|
||||||
|
run: |
|
||||||
|
cd svn-branch
|
||||||
|
|
||||||
|
# Add all new/unversioned files and directories in one pass
|
||||||
|
svn add --force .
|
||||||
|
|
||||||
|
# Delete files removed from git (handles paths with spaces).
|
||||||
|
# '|| true' prevents pipefail aborting when grep finds no '!' lines.
|
||||||
|
svn status | grep '^!' | while IFS= read -r line; do
|
||||||
|
svn delete "${line:8}@"
|
||||||
|
done || true
|
||||||
|
|
||||||
|
# Attempt commit; svn commit exits 0 silently when nothing changed
|
||||||
|
svn commit \
|
||||||
|
--username "${{ secrets.SVN_USERNAME }}" \
|
||||||
|
--password "${{ secrets.SVN_PASSWORD }}" \
|
||||||
|
--no-auth-cache \
|
||||||
|
--non-interactive \
|
||||||
|
--trust-server-cert \
|
||||||
|
-m "Gitea CI sync from commit ${{ github.sha }} [ci skip]"
|
||||||
64
.githooks/pre-commit
Normal file
64
.githooks/pre-commit
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
# Allow master and development
|
||||||
|
if [ "$branch" = "master" ] || [ "$branch" = "development" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate feature/* and bugfix/* branches
|
||||||
|
case "$branch" in
|
||||||
|
feature/*|bugfix/*)
|
||||||
|
name="${branch#*/}"
|
||||||
|
|
||||||
|
# Name must not be empty
|
||||||
|
if [ -z "$name" ]; then
|
||||||
|
echo "ERROR: Branch '$branch' has an empty name after the prefix."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only letters, digits, and dashes allowed
|
||||||
|
if echo "$name" | grep -qE '[^a-zA-Z0-9-]'; then
|
||||||
|
echo "ERROR: Branch '$branch' contains invalid characters."
|
||||||
|
echo " Only letters, digits, and dashes are allowed after the prefix."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No leading or trailing dashes
|
||||||
|
if echo "$name" | grep -qE '^-|-$'; then
|
||||||
|
echo "ERROR: Branch '$branch' must not start or end with a dash."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No consecutive dashes
|
||||||
|
if echo "$name" | grep -q -- '--'; then
|
||||||
|
echo "ERROR: Branch '$branch' must not contain consecutive dashes."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Max 25 characters excluding dashes
|
||||||
|
name_no_dashes=$(echo "$name" | tr -d '-')
|
||||||
|
char_count=${#name_no_dashes}
|
||||||
|
if [ "$char_count" -gt 25 ]; then
|
||||||
|
echo "ERROR: Branch '$branch': the name after the prefix is $char_count characters (excluding dashes), max is 25."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "ERROR: Branch name '$branch' does not follow the naming convention."
|
||||||
|
echo ""
|
||||||
|
echo " Allowed formats:"
|
||||||
|
echo " master"
|
||||||
|
echo " development"
|
||||||
|
echo " feature/{name}"
|
||||||
|
echo " bugfix/{name}"
|
||||||
|
echo ""
|
||||||
|
echo " Rules for feature/bugfix names:"
|
||||||
|
echo " - Letters, digits, and dashes only (no spaces)"
|
||||||
|
echo " - No leading, trailing, or consecutive dashes"
|
||||||
|
echo " - Max 20 characters excluding dashes"
|
||||||
|
exit 1
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Dependencies
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
*.rlog
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
**/*.env
|
||||||
|
**/environment.env
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
**/dist/
|
||||||
|
**/build/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@ -2,5 +2,5 @@ CURRENT_FILE_PATH=src/locale/messages.xlf
|
|||||||
TRANSLATED_FILE_PATH_ES=src/locale/messages.es.xlf
|
TRANSLATED_FILE_PATH_ES=src/locale/messages.es.xlf
|
||||||
TRANSLATED_FILE_PATH_PT=src/locale/messages.pt.xlf
|
TRANSLATED_FILE_PATH_PT=src/locale/messages.pt.xlf
|
||||||
GOOGLE_LOCATION=global
|
GOOGLE_LOCATION=global
|
||||||
GOOGLE_PROJECT_ID=agmission
|
GOOGLE_PROJECT_ID=predictive-fx-392018
|
||||||
GOOGLE_APPLICATION_CREDENTIALS=google-cloud.json
|
GOOGLE_APPLICATION_CREDENTIALS=google-cloud.json
|
||||||
3
Development/client/.vscode/settings.json
vendored
3
Development/client/.vscode/settings.json
vendored
@ -6,5 +6,6 @@
|
|||||||
"formate.alignColon": true,
|
"formate.alignColon": true,
|
||||||
"formate.verticalAlignProperties": true,
|
"formate.verticalAlignProperties": true,
|
||||||
"formate.enable": true,
|
"formate.enable": true,
|
||||||
"formate.additionalSpaces": 0
|
"formate.additionalSpaces": 0,
|
||||||
|
"specstory.cloudSync.enabled": "never"
|
||||||
}
|
}
|
||||||
@ -103,7 +103,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "6kb"
|
"maximumWarning": "12kb",
|
||||||
|
"maximumError": "18kb"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
382
Development/client/docs/MANAGE_SERVICES_PROMO_DISPLAY.md
Normal file
382
Development/client/docs/MANAGE_SERVICES_PROMO_DISPLAY.md
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
# Promo Display Logic — services Screen
|
||||||
|
|
||||||
|
**Component**: `src/app/profile/manage-services/manage-services.component.ts`
|
||||||
|
**Route**: `/profile/services`
|
||||||
|
**Last Updated**: March 18, 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Data Flow](#data-flow)
|
||||||
|
- [Key State](#key-state)
|
||||||
|
- [activePromos Map Construction](#activepromos-map-construction)
|
||||||
|
- [Button Labels and confirmServices Flow](#button-labels-and-confirmservices-flow)
|
||||||
|
- [Create New Subscription Plan](#create-new-subscription-plan)
|
||||||
|
- [Create Trial Subscription Plan](#create-trial-subscription-plan)
|
||||||
|
- [Checkout Promo Display after Each Flow](#checkout-promo-display-after-each-flow)
|
||||||
|
- [getPromoForLookupKey the Core Gate](#getpromoforlookupkey-the-core-gate)
|
||||||
|
- [isAllPackagesPromo Package-Wide Banner Logic](#isallpackagespromo-package-wide-banner-logic)
|
||||||
|
- [Template Rendering Logic](#template-rendering-logic)
|
||||||
|
- [Promo Price Calculation](#promo-price-calculation)
|
||||||
|
- [ESS_1 Legacy Special Handling](#ess_1-legacy-special-handling)
|
||||||
|
- [Promo Display Components](#promo-display-components)
|
||||||
|
- [Quick Reference Table](#quick-reference-table)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `/services` screen ("Choose Your Plan") shows packages and addons with promotional pricing when applicable. Promo data comes from the authenticated `GET /api/activePromos` endpoint (v3.0+), which already filters by customer eligibility server-side. The client only needs to apply display-mode gating (available vs. subscribed) on top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A([ngOnInit]) --> B[dispatch FetchSubPlans]
|
||||||
|
A --> C[loadActivePromos]
|
||||||
|
A --> D[loadPromoMode]
|
||||||
|
|
||||||
|
B --> E["populates essPkgs, addons via Redux store"]
|
||||||
|
|
||||||
|
C --> F["GET /api/activePromos<br/>Auth required - v3.0"]
|
||||||
|
F --> G["Server filters by customer eligibility<br/>eligibility: all / new_only / renew_only"]
|
||||||
|
G --> H["Returns only eligible promos"]
|
||||||
|
H --> I[buildActivePromosMap]
|
||||||
|
I --> J[(activePromos Map)]
|
||||||
|
|
||||||
|
D --> F2["getCurrentMode()"]
|
||||||
|
F2 --> K["promoMode: enabled or disabled"]
|
||||||
|
|
||||||
|
J --> L[Template rendering]
|
||||||
|
K --> L
|
||||||
|
E --> L
|
||||||
|
```
|
||||||
|
|
||||||
|
> **v3.0 (Jan 2026):** `/api/activePromos` requires authentication and returns **only the promos the current customer is eligible for**. The client does **not** need to re-check eligibility — the returned list is already filtered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key State
|
||||||
|
|
||||||
|
| Property | Source | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `activePromos` | `GET /api/activePromos` | Map of eligible promos for current user |
|
||||||
|
| `promoMode` | `currentMode.mode` from same response | Global kill switch (`'enabled'` or `'disabled'`) |
|
||||||
|
| `subs` | Redux `getSubscriptions` | Current Stripe subscriptions for this user |
|
||||||
|
| `isTrial` | Redux `getSubIntentState` — `subIntent.mode === Mode.TRIALING` | Whether checkout intent is a trial sign-up |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## activePromos Map Construction
|
||||||
|
|
||||||
|
`loadActivePromos()` builds a flat `Map<string, ActivePromo>` using three key patterns based on what fields each promo has:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A([Promo from /api/activePromos]) --> B{Has priceKey?}
|
||||||
|
|
||||||
|
B -->|Yes| C["activePromos.set(priceKey, promo)<br/>e.g. 'ess_1_1' -> promo"]
|
||||||
|
|
||||||
|
B -->|No| D{Has type?}
|
||||||
|
|
||||||
|
D -->|Yes - package or addon| E["activePromos.set('package_all' or 'addon_all', promo)"]
|
||||||
|
|
||||||
|
D -->|No - universal promo| F["activePromos.set('package_all', promo)<br/>activePromos.set('addon_all', promo)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lookup at render time:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["getPromoForLookupKey('ess_1_1', 'package')"]
|
||||||
|
A --> B["activePromos.get('ess_1_1')"]
|
||||||
|
B -->|found| C([return exact promo])
|
||||||
|
B -->|not found| D["activePromos.get('package_all')"]
|
||||||
|
D -->|found| E([return type-wide promo])
|
||||||
|
D -->|not found| F([return null])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button Labels and confirmServices Flow
|
||||||
|
|
||||||
|
The confirm button in `#btnSection` uses a computed `confirmLabel` getter:
|
||||||
|
|
||||||
|
```
|
||||||
|
isNewSub = !originalSel.selPkg && !(originalSel.selAddons.length > 0)
|
||||||
|
|
||||||
|
confirmLabel =
|
||||||
|
isNewSub && isTrial → "Create Trial Subscription Plan"
|
||||||
|
isNewSub && !isTrial → "Create New Subscription Plan"
|
||||||
|
otherwise → "Confirm"
|
||||||
|
```
|
||||||
|
|
||||||
|
`isNewSub` is true when the user has **no** existing package and no existing addon subscriptions — i.e. they are subscribing for the first time. `isTrial` comes from Redux `subIntent.mode === Mode.TRIALING`.
|
||||||
|
|
||||||
|
### Create New Subscription Plan
|
||||||
|
|
||||||
|
Triggered when `isNewSub=true` and `isTrial=false`. Full flow from button click through promo display in checkout:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
BTN([Click Create New Subscription Plan]) --> CS[confirmServices]
|
||||||
|
CS --> ISNEW{isNewSub?}
|
||||||
|
ISNEW -->|Yes| REGDIRECT[dispatchStartBillingInfo<br/>mode = Mode.REGULAR]
|
||||||
|
ISNEW -->|No - existing sub change| CONFIRM[Confirm dialog<br/>then dispatchStartBillingInfo<br/>mode = Mode.REGULAR]
|
||||||
|
REGDIRECT --> SBI[StartBillingInfo dispatched<br/>prorateTS = DateUtils.currUTC]
|
||||||
|
CONFIRM --> SBI
|
||||||
|
SBI --> NAV([Navigate to /checkout])
|
||||||
|
|
||||||
|
NAV --> INIT[initPage]
|
||||||
|
INIT --> ISTRIALCK{isTrial?}
|
||||||
|
ISTRIALCK -->|No - regular| INVOICES[Fetch upcoming invoices<br/>calcChkoutPayment]
|
||||||
|
INVOICES --> CAP[checkApplicablePromos]
|
||||||
|
CAP --> GATE["getPromoForLookupKey<br/>hasAnyPackageSub? NO<br/>exact or type-wide match?"]
|
||||||
|
GATE -->|promo found| PROMODISPLAY([Show promo badge + discounted price])
|
||||||
|
GATE -->|no match| NORMALPRICE([Regular price])
|
||||||
|
|
||||||
|
NAV --> LAP[loadActivePromos async]
|
||||||
|
LAP --> CAP2[checkApplicablePromos again<br/>with real activePromos]
|
||||||
|
CAP2 --> PROMODISPLAY2([Promo display updated if match])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Trial Subscription Plan
|
||||||
|
|
||||||
|
Triggered when `isNewSub=true` and `isTrial=true`. The trial flow adds `trialEnd` timestamps to the selected package and addons, then navigates to checkout:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
BTN([Click Create Trial Subscription Plan]) --> CS[confirmServices]
|
||||||
|
CS --> TRIALS[Read membership.trials<br/>Calculate trialEndDate]
|
||||||
|
TRIALS --> PKGTRIALEND["selPkg = { ...currSel.selPkg,<br/>trialEnd: trialEndDate }"]
|
||||||
|
PKGTRIALEND --> ADDONTRIALEND["selAddons = addons.map<br/>addon.trialEnd = trialEndDate"]
|
||||||
|
ADDONTRIALEND --> ISNEW{isNewSub?}
|
||||||
|
ISNEW -->|Yes| TRIALDIRECT[dispatchStartBillingInfo<br/>mode = Mode.TRIALING<br/>prorateTS = null]
|
||||||
|
ISNEW -->|No - existing trial change| TRIALCONFIRM[Confirm dialog<br/>then dispatchStartBillingInfo<br/>mode = Mode.TRIALING]
|
||||||
|
TRIALDIRECT --> SBI[StartBillingInfo dispatched]
|
||||||
|
TRIALCONFIRM --> SBI
|
||||||
|
SBI --> NAV([Navigate to /checkout])
|
||||||
|
|
||||||
|
NAV --> INIT[initPage]
|
||||||
|
INIT --> ISTRIALCK{isTrial = true}
|
||||||
|
ISTRIALCK --> TRIALITEMS["createTrialItems<br/>from selPkg + selAddons"]
|
||||||
|
TRIALITEMS --> CTIP1["checkTrialItemPromos<br/>activePromos EMPTY at this point<br/>totalPromoSavings = 0"]
|
||||||
|
CTIP1 --> AMOUNT1["amount.total = grossTotal - 0<br/>STALE - full price"]
|
||||||
|
|
||||||
|
NAV --> LAP[loadActivePromos async]
|
||||||
|
LAP --> PROMOMAP[activePromos Map built<br/>from /api/activePromos response]
|
||||||
|
PROMOMAP --> CTIP2["checkTrialItemPromos<br/>activePromos NOW loaded"]
|
||||||
|
CTIP2 --> PROMOFOUND{promo in activePromos<br/>for this lookupKey?}
|
||||||
|
PROMOFOUND -->|Yes - e.g. ess_1_1 eligibility=all| SAVINGS["totalPromoSavings recalculated<br/>paymentPromos populated"]
|
||||||
|
SAVINGS --> AMOUNTFIX["amount.total = grossTotal - totalPromoSavings<br/>UpdateAmount dispatched"]
|
||||||
|
AMOUNTFIX --> PROMODISPLAY([Show promo badge + discounted price])
|
||||||
|
PROMOFOUND -->|No match| NORMALPRICE([Full trial price - no promo])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkout Promo Display after Each Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
REG([Regular flow<br/>Mode.REGULAR]) --> REGPATH["checkApplicablePromos<br/>uses chkoutPmt.lineItems<br/>gate: hasAnyPackageSub"]
|
||||||
|
REGPATH --> REGPROMO([paymentPromos map<br/>promo badges + savings])
|
||||||
|
|
||||||
|
TRIAL([Trial flow<br/>Mode.TRIALING]) --> TRIALPATH["checkTrialItemPromos<br/>uses trialItems<br/>no subscription gate<br/>looks up activePromos directly"]
|
||||||
|
TRIALPATH --> TRIALPROMO(["paymentPromos map<br/>promo badges + discounted trial total<br/>eligibility=all promos shown"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key difference**: `checkApplicablePromos` gates on `hasAnyPackageSubscription` because it works with real invoice line items that could include existing subscriptions. `checkTrialItemPromos` skips that gate — the items are the trial package/addons only, and the server already applied eligibility filtering to `/activePromos`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## getPromoForLookupKey the Core Gate
|
||||||
|
|
||||||
|
This is the single method called by the template for every package and addon row. It returns an `ActivePromo` to display or `null` to show nothing.
|
||||||
|
|
||||||
|
Signature: `getPromoForLookupKey(lookupKey, type, mode = 'available')`
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START([getPromoForLookupKey]) --> A{promoMode === disabled?}
|
||||||
|
A -->|Yes| NULL1([return null - global kill switch])
|
||||||
|
A -->|No| B[getUserSubscriptionForLookupKey]
|
||||||
|
|
||||||
|
B --> C{User has sub for this item<br/>AND status === trialing?}
|
||||||
|
C -->|Yes| NULL2([return null - trial IS the promo])
|
||||||
|
C -->|No| D{mode === available<br/>AND userHasThis?}
|
||||||
|
|
||||||
|
D -->|Yes| NULL3([return null - item already subscribed])
|
||||||
|
D -->|No| E{mode === subscribed<br/>AND NOT userHasThis?}
|
||||||
|
|
||||||
|
E -->|Yes| NULL4([return null - item not subscribed])
|
||||||
|
E -->|No| F{mode === subscribed?}
|
||||||
|
|
||||||
|
F -->|Yes| G{promoDetails.hasPromo === true?}
|
||||||
|
G -->|Yes| CONV([return convertPromoDetailsToActivePromo])
|
||||||
|
G -->|No| NULL5([return null - no fallback for subscribed mode])
|
||||||
|
|
||||||
|
F -->|No - mode is available| H["activePromos.get(lookupKey)"]
|
||||||
|
H -->|found| EXACT([return exact-match promo])
|
||||||
|
H -->|not found| I["activePromos.get(type_all)"]
|
||||||
|
I -->|found| TYPE([return type-wide promo])
|
||||||
|
I -->|not found| NULL6([return null])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why isTrial does NOT suppress promos
|
||||||
|
|
||||||
|
Prior to v3.0, the flag `isTrial` (set when checkout intent mode is `TRIALING`) blocked all available promos. This was removed because:
|
||||||
|
|
||||||
|
- Since v3.0, the server already evaluates `eligibility` before returning promos. If a promo with `eligibility: 'all'` is returned (e.g. `ess_1_1` with a `$400 OFF` offer), it means the server has confirmed this user qualifies.
|
||||||
|
- A trial user looking at `/services` to decide whether to subscribe with auto-renewal **should** see that promo — it is the incentive to convert.
|
||||||
|
- The existing guard `status === 'trialing'` (step above) still correctly hides promos on any subscription row where the user is actively in a trial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## isAllPackagesPromo Package-Wide Banner Logic
|
||||||
|
|
||||||
|
Controls whether the green promo banner above the packages table is shown.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START([isAllPackagesPromo]) --> A{essPkgs empty?}
|
||||||
|
A -->|Yes| NULL1([return null])
|
||||||
|
A -->|No| B{User has ANY existing<br/>package subscription?}
|
||||||
|
|
||||||
|
B -->|Yes| NULL2([return null - banner only for new subscribers])
|
||||||
|
B -->|No| C["activePromos.get('package_all')"]
|
||||||
|
|
||||||
|
C -->|found| RET1([return type-wide promo])
|
||||||
|
C -->|not found| D[Map each pkg to its activePromo]
|
||||||
|
|
||||||
|
D --> E{All packages have<br/>an individual promo?}
|
||||||
|
E -->|No| NULL3([return null])
|
||||||
|
E -->|Yes| F{All promos share same<br/>discountType + discountValue?}
|
||||||
|
|
||||||
|
F -->|No| NULL4([return null])
|
||||||
|
F -->|Yes| RET2([return the shared promo])
|
||||||
|
```
|
||||||
|
|
||||||
|
> The banner is intentionally shown **only** to brand-new subscribers (no existing package subscription). Returning subscribers see promo state per-row instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Template Rendering Logic
|
||||||
|
|
||||||
|
### Package Row Structure
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
ROW([Package row rendered]) --> LEGACY{isLegacyEss1?}
|
||||||
|
|
||||||
|
LEGACY -->|Yes| LEGACYLABEL[Show legacy notice label<br/>Promo display suppressed]
|
||||||
|
LEGACY -->|No| SUB{isUserSubscribed?}
|
||||||
|
|
||||||
|
SUB -->|Yes - subscribed| ACTIVE["getPromoForLookupKey(key, package, subscribed)"]
|
||||||
|
ACTIVE -->|promo found| ACTIVELABEL["agm-active-promo-label<br/>Active Promo: DISCOUNT"]
|
||||||
|
ACTIVE -->|null| NOTHING1[No promo label]
|
||||||
|
|
||||||
|
SUB -->|No - not subscribed| AVAIL["getPromoForLookupKey(key, package, available)"]
|
||||||
|
AVAIL -->|promo found| AVAILABLELABEL["Promo name text<br/>e.g. AgMission Essentials 1 Plus"]
|
||||||
|
AVAIL -->|null| NOTHING2[No promo label]
|
||||||
|
|
||||||
|
ROW --> PRICECOL[Price column]
|
||||||
|
PRICECOL --> PROMOCHECK["getPromoForLookupKey(key, package)<br/>mode defaults to available"]
|
||||||
|
PROMOCHECK -->|promo found| CROSSEDPRICE["original-price crossed out<br/>promo-price shown<br/>Valid until date below"]
|
||||||
|
PROMOCHECK -->|null| REGULARPRICE[Regular price only]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Addon Row Structure
|
||||||
|
|
||||||
|
Same dual-mode structure as packages, with **no banner** at the top (per P2-D wireframe). Both Unit Price and Total Price columns independently call `getPromoForLookupKey` in `available` mode.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
ADDONROW([Addon row rendered]) --> SUBSCHECK{isUserSubscribed?}
|
||||||
|
|
||||||
|
SUBSCHECK -->|Yes| A2["getPromoForLookupKey(key, addon, subscribed)"]
|
||||||
|
A2 -->|promo found| A2L[agm-active-promo-label]
|
||||||
|
A2 -->|null| A2N[No promo label]
|
||||||
|
|
||||||
|
SUBSCHECK -->|No| A3["getPromoForLookupKey(key, addon, available)"]
|
||||||
|
A3 -->|promo found| A3L[Promo name text below addon name]
|
||||||
|
A3 -->|null| A3N[No promo label]
|
||||||
|
|
||||||
|
ADDONROW --> UNITPRICE[Unit Price column]
|
||||||
|
UNITPRICE --> UP["getPromoForLookupKey(key, addon)"]
|
||||||
|
UP -->|promo| UPPROMO[crossed price + promo price]
|
||||||
|
UP -->|null| UPREGULAR[Regular unit price]
|
||||||
|
|
||||||
|
ADDONROW --> TOTALPRICE[Total Price column]
|
||||||
|
TOTALPRICE --> TP["getPromoForLookupKey(key, addon)"]
|
||||||
|
TP -->|promo| TPPROMO["crossed total + promo total<br/>Valid until date below"]
|
||||||
|
TP -->|null| TPREGULAR[Regular total price]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promo Price Calculation
|
||||||
|
|
||||||
|
All math is delegated to `SubscriptionService.calculateDiscountedAmount(originalCents, promo)`:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
CALC([calculateDiscountedAmount]) --> A{discountType}
|
||||||
|
|
||||||
|
A -->|free OR discountValue === 100| ZERO([return 0])
|
||||||
|
A -->|percent| PCT["return round(original x 1 - value/100)"]
|
||||||
|
A -->|fixed| FIXED["return max(0, original - value)<br/>value is already in cents"]
|
||||||
|
```
|
||||||
|
|
||||||
|
For addons: `calculatePromoTotal(addon, promo)` = `calculatePromoPrice(addon.price, promo) × quantity`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESS_1 Legacy Special Handling
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
PKG([Package item]) --> SHOW{shouldShowPackage}
|
||||||
|
|
||||||
|
SHOW --> ESS1{lookupKey === ess_1?}
|
||||||
|
ESS1 -->|Yes| HASLEGACY{hasLegacyEss1Subscription?}
|
||||||
|
HASLEGACY -->|Yes - user has active or trialing ESS_1| VISIBLE([Show row])
|
||||||
|
HASLEGACY -->|No| HIDDEN([Hide row])
|
||||||
|
|
||||||
|
ESS1 -->|No - ess_1_1 or others| VISIBLE2([Always show row])
|
||||||
|
|
||||||
|
VISIBLE --> PROMOCHECK{isLegacyEss1?}
|
||||||
|
PROMOCHECK -->|Yes - ess_1 row| LEGACYNOTICE[Show legacy notice<br/>Promo display suppressed]
|
||||||
|
PROMOCHECK -->|No| NORMALPROMO[Normal dual-mode promo display]
|
||||||
|
```
|
||||||
|
|
||||||
|
`isLegacyEss1(lookupKey)`: returns `true` only when `lookupKey === 'ess_1'` AND the user has a legacy ESS_1 subscription. Promo display is suppressed on ESS_1 rows even if a matching global promo exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promo Display Components
|
||||||
|
|
||||||
|
| Component | Selector | Used for | Shows |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ActivePromoLabelComponent` | `agm-active-promo-label` | Subscribed users with `promoDetails.hasPromo` | Active Promo: DISCOUNT |
|
||||||
|
| `ConstraintMessageComponent` | `agm-constraint-message severity="promo"` | Package-wide banner via `isAllPackagesPromo()` | Green info box with promo message and valid-until date |
|
||||||
|
| Raw template | `div.available-promo` | Available promo name below package or addon name | Promo name text |
|
||||||
|
| Raw template | `div.price-with-promo` | Price column when promo available | Crossed-out price, promo price, valid-until date |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference Table
|
||||||
|
|
||||||
|
| User type | Item state | mode arg | Result |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Any, `PROMO_MODE=disabled` | any | any | No promo shown |
|
||||||
|
| Any | `status=trialing` subscription | any | No promo shown |
|
||||||
|
| Not subscribed | available item | `available` | Promo shown if in `activePromos` |
|
||||||
|
| Already subscribed | own subscription | `subscribed` | Promo shown if `promoDetails.hasPromo` |
|
||||||
|
| Trial user (`isTrial=true`) | not yet subscribed | `available` | Promo shown if in `activePromos` |
|
||||||
|
| Legacy ESS_1 subscriber | `ess_1` row | any | Promo suppressed, legacy notice shown |
|
||||||
|
| User with existing package sub | package-wide banner | `isAllPackagesPromo` | Banner hidden |
|
||||||
|
|
||||||
|
> **Trial users CAN see available promos.** Server-side eligibility filtering (v3.0) ensures only qualifying promos are returned. The `isTrial` flag from Redux intent no longer suppresses promo display — only active `status='trialing'` subscriptions suppress promos on their own row.
|
||||||
333
Development/client/docs/NOTIFICATION-DEEP-LINKS.md
Normal file
333
Development/client/docs/NOTIFICATION-DEEP-LINKS.md
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
# Notification Deep-Links
|
||||||
|
|
||||||
|
Reference for handling external deep-link URLs sent in customer notification emails
|
||||||
|
(e.g. "Manage your subscription", "Update payment method").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#1-overview)
|
||||||
|
2. [Registered Routes](#2-registered-routes)
|
||||||
|
3. [How It Works — Full Flow](#3-how-it-works--full-flow)
|
||||||
|
- [Authenticated user](#authenticated-user)
|
||||||
|
- [Unauthenticated user](#unauthenticated-user)
|
||||||
|
4. [Key Files](#4-key-files)
|
||||||
|
5. [Adding a New Notification URL](#5-adding-a-new-notification-url)
|
||||||
|
6. [Design Decisions](#6-design-decisions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Notification emails link customers to top-level URLs such as `/#/manage-subscription`.
|
||||||
|
These URLs must:
|
||||||
|
|
||||||
|
- **Not require authentication themselves** — the customer may be logged out
|
||||||
|
- **Skip the shell layout** — they live outside `AppMainComponent`
|
||||||
|
- **Redirect instantly** — no blank-page flash, no component rendered
|
||||||
|
- **Show a contextual notice** on the login screen when authentication is required
|
||||||
|
|
||||||
|
All of this is handled by a single `NotificationRedirectGuard` combined with route `data`.
|
||||||
|
Adding a new notification URL requires **one route entry and zero new files**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Registered Routes
|
||||||
|
|
||||||
|
All notification routes are declared at the top level in `app-routing.module.ts`,
|
||||||
|
outside the `AppMainComponent` shell:
|
||||||
|
|
||||||
|
```
|
||||||
|
/#/manage-subscription → /profile/myservices (or /profile/services if no subs)
|
||||||
|
/#/update-pm → /profile/payment-method-list
|
||||||
|
/#/update-bill-address → /profile/billing-address
|
||||||
|
```
|
||||||
|
|
||||||
|
Each route in source:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app-routing.module.ts
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'manage-subscription',
|
||||||
|
component: PageNotFoundComponent, // never rendered — guard always redirects
|
||||||
|
canActivate: [NotificationRedirectGuard],
|
||||||
|
data: {
|
||||||
|
redirectTo: ['profile', 'myservices'],
|
||||||
|
redirectToNoSubs: ['profile', 'services'],
|
||||||
|
loginNotice: $localize`@@manageSubLoginNotice:Please log in with your Master account to manage subscriptions.`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'update-pm',
|
||||||
|
component: PageNotFoundComponent,
|
||||||
|
canActivate: [NotificationRedirectGuard],
|
||||||
|
data: {
|
||||||
|
redirectTo: ['profile', 'payment-method-list'],
|
||||||
|
loginNotice: $localize`@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'update-bill-address',
|
||||||
|
component: PageNotFoundComponent,
|
||||||
|
canActivate: [NotificationRedirectGuard],
|
||||||
|
data: {
|
||||||
|
redirectTo: ['profile', 'billing-address'],
|
||||||
|
loginNotice: $localize`@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
> `PageNotFoundComponent` is the placeholder — it is already declared in `AppModule`
|
||||||
|
> and is never displayed because the guard always returns a `UrlTree` before any
|
||||||
|
> component activates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. How It Works — Full Flow
|
||||||
|
|
||||||
|
### Authenticated user
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks link in email
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Browser opens /#/manage-subscription
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Angular router matches route
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
NotificationRedirectGuard.canActivate()
|
||||||
|
│
|
||||||
|
├─ authSvc.loggedIn = true
|
||||||
|
│
|
||||||
|
├─[redirectToNoSubs defined AND master AND no subs]
|
||||||
|
│ └──→ UrlTree: /profile/services
|
||||||
|
│
|
||||||
|
└─[all other authenticated cases]
|
||||||
|
└──→ UrlTree: /profile/myservices
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AppMainComponent loads normally
|
||||||
|
/profile/myservices renders
|
||||||
|
```
|
||||||
|
|
||||||
|
The guard returns a `UrlTree` **synchronously** (auth state is rehydrated from
|
||||||
|
`sessionStorage` before any guard runs). Angular processes the redirect before
|
||||||
|
deactivating the current route or activating any component — no blank page, no
|
||||||
|
shell teardown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Unauthenticated user
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks link in email (not logged in)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Browser opens /#/manage-subscription
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
NotificationRedirectGuard.canActivate()
|
||||||
|
│
|
||||||
|
├─ authSvc.loggedIn = false
|
||||||
|
│
|
||||||
|
└──→ UrlTree: /login?returnUrl=manage-subscription
|
||||||
|
&loginNotice=<i18n message>
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
LoginComponent constructor reads queryParams
|
||||||
|
nav.extractedUrl.queryParams['loginNotice']
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Pushes { severity: 'info', detail: loginNotice }
|
||||||
|
into this.msgs → rendered by <p-messages>
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ [AgMission logo] │
|
||||||
|
│ │
|
||||||
|
│ ℹ Please log in with your Master │
|
||||||
|
│ account to manage subscriptions. │
|
||||||
|
│ │
|
||||||
|
│ Username ________________________ │
|
||||||
|
│ Password ________________________ │
|
||||||
|
│ [ LOGIN ] │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
User logs in
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
authActions.LoginSuccess dispatched
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AuthEffects.navigateDefault()
|
||||||
|
router.parseUrl(router.url)
|
||||||
|
.queryParams['returnUrl']
|
||||||
|
→ 'manage-subscription'
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
window.location.replace('/#/manage-subscription')
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
NotificationRedirectGuard runs again
|
||||||
|
(now authenticated)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Redirects to /profile/myservices
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key Files
|
||||||
|
|
||||||
|
### `src/app/domain/guards/notification-redirect.guard.ts`
|
||||||
|
|
||||||
|
The single guard that handles all notification deep-links.
|
||||||
|
|
||||||
|
**Route `data` contract:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `redirectTo` | `string[]` | Yes | Router path segments for authenticated users |
|
||||||
|
| `redirectToNoSubs` | `string[]` | No | Alternate path when master account has no subscriptions |
|
||||||
|
| `loginNotice` | `string` | No | Message shown in the `<p-messages>` bar on the login screen |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
canActivate(route: ActivatedRouteSnapshot): UrlTree {
|
||||||
|
const { redirectTo, redirectToNoSubs } = route.data;
|
||||||
|
|
||||||
|
if (!this.authSvc.loggedIn) {
|
||||||
|
const { loginNotice } = route.data;
|
||||||
|
return this.router.createUrlTree(['/login'], {
|
||||||
|
queryParams: {
|
||||||
|
returnUrl: route.url.map(s => s.path).join('/'),
|
||||||
|
...(loginNotice ? { loginNotice } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMaster = !this.authSvc.user?.parent;
|
||||||
|
if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) {
|
||||||
|
return this.router.createUrlTree(redirectToNoSubs);
|
||||||
|
}
|
||||||
|
return this.router.createUrlTree(redirectTo);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/app/auth/effects/auth.effects.ts` — `navigateDefault()`
|
||||||
|
|
||||||
|
After a successful login, reads `returnUrl` from the current router URL and
|
||||||
|
replaces the browser location to trigger the notification route again
|
||||||
|
(now authenticated):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private navigateDefault(lang) {
|
||||||
|
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
|
||||||
|
const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home';
|
||||||
|
window.location.replace((lang === 'en' ? hash : `/${lang}${hash}`) + returnUrl);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses `router.parseUrl()` instead of string splitting — correctly handles all
|
||||||
|
URL encodings.
|
||||||
|
|
||||||
|
If no `returnUrl` is present (normal login), falls back to `'home'` as before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/app/auth/login/login.component.ts` — constructor
|
||||||
|
|
||||||
|
Generic `loginNotice` handling — no hardcoded route names:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const nav = this.router.getCurrentNavigation();
|
||||||
|
if (nav) {
|
||||||
|
const msgs: any[] = [];
|
||||||
|
if (nav.extras?.state?.changedPwd) {
|
||||||
|
msgs.push({ severity: 'info', summary: '', detail: globals.pwdChangedOk });
|
||||||
|
}
|
||||||
|
const loginNotice =
|
||||||
|
nav.finalUrl?.queryParams?.['loginNotice'] ??
|
||||||
|
nav.extractedUrl?.queryParams?.['loginNotice'];
|
||||||
|
if (loginNotice) {
|
||||||
|
msgs.push({ severity: 'info', summary: '', detail: loginNotice });
|
||||||
|
}
|
||||||
|
if (msgs.length) this.msgs = msgs;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads from `getCurrentNavigation()` — the only safe place to read query params
|
||||||
|
on a login redirect since the router replaces the URL before `ngOnInit` runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Adding a New Notification URL
|
||||||
|
|
||||||
|
**Zero new files required.** Add one entry to `app-routing.module.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: 'renew-subscription', // URL path: /#/renew-subscription
|
||||||
|
component: PageNotFoundComponent, // never rendered
|
||||||
|
canActivate: [NotificationRedirectGuard],
|
||||||
|
data: {
|
||||||
|
redirectTo: ['profile', 'checkout'], // authenticated destination
|
||||||
|
// redirectToNoSubs: ['profile', 'services'], // optional alternate
|
||||||
|
loginNotice: $localize`:@@renewSubLoginNotice:Please log in with your Master account to renew your subscription.`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. The guard, the login notice, and the post-login redirect all work
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
- [ ] Add route entry with `redirectTo` (required) and optionally `redirectToNoSubs` / `loginNotice`
|
||||||
|
- [ ] Add the `loginNotice` i18n key to all locale `.xlf` translation files if the app is translated
|
||||||
|
- [ ] Provide the deep-link URL to the notifications/email team: `https://app.agmission.com/#/renew-subscription`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Design Decisions
|
||||||
|
|
||||||
|
### Why a guard returning `UrlTree` instead of a redirect component?
|
||||||
|
|
||||||
|
A component that calls `router.navigate()` in `ngOnInit` causes:
|
||||||
|
- The current shell (`AppMainComponent`) to deactivate and re-activate — visible flicker
|
||||||
|
- A blank template to render briefly while `ngOnInit` executes
|
||||||
|
|
||||||
|
A guard returning a `UrlTree` is processed by Angular **before any component is
|
||||||
|
activated or deactivated**. The redirect is invisible and instantaneous.
|
||||||
|
|
||||||
|
### Why route `data` instead of a guard per URL?
|
||||||
|
|
||||||
|
One guard file per URL creates N files for N routes with identical logic.
|
||||||
|
Putting the routing targets in `data` makes the guard a pure engine
|
||||||
|
and the route definition the configuration — consistent with Angular's
|
||||||
|
`canActivate`+`data` idiom used throughout the app (e.g. `RoleGuard` + `data.roles`).
|
||||||
|
|
||||||
|
### Why `PageNotFoundComponent` as the placeholder?
|
||||||
|
|
||||||
|
Angular requires a `component` on every non-lazy route. The component is never
|
||||||
|
rendered (the guard always redirects), so any already-declared component works.
|
||||||
|
`PageNotFoundComponent` is the most semantically appropriate fallback if the guard
|
||||||
|
ever fails to redirect for an unexpected reason.
|
||||||
|
|
||||||
|
### Why `window.location.replace()` instead of `router.navigate()` in `navigateDefault`?
|
||||||
|
|
||||||
|
This was the pre-existing pattern to prevent the login page from appearing in the
|
||||||
|
browser's Back history after authentication. `location.replace` replaces the
|
||||||
|
current history entry rather than pushing a new one.
|
||||||
|
|
||||||
|
### Sub-accounts vs. Master accounts
|
||||||
|
|
||||||
|
Subscription management (`/profile/myservices`) is only meaningful for master
|
||||||
|
accounts, but sub-accounts (users with `user.parent` set) are redirected there
|
||||||
|
too — the manage-subscription view enforces its own access rules once loaded.
|
||||||
|
`redirectToNoSubs` is only evaluated for master accounts with zero subscriptions,
|
||||||
|
sending them to the `/profile/services` plan picker instead.
|
||||||
576
Development/client/docs/SUBSCRIPTION-DISPLAY.md
Normal file
576
Development/client/docs/SUBSCRIPTION-DISPLAY.md
Normal file
@ -0,0 +1,576 @@
|
|||||||
|
# Subscription Display Reference
|
||||||
|
|
||||||
|
Quick overview of how subscriptions, pricing, and promos are displayed across the entire flow — from checkout through confirmation and the account management page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [At a Glance — Full User Journey](#1-at-a-glance--full-user-journey)
|
||||||
|
2. [Page Structure Overview](#2-page-structure-overview)
|
||||||
|
3. [Checkout Flow (3 Stages)](#3-checkout-flow--3-stages)
|
||||||
|
- [Stage 1 — Enter Payment](#stage-1--enter-payment-checkoutcomponent)
|
||||||
|
- [Stage 2 — Review & Submit](#stage-2--review--submit-checkout-reviewcomponent)
|
||||||
|
- [Stage 3 — Confirmation](#stage-3--confirmation-checkout-confirmcomponent)
|
||||||
|
4. [Manage Subscription Page](#4-manage-subscription-page-myservices)
|
||||||
|
- [Subscription State Decision Tree](#subscription-state-decision-tree)
|
||||||
|
- [Case-by-Case Pricing Display](#case-by-case-pricing-display)
|
||||||
|
5. [Shared Pricing Components](#5-shared-pricing-components)
|
||||||
|
- [`<payment-amount>` Template Guide](#payment-amount-template-guide)
|
||||||
|
- [`<payment-summary>` Mode Guide](#payment-summary-mode-guide)
|
||||||
|
6. [Canada Tax Logic](#6-canada-tax-logic)
|
||||||
|
7. [Conditional Label Reference](#7-conditional-label-reference)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. At a Glance — Full User Journey
|
||||||
|
|
||||||
|
```
|
||||||
|
User selects a plan
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────┐ ┌─────────────────────┐
|
||||||
|
│ Regular Purchase │ │ Trial Signup │
|
||||||
|
│ /checkout │ │ /checkout (isTrial) │
|
||||||
|
└────────┬──────────┘ └──────────┬───────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┴─────────────┐
|
||||||
|
│ │ │
|
||||||
|
│ Trial only Trial + "continue
|
||||||
|
│ (no card yet) after trial" checked
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
Stage 1: Stage 1: Stage 1:
|
||||||
|
Payment form $0.00 total After-trial price
|
||||||
|
(Templates 1) (Template 5) (Template 7)
|
||||||
|
│ │ │
|
||||||
|
└──────────────┴───────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Stage 2: Review
|
||||||
|
payment-summary REGULAR
|
||||||
|
(Template 2 — tax/discount/total)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
│ │ │
|
||||||
|
TRIALING CONTINUE_TRIAL REGULAR
|
||||||
|
Stage 3: Stage 3: Stage 3:
|
||||||
|
Template 5 Template 7 Template 2
|
||||||
|
($0 confirm) (after-trial (full receipt)
|
||||||
|
confirm)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
/myservices (manage-subscription)
|
||||||
|
Displays live subscription state
|
||||||
|
for all owned packages/addons
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Page Structure Overview
|
||||||
|
|
||||||
|
### `/checkout` — Stage 1
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ CHECKOUT │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────┐ │
|
||||||
|
│ │ [Regular purchase — no refund] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ payment-info (new line items) │ │
|
||||||
|
│ │ ┌──────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [IF promo active] → Template 1 │ │ │
|
||||||
|
│ │ │ [ELSE] → coupon input field │ │ │
|
||||||
|
│ │ └──────────────────────────────────────────┘ │ │
|
||||||
|
│ │ ── Credit card form ── │ │
|
||||||
|
│ └────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┬─────────────────────────────┐ │
|
||||||
|
│ │ [With refund] │ │ │
|
||||||
|
│ │ Payment column │ Refund column │ │
|
||||||
|
│ │ payment-info │ payment-info (refund items)│ │
|
||||||
|
│ │ Template 1 / │ Template 1 │ │
|
||||||
|
│ │ coupon input │ │ │
|
||||||
|
│ └──────────────────┴─────────────────────────────┘ │
|
||||||
|
│ ── Credit card form ── │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ CHECKOUT (trial — start only, isTrial=true) │
|
||||||
|
│ │
|
||||||
|
│ Trial Information │
|
||||||
|
│ payment-info (trial items) │
|
||||||
|
│ │
|
||||||
|
│ [IF promo]: 🎁 Total Promo Savings: -$X.XX │
|
||||||
|
│ After Trial Total: $X.XX ← * │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ Template 5: "Free trial until DATE" │ │
|
||||||
|
│ │ Total: $0.00 US │ │
|
||||||
|
│ └──────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ☐ I want to continue the service after trial end │
|
||||||
|
│ └─(checked)→ credit card form appears │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ CHECKOUT (trial → continuing, isContAftTrialEnd) │
|
||||||
|
│ │
|
||||||
|
│ ⚠ Your trial is active until [DATE]. You will be │
|
||||||
|
│ charged on that date. │
|
||||||
|
│ │
|
||||||
|
│ Your Subscription After Trial Ends │
|
||||||
|
│ payment-info (trial items) │
|
||||||
|
│ │
|
||||||
|
│ [IF promo]: 🎁 Total Promo Savings: -$X.XX │
|
||||||
|
│ After Trial Total *: $X.XX │
|
||||||
|
│ │
|
||||||
|
│ Total *: $X.XX │
|
||||||
|
│ [Canada only]: Plus Applicable Tax │
|
||||||
|
│ │
|
||||||
|
│ ── Credit card form ── │
|
||||||
|
│ │
|
||||||
|
│ * label changes in Canada (see §6) │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/checkout-review` — Stage 2
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────┐
|
||||||
|
│ REVIEW AND SUBMIT │
|
||||||
|
│ │
|
||||||
|
│ ✓ (success icon) │
|
||||||
|
│ │
|
||||||
|
│ payment-summary [mode]="REGULAR" │
|
||||||
|
│ ┌────────────────────────┬───────────────────────┐ │
|
||||||
|
│ │ Payment Information │ Card Info │ │
|
||||||
|
│ │ ───────────────── │ ───────────────── │ │
|
||||||
|
│ │ Template 2: │ •••• 4242 │ │
|
||||||
|
│ │ Total Excl. Tax $X.XX │ Visa Exp 12/27 │ │
|
||||||
|
│ │ Tax $X.XX │ │ │
|
||||||
|
│ │ [Discount] -$X.XX │ [Edit button] │ │
|
||||||
|
│ │ [Plan Refund]-$X.XX │ │ │
|
||||||
|
│ │ ────── │ │ │
|
||||||
|
│ │ Total $X.XX │ │ │
|
||||||
|
│ └────────────────────────┴───────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Error states: PAST_DUE / CARD_DECLINED / etc. │
|
||||||
|
│ → error banner above payment-summary] │
|
||||||
|
│ │
|
||||||
|
│ [ SUBMIT ] │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### `/checkout-confirm` — Stage 3
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ mode = TRIALING │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ ✓ Trial subscription │
|
||||||
|
│ is active │
|
||||||
|
│ │
|
||||||
|
│ payment-summary TRIALING │
|
||||||
|
│ Trial Information │
|
||||||
|
│ payment-info (items) │
|
||||||
|
│ Template 5: $0.00 │
|
||||||
|
└──────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ mode = CONTINUE_TRIAL │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ ✓ Continuation setup │
|
||||||
|
│ complete │
|
||||||
|
│ │
|
||||||
|
│ payment-summary │
|
||||||
|
│ CONTINUE_TRIAL │
|
||||||
|
│ [showApplicableTax= │
|
||||||
|
│ authSvc.isCanada] │
|
||||||
|
│ ───────────────────── │
|
||||||
|
│ Trial Information │
|
||||||
|
│ ⚠ constraint-message │
|
||||||
|
│ payment-info (items) │
|
||||||
|
│ Template 7: │
|
||||||
|
│ 🎁 Promo Savings $X │
|
||||||
|
│ Total (Before Tax) * │
|
||||||
|
│ Plus Applicable Tax * │
|
||||||
|
│ Card: •••• 4242 │
|
||||||
|
└──────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ mode = REGULAR │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ ✓ Subscription active │
|
||||||
|
│ [promo note if promo] │
|
||||||
|
│ │
|
||||||
|
│ Template 2: │
|
||||||
|
│ Total Excl. Tax $X.XX │
|
||||||
|
│ Tax $X.XX │
|
||||||
|
│ [Discount] -$X.XX │
|
||||||
|
│ [Plan Refund] -$X.XX │
|
||||||
|
│ Total $X.XX │
|
||||||
|
│ │
|
||||||
|
│ Card: •••• 4242 Visa │
|
||||||
|
└──────────────────────────┘
|
||||||
|
|
||||||
|
* label changes in Canada (see §6)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Checkout Flow — 3 Stages
|
||||||
|
|
||||||
|
### Stage 1 — Enter Payment (`checkout.component`)
|
||||||
|
|
||||||
|
#### Decision tree
|
||||||
|
|
||||||
|
```
|
||||||
|
checkout.component
|
||||||
|
│
|
||||||
|
├─[isTrial = false]────────────────── Regular purchase
|
||||||
|
│ │
|
||||||
|
│ ├─[hasRefund = false]─────── Single column
|
||||||
|
│ │ payment-info (items)
|
||||||
|
│ │ [promo?] Template 1 : coupon input
|
||||||
|
│ │ credit card form
|
||||||
|
│ │
|
||||||
|
│ └─[hasRefund = true]──────── Two columns
|
||||||
|
│ Payment col │ Refund col
|
||||||
|
│ items+T1 │ items+T1
|
||||||
|
│ credit card form below
|
||||||
|
│
|
||||||
|
└─[isTrial = true]─────────────────── Trial purchase
|
||||||
|
│
|
||||||
|
├─[isContAftTrialEnd = false]── Trial-start only
|
||||||
|
│ Trial info + items
|
||||||
|
│ [promo?] 🎁 savings + After Trial Total *
|
||||||
|
│ Template 5 ($0.00)
|
||||||
|
│ Checkbox: continue after trial?
|
||||||
|
│ └─(checked) → isContAftTrialEnd = true
|
||||||
|
│
|
||||||
|
└─[isContAftTrialEnd = true]─── Trial + continue
|
||||||
|
Constraint banner
|
||||||
|
After-trial items
|
||||||
|
[promo?] 🎁 savings + After Trial Total *
|
||||||
|
Total * / Plus Applicable Tax (CA)
|
||||||
|
Credit card form
|
||||||
|
|
||||||
|
* = "After Trial Total (Before Tax)" / "Total (Before Tax)" in Canada
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Stage 2 — Review & Submit (`checkout-review.component`)
|
||||||
|
|
||||||
|
Always renders `<payment-summary [mode]="REGULAR">` → **Template 2** inside.
|
||||||
|
|
||||||
|
```
|
||||||
|
Template 2 layout:
|
||||||
|
|
||||||
|
┌─[IF promoSavings > 0]────────────────────────────────┐
|
||||||
|
│ 🎁 Total Promo Savings: -$X.XX │
|
||||||
|
│ [IF creditAmount > 0] │
|
||||||
|
│ 🔄 Plan Refund: -$X.XX │
|
||||||
|
│ Tax: $X.XX │
|
||||||
|
│ Total: $X.XX │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─[ELSE — no promo]────────────────────────────────────┐
|
||||||
|
│ Total Excluding Tax: $X.XX │
|
||||||
|
│ Tax: $X.XX │
|
||||||
|
│ [IF discount.amountOff] │
|
||||||
|
│ (Discount): -$X.XX │
|
||||||
|
│ [IF creditAmount > 0] │
|
||||||
|
│ 🔄 Plan Refund: -$X.XX │
|
||||||
|
│ Total: $X.XX │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Stage 3 — Confirmation (`checkout-confirm.component`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Mode selection:
|
||||||
|
|
||||||
|
TRIALING ──────────────────→ payment-summary TRIALING
|
||||||
|
→ Template 5 ($0.00 + trial msg)
|
||||||
|
|
||||||
|
CONTINUE_TRIAL ────────────→ payment-summary CONTINUE_TRIAL
|
||||||
|
[showApplicableTax]="isCanada"
|
||||||
|
→ Template 7 (after-trial totals)
|
||||||
|
|
||||||
|
REGULAR (default) ─────────→ Template 2 (full tax + total receipt)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Manage Subscription Page (`/myservices`)
|
||||||
|
|
||||||
|
### Subscription State Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
manage-subscription: for each pkg in subscriptions
|
||||||
|
│
|
||||||
|
├─[TRIALING]───────────────────────────────────────────────────
|
||||||
|
│ Trial Ends: [DATE]
|
||||||
|
│ │
|
||||||
|
│ ├─[No promo — Case 2A]
|
||||||
|
│ │ After Trial: $X.XX/year
|
||||||
|
│ │
|
||||||
|
│ ├─[Promo + will continue — Case 2C]
|
||||||
|
│ │ Regular Price: $X.XX/year ← context
|
||||||
|
│ │ Paid Price: $X.XX + (save $X.XX)
|
||||||
|
│ │ [IF time-limited] After Promo Ends: $X.XX/year
|
||||||
|
│ │
|
||||||
|
│ └─[Promo + cancel at end — Case 2D]
|
||||||
|
│ Regular Price: $X.XX/year
|
||||||
|
│ (no paid price shown — trial will cancel)
|
||||||
|
│
|
||||||
|
├─[ACTIVE + hasActivePromo(pkg)]─────────────────────────────
|
||||||
|
│ │
|
||||||
|
│ ├─[isRenewalPromo — Case 2B]
|
||||||
|
│ │ 🏷 Discount badge
|
||||||
|
│ │ getRenewalPromoMessage() (e.g. "Renew by X and save!")
|
||||||
|
│ │
|
||||||
|
│ └─[existingPromo — Case 3]
|
||||||
|
│ Regular Price: $X.XX/year ← context
|
||||||
|
│ Paid Price: $X.XX + (save $X.XX)
|
||||||
|
│ [IF time-limited] After Promo Ends: $X.XX/year
|
||||||
|
│
|
||||||
|
├─[ACTIVE — no promo]────────────────────────────────────────
|
||||||
|
│ Paid Price: $X.XX/year
|
||||||
|
│
|
||||||
|
├─[CANCELED]─────────────────────────────────────────────────
|
||||||
|
│ Ended On: [DATE]
|
||||||
|
│ Previous Price: $X.XX/year
|
||||||
|
│
|
||||||
|
└─[PAST_DUE / INCOMPLETE]────────────────────────────────────
|
||||||
|
Paid Price: $X.XX/year
|
||||||
|
Next Bill Date: [DATE]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Case-by-Case Pricing Display
|
||||||
|
|
||||||
|
Each subscription card shows a **Pricing Section** and a **Details Section**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 📦 Package Name [STATUS BADGE] │
|
||||||
|
│ ───────────────────────────────────────────── │
|
||||||
|
│ PRICING SECTION (varies by case — see below) │
|
||||||
|
│ ───────────────────────────────────────────── │
|
||||||
|
│ DETAILS SECTION (always shown): │
|
||||||
|
│ Max Vehicles: N Aircraft │
|
||||||
|
│ Max Acres: N,000 / Unlimited │
|
||||||
|
│ Billing Cycle: Yearly │
|
||||||
|
│ Payment Method: Visa •••• 4242 │
|
||||||
|
│ Next Bill Date: Jan 1, 2027 ─┐ ACTIVE / │
|
||||||
|
│ Next Bill Amt: $X.XX ┘ TRIALING │
|
||||||
|
│ ───────────────────────────────────────────── │
|
||||||
|
│ [Promo section if applicable — see below] │
|
||||||
|
│ ───────────────────────────────────────────── │
|
||||||
|
│ [ MANAGE ] [ CANCEL / REACTIVATE ] │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pricing section — by case
|
||||||
|
|
||||||
|
```
|
||||||
|
CASE 2A — Trial, no promo
|
||||||
|
Trial Ends: Jan 10, 2026
|
||||||
|
After Trial: $995.00/year
|
||||||
|
|
||||||
|
CASE 2B — Active, renewal incentive promo (cancel_at_period_end)
|
||||||
|
🏷 [badge] "Renew by Jan 10, 2026 and save 50%!"
|
||||||
|
|
||||||
|
CASE 2C — Trial, promo applied, will continue after trial
|
||||||
|
Trial Ends: Jan 10, 2026
|
||||||
|
Regular Price: $995.00/year ← full price for context
|
||||||
|
Paid Price: $497.50/year (save $497.50)
|
||||||
|
After Promo Ends: $995.00/year ← only if time-limited promo
|
||||||
|
|
||||||
|
CASE 2D — Trial, promo applied, cancel at end
|
||||||
|
Trial Ends: Jan 10, 2026
|
||||||
|
Regular Price: $995.00/year
|
||||||
|
|
||||||
|
CASE 3 — Active, promo applied on subscription
|
||||||
|
Regular Price: $995.00/year ← full price for context
|
||||||
|
Paid Price: $497.50/year (save $497.50)
|
||||||
|
After Promo Ends: $995.00/year ← only if time-limited promo
|
||||||
|
|
||||||
|
ACTIVE (no promo)
|
||||||
|
Paid Price: $995.00/year
|
||||||
|
|
||||||
|
CANCELED
|
||||||
|
Ended On: Dec 31, 2025
|
||||||
|
Previous Price: $995.00/year
|
||||||
|
|
||||||
|
PAST_DUE / INCOMPLETE
|
||||||
|
Paid Price: $995.00/year
|
||||||
|
Next Bill Date: Jan 10, 2026
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Promo details block (ACTIVE non-renewal promos)
|
||||||
|
|
||||||
|
```
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
🏷 Percentage Off | Amount Off | Forever
|
||||||
|
Discount: 50% off or $497.50 off
|
||||||
|
Duration: For N months | One time | Forever
|
||||||
|
[IF expires]: Promo Expires: Dec 31, 2026 (N days left)
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
[IF pendingPromo]:
|
||||||
|
⏳ Pending Promo (from next billing cycle):
|
||||||
|
Discount / Duration / Savings
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Shared Pricing Components
|
||||||
|
|
||||||
|
### `<payment-amount>` Template Guide
|
||||||
|
|
||||||
|
The component is template-switched via `[template]="N"`.
|
||||||
|
|
||||||
|
```
|
||||||
|
Template Used In Renders
|
||||||
|
───────── ─────────────────────────────────── ──────────────────────────────────────────────
|
||||||
|
1 checkout (with active promo) Tax + discount + promo savings + Total
|
||||||
|
2 checkout-review, checkout-confirm Full grid: Excl.Tax / Tax / Discount / Total
|
||||||
|
3 (reserved) Subtotal ─── Tax ─── Discount ─── Total
|
||||||
|
4 (reserved) Coupon/discount line only
|
||||||
|
5 trial start confirm, TRIALING mode Trial msg + Total: $0.00
|
||||||
|
6 (reserved) "Will be charged after trial" note + Total
|
||||||
|
7 CONTINUE_TRIAL confirm, isContAftTrial Promo savings + Total (Before Tax)* + Tax note
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Template 1 layout
|
||||||
|
```
|
||||||
|
Tax: $X.XX US
|
||||||
|
[IF %off] 50% off: ($X.XX) US
|
||||||
|
[IF $off] ($ off): ($X.XX) US
|
||||||
|
[IF promo] 🎁 Total Promo Savings: -$X.XX US
|
||||||
|
Total: $X.XX US
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Template 2 layout
|
||||||
|
```
|
||||||
|
┌─[IF promoSavings > 0]───────────────────────────────┐
|
||||||
|
│ 🎁 Total Promo Savings: -$X.XX │
|
||||||
|
│ [Plan Refund]: -$X.XX (if > 0) │
|
||||||
|
│ Tax: $X.XX │
|
||||||
|
│ Total: $X.XX │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
┌─[ELSE]──────────────────────────────────────────────┐
|
||||||
|
│ Total Excluding Tax: $X.XX │
|
||||||
|
│ Tax: $X.XX │
|
||||||
|
│ [(Discount)]: ($X.XX) (if $off) │
|
||||||
|
│ [Plan Refund]: -$X.XX (if > 0) │
|
||||||
|
│ Total: $X.XX │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Template 5 layout
|
||||||
|
```
|
||||||
|
[msg] "Free trial until Jan 10, 2026"
|
||||||
|
Total: $0.00 US
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Template 7 layout
|
||||||
|
```
|
||||||
|
[IF promoSavings > 0]
|
||||||
|
🎁 Total Promo Savings: -$X.XX US
|
||||||
|
|
||||||
|
[!Canada] Total: $X.XX US
|
||||||
|
[Canada] Total (Before Tax): $X.XX US
|
||||||
|
Plus Applicable Tax ← only when totalAmount > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `<payment-summary>` Mode Guide
|
||||||
|
|
||||||
|
A mode-driven wrapper that picks the layout and calls `<payment-amount>` with the right template.
|
||||||
|
|
||||||
|
```
|
||||||
|
Mode Template Used Shows Card? showApplicableTax driven by
|
||||||
|
─────────────── ───────────── ─────────── ──────────────────────────────
|
||||||
|
REGULAR 2 yes N/A (Template 2 has no tax toggle)
|
||||||
|
TRIALING 5 no N/A
|
||||||
|
CONTINUE_TRIAL 7 yes [showApplicableTax] input → isCanada
|
||||||
|
```
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
|
||||||
|
| Input | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `mode` | `Mode` | `REGULAR`, `TRIALING`, or `CONTINUE_TRIAL` |
|
||||||
|
| `card` | `Card` | Credit card info for display |
|
||||||
|
| `payment` | `PaidAmount` | `{ total, totalTax, totalExcludingTax, discount, refundAmount }` |
|
||||||
|
| `trialItems` | `TrialItem[]` | Line items for trial subscriptions |
|
||||||
|
| `promoSavings` | `number` | Total promo discount |
|
||||||
|
| `showApplicableTax` | `boolean` | Passed down to `<payment-amount>` (Template 7) |
|
||||||
|
| `editable` | `boolean` | Shows Edit button in REGULAR mode |
|
||||||
|
| `promos` | `Map<string, any>` | Promo badge data for `<payment-info>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Canada Tax Logic
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// auth.service.ts
|
||||||
|
get isCanada(): boolean {
|
||||||
|
return this.user?.country === 'CA'; // populated from login response
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Where `isCanada` propagates
|
||||||
|
|
||||||
|
```
|
||||||
|
AuthService.isCanada
|
||||||
|
│
|
||||||
|
├── checkout.component (readonly authSvc exposed to template)
|
||||||
|
│ ├── "Total (Before Tax):" label [isContAftTrialEnd block]
|
||||||
|
│ ├── "After Trial Total (Before Tax):" label
|
||||||
|
│ └── "Plus Applicable Tax" div
|
||||||
|
│
|
||||||
|
└── checkout-confirm.component (readonly authSvc)
|
||||||
|
└── <payment-summary [showApplicableTax]="authSvc.isCanada">
|
||||||
|
└── <payment-amount [showApplicableTax]="showApplicableTax">
|
||||||
|
└── Template 7 tax toggle + "Plus Applicable Tax" note
|
||||||
|
```
|
||||||
|
|
||||||
|
### Label changes by country
|
||||||
|
|
||||||
|
```
|
||||||
|
Non-Canada Canada
|
||||||
|
───────────────────── ─────────────────────────────
|
||||||
|
Total label Total: Total (Before Tax):
|
||||||
|
After-trial total label After Trial Total: After Trial Total (Before Tax):
|
||||||
|
Tax line (hidden) Plus Applicable Tax
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Conditional Label Reference
|
||||||
|
|
||||||
|
| Label | Component | Renders when |
|
||||||
|
|---|---|---|
|
||||||
|
| **Total:** | All templates, default | `!showApplicableTax` |
|
||||||
|
| **Total (Before Tax):** | Template 7, `checkout.html` | Canada (`showApplicableTax = true`) |
|
||||||
|
| **Total Excluding Tax:** | Template 2, no-promo branch | Non-promo path (always) |
|
||||||
|
| **Tax:** | Templates 1, 2, 3 | Tax data available |
|
||||||
|
| **Plus Applicable Tax** | Template 7, `checkout.html` | Canada + `totalAmount > 0` |
|
||||||
|
| **After Trial Total:** | `payment-summary #trial`, `checkout.html` | `!showApplicableTax` + `promoSavings > 0` |
|
||||||
|
| **After Trial Total (Before Tax):** | `payment-summary #trial`, `checkout.html` | Canada + `promoSavings > 0` |
|
||||||
|
| **🎁 Total Promo Savings:** | Templates 2, 7; inline in checkout | `promoSavings > 0` |
|
||||||
|
| **🔄 Plan Refund:** | Template 2 | `creditAmount > 0` |
|
||||||
|
| **Regular Price:** | manage-subscription Cases 2C, 2D, 3 | Has promo applied |
|
||||||
|
| **Paid Price:** | manage-subscription Cases 2C, 3, ACTIVE, PAST_DUE | Active promo or non-promo active |
|
||||||
|
| **After Promo Ends:** | manage-subscription Cases 2C, 3 | `showAfterPromoEnds(pkg)` — time-limited promo |
|
||||||
|
| **Next Period Amount:** | manage-subscription | `nextBillAmounts[key]` loaded |
|
||||||
@ -0,0 +1,410 @@
|
|||||||
|
/* Satloc Integration Styles */
|
||||||
|
.satloc-integration-fields {
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satloc-integration-fields .ui-g {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satloc-integration-fields .form-row {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
min-height: 60px;
|
||||||
|
/* Prevent jumping when validation messages appear */
|
||||||
|
}
|
||||||
|
|
||||||
|
.satloc-integration-fields .form-row input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satloc-integration-fields .form-row label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
min-height: 20px;
|
||||||
|
/* Consistent label height */
|
||||||
|
}
|
||||||
|
|
||||||
|
.satloc-integration-fields .ui-message {
|
||||||
|
margin-top: 5px;
|
||||||
|
min-height: 20px;
|
||||||
|
/* Consistent error message height */
|
||||||
|
}
|
||||||
|
|
||||||
|
.satloc-connection-status {
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #0c5460;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-error {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-success {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-details {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-details small {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge i {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inactive {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
FORM FIELD SPACING AND LAYOUT - UX AUDIT COMPLIANCE
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Consistent form field structure */
|
||||||
|
.form-row {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
/* Increased from 15px for better visual separation */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standardized field labels */
|
||||||
|
.field-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
/* Consistent spacing between label and input */
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
/* UX audit recommendation for readability */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline constraint message (beside label) */
|
||||||
|
.field-label .inline-constraint {
|
||||||
|
margin-left: 6px;
|
||||||
|
/* Small gap between label text and icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override constraint wrapper width for inline display */
|
||||||
|
.field-label .inline-constraint ::ng-deep .agm-constraint-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
width: auto;
|
||||||
|
/* Override default 100% width */
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standardized field input containers */
|
||||||
|
.field-input {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
/* Space between input and any messages */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Standardized message spacing */
|
||||||
|
.field-message {
|
||||||
|
margin-top: 8px !important;
|
||||||
|
/* Override inline styles for consistency */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test Connection Section Specific Styling */
|
||||||
|
.test-connection-section {
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
/* Visual separator */
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-connection-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
/* Consistent spacing between button and status indicators */
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading indicator standardization */
|
||||||
|
.loading-indicator {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive spacing adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-row {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
/* Slightly reduced for mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-connection-section {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-connection-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button spacing */
|
||||||
|
.p-button {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-button:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message styling */
|
||||||
|
.p-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner center alignment */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.satloc-integration-fields {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satloc-connection-status {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection Status Badge - Circular design similar to topbar-badge */
|
||||||
|
.connection-status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 2px solid;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-badge:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-badge.success {
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
border-color: #1e7e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-badge.error {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-badge.warning {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #212529;
|
||||||
|
border-color: #e0a800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================ */
|
||||||
|
/* PHASE 3: SAVE CREDENTIALS DIALOG STYLES */
|
||||||
|
/* ============================================================================ */
|
||||||
|
|
||||||
|
/* Dialog content container */
|
||||||
|
.dialog-content {
|
||||||
|
padding: 1rem 0;
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
color: #212121;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message styling */
|
||||||
|
.success-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #E8F5E9;
|
||||||
|
border-left: 4px solid #4CAF50;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #2E7D32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message i {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Save prompt paragraph */
|
||||||
|
.dialog-content>p {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #212121;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styling overrides for dialog footer */
|
||||||
|
::ng-deep .ui-dialog-footer .ui-button-secondary {
|
||||||
|
background-color: #757575;
|
||||||
|
border-color: #757575;
|
||||||
|
color: #ffffff;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .ui-dialog-footer .ui-button-secondary:hover {
|
||||||
|
background-color: #616161;
|
||||||
|
border-color: #616161;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .ui-dialog-footer .ui-button-success {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
border-color: #4CAF50;
|
||||||
|
color: #ffffff;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .ui-dialog-footer .ui-button-success:hover {
|
||||||
|
background-color: #2E7D32;
|
||||||
|
border-color: #2E7D32;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dialog-content {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content>p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* PHASE 4: POST-SAVE VALIDATION STYLING
|
||||||
|
* ============================================================================ */
|
||||||
|
|
||||||
|
/* Post-save validation message spacing */
|
||||||
|
.post-save-message {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post-save validation progress indicator */
|
||||||
|
.validation-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 3px;
|
||||||
|
border-left: 4px solid #4CAF50;
|
||||||
|
/* AgMission primary green */
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-progress i {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #4CAF50;
|
||||||
|
/* AgMission primary green */
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-progress span {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #212121;
|
||||||
|
/* AgMission text color */
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive adjustments for post-save validation */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.post-save-message {
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-progress {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-progress i {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-progress span {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,24 +8,169 @@
|
|||||||
<user-profile-form formControlName="profile" [focusOnFirst]="isNew"></user-profile-form>
|
<user-profile-form formControlName="profile" [focusOnFirst]="isNew"></user-profile-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
|
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
|
||||||
<span style="margin-right:12px">
|
<label for="accountType" class="field-label">
|
||||||
<ng-container i18n="@@accountType">Account Type</ng-container>:
|
<ng-container i18n="@@accountType">Account Type</ng-container>
|
||||||
</span>
|
<!-- Account Type Disabled Feedback - Icon inline with label (detached mode) -->
|
||||||
<p-dropdown name="type" formControlName="kind" [options]="kinds" [style]="{'min-width': '120px'}">
|
<agm-constraint-message #accountTypeConstraint *ngIf="shouldShowAccountTypeDisabledMessage"
|
||||||
<ng-template let-type pTemplate="item">
|
[collapsible]="true" [detached]="true" [message]="accountTypeConstraintMessage"
|
||||||
<span>
|
[title]="accountTypeConstraintTitle" severity="info" icon="pi-info-circle" class="inline-constraint">
|
||||||
<strong>{{ type.label }}</strong>
|
</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>
|
</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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Account constraint message appears below account-editor (detached content) -->
|
||||||
|
<div *ngIf="shouldShowAccountTypeDisabledMessage" class="ui-g-12">
|
||||||
|
<ng-container *ngTemplateOutlet="accountEditor?.accountConstraint?.detachedContentTemplate"></ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
||||||
<button pButton [disabled]="form.invalid" type="button" style="width:auto"
|
<button pButton [disabled]="form.invalid" type="button" style="width:auto"
|
||||||
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save" (click)="saveAccount(); false"></button>
|
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
|
||||||
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back" (click)="goBack()" [label]="globals.back"></button>
|
(click)="saveAccount(); false"></button>
|
||||||
|
<button pButton type="button" style="width:auto" class="amber-btn" icon="ui-icon-arrow-back"
|
||||||
|
(click)="goBack()" [label]="globals.back"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,10 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p-table #dt [columns]="cols" [value]="accounts" 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">
|
<ng-template pTemplate="caption">
|
||||||
<span class="table-caption-1" i18n="@@acountList">Account List</span>
|
<span class="table-caption-1" i18n="@@acountList">Account List</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@ -16,7 +19,8 @@
|
|||||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||||
<div class="input-with-icon" *ngSwitchCase="true">
|
<div class="input-with-icon" *ngSwitchCase="true">
|
||||||
<i class="ui-icon-search"></i>
|
<i class="ui-icon-search"></i>
|
||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
|
[value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<span *ngSwitchDefault></span>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
@ -27,7 +31,8 @@
|
|||||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||||
<span class="ui-column-title">{{col.header}}</span>
|
<span class="ui-column-title">{{col.header}}</span>
|
||||||
<span *ngSwitchCase="KIND">{{ resolveFieldData(rowData, col.field) | userType }}</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>
|
<span *ngSwitchDefault>{{ resolveFieldData(rowData, col.field) }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@ -35,9 +40,12 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</p-table>
|
</p-table>
|
||||||
<div class="ui-widget-header ui-helper-clearfix toolbar">
|
<div class="ui-widget-header ui-helper-clearfix toolbar">
|
||||||
<button type="button" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newAccount()" i18n-label="@@new" label="New"></button>
|
<button type="button" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newAccount()" i18n-label="@@new"
|
||||||
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editAccount()" i18n-label="@@detail" label="Detail"></button>
|
label="New"></button>
|
||||||
<button type="button" [disabled]="!canEdit" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteAccount()" i18n-label="@@delete" label="Delete"></button>
|
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editAccount()"
|
||||||
|
i18n-label="@@detail" label="Detail"></button>
|
||||||
|
<button type="button" [disabled]="!canDelete" *ngIf="canWrite" pButton icon="ui-icon-trash"
|
||||||
|
(click)="deleteAccount()" i18n-label="@@delete" label="Delete"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { User } from '../models/user.model';
|
|||||||
import * as fromUsers from '../reducers';
|
import * as fromUsers from '../reducers';
|
||||||
import * as userActions from '../actions/account.actions';
|
import * as userActions from '../actions/account.actions';
|
||||||
|
|
||||||
import { RoleIds, globals } from '@app/shared/global';
|
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global';
|
||||||
import { BaseComp } from '@app/shared/base/base.component';
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
import { Utils } from '@app/shared/utils';
|
import { Utils } from '@app/shared/utils';
|
||||||
|
|
||||||
@ -20,8 +20,9 @@ import { Utils } from '@app/shared/utils';
|
|||||||
export class AccountListComponent extends BaseComp implements OnInit, OnDestroy {
|
export class AccountListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||||
readonly resolveFieldData = Utils.resolveFieldData;
|
readonly resolveFieldData = Utils.resolveFieldData;
|
||||||
readonly KIND = 'kind';
|
readonly KIND = 'kind';
|
||||||
readonly ACTIVE = 'active';
|
readonly ACTIVE = OperationalStatus.ACTIVE;
|
||||||
accounts: Array<User>;
|
accounts: Array<User>;
|
||||||
|
isLoading: boolean;
|
||||||
currAcc: User;
|
currAcc: User;
|
||||||
cols: any[];
|
cols: any[];
|
||||||
userFilter: string;
|
userFilter: string;
|
||||||
@ -51,11 +52,10 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.sub$ = this.store.select(fromUsers.getAllUsers).subscribe(users => this.accounts = users);
|
this.sub$ = this.store.select(fromUsers.getAllUsers).subscribe(users => this.accounts = users);
|
||||||
|
this.sub$.add(this.store.select(fromUsers.getIsLoading).subscribe(loading => this.isLoading = loading));
|
||||||
this.sub$.add(this.store.select(fromUsers.getSelectedUser).subscribe(
|
this.sub$.add(this.store.select(fromUsers.getSelectedUser).subscribe(
|
||||||
(acc) => this.currAcc = acc
|
(acc) => this.currAcc = acc
|
||||||
));
|
));
|
||||||
// Always fetch the fresh list of accounts
|
|
||||||
this.store.dispatch(new userActions.Fetch());
|
this.store.dispatch(new userActions.Fetch());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,6 +71,13 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
return (this.currAcc && this.currAcc._id !== '0');
|
return (this.currAcc && this.currAcc._id !== '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canDelete() {
|
||||||
|
// WI-2: Soft lock - Allow deletion of all account types including vendor accounts
|
||||||
|
// Previously: blocked PARTNER_SYSTEM_USER accounts
|
||||||
|
// Now: allowed with warning confirmation dialog (see deleteAccount)
|
||||||
|
return this.canEdit;
|
||||||
|
}
|
||||||
|
|
||||||
newAccount() {
|
newAccount() {
|
||||||
this.router.navigate(['account', '0'], { relativeTo: this.route });
|
this.router.navigate(['account', '0'], { relativeTo: this.route });
|
||||||
}
|
}
|
||||||
@ -81,8 +88,19 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
|
|
||||||
deleteAccount() {
|
deleteAccount() {
|
||||||
if (!this.currAcc) { return; }
|
if (!this.currAcc) { return; }
|
||||||
|
|
||||||
|
// WI-2: Soft lock - Show special warning for vendor accounts
|
||||||
|
const isVendorAccount = this.currAcc?.kind === RoleIds.PARTNER_SYSTEM_USER;
|
||||||
|
const message = isVendorAccount
|
||||||
|
? Labels.VENDOR_DELETE_CONFIRM_MESSAGE
|
||||||
|
: globals.confirmDeleteThing.replace('#thing#', globals.account);
|
||||||
|
const header = isVendorAccount ? Labels.VENDOR_DELETE_CONFIRM_TITLE : undefined;
|
||||||
|
|
||||||
this.confirmSvc.confirm({
|
this.confirmSvc.confirm({
|
||||||
message: globals.confirmDeleteThing.replace('#thing#', globals.account),
|
header: header,
|
||||||
|
message: message,
|
||||||
|
acceptLabel: globals.yes,
|
||||||
|
rejectLabel: globals.no,
|
||||||
accept: () => {
|
accept: () => {
|
||||||
this.store.dispatch(new userActions.Delete(this.currAcc));
|
this.store.dispatch(new userActions.Delete(this.currAcc));
|
||||||
this.currAcc = null;
|
this.currAcc = null;
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { CheckboxModule } from 'primeng/checkbox';
|
|||||||
import { AutoCompleteModule } from 'primeng/autocomplete';
|
import { AutoCompleteModule } from 'primeng/autocomplete';
|
||||||
import { ToolbarModule } from 'primeng/toolbar';
|
import { ToolbarModule } from 'primeng/toolbar';
|
||||||
import { InputSwitchModule } from 'primeng/inputswitch';
|
import { InputSwitchModule } from 'primeng/inputswitch';
|
||||||
|
import { TooltipModule } from 'primeng/tooltip';
|
||||||
|
|
||||||
import { TableModule } from 'primeng/table';
|
import { TableModule } from 'primeng/table';
|
||||||
import { CalendarModule } from 'primeng/calendar';
|
import { CalendarModule } from 'primeng/calendar';
|
||||||
@ -37,6 +38,7 @@ import { FEATURE_KEY, reducer } from './reducers/users.reducer';
|
|||||||
ToolbarModule,
|
ToolbarModule,
|
||||||
SplitButtonModule,
|
SplitButtonModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
|
TooltipModule,
|
||||||
|
|
||||||
StoreModule.forFeature(FEATURE_KEY, reducer),
|
StoreModule.forFeature(FEATURE_KEY, reducer),
|
||||||
EffectsModule.forFeature([AccountEffects]),
|
EffectsModule.forFeature([AccountEffects]),
|
||||||
|
|||||||
@ -22,7 +22,12 @@ export const CREATE = '[USERS] Create a user';
|
|||||||
export class Create implements Action {
|
export class Create implements Action {
|
||||||
type: typeof CREATE = CREATE;
|
type: typeof CREATE = CREATE;
|
||||||
|
|
||||||
constructor(readonly payload: User) { }
|
constructor(readonly payload: User & {
|
||||||
|
partnerConfig?: {
|
||||||
|
vendorSystemType: string;
|
||||||
|
vendorConfiguration: any;
|
||||||
|
};
|
||||||
|
}) { }
|
||||||
}
|
}
|
||||||
export const CREATE_SUCCESS = '[USERS] Create user success';
|
export const CREATE_SUCCESS = '[USERS] Create user success';
|
||||||
export class CreateSuccess implements Action {
|
export class CreateSuccess implements Action {
|
||||||
@ -39,7 +44,12 @@ export const UPDATE = '[USERS] Update user';
|
|||||||
export class Update implements Action {
|
export class Update implements Action {
|
||||||
type: typeof UPDATE = UPDATE;
|
type: typeof UPDATE = UPDATE;
|
||||||
|
|
||||||
constructor(readonly payload: User) { }
|
constructor(readonly payload: User & {
|
||||||
|
partnerConfig?: {
|
||||||
|
vendorSystemType: string;
|
||||||
|
vendorConfiguration: any;
|
||||||
|
};
|
||||||
|
}) { }
|
||||||
}
|
}
|
||||||
export const UPDATE_SUCCESS = '[USERS] Update user success';
|
export const UPDATE_SUCCESS = '[USERS] Update user success';
|
||||||
export class UpdateSuccess implements Action {
|
export class UpdateSuccess implements Action {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { map, switchMap, catchError } from 'rxjs/operators';
|
import { map, switchMap, catchError, repeat } from 'rxjs/operators';
|
||||||
|
|
||||||
import { Action } from '@ngrx/store';
|
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 { UserService } from '@app/domain/services/user.service';
|
||||||
import { AuthService } from '@app/domain/services/auth.service';
|
import { AuthService } from '@app/domain/services/auth.service';
|
||||||
import { AppMessageService } from '@app/shared/app-message.service';
|
import { AppMessageService } from '@app/shared/app-message.service';
|
||||||
import { 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()
|
@Injectable()
|
||||||
export class AccountEffects {
|
export class AccountEffects {
|
||||||
@ -17,7 +19,8 @@ export class AccountEffects {
|
|||||||
private readonly actions$: Actions,
|
private readonly actions$: Actions,
|
||||||
private readonly userSvc: UserService,
|
private readonly userSvc: UserService,
|
||||||
private readonly authSvc: AuthService,
|
private readonly authSvc: AuthService,
|
||||||
private readonly msgSvc: AppMessageService
|
private readonly msgSvc: AppMessageService,
|
||||||
|
private readonly partnerSvc: PartnerService
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,55 +28,250 @@ export class AccountEffects {
|
|||||||
loadUsers$: Observable<Action> = this.actions$.pipe(
|
loadUsers$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType<userActions.Fetch>(userActions.FETCH),
|
ofType<userActions.Fetch>(userActions.FETCH),
|
||||||
switchMap(() =>
|
switchMap(() =>
|
||||||
|
// All account types (including PARTNER_SYSTEM_USER) are returned by the backend
|
||||||
|
// /api/users/search endpoint — no separate /api/partners/systemUsers call needed.
|
||||||
this.userSvc.loadUsers({ byPuid: this.authSvc.user.parent }).pipe(
|
this.userSvc.loadUsers({ byPuid: this.authSvc.user.parent }).pipe(
|
||||||
map(users => new userActions.FetchSuccess(users)),
|
map(users => new userActions.FetchSuccess(users))
|
||||||
catchError(err => {
|
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.accounts));
|
|
||||||
return of(new userActions.FetchError());
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
|
catchError(err => this.handleUserOperationError(err, 'load')),
|
||||||
|
repeat()
|
||||||
);
|
);
|
||||||
|
|
||||||
@Effect()
|
@Effect()
|
||||||
createUser$: Observable<Action> = this.actions$.pipe(
|
createUser$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType<userActions.Create>(userActions.CREATE),
|
ofType<userActions.Create>(userActions.CREATE),
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) => {
|
||||||
this.userSvc.saveUser(payload).pipe(
|
// Extract user data and partner config from payload
|
||||||
map((user) => new userActions.CreateSuccess(user)),
|
const { partnerConfig, ...userData } = payload;
|
||||||
catchError(err => {
|
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.account));
|
// For partner system users, create them directly through PartnerService
|
||||||
return of(new userActions.CreateFailed())
|
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()
|
@Effect()
|
||||||
updateUser$: Observable<Action> = this.actions$.pipe(
|
updateUser$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType<userActions.Update>(userActions.UPDATE),
|
ofType<userActions.Update>(userActions.UPDATE),
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) => {
|
||||||
this.userSvc.saveUser(payload).pipe(
|
// Extract user data and partner config from payload
|
||||||
map(() => new userActions.UpdateSuccess(payload)),
|
const { partnerConfig, ...userData } = payload;
|
||||||
catchError(err => {
|
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.account));
|
// Case 1: User WITHOUT partner - use UserService directly + cleanup
|
||||||
return of(new userActions.UpdateFailed());
|
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()
|
@Effect()
|
||||||
deleteUser$: Observable<Action> = this.actions$.pipe(
|
deleteUser$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType<userActions.Delete>(userActions.DELETE),
|
ofType<userActions.Delete>(userActions.DELETE),
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) => {
|
||||||
this.userSvc.deleteUser(payload).pipe(
|
// Check if the user is a PARTNER_SYSTEM_USER
|
||||||
map(() => new userActions.DeleteSuccess(payload)),
|
if (payload.kind === RoleIds.PARTNER_SYSTEM_USER) {
|
||||||
catchError(err => {
|
// Backend only disables partner system users (sets active=false), it does NOT remove them.
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.account));
|
// Dispatch UpdateSuccess so the store reflects the disabled state in-place rather than
|
||||||
return of(new userActions.UpdateFailed())
|
// removing the row — which would cause it to reappear on the next reload.
|
||||||
})
|
return this.partnerSvc.deleteSystemUser(payload._id).pipe(
|
||||||
)
|
map(() => new userActions.UpdateSuccess({ ...payload, active: false }))
|
||||||
)
|
);
|
||||||
|
} else {
|
||||||
|
// Use UserService for regular users
|
||||||
|
return this.userSvc.deleteUser(payload).pipe(
|
||||||
|
map(() => new userActions.DeleteSuccess(payload))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
catchError(err => this.handleUserOperationError(err, 'delete')),
|
||||||
|
repeat()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Partner user workflow methods - use PartnerService exclusively
|
||||||
|
private createPartnerSystemUser(userData: any, partnerConfig: any): Observable<Action> {
|
||||||
|
// Get partner ID based on vendor type
|
||||||
|
return this.getPartnerByVendorType(partnerConfig.vendorSystemType).pipe(
|
||||||
|
switchMap(partnerId => {
|
||||||
|
if (!partnerId) {
|
||||||
|
throw new Error(`Failed to get partner for vendor type: ${partnerConfig.vendorSystemType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create vendor-specific system user data
|
||||||
|
const createData = this.buildPartnerSystemUserData(userData, partnerConfig, partnerId);
|
||||||
|
|
||||||
|
return this.partnerSvc.createSystemUser(createData).pipe(
|
||||||
|
map((systemUser) => {
|
||||||
|
// ✅ FIX: Return the created system user with customerId/partnerId for post-save validation
|
||||||
|
// Merge the saved systemUser data with original userData to preserve all fields
|
||||||
|
return new userActions.CreateSuccess({
|
||||||
|
...userData,
|
||||||
|
...systemUser,
|
||||||
|
// Ensure we have the IDs for post-save validation
|
||||||
|
customer: systemUser.customer || createData.customerId,
|
||||||
|
partner: systemUser.partner || createData.partnerId
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updatePartnerUserWorkflow(userData: any, partnerConfig: any): Observable<any> {
|
||||||
|
// Use getSystemUserById to directly fetch the partner system user
|
||||||
|
return this.partnerSvc.getSystemUserById(userData._id).pipe(
|
||||||
|
switchMap(existingSystemUser => {
|
||||||
|
if (existingSystemUser) {
|
||||||
|
// Update existing partner system user with backend-compatible structure
|
||||||
|
const updateData = this.buildPartnerSystemUserData(userData, partnerConfig, existingSystemUser.partner._id);
|
||||||
|
|
||||||
|
return this.partnerSvc.updateSystemUser(existingSystemUser._id!, updateData).pipe(
|
||||||
|
map(() => userData) // Return the user data
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Partner system user doesn't exist, return error
|
||||||
|
throw new Error('Partner system user not found for update');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build partner system user data structure based on vendor type
|
||||||
|
* This method can be extended to support additional vendors
|
||||||
|
*/
|
||||||
|
private buildPartnerSystemUserData(userData: any, partnerConfig: any, partnerId: string): any {
|
||||||
|
return {
|
||||||
|
partnerId: partnerId,
|
||||||
|
customerId: userData.parent, // AgMission customer (main applicator account)
|
||||||
|
username: userData.username,
|
||||||
|
password: userData.password,
|
||||||
|
name: userData.name,
|
||||||
|
active: userData.active,
|
||||||
|
email: userData.email,
|
||||||
|
address: userData.address,
|
||||||
|
phone: userData.phone,
|
||||||
|
companyId: partnerConfig.vendorConfiguration.companyId || null,
|
||||||
|
apiKey: partnerConfig.vendorConfiguration.apiKey || null,
|
||||||
|
apiSecret: partnerConfig.vendorConfiguration.apiSecret || null
|
||||||
|
// NOTE: metadata intentionally omitted — partner identity is carried by
|
||||||
|
// partnerId (ObjectId). metadata.vendor was a fragile frontend-derived
|
||||||
|
// copy that could silently diverge from the partner document.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get partner ID by vendor type
|
||||||
|
* This method can be extended to support additional vendors
|
||||||
|
*/
|
||||||
|
private getPartnerByVendorType(vendorType: string): Observable<string | null> {
|
||||||
|
return this.partnerSvc.getPartners().pipe(
|
||||||
|
map((partners: any[]) => {
|
||||||
|
let partner = null;
|
||||||
|
|
||||||
|
switch (vendorType) {
|
||||||
|
case KnownPartnerCodes.SATLOC:
|
||||||
|
partner = partners.find(p =>
|
||||||
|
p.partnerCode === KnownPartnerCodes.SATLOC.toUpperCase() ||
|
||||||
|
p.name?.toLowerCase().includes(KnownPartnerCodes.SATLOC)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Add additional vendors here as needed
|
||||||
|
// case 'other_vendor':
|
||||||
|
// partner = partners.find(p =>
|
||||||
|
// p.partnerCode === 'OTHER_VENDOR' ||
|
||||||
|
// p.name?.toLowerCase().includes('other_vendor')
|
||||||
|
// );
|
||||||
|
// break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Fallback: try to find partner by name or code matching vendor type
|
||||||
|
partner = partners.find(p =>
|
||||||
|
p.partnerCode?.toLowerCase() === vendorType.toLowerCase() ||
|
||||||
|
p.name?.toLowerCase().includes(vendorType.toLowerCase())
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return partner ? partner._id : null;
|
||||||
|
}),
|
||||||
|
catchError(() => of(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupPartnerSystemUsers(userId: string): Observable<any> {
|
||||||
|
return this.partnerSvc.getSystemUsersForCustomer(userId).pipe(
|
||||||
|
switchMap((systemUsers: PartnerSystemUser[]) => {
|
||||||
|
if (systemUsers.length === 0) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all system users for this customer
|
||||||
|
const deleteOperations = systemUsers.map(systemUser =>
|
||||||
|
this.partnerSvc.deleteSystemUser(systemUser._id!).pipe(
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Failed to delete partner system user:', error);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for all delete operations to complete
|
||||||
|
return of(...deleteOperations);
|
||||||
|
}),
|
||||||
|
catchError(error => {
|
||||||
|
console.error('Failed to load partner system users for cleanup:', error);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centralized error handler for user operations following subscription.effects pattern
|
||||||
|
private handleUserOperationError(err: any, operation: 'create' | 'save' | 'delete' | 'load'): Observable<Action> {
|
||||||
|
const actionVerb = operation === 'create' ? globals.create :
|
||||||
|
operation === 'save' ? globals.save :
|
||||||
|
operation === 'delete' ? globals.delete : globals.load;
|
||||||
|
|
||||||
|
// For load operation, use 'accounts' (plural), for others use 'account' (singular)
|
||||||
|
const thingName = operation === 'load' ? globals.accounts : globals.account;
|
||||||
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', actionVerb).replace('#thing#', thingName));
|
||||||
|
|
||||||
|
if (operation === 'create') {
|
||||||
|
return of(new userActions.CreateFailed());
|
||||||
|
} else if (operation === 'save') {
|
||||||
|
return of(new userActions.UpdateFailed());
|
||||||
|
} else if (operation === 'delete') {
|
||||||
|
return of(new userActions.UpdateFailed()); // Note: There's no DeleteFailed action, using UpdateFailed
|
||||||
|
} else {
|
||||||
|
return of(new userActions.FetchError());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Address } from '@app/domain/models/subscription.model';
|
import { Address } from '@app/domain/models/subscription.model';
|
||||||
import { RoleIds } from '@app/shared/global';
|
import { RoleIds, OperationalStatusType } from '@app/shared/global';
|
||||||
|
|
||||||
interface RoleArray {
|
interface RoleArray {
|
||||||
[index: number]: string;
|
[index: number]: string;
|
||||||
@ -10,20 +10,98 @@ export interface User {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
address?: string;
|
address?: string | null;
|
||||||
country?: string;
|
country?: string;
|
||||||
Country?: any;
|
phone?: string | null;
|
||||||
phone?: string;
|
email?: string | null;
|
||||||
email?: string;
|
|
||||||
kind: string;
|
kind: string;
|
||||||
roles?: RoleArray;
|
roles?: RoleArray;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
parent?: any;
|
parent?: any;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
addresses?: Address[];
|
addresses?: Address[];
|
||||||
billAddress?;
|
billAddress?;
|
||||||
needReview?: boolean;
|
needReview?: boolean;
|
||||||
|
|
||||||
|
// Optional partner system fields (present for partner system users)
|
||||||
|
customer?: string | { _id: string; username: string; name: string; kind: string; };
|
||||||
|
partner?: string | { _id: string; name: string; kind: string; };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PartnerSystemUser extends User with partner-specific fields
|
||||||
|
export interface PartnerSystemUser extends User {
|
||||||
|
// Partner relationships (populated objects from backend via .populate())
|
||||||
|
// NOTE: backend uses .lean() so the 'customer' Mongoose virtual is NOT present.
|
||||||
|
// 'parent' is populated as { _id, username, name, kind } in API responses.
|
||||||
|
partner: {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
partnerCode?: string;
|
||||||
|
kind: string;
|
||||||
|
};
|
||||||
|
// 'customer' virtual from Mongoose is NOT returned by .lean(). Use 'parent' instead.
|
||||||
|
customer?: {
|
||||||
|
_id: string;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Partner system credentials
|
||||||
|
partnerUserId?: string; // User ID in partner system
|
||||||
|
partnerUsername?: string; // Username in partner system
|
||||||
|
companyId?: string | null; // Company ID in partner system
|
||||||
|
|
||||||
|
// Access credentials (encrypted in production)
|
||||||
|
apiKey?: string | null;
|
||||||
|
apiSecret?: string | null;
|
||||||
|
|
||||||
|
// Status and metadata
|
||||||
|
lastLoginAt?: Date;
|
||||||
|
lastSyncAt?: Date;
|
||||||
|
syncStatus?: OperationalStatusType;
|
||||||
|
|
||||||
|
// Partner-specific metadata (contains vendor config)
|
||||||
|
metadata?: {
|
||||||
|
vendor?: string;
|
||||||
|
satlocUrl?: string;
|
||||||
|
satlocUsername?: string;
|
||||||
|
satlocPassword?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Additional fields from backend response
|
||||||
|
address?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatlocConnectionResult {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
connectionTime?: number;
|
||||||
|
serverInfo?: {
|
||||||
|
version?: string;
|
||||||
|
capabilities?: string[];
|
||||||
|
};
|
||||||
|
account_info?: SatlocAccountInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatlocAccountInfo {
|
||||||
|
company_name: string;
|
||||||
|
aircraft_count: number;
|
||||||
|
api_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatlocIntegration {
|
||||||
|
enabled: boolean;
|
||||||
|
status: OperationalStatusType;
|
||||||
|
account_info: SatlocAccountInfo | null;
|
||||||
|
credentials_stored: boolean;
|
||||||
|
last_error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNewUser = (parentId?: string, kind: String = RoleIds.APP_ADM) => {
|
export const createNewUser = (parentId?: string, kind: String = RoleIds.APP_ADM) => {
|
||||||
|
|||||||
@ -28,6 +28,9 @@ export function reducer(
|
|||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
|
||||||
case actions.FETCH:
|
case actions.FETCH:
|
||||||
|
// Clear stale entities immediately so the list never shows old data while loading.
|
||||||
|
return adapter.removeAll({ ...state, loading: true });
|
||||||
|
|
||||||
case actions.CREATE:
|
case actions.CREATE:
|
||||||
case actions.UPDATE:
|
case actions.UPDATE:
|
||||||
case actions.DELETE:
|
case actions.DELETE:
|
||||||
|
|||||||
@ -185,6 +185,12 @@ export class UpdateAmount implements Action {
|
|||||||
constructor(readonly payload: PaidAmount) { }
|
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
|
// Resolving payment session actions
|
||||||
export const FETCH_LATEST_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription';
|
export const FETCH_LATEST_SUBSCRIPTION = '[SUBSCRIPTION Resolving payment session] Fetch latest subscription';
|
||||||
export class FetchLatestSubscription implements Action {
|
export class FetchLatestSubscription implements Action {
|
||||||
@ -506,6 +512,7 @@ export type SubscriptionIntentAction =
|
|||||||
| UpdateBillingAddressSuccess
|
| UpdateBillingAddressSuccess
|
||||||
| UpdateSubscriptionSuccess
|
| UpdateSubscriptionSuccess
|
||||||
| UpdateAmount
|
| UpdateAmount
|
||||||
|
| UpdatePromoSavings
|
||||||
| ClearPrevStage
|
| ClearPrevStage
|
||||||
| GotoUsageDetail
|
| GotoUsageDetail
|
||||||
| LoadStripe
|
| LoadStripe
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { ReportComponent } from './report.component';
|
|||||||
import { AppMainComponent } from './app.main.component';
|
import { AppMainComponent } from './app.main.component';
|
||||||
import { AppPreloader } from './app-preloader';
|
import { AppPreloader } from './app-preloader';
|
||||||
import { AppPasswordResetComp } from './pages/app.password-reset.component';
|
import { AppPasswordResetComp } from './pages/app.password-reset.component';
|
||||||
|
import { NotificationRedirectGuard } from './domain/guards/notification-redirect.guard';
|
||||||
import { SettingsGuard } from './domain/guards/settings-guard.service';
|
import { SettingsGuard } from './domain/guards/settings-guard.service';
|
||||||
import { MembershipResolver } from './domain/resolvers/membership-resolver';
|
import { MembershipResolver } from './domain/resolvers/membership-resolver';
|
||||||
|
|
||||||
@ -30,6 +31,10 @@ const routes: Routes = [
|
|||||||
path: 'customers',
|
path: 'customers',
|
||||||
loadChildren: () => import('./customers/customer.module').then(m => m.CustomersModule),
|
loadChildren: () => import('./customers/customer.module').then(m => m.CustomersModule),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'partners',
|
||||||
|
loadChildren: () => import('./partners/partners.module').then(m => m.PartnersModule),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
loadChildren: () => import('./profile/profile.module').then((m) => m.ProfileModule),
|
loadChildren: () => import('./profile/profile.module').then((m) => m.ProfileModule),
|
||||||
@ -75,6 +80,16 @@ const routes: Routes = [
|
|||||||
runGuardsAndResolvers: 'always',
|
runGuardsAndResolvers: 'always',
|
||||||
data: { preload: true }
|
data: { preload: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'partner-customers',
|
||||||
|
loadChildren: () => import('./partner-customers/partner-customers.module').then(m => m.PartnerCustomersModule),
|
||||||
|
runGuardsAndResolvers: 'always'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
|
||||||
|
runGuardsAndResolvers: 'always'
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -104,6 +119,34 @@ const routes: Routes = [
|
|||||||
path: 'signup',
|
path: 'signup',
|
||||||
loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule)
|
loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'manage-subscription',
|
||||||
|
component: PageNotFoundComponent,
|
||||||
|
canActivate: [NotificationRedirectGuard],
|
||||||
|
data: {
|
||||||
|
redirectTo: ['profile', 'myservices'],
|
||||||
|
redirectToNoSubs: ['profile', 'services'],
|
||||||
|
loginNotice: $localize`:Login notice for manage-subscription link@@manageSubLoginNotice:Please log in with your Master account to manage your subscriptions.`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'update-pm',
|
||||||
|
component: PageNotFoundComponent,
|
||||||
|
canActivate: [NotificationRedirectGuard],
|
||||||
|
data: {
|
||||||
|
redirectTo: ['profile', 'payment-method-list'],
|
||||||
|
loginNotice: $localize`:Login notice for update-pm link@@updatePmLoginNotice:Please log in with your Master account to update your payment method.`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'update-bill-address',
|
||||||
|
component: PageNotFoundComponent,
|
||||||
|
canActivate: [NotificationRedirectGuard],
|
||||||
|
data: {
|
||||||
|
redirectTo: ['profile', 'billing-address'],
|
||||||
|
loginNotice: $localize`:Login notice for update-bill-address link@@updateBillAddrLoginNotice:Please log in with your Master account to update your billing address.`
|
||||||
|
}
|
||||||
|
},
|
||||||
{ path: '**', component: PageNotFoundComponent },
|
{ path: '**', component: PageNotFoundComponent },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export class AppComponent extends BaseComp implements OnInit, OnDestroy {
|
|||||||
this.gaSvc.initialize();
|
this.gaSvc.initialize();
|
||||||
|
|
||||||
if (!environment.production) {
|
if (!environment.production) {
|
||||||
console.log('GA4 Service initialized:', this.gaSvc.isInitialized());
|
!environment.production && console.log('GA4 Service initialized:', this.gaSvc.isInitialized());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track session start
|
// Track session start
|
||||||
|
|||||||
@ -32,12 +32,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="layout-main">
|
<div class="layout-main">
|
||||||
|
<div class="content-expiry-banner" *ngIf="expiryWarning$ | async as warning">
|
||||||
|
<span class="expiry-warning"
|
||||||
|
[class.warning]="!warning.willAutoRenew"
|
||||||
|
[class.info]="warning.willAutoRenew"
|
||||||
|
(click)="onNavigateToManageSubscription()">
|
||||||
|
{{ getExpiryWarningMessage(warning) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="layout-content">
|
<div class="layout-content">
|
||||||
<ng-container *ngIf="canDisplayTrial()">
|
<ng-container *ngIf="canDisplayTrial()">
|
||||||
<trial-message [trials]="membership.trials" [isTrialDays]="isTrialDays()" [canDisplayAcceptTrial]="canDisplayAcceptTrial()" (accept)="accept()">
|
<trial-message [trials]="membership.trials" [isTrialDays]="isTrialDays()" [canDisplayAcceptTrial]="canDisplayAcceptTrial()" (accept)="accept()">
|
||||||
</trial-message>
|
</trial-message>
|
||||||
</ng-container>
|
</ng-container> <router-outlet></router-outlet>
|
||||||
<router-outlet></router-outlet>
|
|
||||||
<agm-footer *ngIf="showFooter" [showLang]="!isAdmin"></agm-footer>
|
<agm-footer *ngIf="showFooter" [showLang]="!isAdmin"></agm-footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import { AuthService } from './domain/services/auth.service';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { ConfirmationService } from 'primeng-lts/api';
|
import { ConfirmationService } from 'primeng-lts/api';
|
||||||
import { DomSanitizer } from '@angular/platform-browser';
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
|
import { Observable, combineLatest } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
import cloneDeep from 'clone-deep';
|
import cloneDeep from 'clone-deep';
|
||||||
import { globals } from './shared/global';
|
import { globals } from './shared/global';
|
||||||
import { AppConfigService } from './domain/services/app-config.service';
|
import { AppConfigService } from './domain/services/app-config.service';
|
||||||
@ -12,7 +13,10 @@ import { IAppConfig } from './domain/models/appconfig.model';
|
|||||||
import { Compound, FetchLatestSubscriptionSuccess, GotoServices, SetMode } from './actions/subscription.actions';
|
import { Compound, FetchLatestSubscriptionSuccess, GotoServices, SetMode } from './actions/subscription.actions';
|
||||||
import { Mode, SUB } from './profile/common';
|
import { Mode, SUB } from './profile/common';
|
||||||
import { Store } from '@ngrx/store';
|
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 {
|
enum MenuOrientation {
|
||||||
STATIC,
|
STATIC,
|
||||||
@ -71,6 +75,8 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
|||||||
|
|
||||||
settings: IAppConfig;
|
settings: IAppConfig;
|
||||||
membership: IMembership;
|
membership: IMembership;
|
||||||
|
user$: Observable<UserModel>;
|
||||||
|
expiryWarning$: Observable<ExpiryWarning | null>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly zone: NgZone,
|
public readonly zone: NgZone,
|
||||||
@ -86,6 +92,11 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
|||||||
) {
|
) {
|
||||||
this.membership = this.route.snapshot.data['membership'];
|
this.membership = this.route.snapshot.data['membership'];
|
||||||
this.settings = cloneDeep(this.appConfSvc.settings);
|
this.settings = cloneDeep(this.appConfSvc.settings);
|
||||||
|
this.user$ = this.store.select(fromStore.selectAuthUser);
|
||||||
|
this.expiryWarning$ = combineLatest([
|
||||||
|
this.store.select(fromStore.selectExpiryWarning),
|
||||||
|
this.store.select(fromStore.selectNoSubsWarning)
|
||||||
|
]).pipe(map(([expiry, noSubs]) => expiry ?? noSubs));
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@ -95,6 +106,14 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
|||||||
this.showPaidPopup();
|
this.showPaidPopup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getExpiryWarningMessage(warning: ExpiryWarning): string {
|
||||||
|
return buildExpiryWarningMessage(warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
onNavigateToManageSubscription(): void {
|
||||||
|
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
||||||
|
}
|
||||||
|
|
||||||
bindRipple() {
|
bindRipple() {
|
||||||
this.rippleInitListener = this.init.bind(this);
|
this.rippleInitListener = this.init.bind(this);
|
||||||
document.addEventListener('DOMContentLoaded', this.rippleInitListener);
|
document.addEventListener('DOMContentLoaded', this.rippleInitListener);
|
||||||
@ -448,6 +467,11 @@ export class AppMainComponent implements AfterViewInit, OnDestroy, OnInit, After
|
|||||||
}
|
}
|
||||||
|
|
||||||
canDisplayTrial() {
|
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);
|
return this.authSvc.canDisplayTrial(this.membership?.trials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,8 @@ export class AppMenuComponent implements OnInit {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (this.authSvc.hasRole([RoleIds.ADMIN])) {
|
if (this.authSvc.hasRole([RoleIds.ADMIN])) {
|
||||||
this.creatAdminMenu()
|
this.creatAdminMenu()
|
||||||
|
} else if (this.authSvc.isPartner) {
|
||||||
|
this.createPartnerMenu();
|
||||||
} else {
|
} else {
|
||||||
this.createUserMenu();
|
this.createUserMenu();
|
||||||
}
|
}
|
||||||
@ -37,7 +39,39 @@ export class AppMenuComponent implements OnInit {
|
|||||||
const mItems: MenuItem[] = [
|
const mItems: MenuItem[] = [
|
||||||
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
|
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
|
||||||
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
|
{ 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'] },
|
{ 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;
|
this.model = mItems;
|
||||||
}
|
}
|
||||||
@ -174,7 +208,8 @@ export class AppMenuComponent implements OnInit {
|
|||||||
routerLink: ['/tools'],
|
routerLink: ['/tools'],
|
||||||
items: [
|
items: [
|
||||||
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
|
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
|
||||||
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] }
|
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
|
||||||
|
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { ButtonModule } from 'primeng/button';
|
|||||||
import { MenuModule } from 'primeng/menu';
|
import { MenuModule } from 'primeng/menu';
|
||||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||||
|
import { DialogModule } from 'primeng/dialog';
|
||||||
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
|
import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog';
|
||||||
import { ConfirmationService, MessageService } from 'primeng/api';
|
import { ConfirmationService, MessageService } from 'primeng/api';
|
||||||
import { ToastModule } from 'primeng/toast';
|
import { ToastModule } from 'primeng/toast';
|
||||||
@ -86,7 +87,7 @@ export function translationsFactory(locale: string) {
|
|||||||
imports: [
|
imports: [
|
||||||
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
|
BrowserModule, BrowserAnimationsModule, HttpClientModule, GlobalModule,
|
||||||
InputTextModule, ButtonModule, MenuModule, ProgressSpinnerModule, ScrollPanelModule,
|
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
|
// The store that defines our app state
|
||||||
StoreModule.forRoot(reducers, {
|
StoreModule.forRoot(reducers, {
|
||||||
metaReducers,
|
metaReducers,
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-summary-info .account-username {
|
.account-summary-info .account-username {
|
||||||
@ -19,24 +20,3 @@
|
|||||||
color: #ffd700;
|
color: #ffd700;
|
||||||
opacity: 0.9;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,4 +2,35 @@
|
|||||||
<span class="account-username">{{ user.username }}</span>
|
<span class="account-username">{{ user.username }}</span>
|
||||||
<span class="account-type font-bold">{{ getAccountType(user) }}</span>
|
<span class="account-type font-bold">{{ getAccountType(user) }}</span>
|
||||||
<span *ngIf="user.contact" class="account-contact">({{ user.contact }})</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>
|
</div>
|
||||||
|
|
||||||
|
<p-dialog [(visible)]="showMasterPopup" [modal]="true" [resizable]="false"
|
||||||
|
i18n-header="@@masterAccDetails" header="Master Account Details" [style]="{'width': '400px'}">
|
||||||
|
<p i18n="@@subManagedByMasterMsg">AgMission subscriptions of <strong>{{ masterInfo?.name }}</strong> are managed by the Master account, please contact:</p>
|
||||||
|
<table class="master-info-table" style="width:100%; border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:4px 8px; font-weight:bold;" i18n="@@usernameLabel">Username</td>
|
||||||
|
<td style="padding:4px 8px;">{{ masterInfo?.username }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="masterInfo?.contact">
|
||||||
|
<td style="padding:4px 8px; font-weight:bold;" i18n="@@contactLabel">Contact</td>
|
||||||
|
<td style="padding:4px 8px;">{{ masterInfo?.contact }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="masterInfo?.phone">
|
||||||
|
<td style="padding:4px 8px; font-weight:bold;" i18n="@@phoneLabel">Phone</td>
|
||||||
|
<td style="padding:4px 8px;">{{ masterInfo?.phone }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="masterInfo?.email && masterInfo?.email !== masterInfo?.username">
|
||||||
|
<td style="padding:4px 8px; font-weight:bold;" i18n="@@emailLabel">Email</td>
|
||||||
|
<td style="padding:4px 8px;">{{ masterInfo?.email }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<ng-template pTemplate="footer">
|
||||||
|
<button type="button" pButton icon="pi pi-times" (click)="showMasterPopup = false"
|
||||||
|
i18n-label="@@closeBtn" label="Close"></button>
|
||||||
|
</ng-template>
|
||||||
|
</p-dialog>
|
||||||
@ -1,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 { globals } from './shared/global';
|
||||||
import { UserModel } from './auth/models/user.model';
|
import { UserModel } from './auth/models/user.model';
|
||||||
import { UserService } from './domain/services/user.service';
|
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({
|
@Component({
|
||||||
selector: "app-inline-profile",
|
selector: "app-inline-profile",
|
||||||
@ -12,10 +81,58 @@ export class AppInlineProfileComponent {
|
|||||||
readonly globals = globals;
|
readonly globals = globals;
|
||||||
|
|
||||||
@Input() user: UserModel;
|
@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) { }
|
constructor(readonly userSvc: UserService) { }
|
||||||
|
|
||||||
getAccountType(user: UserModel): string {
|
getAccountType(user: UserModel): string {
|
||||||
return this.userSvc.getAccountType(user);
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
<div class="agm-logo"></div>
|
<div class="agm-logo"></div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="user$ | async as user" class="topbar-right" style="display: flex; justify-content: flex-end;">
|
<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)">
|
<a id="menu-button" href="#" (click)="app.onMenuButtonClick($event)">
|
||||||
<i></i>
|
<i></i>
|
||||||
</a>
|
</a>
|
||||||
@ -13,7 +14,8 @@
|
|||||||
<span *ngIf="!app.isAdmin" class="topbar-badge animated">1</span>
|
<span *ngIf="!app.isAdmin" class="topbar-badge animated">1</span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="topbar-items animated fadeInDown" [ngClass]="{ 'topbar-items-visible': app.topbarMenuActive }">
|
<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)">
|
<a href="#" (click)="app.onTopbarItemClick($event, profile)">
|
||||||
<i class="topbar-icon material-icons">apps</i>
|
<i class="topbar-icon material-icons">apps</i>
|
||||||
<span class="topbar-item-name">Profile</span>
|
<span class="topbar-item-name">Profile</span>
|
||||||
|
|||||||
@ -1,26 +1,77 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
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 { Store } from '@ngrx/store';
|
||||||
import { AppMainComponent } from './app.main.component';
|
import { AppMainComponent } from './app.main.component';
|
||||||
import * as authActions from './auth/actions/auth.actions';
|
import * as authActions from './auth/actions/auth.actions';
|
||||||
import * as fromStore from '../../src/app/reducers/index';
|
import * as fromStore from '../../src/app/reducers/index';
|
||||||
import { UserModel } from './auth/models/user.model';
|
import { UserModel } from './auth/models/user.model';
|
||||||
|
import { ExpiryWarning } from './domain/models/subscription.model';
|
||||||
import { SUB } from './profile/common';
|
import { SUB } from './profile/common';
|
||||||
|
import { UserService } from './domain/services/user.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-topbar',
|
selector: 'app-topbar',
|
||||||
templateUrl: './app.topbar.component.html'
|
templateUrl: './app.topbar.component.html'
|
||||||
})
|
})
|
||||||
export class AppTopbarComponent {
|
export class AppTopbarComponent implements OnInit, OnDestroy {
|
||||||
user$: Observable<UserModel>
|
user$: Observable<UserModel>;
|
||||||
|
expiryWarning$: Observable<ExpiryWarning | null>;
|
||||||
|
private sub$ = new Subscription();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly app: AppMainComponent,
|
public readonly app: AppMainComponent,
|
||||||
private readonly store: Store<{}>,
|
private readonly store: Store<{}>,
|
||||||
private readonly router: Router
|
private readonly router: Router,
|
||||||
|
private readonly userSvc: UserService
|
||||||
) {
|
) {
|
||||||
this.user$ = this.store.select(fromStore.selectAuthUser);
|
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() {
|
manageServices() {
|
||||||
@ -43,4 +94,12 @@ export class AppTopbarComponent {
|
|||||||
updateUserProfile(userId: string) {
|
updateUserProfile(userId: string) {
|
||||||
this.router.navigate([SUB.PROFILE, 'edit', userId]);
|
this.router.navigate([SUB.PROFILE, 'edit', userId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to manage subscription page
|
||||||
|
* Triggered by subscription expiry notification click
|
||||||
|
*/
|
||||||
|
onNavigateToManageSubscription(): void {
|
||||||
|
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,9 +37,17 @@ export class LogoutComplete implements Action {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const REFRESH_USER_DATA = '[Auth] Refresh User Data';
|
||||||
|
export class RefreshUserData implements Action {
|
||||||
|
readonly type: typeof REFRESH_USER_DATA = REFRESH_USER_DATA;
|
||||||
|
|
||||||
|
constructor(public payload: { user: UserModel }) { }
|
||||||
|
}
|
||||||
|
|
||||||
export type All =
|
export type All =
|
||||||
| Login
|
| Login
|
||||||
| LoginSuccess
|
| LoginSuccess
|
||||||
| LoginFailed
|
| LoginFailed
|
||||||
| Logout
|
| Logout
|
||||||
| LogoutComplete;
|
| LogoutComplete
|
||||||
|
| RefreshUserData;
|
||||||
|
|||||||
@ -50,8 +50,9 @@ export class AuthEffects {
|
|||||||
|
|
||||||
private navigateDefault(lang) {
|
private navigateDefault(lang) {
|
||||||
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
|
const hash = (this.router.url.indexOf('#') == -1) ? '/#/' : '/';
|
||||||
|
const returnUrl = this.router.parseUrl(this.router.url).queryParams['returnUrl'] || 'home';
|
||||||
// Replace the current page with the next target url => prevent Back to previous
|
// Replace the current page with the next target url => prevent Back to previous
|
||||||
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + 'home');
|
window.location.replace((lang === 'en' ? `${hash}` : `/${lang}${hash}`) + returnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Effect()
|
@Effect()
|
||||||
|
|||||||
@ -12,8 +12,11 @@
|
|||||||
|
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<span class="md-inputfield">
|
<span class="md-inputfield">
|
||||||
<input type="text" name="username" [pattern]="GC?.emailRegex" email [(ngModel)]="model.username" #username="ngModel" required autocomplete="on" pInputText>
|
<input type="text" name="username" [pattern]="GC?.emailRegex" email [(ngModel)]="model.username"
|
||||||
<span *ngIf="username.invalid && (username.dirty || username.touched)" class="ui-message ui-messages-error ui-corner-all">
|
#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() }}
|
{{ userValidMsg() }}
|
||||||
</span>
|
</span>
|
||||||
<label i18n="@@userName">Username</label>
|
<label i18n="@@userName">Username</label>
|
||||||
@ -21,21 +24,31 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<span class="md-inputfield">
|
<span class="md-inputfield">
|
||||||
<input type="password" name="password" agmPwdToggle [(ngModel)]="model.password" #password="ngModel" required autocomplete="on" pInputText>
|
<input type="password" name="password" agmPwdToggle [(ngModel)]="model.password" #password="ngModel" required
|
||||||
<span i18n="@@pwdReqVal" *ngIf="password.invalid && (password.dirty || password.touched)" class="ui-message ui-messages-error ui-corner-all">Password is required</span>
|
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>
|
<label i18n="@@password">Password</label>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui-g-12" *ngIf="useReCaptcha">
|
<div class="ui-g-12" *ngIf="useReCaptcha">
|
||||||
<span class="md-inputfield">
|
<span class="md-inputfield">
|
||||||
<ngx-recaptcha2 #captchaElem [siteKey]="siteKey" [size]="size" [hl]="lang" [theme]="theme" [type]="type" 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>
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<button type="submit" [disabled]="(!f.valid) || (useReCaptcha && !captchaSuccess) || (pending$ | async)" i18n-label="@@login" label="Login" icon="ui-icon-person" pButton></button>
|
<button type="submit" [disabled]="(!f.valid) || (useReCaptcha && !captchaSuccess) || (pending$ | async)"
|
||||||
<img *ngIf="pending$ | async" src="data:image/gif;base64,R0lGODlhEAAQAPIAAP///wAAAMLCwkJCQgAAAGJiYoKCgpKSkiH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCgAAACwAAAAAEAAQAAADMwi63P4wyklrE2MIOggZnAdOmGYJRbExwroUmcG2LmDEwnHQLVsYOd2mBzkYDAdKa+dIAAAh+QQJCgAAACwAAAAAEAAQAAADNAi63P5OjCEgG4QMu7DmikRxQlFUYDEZIGBMRVsaqHwctXXf7WEYB4Ag1xjihkMZsiUkKhIAIfkECQoAAAAsAAAAABAAEAAAAzYIujIjK8pByJDMlFYvBoVjHA70GU7xSUJhmKtwHPAKzLO9HMaoKwJZ7Rf8AYPDDzKpZBqfvwQAIfkECQoAAAAsAAAAABAAEAAAAzMIumIlK8oyhpHsnFZfhYumCYUhDAQxRIdhHBGqRoKw0R8DYlJd8z0fMDgsGo/IpHI5TAAAIfkECQoAAAAsAAAAABAAEAAAAzIIunInK0rnZBTwGPNMgQwmdsNgXGJUlIWEuR5oWUIpz8pAEAMe6TwfwyYsGo/IpFKSAAAh+QQJCgAAACwAAAAAEAAQAAADMwi6IMKQORfjdOe82p4wGccc4CEuQradylesojEMBgsUc2G7sDX3lQGBMLAJibufbSlKAAAh+QQJCgAAACwAAAAAEAAQAAADMgi63P7wCRHZnFVdmgHu2nFwlWCI3WGc3TSWhUFGxTAUkGCbtgENBMJAEJsxgMLWzpEAACH5BAkKAAAALAAAAAAQABAAAAMyCLrc/jDKSatlQtScKdceCAjDII7HcQ4EMTCpyrCuUBjCYRgHVtqlAiB1YhiCnlsRkAAAOwAAAAAAAAAAAA==" />
|
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="">
|
<a [routerLink]="['/password-reset']" href="">
|
||||||
<ng-container i18n="@@forgotPwd">Forgot password</ng-container> ?
|
<ng-container i18n="@@forgotPwd">Forgot password</ng-container> ?
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Component, OnInit, OnDestroy, ViewChild, isDevMode } from '@angular/core';
|
import { Component, OnInit, OnDestroy, ViewChild, isDevMode } from '@angular/core';
|
||||||
import { ReCaptcha2Component } from 'ngx-captcha';
|
import { ReCaptcha2Component } from 'ngx-captcha';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||||
|
|
||||||
import { Authenticate } from '../models/auth.model';
|
import { Authenticate } from '../models/auth.model';
|
||||||
import * as authActions from '../actions/auth.actions';
|
import * as authActions from '../actions/auth.actions';
|
||||||
@ -36,23 +38,57 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
|
|||||||
public captchaSuccess = false;
|
public captchaSuccess = false;
|
||||||
private _lastVerReqAt: number = 0;
|
private _lastVerReqAt: number = 0;
|
||||||
|
|
||||||
|
// Debounced validation to prevent flash error on Chrome autofill
|
||||||
|
public showUsernameError = false;
|
||||||
|
public showPasswordError = false;
|
||||||
|
private usernameValidation$ = new Subject<boolean>();
|
||||||
|
private passwordValidation$ = new Subject<boolean>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this['name'] = "LoginComp";
|
this['name'] = "LoginComp";
|
||||||
|
|
||||||
if (this.router.getCurrentNavigation()) {
|
const nav = this.router.getCurrentNavigation();
|
||||||
const routeSate = this.router.getCurrentNavigation().extras && this.router.getCurrentNavigation().extras.state;
|
if (nav) {
|
||||||
if (routeSate && routeSate.changedPwd) {
|
const msgs: any[] = [];
|
||||||
this.msgs = [{ severity: 'info', summary: '', detail: globals.pwdChangedOk }];
|
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() {
|
ngOnInit() {
|
||||||
this.lang = this.authSvc.locale;
|
this.lang = this.authSvc.locale;
|
||||||
|
|
||||||
|
// Debounce username validation by 100ms to handle Chrome autofill race condition
|
||||||
|
this.sub$.add(
|
||||||
|
this.usernameValidation$.pipe(
|
||||||
|
debounceTime(100),
|
||||||
|
distinctUntilChanged()
|
||||||
|
).subscribe(showError => {
|
||||||
|
this.showUsernameError = showError;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounce password validation by 100ms to handle Chrome autofill race condition
|
||||||
|
this.sub$.add(
|
||||||
|
this.passwordValidation$.pipe(
|
||||||
|
debounceTime(100),
|
||||||
|
distinctUntilChanged()
|
||||||
|
).subscribe(showError => {
|
||||||
|
this.showPasswordError = showError;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.useReCaptcha && (
|
this.useReCaptcha && (
|
||||||
this.sub$.add(this.appActions.ofTypes([authActions.LOGIN_FAILED]).subscribe(action => {
|
this.sub$.add(this.appActions.ofTypes([authActions.LOGIN_FAILED]).subscribe(action => {
|
||||||
this.captchaElem.resetCaptcha();
|
this.captchaElem.resetCaptcha();
|
||||||
@ -73,6 +109,22 @@ export class LoginComponent extends BaseComp implements OnInit, OnDestroy {
|
|||||||
return StringUtils.isEmpty(this.model.username) ? globals.usernameReqVal : globals.usernameInvalidVal;
|
return StringUtils.isEmpty(this.model.username) ? globals.usernameReqVal : globals.usernameInvalidVal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits username validation state with debounce to prevent flash on Chrome autofill.
|
||||||
|
* Called on input and blur events.
|
||||||
|
*/
|
||||||
|
onUsernameValidation(invalid: boolean, dirty: boolean, touched: boolean) {
|
||||||
|
this.usernameValidation$.next(invalid && (dirty || touched));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits password validation state with debounce to prevent flash on Chrome autofill.
|
||||||
|
* Called on input and blur events.
|
||||||
|
*/
|
||||||
|
onPasswordValidation(invalid: boolean, dirty: boolean, touched: boolean) {
|
||||||
|
this.passwordValidation$.next(invalid && (dirty || touched));
|
||||||
|
}
|
||||||
|
|
||||||
handleSuccess(captchaResp: string): void {
|
handleSuccess(captchaResp: string): void {
|
||||||
// Verify user reponse token with server side within 2 minutes according to GG Ref: https://developers.google.com/recaptcha/docs/verify
|
// Verify user reponse token with server side within 2 minutes according to GG Ref: https://developers.google.com/recaptcha/docs/verify
|
||||||
this._lastVerReqAt = Date.now();
|
this._lastVerReqAt = Date.now();
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export interface UserModel {
|
|||||||
billable?: boolean;
|
billable?: boolean;
|
||||||
membership?: IMembership,
|
membership?: IMembership,
|
||||||
contact: string;
|
contact: string;
|
||||||
|
country?: string;
|
||||||
|
partner?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IMembership {
|
export interface IMembership {
|
||||||
@ -18,4 +20,8 @@ export interface IMembership {
|
|||||||
endOfPeriod?: Number;
|
endOfPeriod?: Number;
|
||||||
subscriptions?: AGNavSubscription[];
|
subscriptions?: AGNavSubscription[];
|
||||||
trials?: Trial;
|
trials?: Trial;
|
||||||
|
customLimits?: {
|
||||||
|
maxVehicles?: number | null;
|
||||||
|
maxAcres?: number | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@ -7,27 +7,60 @@ import * as fromClients from './clients.reducer';
|
|||||||
|
|
||||||
export const getClientsState = createFeatureSelector<fromClients.State>(fromClients.FEATURE_KEY);
|
export const 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,
|
getClientsState,
|
||||||
|
(state) => {
|
||||||
|
if (!state) {
|
||||||
|
return {
|
||||||
|
ids: [],
|
||||||
|
entities: {},
|
||||||
|
loading: false,
|
||||||
|
loaded: false,
|
||||||
|
selectedId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getSelectedClientId = createSelector(
|
||||||
|
getClientsStateOrInitial,
|
||||||
fromClients.getSelectedId
|
fromClients.getSelectedId
|
||||||
);
|
);
|
||||||
|
|
||||||
export const isLoading = createSelector(
|
export const isLoading = createSelector(
|
||||||
getClientsState,
|
getClientsStateOrInitial,
|
||||||
fromClients.getIsLoading
|
fromClients.getIsLoading
|
||||||
);
|
);
|
||||||
|
|
||||||
export const isLoaded = createSelector(
|
export const isLoaded = createSelector(
|
||||||
getClientsState,
|
getClientsStateOrInitial,
|
||||||
fromClients.getIsLoaded
|
fromClients.getIsLoaded
|
||||||
);
|
);
|
||||||
|
|
||||||
export const {
|
// Entity selectors wrapped for safety during lazy loading
|
||||||
selectIds: getClientsIds,
|
const entitySelectors = fromClients.adapter.getSelectors(getClientsStateOrInitial);
|
||||||
selectEntities: getClientEntities,
|
|
||||||
selectAll: getAllClients,
|
export const getClientsIds = createSelector(
|
||||||
selectTotal: getTotalClients,
|
entitySelectors.selectIds,
|
||||||
} = fromClients.adapter.getSelectors(getClientsState);
|
(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(
|
export const getSelectedClient = createSelector(
|
||||||
getClientEntities,
|
getClientEntities,
|
||||||
|
|||||||
@ -5,3 +5,124 @@
|
|||||||
ul {
|
ul {
|
||||||
padding-inline-start: 20px;
|
padding-inline-start: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Partner Selection Integration Styles */
|
||||||
|
|
||||||
|
.partner-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-description {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-selected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-config-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-config-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-config-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #007bff;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border: 1px solid #b3d9ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #dc3545;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satloc-config-section {
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-description {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #e8f4fd;
|
||||||
|
border: 1px solid #b3d9ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #0c5aa6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Common label span for form fields */
|
||||||
|
.form-label-span {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.partner-config-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-option {
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-config-section {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-selected>span {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@ -5,14 +5,16 @@
|
|||||||
<div class="ui-g ui-g-nopad" style="margin-top:40px">
|
<div class="ui-g ui-g-nopad" style="margin-top:40px">
|
||||||
<form [formGroup]="form">
|
<form [formGroup]="form">
|
||||||
<div class="ui-g-12 ui-g-nopad">
|
<div class="ui-g-12 ui-g-nopad">
|
||||||
<user-profile-form formControlName="profile" [requireName]="true" [focusOnFirst]="isNew" [updateCountry]="true"></user-profile-form>
|
<user-profile-form formControlName="profile" [requireName]="true" [focusOnFirst]="isNew"
|
||||||
|
[updateCountry]="true"></user-profile-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
|
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
|
||||||
<span style="margin-right:12px">
|
<span class="form-label-span">
|
||||||
<ng-container i18n="@@premiumLevel">Premium Level</ng-container>:
|
<ng-container i18n="@@premiumLevel">Premium Level</ng-container>:
|
||||||
</span>
|
</span>
|
||||||
<p-dropdown id="premium" name="premium" formControlName="premium" [options]="premiumLevels" [style]="{'min-width': '120px'}">
|
<p-dropdown id="premium" name="premium" formControlName="premium" [options]="premiumLevels"
|
||||||
|
[style]="{'min-width': '120px'}">
|
||||||
<ng-template let-type pTemplate="item">
|
<ng-template let-type pTemplate="item">
|
||||||
<span>
|
<span>
|
||||||
<strong>{{ type.label }}</strong>
|
<strong>{{ type.label }}</strong>
|
||||||
@ -21,17 +23,53 @@
|
|||||||
</p-dropdown>
|
</p-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Partner Selection -->
|
||||||
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
|
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
|
||||||
<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>
|
||||||
|
|
||||||
<div class="ui-g-12 ">
|
<div class="ui-g-12 ">
|
||||||
<ng-container *ngIf="hasPaidSubs(); else trialing">
|
<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-container>
|
||||||
<ng-template #trialing>
|
<ng-template #trialing>
|
||||||
<ng-container *ngIf="hasTrialSubs(); else noTrialSubs">
|
<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>
|
<trial formControlName="trials" [trialDays]="trialDays" [trials]="trials" [disable]="true"></trial>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template #noTrialSubs>
|
<ng-template #noTrialSubs>
|
||||||
@ -48,12 +86,16 @@
|
|||||||
|
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<p-messages [(value)]="msgs" [closable]="false"></p-messages>
|
<p-messages [(value)]="msgs" [closable]="false"></p-messages>
|
||||||
<agm-account-editor formControlName="account" [isNew]="isNew" (userExisted)="onUserExisted($event)" 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>
|
</agm-account-editor>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
||||||
<button pButton [disabled]="form.invalid" 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 [disabled]="form.invalid || partnerLoading" type="button" style="width:auto"
|
||||||
<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)="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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,11 +2,12 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { FormGroup, FormBuilder } from '@angular/forms';
|
import { FormGroup, FormBuilder } from '@angular/forms';
|
||||||
import { SelectItem } from 'primeng/api';
|
import { SelectItem } from 'primeng/api';
|
||||||
import { Customer } from '../models/customer.model';
|
import { Customer, Partner } from '../models/customer.model';
|
||||||
import * as customerActions from '../actions/customer.actions';
|
import * as customerActions from '../actions/customer.actions';
|
||||||
import { UserService } from '@app/domain/services/user.service';
|
import { UserService } from '@app/domain/services/user.service';
|
||||||
|
import { PartnerService } from '@app/partners/services/partner.service';
|
||||||
import { BaseComp } from '@app/shared/base/base.component';
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
import { GC, RoleIds, globals } from '@app/shared/global';
|
import { GC, RoleIds, globals, Labels } from '@app/shared/global';
|
||||||
import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model';
|
import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model';
|
||||||
import { SubStripe, SubTexts } from '@app/profile/common';
|
import { SubStripe, SubTexts } from '@app/profile/common';
|
||||||
import { IMembership } from '@app/auth/models/user.model';
|
import { IMembership } from '@app/auth/models/user.model';
|
||||||
@ -17,9 +18,10 @@ import { DateUtils } from '@app/shared/utils';
|
|||||||
templateUrl: './customer-edit.component.html',
|
templateUrl: './customer-edit.component.html',
|
||||||
styleUrls: ['./customer-edit.component.css']
|
styleUrls: ['./customer-edit.component.css']
|
||||||
})
|
})
|
||||||
export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy {
|
export class CustomerEditComponent extends BaseComp implements OnInit {
|
||||||
readonly globals = globals;
|
readonly globals = globals;
|
||||||
readonly SubTexts = SubTexts;
|
readonly SubTexts = SubTexts;
|
||||||
|
readonly Labels = Labels;
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
selectedItem: Customer;
|
selectedItem: Customer;
|
||||||
@ -33,6 +35,11 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
membership: IMembership;
|
membership: IMembership;
|
||||||
lang;
|
lang;
|
||||||
|
|
||||||
|
// Partner Selection Properties
|
||||||
|
partnerOptions: SelectItem[] = [];
|
||||||
|
partnerLoading = false;
|
||||||
|
partnerError: string | null = null;
|
||||||
|
|
||||||
private _customer: Customer;
|
private _customer: Customer;
|
||||||
get customer(): Customer { return this._customer; }
|
get customer(): Customer { return this._customer; }
|
||||||
set customer(customer: Customer) {
|
set customer(customer: Customer) {
|
||||||
@ -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 },
|
account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password },
|
||||||
premium: this.selectedItem.premium,
|
premium: this.selectedItem.premium,
|
||||||
billable: this.selectedItem.billable,
|
billable: this.selectedItem.billable,
|
||||||
trials: this.selectedItem.membership?.trials
|
trials: this.selectedItem.membership?.trials,
|
||||||
|
partner: this.selectedItem.partner || null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set partner selection based on customer.partner field, or null if not set
|
||||||
|
// Form control will be updated by loadPartners() method
|
||||||
}
|
}
|
||||||
|
|
||||||
private _isNew: boolean;
|
private _isNew: boolean;
|
||||||
@ -55,6 +66,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly route: ActivatedRoute,
|
private readonly route: ActivatedRoute,
|
||||||
private readonly userSvc: UserService,
|
private readonly userSvc: UserService,
|
||||||
|
private readonly partnerSvc: PartnerService,
|
||||||
private readonly fb: FormBuilder
|
private readonly fb: FormBuilder
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -69,9 +81,13 @@ export class CustomerEditComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
account: [],
|
account: [],
|
||||||
premium: [],
|
premium: [],
|
||||||
billable: [],
|
billable: [],
|
||||||
trials: []
|
trials: [],
|
||||||
|
// Partner form control
|
||||||
|
partner: [null]
|
||||||
});
|
});
|
||||||
this.lang = this.authSvc.locale;
|
this.lang = this.authSvc.locale;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
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.trialSubs = this.membership.subscriptions?.filter((sub) => sub.status === SubStripe.TRIALING) || [];
|
||||||
this.paidSubs = 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;
|
let custObj;
|
||||||
|
|
||||||
const updateTrialMembship = (membership?) => {
|
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) {
|
if (trialsValue?.selected) {
|
||||||
const trials: Trial = { ...trialsValue };
|
const trials: Trial = { ...trialsValue };
|
||||||
delete trials.selected;
|
delete trials.selected;
|
||||||
|
|
||||||
// If type is null, but trialDays or byDate exist, set type accordingly
|
// If type is null, but trialDays or byDate exist, set type accordingly
|
||||||
if (trials.type == null) {
|
if (trials.type == null) {
|
||||||
if (trials.trialDays && trials.trialDays > 0) {
|
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,
|
custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
|
||||||
{ premium: this.form.value.premium || false },
|
{ 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
|
this.membership
|
||||||
? custObj = Object.assign(custObj, { membership: updateTrialMembship(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);
|
return DateUtils.dateToTS(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partner Methods
|
||||||
|
private loadPartners(): void {
|
||||||
|
this.partnerLoading = true;
|
||||||
|
this.partnerError = null;
|
||||||
|
|
||||||
|
this.partnerSvc.getPartners().subscribe({
|
||||||
|
next: (partners: Partner[]) => {
|
||||||
|
// Create dropdown options starting with "None" option for AgNav direct customers
|
||||||
|
this.partnerOptions = [
|
||||||
|
{
|
||||||
|
label: Labels.NONE_AGNAV_DIRECT_CUSTOMER,
|
||||||
|
value: null // null value indicates AgNav direct customer
|
||||||
|
},
|
||||||
|
// Add active partners
|
||||||
|
...partners
|
||||||
|
.filter(partner => partner.active) // Only show active partners
|
||||||
|
.map(partner => ({
|
||||||
|
label: partner.name,
|
||||||
|
value: partner
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Set selectedPartner based on existing customer partner
|
||||||
|
if (this.customer?.partner && this.partnerOptions.length > 0) {
|
||||||
|
// Find the partner in options that matches the customer's current partner _id
|
||||||
|
const matchingOption = this.partnerOptions.find(option =>
|
||||||
|
option.value && option.value._id === this.customer.partner._id
|
||||||
|
);
|
||||||
|
if (matchingOption) {
|
||||||
|
this.form.patchValue({ partner: matchingOption.value });
|
||||||
|
}
|
||||||
|
} else if (!this.customer?.partner) {
|
||||||
|
// If no partner is set, default to "None" (AgNav direct customer)
|
||||||
|
this.form.patchValue({ partner: null });
|
||||||
|
}
|
||||||
|
this.partnerLoading = false;
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.partnerError = Labels.FAILED_TO_LOAD_PARTNERS;
|
||||||
|
this.partnerLoading = false;
|
||||||
|
console.error('Error loading partners:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPartnerChange(selectedPartner: Partner | null): void {
|
||||||
|
this.partnerError = null;
|
||||||
|
|
||||||
|
// Update customer partner field
|
||||||
|
if (this.customer) {
|
||||||
|
this.customer.partner = selectedPartner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
super.ngOnDestroy();
|
super.ngOnDestroy();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Table } from 'primeng/table';
|
|||||||
import { Customer } from '../models/customer.model';
|
import { Customer } from '../models/customer.model';
|
||||||
import * as fromCustomers from '../reducers';
|
import * as fromCustomers from '../reducers';
|
||||||
import * as customerActions from '../actions/customer.actions';
|
import * as customerActions from '../actions/customer.actions';
|
||||||
import { globals } from '@app/shared/global';
|
import { globals, OperationalStatus } from '@app/shared/global';
|
||||||
|
|
||||||
import { BaseComp } from '@app/shared/base/base.component';
|
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 {
|
export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||||
readonly CREATED = 'createdAt';
|
readonly CREATED = 'createdAt';
|
||||||
readonly ACTIVE = 'active';
|
readonly ACTIVE = OperationalStatus.ACTIVE;
|
||||||
readonly BILLABLE = 'billable';
|
readonly BILLABLE = 'billable';
|
||||||
readonly PARTNER = 'partner';
|
readonly PARTNER = 'partner';
|
||||||
readonly PARTNER_NAME = 'partnerName';
|
readonly PARTNER_NAME = 'partnerName';
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export class CustomerResolver implements Resolve<Customer> {
|
|||||||
if (id === '0') {
|
if (id === '0') {
|
||||||
return createNewCustomer();
|
return createNewCustomer();
|
||||||
} else {
|
} else {
|
||||||
return this.customerService.getCustomer(id).pipe(
|
return this.customerService.getCustomer(id, 'edit').pipe(
|
||||||
map((cust) => {
|
map((cust) => {
|
||||||
if (cust) {
|
if (cust) {
|
||||||
return cust;
|
return cust;
|
||||||
|
|||||||
@ -16,11 +16,16 @@ export interface Customer extends User {
|
|||||||
export interface Partner {
|
export interface Partner {
|
||||||
_id: string;
|
_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
active: boolean;
|
description: string;
|
||||||
|
kind: string; // Required to match User interface
|
||||||
|
active?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNewCustomer = () => {
|
export const createNewCustomer = () => {
|
||||||
const customer = <Customer>createNewUser(null, RoleIds.APP);
|
const customer = createNewUser(null, RoleIds.APP) as Customer;
|
||||||
customer.premium = 0;
|
customer.premium = 0;
|
||||||
|
customer.membership = {} as IMembership; // Initialize required membership property
|
||||||
return customer;
|
return customer;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,8 @@ export class TrialComponent extends BaseComp implements OnDestroy, OnInit, After
|
|||||||
}
|
}
|
||||||
|
|
||||||
get value() {
|
get value() {
|
||||||
return this.form.value;
|
// CRITICAL: Use getRawValue() to include disabled controls (selected, type, trialDays)
|
||||||
|
return this.form.getRawValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
set value(val) {
|
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.dayItems = this.trialDays?.map((day) => ({ label: `${day}`, value: day }));
|
||||||
|
|
||||||
this.sub$.add(this.form.valueChanges.subscribe((val) => {
|
// CRITICAL FIX: Use getRawValue() to include disabled controls in onChange callback
|
||||||
this.onChange(val);
|
this.sub$.add(this.form.valueChanges.subscribe(() => {
|
||||||
this.onTouched(val);
|
const rawValue = this.form.getRawValue();
|
||||||
|
this.onChange(rawValue);
|
||||||
|
this.onTouched(rawValue);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterContentInit() {
|
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 (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.toDate = new Date(this.trials.byDate);
|
||||||
this.form.patchValue({ ...this.trials, selected: true });
|
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 });
|
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 {
|
} else {
|
||||||
this.form.patchValue({ ...this.trials });
|
this.form.patchValue({ ...this.trials });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,12 @@ export class AuthGuard implements CanActivate, CanActivateChild {
|
|||||||
this.router.navigate(['/login'], { replaceUrl: true });
|
this.router.navigate(['/login'], { replaceUrl: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Early exit for partner users - they bypass all subscription checks
|
||||||
|
if (this.authSvc.isPartner) {
|
||||||
|
return hasAllowedRoles;
|
||||||
|
}
|
||||||
|
|
||||||
const requiresResolution = (): boolean => {
|
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);
|
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) {
|
if (hasUnresolvedSubs && hasAllowedRoles) {
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CanActivate, Router, UrlTree, ActivatedRouteSnapshot } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { SUB } from '@app/profile/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic guard for notification deep-link URLs (e.g. /manage-subscription, /update-pm).
|
||||||
|
* All routing logic is declared in the route's `data` — no new guard file needed per URL.
|
||||||
|
*
|
||||||
|
* Route data shape:
|
||||||
|
* data: {
|
||||||
|
* // Required: where to send an authenticated user
|
||||||
|
* redirectTo: string[];
|
||||||
|
*
|
||||||
|
* // Optional: alternate destination when master account has no subscriptions
|
||||||
|
* redirectToNoSubs?: string[];
|
||||||
|
*
|
||||||
|
* // Optional: i18n message shown in the login <p-messages> bar
|
||||||
|
* loginNotice?: string;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Adding a new notification URL = one route entry, zero new files.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class NotificationRedirectGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly authSvc: AuthService,
|
||||||
|
private readonly router: Router
|
||||||
|
) { }
|
||||||
|
|
||||||
|
canActivate(route: ActivatedRouteSnapshot): UrlTree {
|
||||||
|
const { redirectTo, redirectToNoSubs } = route.data as {
|
||||||
|
redirectTo: string[];
|
||||||
|
redirectToNoSubs?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.authSvc.loggedIn) {
|
||||||
|
const { loginNotice } = route.data as { loginNotice?: string };
|
||||||
|
return this.router.createUrlTree(['/login'], {
|
||||||
|
queryParams: {
|
||||||
|
returnUrl: route.url.map(s => s.path).join('/'),
|
||||||
|
...(loginNotice ? { loginNotice } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMaster = !this.authSvc.user?.parent;
|
||||||
|
if (redirectToNoSubs && isMaster && !this.authSvc.hasSubs()) {
|
||||||
|
return this.router.createUrlTree(redirectToNoSubs);
|
||||||
|
}
|
||||||
|
return this.router.createUrlTree(redirectTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,4 +24,6 @@ export interface IAppConfig {
|
|||||||
|
|
||||||
noPopup: boolean;
|
noPopup: boolean;
|
||||||
trialDays: [number];
|
trialDays: [number];
|
||||||
|
/** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */
|
||||||
|
promoMinExpiryDays?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export class PlayRecord {
|
|||||||
// Output 3
|
// Output 3
|
||||||
areaName: string;
|
areaName: string;
|
||||||
totLnLength: number;
|
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;
|
mappedArea: number;
|
||||||
overSprayed: number;
|
overSprayed: number;
|
||||||
pilotName: string;
|
pilotName: string;
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export interface Addon extends BasePackage {
|
|||||||
desc: string;
|
desc: string;
|
||||||
lookupKey: string;
|
lookupKey: string;
|
||||||
trialEnd?: number;
|
trialEnd?: number;
|
||||||
|
interval?: string; // Billing interval ('year' or 'month')
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Package extends BasePackage {
|
export interface Package extends BasePackage {
|
||||||
@ -41,6 +42,7 @@ export interface Package extends BasePackage {
|
|||||||
lookupKey: string;
|
lookupKey: string;
|
||||||
level?: number;
|
level?: number;
|
||||||
trialEnd?: number;
|
trialEnd?: number;
|
||||||
|
interval?: string; // Billing interval ('year' or 'month')
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Address {
|
export interface Address {
|
||||||
@ -84,7 +86,7 @@ export interface InvoicePackage {
|
|||||||
custId: string;
|
custId: string;
|
||||||
package: string;
|
package: string;
|
||||||
addons: BasePackage[];
|
addons: BasePackage[];
|
||||||
prorateTS: number;
|
prorateTS?: number; // Optional: only needed for proration calculations
|
||||||
coupon?: string;
|
coupon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,6 +108,32 @@ export interface Line {
|
|||||||
price: {
|
price: {
|
||||||
lookup_key: string;
|
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 {
|
export interface Invoice {
|
||||||
@ -144,6 +172,18 @@ export interface Invoice {
|
|||||||
discount?: {
|
discount?: {
|
||||||
coupon: Coupon
|
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 {
|
export interface Charge {
|
||||||
@ -171,6 +211,7 @@ export interface PaidAmount {
|
|||||||
totalTax: number;
|
totalTax: number;
|
||||||
total: number;
|
total: number;
|
||||||
discount?: Discount;
|
discount?: Discount;
|
||||||
|
refundAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Discount {
|
export interface Discount {
|
||||||
@ -195,6 +236,7 @@ export interface SubscriptionIntent {
|
|||||||
coupons?: Coupon[];
|
coupons?: Coupon[];
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
subIds?: string[];
|
subIds?: string[];
|
||||||
|
promoSavings?: number; // Total promo discount in cents (calculated in checkout)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionPackage {
|
export interface SubscriptionPackage {
|
||||||
@ -297,6 +339,12 @@ export interface StripeSubscription {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
price: {
|
price: {
|
||||||
lookup_key: string;
|
lookup_key: string;
|
||||||
|
metadata?: {
|
||||||
|
maxVehicles?: string;
|
||||||
|
maxAcres?: string;
|
||||||
|
tier?: string;
|
||||||
|
level?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
@ -304,8 +352,10 @@ export interface StripeSubscription {
|
|||||||
current_period_start: number;
|
current_period_start: number;
|
||||||
default_payment_method: string;
|
default_payment_method: string;
|
||||||
default_source: string;
|
default_source: string;
|
||||||
metadata: {
|
metadata?: {
|
||||||
type: string;
|
type: string;
|
||||||
|
scheduleId?: string;
|
||||||
|
promoId?: string;
|
||||||
};
|
};
|
||||||
cancel_at_period_end: boolean;
|
cancel_at_period_end: boolean;
|
||||||
discount?: {
|
discount?: {
|
||||||
@ -313,6 +363,82 @@ export interface StripeSubscription {
|
|||||||
}
|
}
|
||||||
trial_end?: number;
|
trial_end?: number;
|
||||||
quantity: 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 {
|
export interface AGNavSubscription {
|
||||||
@ -323,6 +449,28 @@ export interface AGNavSubscription {
|
|||||||
periodStart: number;
|
periodStart: number;
|
||||||
type: string;
|
type: string;
|
||||||
cancelAtPeriodEnd: boolean;
|
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 {
|
export interface AGNavSubscriptionShort {
|
||||||
@ -333,6 +481,27 @@ export interface AGNavSubscriptionShort {
|
|||||||
cancelAtPeriodEnd: boolean;
|
cancelAtPeriodEnd: boolean;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
paymentMethod: string;
|
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 {
|
export interface Status {
|
||||||
@ -364,6 +533,7 @@ export interface ConfirmPackage {
|
|||||||
subIds: string[];
|
subIds: string[];
|
||||||
unresolved: Unresolved;
|
unresolved: Unresolved;
|
||||||
applicatorId: string;
|
applicatorId: string;
|
||||||
|
stage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePaymentMethodPackage {
|
export interface CreatePaymentMethodPackage {
|
||||||
@ -398,7 +568,7 @@ export interface Aircraft {
|
|||||||
|
|
||||||
export interface Acre {
|
export interface Acre {
|
||||||
currUsage: number;
|
currUsage: number;
|
||||||
limit: number;
|
limit: number | null; // null = unlimited acres for current subscription packages
|
||||||
overLimit: boolean;
|
overLimit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,8 @@ export class MembershipResolver implements Resolve<IMembership> {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
resolve(): Observable<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) => {
|
map((cust) => {
|
||||||
const membership = cust?.membership;
|
const membership = cust?.membership;
|
||||||
if (membership) {
|
if (membership) {
|
||||||
|
|||||||
@ -21,14 +21,17 @@ export class ProfileResolver implements Resolve<UserWithParentUsername> {
|
|||||||
|
|
||||||
resolve(route: ActivatedRouteSnapshot): Observable<UserWithParentUsername> {
|
resolve(route: ActivatedRouteSnapshot): Observable<UserWithParentUsername> {
|
||||||
const id = route.paramMap.get('id');
|
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 => {
|
switchMap(user => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.router.navigate(['/profile']);
|
this.router.navigate(['/profile']);
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
if (user.parent) {
|
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 })),
|
map(parentUser => ({ user, parentUsername: parentUser?.username })),
|
||||||
first()
|
first()
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -70,7 +70,12 @@ export class AuthInterceptor implements HttpInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onCatch(err: any, req: HttpRequest<any>): Observable<any> {
|
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));
|
this.store.dispatch(new authActions.Logout(true));
|
||||||
}
|
}
|
||||||
return throwError(err);
|
return throwError(err);
|
||||||
|
|||||||
@ -89,6 +89,10 @@ export class AuthService implements OnDestroy {
|
|||||||
return this.hasRole([RoleIds.INSPECTOR]);
|
return this.hasRole([RoleIds.INSPECTOR]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isPartner(): boolean {
|
||||||
|
return this.hasRole([RoleIds.PARTNER]);
|
||||||
|
}
|
||||||
|
|
||||||
hasSubsWithStatus(status: string) {
|
hasSubsWithStatus(status: string) {
|
||||||
return this.user?.membership?.subscriptions?.some((sub) => sub.status === `${status}`);
|
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 {
|
getCurLookupKey(type: SubType.PACKAGE | SubType.ADDON): PriceUsd {
|
||||||
let lookupKey: PriceUsd;
|
// Use centralized utility methods
|
||||||
|
const subscriptions = this.user?.membership?.subscriptions;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SubType.PACKAGE:
|
case SubType.PACKAGE:
|
||||||
lookupKey = this.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.PACKAGE)?.items?.[0].price || '';
|
return this.subSvc.getCurrentPackageLookupKey(subscriptions) || '';
|
||||||
break;
|
|
||||||
case SubType.ADDON:
|
case SubType.ADDON:
|
||||||
lookupKey = this.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.ADDON)?.items?.[0].price || '';
|
return this.subSvc.getCurrentAddonLookupKey(subscriptions) || '';
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
throw new Error('Unsupported type');
|
throw new Error('Unsupported type');
|
||||||
}
|
}
|
||||||
return lookupKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isPlanner() {
|
get isPlanner() {
|
||||||
@ -140,6 +142,10 @@ export class AuthService implements OnDestroy {
|
|||||||
return (this.user && this.user.billable);
|
return (this.user && this.user.billable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isCanada(): boolean {
|
||||||
|
return this.user?.country === 'CA';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parent user, to mange items under an applicator user
|
* Parent user, to mange items under an applicator user
|
||||||
*/
|
*/
|
||||||
@ -163,7 +169,7 @@ export class AuthService implements OnDestroy {
|
|||||||
throwError('invalid_account');
|
throwError('invalid_account');
|
||||||
|
|
||||||
// Store username and jwt token in local storage to keep user logged in between page refreshes
|
// Store username and jwt token in local storage to keep user logged in between page refreshes
|
||||||
const user = <UserModel>{ _id: res['_id'], username: auth.username, billable: res['billable'], roles: res['roles'], parent: (res['pui'] || ''), lang: res['lang'] || 'en', pre: res['pre'], membership: res['membership'], contact: res['contact'] || '' };
|
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._user = user;
|
||||||
this.token = { t: res['token'], rt: res['rt'] };
|
this.token = { t: res['token'], rt: res['rt'] };
|
||||||
|
|
||||||
@ -298,14 +304,13 @@ export class AuthService implements OnDestroy {
|
|||||||
isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(trialEndDate);
|
isWithinTrialPeriod = DateUtils.currUTC() <= DateUtils.dateToTS(trialEndDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return !this.hasRole([RoleIds.ADMIN])
|
return this.hasRole([RoleIds.APP])
|
||||||
&& !this.hasSubs()
|
&& !this.hasSubs()
|
||||||
&& isWithinTrialPeriod;
|
&& isWithinTrialPeriod;
|
||||||
}
|
}
|
||||||
|
|
||||||
canDisplayTrial(trials: Trial) {
|
canDisplayTrial(trials: Trial) {
|
||||||
return this.validateTrial(trials)
|
return this.validateTrial(trials);
|
||||||
&& this.subSvc.subMode !== Mode.REGULAR;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canAcceptTrial(url: string) {
|
canAcceptTrial(url: string) {
|
||||||
|
|||||||
@ -17,8 +17,9 @@ export class CustomerService {
|
|||||||
return this.http.get<Customer[]>(this.customerURL);
|
return this.http.get<Customer[]>(this.customerURL);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCustomer(id: string): Observable<Customer> {
|
getCustomer(id: string, view?: string): Observable<Customer> {
|
||||||
return this.http.get<Customer>(`${this.customerURL}/${id}`);
|
const url = view ? `${this.customerURL}/${id}?view=${view}` : `${this.customerURL}/${id}`;
|
||||||
|
return this.http.get<Customer>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCustomer(customer: Customer): Observable<Customer> {
|
saveCustomer(customer: Customer): Observable<Customer> {
|
||||||
|
|||||||
@ -187,8 +187,24 @@ export class JobService {
|
|||||||
return this.http.post<any>(`${this.jobURL}/appFiles`, { jobId: jobId });
|
return this.http.post<any>(`${this.jobURL}/appFiles`, { jobId: jobId });
|
||||||
}
|
}
|
||||||
|
|
||||||
getFilesData(ids) {
|
getFilesData(fileId: string, params?: {
|
||||||
return this.http.post<any>(`${this.jobURL}/filesdata`, { fileIds: ids });
|
limit?: number,
|
||||||
|
startingAfter?: string,
|
||||||
|
endingBefore?: string,
|
||||||
|
returnAll?: boolean
|
||||||
|
}) {
|
||||||
|
const body: any = {
|
||||||
|
fileId: fileId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
if (params.limit !== undefined) body.limit = params.limit;
|
||||||
|
if (params.startingAfter !== undefined) body.startingAfter = params.startingAfter;
|
||||||
|
if (params.endingBefore !== undefined) body.endingBefore = params.endingBefore;
|
||||||
|
if (params.returnAll !== undefined) body.returnAll = params.returnAll;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<any>(`${this.jobURL}/filesdata`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { PromoLabels } from 'src/app/profile/common';
|
||||||
|
import { ActivePromo } from './active-promo.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class PromoTranslationService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translated promo name with fallback to static name
|
||||||
|
*/
|
||||||
|
getPromoName(promo: ActivePromo): string {
|
||||||
|
if (promo.nameKey && PromoLabels[promo.nameKey]) {
|
||||||
|
return PromoLabels[promo.nameKey];
|
||||||
|
}
|
||||||
|
return promo.name; // Fallback to static name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translated promo description with fallback to static name
|
||||||
|
*/
|
||||||
|
getPromoDescription(promo: ActivePromo): string {
|
||||||
|
if (promo.descriptionKey && PromoLabels[promo.descriptionKey]) {
|
||||||
|
return PromoLabels[promo.descriptionKey];
|
||||||
|
}
|
||||||
|
// Fallback: use name as description if no descriptionKey translation
|
||||||
|
return this.getPromoName(promo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if promo has translation keys available
|
||||||
|
*/
|
||||||
|
hasTranslation(promo: ActivePromo): boolean {
|
||||||
|
return !!(promo.nameKey && PromoLabels[promo.nameKey]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,93 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
|
||||||
import {
|
|
||||||
DisplayPackage,
|
|
||||||
Addon,
|
|
||||||
Package,
|
|
||||||
Price,
|
|
||||||
ConfigRes,
|
|
||||||
Address
|
|
||||||
} from '@app/domain/models/subscription.model';
|
|
||||||
|
|
||||||
|
|
||||||
describe('SubscriptionService', () => {
|
|
||||||
let subSvc: SubscriptionService;
|
|
||||||
let httpTestingController: HttpTestingController;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [HttpClientTestingModule]
|
|
||||||
});
|
|
||||||
subSvc = TestBed.inject(SubscriptionService);
|
|
||||||
httpTestingController = TestBed.inject(HttpTestingController);
|
|
||||||
});
|
|
||||||
|
|
||||||
const MockedPrices: Price[] = [
|
|
||||||
{type: 'essential', priceUSD: 995, lookupKey: 'ess_1'},
|
|
||||||
{type: 'enterprise', priceUSD: 1495, lookupKey: 'ent_1'},
|
|
||||||
{type: 'addon', priceUSD: 2495, lookupKey: 'addon_2'},
|
|
||||||
{type: 'addon', priceUSD: 1495, lookupKey: 'addon_1'}
|
|
||||||
];
|
|
||||||
|
|
||||||
it('should test createSubscription with prices return Display Packages', () => {
|
|
||||||
const EssPkgs: Package[] = [
|
|
||||||
{ priceId: '1', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: '$995.00', lookupKey: 'ess_1'},
|
|
||||||
{ priceId: '2', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'contact', lookupKey: ''}
|
|
||||||
];
|
|
||||||
|
|
||||||
const EntPkgs: Package[] = [
|
|
||||||
{ priceId: '6', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: '$1,495.00', lookupKey: 'ent_1'},
|
|
||||||
{ priceId: '7', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'contact', lookupKey: ''}
|
|
||||||
];
|
|
||||||
|
|
||||||
const Addons: Addon[] = [
|
|
||||||
{ priceId: '1', name: 'Aircraft Tracking (Per Aircraft)', desc: '', price: '$1,495.00', lookupKey: 'addon_1', quantity: 1},
|
|
||||||
{ priceId: '2', name: 'Aircraft Managing (Per Aircraft)', desc: '', price: '$2,495.00', lookupKey: 'addon_2', quantity: 1}
|
|
||||||
];
|
|
||||||
const expectedDispPkg: DisplayPackage = {
|
|
||||||
essential: EssPkgs,
|
|
||||||
enterprise: EntPkgs,
|
|
||||||
addon: Addons,
|
|
||||||
}
|
|
||||||
expect(subSvc.pricesToPkgs(MockedPrices)).toEqual(expectedDispPkg)
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test getPrices and return an array of Prices', () => {
|
|
||||||
subSvc.getPrices().subscribe((res) => {
|
|
||||||
expect(res).toEqual(MockedPrices)
|
|
||||||
});
|
|
||||||
const request = httpTestingController.expectOne('/subscription/prices');
|
|
||||||
request.flush(MockedPrices);
|
|
||||||
httpTestingController.verify();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test getConfig and return Config object', () => {
|
|
||||||
subSvc.getConfig().subscribe((res: ConfigRes) => {
|
|
||||||
expect(res.config).toEqual('pk_1234')
|
|
||||||
});
|
|
||||||
const request = httpTestingController.expectOne('/subscription/config');
|
|
||||||
request.flush({config: 'pk_1234'});
|
|
||||||
httpTestingController.verify();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test convertPostalCode', () => {
|
|
||||||
const address: Address = {
|
|
||||||
_id: '123',
|
|
||||||
valid: true,
|
|
||||||
name: 'justin',
|
|
||||||
city: "Richmond",
|
|
||||||
country: "CA",
|
|
||||||
line1: "4070 Robson Street",
|
|
||||||
line2: null,
|
|
||||||
postal_code: null,
|
|
||||||
postalCode: "V6V 0A4",
|
|
||||||
state: "BC"
|
|
||||||
}
|
|
||||||
const actual = subSvc.convertAddr(address)
|
|
||||||
expect(actual._id).toBeFalsy();
|
|
||||||
expect(actual.postal_code).toEqual('V6V 0A4');
|
|
||||||
expect(actual.postalCode).toBeFalsy();
|
|
||||||
expect(actual.valid).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,11 +1,12 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, of, Subject, Subscription } from 'rxjs';
|
import { environment } from '@environments/environment';
|
||||||
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 { 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 { loadStripe, Stripe, StripeCardElement } from '@stripe/stripe-js';
|
||||||
import { DateUtils, UnitUtils, Utils } from '@app/shared/utils';
|
import { DateUtils, UnitUtils, Utils } from '@app/shared/utils';
|
||||||
import { Mode, SUB, SubKeys, SubStripe, SubTexts, SubType, subPlans } from '@app/profile/common';
|
import { Mode, SUB, SubKeys, SubStripe, SubTexts, SubType, subPlans, UNLIMITED } from '@app/profile/common';
|
||||||
import { map, switchMap } from 'rxjs/operators';
|
import { map, switchMap, tap, catchError } from 'rxjs/operators';
|
||||||
import { IMembership } from '@app/auth/models/user.model';
|
import { IMembership } from '@app/auth/models/user.model';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { getSubIntentMode } from '@app/reducers';
|
import { getSubIntentMode } from '@app/reducers';
|
||||||
@ -88,6 +89,26 @@ export class SubscriptionService {
|
|||||||
return this.http.post<StripeSubscription[]>(`${BASE_URL}/update`, subPkg);
|
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[]> {
|
fetchSubscriptions(custId: string): Observable<StripeSubscription[]> {
|
||||||
return this.http.get<StripeSubscription[]>(`${BASE_URL}?custId=${custId}&billInfo=true`);
|
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[]> {
|
editSub(subsSettings: { subId: string, cancelAtPeriodEnd: boolean }[]): Observable<StripeSubscription[]> {
|
||||||
return this.http.post<StripeSubscription[]>(`${BASE_URL}/setSubsSettings`, {
|
return this.http.post<StripeSubscription[]>(`${BASE_URL}/setSubsSettings`, {
|
||||||
subsSettings
|
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> {
|
getCoupon(coupon: string, priceKeys?: string[]): Observable<Coupon> {
|
||||||
return this.http.get<Coupon>(`${BASE_URL}/getCoupon/${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> {
|
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 {
|
hasSubsWithStatus(subs: StripeSubscription[], status: string): boolean {
|
||||||
return subs?.some((sub) => sub?.status === `${status}`);
|
return subs?.some((sub) => sub?.status === `${status}`);
|
||||||
}
|
}
|
||||||
@ -191,7 +438,16 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isRequireAction(subs: StripeSubscription[]): boolean {
|
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 {
|
getReqPmSubscription(subs: StripeSubscription[]): StripeSubscription {
|
||||||
@ -200,7 +456,12 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
getReqActionSubscription(subs: StripeSubscription[]): StripeSubscription {
|
getReqActionSubscription(subs: StripeSubscription[]): StripeSubscription {
|
||||||
SubStripe.REQUIRE_ACTION
|
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 {
|
atCheckoutReviewStage(): boolean {
|
||||||
@ -275,12 +536,16 @@ export class SubscriptionService {
|
|||||||
private calcInvoiceWithProrate(invoices: Invoice[], coupon?: Coupon): CheckoutPayment {
|
private calcInvoiceWithProrate(invoices: Invoice[], coupon?: Coupon): CheckoutPayment {
|
||||||
let lines = [];
|
let lines = [];
|
||||||
invoices.map((inv) => lines = lines.concat(inv?.lines?.data?.filter((line) => line?.period?.start === inv?.subscription_proration_date)));
|
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 isRefundLine = (line: any) =>
|
||||||
const refLines = lines.filter((line) => line.amount < 0);
|
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;
|
let pmt: CheckoutPayment;
|
||||||
const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(pmtLines));
|
const pmtTotalTax = this.calcTotalAmount(this.extractLineTax(pmtLines));
|
||||||
if (refLines.length > 0) {
|
if (rfdLines.length > 0) {
|
||||||
const refTotalTax = this.calcTotalAmount(this.extractLineTax(refLines));
|
const refTotalTax = this.calcTotalAmount(this.extractLineTax(rfdLines));
|
||||||
pmt = {
|
pmt = {
|
||||||
payment: {
|
payment: {
|
||||||
lineItems: pmtLines,
|
lineItems: pmtLines,
|
||||||
@ -288,8 +553,8 @@ export class SubscriptionService {
|
|||||||
totalTax: pmtTotalTax
|
totalTax: pmtTotalTax
|
||||||
},
|
},
|
||||||
refund: {
|
refund: {
|
||||||
lineItems: refLines,
|
lineItems: rfdLines,
|
||||||
totalAmount: this.calcTotalAmount(refLines) + refTotalTax,
|
totalAmount: this.calcTotalAmount(rfdLines) + refTotalTax,
|
||||||
totalTax: refTotalTax
|
totalTax: refTotalTax
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -311,18 +576,23 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
calcChkoutPayment(invoices: Invoice[], opt?: Option): CheckoutPayment {
|
calcChkoutPayment(invoices: Invoice[], opt?: Option): CheckoutPayment {
|
||||||
if (Utils.isEmptyArray(invoices)) return { payment: { totalAmount: 0, totalTax: 0, lineItems: [] } };
|
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 hasUnresolvedSub = opt?.subscriptions?.some((sub) =>
|
||||||
const hasUnResolvedInvoice = opt?.subscriptions?.some((sub) =>
|
sub.status === SubStripe.UNPAID || sub.status === SubStripe.INCOMPLETE ||
|
||||||
sub.status === SubStripe.UNPAID ||
|
sub.status === SubStripe.PAST_DUE || sub.status === SubStripe.OVERDUE
|
||||||
sub.status === SubStripe.INCOMPLETE ||
|
);
|
||||||
sub.status === SubStripe.PAST_DUE ||
|
if (hasUnresolvedSub) {
|
||||||
sub.status === SubStripe.OVERDUE
|
|
||||||
) || hasNoProrate;
|
|
||||||
if (hasUnResolvedInvoice) {
|
|
||||||
return this.calcInvoice(invoices, opt?.coupon);
|
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 {
|
calcAmount(invoices: Invoice[], opt?: Option): PaidAmount {
|
||||||
@ -401,8 +671,44 @@ export class SubscriptionService {
|
|||||||
return DEFAULT_CURRENCY;
|
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 {
|
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 THOUSAND = 1000;
|
||||||
const maxAcrToK = +maxAcres / THOUSAND;
|
const maxAcrToK = +maxAcres / THOUSAND;
|
||||||
return maxAcrToK > 0 ? `${maxAcrToK}K` : maxAcres.toString();
|
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 {
|
updateMembShip(subscriptions: StripeSubscription[], membership: IMembership): IMembership {
|
||||||
if (Utils.isEmptyArray(subscriptions)) return membership;
|
if (Utils.isEmptyArray(subscriptions)) return membership;
|
||||||
return {
|
|
||||||
...membership,
|
// NOTE: This method works with StripeSubscription (from Stripe API) which has different field names
|
||||||
endOfPeriod: subscriptions?.find((sub) => sub.metadata.type === SubType.PACKAGE)?.latest_invoice.period_end ||
|
// (current_period_end vs periodEnd). The centralized utilities work with AGNavSubscription.
|
||||||
subscriptions?.find((sub) => sub.metadata.type === SubType.ADDON)?.latest_invoice.period_end,
|
// We keep this logic here as it's specific to Stripe API response transformation.
|
||||||
subscriptions: subscriptions?.map((sub) => ({
|
|
||||||
|
// 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,
|
id: sub.id,
|
||||||
periodEnd: sub.current_period_end,
|
periodEnd: sub.current_period_end,
|
||||||
periodStart: sub.current_period_start,
|
periodStart: sub.current_period_start,
|
||||||
status: sub.status,
|
status: sub.status,
|
||||||
items: sub.items.data?.map((item) => ({
|
items: sub.items.data?.map((item) => ({
|
||||||
price: item.price.lookup_key,
|
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,
|
type: this.inferStripeSubType(sub),
|
||||||
cancelAtPeriodEnd: sub.cancel_at_period_end
|
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: {} };
|
return { subscriptions, membership, package: {}, addon: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSubscriptionItem = (type: SubType) =>
|
// Use subscriptions parameter (from Stripe API with custom limits override)
|
||||||
membership.subscriptions.find(sub => sub.type === type
|
// instead of membership.subscriptions (from MongoDB without override)
|
||||||
&& (sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING))?.items[0];
|
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 => ({
|
const createAcrePlan = (currUsage: number, limit: number): Acre => ({
|
||||||
currUsage,
|
currUsage,
|
||||||
@ -458,12 +811,28 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
const pkg = getSubscriptionItem(SubType.PACKAGE);
|
const pkg = getSubscriptionItem(SubType.PACKAGE);
|
||||||
const addon = getSubscriptionItem(SubType.ADDON);
|
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;
|
// ✅ FIX (2026-01-27): Read metadata from MongoDB session data instead of Stripe API
|
||||||
const acre = createAcrePlan(UnitUtils.haToArea(usage.ttArea, true), maxAcres || subPlans[pkgPrice]?.maxAcres);
|
// MongoDB is source of truth for subscription metadata changes (updated by admin/backend)
|
||||||
const maxVehicles = isNaN(+pkg?.metadata?.maxVehicles) ? 0 : +pkg?.metadata?.maxVehicles;
|
// Stripe API caches metadata and doesn't sync with MongoDB direct updates
|
||||||
const pkgNumVeh = maxVehicles || subPlans[pkgPrice]?.maxVehicles || 0;
|
// 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 trackNumVeh = addon?.quantity || 0;
|
||||||
|
|
||||||
const packagePlan = pkg ? {
|
const packagePlan = pkg ? {
|
||||||
@ -497,14 +866,26 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
fmtSubMsg(text: string, key: PriceUsd, vehicle: { trkQuantity?: number, pkgQuantity?: number }): string {
|
fmtSubMsg(text: string, key: PriceUsd, vehicle: { trkQuantity?: number, pkgQuantity?: number }): string {
|
||||||
return text?.replace('#pkg#', subPlans[key].name)
|
return text?.replace('#pkg#', subPlans[key].name)
|
||||||
.replace('#quantity#', `${vehicle.trkQuantity}` || '')
|
.replace('#quantity#', `${vehicle.trkQuantity ?? ''}`)
|
||||||
.replace('#maxAC#', `${vehicle.pkgQuantity}` || '') || '';
|
.replace('#maxAC#', `${vehicle.pkgQuantity ?? ''}`) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
toVehRange(precedingMax: number, maxVehicles: number): string {
|
toVehRange(precedingMax: number, maxVehicles: number): string {
|
||||||
const MIN = 1;
|
const MIN = 1;
|
||||||
const MAX = 10;
|
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
|
return maxVehicles > MIN && maxVehicles <= MAX
|
||||||
? lowerRange ? `${lowerRange}-${maxVehicles}`
|
? lowerRange ? `${lowerRange}-${maxVehicles}`
|
||||||
: `${maxVehicles}`
|
: `${maxVehicles}`
|
||||||
@ -537,7 +918,8 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} 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) => {
|
map((user) => {
|
||||||
return billingInfoPackage = {
|
return billingInfoPackage = {
|
||||||
isNewAccount: true,
|
isNewAccount: true,
|
||||||
@ -552,7 +934,7 @@ export class SubscriptionService {
|
|||||||
postal_code: ''
|
postal_code: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -565,6 +947,7 @@ export class SubscriptionService {
|
|||||||
description: addon.desc,
|
description: addon.desc,
|
||||||
amount: +addon.price * addon.quantity,
|
amount: +addon.price * addon.quantity,
|
||||||
quantity: addon.quantity,
|
quantity: addon.quantity,
|
||||||
|
trialEnd: addon.trialEnd, // Populate trialEnd from addon (for extended trial display)
|
||||||
price: {
|
price: {
|
||||||
lookup_key: addon.lookupKey,
|
lookup_key: addon.lookupKey,
|
||||||
unit_amount: +addon.price
|
unit_amount: +addon.price
|
||||||
@ -576,6 +959,7 @@ export class SubscriptionService {
|
|||||||
description: selPkg.desc,
|
description: selPkg.desc,
|
||||||
amount: +selPkg.price,
|
amount: +selPkg.price,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
trialEnd: selPkg.trialEnd, // Populate trialEnd from package (for extended trial display)
|
||||||
price: {
|
price: {
|
||||||
lookup_key: selPkg.lookupKey,
|
lookup_key: selPkg.lookupKey,
|
||||||
unit_amount: +selPkg.price
|
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 {
|
ngOnDestroy(): void {
|
||||||
if (this.sub$) this.sub$.unsubscribe();
|
if (this.sub$) this.sub$.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,10 +20,17 @@ export class UserService {
|
|||||||
return this.http.post<User[]>(this.userURL + '/search', options);
|
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}`;
|
let url = `${this.userURL}/${id}`;
|
||||||
if (ops && ops.withAddresses !== undefined) {
|
const params: string[] = [];
|
||||||
url += `?withAddresses=${ops.withAddresses}`;
|
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);
|
return this.http.get<User>(url);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,10 +4,9 @@ import { Actions, Effect, ofType } from '@ngrx/effects';
|
|||||||
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import * as subPlansActions from '@app/actions/sub-plans.actions'
|
import * as subPlansActions from '@app/actions/sub-plans.actions'
|
||||||
import { catchError, delay, filter, repeat, retryWhen, switchMap, take } from 'rxjs/operators';
|
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 } from '../profile/common';
|
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 { 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 { StripeSubscription, Usage } from '@app/domain/models/subscription.model';
|
||||||
import { AppMessageService } from '@app/shared/app-message.service';
|
import { AppMessageService } from '@app/shared/app-message.service';
|
||||||
import { globals } from '@app/shared/global';
|
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 { FetchLatestSubscriptionSuccess, GotoAircraftList, UpdateSubscriptionStatus } from '@app/actions/subscription.actions';
|
||||||
import { Vehicle } from '@app/entities/models/vehicle.model';
|
import { Vehicle } from '@app/entities/models/vehicle.model';
|
||||||
import { CustomerService } from '@app/domain/services/customer.service';
|
import { CustomerService } from '@app/domain/services/customer.service';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubPlansEffects {
|
export class SubPlansEffects {
|
||||||
@ -23,42 +23,107 @@ export class SubPlansEffects {
|
|||||||
private readonly actions$: Actions,
|
private readonly actions$: Actions,
|
||||||
private readonly subSvc: SubscriptionService,
|
private readonly subSvc: SubscriptionService,
|
||||||
private readonly authSvc: AuthService,
|
private readonly authSvc: AuthService,
|
||||||
private readonly userSvc: UserService,
|
|
||||||
private readonly msgSvc: AppMessageService,
|
private readonly msgSvc: AppMessageService,
|
||||||
private readonly vehSvc: VehicleService,
|
private readonly vehSvc: VehicleService,
|
||||||
private readonly custSvc: CustomerService
|
private readonly custSvc: CustomerService,
|
||||||
|
private readonly router: Router
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@Effect()
|
@Effect()
|
||||||
refreshSubPlans$: Observable<Action> = this.actions$.pipe(
|
refreshSubPlans$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType<subPlansActions.FetchSubPlans>(subPlansActions.FETCH_SUB_PLANS),
|
ofType<subPlansActions.FetchSubPlans>(subPlansActions.FETCH_SUB_PLANS),
|
||||||
switchMap((action: subPlansActions.FetchSubPlans) => {
|
exhaustMap((action: subPlansActions.FetchSubPlans) => {
|
||||||
let usage: Usage;
|
let usage: Usage;
|
||||||
let subscriptions: StripeSubscription[];
|
let subscriptions: StripeSubscription[];
|
||||||
let vehicles: Vehicle[];
|
let vehicles: Vehicle[];
|
||||||
|
let sortedPrices: any[];
|
||||||
return this.subSvc.getPrices().pipe(
|
return this.subSvc.getPrices().pipe(
|
||||||
filter(prices => prices?.length > 0),
|
filter(prices => prices?.length > 0),
|
||||||
switchMap(prices => {
|
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) => {
|
sortedPrices.forEach((price, indx) => {
|
||||||
if (price) {
|
if (price) {
|
||||||
const plan = subPlans[price.lookupKey] || {};
|
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.price = price.priceUSD || plan.price;
|
||||||
plan.desc = plan.desc?.replace('#price#', this.subSvc.formatCurrency(price.priceUSD)) || plan.desc;
|
plan.desc = plan.desc?.replace('#price#', this.subSvc.formatCurrency(price.priceUSD)) || plan.desc;
|
||||||
plan.maxVehicles = price.maxVehicles || plan.maxVehicles;
|
// Current subscription: use effectiveMaxVehicles (respects custom limits).
|
||||||
plan.maxAcres = price.maxAcres || plan.maxAcres;
|
// 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.level = price.level || plan.level;
|
||||||
plan.type = price.type || plan.type;
|
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;
|
subPlans[price.lookupKey] = plan;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return this.userSvc.getUser(this.authSvc.user?._id);
|
|
||||||
}),
|
const byPuid = this.authSvc.user?.parent || this.authSvc.user?._id;
|
||||||
switchMap(profileUser => {
|
return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, byPuid);
|
||||||
return this.subSvc.retrieveCurrUsage(this.authSvc.user?.membership?.custId, profileUser._id);
|
|
||||||
}),
|
}),
|
||||||
switchMap(_usage => {
|
switchMap(_usage => {
|
||||||
usage = _usage;
|
usage = _usage;
|
||||||
@ -66,21 +131,16 @@ export class SubPlansEffects {
|
|||||||
}),
|
}),
|
||||||
switchMap(_subs => {
|
switchMap(_subs => {
|
||||||
subscriptions = _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;
|
vehicles = _vehicles;
|
||||||
let id;
|
const id = this.authSvc.user?.parent || this.authSvc.user._id;
|
||||||
|
|
||||||
if (this.authSvc.user?.parent) {
|
|
||||||
id = this.authSvc.user?.parent;
|
|
||||||
} else {
|
|
||||||
id = this.authSvc.user._id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.custSvc.getCustomer(id);
|
return this.custSvc.getCustomer(id);
|
||||||
}),
|
}),
|
||||||
switchMap((cust) => {
|
switchMap(cust => {
|
||||||
const curSubPlan = this.subSvc.createSubPlan(subscriptions, cust?.membership, usage);
|
const curSubPlan = this.subSvc.createSubPlan(subscriptions, cust?.membership, usage);
|
||||||
const getNumVehs = (type: string) => vehicles?.filter((veh) => veh[type] === true).length || 0;
|
const getNumVehs = (type: string) => vehicles?.filter((veh) => veh[type] === true).length || 0;
|
||||||
const trkVehicles = getNumVehs(TRACKING);
|
const trkVehicles = getNumVehs(TRACKING);
|
||||||
@ -88,8 +148,97 @@ export class SubPlansEffects {
|
|||||||
const needReview = cust?.needReview;
|
const needReview = cust?.needReview;
|
||||||
|
|
||||||
if (subscriptions?.length === 0) {
|
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 isCurTrkVehAboveLimit = trkVehicles > curSubPlan?.addon?.[SubKeys.TRACKING]?.airCraft?.numOfVehicle;
|
||||||
const isCurActiveVehAboveLimit = pkgActiveVehicles > curSubPlan?.package?.[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.airCraft?.numOfVehicle;
|
const isCurActiveVehAboveLimit = pkgActiveVehicles > curSubPlan?.package?.[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.airCraft?.numOfVehicle;
|
||||||
|
|
||||||
@ -98,7 +247,11 @@ export class SubPlansEffects {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (cust?.membership) {
|
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) {
|
if (isCurActiveVehAboveLimit || isCurTrkVehAboveLimit || needReview) {
|
||||||
@ -116,7 +269,7 @@ export class SubPlansEffects {
|
|||||||
delay(DELAY),
|
delay(DELAY),
|
||||||
take(TAKE)
|
take(TAKE)
|
||||||
)),
|
)),
|
||||||
catchError((err) => {
|
catchError(err => {
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subPlans));
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.subPlans));
|
||||||
return handleErr<Observable<Action>>({
|
return handleErr<Observable<Action>>({
|
||||||
error: err, opt: {
|
error: err, opt: {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { from, interval, Observable, of } from 'rxjs';
|
import { from, interval, Observable, of, forkJoin, throwError } from 'rxjs';
|
||||||
import { map, switchMap, catchError, take, takeUntil, tap, startWith, debounceTime, concatMap, repeat } from 'rxjs/operators';
|
import { map, switchMap, catchError, take, takeUntil, tap, startWith, debounceTime, concatMap, repeat, takeWhile } from 'rxjs/operators';
|
||||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||||
import { Action, Store } from '@ngrx/store';
|
import { Action, Store } from '@ngrx/store';
|
||||||
import * as subAction from '@app/actions/subscription.actions';
|
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 { 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 { PaymentIntentResult, PaymentMethodResult } from '@stripe/stripe-js';
|
||||||
import { UserModel } from '@app/auth/models/user.model';
|
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 { DateUtils, Utils } from '@app/shared/utils'
|
||||||
import { AuthService } from '@app/domain/services/auth.service';
|
import { AuthService } from '@app/domain/services/auth.service';
|
||||||
import { ResetSubPlans } from '@app/actions/sub-plans.actions';
|
import { ResetSubPlans } from '@app/actions/sub-plans.actions';
|
||||||
import { CustomerService } from '@app/domain/services/customer.service';
|
import { CustomerService } from '@app/domain/services/customer.service';
|
||||||
import { environment } from '@environments/environment';
|
|
||||||
import { Customer } from '@app/customers/models/customer.model';
|
import { Customer } from '@app/customers/models/customer.model';
|
||||||
import { GAService } from '@app/shared/ga.service';
|
import { GAService } from '@app/shared/ga.service';
|
||||||
import { GAAnalyticsHelpersService } from '@app/shared/ga.analytics-helpers.service';
|
import { GAAnalyticsHelpersService } from '@app/shared/ga.analytics-helpers.service';
|
||||||
@ -54,7 +53,38 @@ export class SubscriptionEffects {
|
|||||||
fetchLatestSub$: Observable<Action> = this.actions$.pipe(
|
fetchLatestSub$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType<subAction.FetchLatestSubscription>(subAction.FETCH_LATEST_SUBSCRIPTION),
|
ofType<subAction.FetchLatestSubscription>(subAction.FETCH_LATEST_SUBSCRIPTION),
|
||||||
switchMap((action: subAction.FetchLatestSubscription) => this.subSvc.fetchSubscriptions(action.payload.custId)),
|
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 } })),
|
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.FETCH_SUB_ERR } })),
|
||||||
repeat()
|
repeat()
|
||||||
);
|
);
|
||||||
@ -285,7 +315,24 @@ export class SubscriptionEffects {
|
|||||||
// Track successful trial checkout
|
// Track successful trial checkout
|
||||||
this.trackSubscriptionPurchase(subs, { payload: updatePkgPayload });
|
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 };
|
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) {
|
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) => {
|
switchMap((coupon: Coupon) => {
|
||||||
if (!coupon.valid) {
|
if (!coupon.valid) {
|
||||||
return handleErr<Observable<Action>>({ error: '', opt: { extra: SubAppErr.APP_DISCOUNT_PREVIEW_ERR } });
|
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: [] }))
|
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()
|
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
|
// Checkout-review stage
|
||||||
private finalizeConfirm({ action, results, confirmPkg }) {
|
private finalizeConfirm({ action, results, confirmPkg }) {
|
||||||
return switchMap((subscriptions: StripeSubscription[]) => {
|
return switchMap((subscriptions: StripeSubscription[]) => {
|
||||||
@ -391,6 +503,26 @@ export class SubscriptionEffects {
|
|||||||
const lastPaymentErr: any = error?.payment_intent?.last_payment_error;
|
const lastPaymentErr: any = error?.payment_intent?.last_payment_error;
|
||||||
const card: Card = lastPaymentErr?.payment_method?.card || lastPaymentErr?.source;
|
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) {
|
if (isPastdueType) {
|
||||||
return of(
|
return of(
|
||||||
new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card })),
|
new subAction.UpdateSubscriptionStatus(createSubStatus(SubAppErr.CONF_ERR, { extra: lastPaymentErr, card })),
|
||||||
@ -441,20 +573,68 @@ export class SubscriptionEffects {
|
|||||||
return of(action.payload).pipe(
|
return of(action.payload).pipe(
|
||||||
switchMap((_confirmPkg: ConfirmPackage) => {
|
switchMap((_confirmPkg: ConfirmPackage) => {
|
||||||
confirmPkg = _confirmPkg;
|
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$)
|
const promiseChain = Utils.createPromiseChain<PaymentIntentResult>(confirmations$)
|
||||||
return from(promiseChain);
|
return from(promiseChain);
|
||||||
}),
|
}),
|
||||||
switchMap((results: PaymentIntentResult[]) => {
|
switchMap((results: PaymentIntentResult[]) => {
|
||||||
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
|
// Check for errors in 3DS confirmation
|
||||||
this.finalizeConfirm({ action, results, confirmPkg })
|
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()
|
repeat()
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -476,7 +656,33 @@ export class SubscriptionEffects {
|
|||||||
if (hasIncompleteSub) {
|
if (hasIncompleteSub) {
|
||||||
const req3dsVerf = this.subSvc.isRequireAction(subscriptions);
|
const req3dsVerf = this.subSvc.isRequireAction(subscriptions);
|
||||||
const reqPm = this.subSvc.isRequirePaymentMethod(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;
|
const hasLatestInvoices = latestInvoices?.length > 0;
|
||||||
if (hasLatestInvoices) {
|
if (hasLatestInvoices) {
|
||||||
if (req3dsVerf) {
|
if (req3dsVerf) {
|
||||||
@ -487,6 +693,13 @@ export class SubscriptionEffects {
|
|||||||
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }),
|
new subAction.UpdateIncomplete({ invoices: latestInvoices, requiresAction: true, requiresPM: false, numOfRetries: 0, subscriptions }),
|
||||||
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
|
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) {
|
} else if (reqPm) {
|
||||||
const atChkoutRevStage = action.payload.stage === SUB.CHKOUT_REV;
|
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.UpdateIncomplete({ invoices: latestInvoices, requiresAction: false, requiresPM: true, numOfRetries: 0, subscriptions }),
|
||||||
new subAction.UpdateSubscriptionSuccess(this.subSvc.createSubPlan(subscriptions, this.authSvc.user?.membership, usage))
|
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 {
|
} else {
|
||||||
return handleErr<Observable<Action>>({ opt: { extra: SubAppErr.NO_INVOICES_ERR } });
|
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());
|
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 } })),
|
catchError((err) => handleErr<Observable<Action>>({ error: err, opt: { extra: SubAppErr.UPDATE_SUB_ERR } })),
|
||||||
@ -690,7 +915,6 @@ export class SubscriptionEffects {
|
|||||||
switchMap((action: subAction.InitSubscription) => {
|
switchMap((action: subAction.InitSubscription) => {
|
||||||
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
|
return this.subSvc.fetchSubscriptions(action.payload.custId).pipe(
|
||||||
switchMap((subscriptions: StripeSubscription[]) => {
|
switchMap((subscriptions: StripeSubscription[]) => {
|
||||||
|
|
||||||
const hasNoSubs = subscriptions?.length === 0;
|
const hasNoSubs = subscriptions?.length === 0;
|
||||||
const hasUnpaidSubs = subscriptions?.some((sub) => sub?.status === SubStripe.UNPAID);
|
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);
|
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) {
|
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(
|
return of(
|
||||||
new ResetSubPlans(),
|
new ResetSubPlans(),
|
||||||
new subAction.ResetSubscription());
|
new subAction.ResetSubscription());
|
||||||
@ -737,7 +964,8 @@ export class SubscriptionEffects {
|
|||||||
new subAction.UpdateSubscriptionStatus(createSubStatus(SubStripe.REQ_LOC_INPUT))
|
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 } })),
|
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 subscriptionPrice = packageInfo?.amount ? packageInfo.amount / 100 : 0;
|
||||||
const interval = packageInfo?.interval || 'month';
|
const interval = packageInfo?.interval || 'month';
|
||||||
|
|
||||||
// Track addon purchases as placeholder (log for now)
|
// Track addon purchases as placeholder
|
||||||
if (serviceType === SERVICE_TYPE.ADDON) {
|
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
|
// TODO: Implement addon tracking event when requirements are defined
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -994,4 +1217,101 @@ export class SubscriptionEffects {
|
|||||||
console.warn('Failed to track subscription purchase:', error);
|
console.warn('Failed to track subscription purchase:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll subscription status until it becomes 'active' or timeout (r944 requirement)
|
||||||
|
*
|
||||||
|
* After 3DS completion:
|
||||||
|
* - PaymentIntent status becomes 'succeeded' immediately
|
||||||
|
* - Subscription status stays 'incomplete' for 1-3 seconds
|
||||||
|
* - Stripe charges card in background
|
||||||
|
* - This method waits for subscription to become 'active'
|
||||||
|
*
|
||||||
|
* @param subscriptionId Stripe subscription ID (sub_xxxxx)
|
||||||
|
* @param maxAttempts Maximum polling attempts (default 10 = 5 seconds)
|
||||||
|
* @param intervalMs Delay between attempts in milliseconds (default 500ms)
|
||||||
|
* @returns Observable with final subscription status or error
|
||||||
|
*/
|
||||||
|
private pollSubscriptionStatus(
|
||||||
|
subscriptionId: string,
|
||||||
|
maxAttempts: number = 10,
|
||||||
|
intervalMs: number = 500
|
||||||
|
): Observable<any> {
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
return interval(intervalMs).pipe(
|
||||||
|
startWith(0), // Start immediately (no initial delay)
|
||||||
|
|
||||||
|
switchMap(() => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
return this.subSvc.checkSubscriptionStatus(subscriptionId).pipe(
|
||||||
|
map(response => ({
|
||||||
|
subscription: response,
|
||||||
|
attempts,
|
||||||
|
error: null
|
||||||
|
})),
|
||||||
|
catchError(err => {
|
||||||
|
console.error(`❌ Polling error on attempt ${attempts}:`, err);
|
||||||
|
return of({
|
||||||
|
subscription: null,
|
||||||
|
attempts,
|
||||||
|
error: err
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Evaluation logic - when to stop polling
|
||||||
|
tap(({ subscription, attempts, error }: any) => {
|
||||||
|
if (error) {
|
||||||
|
console.error(`❌ Status check failed:`, error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Stop conditions
|
||||||
|
takeWhile(({ subscription, attempts, error }: any) => {
|
||||||
|
// Stop if subscription is active (SUCCESS)
|
||||||
|
if (subscription?.status === 'active') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if subscription failed or canceled
|
||||||
|
if (subscription?.status === 'incomplete_expired' ||
|
||||||
|
subscription?.status === 'canceled') {
|
||||||
|
console.error(`❌ Subscription ${subscription.status} during polling`);
|
||||||
|
throw new Error(`Subscription ${subscription.status} - cannot proceed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if max attempts reached (TIMEOUT)
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
console.error(`❌ Polling timeout after ${attempts} attempts (${attempts * intervalMs}ms)`);
|
||||||
|
throw new Error(
|
||||||
|
`Subscription did not activate within ${maxAttempts * intervalMs / 1000} seconds. ` +
|
||||||
|
`Please check your subscription status in Stripe Dashboard.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if API error occurred
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Status check failed: ${error.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue polling for incomplete or past_due
|
||||||
|
return true;
|
||||||
|
}, true), // inclusive: true - emit the final value before completing
|
||||||
|
|
||||||
|
// Extract subscription from result
|
||||||
|
map(({ subscription }) => subscription),
|
||||||
|
|
||||||
|
// Take only the first successful result (when active) or error
|
||||||
|
take(1),
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
catchError(err => {
|
||||||
|
console.error(`❌ Polling failed:`, err);
|
||||||
|
return throwError(err);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { AutoCompleteModule } from 'primeng/autocomplete';
|
|||||||
import { InputSwitchModule } from 'primeng/inputswitch';
|
import { InputSwitchModule } from 'primeng/inputswitch';
|
||||||
import { SplitButtonModule } from 'primeng/splitbutton';
|
import { SplitButtonModule } from 'primeng/splitbutton';
|
||||||
import { TableModule } from 'primeng/table';
|
import { TableModule } from 'primeng/table';
|
||||||
|
import { MessagesModule } from 'primeng/messages';
|
||||||
|
|
||||||
import { StoreModule } from '@ngrx/store';
|
import { StoreModule } from '@ngrx/store';
|
||||||
import { EffectsModule } from '@ngrx/effects';
|
import { EffectsModule } from '@ngrx/effects';
|
||||||
@ -16,6 +17,7 @@ import { VehicleEffects } from './effects/vehicle.effects';
|
|||||||
import { reducers, FEATURE_KEY } from './reducers';
|
import { reducers, FEATURE_KEY } from './reducers';
|
||||||
|
|
||||||
import { AppSharedModule } from '../shared/app-shared.module';
|
import { AppSharedModule } from '../shared/app-shared.module';
|
||||||
|
import { PopupTooltipModule } from '../shared/popup-tooltip/popup-tooltip.module';
|
||||||
import { EntitiesRoutingModule } from './entities-routing.module';
|
import { EntitiesRoutingModule } from './entities-routing.module';
|
||||||
|
|
||||||
import { EntitiesMgtComponent } from './entities-mgt.component';
|
import { EntitiesMgtComponent } from './entities-mgt.component';
|
||||||
@ -26,6 +28,7 @@ import { PilotEditComponent } from './pilot/pilot-edit/pilot-edit.component';
|
|||||||
import { PilotService } from '../domain/services/pilot.service';
|
import { PilotService } from '../domain/services/pilot.service';
|
||||||
import { PilotResolver } from './pilot-resolver.service';
|
import { PilotResolver } from './pilot-resolver.service';
|
||||||
import { VehicleEditComponent } from './vehicle/vehicle-edit/vehicle-edit.component';
|
import { VehicleEditComponent } from './vehicle/vehicle-edit/vehicle-edit.component';
|
||||||
|
import { VehiclePartnerIntegrationComponent } from './vehicle/vehicle-partner-integration/vehicle-partner-integration.component';
|
||||||
import { VehicleResolver } from './vehicle-resolver.service';
|
import { VehicleResolver } from './vehicle-resolver.service';
|
||||||
import { VehicleService } from '../domain/services/vehicle.service';
|
import { VehicleService } from '../domain/services/vehicle.service';
|
||||||
import { CropEffects } from './effects/crop.effects';
|
import { CropEffects } from './effects/crop.effects';
|
||||||
@ -35,6 +38,7 @@ import { CropListComponent } from './crop/crop-list/crop-list.component';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
AppSharedModule,
|
AppSharedModule,
|
||||||
|
PopupTooltipModule,
|
||||||
DialogModule,
|
DialogModule,
|
||||||
ConfirmDialogModule,
|
ConfirmDialogModule,
|
||||||
CheckboxModule,
|
CheckboxModule,
|
||||||
@ -42,12 +46,13 @@ import { CropListComponent } from './crop/crop-list/crop-list.component';
|
|||||||
InputSwitchModule,
|
InputSwitchModule,
|
||||||
SplitButtonModule,
|
SplitButtonModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
|
MessagesModule,
|
||||||
|
|
||||||
StoreModule.forFeature(FEATURE_KEY, reducers),
|
StoreModule.forFeature(FEATURE_KEY, reducers),
|
||||||
EffectsModule.forFeature([PilotEffects, ProductEffects, VehicleEffects, CropEffects]),
|
EffectsModule.forFeature([PilotEffects, ProductEffects, VehicleEffects, CropEffects]),
|
||||||
EntitiesRoutingModule
|
EntitiesRoutingModule
|
||||||
],
|
],
|
||||||
declarations: [EntitiesMgtComponent, ProductListComponent, PilotListComponent, VehicleListComponent, PilotEditComponent, VehicleEditComponent, CropListComponent],
|
declarations: [EntitiesMgtComponent, ProductListComponent, PilotListComponent, VehicleListComponent, PilotEditComponent, VehicleEditComponent, VehiclePartnerIntegrationComponent, CropListComponent],
|
||||||
providers: [PilotService, PilotResolver, VehicleService, CropService, VehicleResolver],
|
providers: [PilotService, PilotResolver, VehicleService, CropService, VehicleResolver],
|
||||||
schemas: [
|
schemas: [
|
||||||
CUSTOM_ELEMENTS_SCHEMA
|
CUSTOM_ELEMENTS_SCHEMA
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { createNewUser, User } from '@app/accounts/models/user.model';
|
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 {
|
export interface Vehicle extends User {
|
||||||
vehicleType: number;
|
vehicleType: number;
|
||||||
|
tailNumber?: string; // Common tail number field for all aircraft
|
||||||
unitId?: string;
|
unitId?: string;
|
||||||
orgUnitId?: string; // used for unique validation at clientSide only
|
orgUnitId?: string; // used for unique validation at clientSide only
|
||||||
model?: string;
|
model?: string;
|
||||||
@ -12,17 +13,82 @@ export interface Vehicle extends User {
|
|||||||
trackonDate?: Date;
|
trackonDate?: Date;
|
||||||
pkgActive?: boolean;
|
pkgActive?: boolean;
|
||||||
pkgActiveDate?: Date;
|
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 {
|
export interface StatusChange {
|
||||||
ids: { [i: string]: string[] };
|
ids: { [i: string]: string[] };
|
||||||
type: string;
|
type: string;
|
||||||
deActivate?: {[i: string]: boolean};
|
deActivate?: { [i: string]: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNewVehicle = (parentId: string) => {
|
export const createNewVehicle = (parentId: string) => {
|
||||||
const vehicle = <Vehicle>createNewUser(parentId, RoleIds.DEVICE);
|
const vehicle = <Vehicle>createNewUser(parentId, RoleIds.DEVICE);
|
||||||
vehicle.vehicleType = 0;
|
vehicle.vehicleType = 0;
|
||||||
|
vehicle.tailNumber = '';
|
||||||
|
|
||||||
return vehicle;
|
return vehicle;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,155 @@
|
|||||||
|
/* Partner Integration Styles */
|
||||||
|
|
||||||
|
.partner-aircraft-section {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #bdbdbd;
|
||||||
|
/* dividerColor - AgMission borders */
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
/* contentBgColor - AgMission content background */
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-aircraft-section h4 {
|
||||||
|
color: #212121;
|
||||||
|
/* textColor - AgMission primary text */
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tail Number Constraint Message Styling */
|
||||||
|
.md-inputfield+agm-constraint-message {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #03A9F4;
|
||||||
|
/* blue - AgMission info color */
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator i {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-id {
|
||||||
|
color: #757575;
|
||||||
|
/* textSecondaryColor - AgMission secondary text */
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-aircraft-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #E1F5FE;
|
||||||
|
/* Light blue background for info messages */
|
||||||
|
border: 1px solid #03A9F4;
|
||||||
|
/* blue - AgMission info border */
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #0277BD;
|
||||||
|
/* blueHover - AgMission darker blue for text */
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-aircraft-message i {
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-aircraft-info {
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
/* contentBgColor - AgMission content background */
|
||||||
|
border: 1px solid #bdbdbd;
|
||||||
|
/* dividerColor - AgMission borders */
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-aircraft-info h5 {
|
||||||
|
color: #212121;
|
||||||
|
/* textColor - AgMission primary text */
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ready {
|
||||||
|
background-color: #E8F5E8;
|
||||||
|
/* Light green background */
|
||||||
|
color: #2E7D32;
|
||||||
|
/* primaryDarkColor - AgMission dark green */
|
||||||
|
border: 1px solid #4CAF50;
|
||||||
|
/* primaryColor - AgMission main green */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background-color: #FFEBEE;
|
||||||
|
/* Light red background */
|
||||||
|
color: #C62828;
|
||||||
|
/* redHover - AgMission dark red */
|
||||||
|
border: 1px solid #F44336;
|
||||||
|
/* red - AgMission error color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-loading {
|
||||||
|
background-color: #FFF8E1;
|
||||||
|
/* Light amber background */
|
||||||
|
color: #FF8F00;
|
||||||
|
/* amberHover - AgMission dark amber */
|
||||||
|
border: 1px solid #FFC107;
|
||||||
|
/* amber - AgMission warning color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input with inline constraint message */
|
||||||
|
.input-with-inline-constraint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
/* AgMission standard spacing */
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-inline-constraint .md-inputfield {
|
||||||
|
flex: 1;
|
||||||
|
/* Input takes remaining space */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline constraint beside input - vertically aligned with input field center */
|
||||||
|
.input-with-inline-constraint .inline-constraint {
|
||||||
|
margin-top: -2px;
|
||||||
|
/* Shift icon upward to align with input box vertical center */
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-inline-constraint .inline-constraint ::ng-deep .agm-constraint-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
width: auto;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.partner-aircraft-section {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack input and icon vertically on very small screens if needed */
|
||||||
|
.input-with-inline-constraint {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,8 +6,10 @@
|
|||||||
<div class="ui-g ui-g-nopad" style="margin-top:40px">
|
<div class="ui-g ui-g-nopad" style="margin-top:40px">
|
||||||
<div class="ui-g-12 ui-lg-6 form-row">
|
<div class="ui-g-12 ui-lg-6 form-row">
|
||||||
<span class="md-inputfield">
|
<span class="md-inputfield">
|
||||||
<input type="text" id="vehicleName" name="vehicleName" #name="ngModel" required [(ngModel)]="selectedItem.name" #vehicleName pInputText maxlength="100">
|
<input type="text" id="vehicleName" name="vehicleName" #name="ngModel" required
|
||||||
<span i18n="@@aircraftNameReqVal" *ngIf="!name?.valid" class="ui-message ui-messages-error ui-corner-all">Aircraft Name is required</span>
|
[(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>
|
<label i18n="@@name">Name</label>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -15,7 +17,8 @@
|
|||||||
<span style="margin-right:12px">
|
<span style="margin-right:12px">
|
||||||
<ng-container i18n="@@aircraftType">Aircraft Type</ng-container>:
|
<ng-container i18n="@@aircraftType">Aircraft Type</ng-container>:
|
||||||
</span>
|
</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">
|
<ng-template let-type pTemplate="item">
|
||||||
<span>
|
<span>
|
||||||
<strong>{{ type.label }}</strong>
|
<strong>{{ type.label }}</strong>
|
||||||
@ -23,6 +26,12 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</p-dropdown>
|
</p-dropdown>
|
||||||
</div>
|
</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">
|
<div class="ui-g-12 ui-lg-6 form-row">
|
||||||
<span class="md-inputfield">
|
<span class="md-inputfield">
|
||||||
<input type="text" id="model" name="model" [(ngModel)]="selectedItem.model" pInputText maxlength="200">
|
<input type="text" id="model" name="model" [(ngModel)]="selectedItem.model" pInputText maxlength="200">
|
||||||
@ -31,11 +40,38 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui-g-12 ui-lg-6 form-row">
|
<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">
|
<span class="md-inputfield">
|
||||||
<input type="hidden" name="orgUnitId" [ngModel]="orgUnitId">
|
<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">
|
<input autocomplete="off" type="text" id="unitId" name="unitId" #unitId="ngModel"
|
||||||
<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>
|
[(ngModel)]="selectedItem.unitId" pInputText maxlength="15" minlength="10" agmUnitIdUnique
|
||||||
<span *ngIf="(unitId.dirty || unitId.touched) && unitId.errors?.unitIdUnique" class="ui-message ui-messages-error ui-corner-all">
|
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) }}
|
{{ globals.apiErrorMsg(unitId.errors?.unitIdUnique) }}
|
||||||
</span>
|
</span>
|
||||||
<label i18n="@@unitId">UnitId</label>
|
<label i18n="@@unitId">UnitId</label>
|
||||||
@ -53,7 +89,8 @@
|
|||||||
<span style="margin-right:12px">
|
<span style="margin-right:12px">
|
||||||
<ng-container i18n="@@color">Color</ng-container>:
|
<ng-container i18n="@@color">Color</ng-container>:
|
||||||
</span>
|
</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">
|
<ng-template let-item pTemplate="selectedItem">
|
||||||
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||||
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
|
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
|
||||||
@ -66,16 +103,60 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</p-dropdown>
|
</p-dropdown>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui-g-12" style="padding-top: 0">
|
<!-- AgMission Native Account Editor (hidden for partner systems) -->
|
||||||
<agm-account-editor #account [isNew]="isNew" [account]="selectedItem" [showActive]="true" [isAircraftAccount]="true" [canActivateVehicle]="canActivateVehicle" i18n-title="@@accessAccInPt" title="Access Account in Guia Platinum">
|
<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>
|
</agm-account-editor>
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,59 +1,115 @@
|
|||||||
import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { SelectItem } from 'primeng/api';
|
||||||
|
|
||||||
import { Vehicle } from '../../models/vehicle.model';
|
import { Vehicle } from '../../models/vehicle.model';
|
||||||
import * as vehicleActions from '../../actions/vehicle.actions';
|
import * as vehicleActions from '../../actions/vehicle.actions';
|
||||||
|
|
||||||
import { StringUtils } from '@app/shared/utils';
|
import { StringUtils } from '@app/shared/utils';
|
||||||
import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component';
|
import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component';
|
||||||
import { globals, VehType, vehTypes } from '@app/shared/global';
|
import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component';
|
||||||
import { SelectItem } from 'primeng/api';
|
import { globals, VehType, vehTypes, SystemTypes, SourceSystem, OperationalStatus, Labels } from '@app/shared/global';
|
||||||
import { BaseComp } from '@app/shared/base/base.component';
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
import { selectLimit } from '@app/reducers';
|
import { selectLimit } from '@app/reducers';
|
||||||
import { Limit } from '@app/domain/models/subscription.model';
|
import { Limit } from '@app/domain/models/subscription.model';
|
||||||
import { SubKeys, SubType } from '@app/profile/common';
|
import { SubKeys, SubType } from '@app/profile/common';
|
||||||
|
import { PartnerIntegrationData, VehiclePartnerIntegrationComponent } from '../vehicle-partner-integration/vehicle-partner-integration.component';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMPONENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'agm-vehicle-edit',
|
selector: 'agm-vehicle-edit',
|
||||||
templateUrl: './vehicle-edit.component.html',
|
templateUrl: './vehicle-edit.component.html',
|
||||||
styles: []
|
styleUrls: ['./vehicle-edit.component.css']
|
||||||
})
|
})
|
||||||
export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
|
export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONSTANTS & READONLY PROPERTIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
readonly globals = globals;
|
readonly globals = globals;
|
||||||
|
readonly SourceSystem = SourceSystem;
|
||||||
|
readonly Labels = Labels;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CORE VEHICLE PROPERTIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
selectedItem: Vehicle;
|
selectedItem: Vehicle;
|
||||||
orgUnitId: string;
|
orgUnitId: string;
|
||||||
|
|
||||||
|
// Core vehicle form options
|
||||||
acTypes: SelectItem[];
|
acTypes: SelectItem[];
|
||||||
acColors: 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('vehicleName') vehicleName: ElementRef;
|
||||||
@ViewChild('account') accEditor: AccountEditorComponent;
|
@ViewChild('account') accEditor: AccountEditorComponent;
|
||||||
|
@ViewChild('partnerIntegration') partnerIntegration: VehiclePartnerIntegrationComponent;
|
||||||
|
@ViewChild('tailNumberConstraint') tailNumberConstraint: ConstraintMessageComponent;
|
||||||
hasTracking: boolean;
|
hasTracking: boolean;
|
||||||
|
|
||||||
private _vehicle: Vehicle;
|
// ============================================================================
|
||||||
get vehicle(): Vehicle { return this._vehicle; }
|
// VEHICLE MANAGEMENT PROPERTIES
|
||||||
set vehicle(vehicle: Vehicle) {
|
// ============================================================================
|
||||||
this._vehicle = vehicle;
|
|
||||||
this.selectedItem = Object.assign({}, vehicle); // create a clone object to work on the editor
|
|
||||||
|
|
||||||
if (!this.isNew && this.selectedItem.unitId)
|
private _vehicle: Vehicle;
|
||||||
this.orgUnitId = this.selectedItem.unitId;
|
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 {
|
get isNew(): boolean {
|
||||||
return this._isNew;
|
return this._isNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
get user() {
|
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(
|
constructor(
|
||||||
private readonly route: ActivatedRoute,
|
private readonly route: ActivatedRoute,
|
||||||
|
private readonly cdr: ChangeDetectorRef
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.acTypes = [
|
this.acTypes = [
|
||||||
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
|
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
|
||||||
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
|
{ 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.lime, value: 'lime' },
|
||||||
{ label: globals.yellow, value: 'yellow' },
|
{ label: globals.yellow, value: 'yellow' },
|
||||||
{ label: globals.orange, value: 'orange' },
|
{ label: globals.orange, value: 'orange' },
|
||||||
{ label: globals.purple, value: 'purple' },
|
{ label: globals.purple, value: 'purple' }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
// ============================================================================
|
||||||
this.sub$ = this.route.data
|
// LIFECYCLE METHODS
|
||||||
.subscribe((data) => {
|
// ============================================================================
|
||||||
const vehicle = data[0] as Vehicle || null;
|
|
||||||
if (vehicle) {
|
|
||||||
this.vehicle = vehicle;
|
|
||||||
this._isNew = (this.vehicle._id === '0');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.sub$.add(this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS])
|
|
||||||
.subscribe((action) => {
|
|
||||||
this.store.dispatch(new vehicleActions.Select(action['payload']));
|
|
||||||
this.goBack();
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.sub$.add(this.store.select(selectLimit(SubType.ADDON))
|
ngOnInit() {
|
||||||
.subscribe((addon) => {
|
// Handle query parameters for return navigation messages
|
||||||
const tracking: Limit = addon?.[SubKeys.TRACKING];
|
this.sub$ = this.route.queryParams.subscribe(params => {
|
||||||
this.hasTracking = tracking?.airCraft?.numOfVehicle > 0;
|
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 {
|
ngAfterViewInit(): void {
|
||||||
|
// Auto-focus vehicle name field for new vehicles
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
if (this.selectedItem && StringUtils.isEmpty(this.selectedItem.name)) {
|
if (this.selectedItem && StringUtils.isEmpty(this.selectedItem.name)) {
|
||||||
if (this.vehicleName.nativeElement) {
|
if (this.vehicleName && this.vehicleName.nativeElement) {
|
||||||
this.vehicleName.nativeElement.focus();
|
this.vehicleName.nativeElement.focus();
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
}
|
}
|
||||||
@ -102,9 +206,153 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 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() {
|
saveVehicle() {
|
||||||
if (this.accEditor) {
|
if (this.accEditor) {
|
||||||
const acc = this.accEditor.value;
|
const acc = this.accEditor.value;
|
||||||
@ -112,21 +360,233 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
this.selectedItem.password = acc.password;
|
this.selectedItem.password = acc.password;
|
||||||
this.selectedItem.active = acc.active;
|
this.selectedItem.active = acc.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.selectedItem?.tracking && !this.selectedItem?.unitId) {
|
if (this.selectedItem?.tracking && !this.selectedItem?.unitId) {
|
||||||
this.selectedItem.tracking = false;
|
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() {
|
private preparePartnerDataForBackend(): void {
|
||||||
this.router.navigate(['/entities/aircraft/', { id: this.vehicle._id }]);
|
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() {
|
get canActivateVehicle() {
|
||||||
return this.authSvc.canActivateVehicle;
|
return this.authSvc.canActivateVehicle;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
get isPartnerSystemSelected(): boolean {
|
||||||
super.ngOnDestroy();
|
return this.partnerData?.selectedPartner !== null && this.partnerData?.selectedPartner !== SourceSystem.AGNAV;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canEditPartnerFields(): boolean {
|
||||||
|
return this.partnerData?.partnerValidation?.accountExists &&
|
||||||
|
this.partnerData?.partnerValidation?.authenticationValid &&
|
||||||
|
!this.partnerData?.partnerValidation?.isValidating;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canEditBasicFields(): boolean {
|
||||||
|
return !this.partnerData?.partnerValidation?.isValidating;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canSaveVehicle(): boolean {
|
||||||
|
if (!this.selectedItem?.name || this.selectedItem.name.trim() === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basicFieldsValid = this.selectedItem?.name?.trim() &&
|
||||||
|
this.selectedItem?.model &&
|
||||||
|
this.selectedItem?.vehicleType !== undefined;
|
||||||
|
|
||||||
|
// Check account editor validity
|
||||||
|
const accountValid = !this.accEditor || this.accEditor.form?.valid;
|
||||||
|
|
||||||
|
if (!this.isPartnerSystemSelected) {
|
||||||
|
return basicFieldsValid && accountValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.partnerData?.partnerValidation?.accountExists || !this.partnerData?.partnerValidation?.authenticationValid) {
|
||||||
|
return basicFieldsValid && accountValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return basicFieldsValid && accountValid && !!this.partnerData?.selectedPartnerAircraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
get saveButtonTooltip(): string {
|
||||||
|
if (this.isPartnerSystemSelected) {
|
||||||
|
if (!this.partnerData?.partnerValidation?.accountExists) {
|
||||||
|
return Labels.SAVE_TOOLTIP_NO_ACCOUNT;
|
||||||
|
}
|
||||||
|
if (!this.partnerData?.partnerValidation?.authenticationValid) {
|
||||||
|
return Labels.SAVE_TOOLTIP_AUTH_FAILED;
|
||||||
|
}
|
||||||
|
const partnerName = this.partnerData?.selectedPartnerData?.name || Labels.GENERIC_PARTNER;
|
||||||
|
return `${Labels.SAVE_TOOLTIP_BASE_MESSAGE} ${partnerName} ${Labels.SAVE_TOOLTIP_INTEGRATION_SUFFIX}`;
|
||||||
|
}
|
||||||
|
return Labels.SAVE_TOOLTIP_NATIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
get partnerSystemName(): string {
|
||||||
|
return this.partnerData?.selectedPartnerData?.name || 'partner system';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account editor is valid
|
||||||
|
* For partner systems, account editor is hidden, so validation is skipped
|
||||||
|
*/
|
||||||
|
get isAccountValid(): boolean {
|
||||||
|
if (this.isPartnerSystemSelected) {
|
||||||
|
return true; // No account editor for partner systems
|
||||||
|
}
|
||||||
|
return !this.accEditor || this.accEditor.form?.valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PARTNER VEHICLE ACTIVATION METHODS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a partner system vehicle can be activated
|
||||||
|
* Requires: partner account exists, authentication valid, and aircraft selected
|
||||||
|
*/
|
||||||
|
canActivatePartnerVehicle(): boolean {
|
||||||
|
if (!this.isPartnerSystemSelected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.partnerData?.partnerValidation?.accountExists === true &&
|
||||||
|
this.partnerData?.partnerValidation?.authenticationValid === true &&
|
||||||
|
!!this.partnerData?.selectedPartnerAircraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns constraint message explaining why partner vehicle cannot be activated
|
||||||
|
*/
|
||||||
|
getPartnerActivationConstraintMessage(): string {
|
||||||
|
if (!this.isPartnerSystemSelected) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.partnerData?.partnerValidation?.accountExists) {
|
||||||
|
return Labels.PARTNER_ACCOUNT_REQUIRED_FOR_ACTIVATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.partnerData?.partnerValidation?.authenticationValid) {
|
||||||
|
return Labels.PARTNER_AUTH_REQUIRED_FOR_ACTIVATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.partnerData?.selectedPartnerAircraft) {
|
||||||
|
return Labels.PARTNER_AIRCRAFT_REQUIRED_FOR_ACTIVATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,3 +1,292 @@
|
|||||||
.highlight-btn{
|
.highlight-btn {
|
||||||
background-color: green;
|
background-color: #4CAF50;
|
||||||
|
/* $primaryColor */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom Aircraft Review Message - Enhanced UX Layout */
|
||||||
|
.aircraft-review-container {
|
||||||
|
background-color: #FFF8E1;
|
||||||
|
/* Warning background - AgMission amber light */
|
||||||
|
border: 1px solid #FFC107;
|
||||||
|
/* $amber */
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
/* Top-align for better text flow */
|
||||||
|
gap: 0.875rem;
|
||||||
|
/* Optimized spacing for visual hierarchy */
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
/* Subtle depth */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-review-container .pi-info-circle {
|
||||||
|
color: #FF8F00 !important;
|
||||||
|
/* $amberHover - dark warning color for good contrast */
|
||||||
|
font-size: 1.375em !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
/* Optical alignment with text baseline */
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-review-message {
|
||||||
|
color: #212121 !important;
|
||||||
|
/* $textColor - high contrast text */
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
/* Optimized line height for readability */
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
/* Remove default margins for precise control */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Generic Error Message - Enhanced UX Layout */
|
||||||
|
.generic-error-container {
|
||||||
|
background-color: #FFEBEE;
|
||||||
|
/* Error background - AgMission red light */
|
||||||
|
border: 1px solid #F44336;
|
||||||
|
/* $red */
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
/* Top-align for better text flow */
|
||||||
|
gap: 0.875rem;
|
||||||
|
/* Consistent spacing with warning */
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
/* Subtle depth */
|
||||||
|
}
|
||||||
|
|
||||||
|
.generic-error-container .pi-exclamation-triangle {
|
||||||
|
color: #C62828 !important;
|
||||||
|
/* $redHover - dark error red for good contrast */
|
||||||
|
font-size: 1.375em !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
/* Optical alignment with text baseline */
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generic-error-message {
|
||||||
|
color: #C62828 !important;
|
||||||
|
/* $redHover - dark error text for contrast */
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
/* Consistent line height */
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
/* Remove default margins for precise control */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic Message Enhanced Styling */
|
||||||
|
:host ::ng-deep generic-message {
|
||||||
|
display: block;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep generic-message .icon-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Styling for Aircraft Review */
|
||||||
|
:host ::ng-deep generic-message .amber-btn {
|
||||||
|
background-color: #FFC107;
|
||||||
|
/* $amber */
|
||||||
|
border-color: #FF8F00;
|
||||||
|
/* $amberHover */
|
||||||
|
color: #212121;
|
||||||
|
/* $textColor for good contrast on yellow */
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
min-height: 44px;
|
||||||
|
/* Accessibility: Touch target size */
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep generic-message .amber-btn:hover {
|
||||||
|
background-color: #FF8F00;
|
||||||
|
/* $amberHover */
|
||||||
|
border-color: #f9a825;
|
||||||
|
/* $accentLightColor */
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System Type Display Styles */
|
||||||
|
.system-type-display {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 44px;
|
||||||
|
/* Accessibility: Larger touch targets */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design - Improved Accessibility */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.system-type-display {
|
||||||
|
display: inline-flex;
|
||||||
|
/* Keep inline for proper alignment with ui-column-title */
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-start;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-code-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
/* Maintain readability on mobile */
|
||||||
|
padding: 3px 6px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-indicator {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 32px;
|
||||||
|
/* Maintain touch target on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-indicator i {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.system-type-display {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-code-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 5px;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-indicator {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Column Specific Styles - Enhanced for Accessibility */
|
||||||
|
.ui-table .ui-table-tbody>tr>td .system-type-display {
|
||||||
|
min-width: 140px;
|
||||||
|
/* Increased for better layout */
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improved Focus Management for Table Cells */
|
||||||
|
:host ::ng-deep .ui-table .ui-table-tbody>tr>td:focus-within {
|
||||||
|
outline: 2px solid #4CAF50;
|
||||||
|
/* $primaryColor for focus */
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Theme Support - Enhanced Contrast */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.partner-code-badge {
|
||||||
|
background-color: #757575;
|
||||||
|
/* $grayBgColor */
|
||||||
|
color: #ffffff;
|
||||||
|
/* $primaryTextColor */
|
||||||
|
border-color: #bdbdbd;
|
||||||
|
/* $dividerColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-code-badge:hover,
|
||||||
|
.partner-code-badge:focus {
|
||||||
|
background-color: #616161;
|
||||||
|
/* Darker gray */
|
||||||
|
border-color: #e8e8e8;
|
||||||
|
/* $hoverBgColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-indicator.auth-valid {
|
||||||
|
color: #A5D6A7;
|
||||||
|
/* $primaryLightColor */
|
||||||
|
background-color: rgba(165, 214, 167, 0.2);
|
||||||
|
border-color: rgba(165, 214, 167, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-indicator.auth-invalid {
|
||||||
|
color: #EF5350;
|
||||||
|
/* Lighter red for dark theme */
|
||||||
|
background-color: rgba(239, 83, 80, 0.2);
|
||||||
|
border-color: rgba(239, 83, 80, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-indicator.auth-validating {
|
||||||
|
color: #f9a825;
|
||||||
|
/* $accentLightColor */
|
||||||
|
background-color: rgba(249, 168, 37, 0.2);
|
||||||
|
border-color: rgba(249, 168, 37, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
PACKAGE ACTIVATION TOOLTIP STYLES
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Highlight effect for package checkbox when tooltip is shown */
|
||||||
|
.package-activation-highlight .p-checkbox-box {
|
||||||
|
border: 2px solid #FFC107 !important;
|
||||||
|
/* $amber */
|
||||||
|
box-shadow: 0 0 8px rgba(255, 193, 7, 0.4) !important;
|
||||||
|
/* $amber with transparency */
|
||||||
|
animation: pulseGlow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseGlow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 8px rgba(255, 193, 7, 0.4);
|
||||||
|
/* $amber */
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 16px rgba(255, 193, 7, 0.6);
|
||||||
|
/* $amber */
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 8px rgba(255, 193, 7, 0.4);
|
||||||
|
/* $amber */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
AIRCRAFT REVIEW BANNER – NO-CHANGES CONFIRM BUTTON
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Flex column body: stacks message text + button vertically inside the flex row.
|
||||||
|
Takes the flex:1 growth previously on .aircraft-review-message. */
|
||||||
|
.aircraft-review-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
/* Center message text and button horizontally */
|
||||||
|
text-align: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No custom CSS needed for the confirm button - uses .highlight-btn
|
||||||
|
(already defined at top of this file) with PrimeNG default button layout.
|
||||||
|
This matches the existing toolbar button pattern (ui-icon-* + pButton). */
|
||||||
@ -4,7 +4,8 @@
|
|||||||
<ng-container *ngIf="isCompLoaded(); else err">
|
<ng-container *ngIf="isCompLoaded(); else err">
|
||||||
<ng-container [ngTemplateOutlet]="listSection"></ng-container>
|
<ng-container [ngTemplateOutlet]="listSection"></ng-container>
|
||||||
<ng-container [ngTemplateOutlet]="btnSection"></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>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -12,30 +13,53 @@
|
|||||||
|
|
||||||
<ng-template #msgSection>
|
<ng-template #msgSection>
|
||||||
<section>
|
<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>
|
</section>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #listSection>
|
<ng-template #listSection>
|
||||||
<section>
|
<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">
|
<ng-template pTemplate="caption">
|
||||||
<span class="table-caption-1" i18n="@@aircraftList">Aircraft List</span>
|
<span class="table-caption-1" i18n="@@aircraftList">Aircraft List</span>
|
||||||
<ng-container *ngIf="status" [ngTemplateOutlet]="msgSection"></ng-container>
|
<ng-container *ngIf="status" [ngTemplateOutlet]="msgSection"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template pTemplate="header" let-columns>
|
<ng-template pTemplate="header" let-columns>
|
||||||
<tr>
|
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||||
<div class="input-with-icon" *ngSwitchCase="true">
|
<div class="input-with-icon" *ngSwitchCase="true">
|
||||||
<i class="ui-icon-search"></i>
|
<i class="ui-icon-search"></i>
|
||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
|
[value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngIf="col.field === TRACKING" [ngTemplateOutlet]="maxVeh" [ngTemplateOutletContext]="{numOfVehicle: trkLimit?.airCraft?.numOfVehicle}"></ng-container>
|
<ng-container *ngIf="col.field === TRACKING" [ngTemplateOutlet]="maxVeh"
|
||||||
<ng-container *ngIf="col.field === PACKAGE_ACTIVE" [ngTemplateOutlet]="maxVeh" [ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
|
[ngTemplateOutletContext]="{numOfVehicle: trkLimit?.airCraft?.numOfVehicle}"></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 === 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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -47,6 +71,11 @@
|
|||||||
|
|
||||||
<span *ngSwitchCase="VEHICLE_TYPE">{{ rowData[col.field] | vehicleType }}</span>
|
<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">
|
<span *ngSwitchCase="COLOR">
|
||||||
<div class="color-box" [ngStyle]="{ 'background-color': resolveFieldData(rowData, col.field) }"></div>
|
<div class="color-box" [ngStyle]="{ 'background-color': resolveFieldData(rowData, col.field) }"></div>
|
||||||
</span>
|
</span>
|
||||||
@ -55,7 +84,8 @@
|
|||||||
|
|
||||||
<span *ngSwitchCase="TRACKING">
|
<span *ngSwitchCase="TRACKING">
|
||||||
<ng-container *ngIf="rowData[UNIT_ID] && canActivateVehicle; else readonlyStatusTemplate">
|
<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-container>
|
||||||
<ng-template #readonlyStatusTemplate>
|
<ng-template #readonlyStatusTemplate>
|
||||||
<ng-container *ngTemplateOutlet="readonlyStatus; context: { flag: rowData.tracking }"></ng-container>
|
<ng-container *ngTemplateOutlet="readonlyStatus; context: { flag: rowData.tracking }"></ng-container>
|
||||||
@ -64,7 +94,10 @@
|
|||||||
|
|
||||||
<span *ngSwitchCase="PACKAGE_ACTIVE">
|
<span *ngSwitchCase="PACKAGE_ACTIVE">
|
||||||
<ng-container *ngIf="canActivateVehicle; else readonlyStatusTemplate">
|
<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-container>
|
||||||
<ng-template #readonlyStatusTemplate>
|
<ng-template #readonlyStatusTemplate>
|
||||||
<ng-container *ngTemplateOutlet="readonlyStatus; context: { flag: rowData.pkgActive }"></ng-container>
|
<ng-container *ngTemplateOutlet="readonlyStatus; context: { flag: rowData.pkgActive }"></ng-container>
|
||||||
@ -82,7 +115,8 @@
|
|||||||
|
|
||||||
<span *ngSwitchCase="UNIT_ID">
|
<span *ngSwitchCase="UNIT_ID">
|
||||||
<ng-container *ngIf="!rowData[UNIT_ID] && currVehicle && rowData[ID] == currVehicle[ID]; else uId">
|
<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-container>
|
||||||
<ng-template #uId>{{ resolveFieldData(rowData, col.field) }}</ng-template>
|
<ng-template #uId>{{ resolveFieldData(rowData, col.field) }}</ng-template>
|
||||||
</span>
|
</span>
|
||||||
@ -104,12 +138,16 @@
|
|||||||
|
|
||||||
<ng-template #btnSection>
|
<ng-template #btnSection>
|
||||||
<section class="ui-widget-header ui-helper-clearfix toolbar">
|
<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" *ngIf="canWrite" pButton icon="ui-icon-plus" (click)="newVehicle()" i18n-label="@@new"
|
||||||
<button type="button" [disabled]="!canEdit" pButton icon="ui-icon-edit" (click)="editVehicle()" i18n-label="@@detail" label="Detail"></button>
|
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">
|
<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>
|
</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>
|
</section>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
@ -117,12 +155,30 @@
|
|||||||
<ng-container i18n="@@maxVeh">Max vehicles</ng-container>: {{numOfVehicle}}
|
<ng-container i18n="@@maxVeh">Max vehicles</ng-container>: {{numOfVehicle}}
|
||||||
</ng-template>
|
</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>
|
<ng-template #err>
|
||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-7" style="min-width: 740px; margin: auto;">
|
<div class="ui-g-7" style="min-width: 740px; margin: auto;">
|
||||||
<div class="ui-g" style="padding: 1em;">
|
<div class="ui-g" style="padding: 1em;">
|
||||||
<div class="ui-g-12 card in-card-pad" style="margin-bottom: 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,18 +5,23 @@ import { ConfirmationService, SelectItem } from 'primeng/api';
|
|||||||
import { Vehicle } from '../../models/vehicle.model';
|
import { Vehicle } from '../../models/vehicle.model';
|
||||||
import * as vehicleActions from '../../actions/vehicle.actions';
|
import * as vehicleActions from '../../actions/vehicle.actions';
|
||||||
import * as fromEntity from '../../reducers';
|
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 { DateUtils, Utils } from '@app/shared/utils';
|
||||||
import { BaseComp } from '@app/shared/base/base.component';
|
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 { getSubIntentState, getSubscriptionStatus, selectLimit } from '@app/reducers';
|
||||||
import { SUB, SubAppErr, SubTexts, SubType, createSubStatus, SubKeys, ACTIVE, TRACKING, hasVendorErr } from '@app/profile/common';
|
import { SUB, SubAppErr, SubTexts, SubType, createSubStatus, SubKeys, ACTIVE, TRACKING, hasVendorErr } from '@app/profile/common';
|
||||||
import { Limit, Status } from '@app/domain/models/subscription.model';
|
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 { ClearSubscriptionStatus, Compound, GotoMyServices } from '@app/actions/subscription.actions';
|
||||||
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
||||||
import { FetchSubPlans } from '@app/actions/sub-plans.actions';
|
import { FetchSubPlans } from '@app/actions/sub-plans.actions';
|
||||||
import { User } from '@app/accounts/models/user.model';
|
import { User } from '@app/accounts/models/user.model';
|
||||||
import { UserService } from '@app/domain/services/user.service';
|
import { UserService } from '@app/domain/services/user.service';
|
||||||
|
import { PartnerService } from '@app/partners/services/partner.service';
|
||||||
|
import { PopupTooltipService } from '@app/shared/popup-tooltip/popup-tooltip.service';
|
||||||
|
|
||||||
const HIGHLIGHT = 'highlight-btn';
|
const HIGHLIGHT = 'highlight-btn';
|
||||||
|
|
||||||
@ -38,6 +43,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
readonly COLOR = 'color';
|
readonly COLOR = 'color';
|
||||||
readonly MODEL = 'model';
|
readonly MODEL = 'model';
|
||||||
readonly UNIT_ID = 'unitId';
|
readonly UNIT_ID = 'unitId';
|
||||||
|
readonly SOURCE_SYSTEM = 'sourceSystem';
|
||||||
|
|
||||||
vehicles: Vehicle[] = [];
|
vehicles: Vehicle[] = [];
|
||||||
vehiclesChanged = false;
|
vehiclesChanged = false;
|
||||||
@ -64,11 +70,39 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
vendorErr: boolean;
|
vendorErr: boolean;
|
||||||
user: User;
|
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 = [
|
BASE_FIELDS = [
|
||||||
{ field: 'name', header: globals.name, filtered: true },
|
{ 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.VEHICLE_TYPE, header: $localize`:@@type:Type` },
|
||||||
{ field: this.MODEL, header: $localize`:@@model:Model` },
|
{ field: this.MODEL, header: $localize`:@@model:Model` },
|
||||||
{ field: this.ACTIVE, header: globals.active, width: '5%' },
|
{ field: this.ACTIVE, header: globals.active, width: '5%' },
|
||||||
|
{ field: this.SOURCE_SYSTEM, header: $localize`:@@systemType:System Type` }, // NEW COLUMN
|
||||||
];
|
];
|
||||||
|
|
||||||
PACKAGE_ACTIVE_FIELDS = [
|
PACKAGE_ACTIVE_FIELDS = [
|
||||||
@ -96,6 +130,10 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
private readonly confirmService: ConfirmationService,
|
private readonly confirmService: ConfirmationService,
|
||||||
private readonly subSvc: SubscriptionService,
|
private readonly subSvc: SubscriptionService,
|
||||||
private readonly userSvc: UserService,
|
private readonly userSvc: UserService,
|
||||||
|
private readonly partnerService: PartnerService,
|
||||||
|
private readonly partnerUtils: PartnerUtilsService,
|
||||||
|
private readonly badgeFactory: BadgeFactoryService,
|
||||||
|
private readonly popupTooltipService: PopupTooltipService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.acTypes = [
|
this.acTypes = [
|
||||||
@ -108,6 +146,15 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.user = this.route.snapshot.data['user'];
|
this.user = this.route.snapshot.data['user'];
|
||||||
this.clearNeedReview();
|
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.initVehList();
|
||||||
this.initStatus();
|
this.initStatus();
|
||||||
this.store.dispatch(new Compound([
|
this.store.dispatch(new Compound([
|
||||||
@ -121,23 +168,363 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
this.user.needReview = false;
|
this.user.needReview = false;
|
||||||
this.userSvc.saveUser(this.user).subscribe({
|
this.userSvc.saveUser(this.user).subscribe({
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.log(err);
|
|
||||||
this.status = createSubStatus(SubAppErr.AC_LIST_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() {
|
initVehList() {
|
||||||
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
|
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
|
||||||
map((vehicles) => {
|
map((vehicles) => {
|
||||||
this.vehicles = vehicles;
|
this.vehicles = vehicles;
|
||||||
this.vehSelLastUpdated = this.createVehSelections(vehicles);
|
this.vehSelLastUpdated = this.createVehSelections(vehicles);
|
||||||
this.vehiclesChanged = this.isVehSelChanged();
|
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({
|
).subscribe({
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.log(err);
|
|
||||||
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
|
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -181,7 +568,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
}))
|
}))
|
||||||
).subscribe({
|
).subscribe({
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.log(err);
|
|
||||||
this.status = createSubStatus(SubAppErr.AC_LIST_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;
|
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);
|
if (!this.vehSelCurrent) this.vehSelCurrent = this.createVehSelections(this.vehicles);
|
||||||
this.vehSelCurrent[rowData[this.ID]][type] = rowData[type];
|
this.vehSelCurrent[rowData[this.ID]][type] = rowData[type];
|
||||||
this.vehiclesChanged = this.isVehSelChanged();
|
this.vehiclesChanged = this.isVehSelChanged();
|
||||||
@ -223,11 +624,20 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveVehicleList() {
|
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 trkVehicles = this.getVehicles(this.TRACKING, this.vehicles);
|
||||||
const pkgActiveVehs = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles);
|
const pkgActiveVehs = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles);
|
||||||
|
|
||||||
const isTrkVehicleAboveLimit = trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle;
|
const isTrkVehicleAboveLimit = this.trkLimit && trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle;
|
||||||
const isPkgActiveVehicleAboveLimit = pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle;
|
const isPkgActiveVehicleAboveLimit = this.pkgLimit && pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle;
|
||||||
|
|
||||||
if (isTrkVehicleAboveLimit || isPkgActiveVehicleAboveLimit) {
|
if (isTrkVehicleAboveLimit || isPkgActiveVehicleAboveLimit) {
|
||||||
this.vehicles = this.vehicles.map((veh) => ({
|
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,
|
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
|
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 }));
|
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({
|
).subscribe({
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.log(err);
|
|
||||||
this.status = createSubStatus(SubAppErr.AC_LIST_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));
|
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() {
|
newVehicle() {
|
||||||
this.router.navigate(['.', '0'], { relativeTo: this.route });
|
this.router.navigate(['.', '0'], { relativeTo: this.route });
|
||||||
}
|
}
|
||||||
@ -331,6 +1019,11 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
accept: () => {
|
accept: () => {
|
||||||
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles }));
|
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles }));
|
||||||
this.updateBtn?.nativeElement.classList.remove(HIGHLIGHT);
|
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;
|
&& !this.vendorErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAircraftReviewStatus(): boolean {
|
||||||
|
return this.subSvc.isStatusMatchingCode(this.status, SUB.AC_REVIEW);
|
||||||
|
}
|
||||||
|
|
||||||
gotoMySubs() {
|
gotoMySubs() {
|
||||||
this.store.dispatch(new GotoMyServices());
|
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() {
|
get canActivateVehicle() {
|
||||||
return this.authSvc.canActivateVehicle;
|
return this.authSvc.canActivateVehicle;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
// Clear all partner-specific authentication check timers
|
||||||
|
this.authCheckTimers.forEach((timer) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
});
|
||||||
|
this.authCheckTimers.clear();
|
||||||
|
|
||||||
this.store.dispatch(new ClearSubscriptionStatus());
|
this.store.dispatch(new ClearSubscriptionStatus());
|
||||||
super.ngOnDestroy();
|
super.ngOnDestroy();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,468 @@
|
|||||||
|
/* Partner Integration Component Styles - AgMission Theme Compliance */
|
||||||
|
|
||||||
|
/* Host element typography foundation - AgMission standards */
|
||||||
|
:host {
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
/* $fontFamily - AgMission standard */
|
||||||
|
line-height: 1.5;
|
||||||
|
/* $lineHeight - AgMission standard */
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
/* $letterSpacing - AgMission standard */
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-validation-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
INTEGRATION STEPS INDICATOR - AgMission Project Color Compliance
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.integration-steps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 16px 0 24px 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
/* contentBgColor - AgMission content background */
|
||||||
|
border-radius: 3px;
|
||||||
|
/* AgMission standard border radius */
|
||||||
|
border: 1px solid #bdbdbd;
|
||||||
|
/* dividerColor - AgMission borders */
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 2px solid #bdbdbd;
|
||||||
|
/* dividerColor - AgMission neutral border */
|
||||||
|
background: #ffffff;
|
||||||
|
/* contentBgColor - AgMission white background */
|
||||||
|
color: #757575;
|
||||||
|
/* textSecondaryColor - AgMission secondary text */
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
/* $fontFamily - AgMission standard */
|
||||||
|
line-height: 1.5;
|
||||||
|
/* $lineHeight - AgMission standard */
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
/* $letterSpacing - AgMission standard */
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active .step-indicator {
|
||||||
|
border-color: #03A9F4;
|
||||||
|
/* blue - AgMission info color */
|
||||||
|
background: #03A9F4;
|
||||||
|
/* blue - AgMission info color */
|
||||||
|
color: #ffffff;
|
||||||
|
/* primaryTextColor - white text on colored backgrounds */
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.completed .step-indicator {
|
||||||
|
border-color: #4CAF50;
|
||||||
|
/* primaryColor - AgMission main green */
|
||||||
|
background: #4CAF50;
|
||||||
|
/* primaryColor - AgMission main green */
|
||||||
|
color: #ffffff;
|
||||||
|
/* primaryTextColor - white text on colored backgrounds */
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #757575;
|
||||||
|
/* textSecondaryColor - AgMission secondary text */
|
||||||
|
line-height: 1.3;
|
||||||
|
max-width: 100px;
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
/* $fontFamily - AgMission standard */
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
/* $letterSpacing - AgMission standard */
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active .step-label {
|
||||||
|
color: #03A9F4;
|
||||||
|
/* blue - AgMission info color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.completed .step-label {
|
||||||
|
color: #4CAF50;
|
||||||
|
/* primaryColor - AgMission main green */
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-connector {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 2px;
|
||||||
|
width: 40px;
|
||||||
|
background: #bdbdbd;
|
||||||
|
/* dividerColor - AgMission neutral */
|
||||||
|
margin: 0 8px;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.completed+.step-connector {
|
||||||
|
background: #4CAF50;
|
||||||
|
/* primaryColor - AgMission main green */
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active+.step-connector {
|
||||||
|
background: linear-gradient(to right, #4CAF50 50%, #bdbdbd 50%);
|
||||||
|
/* Green to neutral gradient */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
PARTNER SYSTEM INDICATORS - AgMission Project Color Compliance
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.partner-selection-with-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-selection-with-indicator p-dropdown {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-selection-with-indicator p-dropdown .ui-dropdown {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-indicator {
|
||||||
|
color: #4CAF50 !important;
|
||||||
|
/* primaryColor - AgMission main green */
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 1;
|
||||||
|
animation: fadeInScale 0.3s ease-in;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
color: #03A9F4;
|
||||||
|
/* blue - AgMission info color */
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
/* $fontFamily - AgMission standard */
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
/* $letterSpacing - AgMission standard */
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator i {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
VALIDATION LOADING INDICATOR - AgMission Project Color Compliance
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.validation-loading-indicator {
|
||||||
|
color: #03A9F4 !important;
|
||||||
|
/* blue - AgMission info color */
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
AIRCRAFT INFORMATION PANEL - AgMission Project Color Compliance
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.enhanced-aircraft-info-panel {
|
||||||
|
background: linear-gradient(135deg, #E8F5E8 0%, #ffffff 100%);
|
||||||
|
/* Light green to white gradient matching AgMission success styling */
|
||||||
|
border: 1px solid #4CAF50;
|
||||||
|
/* primaryColor - AgMission main green */
|
||||||
|
border-radius: 3px;
|
||||||
|
/* AgMission standard border radius - matches constraint-message */
|
||||||
|
padding: 12px 16px;
|
||||||
|
/* Matches constraint-message content padding */
|
||||||
|
margin: 12px 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
/* Matches constraint-message shadow */
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
/* Matches constraint-message transition */
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-aircraft-info-panel:hover {
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
/* Matches constraint-message hover shadow */
|
||||||
|
transform: translateY(-1px);
|
||||||
|
/* Matches constraint-message hover effect */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
/* Matches constraint-message content gap */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-icon {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
/* Matches constraint-message icon size: 18px */
|
||||||
|
color: #4CAF50;
|
||||||
|
/* primaryColor - AgMission main green */
|
||||||
|
margin-top: 2px;
|
||||||
|
/* Matches constraint-message icon alignment */
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
/* Matches constraint-message text container */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
/* 14px - matches constraint-message title size */
|
||||||
|
font-weight: 500;
|
||||||
|
/* Matches constraint-message title weight */
|
||||||
|
color: #2E7D32;
|
||||||
|
/* primaryDarkColor - darker green for headers */
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
/* $fontFamily - AgMission standard */
|
||||||
|
line-height: 1.5;
|
||||||
|
/* Matches constraint-message line height */
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
/* $letterSpacing - AgMission standard */
|
||||||
|
margin-bottom: 4px;
|
||||||
|
/* Matches constraint-message title margin */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
/* 13px - matches constraint-message description size */
|
||||||
|
line-height: 1.5;
|
||||||
|
/* Matches constraint-message line height */
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
/* $fontFamily - AgMission standard */
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
/* $letterSpacing - AgMission standard */
|
||||||
|
color: #212121;
|
||||||
|
/* textColor - matches constraint-message description */
|
||||||
|
margin: 0;
|
||||||
|
word-wrap: break-word;
|
||||||
|
/* Matches constraint-message description */
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row strong {
|
||||||
|
color: #2E7D32;
|
||||||
|
/* primaryDarkColor - darker green for labels */
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-type-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-type-value {
|
||||||
|
color: #4CAF50;
|
||||||
|
/* primaryColor - AgMission success color for selected system type */
|
||||||
|
font-weight: 600;
|
||||||
|
/* Bold text for emphasis */
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-type-pending {
|
||||||
|
color: #f39c12;
|
||||||
|
/* Warning color */
|
||||||
|
font-style: italic;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
DISABLED PREVIEW STYLING
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.preview-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
ANIMATIONS
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
@keyframes fadeInScale {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
RESPONSIVE DESIGN - Mobile and Tablet
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.integration-steps {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
min-width: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator {
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-connector {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-aircraft-info-panel {
|
||||||
|
padding: 10px 12px;
|
||||||
|
/* Matches constraint-message mobile padding */
|
||||||
|
margin: 8px 0;
|
||||||
|
/* Matches constraint-message mobile margin */
|
||||||
|
max-width: 100%;
|
||||||
|
/* Matches constraint-message mobile width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-content {
|
||||||
|
gap: 10px;
|
||||||
|
/* Matches constraint-message mobile gap */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
/* 16px - matches constraint-message mobile icon size */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-title {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
/* 13px - matches constraint-message mobile title size */
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
/* 12px - matches constraint-message mobile description size */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.integration-steps {
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.partner-selection-with-indicator {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enhanced-aircraft-info-panel {
|
||||||
|
padding: 10px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
/* Consistent with tablet size */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-info-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
/* Smaller for mobile screens */
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
/* Smaller for mobile screens */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
INPUT FIELD WITH INLINE CONSTRAINT - Detached Mode Pattern
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.input-with-inline-constraint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-inline-constraint label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position the constraint trigger button closer to dropdown */
|
||||||
|
.input-with-inline-constraint ::ng-deep .agm-constraint-trigger {
|
||||||
|
margin-right: 32px;
|
||||||
|
}
|
||||||
@ -0,0 +1,263 @@
|
|||||||
|
<div class="ui-g-12 ui-g-nopad">
|
||||||
|
<!-- Partner Selection -->
|
||||||
|
<div class="ui-g-12 ui-lg-6" style="padding-top: 0;">
|
||||||
|
<label for="partner-select" class="field-label" style="margin-right:12px">
|
||||||
|
{{ Labels.PARTNER_SYSTEM }}
|
||||||
|
</label>
|
||||||
|
<div class="partner-selection-with-indicator">
|
||||||
|
<p-dropdown id="partner-select" name="partnerSystem" [options]="partnerOptions" [(ngModel)]="selectedPartner"
|
||||||
|
[placeholder]="Labels.SELECT_PARTNER_SYSTEM" (onChange)="onPartnerChange()" [style]="{'width': '200px'}"
|
||||||
|
[disabled]="partnersLoading">
|
||||||
|
<ng-template let-partner pTemplate="item">
|
||||||
|
<span><strong>{{ partner.label }}</strong></span>
|
||||||
|
</ng-template>
|
||||||
|
</p-dropdown>
|
||||||
|
|
||||||
|
<!-- Success Indicator -->
|
||||||
|
<i class="pi pi-check success-indicator"
|
||||||
|
*ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV && !partnerValidation.isValidating && partnerValidation.accountExists && partnerValidation.authenticationValid"
|
||||||
|
[title]="Labels.PARTNER_VALIDATION_SUCCESS + ' - ' + partnerDisplayName">
|
||||||
|
</i>
|
||||||
|
|
||||||
|
<!-- Loading Indicator -->
|
||||||
|
<span *ngIf="partnersLoading" class="loading-indicator">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i> {{ Labels.LOADING_PARTNERS }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Validation Loading Indicator -->
|
||||||
|
<i class="pi pi-spin pi-spinner validation-loading-indicator"
|
||||||
|
*ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV && partnerValidation.isValidating"
|
||||||
|
[title]="Labels.VALIDATING_PARTNER_SYSTEM">
|
||||||
|
</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Partner System Validation Messages -->
|
||||||
|
<div *ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV" class="ui-g-12">
|
||||||
|
<div class="partner-validation-section">
|
||||||
|
|
||||||
|
<!-- Validation in Progress -->
|
||||||
|
<div *ngIf="partnerValidation.isValidating" class="ui-g-12 form-row">
|
||||||
|
<agm-constraint-message [collapsible]="true" severity="info" [message]="Labels.VALIDATING_PARTNER_SYSTEM"
|
||||||
|
[title]="Labels.VALIDATING_PARTNER_SYSTEM" icon="pi-spinner">
|
||||||
|
</agm-constraint-message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Does Not Exist -->
|
||||||
|
<div *ngIf="!partnerValidation.isValidating && !partnerValidation.accountExists" class="ui-g-12 form-row">
|
||||||
|
<agm-constraint-message severity="warning" [title]="Labels.PARTNER_ACCOUNT_NOT_FOUND"
|
||||||
|
[message]="Labels.PARTNER_ACCOUNT_CREATE_GUIDANCE" icon="pi-exclamation-triangle"
|
||||||
|
[actionLabel]="Labels.CREATE_PARTNER_ACCOUNT" actionIcon="pi-plus"
|
||||||
|
(actionClick)="navigateToAccountCreation()">
|
||||||
|
</agm-constraint-message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authentication Failed -->
|
||||||
|
<div
|
||||||
|
*ngIf="!partnerValidation.isValidating && partnerValidation.accountExists && !partnerValidation.authenticationValid"
|
||||||
|
class="ui-g-12 form-row">
|
||||||
|
<agm-constraint-message severity="error" [title]="Labels.PARTNER_AUTH_FAILED"
|
||||||
|
[message]="Labels.PARTNER_AUTH_FIX_GUIDANCE" icon="pi-times" [actionLabel]="Labels.FIX_AUTHENTICATION"
|
||||||
|
actionIcon="pi-plus" (actionClick)="navigateToAccountEdit()">
|
||||||
|
</agm-constraint-message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Validation Error -->
|
||||||
|
<div *ngIf="partnerValidation.validationError" class="ui-g-12 form-row">
|
||||||
|
<agm-constraint-message severity="error" [title]="Labels.CONSTRAINT_ERROR_TITLE"
|
||||||
|
[message]="partnerValidation.validationError" icon="pi-times">
|
||||||
|
</agm-constraint-message>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Partner Aircraft Selection (shown when partner is selected and validated) -->
|
||||||
|
<div *ngIf="selectedPartner && selectedPartner !== SourceSystem.AGNAV" class="ui-g-12 partner-aircraft-section">
|
||||||
|
<div class="ui-g-12">
|
||||||
|
<h4 style="margin: 0;">{{ Labels.AIRCRAFT_INTEGRATION }}</h4>
|
||||||
|
|
||||||
|
<!-- Step Progress Indicator - Only show for new vehicles -->
|
||||||
|
<div *ngIf="isNew" class="integration-steps" role="progressbar" [attr.aria-valuenow]="getIntegrationProgress()"
|
||||||
|
aria-valuemin="0" [attr.aria-valuemax]="getMaxIntegrationSteps()"
|
||||||
|
[attr.aria-label]="Labels.INTEGRATION_PROGRESS_LABEL">
|
||||||
|
<!-- Step 1: Select Partner System -->
|
||||||
|
<div class="step" [class.completed]="true" [class.active]="!partnerValidation.isValidating">
|
||||||
|
<div class="step-indicator">
|
||||||
|
<i class="pi pi-check" *ngIf="!partnerValidation.isValidating"></i>
|
||||||
|
<span class="step-number" *ngIf="partnerValidation.isValidating">1</span>
|
||||||
|
</div>
|
||||||
|
<span class="step-label">{{ Labels.SELECT_PARTNER_SYSTEM }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-connector"></div>
|
||||||
|
|
||||||
|
<!-- Step 2: Validate Partner Account -->
|
||||||
|
<div class="step" [class.completed]="canEditPartnerFields"
|
||||||
|
[class.active]="partnerValidation.isValidating || canEditPartnerFields">
|
||||||
|
<div class="step-indicator">
|
||||||
|
<i class="pi pi-check" *ngIf="canEditPartnerFields"></i>
|
||||||
|
<i class="pi pi-spin pi-spinner" *ngIf="partnerValidation.isValidating"></i>
|
||||||
|
<span class="step-number" *ngIf="!partnerValidation.isValidating && !canEditPartnerFields">2</span>
|
||||||
|
</div>
|
||||||
|
<span class="step-label">{{ Labels.VALIDATE_PARTNER_ACCOUNT }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-connector"></div>
|
||||||
|
|
||||||
|
<!-- Step 3: Select Partner Aircraft -->
|
||||||
|
<div class="step" [class.completed]="selectedPartnerAircraft"
|
||||||
|
[class.active]="canEditPartnerFields && !partnerAircraftLoading">
|
||||||
|
<div class="step-indicator">
|
||||||
|
<i class="pi pi-check" *ngIf="selectedPartnerAircraft"></i>
|
||||||
|
<i class="pi pi-spin pi-spinner" *ngIf="partnerAircraftLoading"></i>
|
||||||
|
<span class="step-number" *ngIf="!selectedPartnerAircraft && !partnerAircraftLoading">3</span>
|
||||||
|
</div>
|
||||||
|
<span class="step-label">{{ Labels.SELECT_PARTNER_AIRCRAFT }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Select System Type (Satloc Only) -->
|
||||||
|
<ng-container *ngIf="isSatlocPartnerSelected">
|
||||||
|
<div class="step-connector"></div>
|
||||||
|
|
||||||
|
<div class="step" [class.completed]="isSystemTypeStepCompleted"
|
||||||
|
[class.active]="selectedPartnerAircraft && !selectedSystemType">
|
||||||
|
<div class="step-indicator">
|
||||||
|
<i class="pi pi-check" *ngIf="isSystemTypeStepCompleted"></i>
|
||||||
|
<span class="step-number" *ngIf="!isSystemTypeStepCompleted">4</span>
|
||||||
|
</div>
|
||||||
|
<span class="step-label">
|
||||||
|
{{ Labels.SELECT_SYSTEM_TYPE_PLACEHOLDER }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aircraft Selection Section - Always visible when partner is selected -->
|
||||||
|
<div *ngIf="canEditPartnerFields" class="ui-g-12">
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div *ngIf="partnerAircraftLoading" class="ui-g-12 form-row">
|
||||||
|
<div class="loading-indicator">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i>
|
||||||
|
<span>{{ Labels.LOADING_AVAILABLE_AIRCRAFT }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aircraft Selection Dropdown -->
|
||||||
|
<div *ngIf="!partnerAircraftLoading" class="ui-g-12">
|
||||||
|
<div class="ui-g-12 ui-lg-6">
|
||||||
|
<label for="aircraft-select" class="field-label" style="margin-right:12px">
|
||||||
|
{{ Labels.AVAILABLE_AIRCRAFT }}:
|
||||||
|
<span *ngIf="!selectedPartnerAircraft" style="color: #e74c3c; margin-left: 4px;"
|
||||||
|
[title]="Labels.REQUIRED_FOR_PARTNER_INTEGRATION_TOOLTIP">*</span>
|
||||||
|
</label>
|
||||||
|
<p-dropdown id="aircraft-select" name="partnerAircraft" [options]="partnerAircraftOptions"
|
||||||
|
[(ngModel)]="selectedPartnerAircraft" [placeholder]="Labels.SELECT_AIRCRAFT"
|
||||||
|
(onChange)="onPartnerAircraftChange()" [style]="{'width': '250px'}"
|
||||||
|
[disabled]="!partnerAircraftOptions.length" [class.p-invalid]="!selectedPartnerAircraft">
|
||||||
|
<ng-template let-aircraft pTemplate="item">
|
||||||
|
<span><strong>{{ aircraft.label }}</strong> <small class="aircraft-id">({{ aircraft.value
|
||||||
|
}})</small></span>
|
||||||
|
</ng-template>
|
||||||
|
</p-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Type Selection for Satloc Partners -->
|
||||||
|
<div *ngIf="isSatlocPartnerSelected" class="ui-g-12 ui-lg-6">
|
||||||
|
<label for="system-type-select" class="field-label" style="margin-right:12px">
|
||||||
|
{{ Labels.SYSTEM_TYPE }}:
|
||||||
|
<span *ngIf="!selectedSystemType" style="color: #e74c3c; margin-left: 4px;"
|
||||||
|
[title]="Labels.REQUIRED_FOR_SATLOC_INTEGRATION_TOOLTIP">*</span>
|
||||||
|
</label>
|
||||||
|
<p-dropdown id="system-type-select" name="systemType" [options]="systemTypeOptions"
|
||||||
|
[(ngModel)]="selectedSystemType" [placeholder]="Labels.SELECT_SYSTEM_TYPE_PLACEHOLDER"
|
||||||
|
(onChange)="onSystemTypeChange()" [style]="{'width': '180px'}"
|
||||||
|
[title]="Labels.SYSTEM_TYPE_SELECTION_TOOLTIP" [disabled]="!selectedPartnerAircraft"
|
||||||
|
[class.p-invalid]="selectedPartnerAircraft && !selectedSystemType">
|
||||||
|
<ng-template let-systemType pTemplate="item">
|
||||||
|
<span><strong>{{ systemType.label }}</strong></span>
|
||||||
|
</ng-template>
|
||||||
|
</p-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enhanced Aircraft Information Panel with System Type -->
|
||||||
|
<div *ngIf="selectedPartnerAircraft && selectedPartnerAircraftDetails" class="ui-g-12">
|
||||||
|
<div class="enhanced-aircraft-info-panel" role="alert" aria-live="polite"
|
||||||
|
[attr.aria-label]="Labels.SELECTED_AIRCRAFT_DETAILS + ': ' + selectedPartnerAircraftDetails.id">
|
||||||
|
<div class="aircraft-info-content">
|
||||||
|
<!-- Success Icon -->
|
||||||
|
<i class="pi pi-check aircraft-info-icon" aria-hidden="true"></i>
|
||||||
|
|
||||||
|
<!-- Aircraft Information -->
|
||||||
|
<div class="aircraft-info-text">
|
||||||
|
<div class="aircraft-info-header">
|
||||||
|
<strong class="aircraft-info-title">{{ Labels.SELECTED_AIRCRAFT_DETAILS }}</strong>
|
||||||
|
<agm-badge [config]="getPartnerIntegratedBadge()"></agm-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="aircraft-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<strong>{{ Labels.AIRCRAFT_ID }}:</strong> {{ selectedPartnerAircraftDetails.id }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Type Information for Satloc Partners -->
|
||||||
|
<div *ngIf="isSatlocPartnerSelected" class="detail-row system-type-row">
|
||||||
|
<strong>
|
||||||
|
{{ Labels.SYSTEM_TYPE }}:
|
||||||
|
</strong>
|
||||||
|
<span *ngIf="selectedSystemType" class="system-type-value">
|
||||||
|
{{ getSelectedSystemTypeLabel() }}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="!selectedSystemType" class="system-type-pending">
|
||||||
|
<i class="pi pi-exclamation-triangle" style="color: #f39c12; margin-right: 4px;"></i>
|
||||||
|
<em>{{ Labels.SYSTEM_TYPE_SELECTION_REQUIRED }}</em>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Aircraft Available Message (only show when no error) -->
|
||||||
|
<div *ngIf="!partnerAircraftLoading && !partnerAircraftOptions.length && !partnerAircraftError"
|
||||||
|
class="ui-g-12 form-row">
|
||||||
|
<agm-constraint-message [collapsible]="true" severity="info" [title]="Labels.NO_AIRCRAFT_AVAILABLE_TITLE"
|
||||||
|
[message]="Labels.NO_AVAILABLE_AIRCRAFT_FOUND + ' ' + partnerDisplayName + '.'" icon="pi-info-circle">
|
||||||
|
</agm-constraint-message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Display (takes priority over no aircraft message) -->
|
||||||
|
<div *ngIf="partnerAircraftError" class="ui-g-12 form-row">
|
||||||
|
<agm-constraint-message severity="error" [title]="Labels.PARTNER_AIRCRAFT_ERROR_TITLE"
|
||||||
|
[message]="partnerAircraftError" icon="pi-times">
|
||||||
|
</agm-constraint-message>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aircraft Selection Preview - Shown when partner selected but not validated -->
|
||||||
|
<div *ngIf="!canEditPartnerFields && selectedPartner && selectedPartner !== SourceSystem.AGNAV" class="ui-g-12">
|
||||||
|
<div class="ui-g-6">
|
||||||
|
<div class="input-with-inline-constraint">
|
||||||
|
<label class="field-label" style="margin-right:12px">{{ Labels.AVAILABLE_AIRCRAFT }}:</label>
|
||||||
|
<p-dropdown [placeholder]="Labels.COMPLETE_VALIDATION_FIRST" [disabled]="true" [style]="{'width': '250px'}"
|
||||||
|
class="preview-disabled">
|
||||||
|
</p-dropdown>
|
||||||
|
|
||||||
|
<!-- Partner validation required icon (detached mode) -->
|
||||||
|
<agm-constraint-message #partnerValidationConstraint [collapsible]="true" [collapsed]="true" [detached]="true"
|
||||||
|
[message]="Labels.AIRCRAFT_SELECTION_AVAILABLE_AFTER_VALIDATION"
|
||||||
|
[title]="Labels.PARTNER_VALIDATION_REQUIRED_TITLE" severity="info" icon="pi-info-circle"
|
||||||
|
class="inline-constraint">
|
||||||
|
</agm-constraint-message>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Partner validation constraint message -->
|
||||||
|
<div class="ui-g-12" style="margin-top: 8px;">
|
||||||
|
<ng-container *ngTemplateOutlet="partnerValidationConstraint?.detachedContentTemplate"></ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
15
Development/client/src/app/guards/vendor.guard.ts
Normal file
15
Development/client/src/app/guards/vendor.guard.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class VendorGuard implements CanActivate {
|
||||||
|
canActivate(
|
||||||
|
next: ActivatedRouteSnapshot,
|
||||||
|
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,393 @@
|
|||||||
|
/* Job Assignment Component Styles - AgMission Theme Compliance */
|
||||||
|
|
||||||
|
/* Host element typography foundation - AgMission standards */
|
||||||
|
/* These properties cascade to all child elements, reducing repetition */
|
||||||
|
:host {
|
||||||
|
font-family: "Roboto", "Helvetica Neue", sans-serif;
|
||||||
|
/* $fontFamily - AgMission standard */
|
||||||
|
line-height: 1.5;
|
||||||
|
/* $lineHeight - AgMission standard */
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
/* $letterSpacing - AgMission standard */
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-assignment-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aircraft Item Styling */
|
||||||
|
.aircraft-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #bdbdbd;
|
||||||
|
/* $dividerColor */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-icon {
|
||||||
|
color: #03A9F4;
|
||||||
|
/* $blue - info states */
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aircraft Tooltip Styling */
|
||||||
|
:host ::ng-deep .aircraft-tooltip-enhanced {
|
||||||
|
max-width: 350px;
|
||||||
|
white-space: pre-line;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #2E7D32;
|
||||||
|
/* $primaryDarkColor */
|
||||||
|
color: #ffffff;
|
||||||
|
/* $primaryTextColor */
|
||||||
|
border: 1px solid #4CAF50;
|
||||||
|
/* $primaryColor */
|
||||||
|
border-radius: 3px;
|
||||||
|
/* AgMission standard border radius */
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-text {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-arrow::before {
|
||||||
|
border-top-color: #2E7D32;
|
||||||
|
/* $primaryDarkColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-details {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #757575;
|
||||||
|
/* $textSecondaryColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download Options Info */
|
||||||
|
.download-options-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #757575;
|
||||||
|
/* $textSecondaryColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-options-info .pi {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #4CAF50;
|
||||||
|
/* $primaryColor - success indicator */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assignment Status Styling */
|
||||||
|
.assignment-status-section {
|
||||||
|
background: #ffffff;
|
||||||
|
/* $contentBgColor */
|
||||||
|
border-radius: 3px;
|
||||||
|
/* AgMission standard border radius */
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #bdbdbd;
|
||||||
|
/* $dividerColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-header h4 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #212121;
|
||||||
|
/* $textColor - matches other page labels */
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-control-btn,
|
||||||
|
.clear-status-btn {
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
min-width: 44px !important;
|
||||||
|
min-height: 44px !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
/* AgMission standard border radius */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-control-btn:focus,
|
||||||
|
.clear-status-btn:focus {
|
||||||
|
outline: 2px solid #03A9F4;
|
||||||
|
/* $blue - info states */
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polling-status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: #E1F5FE;
|
||||||
|
/* Light blue background for info */
|
||||||
|
border: 1px solid #03A9F4;
|
||||||
|
/* $blue */
|
||||||
|
border-radius: 3px;
|
||||||
|
/* AgMission standard border radius */
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #0277BD;
|
||||||
|
/* $blueHover */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: #FFF8E1;
|
||||||
|
/* Light amber background for progress */
|
||||||
|
border: 1px solid #FFC107;
|
||||||
|
/* $amber */
|
||||||
|
border-radius: 3px;
|
||||||
|
/* AgMission standard border radius */
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #FF8F00;
|
||||||
|
/* $amberHover */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-error-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: #FFEBEE;
|
||||||
|
/* Light red background for error */
|
||||||
|
border: 1px solid #F44336;
|
||||||
|
/* $red */
|
||||||
|
border-radius: 3px;
|
||||||
|
/* AgMission standard border radius */
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #C62828;
|
||||||
|
/* $redHover */
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assignment Status Table Styling */
|
||||||
|
.assignment-status-table {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
/* AgMission standard border radius */
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-thead th {
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
/* $hoverBgColor */
|
||||||
|
border-bottom: 2px solid #bdbdbd;
|
||||||
|
/* $dividerColor */
|
||||||
|
color: #212121;
|
||||||
|
/* $textColor */
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-tbody>tr {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-tbody>tr:hover {
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
/* $hoverBgColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-tbody>tr>td {
|
||||||
|
padding: 12px 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-bottom: 1px solid #bdbdbd;
|
||||||
|
/* $dividerColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .status-row-new {
|
||||||
|
border-left-color: #4527A0;
|
||||||
|
/* $accentDarkColor - new assignments */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .status-row-downloaded {
|
||||||
|
border-left-color: #f9a825;
|
||||||
|
/* $accentLightColor - downloaded assignments */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .status-row-uploaded {
|
||||||
|
border-left-color: #2E7D32;
|
||||||
|
/* $primaryDarkColor - uploaded/completed assignments */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .status-row-error {
|
||||||
|
border-left-color: #F44336;
|
||||||
|
/* Semantic red - error states */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-cell .pi {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #03A9F4;
|
||||||
|
/* $blue - info states */
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-cell .aircraft-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #212121;
|
||||||
|
/* $textColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status message and error details - AgMission Typography */
|
||||||
|
.status-message {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #212121;
|
||||||
|
/* $textColor */
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error-details {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #757575;
|
||||||
|
/* $textSecondaryColor */
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-timestamp {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #757575;
|
||||||
|
/* $textSecondaryColor */
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #757575;
|
||||||
|
/* $textSecondaryColor */
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-action-button {
|
||||||
|
font-size: 12px !important;
|
||||||
|
min-height: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #757575;
|
||||||
|
/* $textSecondaryColor */
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen reader only content */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: -1px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
clip: rect(0, 0, 0, 0) !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus improvements for accessibility */
|
||||||
|
.assignment-status-table tbody tr:focus-within {
|
||||||
|
outline: 2px solid #03A9F4;
|
||||||
|
/* $blue - info states */
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.assignment-status-section {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-tbody>tr>td {
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #bdbdbd;
|
||||||
|
/* $dividerColor */
|
||||||
|
padding: 12px 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-tbody>tr>td:before {
|
||||||
|
content: attr(data-label) ": ";
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
width: 120px;
|
||||||
|
color: #212121;
|
||||||
|
/* $textColor */
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-header-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-control-btn,
|
||||||
|
.clear-status-btn {
|
||||||
|
padding: 6px 10px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
min-width: 40px !important;
|
||||||
|
min-height: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polling-status-indicator {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,205 @@
|
|||||||
|
<div class="job-assignment-container">
|
||||||
|
<p-panel i18n-header="@@jobAssignment" header="Job Assignment" [toggleable]="true" [collapsed]="false">
|
||||||
|
<div class="ui-g">
|
||||||
|
<div class="ui-g-12 ui-g-nopad">
|
||||||
|
<p-pickList [source]="srcUsers" [target]="tarUsers" i18n-sourceHeader="@@aircraft"
|
||||||
|
sourceHeader="Available Aircraft" i18n-targetHeader="@@assignedAircraft" targetHeader="Assigned Aircraft"
|
||||||
|
[disabled]="isArchived" dragdrop="true" dragdropScope="users" [responsive]="true"
|
||||||
|
[sourceStyle]="{'height':'180px'}" [targetStyle]="{'height':'180px'}" [showSourceControls]="false"
|
||||||
|
[showTargetControls]="false" [filterBy]="'name'" filterMatchMode="contains"
|
||||||
|
i18n-sourceFilterPlaceholder="@@filterAcByName" sourceFilterPlaceholder="Filter AC by name"
|
||||||
|
i18n-targetFilterPlaceholder="@@filterAcByName" targetFilterPlaceholder="Filter AC by name"
|
||||||
|
(onMoveToSource)="onMoveToSource($event)" (onMoveToTarget)="onMoveToTarget($event)"
|
||||||
|
[pTooltip]="getPickListSourceTooltip()" tooltipPosition="top">
|
||||||
|
|
||||||
|
<ng-template let-aircraft pTemplate="item">
|
||||||
|
<div class="aircraft-item ui-helper-clearfix"
|
||||||
|
[style.color]="!aircraft.active || !aircraft.pkgActive || aircraft.authValidation?.validationError ? 'red' : null"
|
||||||
|
[style.opacity]="aircraft.authValidation?.isValidating ? '0.6' : '1'"
|
||||||
|
style="display: flex; flex-direction: column; align-items: flex-start;"
|
||||||
|
(click)="onAircraftSelect(aircraft, $event)">
|
||||||
|
|
||||||
|
<!-- Main row with icon, name, and badge -->
|
||||||
|
<div style="display: flex; align-items: center; width: 100%; margin-bottom: 4px;">
|
||||||
|
<!-- Aircraft Icon -->
|
||||||
|
<i class="fa" [class.fa-plane]="!aircraft.authValidation?.isValidating"
|
||||||
|
[class.fa-spinner]="aircraft.authValidation?.isValidating"
|
||||||
|
[class.fa-spin]="aircraft.authValidation?.isValidating" class="aircraft-icon"
|
||||||
|
style="vertical-align: middle; margin-right: 8px" aria-hidden="true"></i>
|
||||||
|
|
||||||
|
<!-- Aircraft Name -->
|
||||||
|
<span class="aircraft-name" [pTooltip]="getAircraftTooltip(aircraft)" tooltipPosition="top"
|
||||||
|
[escape]="false" [showDelay]="500" [hideDelay]="300" tooltipStyleClass="aircraft-tooltip-enhanced"
|
||||||
|
style="flex: 1;">
|
||||||
|
{{ aircraft.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Source System Badge -->
|
||||||
|
<agm-badge [config]="getAircraftSystemBadge(aircraft)"></agm-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aircraft Details (Sync Status) -->
|
||||||
|
<div class="aircraft-details" style="margin-left: 24px;">
|
||||||
|
<small class="aircraft-details">
|
||||||
|
<!-- Satloc-specific sync status -->
|
||||||
|
<span *ngIf="aircraft.sourceSystem === KnownPartnerCodes.SATLOC && aircraft.satlocData"
|
||||||
|
class="sync-status" [class]="'sync-status-' + aircraft.satlocData.syncStatus">
|
||||||
|
<i class="fa" [class.fa-circle]="aircraft.satlocData.syncStatus === OperationalStatus.ACTIVE"
|
||||||
|
[class.fa-clock-o]="aircraft.satlocData.syncStatus === OperationalStatus.PENDING"
|
||||||
|
[class.fa-exclamation-triangle]="aircraft.satlocData.syncStatus === OperationalStatus.ERROR"></i>
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Package status indicator -->
|
||||||
|
<div *ngIf="!aircraft.pkgActive" class="package-inactive"
|
||||||
|
style="position: absolute; top: 5px; right: 5px;">
|
||||||
|
<i class="fa fa-warning" style="color: orange" [pTooltip]="Labels.PACKAGE_INACTIVE"
|
||||||
|
tooltipPosition="top"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</p-pickList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
|
||||||
|
<div class="ui-g-12 ui-sm-4 ui-md-4 ui-lg-4 ui-xl-3">
|
||||||
|
<label for="dlOps"> <ng-container i18n="@@downloadOptions">Download Options</ng-container>:</label>
|
||||||
|
<div class="download-options-info">
|
||||||
|
<i class="pi pi-info-circle" [pTooltip]="Labels.DOWNLOAD_OPTIONS_AGNAV_ONLY_TOOLTIP"
|
||||||
|
tooltipPosition="top"></i>
|
||||||
|
<span>{{ Labels.AGNAV_BRAND_NAME }} <ng-container i18n="@@aircraftOnlySuffix">Aircraft
|
||||||
|
Only</ng-container></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-g-12 ui-sm-8 ui-md-6 ui-lg-8 ui-xl-9">
|
||||||
|
<p-dropdown id="dlOps" name="formats" [(ngModel)]="job.dlOp.type" [disabled]="isArchived"
|
||||||
|
[style]="{'minWidth':'120px'}" [options]="dlOps" [pTooltip]="getDownloadOptionsTooltip()"
|
||||||
|
tooltipPosition="top">
|
||||||
|
</p-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui-g-12 ui-g-nopad" style="margin-top: 10px">
|
||||||
|
<button class="blue-btn" pButton type="button" icon="ui-icon-assignment-ind" i18n-label="@@assign"
|
||||||
|
label="Assign" (click)="assignJob()" [disabled]="isArchived || !canDownload"
|
||||||
|
[pTooltip]="getAssignButtonTooltip()" tooltipPosition="top">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment Status Display -->
|
||||||
|
<div class="ui-g-12 ui-g-nopad assignment-status-section" style="margin-top: 15px">
|
||||||
|
<div class="assignment-status-header">
|
||||||
|
<h4 id="assignment-status-heading" i18n="@@assignmentStatus"
|
||||||
|
title="Track real-time assignment progress and manage aircraft assignments">Assignment Status</h4>
|
||||||
|
<div class="assignment-header-actions">
|
||||||
|
<!-- Refresh Assignment Status -->
|
||||||
|
<button class="status-control-btn" pButton type="button" icon="ui-icon-refresh"
|
||||||
|
[pTooltip]="getRefreshStatusTooltip()" (click)="refreshAssignmentStatus()" [disabled]="!job || !job._id">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment Status Polling Indicator -->
|
||||||
|
<div *ngIf="isPollingAssignments" class="polling-status-indicator" role="status" aria-live="polite">
|
||||||
|
<i class="pi pi-spin pi-refresh" aria-hidden="true"></i>
|
||||||
|
<span i18n="@@pollingAssignmentStatus">Polling assignment status...</span>
|
||||||
|
<small i18n="@@updatesEvery5Seconds">(Updates every 5 seconds)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overall Assignment Progress -->
|
||||||
|
<div *ngIf="isAssignmentInProgress" class="assignment-progress" role="status" aria-live="assertive">
|
||||||
|
<i class="pi pi-spin pi-spinner" aria-hidden="true"></i>
|
||||||
|
<span i18n="@@assignmentInProgress">Assignment in progress...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment Error Summary -->
|
||||||
|
<div *ngIf="assignmentErrorMsg" class="assignment-error-summary" role="alert" aria-live="assertive">
|
||||||
|
<i class="pi pi-exclamation-triangle" aria-hidden="true"></i>
|
||||||
|
<span>{{ assignmentErrorMsg }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment Status Table -->
|
||||||
|
<p-table *ngIf="assignmentStatuses.length > 0" [value]="assignmentStatuses" dataKey="aircraftId"
|
||||||
|
[responsive]="true" [scrollable]="assignmentStatuses.length > 4"
|
||||||
|
[scrollHeight]="assignmentStatuses.length > 4 ? '300px' : 'auto'" styleClass="assignment-status-table"
|
||||||
|
[pTooltip]="getStatusTableTooltip()" tooltipPosition="top">
|
||||||
|
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 25%" i18n="@@aircraft" title="Aircraft name and assignment status indicator">Aircraft
|
||||||
|
</th>
|
||||||
|
<th style="width: 35%" i18n="@@statusMessage" title="Current assignment status and any error messages">
|
||||||
|
Status & Message</th>
|
||||||
|
<th style="width: 25%" i18n="@@assignTime" title="When the assignment was initiated">Assign Time</th>
|
||||||
|
<th style="width: 15%" i18n="@@actions" title="Available actions for this assignment">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template pTemplate="body" let-status>
|
||||||
|
<tr [ngClass]="'status-row-' + getStatusCssClass(status)">
|
||||||
|
<!-- Aircraft Name Column -->
|
||||||
|
<td style="text-align: center;">
|
||||||
|
<span class="ui-column-title" i18n="@@aircraft">Aircraft</span>
|
||||||
|
<div class="aircraft-cell" style="justify-content: center;">
|
||||||
|
<div style="display: flex; flex-direction: column; align-items: center;">
|
||||||
|
<span class="aircraft-name">{{ status.aircraftName }}</span>
|
||||||
|
<agm-badge [config]="getStatusSystemBadge(status.sourceSystem)"
|
||||||
|
style="margin-top: 4px; font-size: 0.75rem;">
|
||||||
|
</agm-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Combined Status & Message Column -->
|
||||||
|
<td>
|
||||||
|
<span class="ui-column-title" i18n="@@statusMessage">Status & Message</span>
|
||||||
|
<div>
|
||||||
|
<agm-badge [config]="getAssignmentStatusBadge(status)"></agm-badge>
|
||||||
|
<div class="status-message">{{ status.message }}</div>
|
||||||
|
<div *ngIf="status.errorDetails" class="status-error-details">
|
||||||
|
<small>{{ status.errorDetails }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Assign Time Column -->
|
||||||
|
<td>
|
||||||
|
<span class="ui-column-title" i18n="@@assignTime">Assign Time</span>
|
||||||
|
<div class="status-timestamp">{{ status.timestamp | date:'short' }}</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Actions Column -->
|
||||||
|
<td>
|
||||||
|
<span class="ui-column-title" i18n="@@actions">Actions</span>
|
||||||
|
<div class="status-actions">
|
||||||
|
<!-- Unified Slim Split Button for All States -->
|
||||||
|
<p-splitButton *ngIf="!isStatusNew(status)" styleClass="slim assignment-action-button"
|
||||||
|
[model]="getUnifiedActionOptions(status)" [disabled]="isAircraftAssignmentInProgress(status)"
|
||||||
|
i18n-pTooltip="@@assignmentActionsTooltip" pTooltip="Assignment actions">
|
||||||
|
</p-splitButton>
|
||||||
|
|
||||||
|
<!-- Simple indicator for new/pending states -->
|
||||||
|
<span *ngIf="isStatusNew(status)" class="status-indicator-text">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i>
|
||||||
|
<span class="sr-only">{{ Labels.PROCESSING_ASSIGNMENT }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template pTemplate="emptymessage">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" role="cell">
|
||||||
|
<div class="empty-message" i18n="@@noAssignmentStatus">
|
||||||
|
No assignment status to display
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</p-panel>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,822 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
|
||||||
|
import { Observable, Subject, timer, interval } from 'rxjs';
|
||||||
|
import { switchMap, takeUntil, retryWhen, delayWhen, startWith } from 'rxjs/operators';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
|
||||||
|
import { MenuItem, SelectItem } from 'primeng/api';
|
||||||
|
|
||||||
|
import { IUIJob } from '../models/job.model';
|
||||||
|
import * as fromEntity from '@app/entities/reducers';
|
||||||
|
import * as jobActions from '../actions/job.actions';
|
||||||
|
|
||||||
|
import { JobService } from '@app/domain/services/job.service';
|
||||||
|
import { PartnerService } from '@app/partners/services/partner.service';
|
||||||
|
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
|
||||||
|
import { BadgeFactoryService } from '@app/shared/services/badge-factory.service';
|
||||||
|
import { AuthService } from '@app/domain/services/auth.service';
|
||||||
|
|
||||||
|
import { AircraftAssignmentItem } from '@app/entities/models/vehicle.model';
|
||||||
|
import { Partner } from '@app/partners/models/partner.model';
|
||||||
|
import { BadgeConfig } from '@app/shared/badge/badge-config.model';
|
||||||
|
|
||||||
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
|
import { SourceSystem, OperationalStatus, AssignStatus, AssignStatusType, Labels, globals, KnownPartnerCodes, SystemOrPartnerType } from '@app/shared/global';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Assignment Status Tracking Interface
|
||||||
|
interface AssignmentStatus {
|
||||||
|
aircraftId: string;
|
||||||
|
aircraftName: string;
|
||||||
|
sourceSystem: SystemOrPartnerType; // Track source system for badge display
|
||||||
|
state: AssignStatusType; // Using AssignStatus values
|
||||||
|
message: string;
|
||||||
|
timestamp: Date;
|
||||||
|
errorDetails?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job Assignment Component
|
||||||
|
*
|
||||||
|
* Supports both AgNav and partner aircraft assignment to jobs.
|
||||||
|
*
|
||||||
|
* Partner Info Response Structure (from assignments_post):
|
||||||
|
* - AgNav vehicles: No partnerInfo field
|
||||||
|
* - Partner vehicles: { partnerInfo: { name: "satloc", partnerCode: "SATLOC" } }
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'agm-job-assignment',
|
||||||
|
templateUrl: './job-assignment.component.html',
|
||||||
|
styleUrls: ['./job-assignment.component.css']
|
||||||
|
})
|
||||||
|
export class JobAssignmentComponent extends BaseComp implements OnInit, OnDestroy {
|
||||||
|
// Template readonly objects for direct usage
|
||||||
|
readonly SourceSystem = SourceSystem;
|
||||||
|
readonly KnownPartnerCodes = KnownPartnerCodes;
|
||||||
|
readonly OperationalStatus = OperationalStatus;
|
||||||
|
readonly Labels = Labels;
|
||||||
|
|
||||||
|
// Inputs from parent component
|
||||||
|
@Input() job: IUIJob;
|
||||||
|
@Input() isArchived: boolean = false;
|
||||||
|
@Input() canDownload: boolean = false;
|
||||||
|
@Input() dlOps: SelectItem[] = [];
|
||||||
|
|
||||||
|
// Outputs to parent component
|
||||||
|
@Output() assignmentComplete = new EventEmitter<any>();
|
||||||
|
|
||||||
|
// Assignment-related properties
|
||||||
|
srcUsers: AircraftAssignmentItem[] = [];
|
||||||
|
tarUsers: AircraftAssignmentItem[] = [];
|
||||||
|
allAircraft: AircraftAssignmentItem[] = []; // Store all aircraft data
|
||||||
|
|
||||||
|
// Assignment Status Tracking
|
||||||
|
assignmentStatuses: AssignmentStatus[] = [];
|
||||||
|
isAssignmentInProgress = false;
|
||||||
|
assignmentErrorMsg: string | null = null;
|
||||||
|
|
||||||
|
// Assignment Status Polling
|
||||||
|
isPollingAssignments = false;
|
||||||
|
private stoppedAssignmentPoll: Subject<boolean>;
|
||||||
|
|
||||||
|
// Partner caching for performance
|
||||||
|
private partnersCache = new Map<string, Partner>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected store: Store<fromEntity.EntityState>,
|
||||||
|
private jobSvc: JobService,
|
||||||
|
private partnerSvc: PartnerService,
|
||||||
|
private partnerUtils: PartnerUtilsService,
|
||||||
|
private badgeFactory: BadgeFactoryService,
|
||||||
|
protected authSvc: AuthService,
|
||||||
|
private cdr: ChangeDetectorRef
|
||||||
|
) {
|
||||||
|
super(cdr);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.stoppedAssignmentPoll = new Subject();
|
||||||
|
|
||||||
|
// Load partners for aircraft display
|
||||||
|
this.loadPartners();
|
||||||
|
|
||||||
|
// Load aircraft data
|
||||||
|
this.loadAircraftData();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Stop assignment status polling
|
||||||
|
this.stopAssignmentStatusPolling();
|
||||||
|
super.ngOnDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load partners and cache them for performance
|
||||||
|
*/
|
||||||
|
private loadPartners(): void {
|
||||||
|
this.partnerSvc.getPartners().subscribe({
|
||||||
|
next: (partners) => {
|
||||||
|
this.partnersCache.clear();
|
||||||
|
partners.forEach(partner => {
|
||||||
|
this.partnersCache.set(partner._id, partner);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh aircraft data now that partners are loaded
|
||||||
|
this.refreshAircraftDisplay();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error(globals.consoleFailedToLoadPartners, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get partner from cache by ID
|
||||||
|
*/
|
||||||
|
private getPartner(partnerId: string): Partner | null {
|
||||||
|
return this.partnersCache.get(partnerId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load aircraft data from backend API for job assignment
|
||||||
|
*/
|
||||||
|
private loadAircraftData(): void {
|
||||||
|
if (!this.job || !this.job._id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use backend API to get assignment data with partnerInfo
|
||||||
|
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
|
||||||
|
next: (assignmentData) => {
|
||||||
|
this.updateAircraftAssignmentData(assignmentData);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error(globals.consoleFailedToLoadExistingAssignments, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update aircraft assignment data from backend API response
|
||||||
|
*/
|
||||||
|
private updateAircraftAssignmentData(assignmentData: any): void {
|
||||||
|
// Process available users
|
||||||
|
let availableAircraft: AircraftAssignmentItem[] = [];
|
||||||
|
if (assignmentData.avUsers && assignmentData.avUsers.length > 0) {
|
||||||
|
availableAircraft = assignmentData.avUsers.map(user => this.convertBackendUserToAssignmentItem(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process assigned users
|
||||||
|
let assignedAircraft: AircraftAssignmentItem[] = [];
|
||||||
|
if (assignmentData.asUsers && assignmentData.asUsers.length > 0) {
|
||||||
|
assignedAircraft = assignmentData.asUsers.map(user => this.convertBackendUserToAssignmentItem(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine all aircraft for sorting
|
||||||
|
this.allAircraft = this.sortAircraftBySource([...availableAircraft, ...assignedAircraft]);
|
||||||
|
|
||||||
|
// Set source users (available) and target users (assigned)
|
||||||
|
this.srcUsers = availableAircraft;
|
||||||
|
this.tarUsers = assignedAircraft;
|
||||||
|
|
||||||
|
// Always update assignment status to reflect current state (including when empty)
|
||||||
|
this.updateAssignmentStatus(assignmentData);
|
||||||
|
|
||||||
|
// Start polling if there are assigned aircraft to track
|
||||||
|
if (assignedAircraft.length > 0 && !this.isPollingAssignments) {
|
||||||
|
this.startAssignmentStatusPolling();
|
||||||
|
} else if (assignedAircraft.length === 0 && this.isPollingAssignments) {
|
||||||
|
// Stop polling if no assignments
|
||||||
|
this.stopAssignmentStatusPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh aircraft display after partners are loaded
|
||||||
|
*/
|
||||||
|
private refreshAircraftDisplay(): void {
|
||||||
|
// Reload aircraft data from backend API now that partners are cached
|
||||||
|
// This ensures partner names are resolved correctly
|
||||||
|
if (!this.job || !this.job._id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
|
||||||
|
next: (assignmentData) => {
|
||||||
|
this.updateAircraftAssignmentData(assignmentData);
|
||||||
|
// Trigger change detection to update the UI with partner names
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error(globals.consoleFailedToLoadExistingAssignments, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert backend user object (from assignments API) to AircraftAssignmentItem
|
||||||
|
*/
|
||||||
|
private convertBackendUserToAssignmentItem(user: any): AircraftAssignmentItem {
|
||||||
|
// Determine partner information from partnerInfo
|
||||||
|
let partnerId: string | undefined;
|
||||||
|
let partnerName: string | undefined;
|
||||||
|
let partnerCode: string | undefined;
|
||||||
|
let sourceSystem: SystemOrPartnerType = SourceSystem.AGNAV; // default
|
||||||
|
|
||||||
|
// Check for partnerInfo.partnerCode (from assignments_post response)
|
||||||
|
if (user.partnerInfo?.partnerCode) {
|
||||||
|
partnerCode = user.partnerInfo.partnerCode;
|
||||||
|
partnerName = user.partnerInfo.name;
|
||||||
|
|
||||||
|
// Find partner by partnerCode to get partner ID
|
||||||
|
const partner = Array.from(this.partnersCache.values()).find(p =>
|
||||||
|
p.partnerCode?.toUpperCase() === partnerCode!.toUpperCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (partner) {
|
||||||
|
partnerId = partner._id;
|
||||||
|
// Use partner ID as sourceSystem for all partner aircraft
|
||||||
|
sourceSystem = partnerId as SystemOrPartnerType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignmentItem: AircraftAssignmentItem = {
|
||||||
|
_id: user.uid, // Backend returns uid instead of _id
|
||||||
|
name: user.name,
|
||||||
|
active: user.active || false,
|
||||||
|
pkgActive: user.pkgActive || false,
|
||||||
|
tailNumber: user.tailNumber,
|
||||||
|
username: user.username, // Add username for tooltip display
|
||||||
|
partnerSystem: sourceSystem,
|
||||||
|
sourceSystem: sourceSystem,
|
||||||
|
partnerId: partnerId,
|
||||||
|
partnerName: partnerName,
|
||||||
|
partnerCode: partnerCode
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add partner-specific data for all partner aircraft (not just SATLOC)
|
||||||
|
if (!this.partnerUtils.isNativeSystem(sourceSystem)) {
|
||||||
|
const partnerObj = partnerId ? this.getPartner(partnerId) : null;
|
||||||
|
|
||||||
|
// For backward compatibility, keep satlocData for SATLOC aircraft
|
||||||
|
if (partnerObj && this.partnerUtils.isSatlocPartner(partnerObj)) {
|
||||||
|
assignmentItem.satlocData = {
|
||||||
|
tailNumber: user.tailNumber || Labels.N_A,
|
||||||
|
syncStatus: user.partnerInfo?.metadata?.syncStatus || OperationalStatus.PENDING
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignmentItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get partner display name for aircraft
|
||||||
|
*/
|
||||||
|
getPartnerDisplayName(aircraft: AircraftAssignmentItem): string {
|
||||||
|
if (aircraft.partnerName) {
|
||||||
|
return aircraft.partnerName;
|
||||||
|
}
|
||||||
|
return Labels.AGNAV_BRAND_NAME; // Use consistent non-translatable brand name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get partner display name from source system (for assignment status table)
|
||||||
|
*/
|
||||||
|
getPartnerDisplayNameFromSource(sourceSystem: SystemOrPartnerType): string {
|
||||||
|
if (this.partnerUtils.isNativeSystem(sourceSystem)) {
|
||||||
|
return Labels.AGNAV_BRAND_NAME;
|
||||||
|
}
|
||||||
|
const partner = this.getPartner(sourceSystem);
|
||||||
|
return partner ? partner.name : sourceSystem.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get simplified tooltip text for aircraft
|
||||||
|
* - For AgNav: name <br> username
|
||||||
|
* - For Partner: partner name <br> tailNumber
|
||||||
|
* - Adds warning if package is not active
|
||||||
|
*/
|
||||||
|
getAircraftTooltip(aircraft: AircraftAssignmentItem): string {
|
||||||
|
let tooltip = '';
|
||||||
|
|
||||||
|
// For AgNav aircraft: show name and username
|
||||||
|
if (aircraft.sourceSystem === SourceSystem.AGNAV) {
|
||||||
|
if (aircraft.username) {
|
||||||
|
tooltip = `${aircraft.name}<br>${aircraft.username}`;
|
||||||
|
} else {
|
||||||
|
tooltip = aircraft.name;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For Partner aircraft: show partner name and tail number
|
||||||
|
const partnerName = this.getPartnerDisplayName(aircraft);
|
||||||
|
if (aircraft.tailNumber) {
|
||||||
|
tooltip = `${partnerName}<br>${aircraft.tailNumber}`;
|
||||||
|
} else {
|
||||||
|
tooltip = partnerName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add package inactive warning if applicable
|
||||||
|
if (!aircraft.pkgActive) {
|
||||||
|
tooltip += `<br><span style="color: orange;">⚠️ ${Labels.PACKAGE_INACTIVE}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if aircraft can be assigned to job
|
||||||
|
*/
|
||||||
|
canAssignAircraft(aircraft: AircraftAssignmentItem): boolean {
|
||||||
|
// Only check package status - no authentication constraints
|
||||||
|
return aircraft.pkgActive === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AIRCRAFT SORTING UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort aircraft by source system (AgNav first, then all partners alphabetically)
|
||||||
|
*/
|
||||||
|
private sortAircraftBySource(aircraft: AircraftAssignmentItem[]): AircraftAssignmentItem[] {
|
||||||
|
return aircraft.sort((a, b) => {
|
||||||
|
// Primary sort: AgNav first, then all partner systems
|
||||||
|
const aIsNative = this.partnerUtils.isNativeSystem(a.sourceSystem);
|
||||||
|
const bIsNative = this.partnerUtils.isNativeSystem(b.sourceSystem);
|
||||||
|
|
||||||
|
if (aIsNative && !bIsNative) return -1; // AgNav comes first
|
||||||
|
if (!aIsNative && bIsNative) return 1; // AgNav comes first
|
||||||
|
|
||||||
|
// If both are partners or both are native, sort by partner name then aircraft name
|
||||||
|
if (!aIsNative && !bIsNative) {
|
||||||
|
// Both are partners - sort by partner name first
|
||||||
|
const aPartnerName = a.partnerName || Labels.UNKNOWN_PARTNER;
|
||||||
|
const bPartnerName = b.partnerName || Labels.UNKNOWN_PARTNER;
|
||||||
|
const partnerCompare = aPartnerName.localeCompare(bPartnerName);
|
||||||
|
if (partnerCompare !== 0) return partnerCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary sort: Alphabetical by aircraft name within each source group
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main assignment method - handles both AgNav and partner aircraft
|
||||||
|
*/
|
||||||
|
assignJob(): void {
|
||||||
|
if (!this.job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform aircraft data to backend API format
|
||||||
|
// Backend expects 'uid' property, but frontend model uses '_id'
|
||||||
|
const formattedAsUsers = this.tarUsers.map(aircraft => {
|
||||||
|
// Base assignment data - ALL aircraft need uid for backend
|
||||||
|
const assignmentData: any = {
|
||||||
|
uid: aircraft._id, // Required for all aircraft types - backend uses this for 'user' field
|
||||||
|
name: aircraft.name
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add partner-specific data for Satloc aircraft
|
||||||
|
if (aircraft.sourceSystem === KnownPartnerCodes.SATLOC && aircraft.satlocData) {
|
||||||
|
assignmentData.partnerAircraftId = aircraft.satlocData.satlocId || aircraft._id;
|
||||||
|
assignmentData.notes = `${Labels.SATLOC_AIRCRAFT_PREFIX} ${aircraft.satlocData.tailNumber}`;
|
||||||
|
assignmentData.jobName = this.job.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignmentData;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedAvUsers = this.srcUsers.map(aircraft => ({
|
||||||
|
uid: aircraft._id,
|
||||||
|
name: aircraft.name
|
||||||
|
}));
|
||||||
|
|
||||||
|
const assignment: jobActions.AssignInfo = {
|
||||||
|
jobId: this.job._id,
|
||||||
|
dlOp: this.job.dlOp,
|
||||||
|
avUsers: formattedAvUsers,
|
||||||
|
asUsers: formattedAsUsers
|
||||||
|
};
|
||||||
|
|
||||||
|
this.store.dispatch(new jobActions.Assign(assignment));
|
||||||
|
|
||||||
|
// Start polling immediately if assigning aircraft (to track assignment progress)
|
||||||
|
if (formattedAsUsers.length > 0 && !this.isPollingAssignments) {
|
||||||
|
this.startAssignmentStatusPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ASSIGNMENT STATUS POLLING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling assignment status from backend API
|
||||||
|
*/
|
||||||
|
private startAssignmentStatusPolling(): void {
|
||||||
|
if (this.isPollingAssignments) {
|
||||||
|
return; // Already polling
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isPollingAssignments = true;
|
||||||
|
this.stoppedAssignmentPoll.next(false);
|
||||||
|
|
||||||
|
const polling$ = this.pollAssignmentStatus().subscribe({
|
||||||
|
next: (assignmentData) => {
|
||||||
|
this.updateAssignmentStatus(assignmentData);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error(globals.consoleAssignmentStatusPollingError, error);
|
||||||
|
this.isPollingAssignments = false;
|
||||||
|
// Reset assignment progress flag on polling error to prevent UI from being stuck
|
||||||
|
if (this.isAssignmentInProgress) {
|
||||||
|
this.isAssignmentInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add subscription to component's subscription manager
|
||||||
|
this.sub$.add(polling$);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopAssignmentStatusPolling(): void {
|
||||||
|
if (this.stoppedAssignmentPoll) {
|
||||||
|
this.stoppedAssignmentPoll.next(true);
|
||||||
|
this.isPollingAssignments = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pollAssignmentStatus(): Observable<any> {
|
||||||
|
return interval(10000).pipe( // Poll every 10 seconds for real status updates
|
||||||
|
startWith(1000), // Start after 1 second
|
||||||
|
switchMap(() => this.jobSvc.getAssignments({ 'jobId': this.job._id })),
|
||||||
|
takeUntil(this.stoppedAssignmentPoll),
|
||||||
|
retryWhen(errors =>
|
||||||
|
errors.pipe(
|
||||||
|
delayWhen(val => timer(15 * 1000)) // Retry after 15 seconds on error
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateAssignmentStatus(assignmentData: any): void {
|
||||||
|
// Clear assignment statuses if no assigned users (all unassigned)
|
||||||
|
if (!assignmentData || !assignmentData.asUsers || assignmentData.asUsers.length === 0) {
|
||||||
|
this.assignmentStatuses = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update assignment statuses with real assignment data
|
||||||
|
this.assignmentStatuses = assignmentData.asUsers.map((assignedAircraft) => {
|
||||||
|
const existingStatus = this.assignmentStatuses.find(s => s.aircraftId === assignedAircraft.uid);
|
||||||
|
|
||||||
|
// Use the assignStatus field from the backend data
|
||||||
|
const backendStatus = assignedAircraft.assignStatus !== undefined ? assignedAircraft.assignStatus : AssignStatus.NEW;
|
||||||
|
|
||||||
|
// Generate status object from real assignment data
|
||||||
|
const statusUpdate = this.generateAssignmentStatusFromBackend(assignedAircraft, backendStatus);
|
||||||
|
|
||||||
|
// If there's existing status, preserve timestamp if status hasn't changed
|
||||||
|
if (existingStatus) {
|
||||||
|
return {
|
||||||
|
...statusUpdate,
|
||||||
|
// Preserve timestamp if status hasn't changed
|
||||||
|
timestamp: existingStatus.state === statusUpdate.state ? existingStatus.timestamp : new Date()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusUpdate;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if all assignments have completed (no longer in NEW/pending status)
|
||||||
|
this.checkAssignmentProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check assignment progress and update isAssignmentInProgress flag
|
||||||
|
*/
|
||||||
|
private checkAssignmentProgress(): void {
|
||||||
|
if (this.assignmentStatuses.length === 0) {
|
||||||
|
// No assignments to track
|
||||||
|
this.isAssignmentInProgress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any assignments are still in NEW (pending) status
|
||||||
|
const hasNewAssignments = this.assignmentStatuses.some(status => status.state === AssignStatus.NEW);
|
||||||
|
|
||||||
|
if (!hasNewAssignments && this.isAssignmentInProgress) {
|
||||||
|
// All assignments have completed (no longer in NEW status)
|
||||||
|
this.isAssignmentInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateAssignmentStatusFromBackend(assignedAircraft: any, backendStatus: number): AssignmentStatus {
|
||||||
|
const statusMessages = {
|
||||||
|
[AssignStatus.NEW]: globals.assignmentInProgress,
|
||||||
|
[AssignStatus.DOWNLOADED]: globals.assignmentDownloaded,
|
||||||
|
[AssignStatus.UPLOADED]: globals.assignmentCompleted,
|
||||||
|
[AssignStatus.ERROR]: globals.assignmentFailed
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the aircraft in tarUsers or allAircraft to get sourceSystem
|
||||||
|
const aircraft = this.tarUsers.find(a => a._id === assignedAircraft.uid) ||
|
||||||
|
this.allAircraft.find(a => a._id === assignedAircraft.uid);
|
||||||
|
const sourceSystem = aircraft?.sourceSystem || SourceSystem.AGNAV;
|
||||||
|
|
||||||
|
return {
|
||||||
|
aircraftId: assignedAircraft.uid,
|
||||||
|
aircraftName: assignedAircraft.name,
|
||||||
|
sourceSystem: sourceSystem,
|
||||||
|
state: backendStatus as AssignStatusType,
|
||||||
|
message: statusMessages[backendStatus] || globals.unknownStatus,
|
||||||
|
timestamp: new Date(),
|
||||||
|
// Use actual error details from backend if available
|
||||||
|
errorDetails: backendStatus === AssignStatus.ERROR ? assignedAircraft.errorDetails : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AIRCRAFT SELECTION HANDLERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle aircraft selection/click - validate package status only
|
||||||
|
*/
|
||||||
|
async onAircraftSelect(aircraft: AircraftAssignmentItem, event: Event): Promise<void> {
|
||||||
|
// Only validate if this is in the source list (available aircraft)
|
||||||
|
const isInSourceList = this.srcUsers.some(ac => ac._id === aircraft._id);
|
||||||
|
if (!isInSourceList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check package status (applies to all aircraft)
|
||||||
|
if (!aircraft.pkgActive) {
|
||||||
|
// Package not enabled - visual feedback already provided via red highlighting and tooltip
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No authentication validation - allow all aircraft with active packages to be assigned
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aircraft movement event handlers
|
||||||
|
*/
|
||||||
|
async onMoveToTarget(event: any): Promise<void> {
|
||||||
|
// Get the aircraft that were just moved
|
||||||
|
const movedAircraft = event.items || [];
|
||||||
|
|
||||||
|
// Validate each moved aircraft for package status only
|
||||||
|
for (const aircraft of movedAircraft) {
|
||||||
|
let shouldMoveBack = false;
|
||||||
|
let reason = '';
|
||||||
|
|
||||||
|
// Check package active status - only constraint remaining
|
||||||
|
if (!aircraft.pkgActive) {
|
||||||
|
shouldMoveBack = true;
|
||||||
|
reason = Labels.PACKAGE_NOT_ENABLED_REASON;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move aircraft back to source if validation failed
|
||||||
|
if (shouldMoveBack) {
|
||||||
|
const aircraftIndex = this.tarUsers.findIndex(ac => ac._id === aircraft._id);
|
||||||
|
if (aircraftIndex !== -1) {
|
||||||
|
this.tarUsers.splice(aircraftIndex, 1);
|
||||||
|
this.srcUsers.push(aircraft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting to maintain AgNav-first order
|
||||||
|
this.tarUsers = this.sortAircraftBySource(this.tarUsers);
|
||||||
|
this.srcUsers = this.sortAircraftBySource(this.srcUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMoveToSource(event: any): void {
|
||||||
|
// Apply sorting to maintain AgNav-first order
|
||||||
|
this.srcUsers = this.sortAircraftBySource(this.srcUsers);
|
||||||
|
this.tarUsers = this.sortAircraftBySource(this.tarUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI Helper Methods (unified badge system)
|
||||||
|
* Uses BadgeFactoryService to create configuration-driven badges
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get badge configuration for aircraft source system (picklist)
|
||||||
|
*/
|
||||||
|
getAircraftSystemBadge(aircraft: AircraftAssignmentItem): BadgeConfig {
|
||||||
|
return this.badgeFactory.createSystemBadge(
|
||||||
|
aircraft.sourceSystem,
|
||||||
|
this.getPartnerDisplayName(aircraft)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get badge configuration for assignment status source system (status table)
|
||||||
|
*/
|
||||||
|
getStatusSystemBadge(sourceSystem: SystemOrPartnerType): BadgeConfig {
|
||||||
|
return this.badgeFactory.createSystemBadge(
|
||||||
|
sourceSystem,
|
||||||
|
this.getPartnerDisplayNameFromSource(sourceSystem)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get badge configuration for assignment status (status table)
|
||||||
|
*/
|
||||||
|
getAssignmentStatusBadge(status: AssignmentStatus): BadgeConfig {
|
||||||
|
return this.badgeFactory.createAssignmentStatusBadge(
|
||||||
|
status.state,
|
||||||
|
status.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRefreshStatusTooltip(): string {
|
||||||
|
return Labels.MANUALLY_REFRESH_ASSIGNMENT_STATUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAssignButtonTooltip(): string {
|
||||||
|
if (this.isArchived) {
|
||||||
|
return Labels.ASSIGN_BUTTON_ARCHIVED_TOOLTIP;
|
||||||
|
}
|
||||||
|
if (!this.canDownload) {
|
||||||
|
return Labels.ASSIGN_BUTTON_NO_BOUNDARY_TOOLTIP;
|
||||||
|
}
|
||||||
|
return Labels.ASSIGN_BUTTON_READY_TOOLTIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPickListSourceTooltip(): string {
|
||||||
|
return Labels.PICK_LIST_SOURCE_TOOLTIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPickListTargetTooltip(): string {
|
||||||
|
return Labels.PICK_LIST_TARGET_TOOLTIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDownloadOptionsTooltip(): string {
|
||||||
|
return Labels.DOWNLOAD_OPTIONS_DROPDOWN_TOOLTIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusTableTooltip(): string {
|
||||||
|
return Labels.ASSIGNMENT_STATUS_TABLE_TOOLTIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusIconTooltip(status: AssignmentStatus): string {
|
||||||
|
switch (status.state) {
|
||||||
|
case AssignStatus.NEW:
|
||||||
|
return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP;
|
||||||
|
case AssignStatus.DOWNLOADED:
|
||||||
|
return Labels.ASSIGNMENT_STATUS_DOWNLOADED_TOOLTIP;
|
||||||
|
case AssignStatus.UPLOADED:
|
||||||
|
return Labels.ASSIGNMENT_STATUS_UPLOADED_TOOLTIP;
|
||||||
|
case AssignStatus.ERROR:
|
||||||
|
return Labels.ASSIGNMENT_STATUS_ERROR_TOOLTIP;
|
||||||
|
default:
|
||||||
|
return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP; // Default to new/pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assignment Status UI Methods
|
||||||
|
*/
|
||||||
|
refreshAssignmentStatus(): void {
|
||||||
|
if (!this.job || !this.job._id) {
|
||||||
|
console.warn(globals.consoleCannotRefreshAssignmentStatus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
|
||||||
|
next: (assignmentData) => {
|
||||||
|
// Update both assignment status AND aircraft lists
|
||||||
|
this.updateAircraftAssignmentData(assignmentData);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
console.error(globals.consoleFailedToRefreshAssignmentStatus, error);
|
||||||
|
this.msgSvc.addFailedMsg(globals.failedToRefreshAssignmentStatus);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assignment Status Action Methods
|
||||||
|
*/
|
||||||
|
getUnifiedActionOptions(status: AssignmentStatus): MenuItem[] {
|
||||||
|
const commonActions = [
|
||||||
|
{
|
||||||
|
label: globals.clearStatus,
|
||||||
|
icon: 'ui-icon-clear',
|
||||||
|
command: () => this.clearSingleStatus(status.aircraftId)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (status.state === AssignStatus.ERROR) {
|
||||||
|
return [
|
||||||
|
...commonActions,
|
||||||
|
{
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: globals.resetToAvailable,
|
||||||
|
icon: 'ui-icon-arrow-back',
|
||||||
|
command: () => this.resetAircraftToAvailable(status.aircraftId)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.state === AssignStatus.UPLOADED) {
|
||||||
|
return [
|
||||||
|
...commonActions
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default actions for any other states
|
||||||
|
return commonActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSingleStatus(aircraftId: string): void {
|
||||||
|
this.assignmentStatuses = this.assignmentStatuses.filter(
|
||||||
|
status => status.aircraftId !== aircraftId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
resetAircraftToAvailable(aircraftId: string): void {
|
||||||
|
// Find the aircraft in assigned list
|
||||||
|
const aircraft = this.tarUsers.find(a => a._id === aircraftId);
|
||||||
|
if (!aircraft) return;
|
||||||
|
|
||||||
|
// Move aircraft back to available list
|
||||||
|
this.tarUsers = this.tarUsers.filter(a => a._id !== aircraftId);
|
||||||
|
|
||||||
|
// Add to source list only if package active, meets auth requirements, and not already there
|
||||||
|
// Partner aircraft: Only require active package
|
||||||
|
// Native aircraft: Require active package AND credentials (matches backend filter: username: { $nin: [null, ''] })
|
||||||
|
const isPartner = !this.partnerUtils.isNativeSystem(aircraft.sourceSystem);
|
||||||
|
const hasCredentials = aircraft.username && aircraft.username !== '';
|
||||||
|
const meetsAuthRequirements = isPartner || hasCredentials;
|
||||||
|
|
||||||
|
if (aircraft.pkgActive === true && meetsAuthRequirements && !this.srcUsers.find(a => a._id === aircraftId)) {
|
||||||
|
this.srcUsers.push(aircraft);
|
||||||
|
// Apply sorting to maintain AgNav-first order
|
||||||
|
this.srcUsers = this.sortAircraftBySource(this.srcUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the status
|
||||||
|
this.clearSingleStatus(aircraftId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status helper methods for template
|
||||||
|
*/
|
||||||
|
getStatusIcon(status: AssignmentStatus): string {
|
||||||
|
switch (status.state) {
|
||||||
|
case AssignStatus.NEW:
|
||||||
|
return 'pi-spin pi-spinner';
|
||||||
|
case AssignStatus.DOWNLOADED:
|
||||||
|
return 'pi-download';
|
||||||
|
case AssignStatus.UPLOADED:
|
||||||
|
return 'pi-check-circle';
|
||||||
|
case AssignStatus.ERROR:
|
||||||
|
return 'pi-times-circle';
|
||||||
|
default:
|
||||||
|
return 'pi-question-circle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isStatusNew(status: AssignmentStatus): boolean {
|
||||||
|
return status.state === AssignStatus.NEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific aircraft's assignment is in progress
|
||||||
|
*/
|
||||||
|
isAircraftAssignmentInProgress(status: AssignmentStatus): boolean {
|
||||||
|
return status.state === AssignStatus.NEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusCssClass(status: AssignmentStatus): string {
|
||||||
|
switch (status.state) {
|
||||||
|
case AssignStatus.NEW:
|
||||||
|
return 'new';
|
||||||
|
case AssignStatus.DOWNLOADED:
|
||||||
|
return 'downloaded';
|
||||||
|
case AssignStatus.UPLOADED:
|
||||||
|
return 'uploaded';
|
||||||
|
case AssignStatus.ERROR:
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
}
|
||||||
@ -1,3 +1,732 @@
|
|||||||
.sprayed-value {
|
.sprayed-value {
|
||||||
margin-top: .25em;
|
margin-top: .25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Aircraft Item Layout */
|
||||||
|
.aircraft-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 4px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-name {
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-icon {
|
||||||
|
color: #007ad9;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Satloc-specific Details */
|
||||||
|
.satloc-details {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tail-number {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync Status Indicators */
|
||||||
|
.sync-status {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status-active {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status-pending {
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status-error {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Package Status */
|
||||||
|
.package-inactive {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover Effects */
|
||||||
|
.aircraft-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aircraft item hover effects are now handled by global badge system */
|
||||||
|
|
||||||
|
/* Download Options Styling */
|
||||||
|
.download-options-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: #e8f4fd;
|
||||||
|
border: 1px solid #bbdefb;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #1976d2;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-options-info .pi {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for download options */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.download-options-info {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-options-info .pi {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.download-options-info {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.aircraft-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 6px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.satloc-details {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Round Split Button Styling with ::ng-deep */
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button {
|
||||||
|
border-radius: 50% !important;
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
min-width: auto !important;
|
||||||
|
background-color: #6c757d !important;
|
||||||
|
border-color: #6c757d !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:hover {
|
||||||
|
background-color: #5a6268 !important;
|
||||||
|
border-color: #545b62 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the main button, show only dropdown arrow */
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:first-child {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the dropdown arrow button to be round */
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
|
||||||
|
border-radius: 50% !important;
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-left: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
background-color: #6c757d !important;
|
||||||
|
border-color: #6c757d !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton:hover {
|
||||||
|
background-color: #5a6268 !important;
|
||||||
|
border-color: #545b62 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override PrimeNG corner classes */
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .ui-corner-right {
|
||||||
|
border-radius: 50% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center the dropdown arrow icon */
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-left {
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assignment Status Display Styles (Update 1.1.4a) */
|
||||||
|
.assignment-status-section {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-status-btn {
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
min-width: auto !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
background-color: #ffc107 !important;
|
||||||
|
color: #666 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-status-btn:hover {
|
||||||
|
background-color: #e0e0e0 !important;
|
||||||
|
color: #333 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border: 1px solid #bbdefb;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #1976d2;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-progress .pi-spinner {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assignment Status Polling Indicator */
|
||||||
|
.polling-status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border: 1px solid #90caf9;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #1565c0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polling-status-indicator .pi-refresh {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.polling-status-indicator small {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #424242;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-error-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #ffebee;
|
||||||
|
border: 1px solid #ffcdd2;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #c62828;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-error-summary .pi {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
background-color: white;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item:hover {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-aircraft-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-aircraft-info .aircraft-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-timestamp {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error-details {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status actions column centering and sizing */
|
||||||
|
.status-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 100% !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assignment Status Table Actions column centering */
|
||||||
|
.assignment-status-table .status-actions {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 56px !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Ensure the assignment action button container is centered */
|
||||||
|
.assignment-action-button.slim {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
min-width: auto !important;
|
||||||
|
background-color: #ff9800 !important;
|
||||||
|
border: 1px solid #f57c00 !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn:hover:not(:disabled) {
|
||||||
|
background-color: #f57c00 !important;
|
||||||
|
border-color: #ef6c00 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn:disabled {
|
||||||
|
opacity: 0.6 !important;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced Status Actions Menu Items */
|
||||||
|
.p-menu .p-menuitem-link {
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-menu .p-menuitem-icon {
|
||||||
|
margin-right: 8px !important;
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Split Button Menu Positioning */
|
||||||
|
.status-actions .p-splitbutton .p-menu {
|
||||||
|
min-width: 180px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position dropdown menu slightly to the left to prevent cutoff at screen edge */
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .ui-menu {
|
||||||
|
transform: translateX(-120px) !important;
|
||||||
|
margin-top: 2px !important;
|
||||||
|
min-width: 180px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alternative positioning for PrimeNG p-menu */
|
||||||
|
:host ::ng-deep .assignment-action-button.slim .p-menu {
|
||||||
|
transform: translateX(-120px) !important;
|
||||||
|
margin-top: 2px !important;
|
||||||
|
min-width: 180px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for split buttons */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
.retry-split-button .p-button,
|
||||||
|
.status-split-button .p-button {
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions .p-splitbutton .p-menu {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status State Specific Styles */
|
||||||
|
.status-pending .status-icon {
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-retrying .status-icon {
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
border-color: #c8e6c9;
|
||||||
|
background-color: #f1f8e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success .status-icon {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
border-color: #ffcdd2;
|
||||||
|
background-color: #ffebee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error .status-icon {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design for Assignment Status */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.assignment-status-section {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-header h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
padding: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-aircraft-info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-aircraft-info .aircraft-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-timestamp {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.status-item {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions {
|
||||||
|
align-self: flex-end;
|
||||||
|
max-width: 48px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-progress {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-error-summary {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assignment Status Table Styling (Update 1.1.4b) */
|
||||||
|
.assignment-status-table {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-tbody>tr {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .status-row-pending {
|
||||||
|
border-left-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .status-row-success {
|
||||||
|
border-left-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .status-row-error {
|
||||||
|
border-left-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .status-row-retrying {
|
||||||
|
border-left-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-cell .pi {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-pending {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-success {
|
||||||
|
background-color: #e8f5e8;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-error {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-retrying {
|
||||||
|
background-color: #fff3e0;
|
||||||
|
color: #f57c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error-details {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-timestamp {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design for table */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.assignment-status-table .ui-table-thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-tbody>tr>td {
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-status-table .ui-table-tbody>tr>td:before {
|
||||||
|
content: attr(data-label) ": ";
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Update 1.1.6a: Slim Split Button Actions Styling */
|
||||||
|
|
||||||
|
/* Slim Split Button for Assignment Status Table */
|
||||||
|
.assignment-action-button.slim .ui-splitbutton {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-action-button.slim .ui-splitbutton .ui-button {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
min-width: auto !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
background-color: #6c757d !important;
|
||||||
|
border-color: #6c757d !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-action-button.slim .ui-splitbutton .ui-button:hover {
|
||||||
|
background-color: #5a6268 !important;
|
||||||
|
border-color: #545b62 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-action-button.slim .ui-splitbutton .ui-button:focus {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(108, 117, 125, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the left button for slim style - enhanced for assignment status */
|
||||||
|
.assignment-action-button.slim .ui-splitbutton .ui-button:first-child {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the dropdown arrow button */
|
||||||
|
.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
border-left: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-primary {
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicator text for pending/retrying states */
|
||||||
|
.status-indicator-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-text .pi {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-text .pi-spin {
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-text .pi-clock {
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.assignment-action-button.slim .ui-splitbutton {
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-action-button.slim .ui-splitbutton .ui-button,
|
||||||
|
.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,8 +18,7 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-3 ui-sm-4"><strong i18n="@@name">Name</strong></div>
|
<div class="ui-g-3 ui-sm-4"><strong i18n="@@name">Name</strong></div>
|
||||||
<div class="ui-g-9 ui-sm-8">
|
<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"
|
<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>
|
||||||
(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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@ -427,31 +426,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div *ngIf="isPlanner && isEdit" class="ui-g-12 ui-md-12 ui-lg-12">
|
<div *ngIf="isPlanner && isEdit" class="ui-g-12 ui-md-12 ui-lg-12">
|
||||||
<div class="ui-g-12 ui-g-nopad">
|
<div class="ui-g-12 ui-g-nopad">
|
||||||
<p-panel i18n-header="@@jobAssignment" header="Job Assignment" [toggleable]="true" [collapsed]="false">
|
<agm-job-assignment [job]="selectedItem" [isEdit]="isEdit" [isArchived]="isArchived" [canDownload]="canDownload" [dlOps]="dlOps" (assignmentComplete)="onAssignmentComplete($event)" (assignmentErrorEvent)="onAssignmentError($event)">
|
||||||
<div class="ui-g">
|
</agm-job-assignment>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,7 +41,6 @@ import { selectLimit } from '@app/reducers';
|
|||||||
import { Acre } from '@app/domain/models/subscription.model';
|
import { Acre } from '@app/domain/models/subscription.model';
|
||||||
import { SUB, SubTexts, SubType } from '@app/profile/common';
|
import { SUB, SubTexts, SubType } from '@app/profile/common';
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'agm-job-edit',
|
selector: 'agm-job-edit',
|
||||||
templateUrl: './job-edit.component.html',
|
templateUrl: './job-edit.component.html',
|
||||||
@ -105,9 +104,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
|
|
||||||
grpedProds: SelectItemGroup[] = [];
|
grpedProds: SelectItemGroup[] = [];
|
||||||
|
|
||||||
srcUsers: any[];
|
|
||||||
tarUsers: any[];
|
|
||||||
|
|
||||||
uploadUrl = '/imports/uploadJob';
|
uploadUrl = '/imports/uploadJob';
|
||||||
uploadedFiles = [];
|
uploadedFiles = [];
|
||||||
dlLogs = [];
|
dlLogs = [];
|
||||||
@ -405,9 +401,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
this.resetNewEntities();
|
this.resetNewEntities();
|
||||||
this.checkOKDl();
|
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.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).pipe(take(1)).subscribe((pkg) => {
|
||||||
this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre;
|
this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre;
|
||||||
@ -441,9 +434,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
if (this.isEdit) {
|
if (this.isEdit) {
|
||||||
this.getUploadedFiles();
|
this.getUploadedFiles();
|
||||||
this.getLogs();
|
this.getLogs();
|
||||||
if (this.isPlanner) {
|
|
||||||
this.getAssignments();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 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) {
|
private getAppRateUnits(isUS: boolean) {
|
||||||
if (isUS) {
|
if (isUS) {
|
||||||
this.rateUnits = [
|
this.rateUnits = [
|
||||||
@ -550,19 +531,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
return valid;
|
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) {
|
getUserToolTip(user) {
|
||||||
if (Utils.isEmptyObj(user)) return '';
|
if (Utils.isEmptyObj(user)) return '';
|
||||||
let userTT = user.username;
|
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) {
|
editJobMap(id?: number) {
|
||||||
this.router.navigate(
|
this.router.navigate(
|
||||||
[
|
[
|
||||||
@ -1056,18 +1007,51 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
assignJob() {
|
// Assignment functionality moved to job-assignment component
|
||||||
if (!this.job) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assignment = <jobActions.AssignInfo>{
|
downLoadJob(type: number) {
|
||||||
jobId: this.job._id,
|
this.doDownLoadJob(type);
|
||||||
dlOp: this.selectedItem.dlOp,
|
}
|
||||||
avUsers: this.srcUsers,
|
|
||||||
asUsers: this.tarUsers
|
private doDownLoadJob(type) {
|
||||||
};
|
// TODO: Need to be handled in effects ???
|
||||||
this.store.dispatch(new jobActions.Assign(assignment));
|
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) {
|
downloadAppfile(data) {
|
||||||
@ -1389,10 +1373,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
this.addingNewCropJob = evt == 1;
|
this.addingNewCropJob = evt == 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
super.ngOnDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse file size string to megabytes for GA4 tracking
|
* Parse file size string to megabytes for GA4 tracking
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card clearfix">
|
<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">
|
<ng-template pTemplate="caption">
|
||||||
<div class="ui-g ui-g-nopad">
|
<div class="ui-g ui-g-nopad">
|
||||||
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
|
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
|
||||||
@ -23,10 +27,13 @@
|
|||||||
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
<th *ngFor="let col of columns" [ngSwitch]="col.filtered" class="ui-fluid">
|
||||||
<div class="input-with-icon" *ngSwitchCase="true">
|
<div class="input-with-icon" *ngSwitchCase="true">
|
||||||
<i class="ui-icon-search"></i>
|
<i class="ui-icon-search"></i>
|
||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
|
[value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label" [ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
|
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label"
|
||||||
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter" (onChange)="handleStatusFilter($event.value)"></p-dropdown>
|
[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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</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[1].header}}</span>{{job._id}}</td>
|
||||||
<td class="table-col-center"><span class="ui-column-title">{{cols[2].header}}</span>{{job.orderNumber}}</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><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[4].header}}</span>{{job.startDate |
|
||||||
<td class="table-col-center"><span class="ui-column-title">{{cols[5].header}}</span>{{job.endDate | date:'shortDate'}}</td>
|
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">
|
<td class="table-col-center">
|
||||||
<span class="ui-column-title">{{cols[5].header}}</span>
|
<span class="ui-column-title">{{cols[5].header}}</span>
|
||||||
{{ job.status | jobStatus }}
|
{{ job.status | jobStatus }}
|
||||||
@ -52,15 +61,24 @@
|
|||||||
</p-table>
|
</p-table>
|
||||||
<div class="ui-widget-header ui-helper-clearfix toolbar">
|
<div class="ui-widget-header ui-helper-clearfix toolbar">
|
||||||
<span class="ui-g ui-g-10 ui-sm-12 no-pad">
|
<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>
|
<!-- Note: !acre checks if subscription package loaded (not acre limits, packages have unlimited acres) -->
|
||||||
<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" pButton icon="ui-icon-plus" *ngIf="canWrite"
|
||||||
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-edit" (click)="editJob()" i18n-label="@@detail" label="Detail"></button>
|
[disabled]="currClient.value === null || !acre" (click)="newJob()" i18n-label="@@new" label="New"></button>
|
||||||
<button type="button" [disabled]="!canEdit()" pButton icon="ui-icon-map" (click)="editJobMap()" i18n-label="@@map" label="Map"></button>
|
<button type="button" *ngIf="canWrite" [disabled]="!canEdit() || currClient.value === null || !acre" pButton
|
||||||
<button type="button" [disabled]="!canEdit()" *ngIf="canWrite" pButton icon="ui-icon-trash" (click)="deleteJob()" i18n-label="@@delete" label="Delete"></button>
|
icon="ui-icon-control-point-duplicate" (click)="duplicateJob()" i18n-label="@@duplicate"
|
||||||
<button type="button" [disabled]="!canCreateInvoice()" *ngIf="canWriteInvoice" pButton icon="ui-icon-add" (click)="createInvoice()" i18n-label="@@createInvoice" label="Create Invoice"></button>
|
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>
|
||||||
<span class="ui-g-2 ui-sm-12 no-pad" *ngIf="!isClientUser">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -73,22 +91,28 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<span i18n="@@filtJobsByCreatedDate">Filter Jobs By Created Date</span>
|
<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>
|
||||||
</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">
|
<ng-template let-item pTemplate="item">
|
||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div [ngClass]="isShowXBtn(item) ? 'ui-g-8' : 'ui-g-12'" class="ui-g-nopad">{{ item.label }}</div>
|
<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>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</p-dropdown>
|
</p-dropdown>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui-g-4 ui-lg-4 ui-md-12 ui-sm-12 inline-flex-end">
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@ -162,7 +162,20 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).subscribe((pkg) => {
|
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 {
|
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;
|
return !!this.acre && !this.acre.overLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -678,7 +678,7 @@
|
|||||||
<div>
|
<div>
|
||||||
{{curPlayRec.timeLocal || "00:00:00.0"}}
|
{{curPlayRec.timeLocal || "00:00:00.0"}}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div *ngIf="isPlayingAgNavFile">
|
||||||
<p-dropdown id="tzone" name="tzone" [style]="{'width':'50px'}" [(ngModel)]="localTz" [options]="timeZones" (onChange)="onTzChange($event)"></p-dropdown>
|
<p-dropdown id="tzone" name="tzone" [style]="{'width':'50px'}" [(ngModel)]="localTz" [options]="timeZones" (onChange)="onTzChange($event)"></p-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</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-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-4 data-field field-name">TrckAngle</div>
|
||||||
<div class="ui-g-8 data-field">{{curPlayRec.trckAngle}}</div>
|
<div class="ui-g-8 data-field">{{curPlayRec.trckAngle}}</div>
|
||||||
<div class="ui-g-4 data-field field-name">LckedLine</div>
|
<ng-container *ngIf="isPlayingAgNavFile">
|
||||||
<div class="ui-g-8 data-field">{{curPlayRec.lockedLine | lockline:curPlayLoc?.xTrack }}</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>
|
||||||
<div class="ui-g-4 data-field field-name">HDOP</div>
|
<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-8 data-field">{{curPlayRec.hdop}}</div>
|
||||||
<div class="ui-g-4 data-field field-name">Sat/Cor/ID</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 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>
|
<ng-container *ngIf="isDebug">
|
||||||
<div *ngIf="isDebug" class="ui-g-8 data-field">{{curPlayLoc?.sprayStat}}</div>
|
<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>
|
</div>
|
||||||
</p-tabPanel>
|
</p-tabPanel>
|
||||||
<p-tabPanel i18n-header="@@applicInfo" header="Applic Info">
|
<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-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-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-4 data-field field-name">Applic.RateRq</div>
|
||||||
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | appRate:playMatType:isUS:curPlayRec.applicRateUnit:false }}</div>
|
<ng-container *ngIf="isPlayingAgNavFile; else PARTNERATE">
|
||||||
<div class="ui-g-4 data-field field-name">FlowRateAp</div>
|
<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-8 data-field">{{curPlayRec.flowRateAp || 0 | flowRate:isUS }}</div>
|
||||||
<div class="ui-g-4 data-field field-name">FlowRateRq</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>
|
<div class="ui-g-8 data-field">{{curPlayRec.flowRateRq || 0 | flowRate:isUS }}</div>
|
||||||
@ -732,9 +742,9 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<div class="ui-g-4 data-field field-name">Flow Control</div>
|
<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-4 data-field field-name">Bm Pressure</div>
|
||||||
<div class="ui-g-8 data-field">{{curPlayRec.bmPressure | number:'1.1-1':'en'}} psi</div>
|
<div class="ui-g-8 data-field">{{curPlayRec.bmPressure | number:'1.1-1':'en'}} psi</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@ -773,8 +783,10 @@
|
|||||||
|
|
||||||
<div class="ui-g-4 data-field field-name">AutoSpr On/Off</div>
|
<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-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>
|
<ng-container *ngIf="isPlayingAgNavFile">
|
||||||
<div class="ui-g-8 data-field" *ngIf="playMatType === MatType.LIQUID">{{curPlayRec.pulsesPLiter | number:'1.0-0':'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>
|
||||||
</div>
|
</div>
|
||||||
</p-tabPanel>
|
</p-tabPanel>
|
||||||
<p-tabPanel i18n-header="@@met" header="MET">
|
<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-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-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-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>
|
<ng-container *ngIf="isPlayingAgNavFile">
|
||||||
<div class="ui-g-8 data-field">{{curPlayRec.lockedLine | lockline}}</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>
|
||||||
<div class="ui-g-4 data-field field-name">Wind Spd</div>
|
<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-8 data-field">{{curPlayRec.windSpd | number:'1.1-1':'en' }} knots</div>
|
||||||
<div class="ui-g-4 data-field field-name">Wind Dir</div>
|
<div class="ui-g-4 data-field field-name">Wind Dir</div>
|
||||||
@ -823,8 +837,10 @@
|
|||||||
</p-tabPanel>
|
</p-tabPanel>
|
||||||
<p-tabPanel i18n-header="@@summary" header="Summary">
|
<p-tabPanel i18n-header="@@summary" header="Summary">
|
||||||
<div class="ui-g ui-g-nopad output">
|
<div class="ui-g ui-g-nopad output">
|
||||||
<div class="ui-g-4 data-field field-name">AreaName</div>
|
<ng-container *ngIf="isPlayingAgNavFile">
|
||||||
<div class="ui-g-8 data-field">{{curPlayRec.areaName}}</div>
|
<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-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-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>
|
<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-4 data-field field-name">Pilot Name</div>
|
||||||
<div class="ui-g-8 data-field">{{curPlayRec.pilotName}}</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-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-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-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>
|
<div class="ui-g-4 data-field field-name">Mat Sprayed</div>
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import { IJob, Area, WayPoint, ITEM, BufferZone, RptOption, WeatherInfo, defWeat
|
|||||||
import * as jobActions from '../actions/job.actions';
|
import * as jobActions from '../actions/job.actions';
|
||||||
import { UpdateJobOps } 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 { LengthUnitPipe } from '@app/shared/pipes/length-unit.pipe';
|
||||||
import { JobService } from '@app/domain/services/job.service';
|
import { JobService } from '@app/domain/services/job.service';
|
||||||
import { ObstacleService } from '@app/domain/services/obstacle.service';
|
import { ObstacleService } from '@app/domain/services/obstacle.service';
|
||||||
@ -179,6 +179,13 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
private lastPlayUnit;
|
private lastPlayUnit;
|
||||||
// {<fileId/Name>: { <file meta object> }}, <file meta object> = { data: [], other fields }
|
// {<fileId/Name>: { <file meta object> }}, <file meta object> = { data: [], other fields }
|
||||||
filesDataSet = <any>{};
|
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;
|
playIdx: number = -1;
|
||||||
centerPlayPos: boolean = false;
|
centerPlayPos: boolean = false;
|
||||||
playMarker: any;
|
playMarker: any;
|
||||||
@ -230,6 +237,14 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
return this.job;
|
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) {
|
protected postUpdateDrawToolTips(type: DRAW) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case DRAW.BUFFER:
|
case DRAW.BUFFER:
|
||||||
@ -310,7 +325,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
private readonly weatherSvc: WeatherService,
|
private readonly weatherSvc: WeatherService,
|
||||||
private readonly ngZone: NgZone,
|
private readonly ngZone: NgZone,
|
||||||
protected cdRef: ChangeDetectorRef,
|
protected cdRef: ChangeDetectorRef,
|
||||||
|
|
||||||
) {
|
) {
|
||||||
super(cdRef);
|
super(cdRef);
|
||||||
|
|
||||||
@ -591,7 +605,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
label: globals.sprayZone, icon: '', command: () => {
|
label: globals.sprayZone, icon: '', command: () => {
|
||||||
this.confirmSvc.confirm({
|
this.confirmSvc.confirm({
|
||||||
header: SubTexts.textUpgradeSub,
|
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: () => {
|
accept: () => {
|
||||||
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
|
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.map && this.map.removeLayer(this.playMarker);
|
||||||
this.playMarker = null;
|
this.playMarker = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private findRefZone() {
|
private findRefZone() {
|
||||||
@ -2685,7 +2700,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
if (!file.meta) {
|
if (!file.meta) {
|
||||||
file.meta = { appRate: this.job.appRate, rateUnit: this.job.appRateUnit, hasQfile: false, useFC: false };
|
file.meta = { appRate: this.job.appRate, rateUnit: this.job.appRateUnit, hasQfile: false, useFC: false };
|
||||||
} else {
|
} 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;
|
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.selDataFiles = [];
|
||||||
this.dataFiles = [];
|
this.dataFiles = [];
|
||||||
this.filesDataSet = {};
|
this.filesDataSet = {};
|
||||||
|
// Reset loaded file pagination data
|
||||||
|
this.fileDataPagination.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updatePlayRecord(idx: number, forward: boolean) {
|
private updatePlayRecord(idx: number, forward: boolean) {
|
||||||
@ -2711,8 +2728,13 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
const newRec = new PlayRecord();
|
const newRec = new PlayRecord();
|
||||||
newRec.timeGPS = this.curPlayLoc.gpsTime;
|
newRec.timeGPS = this.curPlayLoc.gpsTime;
|
||||||
|
|
||||||
if (newRec.timeGPS)
|
if (newRec.timeGPS) {
|
||||||
newRec.timeLocal = DateUtils.msToTime(newRec.timeGPS * 1000, this.localTz);
|
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.lat = this.curPlayLoc.lat;
|
||||||
newRec.lon = this.curPlayLoc.lon;
|
newRec.lon = this.curPlayLoc.lon;
|
||||||
@ -2742,26 +2764,24 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
|
|
||||||
newRec.flowRateRq = this.curPlayLoc.lminReq;
|
newRec.flowRateRq = this.curPlayLoc.lminReq;
|
||||||
|
|
||||||
// If an FC used => assign the flowControl field w/ the value from the Qfile
|
newRec.flowControl = file.meta?.fcName && !file?.meta?.fcName.match(/none/i) ? file.meta.fcName : 'No FC';
|
||||||
if (file.meta.fcType && file.meta.fcType.trim().length && !file.meta.fcType.match(/none/i))
|
|
||||||
newRec.flowControl = file.meta.fcType;
|
|
||||||
|
|
||||||
if (this.curPlayLoc.sprayStat) {
|
if (this.curPlayLoc.sprayStat) {
|
||||||
newRec.flowRateAp = this.curPlayLoc.lminApp; // Apply for Liquid
|
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 && 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);
|
const uniRate = UnitUtils.toRateUnit(file.meta.appRate, file.rateUnit, false);
|
||||||
newRec.appRateAp = uniRate.value;
|
|
||||||
newRec.rateUnit = uniRate.unit; // Expected in Metrics
|
newRec.rateUnit = uniRate.unit; // Expected in Metrics
|
||||||
|
newRec.appRateAp = uniRate.value;
|
||||||
} else {
|
} else {
|
||||||
if (this.playMatType === MatType.LIQUID) {
|
if (this.playMatType === MatType.LIQUID) {
|
||||||
newRec.appRateAp = UnitUtils.appRateFromFlowRate(newRec.flowRateAp, this.curPlayLoc.swath, newRec.speed);
|
|
||||||
newRec.rateUnit = RateUnit.LPH;
|
newRec.rateUnit = RateUnit.LPH;
|
||||||
|
newRec.appRateAp = UnitUtils.appRateFromFlowRate(newRec.flowRateAp, this.curPlayLoc.swath, newRec.speed);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
newRec.appRateAp = this.curPlayLoc.lminApp;
|
|
||||||
newRec.rateUnit = RateUnit.KGPH;
|
newRec.rateUnit = RateUnit.KGPH;
|
||||||
|
newRec.appRateAp = this.curPlayLoc.lminApp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.lastPlayUnit = newRec.rateUnit;
|
this.lastPlayUnit = newRec.rateUnit;
|
||||||
@ -2771,25 +2791,36 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
|
|
||||||
newRec.bmPressure = this.curPlayLoc.psi || 0.0;
|
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.area = file.meta.sprCoverage[1]; // Current spray zone area size in metric, ha
|
||||||
|
|
||||||
newRec.swathWidth = this.curPlayLoc.swath;
|
newRec.swathWidth = this.curPlayLoc.swath;
|
||||||
newRec.sprOnLag = file.meta.sprOnLag || 0;
|
newRec.sprOnLag = file.meta?.sprOnLag || 0;
|
||||||
newRec.sprOffLag = file.meta.sprOffLag || 0;
|
newRec.sprOffLag = file.meta?.sprOffLag || 0;
|
||||||
newRec.pulsesPLiter = file.meta.pulsesPerLit;
|
newRec.pulsesPLiter = file.meta?.pulsesPerLit;
|
||||||
|
|
||||||
// For Output 3
|
// 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
|
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
|
// Planned/Target Application Rate
|
||||||
if ((file.meta && file.meta.appRate !== 0)) {
|
if (this.isPlayingAgNavFile) {
|
||||||
newRec.applicRate = file.meta.appRate;
|
if ((file.meta && !isNaN(file.meta?.appRate) && file.meta?.appRate !== 0)) {
|
||||||
newRec.applicRateUnit = file.rateUnit;
|
newRec.applicRate = file.meta.appRate;
|
||||||
} else {
|
newRec.applicRateUnit = file.rateUnit;
|
||||||
newRec.applicRate = this.job.appRate;
|
} 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.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);
|
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
|
// (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.overSprayed = newRec.mappedArea ? ((sprTot - newRec.mappedArea) / newRec.mappedArea) * 100 : 0;
|
||||||
newRec.pilotName = file.meta.operator;
|
newRec.pilotName = file.meta?.operator;
|
||||||
if (!newRec.pilotName && this.job.operator)
|
if (!newRec.pilotName && this.job.operator)
|
||||||
newRec.pilotName = this.job.operator.name;
|
newRec.pilotName = this.job.operator.name;
|
||||||
|
|
||||||
@ -2905,14 +2936,21 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
if (cb) cb();
|
if (cb) cb();
|
||||||
};
|
};
|
||||||
const fid = this.selDataFiles[nextFileIdx].fid;
|
const fid = this.selDataFiles[nextFileIdx].fid;
|
||||||
if (!this.filesDataSet[fid].loaded) {
|
|
||||||
this.jobSvc.getFilesData([fid]).subscribe(filesdata => {
|
// Initialize pagination tracking if not exists
|
||||||
if (filesdata.length) {
|
if (!this.fileDataPagination.has(fid)) {
|
||||||
this.filesDataSet[fid].data = filesdata[0].data;
|
this.fileDataPagination.set(fid, {
|
||||||
this.filesDataSet[fid].loaded = true;
|
hasMore: true,
|
||||||
}
|
startingAfter: null,
|
||||||
setNextFile(nextFileIdx, cb);
|
loading: false,
|
||||||
|
allLoaded: false
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagination = this.fileDataPagination.get(fid);
|
||||||
|
|
||||||
|
if (!this.filesDataSet[fid].loaded || !pagination.allLoaded) {
|
||||||
|
this.loadFileDataWithPagination(fid, () => setNextFile(nextFileIdx, cb));
|
||||||
} else {
|
} else {
|
||||||
setNextFile(nextFileIdx, cb);
|
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) {
|
private createLine(locs = [], isSpray: boolean) {
|
||||||
locs = locs || [];
|
locs = locs || [];
|
||||||
let line, ops;
|
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 = new PlayBack(this.atRecord.bind(this));
|
||||||
this.player.speed = this.playSpd;
|
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.playMatType = MatType.DRY;
|
||||||
this.playIdx = -1;
|
this.playIdx = -1;
|
||||||
this.totLnLength = 0;
|
this.totLnLength = 0;
|
||||||
@ -3204,6 +3316,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
|
|||||||
this.player.speed = e.value;
|
this.player.speed = e.value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onTzChange(e) {
|
onTzChange(e) {
|
||||||
if (!e) return;
|
if (!e) return;
|
||||||
if (this.curPlayRec) {
|
if (this.curPlayRec) {
|
||||||
|
|||||||
@ -33,9 +33,10 @@ import { JobMgtComponent } from './job-mgt.component';
|
|||||||
import { AppSharedModule } from '../shared/app-shared.module';
|
import { AppSharedModule } from '../shared/app-shared.module';
|
||||||
import { JobListComponent } from './job-list/job-list.component';
|
import { JobListComponent } from './job-list/job-list.component';
|
||||||
import { JobEditComponent } from './job-edit/job-edit.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 { JobMapEditComponent } from './job-map-edit/job-map-edit.component';
|
||||||
import { JobsRoutingModule } from './job-routing.module';
|
import { JobsRoutingModule } from './job-routing.module';
|
||||||
import {InvoicesModule} from '@app/invoices/invoices.module';
|
import { InvoicesModule } from '@app/invoices/invoices.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@ -50,7 +51,7 @@ import {InvoicesModule} from '@app/invoices/invoices.module';
|
|||||||
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
|
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
|
||||||
EffectsModule.forFeature([JobEffects]), InvoicesModule,
|
EffectsModule.forFeature([JobEffects]), InvoicesModule,
|
||||||
],
|
],
|
||||||
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobMapEditComponent],
|
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
|
||||||
providers: [DatePipe],
|
providers: [DatePipe],
|
||||||
schemas: [
|
schemas: [
|
||||||
CUSTOM_ELEMENTS_SCHEMA
|
CUSTOM_ELEMENTS_SCHEMA
|
||||||
|
|||||||
@ -10,26 +10,60 @@ import { IUIJob } from '../models/job.model';
|
|||||||
|
|
||||||
export const getJobsState = createFeatureSelector<fromJobs.State>(fromJobs.FEATURE_KEY);
|
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,
|
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
|
fromJobs.getSelectedId
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getIsLoading = createSelector(
|
export const getIsLoading = createSelector(
|
||||||
getJobsState,
|
getJobsStateOrInitial,
|
||||||
fromJobs.getIsLoading
|
fromJobs.getIsLoading
|
||||||
);
|
);
|
||||||
export const getIsLoaded = createSelector(
|
export const getIsLoaded = createSelector(
|
||||||
getJobsState,
|
getJobsStateOrInitial,
|
||||||
fromJobs.getIsLoaded
|
fromJobs.getIsLoaded
|
||||||
);
|
);
|
||||||
|
|
||||||
export const {
|
// Use safe wrapper with adapter selectors to prevent undefined access
|
||||||
selectIds: getJobsIds,
|
const entitySelectors = fromJobs.adapter.getSelectors(getJobsStateOrInitial);
|
||||||
selectEntities: getJobEntities,
|
|
||||||
selectAll: getAllJobs,
|
export const getJobsIds = createSelector(
|
||||||
selectTotal: getTotalJobs,
|
entitySelectors.selectIds,
|
||||||
} = fromJobs.adapter.getSelectors(getJobsState);
|
(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(
|
export const getSelectedJob = createSelector(
|
||||||
getJobEntities,
|
getJobEntities,
|
||||||
|
|||||||
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
/* Partner Customer List Component Styles */
|
||||||
@ -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>
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() { }
|
||||||
|
}
|
||||||
@ -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 { }
|
||||||
@ -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 { }
|
||||||
@ -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)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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 }>()
|
||||||
|
);
|
||||||
158
Development/client/src/app/partners/effects/partner.effects.ts
Normal file
158
Development/client/src/app/partners/effects/partner.effects.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
67
Development/client/src/app/partners/models/partner.model.ts
Normal file
67
Development/client/src/app/partners/models/partner.model.ts
Normal 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')
|
||||||
|
}
|
||||||
|
];
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user