Compare commits

...

22 Commits

Author SHA1 Message Date
6ff07c9d74 additional integration tests
All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m13s
Server Tests / Jest – Integration Tests (push) Successful in 1m43s
2026-05-08 14:51:04 -04:00
83a0aa1109 fixed a regression with the playback panel
All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m9s
Server Tests / Jest – Integration Tests (push) Successful in 1m31s
2026-05-08 14:34:56 -04:00
cdf6fb7b7e gridline fix for edge and feature buffers
All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m11s
Server Tests / Jest – Integration Tests (push) Successful in 1m35s
2026-05-08 13:18:28 -04:00
1e7977cdba fix for failing integration tests
All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m10s
Server Tests / Jest – Integration Tests (push) Successful in 1m33s
2026-05-08 12:02:19 -04:00
ad7db99f07 all Data Export API changes until may 8 2026
Some checks failed
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m29s
Server Tests / Jest – Integration Tests (push) Failing after 1m57s
2026-05-08 11:48:21 -04:00
ea46cbeb02 output mocha's test results and coverage report as artifacts
All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m12s
Server Tests / Jest – Integration Tests (push) Successful in 1m35s
2026-04-29 13:41:28 -04:00
a6d75af447 downgrade artifacts action
All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m12s
Server Tests / Jest – Integration Tests (push) Successful in 1m57s
2026-04-29 13:35:18 -04:00
0a2b93a8c7 output the test results and coverage report as artifacts
Some checks failed
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 50s
Server Tests / Jest – Integration Tests (push) Failing after 1m25s
2026-04-29 13:30:25 -04:00
4dad304f86 fix bootstrapping of test data
Some checks failed
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 48s
Server Tests / Jest – Integration Tests (push) Failing after 1m20s
2026-04-29 13:24:33 -04:00
365292dac1 install deps on user basis instead of globally
Some checks failed
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 48s
Server Tests / Jest – Integration Tests (push) Failing after 1m19s
2026-04-29 13:21:01 -04:00
7b63f9164d fix for missing libssl1.1 package (required by mongodb 4.4)
Some checks failed
Server Tests / Jest – Integration Tests (push) Failing after 19s
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 47s
2026-04-29 13:18:06 -04:00
b46c995b3d fix for build error running itests
Some checks failed
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 49s
Server Tests / Jest – Integration Tests (push) Failing after 1m17s
2026-04-29 13:13:42 -04:00
f6700793e8 potential fix for build error while installing dependencies for itesting
Some checks failed
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 47s
Server Tests / Jest – Integration Tests (push) Failing after 1m20s
2026-04-29 11:06:23 -04:00
6fff4011ad added integration test cases for all server controllers where applicable, added mock data, updated run-tests.yaml to run integration tests upon committing, added coverage check for controllers (check_controller_contract.js)
Some checks failed
Server Tests / Jest – Integration Tests (push) Failing after 55s
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m20s
2026-04-29 09:40:51 -04:00
9303274349 all Data Export API changes from April 28 2026
Some checks are pending
Server Tests / Mocha – Unit & Utility Tests (push) Waiting to run
2026-04-28 15:44:46 -04:00
df31b2080d -(#3013) Data Export - Implement Data Export API BE (Cont.)
All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 42s
+ Added public data export API enhancements, tests, and customer documentation
  + Extended /api/v1 data export endpoints with richer session, records, area, and async export output
  + Added confirmed/fallback report values, client metadata, mapped area, over-spray, volume/apprate (string) units, and weather blocks
  + Normalized flowController to "No FC" and align record field names with playback output
  + Converted record wind speed output to knots, add Fligh Mater only record/export fields behind fm=true, and persist fm on export jobs
  + Added export status/area constants, HTTP 202 support, route-level API docs, and per-account export rate limiting support
  + Added comprehensive endpoint, format, and verification test coverage plus test-suite README
  + Added customer-facing data export design, integration, rate-limit, and documentation index guides
  + Updated README/DLQ docs and related documentation links to current HTTPS dashboard paths
2026-04-24 09:05:55 -04:00
d99ffa9b40 fix cache error in build
All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m0s
2026-04-23 15:03:54 -04:00
35dad9bfff add workflow to run tests on any branch (for now just runs existing mocha tests, but later will run integration tests against controllers as well)
Some checks failed
Server Tests / Mocha – Unit & Utility Tests (push) Failing after 1m37s
2026-04-23 14:58:54 -04:00
fbfa44ba97 all changes from April 23 2026 2026-04-23 14:24:54 -04:00
40e405ac57 Merge remote-tracking branch 'origin/development' into feature/data-export-api 2026-04-23 08:58:50 -04:00
fbe71daa86 updated location of error-handler package 2026-04-23 08:58:00 -04:00
9ea0a43ae7 copy of data-export-api branch as of April 22 2026 2026-04-22 15:12:27 -04:00
237 changed files with 93450 additions and 496 deletions

View File

@ -0,0 +1,218 @@
# Gitea Actions Server Tests
#
# Two jobs run on every push to any branch:
# 1. jest-integration Jest integration tests using an in-memory MongoDB
# instance (mongodb-memory-server). No external database
# or repository secrets are required.
# 2. mocha-unit Existing Mocha/Chai unit tests in tests/ and tests/utils/
#
# Optional repository secret:
# TOKEN_SECRET JWT secret used by the server's auth helpers.
# A safe default is used automatically when absent.
name: Server Tests
on:
push:
branches:
- '**'
# ─────────────────────────────────────────────────────────────────────────────
jobs:
# ══════════════════════════════════════════════════════════════════════════
# Job 1: Jest integration tests (in-memory MongoDB via mongodb-memory-server)
# ══════════════════════════════════════════════════════════════════════════
jest-integration:
name: Jest Integration Tests
runs-on: self-hosted
defaults:
run:
working-directory: Development/server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: Development/server/package-lock.json
- name: Prepare MongoDB runtime compatibility libraries
run: |
set -e
if command -v ldconfig >/dev/null 2>&1 && ldconfig -p | grep -q 'libcrypto.so.1.1'; then
echo "OpenSSL 1.1 compatibility already present"
exit 0
fi
LIBSSL_DIR="${RUNNER_TEMP:-/tmp}/libssl11"
DEB_PATH="${RUNNER_TEMP:-/tmp}/libssl1.1.deb"
mkdir -p "$LIBSSL_DIR"
if [ ! -f "$DEB_PATH" ]; then
curl -fsSL \
-o "$DEB_PATH" \
"http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2.24_amd64.deb"
fi
dpkg-deb -x "$DEB_PATH" "$LIBSSL_DIR"
LIB_DIR="$LIBSSL_DIR/usr/lib/x86_64-linux-gnu"
if [ ! -f "$LIB_DIR/libcrypto.so.1.1" ]; then
echo "libcrypto.so.1.1 was not found after extracting $DEB_PATH"
exit 1
fi
echo "LD_LIBRARY_PATH=$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV"
echo "Prepared OpenSSL 1.1 compatibility libraries in $LIB_DIR"
- name: Install dependencies
env:
NPM_CONFIG_CACHE: /tmp/npm-cache
run: npm ci --prefer-offline
- name: Write test environment file
run: |
cat > environment.test.env <<'EOF'
NODE_ENV=test
TOKEN_SECRET=${{ secrets.TOKEN_SECRET || 'ci-test-secret-not-for-production' }}
PRODUCTION=false
NO_EMAIL_MODE=true
ENABLE_SUBSCRIPTION=false
INV_IMG_VIR_DIR=/tmp/inv-img
INV_UPLOAD_DIR=/tmp/inv-upload
INV_MAX_UPLOAD_SIZE_MB=5
PAGINATION_DEFAULT_LIMIT=1000
PAGINATION_MAX_LIMIT=14000
STRIPE_SEC_KEY=sk_test_placeholder
STRIPE_API_VERSION=2022-11-15
EOF
- name: Enforce controller integration contract
env:
TEST_ENV_FILE: ./environment.test.env
NODE_ENV: test
MONGOMS_VERSION: 4.4.28
run: npm run test:integration:contract
- name: Run Jest integration tests
env:
TEST_ENV_FILE: ./environment.test.env
NODE_ENV: test
MONGOMS_VERSION: 4.4.28
run: |
set -o pipefail
npm run test:integration:jest -- --ci --coverage 2>&1 | tee jest-integration.log
- name: Upload Jest results
if: always()
uses: actions/upload-artifact@v3
with:
name: jest-integration-results
path: Development/server/jest-integration.log
- name: Upload Jest coverage
if: always()
uses: actions/upload-artifact@v3
with:
name: jest-integration-coverage
path: Development/server/coverage/integration
# ══════════════════════════════════════════════════════════════════════════
# Job 2: Mocha/Chai tests tests/ and tests/utils/
# These tests are self-contained unit tests that do not require MongoDB.
# ══════════════════════════════════════════════════════════════════════════
mocha-unit:
name: Mocha Unit & Utility Tests
runs-on: self-hosted
defaults:
run:
working-directory: Development/server
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
# Redirect the npm cache away from /root/.npm (root-owned on this runner)
# to a writable temp directory. Also clears any stale node_modules left
# by a previous run that the root-owned cache could not clean up.
- name: Fix npm cache permissions
run: |
mkdir -p /tmp/npm-cache
rm -rf node_modules || true
- name: Install dependencies
env:
NPM_CONFIG_CACHE: /tmp/npm-cache
run: npm ci
# Write a minimal env file so dotenv does not error on startup.
# These tests do not hit the database; DB_* values are placeholders.
- name: Write test environment file
run: |
cat > environment.env <<'EOF'
NODE_ENV=test
DB_HOSTS=127.0.0.1:27017
DB_NAME=agmission_test
DB_USR=
DB_PWD=
TOKEN_SECRET=${{ secrets.TOKEN_SECRET || 'ci-test-secret-not-for-production' }}
PRODUCTION=false
NO_EMAIL_MODE=true
ENABLE_SUBSCRIPTION=false
STRIPE_SEC_KEY=sk_test_placeholder
STRIPE_API_VERSION=2022-11-15
EOF
# Run all top-level test_*.js files (skips integration/ and satloc/ sub-dirs
# which are covered by the jest-integration job or need live connections).
# Coverage is aggregated across this run and tests/utils/.
- name: Run Mocha unit tests with coverage
run: |
set -o pipefail
rm -rf .nyc_output coverage/mocha mocha-unit.log
npx nyc \
--silent \
--temp-dir .nyc_output \
npx mocha --exit --timeout 120000 \
--require tests/setup.js \
'tests/test_*.js' 2>&1 | tee mocha-unit.log
npx nyc \
--silent \
--temp-dir .nyc_output \
--no-clean \
npm run test:utils 2>&1 | tee -a mocha-unit.log
npx nyc report \
--temp-dir .nyc_output \
--report-dir coverage/mocha \
--reporter=text \
--reporter=lcov \
--reporter=json-summary | tee -a mocha-unit.log
- name: Upload Mocha results
if: always()
uses: actions/upload-artifact@v3
with:
name: mocha-unit-results
path: Development/server/mocha-unit.log
- name: Upload Mocha coverage
if: always()
uses: actions/upload-artifact@v3
with:
name: mocha-unit-coverage
path: Development/server/coverage/mocha

View File

@ -52,6 +52,16 @@
"glob": "**/*",
"input": "node_modules/leaflet/dist/images",
"output": "/assets/images"
},
{
"glob": "CHANGELOG.md",
"input": "docs",
"output": "/assets/docs"
},
{
"glob": "**/*",
"input": "docs/releases",
"output": "/assets/docs/releases"
}
],
"styles": [

View File

@ -0,0 +1,149 @@
# BrowserCacheService
**File**: `src/app/domain/services/browser-cache.service.ts`
A generic, injectable Angular service that provides a typed read/write/invalidate API over the browser's [Cache Storage API](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage). Intended as a shared foundation for any feature that wants to cache HTTP responses across navigation events without a Service Worker.
---
## Why Cache Storage?
| Mechanism | Survives navigation | Survives page reload | Configurable TTL | Storage limit |
|---|---|---|---|---|
| Component state | ✗ | ✗ | — | Memory |
| NgRx store | ✓ (same tab) | ✗ | — | Memory |
| `sessionStorage` | ✓ | ✗ | Manual | ~5 MB |
| `localStorage` | ✓ | ✓ | Manual | ~5 MB |
| **Cache Storage** | ✓ | ✓ | ✓ (per entry) | Quota-managed |
Cache Storage was chosen because it:
- Is available in all modern browsers (Chrome 40+, Firefox 44+, Safari 11.1+)
- Stores structured data alongside an expiry timestamp without size pressure
- Is already used by Service Workers and the browser's native HTTP cache, so quota management is handled by the browser
- Falls back gracefully (service becomes a no-op) when unavailable
---
## API
```typescript
@Injectable({ providedIn: 'root' })
class BrowserCacheService {
get<T>(cacheName: string, key: string, maxAgeMs?: number): Observable<T | null>
put<T>(cacheName: string, key: string, data: T): void
invalidate(cacheName: string): void
}
```
### `get<T>(cacheName, key, maxAgeMs?)`
Returns an `Observable` that emits the cached value (`T`) or `null` when:
- The Cache Storage API is unavailable (e.g. older browser, unit test environment)
- No entry exists for the given `cacheName` + `key` combination
- The entry is older than `maxAgeMs` (default: `60 000` ms / 1 minute)
Errors from the Cache API are caught and converted to `null` — they never propagate to the caller.
### `put<T>(cacheName, key, data)`
Stores `data` in the named cache bucket under `key`. A `cachedAt` timestamp is embedded alongside the data so staleness can be checked on the next `get`.
Fire-and-forget: errors are silently swallowed.
### `invalidate(cacheName)`
Deletes the **entire** Cache Storage bucket for `cacheName`. This removes all entries for that feature in one call.
Fire-and-forget: errors are silently swallowed.
---
## Cache key format
Internally, entries are stored under a pseudo-URL:
```
/browser-cache/<encodedCacheName>?<key>
```
This keeps entries within a single Cache Storage bucket readable via browser DevTools (Application → Cache Storage).
---
## Adding a new feature cache
Create a typed facade service that delegates to `BrowserCacheService`. This keeps the cache name and TTL in one place and gives callers a clean domain API.
```typescript
// src/app/domain/services/customer-cache.service.ts
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BrowserCacheService } from './browser-cache.service';
import { ICustomer } from '../../customers/models/customer.model';
const CACHE_NAME = 'agm-customer-list-v1';
const MAX_AGE_MS = 60_000; // 1 minute
@Injectable({ providedIn: 'root' })
export class CustomerCacheService {
constructor(private readonly browserCache: BrowserCacheService) {}
get(queryParams: string): Observable<ICustomer[] | null> {
return this.browserCache.get<ICustomer[]>(CACHE_NAME, queryParams, MAX_AGE_MS);
}
put(queryParams: string, data: ICustomer[]): void {
this.browserCache.put(CACHE_NAME, queryParams, data);
}
invalidate(): void {
this.browserCache.invalidate(CACHE_NAME);
}
}
```
Then, in the corresponding service:
```typescript
// In CustomerService.loadCustomers():
const cacheKey = params.toString();
return this.customerCache.get(cacheKey).pipe(
switchMap(cached => {
if (cached !== null) return of(cached);
return this.http.get<ICustomer[]>(this.url, { params }).pipe(
tap(data => this.customerCache.put(cacheKey, data))
);
})
);
```
And in the effects, call `this.customerCache.invalidate()` after any create / update / delete action succeeds.
---
## Existing implementations
| Feature | Facade | Cache name | TTL |
|---|---|---|---|
| Job list | `JobCacheService` | `agm-jobs-list-v1` | 60 s |
---
## Versioning the cache name
Append a version suffix (e.g. `-v1`, `-v2`) to `cacheName` whenever the shape of the stored data changes. The old bucket will be orphaned in the browser until the browser's quota manager evicts it, or you can explicitly delete the old name during app initialisation.
---
## Browser DevTools
Cached entries are visible under:
**Chrome DevTools** → Application tab → Cache Storage → `agm-jobs-list-v1`

36904
Development/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,12 +4,19 @@
"license": "COMMERCIAL",
"angular-cli": {},
"scripts": {
"generate-release-manifest": "node scripts/generate-release-manifest.js",
"ng": "ng",
"prestart-cert": "npm run generate-release-manifest",
"start-cert": "ng serve --ssl true --sslKey ~/ssl/server.key --sslCert ~/ssl/server.crt --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
"prestart": "npm run generate-release-manifest",
"start": "CHOKIDAR_USEPOLLING=true ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
"prestart-es": "npm run generate-release-manifest",
"start-es": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=es",
"prestart-pt": "npm run generate-release-manifest",
"start-pt": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=pt",
"prebuild": "npm run generate-release-manifest",
"build": "ng build",
"prebuild-prep": "npm run generate-release-manifest",
"build-prep": "ng build --aot --localize=false",
"test": "ng test",
"lint": "ng lint",
@ -22,7 +29,9 @@
"sync-i18n": "npm run build-prep && npm run i18n-extract && npm run i18n-merge",
"sync-i18n-w": "npm run build-prep && npm run i18n-extract-w && npm run i18n-merge-w",
"pre-translate": "npx translation start && npm run sync-i18n && npx translation translate && npx translation cleanup",
"prebuild-prod": "npm run generate-release-manifest",
"build-prod": "ng build --prod --localize && cp -R dist/en/* dist/ && rm -R dist/en",
"prebuild-prod-window": "npm run generate-release-manifest",
"build-prod-window": "ng build --prod --localize && xcopy /E /Y dist\\en\\* dist\\ && rmdir /S /Q dist\\en"
},
"private": true,
@ -58,8 +67,13 @@
"geodesy": "^1.1.3",
"intl": "^1.2.5",
"leaflet": "^1.9.4",
"leaflet-river": "^1.0.1",
"marked": "^1.2.9",
"mermaid": "^8.14.0",
"ngrx-store-localstorage": "^9.0.0",
"ngx-captcha": "^8.0.1",
"ngx-markdown": "^9.1.1",
"polygon-clipping": "^0.15.7",
"primeng-lts": "^9.2.8",
"quill": "^1.3.7",
"rbush": "^3.0.1",

View File

@ -0,0 +1,36 @@
const fs = require('fs');
const path = require('path');
const releasesDir = path.resolve(__dirname, '..', 'docs', 'releases');
const manifestPath = path.join(releasesDir, 'releases-manifest.json');
function toTitle(fileName) {
return path.basename(fileName, path.extname(fileName));
}
function getReleaseEntries() {
if (!fs.existsSync(releasesDir)) {
return [];
}
return fs.readdirSync(releasesDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === '.md')
.map((entry) => entry.name)
.sort((left, right) => right.localeCompare(left, undefined, { numeric: true, sensitivity: 'base' }))
.map((fileName) => ({
fileName,
title: toTitle(fileName)
}));
}
function main() {
fs.mkdirSync(releasesDir, { recursive: true });
const manifest = {
revisions: getReleaseEntries()
};
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
}
main();

View File

@ -22,6 +22,8 @@
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value">
</div>
<p-dropdown *ngIf="col.field === ACTIVE" [options]="activeOpts" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
<p-dropdown *ngIf="col.field === KIND" [options]="kindOpts" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
<span *ngSwitchDefault></span>
</th>
</tr>

View File

@ -7,7 +7,7 @@ import { User } from '../models/user.model';
import * as fromUsers from '../reducers';
import * as userActions from '../actions/account.actions';
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global';
import { RoleIds, Roles, globals, OperationalStatus, Labels } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component';
import { Utils } from '@app/shared/utils';
@ -21,6 +21,15 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
readonly resolveFieldData = Utils.resolveFieldData;
readonly KIND = 'kind';
readonly ACTIVE = OperationalStatus.ACTIVE;
activeOpts = [
{ label: globals.all, value: null },
{ label: globals.active, value: true },
{ label: globals.notActive, value: false },
];
kindOpts = [
{ label: globals.all, value: null },
...Object.entries(Roles).map(([value, label]) => ({ label: label as string, value }))
];
accounts: Array<User>;
isLoading: boolean;
currAcc: User;

View File

@ -69,6 +69,16 @@ const routes: Routes = [
loadChildren: () => import('./tools/tools.module').then(m => m.ToolsModule),
runGuardsAndResolvers: 'always',
},
{
path: 'dlq',
loadChildren: () => import('./tools/dlq-monitor/dlq-monitor.module').then(m => m.DlqMonitorModule),
runGuardsAndResolvers: 'always',
},
{
path: 'dealers',
loadChildren: () => import('./tools/dealers/dealers.module').then(m => m.DealersModule),
runGuardsAndResolvers: 'always',
},
{
path: 'track',
loadChildren: () => import('./track/track.module').then(m => m.TrackModule),
@ -90,6 +100,21 @@ const routes: Routes = [
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
runGuardsAndResolvers: 'always'
},
{
path: 'api-keys',
loadChildren: () => import('./settings/api-keys/api-keys.module').then(m => m.ApiKeysModule),
runGuardsAndResolvers: 'always'
},
{
path: 'release-notes',
loadChildren: () => import('./release-notes/release-notes.module').then(m => m.ReleaseNotesModule),
runGuardsAndResolvers: 'always'
},
{
path: 'changelog',
redirectTo: 'release-notes',
pathMatch: 'full'
},
],
},
{

View File

@ -27,7 +27,16 @@
<app-topbar></app-topbar>
<!-- Mobile-only left-edge tab to open/close the navigation panel -->
<button id="mobile-menu-tab" (click)="onMenuButtonClick($event)" aria-label="Toggle navigation">
<i class="material-icons">chevron_right</i>
</button>
<div class="layout-menu" [ngClass]="{'layout-menu-dark':darkMenu}" (click)="onMenuClick($event)">
<div class="menu-user-info" *ngIf="user$ | async as user">
<app-inline-profile [user]="user" [expiryWarning]="expiryWarning$ | async"
(navigateToSubscription)="onNavigateToManageSubscription()"></app-inline-profile>
</div>
<app-menu></app-menu>
</div>

View File

@ -39,8 +39,18 @@ export class AppMenuComponent implements OnInit {
const mItems: MenuItem[] = [
{ id: 'dashboard', label: $localize`:@@dashboard:Dashboard`, icon: 'dashboard', routerLink: ['/home'] },
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
{ id: 'dealers', label: $localize`:@@dealers:Dealers`, icon: 'store', routerLink: ['/dealers'] },
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
{
id: 'tools',
label: $localize`:@@tools:Tools`, icon: 'extension',
routerLink: ['/tools'],
items: [
{ id: 'api-keys', label: $localize`:@@apiKeys:API Keys`, icon: 'vpn_key', routerLink: ['/api-keys'] },
{ id: 'dlq-monitor', label: $localize`:@@dlqMonitor:DLQ Monitor`, icon: 'bug_report', routerLink: ['/dlq'] }
]
},
{
id: 'settings',
label: $localize`:@@settings:Settings`, icon: 'settings',
@ -49,6 +59,7 @@ export class AppMenuComponent implements OnInit {
{ id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] }
]
},
{ id: 'release-notes', label: $localize`:@@releaseNotes:Release Notes`, icon: 'history', routerLink: ['/release-notes'] },
];
this.model = mItems;
}
@ -65,12 +76,7 @@ export class AppMenuComponent implements OnInit {
{
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'
}]
items: this.buildHelpMenuItems()
}
];
this.model = mItems;
@ -113,14 +119,26 @@ export class AppMenuComponent implements OnInit {
{
id: 'Help',
label: $localize`:@@help:Help`, icon: 'help_outline',
items: [{
items: this.buildHelpMenuItems()
}
)
}
private buildHelpMenuItems(): MenuItem[] {
return [
{
id: 'release-notes',
label: $localize`:@@releaseNotes:Release Notes`,
icon: 'history',
routerLink: ['/release-notes']
},
{
label: $localize`:@@trainingVideos:Training Videos`,
icon: 'video_library',
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
target: '_blank'
}]
}
)
];
}
private addOnlyTrackingItems(mItems: MenuItem[]) {
@ -209,7 +227,10 @@ export class AppMenuComponent implements OnInit {
items: [
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] }
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] },
...( this.authSvc.hasRole([RoleIds.APP])
? [{ id: 'api-keys', label: $localize`:@@apiKeys:API Keys`, icon: 'vpn_key', routerLink: ['/api-keys'] }]
: [] )
]
}
);

View File

@ -8,15 +8,18 @@
.account-summary-info .account-username {
margin-right: 0.5em;
text-align:center;
}
.account-summary-info .account-type {
margin-right: 0.5em;
font-style: italic;
opacity: 0.85;
text-align:center;
}
.account-summary-info .account-contact {
color: #ffd700;
opacity: 0.9;
text-align:center;
}

View File

@ -4,6 +4,7 @@ import { Client } from "../models/client.model";
export const FETCH = '[CLIENTS] Fetch clients';
export class Fetch implements Action {
type: typeof FETCH = FETCH;
constructor(readonly payload?: { filters?: string; useCache?: boolean }) { }
}
export const FETCH_SUCCESS = '[CLIENTS] Fetch clients success';

View File

@ -4,3 +4,87 @@ Ref:https://stackoverflow.com/questions/48675497/how-to-disable-the-option-to-de
tr.ui-state-highlight {
pointer-events: none;
}
.cache-ttl-caption {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
}
.cache-ttl-caption-title {
flex: 1 1 auto;
min-width: 0;
}
.cache-ttl-caption-controls {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 0 0 auto;
white-space: nowrap;
text-align: right;
padding-left: 8px;
}
.cache-ttl-help {
position: relative;
display: inline-flex;
vertical-align: middle;
margin-left: 6px;
outline: none;
}
.cache-ttl-help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
font-weight: bold;
cursor: help;
color: #fff;
background: transparent;
}
.cache-ttl-help-text {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 220px;
white-space: normal;
padding: 8px 10px;
border-radius: 4px;
background: #323232;
color: #fff;
text-align: left;
line-height: 1.35;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
opacity: 0;
visibility: hidden;
pointer-events: none;
z-index: 1000;
transition: opacity 0.15s ease;
}
.cache-ttl-help:hover .cache-ttl-help-text,
.cache-ttl-help:focus .cache-ttl-help-text,
.cache-ttl-help:focus-within .cache-ttl-help-text {
opacity: 1;
visibility: visible;
}
@media (max-width: 640px) {
.cache-ttl-caption-title,
.cache-ttl-caption-controls {
width: auto;
float: none;
}
.cache-ttl-caption-controls {
padding-left: 4px;
}
}

View File

@ -1,9 +1,27 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card">
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
<p-accordionTab i18n-header="@@searchClients" header="Search Clients" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
<agm-dynamic-filter [filterDefinitions]="clientFilterDefinitions" [locale]="locale" stateKey="client-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
</p-accordionTab>
</p-accordion>
<p-table #dt [value]="clients" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[15,30,50]" [alwaysShowPaginator]="false" [(selection)]="currClient" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="cltb-ops" [responsive]="true">
<ng-template pTemplate="caption">
<span class="table-caption-1" i18n="@@clientList">Client List</span>
<div class="ui-g ui-g-nopad cache-ttl-caption">
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-title">
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@clientList">Client List</span>
</div>
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-controls">
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
(blur)="updateCacheTtl()" style="width: 3.5rem;">
<span class="cache-ttl-help" tabindex="0">
<span class="cache-ttl-help-icon">?</span>
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
</span>
</div>
</div>
</ng-template>
<ng-template pTemplate="header" let-columns>
<tr>
@ -16,6 +34,10 @@
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
</div>
<div class="input-with-icon" *ngIf="col.field === 'address'">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span>
</th>
</tr>

View File

@ -11,6 +11,9 @@ import { RoleIds, globals } from '../../shared/global';
import { JobService } from '../../domain/services/job.service';
import { Utils } from 'src/app/shared/utils';
import { BaseComp } from 'src/app/shared/base/base.component';
import { ClientCacheService } from '@app/domain/services/client-cache.service';
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
@Component({
@ -29,6 +32,20 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
cols: any[];
loading$ = this.store.select(fromClients.isLoading);
searchAccordionOpen = sessionStorage.getItem('client-list-accordion') === 'true';
private lastFiltersQuery: Record<string, any> | undefined;
private useCacheOnReturn = false;
cacheTtlSeconds: number;
clientFilterDefinitions: FilterDefinition[] = [
{ key: 'name', label: globals.name, dataType: 'text' },
{ key: 'username', label: globals.userName, dataType: 'text' },
{ key: 'email', label: globals.email, dataType: 'text' },
{ key: 'phone', label: globals.phone + ' ' + $localize`:@@Num:N°`, dataType: 'text' },
{ key: 'contact', label: globals.contact, dataType: 'text' },
{ key: 'address', label: globals.address, dataType: 'text' },
];
get canWrite(): boolean {
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]);
}
@ -36,9 +53,11 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
constructor(
private readonly route: ActivatedRoute,
private readonly jobService: JobService,
private readonly clientCache: ClientCacheService,
private readonly listReturnCache: ListReturnCacheService,
) {
super();
this.cacheTtlSeconds = Math.round(this.clientCache.getTtlMs() / 1000);
}
ngOnInit() {
@ -58,7 +77,51 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
this.currClient = client;
}));
this.store.dispatch(new clientActions.Fetch());
this.useCacheOnReturn = this.listReturnCache.startVisit('clients');
const savedFilters = sessionStorage.getItem('client-list-last-filters');
if (savedFilters) {
try {
this.lastFiltersQuery = JSON.parse(savedFilters);
} catch (_err) {
this.lastFiltersQuery = undefined;
}
}
this.store.dispatch(savedFilters
? new clientActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
: new clientActions.Fetch({ useCache: this.useCacheOnReturn })
);
}
onAccordionToggle(expanded: boolean) {
sessionStorage.setItem('client-list-accordion', String(expanded));
}
updateCacheTtl(): void {
const ttlMs = this.clientCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
}
onFiltersSubmit(event: FilterChangeEvent) {
const q = { ...event.query };
const filtersStr = JSON.stringify(q);
const prevFilters = sessionStorage.getItem('client-list-last-filters');
if (filtersStr !== prevFilters) {
this.clientCache.invalidate();
this.useCacheOnReturn = false;
}
this.lastFiltersQuery = q;
sessionStorage.setItem('client-list-last-filters', filtersStr);
this.store.dispatch(new clientActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
}
reloadClients() {
this.clientCache.invalidate();
this.useCacheOnReturn = false;
if (this.lastFiltersQuery) {
this.store.dispatch(new clientActions.Fetch({ filters: JSON.stringify(this.lastFiltersQuery), useCache: false }));
} else {
this.store.dispatch(new clientActions.Fetch({ useCache: false }));
}
}
onRowSelect(event) {
@ -74,6 +137,7 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
}
editClient() {
this.listReturnCache.markPending('clients');
this.router.navigate(['client', this.currClient._id], { relativeTo: this.route });
}

View File

@ -13,6 +13,7 @@ import { DropdownModule } from 'primeng/dropdown';
import { TableModule } from 'primeng/table';
import { ToastModule } from 'primeng/toast';
import { AccordionModule } from 'primeng/accordion';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
@ -28,7 +29,7 @@ import { AppSharedModule } from '../shared/app-shared.module';
@NgModule({
imports: [
CommonModule, TableModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule, InputTextModule,
CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AppSharedModule,
CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AccordionModule, AppSharedModule,
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
EffectsModule.forFeature([ClientEffects]),
ClientsRoutingModule

View File

@ -10,6 +10,7 @@ import { ClientService } from '@app/domain/services/client.service';
import { AuthService } from '@app/domain/services/auth.service';
import { AppMessageService } from '@app/shared/app-message.service';
import { globals } from '@app/shared/global';
import { ClientCacheService } from '@app/domain/services/client-cache.service';
@Injectable()
export class ClientEffects {
@ -17,15 +18,20 @@ export class ClientEffects {
private readonly actions$: Actions,
private readonly clientSvc: ClientService,
private readonly authSvc: AuthService,
private readonly msgSvc: AppMessageService
private readonly msgSvc: AppMessageService,
private readonly clientCache: ClientCacheService
) {
}
@Effect()
loadClients$: Observable<Action> = this.actions$.pipe(
ofType<clientActions.Fetch>(clientActions.FETCH),
switchMap(() =>
this.clientSvc.loadClients({ byPuid: this.authSvc.user.parent }).pipe(
switchMap(({ payload }) =>
this.clientSvc.loadClients({
byPuid: this.authSvc.user.parent,
useCache: payload?.useCache,
...(payload?.filters ? { filters: payload.filters } : {})
}).pipe(
map(clients => new clientActions.FetchSuccess(clients)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients));
@ -40,7 +46,10 @@ export class ClientEffects {
ofType<clientActions.Create>(clientActions.CREATE),
switchMap(({ payload }) =>
this.clientSvc.saveClient(payload).pipe(
map((client) => new clientActions.CreateSuccess(client)),
map((client) => {
this.clientCache.invalidate();
return new clientActions.CreateSuccess(client);
}),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.client));
return of(new clientActions.CreateFailed())
@ -54,7 +63,9 @@ export class ClientEffects {
ofType<clientActions.Update>(clientActions.UPDATE),
switchMap(({ payload }) =>
this.clientSvc.saveClient(payload).pipe(
map(() => new clientActions.UpdateSuccess(payload)),
map(() => {
return new clientActions.UpdateSuccess(payload);
}),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.client));
return of(new clientActions.UpdateFailed());
@ -68,7 +79,10 @@ export class ClientEffects {
ofType<clientActions.Delete>(clientActions.DELETE),
switchMap(({ payload }) =>
this.clientSvc.deleteClient(payload).pipe(
map(() => new clientActions.DeleteSuccess(payload)),
map(() => {
this.clientCache.invalidate();
return new clientActions.DeleteSuccess(payload);
}),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.client));
return of(new clientActions.UpdateFailed())

View File

@ -4,6 +4,7 @@ import { Customer } from "../models/customer.model";
export const FETCH = '[CUSTOMERS] Fetch customers';
export class Fetch implements Action {
type: typeof FETCH = FETCH;
constructor(readonly payload?: { filters?: string; useCache?: boolean }) {}
}
export const FETCH_SUCCESS = '[CUSTOMERS] Fetch customers success';

View File

@ -56,6 +56,20 @@
</div>
</div>
<!-- Dealer Selection -->
<div class="ui-g-12 ui-md-6 ui-lg-6 form-row">
<span class="form-label-span">Dealer:</span>
<p-dropdown id="dealer" name="dealer" formControlName="dealer"
[options]="dealerOptions"
[style]="{'min-width': '200px'}"
placeholder="Select Dealer"
[loading]="dealerLoading"
[filter]="true"
filterBy="label"
appendTo="body">
</p-dropdown>
</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>
@ -90,6 +104,12 @@
required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
</agm-account-editor>
</div>
<!-- API Key Manager (existing customers only) -->
<div class="ui-g-12" *ngIf="!isNew">
<agm-api-key-manager [ownerId]="customer._id" [toggleable]="true" [collapsed]="true"></agm-api-key-manager>
</div>
<div class="ui-g-12 toolbar padtop1 ui-fluid">
<button pButton [disabled]="form.invalid || partnerLoading" type="button" style="width:auto"
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"

View File

@ -6,6 +6,7 @@ import { Customer, Partner } from '../models/customer.model';
import * as customerActions from '../actions/customer.actions';
import { UserService } from '@app/domain/services/user.service';
import { PartnerService } from '@app/partners/services/partner.service';
import { Dealer, DealerService } from '@app/tools/dealers/dealer.service';
import { BaseComp } from '@app/shared/base/base.component';
import { GC, RoleIds, globals, Labels } from '@app/shared/global';
import { AGNavSubscription, Trial } from '@app/domain/models/subscription.model';
@ -40,6 +41,10 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
partnerLoading = false;
partnerError: string | null = null;
// Dealer Selection Properties
dealerOptions: SelectItem[] = [];
dealerLoading = false;
private _customer: Customer;
get customer(): Customer { return this._customer; }
set customer(customer: Customer) {
@ -51,7 +56,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
premium: this.selectedItem.premium,
billable: this.selectedItem.billable,
trials: this.selectedItem.membership?.trials,
partner: this.selectedItem.partner || null
partner: this.selectedItem.partner || null,
dealer: this.selectedItem.dealer || null
});
// Set partner selection based on customer.partner field, or null if not set
@ -67,6 +73,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
private readonly route: ActivatedRoute,
private readonly userSvc: UserService,
private readonly partnerSvc: PartnerService,
private readonly dealerSvc: DealerService,
private readonly fb: FormBuilder
) {
super();
@ -83,7 +90,9 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
billable: [],
trials: [],
// Partner form control
partner: [null]
partner: [null],
// Dealer form control
dealer: [null]
});
this.lang = this.authSvc.locale;
@ -106,6 +115,7 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
}
// Load partners from service
this.loadPartners();
this.loadDealers();
}
});
@ -171,7 +181,8 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
custObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account,
{ premium: this.form.value.premium || false },
{ billable: this.form.value.billable || false },
{ partner: this.form.value.partner || null });
{ partner: this.form.value.partner || null },
{ dealer: this.form.value.dealer?._id || this.form.value.dealer || null });
this.membership
? custObj = Object.assign(custObj, { membership: updateTrialMembship(this.membership) })
@ -209,6 +220,30 @@ export class CustomerEditComponent extends BaseComp implements OnInit {
return DateUtils.dateToTS(date);
}
// Dealer Methods
private loadDealers(): void {
this.dealerLoading = true;
this.dealerSvc.getAll().subscribe({
next: (dealers: Dealer[]) => {
this.dealerOptions = [
{ label: 'None', value: null },
...dealers
.sort((a, b) => a.companyName.localeCompare(b.companyName))
.map(d => ({
label: d.country ? `${d.companyName} (${d.country})` : d.companyName,
value: d
}))
];
if (this.customer?.dealer) {
const match = this.dealerOptions.find(o => o.value && o.value._id === (this.customer.dealer as any)?._id);
if (match) { this.form.patchValue({ dealer: match.value }); }
}
this.dealerLoading = false;
},
error: () => { this.dealerLoading = false; }
});
}
// Partner Methods
private loadPartners(): void {
this.partnerLoading = true;

View File

@ -0,0 +1,83 @@
.cache-ttl-caption {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
}
.cache-ttl-caption-title {
flex: 1 1 auto;
min-width: 0;
}
.cache-ttl-caption-controls {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 0 0 auto;
white-space: nowrap;
text-align: right;
padding-left: 8px;
}
.cache-ttl-help {
position: relative;
display: inline-flex;
vertical-align: middle;
margin-right: 8px;
outline: none;
}
.cache-ttl-help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
font-weight: bold;
cursor: help;
color: #fff;
background: transparent;
}
.cache-ttl-help-text {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 220px;
white-space: normal;
padding: 8px 10px;
border-radius: 4px;
background: #323232;
color: #fff;
text-align: left;
line-height: 1.35;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
opacity: 0;
visibility: hidden;
pointer-events: none;
z-index: 1000;
transition: opacity 0.15s ease;
}
.cache-ttl-help:hover .cache-ttl-help-text,
.cache-ttl-help:focus .cache-ttl-help-text,
.cache-ttl-help:focus-within .cache-ttl-help-text {
opacity: 1;
visibility: visible;
}
@media (max-width: 640px) {
.cache-ttl-caption-title,
.cache-ttl-caption-controls {
width: auto;
float: none;
}
.cache-ttl-caption-controls {
padding-left: 4px;
}
}

View File

@ -1,15 +1,25 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card">
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
<p-accordionTab i18n-header="@@searchCustomers" header="Search Customers" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
<agm-dynamic-filter [filterDefinitions]="customerFilterDefinitions" [locale]="locale" stateKey="customers-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
</p-accordionTab>
</p-accordion>
<p-table #dt [value]="customers" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[10, 15, 30]" [alwaysShowPaginator]="true" [(selection)]="curCust" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="ctb-ops" [responsive]="true">
<ng-template pTemplate="caption">
<div class="ui-g ui-g-nopad">
<div class="ui-g-6 cc-field-label">
<span class="table-caption-1" i18n="@@customerList">Customer List</span>
<div class="ui-g ui-g-nopad cache-ttl-caption">
<div class="ui-g-6 cc-field-label cache-ttl-caption-title">
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@customerList">Customer List</span>
</div>
<div class="ui-g-6 cc-field-label">
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label>
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
<div class="ui-g-6 cc-field-label cache-ttl-caption-controls">
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
(blur)="updateCacheTtl()" style="width: 3.5rem; margin-right: 8px;">
<span class="cache-ttl-help" tabindex="0">
<span class="cache-ttl-help-icon">?</span>
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
</span>
</div>
</div>
</ng-template>
@ -30,6 +40,10 @@
<p-dropdown *ngIf="col.field === PARTNER_NAME" [options]="partners" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
<div class="input-with-icon" *ngIf="col.field === 'contact'">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span>
</th>
</tr>

View File

@ -10,6 +10,9 @@ import * as customerActions from '../actions/customer.actions';
import { globals, OperationalStatus } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component';
import { CustomerCacheService } from '@app/domain/services/customer-cache.service';
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
@Component({
selector: 'agm-customer-list',
@ -32,13 +35,20 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
partners: SelectItem[];
cols: any[];
totalItems;
isSelfSignup = false;
searchAccordionOpen = sessionStorage.getItem('customers-list-accordion') === 'true';
private lastFiltersQuery: Record<string, any> | undefined;
private useCacheOnReturn = false;
cacheTtlSeconds: number;
customerFilterDefinitions: FilterDefinition[];
constructor(
private readonly route: ActivatedRoute,
private readonly customerCache: CustomerCacheService,
private readonly listReturnCache: ListReturnCacheService,
) {
super();
this.cacheTtlSeconds = Math.round(this.customerCache.getTtlMs() / 1000);
this.totalItems = { '=0': '', '=1': '1 ' + $localize`:@@customer:customer`.toLocaleLowerCase(), 'other': $localize`:@@total#Customers:Total: # customers` };
this.statuses = [
@ -56,12 +66,21 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
{ field: this.ACTIVE, header: globals.active, width: '9%' },
{ field: this.PARTNER_NAME, header: globals.partner, width: '9%' }
];
this.customerFilterDefinitions = [
{ key: 'name', label: globals.name, dataType: 'text' },
{ key: 'username', label: globals.userName, dataType: 'text' },
{ key: 'email', label: globals.email, dataType: 'text' },
{ key: 'contact', label: globals.contact, dataType: 'text' },
{ key: 'createdAt', label: globals.from, dataType: 'date-preset' },
{ key: 'selfSignup', label: 'Self Signup', dataType: 'select', options: [
{ label: 'True', value: true },
{ label: 'False', value: false },
]},
];
}
ngOnInit() {
const saved = localStorage.getItem('isSelfSignup');
this.isSelfSignup = saved === 'true';
this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
this.setCustomersAndPartners(customers);
});
@ -70,12 +89,23 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
this.curCust = cust;
}));
this.store.dispatch(new customerActions.Fetch());
this.useCacheOnReturn = this.listReturnCache.startVisit('customers');
const savedFilters = sessionStorage.getItem('customers-list-last-filters');
if (savedFilters) {
try {
this.lastFiltersQuery = JSON.parse(savedFilters);
} catch (_err) {
this.lastFiltersQuery = undefined;
}
}
this.store.dispatch(savedFilters
? new customerActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
: new customerActions.Fetch({ useCache: this.useCacheOnReturn })
);
}
private setCustomersAndPartners(customers: Customer[]) {
const filtered = this.isSelfSignup ? customers.filter(c => c.selfSignup) : customers;
this.customers = filtered.map(c => ({
this.customers = customers.map(c => ({
...c,
partnerName: c.partner?.name || null
}));
@ -89,18 +119,32 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
];
}
onToggle(event: any): void {
this.isSelfSignup = event.checked;
localStorage.setItem('isSelfSignup', String(this.isSelfSignup));
this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
this.setCustomersAndPartners(customers);
});
}
onRowSelect(event) {
this.store.dispatch(new customerActions.Select(event.data));
}
onAccordionToggle(expanded: boolean) {
sessionStorage.setItem('customers-list-accordion', String(expanded));
}
updateCacheTtl(): void {
const ttlMs = this.customerCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
}
onFiltersSubmit(event: FilterChangeEvent) {
const q = { ...event.query };
const filtersStr = JSON.stringify(q);
const prevFilters = sessionStorage.getItem('customers-list-last-filters');
if (filtersStr !== prevFilters) {
this.customerCache.invalidate();
this.useCacheOnReturn = false;
}
this.lastFiltersQuery = q;
sessionStorage.setItem('customers-list-last-filters', filtersStr);
this.store.dispatch(new customerActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
}
get canEdit() {
return (this.curCust && this.curCust._id !== '0');
}
@ -110,6 +154,7 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
}
editCustomer() {
this.listReturnCache.markPending('customers');
this.router.navigate(['customer', this.curCust._id], { relativeTo: this.route });
}

View File

@ -12,8 +12,10 @@ import { MessageModule } from 'primeng/message';
import { TableModule } from 'primeng/table';
import { ToastModule } from 'primeng/toast';
import { MessagesModule } from 'primeng/messages';
import { AccordionModule } from 'primeng/accordion';
import { AppSharedModule } from '../shared/app-shared.module';
import { ApiKeySharedModule } from '../settings/api-keys/api-key-shared.module';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
@ -41,6 +43,8 @@ import { TrialComponent } from './trial/trial.component';
SplitButtonModule,
TableModule,
AppSharedModule,
ApiKeySharedModule,
AccordionModule,
StoreModule.forFeature(fromCustomers.FEATURE_KEY, fromCustomers.reducer),
EffectsModule.forFeature([CustomerEffects]),

View File

@ -9,21 +9,23 @@ import * as customerActions from '../actions/customer.actions';
import { CustomerService } from '@app/domain/services/customer.service';
import { AppMessageService } from '@app/shared/app-message.service';
import { globals } from '@app/shared/global';
import { CustomerCacheService } from '@app/domain/services/customer-cache.service';
@Injectable()
export class CustomerEffects {
constructor(
private readonly actions$: Actions,
private readonly customerSvc: CustomerService,
private readonly msgSvc: AppMessageService
private readonly msgSvc: AppMessageService,
private readonly customerCache: CustomerCacheService
) {
}
@Effect()
loadCustomers$: Observable<Action> = this.actions$.pipe(
ofType<customerActions.Fetch>(customerActions.FETCH),
switchMap(() =>
this.customerSvc.loadCustomers().pipe(
switchMap(({ payload }) =>
this.customerSvc.loadCustomers(payload?.filters, payload?.useCache).pipe(
map(customers => new customerActions.FetchSuccess(customers)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.customers));
@ -38,7 +40,10 @@ export class CustomerEffects {
ofType<customerActions.Create>(customerActions.CREATE),
switchMap(({ payload }) =>
this.customerSvc.saveCustomer(payload).pipe(
map((customer) => new customerActions.CreateSuccess(customer)),
map((customer) => {
this.customerCache.invalidate();
return new customerActions.CreateSuccess(customer);
}),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.customer));
return of(new customerActions.CreateFailed())
@ -52,7 +57,9 @@ export class CustomerEffects {
ofType<customerActions.Update>(customerActions.UPDATE),
switchMap(({ payload }) =>
this.customerSvc.saveCustomer(payload).pipe(
map(() => new customerActions.UpdateSuccess(payload)),
map(() => {
return new customerActions.UpdateSuccess(payload);
}),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.customer));
return of(new customerActions.UpdateFailed());
@ -66,7 +73,10 @@ export class CustomerEffects {
ofType<customerActions.Delete>(customerActions.DELETE),
switchMap(({ payload }) =>
this.customerSvc.deleteCustomer(payload).pipe(
map(() => new customerActions.DeleteSuccess(payload)),
map(() => {
this.customerCache.invalidate();
return new customerActions.DeleteSuccess(payload);
}),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.customer));
return of(new customerActions.UpdateFailed())

View File

@ -1,6 +1,7 @@
import { RoleIds } from '@app/shared/global';
import { createNewUser, User } from '@app/accounts/models/user.model';
import { IMembership } from '@app/auth/models/user.model';
import { Dealer } from '@app/tools/dealers/dealer.service';
export interface Customer extends User {
contact?: string;
@ -10,6 +11,7 @@ export interface Customer extends User {
totalJobs?: number;
membership: IMembership,
partner?: Partner;
dealer?: Dealer;
selfSignup?: boolean;
}

View File

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

View File

@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiKey, CreateApiKeyRequest, CreateApiKeyResponse } from '../../settings/api-keys/models/api-key.model';
@Injectable()
export class ApiKeyService {
private readonly apiURL = '/keys';
constructor(private readonly http: HttpClient) {}
listKeys(ownerId?: string): Observable<ApiKey[]> {
const params = ownerId ? new HttpParams().set('ownerId', ownerId) : undefined;
return this.http.get<ApiKey[]>(this.apiURL, { params });
}
createKey(req: CreateApiKeyRequest): Observable<CreateApiKeyResponse> {
return this.http.post<CreateApiKeyResponse>(this.apiURL, req);
}
revokeKey(keyId: string): Observable<void> {
return this.http.patch<void>(`${this.apiURL}/${keyId}/revoke`, {});
}
deleteKey(keyId: string): Observable<void> {
return this.http.delete<void>(`${this.apiURL}/${keyId}`);
}
regenerateKey(keyId: string): Observable<CreateApiKeyResponse> {
return this.http.post<CreateApiKeyResponse>(`${this.apiURL}/${keyId}/regenerate`, {});
}
}

View File

@ -130,6 +130,8 @@ export class AppConfigService {
}
if (Utils.isNulOrUndef(settings['matType']))
settings['matType'] = MatType.LIQUID;
if (Utils.isNulOrUndef(settings['browserListCacheTtlMs']))
settings['browserListCacheTtlMs'] = 60 * 1000;
this.settings = settings;
this.wasSetDefault = true;

View File

@ -41,7 +41,7 @@ export class AuthInterceptor implements HttpInterceptor {
});
let url = req.url;
if (!StringUtils.contains(req.url, '/track'))
if (!StringUtils.contains(req.url, '/track') && !req.url.startsWith('/assets'))
url = `/api${!req.url.startsWith('/') ? '' + req.url : req.url}`;
const authReq = req.clone({ url: url, headers: headers });

View File

@ -0,0 +1,142 @@
import { Injectable } from '@angular/core';
import { from, Observable, of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AppConfigService } from './app-config.service';
import { AuthService } from './auth.service';
/** Shape of every entry stored in Cache Storage. */
export interface BrowserCacheEntry<T> {
data: T;
cachedAt: number; // epoch ms
}
/**
* Generic browser-side Cache Storage wrapper.
*
* Each logical cache is identified by a **cacheName** (e.g. `'agm-jobs-list-v1'`).
* Within that cache, individual entries are keyed by an arbitrary **key** string
* (typically a serialised set of query params).
*
* Usage:
* ```ts
* // Read
* this.browserCache.get<MyModel[]>('my-cache-v1', key, 60_000).subscribe(data => { ... });
*
* // Write
* this.browserCache.put('my-cache-v1', key, data);
*
* // Invalidate
* this.browserCache.invalidate('my-cache-v1');
* ```
*
* All operations are no-ops when the Cache Storage API is unavailable
* (e.g. in unit tests or older browsers).
*/
@Injectable({ providedIn: 'root' })
export class BrowserCacheService {
private readonly supported = typeof caches !== 'undefined';
private readonly fallbackMaxAgeMs = 60_000;
constructor(
private readonly appConfig: AppConfigService,
private readonly authSvc: AuthService
) {}
private ttlStorageKey(cacheName: string): string {
const userId = this.authSvc.user?._id || 'anonymous';
return `browser-cache-ttl:${userId}:${cacheName}`;
}
private get defaultMaxAgeMs(): number {
return this.appConfig.settings?.browserListCacheTtlMs || this.fallbackMaxAgeMs;
}
getTtl(cacheName: string): number {
const storedValue = localStorage.getItem(this.ttlStorageKey(cacheName));
if (storedValue === null) {
return this.defaultMaxAgeMs;
}
const parsedValue = Number(storedValue);
return Number.isFinite(parsedValue) && parsedValue >= 0
? parsedValue
: this.defaultMaxAgeMs;
}
setTtl(cacheName: string, ttlMs: number): number {
const normalizedValue = Number.isFinite(ttlMs) && ttlMs >= 0
? Math.floor(ttlMs)
: this.defaultMaxAgeMs;
localStorage.setItem(this.ttlStorageKey(cacheName), String(normalizedValue));
return normalizedValue;
}
/**
* Build the pseudo-URL used as the cache key inside a named Cache bucket.
* We prefix with a fixed path so it looks like a valid Request URL.
*/
private entryUrl(cacheName: string, key: string): string {
return `/browser-cache/${encodeURIComponent(cacheName)}?${key}`;
}
/**
* Retrieve a cached value.
*
* @param cacheName Name of the Cache Storage bucket (e.g. `'agm-jobs-list-v1'`).
* @param key Entry key typically serialised query params.
* @param maxAgeMs Maximum age in milliseconds before the entry is treated as stale.
* Defaults to `appConfig.browserListCacheTtlMs` or 60 000.
* @returns The cached value, or `null` when unavailable / stale / missing.
*/
get<T>(cacheName: string, key: string, maxAgeMs?: number): Observable<T | null> {
if (!this.supported) return of(null);
const effectiveMaxAgeMs = maxAgeMs ?? this.getTtl(cacheName);
return from(caches.open(cacheName)).pipe(
switchMap(cache => from(cache.match(this.entryUrl(cacheName, key)))),
switchMap(response => {
if (!response) return of(null);
return from(response.json() as Promise<BrowserCacheEntry<T>>);
}),
switchMap((entry: BrowserCacheEntry<T> | null) => {
if (!entry) return of(null);
if (Date.now() - entry.cachedAt > effectiveMaxAgeMs) return of(null);
return of(entry.data);
}),
catchError(() => of(null))
);
}
/**
* Store a value in Cache Storage.
* Fire-and-forget errors are silently swallowed so they never block the caller.
*
* @param cacheName Name of the Cache Storage bucket.
* @param key Entry key.
* @param data Value to store (must be JSON-serialisable).
*/
put<T>(cacheName: string, key: string, data: T): void {
if (!this.supported) return;
const entry: BrowserCacheEntry<T> = { data, cachedAt: Date.now() };
caches.open(cacheName)
.then(cache => cache.put(
this.entryUrl(cacheName, key),
new Response(JSON.stringify(entry), { headers: { 'Content-Type': 'application/json' } })
))
.catch(() => { /* silent */ });
}
/**
* Delete an entire Cache Storage bucket, invalidating all its entries.
* Typically called after a mutation (create / update / delete).
*
* @param cacheName Name of the Cache Storage bucket to delete.
*/
invalidate(cacheName: string): void {
if (!this.supported) return;
caches.delete(cacheName).catch(() => { /* silent */ });
}
}

View File

@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BrowserCacheService } from './browser-cache.service';
const CACHE_NAME = 'agm-clients-list-v1';
/**
* Clients-list-specific facade over {@link BrowserCacheService}.
*
* Encapsulates the cache name and TTL so callers (ClientService, ClientEffects)
* don't need to know those details.
*/
@Injectable({ providedIn: 'root' })
export class ClientCacheService {
constructor(private readonly browserCache: BrowserCacheService) {}
getTtlMs(): number {
return this.browserCache.getTtl(CACHE_NAME);
}
setTtlMs(ttlMs: number): number {
return this.browserCache.setTtl(CACHE_NAME, ttlMs);
}
/** Return cached clients for the given query-param string, or null if stale/missing. */
get(queryParams: string): Observable<any[] | null> {
return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
}
/** Store a fresh clients list for the given query-param string. */
put(queryParams: string, data: any[]): void {
this.browserCache.put(CACHE_NAME, queryParams, data);
}
/** Invalidate all cached client-list entries (call after any client mutation). */
invalidate(): void {
this.browserCache.invalidate(CACHE_NAME);
}
}

View File

@ -1,11 +1,13 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { Client } from '../../client/models/client.model';
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
import { ClientCacheService } from './client-cache.service';
@Injectable()
export class ClientService {
@ -14,12 +16,35 @@ export class ClientService {
constructor(
private store: Store<{}>,
private http: HttpClient
private http: HttpClient,
private readonly clientCache: ClientCacheService
) {
}
loadClients(options?: LoadClientOps): Observable<Client[]> {
return this.http.post<Client[]>(this.clientURL + '/search', options);
const cacheKey = JSON.stringify({
byPuid: options?.byPuid,
filters: options?.filters || ''
});
const requestBody = {
byPuid: options?.byPuid,
...(options?.filters ? { filters: options.filters } : {})
};
if (options?.useCache) {
return this.clientCache.get(cacheKey).pipe(
switchMap(cached => {
if (cached !== null) return of(cached as Client[]);
return this.http.post<Client[]>(this.clientURL + '/search', requestBody).pipe(
tap(data => this.clientCache.put(cacheKey, data))
);
})
);
}
return this.http.post<Client[]>(this.clientURL + '/search', requestBody).pipe(
tap(data => this.clientCache.put(cacheKey, data))
);
}
getClient(id: string): Observable<Client> {
@ -53,6 +78,8 @@ export class ClientService {
export interface LoadClientOps {
byPuid: string;
filters?: string;
useCache?: boolean;
}
export interface ClientWithSetting extends Client {

View File

@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BrowserCacheService } from './browser-cache.service';
const CACHE_NAME = 'agm-customers-list-v1';
@Injectable({ providedIn: 'root' })
export class CustomerCacheService {
constructor(private readonly browserCache: BrowserCacheService) {}
getTtlMs(): number {
return this.browserCache.getTtl(CACHE_NAME);
}
setTtlMs(ttlMs: number): number {
return this.browserCache.setTtl(CACHE_NAME, ttlMs);
}
get(queryParams: string): Observable<any[] | null> {
return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
}
put(queryParams: string, data: any[]): void {
this.browserCache.put(CACHE_NAME, queryParams, data);
}
invalidate(): void {
this.browserCache.invalidate(CACHE_NAME);
}
}

View File

@ -1,7 +1,9 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { Customer } from '../../customers/models/customer.model';
import { CustomerCacheService } from './customer-cache.service';
@Injectable()
export class CustomerService {
@ -9,12 +11,30 @@ export class CustomerService {
private readonly customerURL = '/customers';
constructor(
private http: HttpClient
private http: HttpClient,
private readonly customerCache: CustomerCacheService
) {
}
loadCustomers(): Observable<Customer[]> {
return this.http.get<Customer[]>(this.customerURL);
loadCustomers(filters?: string, useCache: boolean = false): Observable<Customer[]> {
const cacheKey = filters || '';
const params: any = {};
if (filters) params.filters = filters;
if (useCache) {
return this.customerCache.get(cacheKey).pipe(
switchMap(cached => {
if (cached !== null) return of(cached as Customer[]);
return this.http.get<Customer[]>(this.customerURL, { params }).pipe(
tap(data => this.customerCache.put(cacheKey, data))
);
})
);
}
return this.http.get<Customer[]>(this.customerURL, { params }).pipe(
tap(data => this.customerCache.put(cacheKey, data))
);
}
getCustomer(id: string, view?: string): Observable<Customer> {

View File

@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BrowserCacheService } from './browser-cache.service';
const CACHE_NAME = 'agm-invoices-list-v1';
@Injectable({ providedIn: 'root' })
export class InvoiceCacheService {
constructor(private readonly browserCache: BrowserCacheService) {}
getTtlMs(): number {
return this.browserCache.getTtl(CACHE_NAME);
}
setTtlMs(ttlMs: number): number {
return this.browserCache.setTtl(CACHE_NAME, ttlMs);
}
get(queryParams: string): Observable<any[] | null> {
return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
}
put(queryParams: string, data: any[]): void {
this.browserCache.put(CACHE_NAME, queryParams, data);
}
invalidate(): void {
this.browserCache.invalidate(CACHE_NAME);
}
}

View File

@ -5,10 +5,11 @@ import { Observable, of } from 'rxjs';
import { Client, Invoice } from '@app/invoices/models/invoice.model';
import { CostingItem } from '@app/invoices/models/costing-item.model';
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
import { catchError, map } from 'rxjs/operators';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { AppMessageService } from '@app/shared/app-message.service';
import { RouterUtilsService } from '@app/shared/router-utils.service';
import { Utils } from '@app/shared/utils';
import { InvoiceCacheService } from './invoice-cache.service';
@Injectable()
export class InvoiceService {
@ -28,7 +29,8 @@ export class InvoiceService {
constructor(
private http: HttpClient,
private readonly appMsgSvc: AppMessageService,
private readonly routerUtils: RouterUtilsService
private readonly routerUtils: RouterUtilsService,
private readonly invoiceCache: InvoiceCacheService
) { }
// Setting
@ -71,8 +73,25 @@ export class InvoiceService {
}
// Invoice
getInvoices(): Observable<Invoice[]> {
return this.http.get<Invoice[]>(this.invoiceURL);
getInvoices(filters?: string, useCache: boolean = false): Observable<Invoice[]> {
const cacheKey = filters || '';
const params: any = {};
if (filters) params.filters = filters;
if (useCache) {
return this.invoiceCache.get(cacheKey).pipe(
switchMap(cached => {
if (cached !== null) return of(cached as Invoice[]);
return this.http.get<Invoice[]>(this.invoiceURL, { params }).pipe(
tap(data => this.invoiceCache.put(cacheKey, data))
);
})
);
}
return this.http.get<Invoice[]>(this.invoiceURL, { params }).pipe(
tap(data => this.invoiceCache.put(cacheKey, data))
);
}
getInvoiceById(id): Observable<Invoice> {

View File

@ -0,0 +1,41 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { BrowserCacheService } from './browser-cache.service';
const CACHE_NAME = 'agm-jobs-list-v1';
/**
* Jobs-list-specific facade over {@link BrowserCacheService}.
*
* Encapsulates the cache name and TTL so callers (JobService, JobEffects)
* don't need to know those details.
*/
@Injectable({ providedIn: 'root' })
export class JobCacheService {
constructor(private readonly browserCache: BrowserCacheService) {}
getTtlMs(): number {
return this.browserCache.getTtl(CACHE_NAME);
}
setTtlMs(ttlMs: number): number {
return this.browserCache.setTtl(CACHE_NAME, ttlMs);
}
/** Return cached jobs for the given query-param string, or null if stale/missing. */
get(queryParams: string): Observable<any[] | null> {
return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
}
/** Store a fresh jobs list for the given query-param string. */
put(queryParams: string, data: any[]): void {
this.browserCache.put(CACHE_NAME, queryParams, data);
}
/** Invalidate all cached job-list entries (call after any job mutation). */
invalidate(): void {
this.browserCache.invalidate(CACHE_NAME);
}
}

View File

@ -2,11 +2,12 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, switchMap, tap } from 'rxjs/operators';
import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model';
import { AppFile } from '../models/shared.model';
import { UpdateJobOps } from '../../job/actions/job.actions';
import { JobCacheService } from './job-cache.service';
@Injectable()
export class JobService {
@ -14,16 +15,23 @@ export class JobService {
private readonly jobURL = '/jobs';
constructor(
private http: HttpClient
private http: HttpClient,
private jobCache: JobCacheService
) {
}
loadJobs(ops: any): Observable<IJob[]> {
let _ops = new HttpParams()
.set('clientId', ops?.clientId || '')
.set('jpo', ops?.jobsByPilot || 'false')
.set('status', ops?.status || '');
.set('jpo', ops?.jobsByPilot || 'false');
if (ops?.filters != null) {
// Filter-submit path: all filtering is encoded in the filters param
_ops = _ops.set('filters', ops.filters);
} else {
// Legacy reload path: use individual params
_ops = _ops
.set('clientId', ops?.clientId || '')
.set('status', ops?.status || '');
if (ops?.byTime?.length === 2) {
for (const time of ops.byTime) {
if (time) {
@ -31,10 +39,31 @@ export class JobService {
}
}
} else {
_ops = _ops.append('byTime', ops?.byTime[0] || '');
_ops = _ops.append('byTime', ops?.byTime?.[0] || '');
}
}
return this.http.get<IJob[]>(this.jobURL, { params: _ops });
const cacheKey = _ops.toString();
if (ops?.useCache) {
return this.jobCache.get(cacheKey).pipe(
switchMap(cached => {
if (cached !== null) {
return new Observable<IJob[]>(observer => {
observer.next(cached as IJob[]);
observer.complete();
});
}
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
tap(data => this.jobCache.put(cacheKey, data))
);
})
);
}
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
tap(data => this.jobCache.put(cacheKey, data))
);
}
getJob(id: number, withItems: boolean = false, withLines?: boolean): Observable<IUIJob> {

View File

@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ListReturnCacheService {
private storageKey(listKey: string): string {
return `list-return-cache:${listKey}`;
}
markPending(listKey: string): void {
sessionStorage.setItem(this.storageKey(listKey), '1');
}
startVisit(listKey: string): boolean {
const storageKey = this.storageKey(listKey);
const shouldUseCache = sessionStorage.getItem(storageKey) === '1';
sessionStorage.removeItem(storageKey);
return shouldUseCache;
}
}

View File

@ -0,0 +1,3 @@
.ui-g-12.ui-sm-12.ui-md-12.ui-lg-10.ui-xl-10 {
width: 100% !important;
}

View File

@ -16,6 +16,22 @@
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
</div>
<p-dropdown *ngIf="col.field === 'color'" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'color', 'equals')">
<ng-template let-item pTemplate="selectedItem">
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
</ng-template>
<ng-template let-item pTemplate="item">
<div style="display:flex; align-items:center; justify-content:center; gap:.5em;">
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
<span>{{item.label}}</span>
</div>
</ng-template>
</p-dropdown>
<div class="input-with-icon" *ngIf="col.field === 'desc'">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span>
</th>
</tr>
@ -65,9 +81,9 @@
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
</ng-template>
<ng-template let-item pTemplate="item">
<div class="ui-helper-clearfix" style="position:relative;">
<div class="color-box" style="margin-left:3px" [ngStyle]="{ 'background-color': item.value }"></div>
<div style="float:right; margin-right: .15em;">{{item.label}}</div>
<div style="display:flex; align-items:center; justify-content:center; gap:.5em;">
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
<span>{{item.label}}</span>
</div>
</ng-template>
</p-dropdown>

View File

@ -34,6 +34,7 @@ export class CropListComponent extends BaseComp implements OnInit, AfterViewInit
loading$ = this.store.select(fromEntity.getCropsLoading);
sprZoneColors: SelectItem[] = [...GC.selSprZoneColors];
colorFilterOpts: SelectItem[] = [GC.selAll, ...GC.selSprZoneColors];
constructor() {
super();

View File

@ -17,6 +17,10 @@
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
</div>
<div class="input-with-icon" *ngIf="col.field === 'address'">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span>
</th>
</tr>

View File

@ -17,6 +17,15 @@
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
</div>
<p-dropdown *ngIf="col.field === 'type'" [options]="prodTypes" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'type', 'equals')"></p-dropdown>
<p-dropdown *ngIf="col.field === 'restricted'" [options]="restrictedOpts" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'restricted', 'equals')"></p-dropdown>
<div class="input-with-icon" *ngIf="col.field === 'rate'">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, 'rateStr', 'contains')" [value]="dt.filters['rateStr']?.value">
</div>
<div class="input-with-icon" *ngIf="col.field === 'desc'">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span>
</th>
</tr>

View File

@ -30,6 +30,11 @@ export class ProductListComponent extends BaseComp implements OnInit, AfterViewI
prodTypes: SelectItem[] = [GC.selAll, ...GC.selProdTypes];
prodTypes2: SelectItem[] = [...GC.selProdTypes];
restrictedOpts: SelectItem[] = [
{ label: globals.all, value: null },
{ label: $localize`:@@yes:Yes`, value: true },
{ label: $localize`:@@no:No`, value: false },
];
rateUnits: SelectItem[] = [
{ label: 'oz/ac', value: 0 },
{ label: 'gal/ac', value: 1 },
@ -61,7 +66,7 @@ export class ProductListComponent extends BaseComp implements OnInit, AfterViewI
ngOnInit() {
this.sub$ = this.store.pipe(select(fromEntity.getAllProducts))
.subscribe((items) => {
this.products = items;
this.products = items.map(p => ({ ...p, rateStr: this.getRate(p.rate) }));
});
this.sub$.add(this.appActions.ofTypes([productActions.CREATE_SUCCESS, productActions.UPDATE_SUCCESS]).subscribe((action) => {

View File

@ -30,7 +30,7 @@ export const getEntityState = createFeatureSelector<EntityState>(FEATURE_KEY);
export const getCropsState = createSelector(
getEntityState,
state => state.crops
state => state ? state.crops : fromCrops.initialState
)
export const {
selectIds: getCropIds,
@ -44,7 +44,7 @@ export const getCropsLoading = createSelector(getCropsState, fromCrops.getIsLoad
export const getPilotsState = createSelector(
getEntityState,
state => state.pilots
state => state ? state.pilots : fromPilots.initialState
)
export const {
selectIds: getPilotIds,
@ -56,7 +56,7 @@ export const {
export const getProductsState = createSelector(
getEntityState,
state => state.products
state => state ? state.products : fromProducts.initialState
)
export const {
selectIds: getProductIds,
@ -68,7 +68,7 @@ export const {
export const getVehilesState = createSelector(
getEntityState,
state => state.vehicles
state => state ? state.vehicles : fromVehicles.initialState
)
export const {
selectIds: getVehicleIds,

View File

@ -60,6 +60,31 @@
[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>
<p-dropdown *ngIf="col.field === ACTIVE" [options]="activeOpts" [ngModel]="dt.filters[col.field]?.value"
(onChange)="dt.filter($event.value, ACTIVE, 'equals')"></p-dropdown>
<p-dropdown *ngIf="col.field === SOURCE_SYSTEM" [options]="sourceSystemOpts" [ngModel]="dt.filters[col.field]?.value"
(onChange)="dt.filter($event.value, SOURCE_SYSTEM, 'equals')"></p-dropdown>
<p-dropdown *ngIf="col.field === COLOR" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value"
(onChange)="dt.filter($event.value, COLOR, 'equals')">
<ng-template let-item pTemplate="selectedItem">
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
</ng-template>
<ng-template let-item pTemplate="item">
<div style="display:flex; align-items:center; justify-content:center; gap:.5em;">
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
<span>{{item.label}}</span>
</div>
</ng-template>
</p-dropdown>
<div class="input-with-icon" *ngIf="col.field === MODEL">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div>
<div class="input-with-icon" *ngIf="col.field === TRK_ON_DATE">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span>
</th>
</tr>

View File

@ -5,7 +5,7 @@ import { ConfirmationService, SelectItem } from 'primeng/api';
import { Vehicle } from '../../models/vehicle.model';
import * as vehicleActions from '../../actions/vehicle.actions';
import * as fromEntity from '../../reducers';
import { RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
import { GC, RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
import { DateUtils, Utils } from '@app/shared/utils';
import { BaseComp } from '@app/shared/base/base.component';
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
@ -59,6 +59,8 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
@ViewChild('updateBtn') updateBtn: ElementRef;
cols: any[] = [];
acTypes: SelectItem[];
activeOpts: SelectItem[];
colorFilterOpts: SelectItem[];
loading$ = this.store.select(fromEntity.getVehiclesLoading);
trkLimit: Limit;
pkgLimit: Limit;
@ -141,6 +143,26 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
];
this.activeOpts = [
{ label: globals.all, value: null },
{ label: globals.active, value: true },
{ label: globals.notActive, value: false },
];
this.colorFilterOpts = [GC.selAll, ...GC.selSprZoneColors];
}
get sourceSystemOpts(): SelectItem[] {
const seen = new Set<string>();
const opts: SelectItem[] = [{ label: globals.all, value: null }];
for (const v of (this.vehicles || [])) {
const val = v.partnerSystem || SourceSystem.AGNAV;
if (!seen.has(val)) {
seen.add(val);
const label = val === SourceSystem.AGNAV ? Labels.AGNAV_BRAND_NAME : val;
opts.push({ label, value: val });
}
}
return opts;
}
ngOnInit() {
@ -499,7 +521,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
initVehList() {
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
map((vehicles) => {
this.vehicles = vehicles;
this.vehicles = vehicles.map(v => ({ ...v, sourceSystem: v.partnerInfo?.metadata?.partnerSystem || SourceSystem.AGNAV }));
this.vehSelLastUpdated = this.createVehSelections(vehicles);
this.vehiclesChanged = this.isVehSelChanged();

View File

@ -5,6 +5,7 @@ export const FETCH = '[INVOICES] Fetch invoices';
export class Fetch implements Action {
type: typeof FETCH = FETCH;
constructor(readonly payload?: { filters?: string; useCache?: boolean }) {}
}
export const FETCH_SUCCESS = '[INVOICES] Fetch invoices success';

View File

@ -4,7 +4,7 @@
<p-table #ci [value]="costingItems" [columns]="cols" selectionMode="single" [paginator]="true" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" stateStorage="session" stateKey="costingItem-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false" [(selection)]="selectedItem">
<ng-template pTemplate="caption">
<div class="ui-g ui-g-nopad">
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
<div class="ui-g-12 ui-g-nopad" style="text-align: center">
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@costingItems">Costing Items</span>
</div>
</div>
@ -23,6 +23,7 @@
<input pInputText type="text" (input)="ci.filter($event.target.value, col.field, col.filterMatchMode)" [value]="ci.filters[col.field]?.value">
</div>
<p-dropdown *ngIf="col.field === 'type'" [options]="costingItemTypeOpt" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'type', 'equals')"></p-dropdown>
<p-dropdown *ngIf="col.field === 'unit'" [options]="unitFilterOpts" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'unit', 'equals')"></p-dropdown>
<span *ngSwitchDefault></span>
</th>
</tr>

View File

@ -35,6 +35,17 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy
costingTypes;
costingItemTypeOpt;
amountUnits;
unitFilterOpts = [
{ label: globals.all, value: null },
{ label: 'acre', value: CostingItemUnit.ACRE },
{ label: 'ha', value: CostingItemUnit.HA },
{ label: 'oz', value: CostingItemUnit.OZ },
{ label: 'gal', value: CostingItemUnit.GAL },
{ label: 'lb', value: CostingItemUnit.LB },
{ label: 'lit', value: CostingItemUnit.LIT },
{ label: 'kg', value: CostingItemUnit.KG },
{ label: 'hour', value: CostingItemUnit.HOUR },
];
currencyUnit;
totalCostingItems;
isNewItem = true;

View File

@ -7,13 +7,15 @@ import { Action } from '@ngrx/store';
import * as invoiceActions from '../actions/invoice.actions';
import { catchError, map, switchMap } from 'rxjs/operators';
import { globals } from '@app/shared/global';
import { InvoiceCacheService } from '@app/domain/services/invoice-cache.service';
@Injectable()
export class InvoiceEffects {
constructor(
private readonly actions$: Actions,
private readonly invoiceSvc: InvoiceService,
private readonly msgSvc: AppMessageService
private readonly msgSvc: AppMessageService,
private readonly invoiceCache: InvoiceCacheService
) {
}
@ -24,6 +26,7 @@ export class InvoiceEffects {
const isNew = true;
return this.invoiceSvc.saveInvoice(payload, isNew).pipe(
map(invoice => {
this.invoiceCache.invalidate();
this.msgSvc.addSuccessMsg(globals.doThingsSuccess.replace('#do#', globals.create).replace('#thing#', globals.invoice));
return new invoiceActions.CreateSuccess(invoice);
}),
@ -56,8 +59,8 @@ export class InvoiceEffects {
@Effect()
loadInvoice$: Observable<Action> = this.actions$.pipe(
ofType<invoiceActions.Fetch>(invoiceActions.FETCH),
switchMap(() => {
return this.invoiceSvc.getInvoices().pipe(
switchMap(({ payload }) => {
return this.invoiceSvc.getInvoices(payload?.filters, payload?.useCache).pipe(
map(res => {
return new invoiceActions.FetchSuccess(res);
}),
@ -75,6 +78,7 @@ export class InvoiceEffects {
switchMap(({ payload }) => {
return this.invoiceSvc.deleteInvoice(payload).pipe(
map((res: any[]) => {
this.invoiceCache.invalidate();
return new invoiceActions.DeleteSuccess(res?.map(i => i._id));
}),
catchError(err => {

View File

@ -11,3 +11,87 @@
border: 1px solid #4caf50;
background-color: #4caf50;
}
.cache-ttl-caption {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
}
.cache-ttl-caption-title {
flex: 1 1 auto;
min-width: 0;
}
.cache-ttl-caption-controls {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 0 0 auto;
white-space: nowrap;
text-align: right;
padding-left: 8px;
}
.cache-ttl-help {
position: relative;
display: inline-flex;
vertical-align: middle;
margin-left: 6px;
outline: none;
}
.cache-ttl-help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
font-weight: bold;
cursor: help;
color: #fff;
background: transparent;
}
.cache-ttl-help-text {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 220px;
white-space: normal;
padding: 8px 10px;
border-radius: 4px;
background: #323232;
color: #fff;
text-align: left;
line-height: 1.35;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
opacity: 0;
visibility: hidden;
pointer-events: none;
z-index: 1000;
transition: opacity 0.15s ease;
}
.cache-ttl-help:hover .cache-ttl-help-text,
.cache-ttl-help:focus .cache-ttl-help-text,
.cache-ttl-help:focus-within .cache-ttl-help-text {
opacity: 1;
visibility: visible;
}
@media (max-width: 640px) {
.cache-ttl-caption-title,
.cache-ttl-caption-controls {
width: auto;
float: none;
}
.cache-ttl-caption-controls {
padding-left: 4px;
}
}

View File

@ -1,11 +1,25 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card clearfix">
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
<p-accordionTab i18n-header="@@searchInvoices" header="Search Invoices" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
<agm-dynamic-filter [filterDefinitions]="invoiceFilterDefinitions" [locale]="locale" stateKey="invoices-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
</p-accordionTab>
</p-accordion>
<p-table #il [value]="invoices" [columns]="cols" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" selectionMode="multiple" (onRowSelect)="onSelectInvoice($event)" (onRowUnselect)="onUnselectInvoice($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" stateStorage="session" stateKey="inv-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false" [(selection)]="selectedInvoice">
<ng-template pTemplate="caption">
<div class="ui-g ui-g-nopad">
<div class="ui-g-6 ui-g-nopad text-left">
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@invoiceList">Invoice List</span>
<div class="ui-g ui-g-nopad cache-ttl-caption">
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-title">
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@invoiceList">Invoice List</span>
</div>
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-controls">
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
(blur)="updateCacheTtl()" style="width: 3.5rem;">
<span class="cache-ttl-help" tabindex="0">
<span class="cache-ttl-help-icon">?</span>
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
</span>
</div>
</div>
</ng-template>

View File

@ -16,6 +16,9 @@ import { FilterUtils } from 'primeng/utils';
import { DateUtils, Utils } from '@app/shared/utils';
import { RestoreTableState } from '@app/shared/restore-table-state';
import { GAService } from '@app/shared/ga.service';
import { InvoiceCacheService } from '@app/domain/services/invoice-cache.service';
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
@Component({
selector: 'agm-invoices-list',
@ -43,13 +46,23 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
readonly invoiceStatus = invoiceStatus;
searchAccordionOpen = sessionStorage.getItem('invoices-list-accordion') === 'true';
private lastFiltersQuery: Record<string, any> | undefined;
private useCacheOnReturn = false;
cacheTtlSeconds: number;
invoiceFilterDefinitions: FilterDefinition[];
constructor(
private readonly route: ActivatedRoute,
private readonly datePipe: DatePipe,
private readonly invoiceSvc: InvoiceService,
private readonly restoreTableSvc: RestoreTableState
private readonly restoreTableSvc: RestoreTableState,
private readonly invoiceCache: InvoiceCacheService,
private readonly listReturnCache: ListReturnCacheService
) {
super();
this.cacheTtlSeconds = Math.round(this.invoiceCache.getTtlMs() / 1000);
this.totalInvoices = {
'=0': '',
'=1': $localize`:@@total#invoice:Total: # invoice`,
@ -74,6 +87,14 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
];
this.statusFilter = [];
this.invoiceFilterDefinitions = [
{ key: 'code', label: $localize`:@@invoiceNumber:Invoice Number`, dataType: 'text' },
{ key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi', options: this.status },
{ key: 'openDate', label: $localize`:@@openDate:Open Date`, dataType: 'date' },
{ key: 'dueDate', label: $localize`:@@dueDate:Due Date`, dataType: 'date' },
{ key: 'createdAt', label: $localize`:@@createdAt:Created Date`, dataType: 'date-preset' },
];
}
ngOnInit(): void {
@ -97,7 +118,19 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
});
}
});
this.store.dispatch(new invoiceActions.Fetch());
this.useCacheOnReturn = this.listReturnCache.startVisit('invoices');
const savedFilters = sessionStorage.getItem('invoices-list-last-filters');
if (savedFilters) {
try {
this.lastFiltersQuery = JSON.parse(savedFilters);
} catch (_err) {
this.lastFiltersQuery = undefined;
}
}
this.store.dispatch(savedFilters
? new invoiceActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
: new invoiceActions.Fetch({ useCache: this.useCacheOnReturn })
);
FilterUtils[this.openDateFilter] = (value, filter): boolean => {
if (filter === undefined || filter === null) {
return true;
@ -180,6 +213,28 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
this.restoreTableSvc.restoreTableFirst(this.dt);
}
onAccordionToggle(expanded: boolean) {
sessionStorage.setItem('invoices-list-accordion', String(expanded));
}
updateCacheTtl(): void {
const ttlMs = this.invoiceCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
}
onFiltersSubmit(event: FilterChangeEvent) {
const q = { ...event.query };
const filtersStr = JSON.stringify(q);
const prevFilters = sessionStorage.getItem('invoices-list-last-filters');
if (filtersStr !== prevFilters) {
this.invoiceCache.invalidate();
this.useCacheOnReturn = false;
}
this.lastFiltersQuery = q;
sessionStorage.setItem('invoices-list-last-filters', filtersStr);
this.store.dispatch(new invoiceActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
}
onPageChange(e) {
this.restoreTableSvc.onPageChange(this.dt, e);
}
@ -208,6 +263,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
editInvoice(invoice: Invoice) {
this.selectInvoice(invoice);
this.listReturnCache.markPending('invoices');
// Track invoice selection
this.gaSvc.trackInvoiceSelected({
@ -225,6 +281,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
viewInvoice(invoice: Invoice) {
this.selectInvoice(invoice);
this.listReturnCache.markPending('invoices');
// Track invoice selection
this.gaSvc.trackInvoiceSelected({

View File

@ -39,6 +39,7 @@ import { CurrencyNamePipe } from '@app/invoices/pipes/currency-name.pipe';
import { ScrollPanelModule } from 'primeng/scrollpanel';
import { CurrencyCodePositionPipe } from '@app/invoices/pipes/currency-code-position.pipe';
import { InvoiceStatusPipe } from '@app/invoices/pipes/invoice-status.pipe';
import { AccordionModule } from 'primeng/accordion';
@NgModule({
@ -66,6 +67,7 @@ import { InvoiceStatusPipe } from '@app/invoices/pipes/invoice-status.pipe';
EffectsModule.forFeature([SettingEffects, InvoiceEffects, CostingItemEffects, JobEffects]),
PanelModule,
ScrollPanelModule,
AccordionModule,
],
declarations: [InvoicesListComponent, InvoicesMgtComponent, SettingsComponent, CustomerSettingsListComponent, CustomerSettingsComponent, InvoiceEditComponent, CostingItemComponent, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe, InvoiceDetailComponent],
exports: [CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe],

View File

@ -1,4 +1,4 @@
<div class="ui-g ui-fluid" style="max-width: 1025px;">
<div class="ui-g ui-fluid">
<div class="ui-g-12">
<div class="card card-w-title">
<h1 i18n="@@invoiceSettings">Invoice Settings</h1>

View File

@ -8,6 +8,7 @@ import { toJob } from '../models/job.model';
import * as jobActions from '../actions/job.actions';
import { JobService } from '@app/domain/services/job.service';
import { JobCacheService } from '@app/domain/services/job-cache.service';
import { AppMessageService } from '@app/shared/app-message.service';
import { globals } from '@app/shared/global';
@ -19,6 +20,7 @@ export class JobEffects {
private readonly actions$: Actions,
private readonly jobSvc: JobService,
private readonly jobCache: JobCacheService,
private readonly msgSvc: AppMessageService,
private readonly gaSvc: GAService
) {
@ -63,6 +65,7 @@ export class JobEffects {
priority: 'medium' // Default priority
});
this.jobCache.invalidate();
return new jobActions.CreateSuccess(job);
}),
catchError(err => {
@ -139,6 +142,7 @@ export class JobEffects {
Math.floor((new Date().getTime() - new Date(payload.createdAt).getTime()) / (1000 * 60 * 60)) : 0
});
this.jobCache.invalidate();
return new jobActions.DeleteSuccess(payload)
}),
catchError(err => {

View File

@ -8,14 +8,76 @@
.inline-flex-end {
display: inline-flex;
justify-content: flex-end;
align-items: center;
flex-wrap: nowrap;
white-space: nowrap;
max-width: 100%;
}
:host ::ng-deep .ui-calendar input,
:host ::ng-deep .ui-calendar .ui-datepicker-trigger {
opacity: 0;
height: 1px;
width: 1px;
overflow: hidden;
position: absolute;
pointer-events: auto;
.cache-ttl-caption-controls {
display: flex;
justify-content: flex-end;
flex: 0 0 auto;
min-width: 0;
}
.cache-ttl-help {
position: relative;
display: inline-flex;
vertical-align: middle;
margin-right: 6px;
outline: none;
}
.cache-ttl-help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
font-weight: bold;
cursor: help;
color: #fff;
background: transparent;
}
.cache-ttl-help-text {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 220px;
white-space: normal;
padding: 8px 10px;
border-radius: 4px;
background: #323232;
color: #fff;
text-align: left;
line-height: 1.35;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
opacity: 0;
visibility: hidden;
pointer-events: none;
z-index: 1000;
transition: opacity 0.15s ease;
}
.cache-ttl-help:hover .cache-ttl-help-text,
.cache-ttl-help:focus .cache-ttl-help-text,
.cache-ttl-help:focus-within .cache-ttl-help-text {
opacity: 1;
visibility: visible;
}
:host ::ng-deep .ui-fluid .ui-calendar {
width: 100%;
}
@media (max-width: 640px) {
.cache-ttl-caption-controls {
width: auto;
float: none;
}
}

View File

@ -1,7 +1,13 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card clearfix">
<p-table #dt [value]="jobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()"
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
<p-accordionTab i18n-header="@@searchJobs" header="Search Jobs" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
<agm-dynamic-filter [filterDefinitions]="jobFilterDefinitions" [locale]="locale" [defaultFilters]="defaultDynamicFilters" stateKey="job-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
</p-accordionTab>
</p-accordion>
<p-table #dt [value]="filteredJobs" [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"
@ -30,10 +36,19 @@
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value">
</div>
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label"
<p-dropdown #cl *ngIf="col.field === 'client.name' && !filterClientLocked" name="clients" [options]="clients" optionLabel="label"
[ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
<span *ngIf="col.field === 'client.name' && filterClientLocked">{{ currClient.label }}</span>
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter"
(onChange)="handleStatusFilter($event.value)"></p-dropdown>
<p-calendar *ngIf="col.field === 'startDate'" [(ngModel)]="startDateFilter" [locale]="locale"
[dateFormat]="locale.dateFormat" [showButtonBar]="true" [showIcon]="true" appendTo="body"
(onSelect)="onDateFilter($event, 'startDate')" (onClearClick)="onDateFilter(null, 'startDate')"
[style]="{'width':'100%'}" i18n-placeholder="@@filterDate" placeholder="Filter..."></p-calendar>
<p-calendar *ngIf="col.field === 'endDate'" [(ngModel)]="endDateFilter" [locale]="locale"
[dateFormat]="locale.dateFormat" [showButtonBar]="true" [showIcon]="true" appendTo="body"
(onSelect)="onDateFilter($event, 'endDate')" (onClearClick)="onDateFilter(null, 'endDate')"
[style]="{'width':'100%'}" i18n-placeholder="@@filterDate" placeholder="Filter..."></p-calendar>
<span *ngSwitchDefault></span>
</th>
</tr>
@ -86,28 +101,14 @@
</div>
<ng-template #dropdowns>
<div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad">
<div class="ui-g-8 ui-lg-8 ui-md-12 ui-sm-12 inline-flex-end">
<div class="ui-g">
<div class="ui-g-12">
<span i18n="@@filtJobsByCreatedDate">Filter Jobs By Created Date</span>
<p-calendar #calendar [(ngModel)]="selCalDate" selectionMode="range" [readonlyInput]="true"
[showButtonBar]="true" [showIcon]="true" (onClose)="onCalClose()"></p-calendar>
</div>
</div>
<p-dropdown [style]="dropdownStyle" [options]="dateOptions" [(ngModel)]="selDate"
(onChange)="onDropdownChange($event)">
<ng-template let-item pTemplate="item">
<div class="ui-g">
<div [ngClass]="isShowXBtn(item) ? 'ui-g-8' : 'ui-g-12'" class="ui-g-nopad">{{ item.label }}</div>
<div *ngIf="isShowXBtn(item)" class="ui-g-4 ui-g-nopad" style="text-align: center;"><button
style="border: unset; background: none; cursor: pointer;" class="pi pi-times"
(click)="onCalClick()"></button></div>
</div>
</ng-template>
</p-dropdown>
</div>
<div class="ui-g-4 ui-lg-4 ui-md-12 ui-sm-12 inline-flex-end">
<div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad cache-ttl-caption-controls">
<div class="ui-g-12 inline-flex-end">
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
(blur)="updateCacheTtl()" style="width: 3.5rem; margin-right: 6px;">
<span class="cache-ttl-help" tabindex="0">
<span class="cache-ttl-help-icon">?</span>
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
</span>
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
(onChange)="reloadChanged($event.value)">
</p-dropdown>

View File

@ -6,6 +6,7 @@ import { Subscription, interval } from 'rxjs';
import { SelectItem } from 'primeng/api';
import { Dropdown } from 'primeng/dropdown';
import { Table } from 'primeng/table';
import { FilterUtils } from 'primeng/utils';
import { IUIJob } from '../models/job.model';
import * as jobActions from '../actions/job.actions';
@ -26,8 +27,10 @@ import { Acre } from '@app/domain/models/subscription.model';
import { SUB, SubTexts, SubType } from '@app/profile/common';
import { InvoiceService } from '@app/domain/services/invoice.service';
import { RestoreTableState } from '@app/shared/restore-table-state';
import { SubscriptionService } from '@app/domain/services/subscription.service';
import { GAService } from '@app/shared/ga.service';
import { JobCacheService } from '@app/domain/services/job-cache.service';
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
@Component({
@ -38,23 +41,48 @@ import { GAService } from '@app/shared/ga.service';
export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
globals = globals;
readonly dropdownStyle = { 'min-width': '170px', 'color': 'black' };
readonly customeDate = 'customDate';
jobs: Array<IUIJob> = [];
filteredJobs: Array<IUIJob> = [];
currentJob: IUIJob;
currClient: SelectItem;
filterClientLocked = false;
clients: SelectItem[];
defaultInvoiceSetting;
private currentByTime: string[] | undefined;
private lastFiltersQuery: Record<string, any> | undefined;
private useCacheOnReturn = false;
cacheTtlSeconds: number;
jobFilterDefinitions: FilterDefinition[] = [];
readonly defaultDynamicFilters = [
...(!this.isClientUser ? [{ key: 'client', value: null }] : []),
{ key: 'createdAt', value: '1m' }
];
@ViewChild('dt') public dt: Table;
@ViewChild('cl') public cl: Dropdown;
@ViewChild('calendar') calendar: any;
private _cl: Dropdown;
@ViewChild('cl') set cl(dropdown: Dropdown) {
this._cl = dropdown;
if (dropdown) {
dropdown.registerOnChange((newVal) => {
this.currClient = newVal;
this.filteredJobs = newVal.value
? this.jobs.filter(j => j.client?._id === newVal.value)
: this.jobs;
this.dt.first = 0;
});
}
}
rows1Page = [10, 15, 30, 60, 100];
cols: any[];
status: SelectItem[] = [GC.selAll, ...GC.selJobStatuses];
statusFilter;
startDateFilter: Date;
endDateFilter: Date;
reloadOps: SelectItem[];
reloadBy = 0;
reload$: Subscription;
@ -63,13 +91,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
totalJobs;
acre: Acre;
dateOptions: {
label: string;
value: string;
}[];
selDate: string;
selCalDate: [Date, Date];
searchAccordionOpen = sessionStorage.getItem('job-list-accordion') === 'true';
get canWrite(): boolean {
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]);
@ -85,11 +108,13 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
private readonly datePipe: DatePipe,
private readonly invoiceSvc: InvoiceService,
private readonly restoreTableSvc: RestoreTableState,
private readonly subscriptionService: SubscriptionService,
private readonly gaService: GAService
private readonly gaService: GAService,
private readonly jobCache: JobCacheService,
private readonly listReturnCache: ListReturnCacheService
) {
super();
this.currClient = ({ label: globals.all, value: null });
this.cacheTtlSeconds = Math.round(this.jobCache.getTtlMs() / 1000);
this.totalJobs = { '=0': '', '=1': '1 ' + $localize`:@@job:job`.toLocaleLowerCase(), 'other': $localize`:@@total#Jobs:Total: # jobs` };
this.status = [
@ -130,8 +155,26 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
this.showStatusPlus = !this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR]);
this.defaultInvoiceSetting = this.invoiceSvc.defaultSetting;
this.dateOptions = this.subscriptionService.getDateOptions();
this.dateOptions.push({ label: $localize`:@@customDate:Custom Date`, value: this.customeDate });
(FilterUtils as any)['dateIs'] = (value: any, filter: any): boolean => {
if (!filter) { return true; }
if (!value) { return false; }
const valDate = new Date(value);
const filterDate = new Date(filter);
return valDate.getFullYear() === filterDate.getFullYear()
&& valDate.getMonth() === filterDate.getMonth()
&& valDate.getDate() === filterDate.getDate();
};
this.jobFilterDefinitions = [
...(!this.isClientUser ? [{ key: 'client', label: $localize`:@@client:Client`, dataType: 'select' as const, options: [] }] : []),
{ key: '_id', label: $localize`:@@id:Id` + ' ' + globals.num, dataType: 'text' as const },
{ key: 'orderNumber', label: $localize`:@@order:Order` + ' ' + globals.num, dataType: 'text' as const },
{ key: 'name', label: globals.name, dataType: 'text' as const },
{ key: 'startDate', label: $localize`:@@startDate:Start Date`, dataType: 'date' as const },
{ key: 'endDate', label: $localize`:@@endDate:End Date`, dataType: 'date' as const },
{ key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date-preset' as const, removable: false },
{ key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi' as const, options: GC.selJobStatuses },
];
}
ngOnInit() {
@ -144,6 +187,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
this.clients = clients.map(it => ({ value: it._id, label: it.name }));
if (!this.isClientUser) {
this.clients.unshift(({ label: globals.all, value: null }));
const clientDef = this.jobFilterDefinitions.find(f => f.key === 'client');
if (clientDef) { clientDef.options = this.clients; }
}
});
this.sub$.add(this.store.pipe(select(fromClients.getSelectedClient)).subscribe(client => {
@ -156,6 +201,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
})); this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
this.jobs = jobs;
this.filteredJobs = jobs;
}));
this.sub$.add(this.store.pipe(select(fromJobs.getSelectedJob)).subscribe((job) => {
this.currentJob = job;
@ -177,6 +223,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
this.acre = pkg[effectiveLookupKey]?.acre;
}
}));
this.useCacheOnReturn = this.listReturnCache.startVisit('jobs');
}
ngAfterViewInit(): void {
@ -190,32 +238,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
const invoiced = listFilter.filters.invoiceStatus?.value;
this.restoreStatusState(status, invoiced);
}
const storedDateSelection = sessionStorage.getItem('jobListSelDate');
if (storedDateSelection) {
const parsedDateSelection = JSON.parse(storedDateSelection);
if (parsedDateSelection.selDate) {
this.selDate = parsedDateSelection.selDate;
} else {
if (parsedDateSelection.selCalDate) {
if (parsedDateSelection.selCalDate[0] && parsedDateSelection.selCalDate[1]) {
this.selCalDate = [new Date(parsedDateSelection.selCalDate[0]), new Date(parsedDateSelection.selCalDate[1])];
} else {
this.selCalDate = [new Date(parsedDateSelection.selCalDate[0]), null];
}
}
this.selDate = this.selCalDate ? this.customeDate : this.dateOptions[0].value;
this.setCustomDateLabel();
}
}
if (this.cl) {
this.cl.registerOnChange((newVal) => {
this.store.dispatch(new clientActions.Select(<Client>({ _id: newVal.value })));
this.fetchJobsByClient(this.currClient.value);
this.dt.first = 0;
});
}
setTimeout(() => {
this.fetchJobsByClient(this.currClient.value);
if (this.dt.rows >= this.dt.totalRecords) {
this.dt.first = 0;
}
@ -258,22 +281,34 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
[jobListStatus.INVOICED]: jobInvoiceStatus.INVOICED
};
const byTime =
this.selDate
? this.selDate == this.customeDate
? this.selCalDate
: [this.selDate]
: [this.dateOptions[0].value];
const statusValue = statusMap[this.statusFilter] ?? jobListStatus.ALL;
this.store.dispatch(new jobActions.Fetch({
clientId: clientId,
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
byTime,
status: statusValue
byTime: this.currentByTime,
status: statusValue,
useCache: this.useCacheOnReturn
}));
}
onCreatedDateChanged(byTime: string[]): void {
this.currentByTime = byTime;
this.reloadJobs();
}
onDateFilter(value: Date, field: string) {
this.dt.filter(value, field, 'dateIs');
}
onAccordionToggle(expanded: boolean) {
sessionStorage.setItem('job-list-accordion', String(expanded));
}
updateCacheTtl(): void {
const ttlMs = this.jobCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
}
restoreTableFirst() {
this.restoreTableSvc.restoreTableFirst(this.dt);
}
@ -327,6 +362,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
duplicateJob() {
if (this.canAddNew) {
this.listReturnCache.markPending('jobs');
// Track bulk action (duplicate)
this.gaService.trackJobBulkAction({
user_id: this.authSvc.user?._id || 'anonymous',
@ -343,10 +379,12 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
editJob() {
this.listReturnCache.markPending('jobs');
this.router.navigate([`./${this.currentJob._id}/edit`], { relativeTo: this.route });
}
editJobMap() {
this.listReturnCache.markPending('jobs');
this.router.navigate([`./${this.currentJob._id}/editMap`, { flag: 0 }], { relativeTo: this.route });
}
@ -364,8 +402,18 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
reloadJobs() {
const startTime = performance.now();
this.jobCache.invalidate();
this.useCacheOnReturn = false;
if (this.lastFiltersQuery) {
this.store.dispatch(new jobActions.Fetch({
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
filters: JSON.stringify(this.lastFiltersQuery),
useCache: false
}));
} else {
this.fetchJobsByClient(this.currClient && this.currClient.value);
}
// Track job list reload
setTimeout(() => {
@ -421,6 +469,35 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
this.router.navigate(['/clients']);
}
onFiltersSubmit(event: FilterChangeEvent) {
const q = { ...event.query };
// Ensure createdAt always has a value so the server always applies a date range.
// Default to 'Past 1 Month' if the user has not added a Created Date filter.
if (!q.createdAt) {
q.createdAt = { value: '1m', operator: 'and', valueOperator: 'exact', dataType: 'date-preset' };
}
// Sync the table's client dropdown to match the client selected in the search filters.
const clientId = q.client?.value ?? null;
const matchedClient = this.clients?.find(c => c.value === clientId);
this.currClient = matchedClient || { label: globals.all, value: null };
this.filterClientLocked = !!clientId;
const filtersStr = JSON.stringify(q);
const prevFilters = sessionStorage.getItem('job-list-last-filters');
if (filtersStr !== prevFilters) {
this.useCacheOnReturn = false;
}
this.lastFiltersQuery = q;
sessionStorage.setItem('job-list-last-filters', filtersStr);
this.store.dispatch(new jobActions.Fetch({
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
filters: filtersStr,
useCache: this.useCacheOnReturn
}));
}
getUsers(byUsers) {
if (!byUsers || !Array.isArray(byUsers) || byUsers.length === 0) {
return '';
@ -487,96 +564,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}, 100);
}
private setJobListSelDate(dateSelection): void {
sessionStorage.setItem('jobListSelDate', JSON.stringify(dateSelection));
}
private setCustomDateLabel(): void {
const dateFormat = this.locale.dateFormat.replace(/(^|\/)mm(\/|$)/g, '$1MM$2');
if (!this.selCalDate) {
this.dateOptions.find(it => it.value === this.customeDate).label = $localize`:@@customDate:Custom Date`;
} else if (!this.selCalDate[1]) {
this.dateOptions.find(it => it.value === this.customeDate).label =
`${this.datePipe.transform(this.selCalDate[0], dateFormat)}`;
} else {
this.dateOptions.find(it => it.value === this.customeDate).label =
`${this.datePipe.transform(this.selCalDate[0], dateFormat)} - ${this.datePipe.transform(this.selCalDate[1], dateFormat)}`;
}
}
onDropdownChange(evt): void {
const previousCount = this.jobs?.length || 0;
if (evt.value === this.customeDate) {
setTimeout(() => this.showCal());
} else {
this.setJobListSelDate({ selDate: evt.value, selCalDate: null });
this.reloadJobs();
// Track date filter usage
setTimeout(() => {
const currentCount = this.jobs?.length || 0;
this.gaService.trackJobListFiltered({
user_id: this.authSvc.user?._id || 'anonymous',
platform: 'web',
filter_type: 'date',
filter_value: evt.value,
results_before: previousCount,
results_after: currentCount,
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
date_filter_type: this.getDateFilterType(evt.value)
});
}, 500);
}
}
onCalClose(): void {
const previousCount = this.jobs?.length || 0;
this.setCustomDateLabel();
if (this.selCalDate) {
this.setJobListSelDate({ selDate: null, selCalDate: this.selCalDate });
} else {
this.selDate = this.dateOptions[0].value;
this.setJobListSelDate({ selDate: this.selDate, selCalDate: null });
}
this.reloadJobs();
// Track custom date filter usage
if (this.selCalDate) {
setTimeout(() => {
const currentCount = this.jobs?.length || 0;
this.gaService.trackJobListFiltered({
user_id: this.authSvc.user?._id || 'anonymous',
platform: 'web',
filter_type: 'date',
filter_value: 'custom_date_range',
results_before: previousCount,
results_after: currentCount,
filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
date_filter_type: 'custom',
custom_date_range: [
this.selCalDate[0]?.toISOString().split('T')[0],
this.selCalDate[1]?.toISOString().split('T')[0]
]
});
}, 500);
}
}
onCalClick() {
setTimeout(() => this.showCal());
}
showCal() {
this.calendar?.el.nativeElement.querySelector('button')?.click();
}
isShowXBtn(item) {
return item?.value == this.customeDate && this.selDate == this.customeDate
}
// Helper method to count active filters
private getActiveFilterCount(): number {
let count = 0;
@ -592,7 +579,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
// Check date filter
if (this.selDate && this.selDate !== this.dateOptions[0]?.value) {
if (this.currentByTime && this.currentByTime.length > 0) {
count++;
}
@ -609,16 +596,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
return count;
}
// Helper method to determine date filter type
private getDateFilterType(value: string): 'today' | 'week' | 'month' | 'quarter' | 'custom' {
if (value === this.customeDate) return 'custom';
if (value?.includes('today')) return 'today';
if (value?.includes('week')) return 'week';
if (value?.includes('month')) return 'month';
if (value?.includes('quarter')) return 'quarter';
return 'custom';
}
ngOnDestroy() {
super.ngOnDestroy();
if (this.reload$) {

View File

@ -0,0 +1,120 @@
.buf-editor-panel {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
background: #fff;
max-width: 50em;
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
border-radius: 4px;
font-size: 13px;
pointer-events: auto;
}
.buf-editor-toolbar {
display: flex;
align-items: center;
padding: 6px 8px;
border-bottom: 1px solid #e0e0e0;
background: #f5f5f5;
border-radius: 4px 4px 0 0;
}
.buf-editor-title {
font-weight: 500;
font-size: 13px;
}
.buf-editor-drag-handle {
display: flex;
align-items: center;
cursor: grab;
margin-right: 6px;
color: #757575;
user-select: none;
}
.buf-editor-drag-handle:active {
cursor: grabbing;
}
.buf-editor-footer {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid #e0e0e0;
align-items: center;
}
.buf-editor-body {
padding: 8px 10px;
}
.buf-editor-name-row {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 0 !important;
}
.buf-editor-name-label {
font-size: 12px;
white-space: nowrap;
}
.buf-editor-name-input.ui-inputtext {
flex: 1;
font-size: 12px;
padding: 2px 4px !important;
}
.buf-editor-controls-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
}
.buf-editor-width-group {
display: flex;
align-items: center;
white-space: nowrap;
gap: 6px;
}
.buf-editor-slider {
width: 100px;
cursor: pointer;
accent-color: #388e3c;
}
.buf-editor-width-input.ui-inputtext {
width: 54px !important;
text-align: right;
padding: 2px 4px !important;
font-size: 12px;
margin: 0 2px 0 4px;
}
.buf-editor-unit-label {
font-size: 12px;
color: #555;
min-width: 16px;
}
.buf-editor-icon-btn.ui-button {
width: 28px !important;
height: 28px !important;
padding: 0 !important;
}
.buf-editor-create-another-label {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 12px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}

View File

@ -0,0 +1,65 @@
<div *ngIf="active" class="buf-editor-panel leaflet-bar"
[style.top.px]="panelTop" [style.left.px]="panelLeft"
(mousedown)="$event.stopPropagation()" (dblclick)="$event.stopPropagation()"
(wheel)="$event.stopPropagation()">
<!-- Toolbar / drag handle -->
<div class="buf-editor-toolbar">
<span class="buf-editor-drag-handle" (mousedown)="dragStart.emit($event)" title="Drag to move">
<i class="ui-icon-drag-handle"></i>
</span>
<span class="buf-editor-title">{{ title }}</span>
</div>
<!-- Instruction text (before geometry is ready) -->
<div *ngIf="!confirmReady && instructionText" class="buf-editor-body">
<span>{{ instructionText }}</span>
</div>
<!-- Controls: name + width (always shown once panel is active) -->
<div class="buf-editor-body buf-editor-name-row">
<label class="buf-editor-name-label" i18n="@@name">Name</label>
<input type="text" pInputText maxlength="20" class="buf-editor-name-input"
[value]="name" (input)="nameChange.emit($any($event.target).value)">
</div>
<div class="buf-editor-body buf-editor-controls-row">
<div class="buf-editor-width-group">
<label i18n="@@width">Width</label>
<input type="range" min="1" max="100" step="1" [value]="widthSlider"
class="buf-editor-slider"
(input)="widthSliderChange.emit(+$any($event.target).value)">
<input type="number" pInputText min="1" [max]="maxWidthInUnit" step="1" [value]="widthInUnit"
class="buf-editor-width-input"
(change)="widthInputChange.emit(+$any($event.target).value)">
<span class="buf-editor-unit-label">{{ widthUnit }}</span>
</div>
<p-selectButton *ngIf="isEdge" [options]="edgeSideOptions" [ngModel]="edgeSide"
[ngModelOptions]="{standalone: true}"
(onChange)="edgeSideChange.emit($event.value)"></p-selectButton>
</div>
<!-- Optional projected content (e.g. feature-type selector) -->
<ng-content></ng-content>
<!-- Footer buttons -->
<div class="buf-editor-footer">
<button *ngIf="isEdge && canFlip" pButton type="button" icon="ui-icon-swap-horiz"
class="ui-button-secondary buf-editor-icon-btn"
i18n-pTooltip="@@flipDirection" pTooltip="Flip direction" tooltipPosition="top"
(click)="flipClick.emit()"></button>
<button *ngIf="confirmReady" pButton type="button" icon="ui-icon-check"
class="green-btn buf-editor-icon-btn"
i18n-pTooltip="@@confirm" pTooltip="Confirm" tooltipPosition="top"
(click)="confirmClick.emit()"></button>
<button pButton type="button" icon="ui-icon-cancel"
class="orange-btn buf-editor-icon-btn"
i18n-pTooltip="@@cancel" pTooltip="Cancel" tooltipPosition="top"
(click)="cancelClick.emit()"></button>
<label *ngIf="confirmReady && !isEditing" class="buf-editor-create-another-label">
<input type="checkbox" [checked]="createAnother"
(change)="createAnotherChange.emit($any($event.target).checked)">
<span i18n="@@createAnother">Create another</span>
</label>
</div>
</div>

View File

@ -0,0 +1,45 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { SelectItem } from 'primeng/api';
@Component({
selector: 'app-buf-editor-panel',
templateUrl: './buf-editor-panel.component.html',
styleUrls: ['./buf-editor-panel.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BufEditorPanelComponent {
/** Whether the panel is visible. */
@Input() active = false;
/** Title shown in the toolbar. */
@Input() title = 'Buffer Zone';
/** True = edge-buffer mode (shows edge-side selector + flip button). */
@Input() isEdge = false;
/** True once the geometry is ready and Confirm should be shown. */
@Input() confirmReady = false;
/** Hides "Create another" when editing an existing buffer. */
@Input() isEditing = false;
/** Instruction text shown before confirmReady. */
@Input() instructionText = '';
@Input() name = '';
@Input() widthSlider = 0;
@Input() widthInUnit = 30;
@Input() maxWidthInUnit = 100;
@Input() widthUnit: 'm' | 'ft' = 'm';
@Input() edgeSideOptions: SelectItem[] = [];
@Input() edgeSide = 'on';
@Input() canFlip = false;
@Input() createAnother = false;
@Input() panelTop = 10;
@Input() panelLeft = 10;
@Output() nameChange = new EventEmitter<string>();
@Output() widthSliderChange = new EventEmitter<number>();
@Output() widthInputChange = new EventEmitter<number>();
@Output() edgeSideChange = new EventEmitter<string>();
@Output() flipClick = new EventEmitter<void>();
@Output() confirmClick = new EventEmitter<void>();
@Output() cancelClick = new EventEmitter<void>();
@Output() createAnotherChange = new EventEmitter<boolean>();
@Output() dragStart = new EventEmitter<MouseEvent>();
}

View File

@ -3,6 +3,25 @@
margin-top : 1em;
}
/* Projected content inside app-buf-editor-panel — must live here due to Angular view encapsulation */
.buf-editor-feature-row {
padding: 0 10px 10px 10px;
gap: 6px;
display: flex;
align-items: center;
font-size: 12px;
}
.buf-editor-feature-row label {
white-space: nowrap;
}
.buf-editor-feature-row select {
flex: 1;
font-size: 12px;
padding: 2px 4px;
}
.data-detail-box {
border: 1px solid lightgray;
}
@ -42,3 +61,193 @@
.loc-time {
padding-top: .45em;
}
/* Advanced Buffer Tools chooser panel */
.adv-buf-chooser-panel {
min-width: 200px;
}
.adv-buf-chooser-body {
padding: 10px 12px 6px;
display: flex;
flex-direction: column;
gap: 10px;
}
.adv-buf-chooser-label {
font-size: 12px;
color: #555;
}
.adv-buf-chooser-btns {
display: flex;
gap: 8px;
align-items: center;
}
.adv-buf-type-btn.ui-button {
width: 40px !important;
height: 40px !important;
padding: 0 !important;
font-size: 20px !important;
}
.adv-buf-type-btn.ui-button .ui-button-icon-left {
font-size: 20px;
margin-top: -10px;
margin-left: -10px;
}
.adv-buf-type-btn--disabled.ui-button {
opacity: 0.4;
cursor: not-allowed;
}
/* Edge Buffer overlay panel positioned inside the Leaflet map at top-left, matching Leaflet control pane */
.edge-buf-panel {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
background: #fff;
max-width: 50em;
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
border-radius: 4px;
font-size: 13px;
pointer-events: auto;
}
.edge-buf-panel-toolbar {
display: flex;
align-items: center;
padding: 6px 8px;
border-bottom: 1px solid #e0e0e0;
background: #f5f5f5;
border-radius: 4px 4px 0 0;
}
.edge-buf-panel-title {
font-weight: 500;
font-size: 13px;
}
.edge-buf-drag-handle {
display: flex;
align-items: center;
cursor: grab;
margin-right: 6px;
color: #757575;
user-select: none;
}
.edge-buf-drag-handle:active {
cursor: grabbing;
}
.edge-buf-panel-footer {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid #e0e0e0;
justify-content: flex-start;
}
.edge-buf-controls-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
}
.edge-buf-width-group {
display: flex;
align-items: center;
white-space: nowrap;
gap: 6px;
}
.edge-buf-slider {
width: 100px;
cursor: pointer;
accent-color: #388e3c;
}
.edge-buf-width-input.ui-inputtext {
width: 54px !important;
text-align: right;
padding: 2px 4px !important;
font-size: 12px;
margin: 0 2px 0 4px;
}
.edge-buf-unit-select {
font-size: 12px;
border: 1px solid #bdbdbd;
border-radius: 3px;
padding: 1px 2px;
cursor: pointer;
background: #fff;
}
.edge-buf-icon-btn.ui-button {
width: 28px !important;
height: 28px !important;
padding: 0 !important;
}
.edge-buf-panel-body {
padding: 8px 10px;
}
.edge-buf-name-row {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 0 !important;
}
.edge-buf-name-label {
font-size: 12px;
white-space: nowrap;
}
.edge-buf-name-input.ui-inputtext {
flex: 1;
font-size: 12px;
padding: 2px 4px !important;
}
.edge-buf-create-another-label {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 12px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
/* Edge Buffer snap-to-edge drawing mode */
.edge-buf-snap-marker {
width: 12px;
height: 12px;
background: #fff;
border: 2px solid #e65100;
border-radius: 50%;
}
.edge-buf-cursor-tip {
position: fixed;
pointer-events: none;
z-index: 9999;
background: rgba(0, 0, 0, 0.72);
color: #fff;
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
white-space: nowrap;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
left: -9999px;
top: -9999px;
}

View File

@ -71,7 +71,7 @@
<ng-template pTemplate="paginatorleft" let-state>
{{ state.totalRecords | i18nPlural: totalItems }}
</ng-template>
<ng-template pTemplate="emptymessage">
<ng-template pTemplate="empty sage">
<tr>
<td [attr.colspan]="4">
<div class="ui-messages-error" *ngIf="!hasItems()">
@ -157,6 +157,171 @@
</p-toolbar>
</div>
<div #map id="map" [style.height]="mapHeight" leaflet (leafletMapReady)="onMapReady($event)" [leafletOptions]="mapOps" [leafletLayers]="layers" [leafletLayersControl]="layersControl">
<!-- Advanced Buffer Tools: Buffer Creation Mode chooser panel -->
<div *ngIf="advBufPanelActive && !edgeBufActive && !segBufPanelActive && !featureBufActive && !featureBufPanelActive" class="edge-buf-panel leaflet-bar adv-buf-chooser-panel"
[style.top.px]="edgeBufPanelTop" [style.left.px]="edgeBufPanelLeft"
(mousedown)="$event.stopPropagation()" (dblclick)="$event.stopPropagation()"
(wheel)="$event.stopPropagation()">
<div class="edge-buf-panel-toolbar">
<span class="edge-buf-drag-handle" (mousedown)="onEdgeBufPanelDragStart($event)" title="Drag to move">
<i class="ui-icon-drag-handle"></i>
</span>
<span class="edge-buf-panel-title" i18n="@@advBufTitle">Buffer Creation Mode</span>
</div>
<div class="adv-buf-chooser-body">
<span class="adv-buf-chooser-label" i18n="@@advBufSelectType">Please select a buffer type</span>
<div class="adv-buf-chooser-btns">
<button pButton type="button" icon="ui-icon-timeline"
class="ui-button-secondary adv-buf-type-btn"
pTooltip="Segment" tooltipPosition="bottom"
(click)="onAdvBufSegment()"></button>
<button pButton type="button" icon="ui-icon-border-style"
class="ui-button-secondary adv-buf-type-btn"
pTooltip="Edge" tooltipPosition="bottom"
(click)="onAdvBufEdge()"></button>
<button pButton type="button" icon="ui-icon-terrain"
class="ui-button-secondary adv-buf-type-btn"
pTooltip="Feature" tooltipPosition="bottom"
(click)="onAdvBufFeature()"></button>
</div>
</div>
<div class="edge-buf-panel-footer">
<button pButton type="button" icon="ui-icon-cancel"
class="orange-btn edge-buf-icon-btn"
i18n-pTooltip="@@cancel" pTooltip="Cancel" tooltipPosition="top"
(click)="closeAdvBuf()"></button>
</div>
</div>
<!-- Segment Buffer Zone editor panel -->
<app-buf-editor-panel
[active]="segBufPanelActive"
title="Segment Buffer Zone"
[isEdge]="false"
[confirmReady]="segBufConfirmReady"
[isEditing]="false"
[name]="segBufName"
[widthSlider]="_bufWidthSlider"
[widthInUnit]="bufWidthInUnit"
[maxWidthInUnit]="maxBufInUnit"
[widthUnit]="bufWidthUnit"
[createAnother]="segBufCreateAnother"
[panelTop]="edgeBufPanelTop"
[panelLeft]="edgeBufPanelLeft"
(nameChange)="segBufName = $event"
(widthSliderChange)="onBufWidthSliderChange($event)"
(widthInputChange)="onBufWidthInputChange($event)"
(confirmClick)="confirmSegBuf()"
(cancelClick)="cancelSegBuf()"
(createAnotherChange)="segBufCreateAnother = $event"
(dragStart)="onEdgeBufPanelDragStart($event)">
</app-buf-editor-panel>
<!-- Feature Buffer Zone editor panel (water bodies + schools via OpenStreetMap) -->
<app-buf-editor-panel
[active]="featureBufPanelActive || featureBufLoading"
title="Feature Buffer Zone"
[isEdge]="false"
[confirmReady]="featureBufConfirmReady && !featureBufLoading"
[isEditing]="false"
[instructionText]="featureBufLoading ? 'Loading features from OpenStreetMap…' : (!featureBufConfirmReady ? 'No features found in the selected area.' : '')"
[name]="featureBufName"
[widthSlider]="_bufWidthSlider"
[widthInUnit]="bufWidthInUnit"
[maxWidthInUnit]="maxBufInUnit"
[widthUnit]="bufWidthUnit"
[createAnother]="featureBufCreateAnother"
[panelTop]="edgeBufPanelTop"
[panelLeft]="edgeBufPanelLeft"
(nameChange)="featureBufName = $event"
(widthSliderChange)="onBufWidthSliderChange($event)"
(widthInputChange)="onBufWidthInputChange($event)"
(confirmClick)="confirmFeatureBuf()"
(cancelClick)="cancelFeatureBuf()"
(createAnotherChange)="featureBufCreateAnother = $event"
(dragStart)="onEdgeBufPanelDragStart($event)">
<div class="buf-editor-feature-row">
<label class="buf-editor-name-label">Feature</label>
<select [value]="featureBufFeatureType"
(change)="onFeatureBufFeatureTypeChange($any($event.target).value)">
<option value="water">Water</option>
<option value="schools">Schools</option>
<option value="both">All</option>
</select>
</div>
</app-buf-editor-panel>
<!-- Edge Buffer Zone editor dialog (opened by clicking an existing buffer on the map) -->
<p-dialog header="Edge Buffer Zone" [(visible)]="edgeBufDlgVisible"
modal="true" [resizable]="false" [style]="{'width':'380px'}"
[contentStyle]="{'overflow':'visible'}"
styleClass="edge-buf-dialog"
(onHide)="cancelEdgeBuf()">
<div class="ui-g ui-g-fluid ui-g-nopad" style="margin-bottom: 16px">
<div class="ui-g-12 ui-g-nopad">
<div class="ui-g-4"><label i18n="@@name">Name</label></div>
<div class="ui-g-8">
<input type="text" pInputText maxlength="20"
[value]="edgeBufName" (input)="edgeBufName = $any($event.target).value">
</div>
</div>
<div class="ui-g-12 ui-g-nopad">
<div class="ui-g-4"><label i18n="@@width">Width</label></div>
<div class="ui-g-8">
<input type="number" pInputText min="1" [max]="maxBufInUnit" step="1"
[value]="bufWidthInUnit" style="width:120px"
(change)="onBufWidthInputChange(+$any($event.target).value)">
<span> {{ bufWidthUnit }}</span>
</div>
</div>
<div class="ui-g-12 ui-g-nopad">
<div class="ui-g-4"><label i18n="@@edgeSide">Edge Side</label></div>
<div class="ui-g-8">
<p-selectButton [options]="edgeSideOptions" [ngModel]="bufEdgeSide"
[ngModelOptions]="{standalone: true}"
styleClass="edge-buf-side-btn"
(onChange)="onEdgeSideChange($event.value)"></p-selectButton>
</div>
</div>
</div>
<p-footer>
<div class="ui-helper-clearfix">
<button *ngIf="canFlipEdgeBuf" pButton type="button" icon="ui-icon-swap-horiz"
class="ui-button-secondary" style="margin-right:4px"
i18n-label="@@flipDirection" label="Flip"
(click)="flipEdgeBufDirection()"></button>
<button pButton type="button" icon="ui-icon-save"
i18n-label="@@OK" label="OK"
(click)="confirmEdgeBuf()"></button>
</div>
</p-footer>
</p-dialog>
<!-- Edge Buffer Zone editor panel (floating, used during toolbar-driven creation) -->
<app-buf-editor-panel
[active]="edgeBufActive"
title="Edge Buffer Zone"
[isEdge]="true"
[confirmReady]="edgeBufConfirmReady"
[isEditing]="edgeBufIsEditing"
instructionText=""
[name]="edgeBufName"
[widthSlider]="_bufWidthSlider"
[widthInUnit]="bufWidthInUnit"
[maxWidthInUnit]="maxBufInUnit"
[widthUnit]="bufWidthUnit"
[edgeSideOptions]="edgeSideOptions"
[edgeSide]="bufEdgeSide"
[canFlip]="canFlipEdgeBuf"
[createAnother]="edgeBufCreateAnother"
[panelTop]="edgeBufPanelTop"
[panelLeft]="edgeBufPanelLeft"
(nameChange)="edgeBufName = $event"
(widthSliderChange)="onBufWidthSliderChange($event)"
(widthInputChange)="onBufWidthInputChange($event)"
(edgeSideChange)="onEdgeSideChange($event)"
(flipClick)="flipEdgeBufDirection()"
(confirmClick)="confirmEdgeBuf()"
(cancelClick)="cancelEdgeBuf()"
(createAnotherChange)="edgeBufCreateAnother = $event"
(dragStart)="onEdgeBufPanelDragStart($event)">
</app-buf-editor-panel>
</div>
</div>
</div>
@ -212,10 +377,18 @@
<label for="width" i18n="@@width">Width</label>
</div>
<div class="ui-g-8">
<input id="width" name="width" type="number" [min]="minBuf" [max]="maxBuf" step="0.5" [(ngModel)]="curItem.width" pInputText pKeyFilter="pnum" style="width:120px">
<input id="width" name="width" type="number" [min]="minBuf" [max]="maxBuf" step="1" [(ngModel)]="curItem.width" pInputText pKeyFilter="pnum" style="width:120px">
<span>{{ job.measureUnit | lengthUnit }}</span>
</div>
</div>
<div *ngIf="curItem.type === ITEM.BUFFER && curItem.edgeSide != null" class="ui-g-12 ui-g-nopad">
<div class="ui-g-4">
<label i18n="@@edgeSide">Edge Side</label>
</div>
<div class="ui-g-8">
<p-selectButton [options]="edgeSideOptions" [(ngModel)]="curItem.edgeSide" name="edgeSide"></p-selectButton>
</div>
</div>
</div>
<div class="ui-g-12 ui-g-nopad" *ngIf="curItem.type === ITEM.SPRAY">
<div class="ui-g-12 ui-g-nopad">
@ -583,6 +756,8 @@
</div>
</p-dialog>
<p-dialog #gridGen position="topleft" showEffect="fade" [(visible)]="gridGenOn" header="" [resizable]="false" [closable]="false" [closeOnEscape]="false" [contentStyle]="{'overflow':'visible'}" [style]="{ 'width': '300px'}" [modal]="false">
<div class="ui-g-12 ui-g-nopad">
<p-toolbar>
@ -627,6 +802,7 @@
</div>
</p-dialog>
<p-dialog #playbackSpr position="topleft" showEffect="fade" [(visible)]="playbackOn" header="" [resizable]="false" [closable]="false" [closeOnEscape]="false" [contentStyle]="{'overflow':'visible'}" [style]="{ 'width': '360px' }" [modal]="false" (onShow)="onPlayDlgShow()">
<div class="ui-g-12 ui-g-nopad">
<p-toolbar>
@ -850,7 +1026,6 @@
<div class="ui-g-4 data-field field-name">Pilot Name</div>
<div class="ui-g-8 data-field">{{curPlayRec.pilotName}}</div>
<div class="ui-g-4 data-field field-name">Applic.Rate</div>
<!-- <div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:null:false }}</div> -->
<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>

View File

@ -23,11 +23,15 @@ import { TooltipModule } from 'primeng/tooltip';
import { TabViewModule } from 'primeng/tabview';
import { SliderModule } from 'primeng/slider';
import { OrderListModule } from 'primeng/orderlist';
import { AccordionModule } from 'primeng/accordion';
import { SelectButtonModule } from 'primeng/selectbutton';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import * as fromJobs from './reducers/jobs.reducer';
import { JobEffects } from './effects/job.effects';
import * as fromClients from '../client/reducers/clients.reducer';
import { ClientEffects } from '../client/effects/client.effects';
import { JobMgtComponent } from './job-mgt.component';
import { AppSharedModule } from '../shared/app-shared.module';
@ -35,6 +39,7 @@ import { JobListComponent } from './job-list/job-list.component';
import { JobEditComponent } from './job-edit/job-edit.component';
import { JobAssignmentComponent } from './job-assignment/job-assignment.component';
import { JobMapEditComponent } from './job-map-edit/job-map-edit.component';
import { BufEditorPanelComponent } from './job-map-edit/buf-editor-panel/buf-editor-panel.component';
import { JobsRoutingModule } from './job-routing.module';
import { InvoicesModule } from '@app/invoices/invoices.module';
@ -44,14 +49,15 @@ import { InvoicesModule } from '@app/invoices/invoices.module';
LeafletModule,
PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule,
CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule,
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule,
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule,
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule, AccordionModule,
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule, SelectButtonModule,
JobsRoutingModule,
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
EffectsModule.forFeature([JobEffects]), InvoicesModule,
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
EffectsModule.forFeature([JobEffects, ClientEffects]), InvoicesModule,
],
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent, BufEditorPanelComponent],
providers: [DatePipe],
schemas: [
CUSTOM_ELEMENTS_SCHEMA

View File

@ -29,6 +29,12 @@ export interface BufferZone {
type: ITEM;
name?: string;
width: number;
/** Computed display offset in metres (derived from edgeSide + width). */
offset?: number;
/** Which side of the traced edge the corridor occupies. */
edgeSide?: 'on' | 'inside' | 'outside';
/** Winding sign of the source polygon ring: +1 = CCW (left of travel = inside), -1 = CW. */
edgeSign?: number;
}
export interface MapFeature {

View File

@ -23,6 +23,10 @@
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
</div>
<p-dropdown *ngIf="col.field === 'active'" [options]="statuses" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
<div class="input-with-icon" *ngIf="col.field === 'createdAt'">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span>
</th>
</tr>

View File

@ -1,3 +1,8 @@
*:focus {
outline: none;
}
.ui-g-12.ui-lg-10.ui-xl-8 {
min-width: 19rem;
width:100vw;
}

View File

@ -1,22 +1,19 @@
<div class="ui-g">
<div class="ui-g-12 ui-lg-10 ui-xl-8" style="margin: auto;">
<div class="ui-g" style="padding: 1em;">
<h1 style="margin-bottom: 1em;" i18n="@@billingAddresses">Billing Addresses</h1>
<div class="ui-g-12 ui-lg-10 ui-xl-8">
<div class="ui-g">
<div class="ui-g-12 card in-card-pad">
<p class="large-font align-vertical" i18n="@@selBillingAddress">Select a billing address</p>
<h1 class="large-font align-vertical" i18n="@@selBillingAddress" style="margin-bottom:1rem;">Billing Addresses</h1>
<hr style="width: 100%;margin-bottom:1rem;" />
<div class="ui-g-12 card in-card-pad">
<ng-container *ngIf="user.addresses?.length > 0">
<ng-container *ngTemplateOutlet="header"></ng-container>
<ng-container *ngTemplateOutlet="content"></ng-container>
</ng-container>
<button type="button" pButton icon="ui-icon-plus" i18n-label="@@addAdr" label="Add Address" (click)="add()"></button>
<span class="ui-message ui-messages-error" style="width: 100%; font-size: 1em;">{{error}}</span>
</div>
<hr style="width: 100%;" />
<div class="ui-g-12" style="text-align: right;">
<div class="ui-g-12">
<ng-container *ngTemplateOutlet="btn"></ng-container>
</div>
</div>
@ -26,7 +23,7 @@
<ng-template #header>
<div class="ui-g ui-g-nopad" style="justify-content: space-around;">
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@address">Address</ng-container></strong></div>
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@address">Select a billing address</ng-container></strong></div>
<div class="ui-g-2 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@name">Name</ng-container></strong></div>
<div class="ui-g-4 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@cityStateZip">City, State, Zip/Postal Code</ng-container></strong></div>
</div>
@ -55,9 +52,8 @@
<ng-template #btn>
<button pButton type="button" i18n-label="@@back" label="Back" class="inline-space" (click)="gotoMySubs()"></button>
<ng-container *ngIf="user.addresses?.length > 1">
<button pButton type="button" [disabled]="!selectedAddress || selectedAddress.isBilling" [label]="SubTexts.labelChngBilAddr" (click)="changeBilAdr(selectedAddress)"></button>
</ng-container>
<button type="button" pButton icon="ui-icon-plus" i18n-label="@@addAdr" label="Add Address" (click)="add()"></button>
<button *ngIf="user.addresses?.length > 1" style="margin-left: 0.5rem;" pButton type="button" [disabled]="!selectedAddress || selectedAddress.isBilling" [label]="SubTexts.labelChngBilAddr" (click)="changeBilAdr(selectedAddress)"></button>
</ng-template>
<p-dialog [(visible)]="displayAddressDialog" [style]="{'width': '600px'}" [contentStyle]="{'overflow':'visible'}" resizable="false" modal="true">

View File

@ -1,6 +1,9 @@
<ng-container *ngIf="isCompLoaded(); else err">
<div class="ui-g">
<div class="ui-g-12 ui-md-11 ui-lg-10 ui-xl-8" style="margin: auto;;min-width: 564px">
<div class="ui-g-12">
<div class="card clearfix">
<div class="ui-g">
<div class="ui-g-12 ui-md-11 ui-lg-10 ui-xl-8" style="margin: auto;">
<div class="ui-g">
<h1 style="margin-bottom: 1em;" i18n="@@pmtHist">Payment history</h1>
<div class="ui-g ui-g-12">
@ -60,6 +63,9 @@
</div>
</div>
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #err>

View File

@ -1,4 +1,4 @@
<div class="ui-g" style="max-width: 1025px">
<div class="ui-g">
<div class="ui-g-12">
<div class="card card-w-title">
<h1>{{ isApplicator ? globals.applProfile : globals.userProfile }}</h1>

View File

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../domain/guards/auth.guard';
import { ReleaseNotesComponent } from './release-notes.component';
const routes: Routes = [
{
path: '',
component: ReleaseNotesComponent,
canActivate: [AuthGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ReleaseNotesRoutingModule { }

View File

@ -0,0 +1,212 @@
.changelog-empty {
padding: 2rem 1rem;
color: #666;
font-style: italic;
}
.changelog-releases-layout {
display: flex;
gap: 0;
align-items: stretch;
background: none;
}
.changelog-left-column {
flex: 0 0 270px;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: flex-start;
position: sticky;
top: 9.5vh;
max-height: 100vh;
overflow-y: auto;
background: none;
box-sizing: border-box;
}
.changelog-releases-wrapper {
/* width controlled by parent column */
}
.changelog-toc-wrapper {
/* width controlled by parent column */
}
/* Ensure PrimeNG p-panel custom elements stretch to full column width */
:host ::ng-deep .changelog-left-column p-panel {
display: block;
}
/* Make the release content panel fill the column and scroll only inside */
:host ::ng-deep .changelog-releases-content p-panel,
:host ::ng-deep .changelog-releases-content .ui-panel {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
:host ::ng-deep .changelog-releases-content .ui-panel-content-wrapper {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
:host ::ng-deep .changelog-releases-content .ui-panel-content {
height: 100%;
overflow-y: auto;
box-sizing: border-box;
}
/* Collapse/expand toggle button */
.changelog-left-toggle {
position: sticky;
top: calc(50vh - 1rem);
align-self: flex-start;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
border: 0;
background: #4CAF50;
cursor: pointer;
border-radius: 4px 0 0 4px;
color: #ffffff;
font-size: 0.85rem;
padding: 0.4rem 0;
box-shadow: -2px 0 6px rgba(0, 0, 0, 0.35);
transition: background 0.2s ease;
z-index: 2;
margin-left: -0.75rem;
}
.changelog-left-toggle--hidden {
margin-left: 0;
border-radius: 0 4px 4px 0;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.35);
}
.changelog-left-toggle:hover {
background: #2E7D32;
}
/* Resize handle between left column and content */
.changelog-left-resize {
flex: 0 0 3px;
cursor: col-resize;
background: none;
position: relative;
align-self: stretch;
z-index: 1;
}
.changelog-left-resize::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 2px;
right: 2px;
background: transparent;
border-radius: 3px;
transition: background 0.15s;
}
.changelog-left-resize,
.changelog-left-resize {
background: #8fa3bb;
}
.changelog-releases-content {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
background: #ffffff;
height: 85vh;
overflow: hidden;
}
.changelog-releases-list {
list-style: none;
padding: 0;
margin: 0;
}
.changelog-releases-list li {
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.changelog-releases-item--active > a {
font-weight: 700;
color: #1a4f7a;
}
.changelog-releases-latest-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
background: #2e7d32;
color: #fff;
border-radius: 3px;
padding: 0 0.35rem;
margin-left: 0.35rem;
vertical-align: middle;
line-height: 1.5;
}
.changelog-releases-group {
list-style: none;
}
.changelog-releases-group-header {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.3rem;
font-weight: 600;
color: #3a5068;
text-decoration: none;
padding: 0.35rem 0;
cursor: pointer;
}
.changelog-releases-group-header:hover {
color: #1a4f7a;
}
.changelog-releases-group-icon {
font-size: 0.75rem;
line-height: 1;
}
.changelog-releases-group-list {
padding-left: 1rem;
}
@media (max-width: 640px) {
.changelog-releases-layout {
flex-direction: column;
}
.changelog-left-column {
flex-basis: auto !important;
max-width: none !important;
width: 100%;
position: static;
max-height: none;
overflow-y: visible;
}
.changelog-left-toggle {
display: none;
}
.changelog-left-resize {
display: none;
}
}

View File

@ -0,0 +1,112 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card card-w-title">
<div *ngIf="loading" style="text-align:center; padding: 2rem;">
<p-progressSpinner></p-progressSpinner>
</div>
<div *ngIf="!loading && !sections.length" class="changelog-empty" i18n="@@noReleaseNotes">
No release notes have been uploaded yet.
</div>
<div *ngIf="!loading && sections.length" class="changelog-releases-layout">
<!-- Left column: Release Notes + TOC -->
<div
*ngIf="leftColumnVisible"
class="changelog-left-column"
[style.flex-basis.px]="leftColumnWidth"
[style.max-width.px]="leftColumnWidth">
<div class="changelog-releases-wrapper">
<p-panel header="Release Notes" i18n-header="@@releaseNotes" [toggleable]="true">
<ul class="changelog-releases-list">
<!-- Latest release (top-level) -->
<ng-container *ngIf="latestSectionIndex !== -1">
<li [class.changelog-releases-item--active]="latestSectionIndex === activeSectionIndex">
<a href="#" (click)="selectSection($event, latestSectionIndex)">
{{ sections[latestSectionIndex].title }}
<span class="changelog-releases-latest-badge" i18n="@@latest">Latest</span>
</a>
</li>
</ng-container>
<!-- Previous Releases collapsible group -->
<li *ngIf="sections.length > 1" class="changelog-releases-group">
<a href="#" class="changelog-releases-group-header" (click)="togglePreviousReleases($event)">
<span class="changelog-releases-group-icon">{{ previousReleasesExpanded ? '&#x25BE;' : '&#x25B8;' }}</span>
<span i18n="@@previousReleases">Previous Releases</span>
</a>
<ul *ngIf="previousReleasesExpanded" class="changelog-releases-list changelog-releases-group-list">
<li
*ngFor="let section of sections; let i = index"
[class.changelog-releases-item--active]="i === activeSectionIndex"
[style.display]="i === latestSectionIndex ? 'none' : ''">
<a href="#" (click)="selectSection($event, i)">{{ section.title }}</a>
</li>
</ul>
</li>
<!-- No latest marked: show all flat -->
<ng-container *ngIf="latestSectionIndex === -1">
<li
*ngFor="let section of sections; let i = index"
[class.changelog-releases-item--active]="i === activeSectionIndex">
<a href="#" (click)="selectSection($event, i)">{{ section.title }}</a>
</li>
</ng-container>
</ul>
</p-panel>
</div>
<!-- Table of Contents p-panel (populated from markdown-viewer output) -->
<div *ngIf="tocItems.length" class="changelog-toc-wrapper">
<p-panel header="Table of Contents" i18n-header="@@tableOfContents" [toggleable]="true">
<ul class="changelog-releases-list">
<li *ngFor="let item of tocItems">
<a href="#" (click)="scrollToContent($event, item.anchorId)">{{ item.label }}</a>
</li>
</ul>
</p-panel>
</div>
</div><!-- end .changelog-left-column -->
<!-- Toggle button: always in the flex row, sticky at 50vh -->
<button
type="button"
class="changelog-left-toggle"
[class.changelog-left-toggle--hidden]="!leftColumnVisible"
(click)="toggleLeftColumn()"
[title]="leftColumnVisible ? 'Hide sidebar' : 'Show sidebar'">
<span>{{ leftColumnVisible ? '&#x276E;' : '&#x276F;' }}</span>
</button>
<!-- Resize handle -->
<div
*ngIf="leftColumnVisible"
class="changelog-left-resize"
(mousedown)="onResizeStart($event)">
</div>
<!-- Active release content -->
<div class="changelog-releases-content" *ngIf="sections.length">
<p-panel
[header]="sections[activeSectionIndex].title"
[toggleable]="true"
styleClass="changelog-panel">
<app-markdown-viewer
[markdown]="activeMarkdown"
[showFindBar]="true"
(tocItemsChange)="tocItems = $event">
</app-markdown-viewer>
</p-panel>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,121 @@
import { HttpClient } from '@angular/common/http';
import { Component, HostListener, OnInit, ViewChild } from '@angular/core';
import { forkJoin } from 'rxjs';
import { MarkdownViewerComponent } from '../shared/markdown-viewer/markdown-viewer.component';
interface ReleaseManifestEntry {
fileName: string;
title: string;
ver: string;
}
interface ReleaseSection {
title: string;
markdown: string;
}
@Component({
selector: 'app-release-notes',
templateUrl: './release-notes.component.html',
styleUrls: ['./release-notes.component.css']
})
export class ReleaseNotesComponent implements OnInit {
@ViewChild(MarkdownViewerComponent) markdownViewer?: MarkdownViewerComponent;
private readonly releasesApiBase = '/releases';
private readonly releasesStaticBase = '/releases';
sections: ReleaseSection[] = [];
loading = false;
activeSectionIndex = 0;
latestSectionIndex = -1;
previousReleasesExpanded = false;
tocItems: { label: string; anchorId: string }[] = [];
leftColumnVisible = true;
leftColumnWidth = 270;
private isResizing = false;
private resizeStartX = 0;
private resizeStartWidth = 0;
constructor(private readonly http: HttpClient) {}
ngOnInit(): void {
this.loadManifest();
}
get activeMarkdown(): string {
return this.sections[this.activeSectionIndex]?.markdown ?? '';
}
private loadManifest(): void {
this.loading = true;
this.http.get<ReleaseManifestEntry[]>(this.releasesApiBase).subscribe({
next: (revisions) => {
if (!revisions || revisions.length === 0) {
this.loading = false;
return;
}
const requests = revisions.map((r: ReleaseManifestEntry) =>
this.http.get(`${this.releasesStaticBase}/${encodeURIComponent(r.fileName)}`, { responseType: 'text' })
);
forkJoin(requests).subscribe({
next: (markdownFiles: string[]) => {
this.sections = markdownFiles.map((md: string, i: number) => ({
title: revisions[i].title || revisions[i].fileName.replace(/\.md$/i, ''),
markdown: md
}));
// Server returns entries sorted descending by ver; index 0 is always the latest.
this.latestSectionIndex = revisions.length > 0 ? 0 : -1;
this.activeSectionIndex = 0;
this.loading = false;
},
error: () => { this.loading = false; }
});
},
error: () => { this.loading = false; }
});
}
selectSection(event: MouseEvent, index: number): void {
event.preventDefault();
this.activeSectionIndex = index;
}
scrollToContent(event: MouseEvent, anchorId: string): void {
event.preventDefault();
this.markdownViewer?.scrollToId(anchorId);
}
togglePreviousReleases(event: MouseEvent): void {
event.preventDefault();
this.previousReleasesExpanded = !this.previousReleasesExpanded;
}
toggleLeftColumn(): void {
this.leftColumnVisible = !this.leftColumnVisible;
}
onResizeStart(event: MouseEvent): void {
this.isResizing = true;
this.resizeStartX = event.clientX;
this.resizeStartWidth = this.leftColumnWidth;
event.preventDefault();
}
@HostListener('document:mousemove', ['$event'])
onResizeMove(event: MouseEvent): void {
if (this.isResizing) {
const delta = event.clientX - this.resizeStartX;
this.leftColumnWidth = Math.max(160, Math.min(480, this.resizeStartWidth + delta));
}
}
@HostListener('document:mouseup')
onResizeEnd(): void {
this.isResizing = false;
}
}

View File

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { PanelModule } from 'primeng/panel';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { AppSharedModule } from '../shared/app-shared.module';
import { ReleaseNotesRoutingModule } from './release-notes-routing.module';
import { ReleaseNotesComponent } from './release-notes.component';
@NgModule({
imports: [
AppSharedModule,
ReleaseNotesRoutingModule,
PanelModule,
ProgressSpinnerModule
],
declarations: [ReleaseNotesComponent]
})
export class ReleaseNotesModule { }

View File

@ -0,0 +1,79 @@
import { createAction, props } from '@ngrx/store';
import { ApiKey, CreateApiKeyRequest, CreateApiKeyResponse } from '../api-keys/models/api-key.model';
export const loadApiKeys = createAction(
'[ApiKey] Load Keys',
props<{ ownerId?: string }>()
);
export const loadApiKeysSuccess = createAction(
'[ApiKey] Load Keys Success',
props<{ keys: ApiKey[] }>()
);
export const loadApiKeysFailure = createAction(
'[ApiKey] Load Keys Failure',
props<{ error: string }>()
);
export const createApiKey = createAction(
'[ApiKey] Create Key',
props<{ request: CreateApiKeyRequest }>()
);
export const createApiKeySuccess = createAction(
'[ApiKey] Create Key Success',
props<{ response: CreateApiKeyResponse }>()
);
export const createApiKeyFailure = createAction(
'[ApiKey] Create Key Failure',
props<{ error: string }>()
);
export const revokeApiKey = createAction(
'[ApiKey] Revoke Key',
props<{ keyId: string; ownerId?: string }>()
);
export const revokeApiKeySuccess = createAction(
'[ApiKey] Revoke Key Success',
props<{ keyId: string; ownerId?: string }>()
);
export const revokeApiKeyFailure = createAction(
'[ApiKey] Revoke Key Failure',
props<{ error: string }>()
);
export const dismissNewKey = createAction('[ApiKey] Dismiss New Key');
export const deleteApiKey = createAction(
'[ApiKey] Delete Key',
props<{ keyId: string; ownerId?: string }>()
);
export const deleteApiKeySuccess = createAction(
'[ApiKey] Delete Key Success',
props<{ keyId: string; ownerId?: string }>()
);
export const deleteApiKeyFailure = createAction(
'[ApiKey] Delete Key Failure',
props<{ error: string }>()
);
export const regenerateApiKey = createAction(
'[ApiKey] Regenerate Key',
props<{ keyId: string; ownerId?: string }>()
);
export const regenerateApiKeySuccess = createAction(
'[ApiKey] Regenerate Key Success',
props<{ response: CreateApiKeyResponse; ownerId?: string }>()
);
export const regenerateApiKeyFailure = createAction(
'[ApiKey] Regenerate Key Failure',
props<{ error: string }>()
);

View File

@ -0,0 +1,173 @@
/* New Key Banner */
.new-key-banner {
background: #e8f5e9;
border: 1px solid #A5D6A7;
border-radius: 0.25rem;
padding: 1rem;
margin-bottom: 1rem;
}
.new-key-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
.new-key-header i {
font-size: 1.2rem;
color: #2E7D32;
}
.new-key-value-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.new-key-value {
flex: 1 1 auto;
background: #fff;
border: 1px solid #A5D6A7;
border-radius: 0.2rem;
padding: 0.35rem 0.6rem;
font-family: 'Courier New', monospace;
font-size: 0.82rem;
word-break: break-all;
color: #2E7D32;
}
/* Table */
.revoked-row {
opacity: 0.55;
}
.badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 0.75rem;
font-size: 0.8rem;
font-weight: 600;
}
.badge-active {
background: #4CAF50;
border: 1px solid #2E7D32;
color: #fff;
}
.badge-revoked {
background: #f44336;
border: 1px solid #d32f2f;
color: #fff;
}
.empty-message {
text-align: center;
padding: 2rem 1rem;
color: #777;
}
/* Row expansion */
.row-expansion > td {
background: #f9f9f9;
border-top: none;
padding: 0.75rem 1rem 1rem 1rem;
}
.expansion-grid {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
width: 100%;
}
.expansion-item {
display: flex;
flex-direction: column;
flex: 1;
min-width: 8.75rem;
}
.expansion-label {
font-size: 0.75rem;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.2rem;
}
.expansion-value {
font-size: 0.9rem;
color: #333;
}
.expansion-actions {
justify-content: flex-end;
align-self: center;
}
@media (max-width: 767px) {
.expansion-grid {
flex-direction: column;
gap: 0;
}
.expansion-item {
flex-direction: row;
align-items: baseline;
flex: unset;
min-width: unset;
padding: 0.5em 0;
border-bottom: 1px solid #f0f0f0;
}
.expansion-item:last-child {
border-bottom: none;
}
.expansion-label {
display: inline-block;
min-width: 40%;
margin-bottom: 0;
margin-right: 1em;
vertical-align: top;
}
.expansion-value {
display: inline-block;
vertical-align: top;
}
}
/* Create dialog */
.create-form {
padding: 1rem;
}
.form-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.form-field {
flex: 1;
min-width: 12.5rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-field label {
font-family: "Roboto", "Helvetica Neue", sans-serif;
font-size: 0.75rem;
font-weight: 500;
color: #757575;
text-transform: uppercase;
letter-spacing: 0.03em;
}

View File

@ -0,0 +1,244 @@
<div class="ui-g" *ngIf="!toggleable; else toggleablePanel">
<div class="ui-g-12">
<div class="card">
<ng-container *ngTemplateOutlet="content"></ng-container>
</div>
</div>
</div>
<ng-template #toggleablePanel>
<p-panel i18n-header="@@apiKeys" header="API Keys"
[toggleable]="true" [collapsed]="collapsed">
<ng-container *ngTemplateOutlet="content"></ng-container>
</p-panel>
</ng-template>
<ng-template #content>
<p-messages *ngIf="error$ | async as err" severity="error">
<ng-template pTemplate>{{ err }}</ng-template>
</p-messages>
<!-- New key banner — shown after creation -->
<div *ngIf="newKey" class="new-key-banner">
<div class="new-key-header">
<i class="ui-icon-vpn-key"></i>
<span i18n="@@newKeyCreated">Key <strong>{{ newKey.label }}</strong><ng-container *ngIf="newKeyOwnerLabel"> for <strong>{{ newKeyOwnerLabel }}</strong></ng-container> created. Copy it now — it will not be shown again.</span>
</div>
<div class="new-key-value-row">
<code class="new-key-value">{{ newKey.key }}</code>
<button pButton type="button" icon="ui-icon-content-copy"
[label]="keyCopied ? ('Copied!' | titlecase) : 'Copy'"
[class]="keyCopied ? 'ui-button-secondary' : 'ui-button-primary'"
(click)="copyKey()">
</button>
<button pButton type="button" icon="ui-icon-close"
class="ui-button-secondary"
i18n-label="@@dismiss" label="Dismiss"
(click)="dismissNewKey()">
</button>
</div>
</div>
<!-- Dynamic filters (admin only) -->
<p-accordion *ngIf="isAdmin && !isMasterAccount && !ownerId" styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
<p-accordionTab i18n-header="@@searchApiKeys" header="Search API Keys" [transitionOptions]="'250ms'"
[selected]="filterAccordionOpen"
(selectedChange)="filterAccordionOpen = $event; onAccordionToggle($event)">
<agm-dynamic-filter
[filterDefinitions]="filterDefinitions"
[locale]="locale"
[autoSaveOnChange]="true"
stateKey="api-key-manager-filters"
(filtersChanged)="onFiltersChanged($event)"
(filtersSubmit)="onFiltersSubmit($event)">
</agm-dynamic-filter>
</p-accordionTab>
</p-accordion>
<!-- Keys table -->
<p-table #dt [value]="filteredKeys$ | async" [columns]="cols" [loading]="loading$ | async"
[paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[10, 15, 30]"
[alwaysShowPaginator]="true" dataKey="_id" [responsive]="true"
selectionMode="single" [(selection)]="selectedKey"
[(expandedRowKeys)]="expandedRows"
[resetPageOnSort]="false">
<ng-template pTemplate="caption">
<span class="table-caption-1" i18n="@@apiKeys">API Keys</span>
</ng-template>
<ng-template pTemplate="header" let-columns>
<tr>
<th style="width: 3rem;"></th>
<th *ngFor="let col of columns" [pSortableColumn]="col.field">
{{ col.header }}
<p-sortIcon [field]="col.field"></p-sortIcon>
</th>
</tr>
<tr>
<th></th>
<th *ngFor="let col of columns" [ngSwitch]="col.field" class="ui-fluid">
<div class="input-with-icon" *ngSwitchCase="'owner.name'">
<i class="ui-icon-search"></i>
<input pInputText type="text"
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value || ''">
</div>
<div class="input-with-icon" *ngSwitchCase="'owner.username'">
<i class="ui-icon-search"></i>
<input pInputText type="text"
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value || ''">
</div>
<div class="input-with-icon" *ngSwitchCase="'owner.contact'">
<i class="ui-icon-search"></i>
<input pInputText type="text"
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value || ''">
</div>
<div class="input-with-icon" *ngSwitchCase="'label'">
<i class="ui-icon-search"></i>
<input pInputText type="text"
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value || ''">
</div>
<div class="input-with-icon" *ngSwitchCase="'prefix'">
<i class="ui-icon-search"></i>
<input pInputText type="text"
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value || ''">
</div>
<div class="input-with-icon" *ngSwitchCase="'service'">
<i class="ui-icon-search"></i>
<input pInputText type="text"
(input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
[value]="dt.filters[col.field]?.value || ''">
</div>
<p-dropdown *ngIf="col.field === 'active'" [options]="statusOptions" [style]="{'width':'100%'}"
[ngModel]="dt.filters['active']?.value"
(onChange)="dt.filter($event.value, 'active', 'equals')">
</p-dropdown>
<span *ngSwitchDefault></span>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-key let-expanded="expanded">
<tr [class.revoked-row]="!key.active" [pSelectableRow]="key">
<td>
<button pButton type="button"
[icon]="expanded ? 'ui-icon-keyboard-arrow-up' : 'ui-icon-keyboard-arrow-down'"
class="ui-button-text ui-button-plain"
[pRowToggler]="key">
</button>
</td>
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@name">Name</span>{{ key.owner?.name || '—' }}</td>
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@userName">Username</span>{{ key.owner?.username || '—' }}</td>
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@contact">Contact</span>{{ key.owner?.contact || '—' }}</td>
<td><span class="ui-column-title" i18n="@@label">Label</span>{{ key.label }}<ng-container *ngIf="!isAdmin || ownerId">&nbsp;<span [class]="key.active ? 'badge badge-active' : 'badge badge-revoked'">{{ key.active ? 'Active' : 'Revoked' }}</span></ng-container></td>
<td><span class="ui-column-title" i18n="@@prefix">Prefix</span><code>{{ key.prefix }}…</code></td>
<td><span class="ui-column-title" i18n="@@service">Service</span>{{ serviceLabels[key.service] || key.service }}</td>
<td *ngIf="isAdmin && !ownerId"><span class="ui-column-title" i18n="@@status">Status</span><span [class]="key.active ? 'badge badge-active' : 'badge badge-revoked'">{{ key.active ? 'Active' : 'Revoked' }}</span></td>
</tr>
</ng-template>
<ng-template pTemplate="rowexpansion" let-key let-columns="columns">
<tr class="row-expansion">
<td [attr.colspan]="cols.length + 1">
<div class="expansion-grid">
<div class="expansion-item">
<span class="expansion-label" i18n="@@service">Service</span>
<span class="expansion-value">{{ serviceLabels[key.service] || key.service }}</span>
</div>
<div class="expansion-item">
<span class="expansion-label" i18n="@@createdDate">Created Date</span>
<span class="expansion-value">{{ key.createdAt | date:'short' }}</span>
</div>
<div class="expansion-item">
<span class="expansion-label" i18n="@@lastUsed">Last Used</span>
<span class="expansion-value">{{ key.lastUsedAt ? (key.lastUsedAt | date:'short') : '—' }}</span>
</div>
<div class="expansion-item">
<span class="expansion-label" i18n="@@requests">Requests</span>
<span class="expansion-value">{{ key.requestCount != null ? (key.requestCount | number) : 0 }}</span>
</div>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td [attr.colspan]="cols.length + 1" class="empty-message">
<span i18n="@@noApiKeys">No API keys yet. Click <strong>Generate Key</strong> to create one.</span>
</td>
</tr>
</ng-template>
</p-table>
<div class="ui-widget-header ui-helper-clearfix toolbar">
<button type="button" pButton icon="ui-icon-add"
i18n-label="@@new" label="New"
(click)="openNewDialog()">
</button>
<button type="button" pButton icon="ui-icon-refresh"
[disabled]="!keys.length || !selectedKey"
i18n-label="@@regenerateKey" label="Regenerate"
(click)="confirmRegenerate(selectedKey)">
</button>
<button *ngIf="isAdmin" type="button" pButton icon="ui-icon-block"
[disabled]="!keys.length || !selectedKey || !selectedKey.active"
i18n-label="@@revokeKey" label="Revoke"
(click)="confirmRevoke(selectedKey)">
</button>
<button type="button" pButton icon="ui-icon-trash"
[disabled]="!keys.length || !selectedKey"
i18n-label="@@deleteKey" label="Delete"
(click)="confirmDelete(selectedKey)">
</button>
</div>
<!-- Generate Key dialog -->
<p-dialog i18n-header="@@generateKey" header="Generate Key"
[(visible)]="showNewDialog" [modal]="true" [responsive]="true"
[style]="{'width':'480px'}" [closable]="true">
<div class="create-form">
<div class="form-row">
<div *ngIf="isAdmin && !ownerId" class="form-field">
<label i18n="@@customer">Customer</label>
<p-dropdown [options]="customerOptions" [(ngModel)]="newKeyOwnerId"
i18n-placeholder="@@selectCustomer" placeholder="Select a customer..."
[filter]="true" filterBy="label" appendTo="body"
[style]="{'width':'100%'}">
</p-dropdown>
</div>
<div class="form-field">
<label i18n="@@service">Service</label>
<p-dropdown [options]="serviceOptions" [(ngModel)]="newService"
[style]="{'width':'100%'}">
</p-dropdown>
</div>
<div class="form-field">
<label for="keyLabel" i18n="@@keyLabel">Label</label>
<input id="keyLabel" pInputText type="text" [(ngModel)]="newKeyLabel"
i18n-placeholder="@@keyLabelPlaceholder" placeholder="e.g. Power BI connector"
class="full-width" maxlength="100" (keydown.enter)="submitCreate()">
</div>
</div>
</div>
<p-footer>
<button pButton type="button" icon="ui-icon-add"
i18n-label="@@generate" label="Generate"
[disabled]="!newKeyLabel.trim() || (isAdmin && !ownerId && !newKeyOwnerId)"
(click)="submitCreate()">
</button>
<button pButton type="button" icon="ui-icon-close"
class="ui-button-secondary"
i18n-label="@@cancel" label="Cancel"
(click)="showNewDialog = false">
</button>
</p-footer>
</p-dialog>
</ng-template>
<p-toast key="apiKeyToast" position="bottom-center" life="3000"></p-toast>
<p-confirmDialog [style]="{ width: '420px' }"></p-confirmDialog>

View File

@ -0,0 +1,314 @@
import { Component, OnInit, OnDestroy, OnChanges, SimpleChanges, Input, ViewChild } from '@angular/core';
import { Observable, Subject, BehaviorSubject, combineLatest } from 'rxjs';
import { takeUntil, map } from 'rxjs/operators';
import { Table } from 'primeng/table';
import { ApiKey, CreateApiKeyResponse } from '../models/api-key.model';
import { ApiKeyState, FEATURE_KEY } from '../../reducers';
import * as ApiKeyActions from '../../actions/api-key.actions';
import { BaseComp } from '@app/shared/base/base.component';
import { RoleIds } from '@app/shared/global';
import { CustomerService } from '@app/domain/services/customer.service';
import { FilterDefinition, FilterChangeEvent, ActiveFilter } from '@app/shared/dynamic-filter/dynamic-filter.component';
@Component({
selector: 'agm-api-key-manager',
templateUrl: './api-key-manager.component.html',
styleUrls: ['./api-key-manager.component.css'],
})
export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestroy, OnChanges {
@Input() ownerId: string;
@Input() toggleable = false;
@Input() collapsed = false;
@ViewChild('dt') dt!: Table;
keys$: Observable<ApiKey[]>;
filteredKeys$: Observable<ApiKey[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
filterDefinitions: FilterDefinition[] = [];
filterAccordionOpen = sessionStorage.getItem('api-key-filter-accordion') === 'true';
private readonly activeFilters$ = new BehaviorSubject<ActiveFilter[]>([]);
newKey: CreateApiKeyResponse | null = null;
newKeyOwnerLabel: string | null = null;
newKeyLabel = '';
newService = 'data_export';
newKeyOwnerId: string | null = null;
keyCopied = false;
isAdmin = false;
isMasterAccount = false;
customerOptions: { label: string; value: string }[] = [];
private destroy$ = new Subject<void>();
cols: any[] = [];
expandedRows: { [id: string]: boolean } = {};
selectedKey: ApiKey | null = null;
keys: ApiKey[] = [];
showNewDialog = false;
createdAtFilter: Date | null = null;
lastUsedAtFilter: Date | null = null;
statusOptions = [
{ label: $localize`:@@all:All`, value: null },
{ label: $localize`:@@active:Active`, value: true },
{ label: $localize`:@@revoked:Revoked`, value: false },
];
serviceOptions = [
{ label: $localize`:@@dataExportApi:Data Export API`, value: 'data_export' },
{ label: $localize`:@@partnerApi:Partner API`, value: 'partner_api' },
];
readonly serviceLabels: Record<string, string> = {
data_export: $localize`:@@dataExportApi:Data Export API`,
partner_api: $localize`:@@partnerApi:Partner API`,
};
constructor(private readonly customerSvc: CustomerService) {
super();
this.keys$ = this.store.select((s: any) => s[FEATURE_KEY].keys);
this.loading$ = this.store.select((s: any) => s[FEATURE_KEY].loading);
this.error$ = this.store.select((s: any) => s[FEATURE_KEY].error);
}
ngOnInit(): void {
this.filteredKeys$ = combineLatest([
this.keys$.pipe(map(k => k || [])),
this.activeFilters$,
]).pipe(
map(([keys, filters]) => this.applyFilters(keys, filters))
);
this.filteredKeys$.pipe(takeUntil(this.destroy$)).subscribe(k => { this.keys = k; });
this.isAdmin = this.authSvc.hasRole([RoleIds.ADMIN]);
this.isMasterAccount = this.authSvc.hasRole([RoleIds.APP]);
const serviceFilterOptions = [
{ label: $localize`:@@all:All`, value: null },
...this.serviceOptions.map(o => ({ label: o.label, value: o.value })),
];
const statusFilterOptions = [
{ label: $localize`:@@all:All`, value: null },
{ label: $localize`:@@active:Active`, value: true },
{ label: $localize`:@@revoked:Revoked`, value: false },
];
this.filterDefinitions = [
{ key: 'label', label: $localize`:@@label:Label`, dataType: 'text' },
{ key: 'prefix', label: $localize`:@@prefix:Prefix`, dataType: 'text' },
{ key: 'service', label: $localize`:@@service:Service`, dataType: 'select', options: serviceFilterOptions },
{ key: 'active', label: $localize`:@@status:Status`, dataType: 'select', options: statusFilterOptions },
{ key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date' },
{ key: 'lastUsedAt', label: $localize`:@@lastUsed:Last Used`, dataType: 'date' },
{ key: 'requestCount', label: $localize`:@@requests:Requests`, dataType: 'number' },
];
if (this.isAdmin && !this.ownerId) {
this.filterDefinitions = [
{ key: 'owner.name', label: $localize`:@@name:Name`, dataType: 'text' },
{ key: 'owner.username', label: $localize`:@@userName:Username`, dataType: 'text' },
{ key: 'owner.contact', label: $localize`:@@contact:Contact`, dataType: 'text' },
...this.filterDefinitions,
];
}
this.cols = [
{ field: 'label', header: $localize`:@@label:Label`, filtered: true, filterMatchMode: 'contains' },
{ field: 'prefix', header: $localize`:@@prefix:Prefix`, filtered: true, filterMatchMode: 'contains' },
{ field: 'service', header: $localize`:@@service:Service`, filtered: true, filterMatchMode: 'contains' },
];
if (this.isAdmin && !this.ownerId) {
this.cols = [
{ field: 'owner.name', header: $localize`:@@name:Name`, filtered: true, filterMatchMode: 'contains' },
{ field: 'owner.username', header: $localize`:@@userName:Username`, filtered: true, filterMatchMode: 'contains' },
{ field: 'owner.contact', header: $localize`:@@contact:Contact`, filtered: true, filterMatchMode: 'contains' },
...this.cols,
{ field: 'active', header: $localize`:@@status:Status` },
];
}
this.store.dispatch(ApiKeyActions.loadApiKeys({ ownerId: this.ownerId }));
if (this.isAdmin && !this.ownerId) {
this.customerSvc.loadCustomers().pipe(takeUntil(this.destroy$)).subscribe(customers => {
this.customerOptions = customers.map(c => ({ label: c.username || c.name || c._id, value: c._id }));
});
}
this.store.select((s: any) => s[FEATURE_KEY].newKey)
.pipe(takeUntil(this.destroy$))
.subscribe(key => {
this.newKey = key;
if (key) { this.showNewDialog = false; }
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.ownerId && !changes.ownerId.firstChange) {
this.store.dispatch(ApiKeyActions.dismissNewKey());
this.store.dispatch(ApiKeyActions.loadApiKeys({ ownerId: this.ownerId }));
}
}
ngOnDestroy(): void {
this.store.dispatch(ApiKeyActions.dismissNewKey());
this.activeFilters$.complete();
this.destroy$.next();
this.destroy$.complete();
}
openNewDialog(): void {
this.newKeyLabel = '';
this.newService = 'data_export';
this.newKeyOwnerId = null;
this.showNewDialog = true;
}
submitCreate(): void {
const label = this.newKeyLabel.trim();
if (!label) { return; }
const effectiveOwnerId = this.ownerId || (this.isAdmin ? this.newKeyOwnerId : null);
if (this.isAdmin && !this.ownerId && !effectiveOwnerId) { return; }
const request: any = { label, service: this.newService };
if (effectiveOwnerId) { request.ownerId = effectiveOwnerId; }
const ownerOpt = this.customerOptions.find(o => o.value === effectiveOwnerId);
this.newKeyOwnerLabel = ownerOpt ? ownerOpt.label : null;
this.store.dispatch(ApiKeyActions.createApiKey({ request }));
this.newKeyLabel = '';
this.newService = 'data_export';
this.newKeyOwnerId = null;
}
dismissNewKey(): void {
this.newKey = null;
this.newKeyOwnerLabel = null;
this.store.dispatch(ApiKeyActions.dismissNewKey());
this.keyCopied = false;
}
copyKey(): void {
if (!this.newKey?.key) { return; }
navigator.clipboard.writeText(this.newKey.key).then(() => {
this.keyCopied = true;
});
}
confirmRegenerate(key: ApiKey): void {
this.confirmSvc.confirm({
message: $localize`:@@regenerateKeyConfirm:Regenerate the key "${key.label}"? The old key will stop working immediately.`,
header: $localize`:@@regenerateKey:Regenerate Key`,
icon: 'ui-icon-refresh',
accept: () => {
this.store.dispatch(ApiKeyActions.regenerateApiKey({ keyId: key._id, ownerId: this.ownerId }));
}
});
}
confirmRevoke(key: ApiKey): void {
this.confirmSvc.confirm({
message: $localize`:@@revokeKeyConfirm:Revoke the key "${key.label}"? This cannot be undone.`,
header: $localize`:@@revokeKey:Revoke Key`,
icon: 'pi pi-exclamation-triangle',
accept: () => {
this.store.dispatch(ApiKeyActions.revokeApiKey({ keyId: key._id, ownerId: this.ownerId }));
}
});
}
confirmDelete(key: ApiKey): void {
this.confirmSvc.confirm({
message: $localize`:@@deleteKeyConfirm:Permanently delete the key "${key.label}"? This action cannot be undone.`,
header: $localize`:@@deleteKey:Delete Key`,
icon: 'pi pi-trash',
accept: () => {
this.selectedKey = null;
this.store.dispatch(ApiKeyActions.deleteApiKey({ keyId: key._id, ownerId: this.ownerId }));
}
});
}
onFiltersChanged(event: FilterChangeEvent): void {
// Apply immediately only when a filter is removed or all are cleared
if (event.filters.length < this.activeFilters$.value.length) {
this.activeFilters$.next(event.filters);
}
}
onFiltersSubmit(event: FilterChangeEvent): void {
this.activeFilters$.next(event.filters);
}
onAccordionToggle(expanded: boolean): void {
sessionStorage.setItem('api-key-filter-accordion', String(expanded));
}
onDateFilter(value: Date, field: string): void {
this.dt.filter(value, field, 'dateIs');
}
private applyFilters(keys: ApiKey[], filters: ActiveFilter[]): ApiKey[] {
if (!filters.length) { return keys; }
return keys.filter(key => {
// Left-to-right operator evaluation, matching server buildDynamicFilter logic:
// filter[i].operator describes how filter[i] combines with the accumulated result.
// e.g. A(and) B(and) C(or) D(and) → ((A ∧ B) C) ∧ D
let result = this.matchesFilter(key, filters[0]);
for (let i = 1; i < filters.length; i++) {
const match = this.matchesFilter(key, filters[i]);
result = filters[i].operator === 'or' ? result || match : result && match;
}
return result;
});
}
private resolveField(obj: any, path: string): any {
return path.split('.').reduce((cur, part) => (cur != null ? cur[part] : undefined), obj);
}
private matchesFilter(key: ApiKey, filter: ActiveFilter): boolean {
const val = this.resolveField(key, filter.definition.key);
const fval = filter.value;
if (fval == null || fval === '') { return true; }
switch (filter.definition.dataType) {
case 'text': {
const s = String(val ?? '').toLowerCase();
const q = String(fval).toLowerCase();
switch (filter.valueOperator) {
case 'startsWith': return s.startsWith(q);
case 'exact': return s === q;
default: return s.includes(q);
}
}
case 'select':
return val === fval;
case 'date': {
if (!val) { return false; }
const d = new Date(val).setHours(0, 0, 0, 0);
const fd = new Date(fval).setHours(0, 0, 0, 0);
switch (filter.valueOperator) {
case 'before': return d < fd;
case 'after': return d > fd;
default: return d === fd;
}
}
case 'number': {
const n = Number(val ?? 0);
const fn = Number(fval);
switch (filter.valueOperator) {
case 'greaterThan': return n > fn;
case 'lessThan': return n < fn;
default: return n === fn;
}
}
default:
return true;
}
}
}

View File

@ -0,0 +1,74 @@
import { NgModule } from '@angular/core';
import { CommonModule, TitleCasePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
// PrimeNG
import { ButtonModule } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { DropdownModule } from 'primeng/dropdown';
import { CalendarModule } from 'primeng/calendar';
import { TableModule } from 'primeng/table';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { DialogModule } from 'primeng/dialog';
import { TooltipModule } from 'primeng/tooltip';
import { MessagesModule } from 'primeng/messages';
import { MessageModule } from 'primeng/message';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { PanelModule } from 'primeng/panel';
import { ToastModule } from 'primeng/toast';
import { AccordionModule } from 'primeng/accordion';
import { ConfirmationService } from 'primeng/api';
// Store
import { FEATURE_KEY, apiKeyReducer } from '../reducers';
import { ApiKeyEffects } from '../effects/api-key.effects';
// Shared
import { AppSharedModule } from '@app/shared/app-shared.module';
// Service
import { ApiKeyService } from '@app/domain/services/api-key.service';
import { CustomerService } from '@app/domain/services/customer.service';
// Component
import { ApiKeyManagerComponent } from './api-key-manager/api-key-manager.component';
@NgModule({
declarations: [
ApiKeyManagerComponent
],
imports: [
CommonModule,
FormsModule,
AppSharedModule,
StoreModule.forFeature(FEATURE_KEY, apiKeyReducer),
EffectsModule.forFeature([ApiKeyEffects]),
ButtonModule,
InputTextModule,
DropdownModule,
CalendarModule,
TableModule,
ConfirmDialogModule,
TooltipModule,
MessagesModule,
MessageModule,
ProgressSpinnerModule,
PanelModule,
ToastModule,
AccordionModule,
DialogModule,
],
exports: [
ApiKeyManagerComponent
],
providers: [
TitleCasePipe,
ConfirmationService,
ApiKeyService,
CustomerService,
]
})
export class ApiKeySharedModule {}

View File

@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../../domain/guards/auth.guard';
import { RoleIds } from '../../shared/global';
import { ApiKeyManagerComponent } from './api-key-manager/api-key-manager.component';
const routes: Routes = [
{
path: '',
component: ApiKeyManagerComponent,
data: {
roles: [RoleIds.ADMIN, RoleIds.APP]
},
canActivate: [AuthGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ApiKeysRoutingModule {}

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
// Routing
import { ApiKeysRoutingModule } from './api-keys-routing.module';
// Shared module (declares + exports ApiKeyManagerComponent, registers store/effects)
import { ApiKeySharedModule } from './api-key-shared.module';
@NgModule({
imports: [
ApiKeysRoutingModule,
ApiKeySharedModule,
],
})
export class ApiKeysModule {}

View File

@ -0,0 +1,22 @@
export interface ApiKey {
_id: string;
label: string;
prefix: string;
active: boolean;
service: string;
managedBy: 'owner' | 'admin';
createdAt: string;
lastUsedAt?: string;
requestCount: number;
owner?: string | { _id: string; username: string; name?: string; contact?: string };
}
export interface CreateApiKeyResponse extends ApiKey {
key: string; // plain key — returned once only
}
export interface CreateApiKeyRequest {
label: string;
service: string;
ownerId?: string; // admin only
}

View File

@ -0,0 +1,112 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, mergeMap, catchError, repeat } from 'rxjs/operators';
import { MessageService } from 'primeng/api';
import { ApiKeyService } from '@app/domain/services/api-key.service';
import * as ApiKeyActions from '../actions/api-key.actions';
@Injectable()
export class ApiKeyEffects {
constructor(
private readonly actions$: Actions,
private readonly apiKeySvc: ApiKeyService,
private readonly messageSvc: MessageService,
) {}
loadKeys$ = createEffect(() =>
this.actions$.pipe(
ofType(ApiKeyActions.loadApiKeys),
mergeMap(action =>
this.apiKeySvc.listKeys(action.ownerId).pipe(
map(keys => ApiKeyActions.loadApiKeysSuccess({ keys })),
catchError(err => of(ApiKeyActions.loadApiKeysFailure({ error: err.message })))
)
),
repeat()
)
);
createKey$ = createEffect(() =>
this.actions$.pipe(
ofType(ApiKeyActions.createApiKey),
mergeMap(action =>
this.apiKeySvc.createKey(action.request).pipe(
map(response => ApiKeyActions.createApiKeySuccess({ response })),
catchError(err => of(ApiKeyActions.createApiKeyFailure({ error: err.error?.message || err.message })))
)
),
repeat()
)
);
revokeKey$ = createEffect(() =>
this.actions$.pipe(
ofType(ApiKeyActions.revokeApiKey),
mergeMap(action =>
this.apiKeySvc.revokeKey(action.keyId).pipe(
map(() => ApiKeyActions.revokeApiKeySuccess({ keyId: action.keyId, ownerId: action.ownerId })),
catchError(err => of(ApiKeyActions.revokeApiKeyFailure({ error: err.error?.message || err.message })))
)
),
repeat()
)
);
revokeSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(ApiKeyActions.revokeApiKeySuccess),
map(action => {
this.messageSvc.add({ key: 'apiKeyToast', severity: 'success', summary: 'Key Revoked', detail: 'API key has been revoked.' });
return ApiKeyActions.loadApiKeys({ ownerId: action.ownerId });
})
)
);
deleteKey$ = createEffect(() =>
this.actions$.pipe(
ofType(ApiKeyActions.deleteApiKey),
mergeMap(action =>
this.apiKeySvc.deleteKey(action.keyId).pipe(
map(() => ApiKeyActions.deleteApiKeySuccess({ keyId: action.keyId, ownerId: action.ownerId })),
catchError(err => of(ApiKeyActions.deleteApiKeyFailure({ error: err.error?.message || err.message })))
)
),
repeat()
)
);
deleteSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(ApiKeyActions.deleteApiKeySuccess),
map(action => {
this.messageSvc.add({ key: 'apiKeyToast', severity: 'success', summary: 'Key Deleted', detail: 'API key has been permanently deleted.' });
return ApiKeyActions.loadApiKeys({ ownerId: action.ownerId });
})
)
);
regenerateKey$ = createEffect(() =>
this.actions$.pipe(
ofType(ApiKeyActions.regenerateApiKey),
mergeMap(action =>
this.apiKeySvc.regenerateKey(action.keyId).pipe(
map(response => ApiKeyActions.regenerateApiKeySuccess({ response, ownerId: action.ownerId })),
catchError(err => of(ApiKeyActions.regenerateApiKeyFailure({ error: err.error?.message || err.message })))
)
),
repeat()
)
);
failure$ = createEffect(() =>
this.actions$.pipe(
ofType(ApiKeyActions.loadApiKeysFailure, ApiKeyActions.createApiKeyFailure, ApiKeyActions.revokeApiKeyFailure, ApiKeyActions.deleteApiKeyFailure, ApiKeyActions.regenerateApiKeyFailure),
map(action => {
this.messageSvc.add({ key: 'apiKeyToast', severity: 'error', summary: 'Error', detail: action.error });
return { type: '[ApiKey] Noop' };
})
)
);
}

View File

@ -0,0 +1,92 @@
import { createReducer, on } from '@ngrx/store';
import { ApiKey, CreateApiKeyResponse } from '../api-keys/models/api-key.model';
import * as ApiKeyActions from '../actions/api-key.actions';
export interface ApiKeyState {
keys: ApiKey[];
loading: boolean;
error: string | null;
newKey: CreateApiKeyResponse | null; // holds the just-created key (plain key visible once)
}
export const initialState: ApiKeyState = {
keys: [],
loading: false,
error: null,
newKey: null,
};
export const FEATURE_KEY = 'apiKey';
export const apiKeyReducer = createReducer(
initialState,
on(ApiKeyActions.loadApiKeys, (state) => ({
...state, loading: true, error: null
})),
on(ApiKeyActions.loadApiKeysSuccess, (state, { keys }) => ({
...state, keys, loading: false
})),
on(ApiKeyActions.loadApiKeysFailure, (state, { error }) => ({
...state, loading: false, error
})),
on(ApiKeyActions.createApiKey, (state) => ({
...state, loading: true, error: null
})),
on(ApiKeyActions.createApiKeySuccess, (state, { response }) => ({
...state,
loading: false,
newKey: response,
// Add new key to list (without the plain key field)
keys: [{ _id: response._id, label: response.label, prefix: response.prefix,
active: response.active, service: response.service, managedBy: response.managedBy,
createdAt: response.createdAt, owner: response.owner }, ...state.keys],
})),
on(ApiKeyActions.createApiKeyFailure, (state, { error }) => ({
...state, loading: false, error
})),
on(ApiKeyActions.revokeApiKey, (state) => ({
...state, loading: true, error: null
})),
on(ApiKeyActions.revokeApiKeySuccess, (state, { keyId }) => ({
...state,
loading: false,
keys: state.keys.map(k => k._id === keyId ? { ...k, active: false } : k),
})),
on(ApiKeyActions.revokeApiKeyFailure, (state, { error }) => ({
...state, loading: false, error
})),
on(ApiKeyActions.deleteApiKey, (state) => ({
...state, loading: true, error: null
})),
on(ApiKeyActions.deleteApiKeySuccess, (state, { keyId }) => ({
...state,
loading: false,
keys: state.keys.filter(k => k._id !== keyId),
})),
on(ApiKeyActions.deleteApiKeyFailure, (state, { error }) => ({
...state, loading: false, error
})),
on(ApiKeyActions.regenerateApiKey, (state) => ({
...state, loading: true, error: null
})),
on(ApiKeyActions.regenerateApiKeySuccess, (state, { response }) => ({
...state,
loading: false,
newKey: response,
keys: state.keys.map(k => k._id === response._id
? { ...k, prefix: response.prefix, active: true }
: k),
})),
on(ApiKeyActions.regenerateApiKeyFailure, (state, { error }) => ({
...state, loading: false, error
})),
on(ApiKeyActions.dismissNewKey, (state) => ({
...state, newKey: null
})),
);

View File

@ -0,0 +1,9 @@
import { createFeatureSelector } from '@ngrx/store';
import * as fromApiKey from './api-key.reducer';
export { FEATURE_KEY } from './api-key.reducer';
export type { ApiKeyState } from './api-key.reducer';
export { apiKeyReducer } from './api-key.reducer';
export const getApiKeyState = createFeatureSelector<fromApiKey.ApiKeyState>(fromApiKey.FEATURE_KEY);

View File

@ -1,7 +1,6 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { SettingsRoutingModule } from './settings-routing.module';
import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component';
@ -30,7 +29,6 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner';
CommonModule,
FormsModule,
ReactiveFormsModule,
HttpClientModule,
SettingsRoutingModule,
AppSharedModule,
// PrimeNG

View File

@ -1,6 +1,6 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { SharedModule } from 'primeng/api';
import { InputTextModule } from 'primeng/inputtext';
@ -9,11 +9,13 @@ import { DropdownModule } from 'primeng/dropdown';
import { CheckboxModule } from 'primeng/checkbox';
import { KeyFilterModule } from 'primeng/keyfilter';
import { PanelModule } from 'primeng/panel';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { MessagesModule } from 'primeng/messages';
import { MessageModule } from 'primeng/message';
import { RadioButtonModule } from 'primeng/radiobutton';
import { CalendarModule } from 'primeng/calendar';
import { DialogModule } from 'primeng/dialog';
import { MultiSelectModule } from 'primeng/multiselect';
import { LengthUnitPipe } from './pipes/length-unit.pipe';
import { RateUnitPipe } from './pipes/rate-unit.pipe';
@ -76,12 +78,15 @@ import { BadgeComponent } from './badge/badge.component';
import { PromoLabelComponent } from './promo-label/promo-label.component';
import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component';
import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component';
import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.component';
import { MarkdownViewerComponent } from './markdown-viewer/markdown-viewer.component';
@NgModule({
imports: [
CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule,
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule
ProgressSpinnerModule, MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule,
MultiSelectModule, FormsModule
],
declarations: [
LengthUnitPipe, RateUnitPipe, UserTypePipe, AreaUnitPipe, NoCommaPipe,
@ -90,18 +95,19 @@ import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-
JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent,
PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
DynamicFilterComponent, MarkdownViewerComponent
],
exports: [
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule,
InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, RadioButtonModule,
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule, FormsModule,
InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, RadioButtonModule, MultiSelectModule,
ItemEditorComponent, ProductEditorComponent, AccountEditorComponent, DisplayConfigComponent, CropEditorComponent,
LengthUnitPipe, RateUnitPipe, AreaUnitPipe, UserTypePipe, NoCommaPipe, UniqueUserValidatorDirective, UnitPipe, ProductTypePipe,
ActivityPipe, CoordinatePipe, SpeedPipe, LengthPipe, TemperaturePipe, AppRatePipe, DistancePipe, JobStatusPipe,
VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, AppVolumePipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
DebounceDirective, UnitIdUniqueDirective,
ProfileFormComponent, CreditcardFormComponent, CardInfoComponent,
PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent
PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
DynamicFilterComponent, MarkdownViewerComponent
],
providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe]
})

View File

@ -316,7 +316,10 @@ export class MapBaseComp extends BaseComp implements OnDestroy {
color: props.color,
area: parseFloat(props.area),
appRate: parseFloat(props.appRate),
width: NumUtils.round(props.width, 1)
width: NumUtils.round(props.width, 1),
offset: props.offset !== undefined ? Number(props.offset) : undefined,
edgeSide: props.edgeSide,
edgeSign: props.edgeSign !== undefined ? Number(props.edgeSign) : undefined
};
if (item.type === ITEM.SPRAY || item.type === ITEM.XCL) {
if (!item.area)
@ -415,4 +418,7 @@ export interface MapItem {
client?: string;
lat?: number;
lon?: number;
offset?: number;
edgeSide?: 'on' | 'inside' | 'outside';
edgeSign?: number;
}

View File

@ -363,16 +363,21 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
xclArea += dA;
}
} else if (exType === ITEM.BUFFER) {
// Check whether the buffer zone within (at least one point within) the spray poly to count exlusion
xclLayers[j].updateArea();
const llns = xclLayers[j].getLatLngs();
if (llns.length) {
let ll;
for (let k = 0; k < 1; k++) {
ll = llns[k];
if (turf.booleanPointInPolygon([ll.lng, ll.lat], sprayPoly)) {
const firstEl = llns[0];
if (Array.isArray(firstEl)) {
// Polygon-type buffer (e.g. edge buffer zone) — use turf.intersect for accuracy
const diff = turf.intersect(sprayPoly, xclPoly);
if (diff) {
const dA = turf.area(diff);
if (dA) xclArea += dA;
}
} else {
// L.Corridor buffer — use corridor's own area calculation
xclLayers[j].updateArea();
if (turf.booleanPointInPolygon([firstEl.lng, firstEl.lat], sprayPoly)) {
xclArea += xclLayers[j].getArea();
break;
}
}
}
@ -446,7 +451,7 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
this.postTypeChanged(e);
}
protected getDefaultName(layer) {
protected getDefaultName(layer, extraOffset = 0) {
if (!layer.feature || !layer.feature.properties) {
return '';
}
@ -479,7 +484,7 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
}
}
}
number = layers.filter(l => (<any>l).feature.properties.type === type).length;
number = layers.filter(l => (<any>l).feature.properties.type === type).length + extraOffset;
}
return `${name.trim()}_${NumUtils.padZero(number, 2)}`;
}
@ -555,7 +560,10 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
else {
if (type === ITEM.BUFFER) {
this.map.fitBounds((<any>layer).getBounds(), GC.fbOps);
this.selItem = layer.openTooltip();
layer.openTooltip();
// Use a proxy so cleanup calls closeTooltip() but never removeLayer()
// (instanceof L.Polygon would match feature buffers and wrongly remove them)
this.selItem = { closeTooltip: () => layer.closeTooltip() };
} else {
setTimeout(() => this.map.setView((<any>layer).getLatLng(), Math.min(GC.MAX_ZOOM_ITEM, this.map.getZoom())), 200);
if (!layer.isTooltipOpen())

View File

@ -0,0 +1,221 @@
:host {
display: block;
margin-bottom: 0.75rem;
}
.dynamic-filter {
min-width: 16.25rem;
}
.filter-add-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.filter-add-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-action-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.add-btn {
flex-shrink: 0;
}
.full-width {
width: 100%;
}
:host ::ng-deep .filter-selector-dropdown {
min-width: 180px;
width: 220px;
flex: 1 1 180px;
}
:host ::ng-deep .logic-operator-dropdown {
width: 70px;
}
:host ::ng-deep .value-operator-dropdown {
flex: 1;
}
:host ::ng-deep .full-width {
width: 100%;
}
.filter-grid {
display: flex;
flex-wrap: wrap;
}
.filter-field {
padding: 0.2rem 0.25rem;
box-sizing: border-box;
min-width:16rem;
}
@media (max-width: 1200px) {
.filter-field {
width: calc(100% / 4);
}
}
@media (max-width: 768px) {
.filter-field {
width: calc(100% / 2);
}
}
@media (max-width: 480px) {
.filter-field {
width: 100%;
}
}
.filter-field-inner {
border: 1px solid #ddd;
border-radius: 4px;
position: relative;
}
.filter-field-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.2rem 0.35rem;
background: #f0f0f0;
border-bottom: 1px solid #ddd;
}
.filter-field-header label {
font-weight: 600;
font-size: 0.9em;
margin: 0;
}
.filter-field-header-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
min-height: 2rem;
}
.filter-field-header .remove-btn {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0.15rem 0.35rem;
font-size: 0.75em;
}
:host ::ng-deep .filter-field-header .remove-btn .ui-button-icon {
color: #e53935;
}
:host ::ng-deep .filter-field-header .remove-btn:hover .ui-button-icon {
color: #b71c1c;
}
:host ::ng-deep body .ui-button.remove-btn .pi,
:host ::ng-deep .filter-field-header .remove-btn .pi {
color: #e53935;
}
:host ::ng-deep .filter-field-header .remove-btn:hover .pi {
color: #b71c1c;
}
.filter-field-body {
padding: 0.25rem 0.35rem;
}
.filter-operators-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem;
margin-bottom: 0.25rem;
}
.filter-label {
font-weight: 600;
white-space: nowrap;
font-size: 0.85rem;
}
.date-input-row {
display: flex;
align-items: center;
gap: 0.375rem;
border-bottom: 1px solid #a6a6a6;
cursor: pointer;
min-height: 1.5rem;
width: 100%;
}
.date-input-row:hover {
border-bottom-color: #007ad9;
}
.date-input-icon {
font-size: 0.9em;
color: #555;
flex-shrink: 0;
}
.date-input-row span {
flex: 1;
}
.date-placeholder {
color: #aaa;
}
.date-clear-btn {
font-size: 0.85em;
color: #888;
cursor: pointer;
flex-shrink: 0;
margin-left: auto;
}
.date-clear-btn:hover {
color: #333;
}
.date-cal-anchor {
position: relative;
overflow: visible;
}
.date-cal-anchor ::ng-deep .ui-calendar {
display: block;
height: 0;
overflow: visible;
}
.date-cal-anchor ::ng-deep .ui-calendar .ui-inputtext {
display: none;
}
.date-cal-anchor ::ng-deep .ui-calendar .ui-calendar-button {
visibility: hidden;
width: 1px;
padding: 0;
margin: 0;
border: none;
overflow: hidden;
}

View File

@ -0,0 +1,136 @@
<div class="dynamic-filter">
<!-- Add filter row -->
<div class="filter-add-row">
<div class="filter-add-group">
<p-dropdown [options]="availableFilters" [(ngModel)]="selectedFilterKey"
styleClass="filter-selector-dropdown" placeholder="-- Select Filter --" appendTo="body">
</p-dropdown>
<button pButton type="button" icon="pi pi-plus" class="ui-button-success add-btn"
[disabled]="!selectedFilterKey" (click)="addFilter()">
</button>
</div>
<div class="filter-action-group" *ngIf="activeFilters.length">
<button pButton type="button" icon="ui-icon-clear-all"
class="ui-button-secondary clear-btn" (click)="clearAll()" i18n-label="@@clearFilters" label="Clear Filters">
</button>
<button *ngIf="showSearch" pButton type="button" icon="pi pi-search"
class="ui-button-primary submit-btn" (click)="submit()" i18n-label="@@applyFilters" label="Search">
</button>
</div>
</div>
<!-- Active filters -->
<div class="filter-grid" *ngIf="activeFilters.length">
<div class="filter-field" *ngFor="let filter of activeFilters; let i = index">
<div class="filter-field-inner">
<!-- Header: label + remove -->
<div class="filter-field-header">
<label>{{ filter.definition.label }}</label>
<span class="filter-field-header-action">
<button *ngIf="isFilterRemovable(filter)" pButton type="button" icon="pi pi-times" class="ui-button-text remove-btn"
(click)="removeFilter(filter.id)">
</button>
</span>
</div>
<div class="filter-field-body">
<!-- And/Or + Label + Value operator row -->
<div class="filter-operators-row">
<p-dropdown *ngIf="i > 0" [options]="[{label: 'And', value: 'and'}, {label: 'Or', value: 'or'}]"
[(ngModel)]="filter.operator" styleClass="logic-operator-dropdown"
(onChange)="onOperatorChange()" appendTo="body">
</p-dropdown>
<p>{{ filter.definition.label }}</p>
<p *ngIf="filter.definition.dataType === 'select' || filter.definition.dataType === 'select-multi' || filter.definition.dataType === 'numeric-enum'">is</p>
<p-dropdown *ngIf="getValueOperatorOptions(filter.definition.dataType).length"
[options]="getValueOperatorOptions(filter.definition.dataType)"
[(ngModel)]="filter.valueOperator" styleClass="value-operator-dropdown"
(onChange)="onValueOperatorChange(filter)" appendTo="body">
</p-dropdown>
</div>
<!-- Text input -->
<input *ngIf="filter.definition.dataType === 'text'" pInputText type="text"
[(ngModel)]="filter.value" (input)="onValueChange()" placeholder="Search..." class="full-width">
<!-- Number input -->
<input *ngIf="filter.definition.dataType === 'number'" pInputText type="number"
[(ngModel)]="filter.value" (input)="onValueChange()" placeholder="Enter number..." class="full-width">
<!-- Select — single select -->
<p-dropdown *ngIf="filter.definition.dataType === 'select'"
[options]="filter.definition.options" [(ngModel)]="filter.value"
styleClass="full-width" [filter]="true" (onChange)="onValueChange()"
placeholder="Select..." appendTo="body">
</p-dropdown>
<!-- Select — multi select -->
<p-multiSelect *ngIf="filter.definition.dataType === 'select-multi'"
[options]="filter.definition.options" [(ngModel)]="filter.value"
styleClass="full-width" (onChange)="onValueChange()"
defaultLabel="Select..." appendTo="body">
</p-multiSelect>
<!-- Date — single date (before / after / exact) -->
<ng-container *ngIf="filter.definition.dataType === 'date' && filter.valueOperator !== 'range'">
<div class="date-cal-anchor">
<div class="date-input-row" (click)="openCal(filter.id, false)">
<i class="pi pi-calendar date-input-icon"></i>
<span *ngIf="!filter.value" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
<span *ngIf="filter.value">{{ filter.value | date:'shortDate' }}</span>
<i *ngIf="filter.value" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
</div>
<p-calendar [attr.data-filter-cal]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
(onClearClick)="onValueChange()" [showButtonBar]="true">
</p-calendar>
</div>
</ng-container>
<!-- Date — range mode -->
<ng-container *ngIf="filter.definition.dataType === 'date' && filter.valueOperator === 'range'">
<div class="date-cal-anchor">
<div class="date-input-row" (click)="openCal(filter.id, true)">
<i class="pi pi-calendar date-input-icon"></i>
<span *ngIf="!filter.value || !filter.value[0]" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
<span *ngIf="filter.value && filter.value[0] && !filter.value[1]">{{ filter.value[0] | date:'shortDate' }}</span>
<span *ngIf="filter.value && filter.value[0] && filter.value[1]">{{ filter.value[0] | date:'shortDate' }} - {{ filter.value[1] | date:'shortDate' }}</span>
<i *ngIf="filter.value" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
</div>
<p-calendar [attr.data-filter-cal-range]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
(onClearClick)="onValueChange()" [showButtonBar]="true"
selectionMode="range" [readonlyInput]="true">
</p-calendar>
</div>
</ng-container>
<!-- Date preset — dropdown with presets + optional custom calendar -->
<ng-container *ngIf="filter.definition.dataType === 'date-preset'">
<p-dropdown [options]="datePresetOptions"
[ngModel]="datePresetSelected.get(filter.id) || null"
styleClass="full-width" placeholder="-- Select --"
(onChange)="onDatePresetChange(filter, $event)" appendTo="body">
</p-dropdown>
<ng-container *ngIf="isDatePresetCustom(filter.id)">
<div class="date-cal-anchor" style="margin-top: 0.25rem;">
<div class="date-input-row" (click)="openCal(filter.id, true)">
<i class="pi pi-calendar date-input-icon"></i>
<span *ngIf="!filter.value || !filter.value[0]" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
<span *ngIf="filter.value && filter.value[0] && !filter.value[1]">{{ filter.value[0] | date:'shortDate' }}</span>
<span *ngIf="filter.value && filter.value[0] && filter.value[1]">{{ filter.value[0] | date:'shortDate' }} {{ filter.value[1] | date:'shortDate' }}</span>
<i *ngIf="filter.value && filter.value[0]" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
</div>
<p-calendar [attr.data-filter-cal-range]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
(onClearClick)="onValueChange()" [showButtonBar]="true"
selectionMode="range" [readonlyInput]="true">
</p-calendar>
</div>
</ng-container>
</ng-container>
</div><!-- /.filter-field-body -->
</div><!-- /.filter-field-inner -->
</div>
</div>
</div>

View File

@ -0,0 +1,439 @@
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core';
import { SelectItem } from 'primeng/api';
export type FilterDataType = 'text' | 'select' | 'select-multi' | 'date' | 'date-preset' | 'number';
export type TextValueOperator = 'contains' | 'startsWith' | 'exact';
export type SelectValueOperator = 'multi';
export type DateValueOperator = 'before' | 'after' | 'exact' | 'range';
export type NumberValueOperator = 'exact' | 'greaterThan' | 'lessThan';
export type ValueOperator = TextValueOperator | SelectValueOperator | DateValueOperator | NumberValueOperator;
export interface FilterDefinition {
key: string;
label: string;
dataType: FilterDataType;
options?: SelectItem[];
removable?: boolean;
}
export type FilterOperator = 'and' | 'or';
export interface ActiveFilter {
id: number;
definition: FilterDefinition;
value: any;
operator: FilterOperator;
valueOperator: ValueOperator;
}
export interface FilterChangeEvent {
filters: ActiveFilter[];
query: Record<string, any>;
}
export const VALUE_OPERATOR_OPTIONS: Record<FilterDataType, SelectItem[]> = {
text: [
{ label: 'Contains', value: 'contains' },
{ label: 'Starts With', value: 'startsWith' },
{ label: 'Is', value: 'exact' },
],
select: [],
'select-multi': [],
date: [
{ label: 'Before', value: 'before' },
{ label: 'After', value: 'after' },
{ label: 'Is', value: 'exact' },
{ label: 'Between', value: 'range' },
],
'date-preset': [],
number: [
{ label: 'Is', value: 'exact' },
{ label: 'Greater Than', value: 'greaterThan' },
{ label: 'Less Than', value: 'lessThan' },
],
};
export const DEFAULT_VALUE_OPERATOR: Record<FilterDataType, ValueOperator> = {
text: 'contains',
select: 'multi',
'select-multi': 'multi',
date: 'exact',
'date-preset': 'exact',
number: 'exact',
};
/**
* Convert active filters into a plain query object for API requests.
*
* Each filter produces a key in the result whose value depends on the
* data type and operator. Consumers can map these keys to their own
* API parameter names.
*/
export function buildFilterQuery(activeFilters: ActiveFilter[]): Record<string, any> {
const query: Record<string, any> = {};
for (const f of activeFilters) {
if (f.value == null) { continue; }
if (f.definition.dataType === 'text' && f.value === '') { continue; }
if (f.definition.dataType === 'select' && f.value == null) { continue; }
if (f.definition.dataType === 'select-multi' && (!Array.isArray(f.value) || f.value.length === 0)) { continue; }
if (f.definition.dataType === 'date' && f.valueOperator === 'range'
&& (!Array.isArray(f.value) || f.value[0] == null)) { continue; }
if (f.definition.dataType === 'date-preset' && f.value == null) { continue; }
if (f.definition.dataType === 'date-preset' && Array.isArray(f.value) && !f.value[0]) { continue; }
const hasValueOperator = VALUE_OPERATOR_OPTIONS[f.definition.dataType]?.length > 0;
const queryValue = (f.definition.dataType === 'date-preset' && Array.isArray(f.value))
? f.value.filter((v: any) => v != null)
: f.value;
query[f.definition.key] = {
value: queryValue,
operator: f.operator,
...(hasValueOperator ? { valueOperator: f.valueOperator } : {}),
dataType: f.definition.dataType,
};
}
return query;
}
@Component({
selector: 'agm-dynamic-filter',
templateUrl: './dynamic-filter.component.html',
styleUrls: ['./dynamic-filter.component.css']
})
export class DynamicFilterComponent implements OnInit, OnChanges {
@Input() filterDefinitions: FilterDefinition[] = [];
@Input() locale: any = {};
@Input() stateKey?: string;
@Input() defaultFilters: Array<{ key: string; value: any }> = [];
@Input() showSearch = true;
@Input() autoSaveOnChange = false;
@Output() filtersChanged = new EventEmitter<FilterChangeEvent>();
@Output() filtersSubmit = new EventEmitter<FilterChangeEvent>();
availableFilters: SelectItem[] = [];
selectedFilterKey: string | null = null;
activeFilters: ActiveFilter[] = [];
datePresetOptions: SelectItem[] = [];
datePresetSelected = new Map<number, string>();
private nextId = 1;
constructor(private readonly el: ElementRef) {}
private stateRestored = false;
ngOnInit(): void {
this.buildAvailableFilters();
this.buildDatePresetOptions();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.filterDefinitions && this.filterDefinitions?.length && !this.stateRestored) {
this.restoreState();
}
}
private buildDatePresetOptions(): void {
const year = new Date().getFullYear();
this.datePresetOptions = [
{ label: '-- Select --', value: null },
{ label: 'Past 1 Month', value: '1m' },
{ label: 'Past 3 Months', value: '3m' },
{ label: 'Past 6 Months', value: '6m' },
{ label: String(year), value: String(year) },
{ label: String(year - 1), value: String(year - 1) },
{ label: String(year - 2), value: String(year - 2) },
{ label: 'Custom', value: 'custom' },
];
}
addFilter(): void {
if (!this.selectedFilterKey) { return; }
const def = this.filterDefinitions.find(f => f.key === this.selectedFilterKey);
if (!def) { return; }
const defaultOp = DEFAULT_VALUE_OPERATOR[def.dataType];
const filter: ActiveFilter = {
id: this.nextId++,
definition: def,
value: this.getDefaultValue(def, defaultOp),
operator: 'and',
valueOperator: defaultOp
};
this.activeFilters.push(filter);
if (def.dataType === 'date-preset') {
this.datePresetSelected.set(filter.id, filter.value);
}
this.selectedFilterKey = null;
this.buildAvailableFilters();
this.emitChange();
}
getValueOperatorOptions(dataType: FilterDataType): SelectItem[] {
return VALUE_OPERATOR_OPTIONS[dataType] || [];
}
onValueOperatorChange(filter: ActiveFilter): void {
// Reset value when operator changes to avoid type mismatches
filter.value = this.getDefaultValue(filter.definition, filter.valueOperator);
this.emitChange();
}
removeFilter(id: number): void {
const filter = this.activeFilters.find(f => f.id === id);
if (!filter || !this.isFilterRemovable(filter)) { return; }
this.activeFilters = this.activeFilters.filter(f => f.id !== id);
this.datePresetSelected.delete(id);
this.buildAvailableFilters();
this.emitChange();
this.submit();
}
onValueChange(): void {
this.emitChange();
}
onOperatorChange(): void {
this.emitChange();
}
submit(): void {
const event: FilterChangeEvent = {
filters: [...this.activeFilters],
query: buildFilterQuery(this.activeFilters)
};
this.saveState();
this.filtersSubmit.emit(event);
}
clearAll(): void {
this.activeFilters = this.activeFilters
.filter((filter: ActiveFilter) => !this.isFilterRemovable(filter))
.map((filter: ActiveFilter) => this.resetFilter(filter));
this.selectedFilterKey = null;
this.datePresetSelected.clear();
this.activeFilters.forEach((filter: ActiveFilter) => {
if (filter.definition.dataType === 'date-preset' && filter.value != null) {
this.datePresetSelected.set(filter.id, filter.value);
}
});
this.buildAvailableFilters();
this.emitChange();
this.submit();
}
onDatePresetChange(filter: ActiveFilter, event: any): void {
const key = event.value;
if (!key) {
filter.value = null;
this.datePresetSelected.delete(filter.id);
this.emitChange();
return;
}
this.datePresetSelected.set(filter.id, key);
if (key === 'custom') {
filter.value = null;
} else {
filter.value = key;
}
this.emitChange();
}
isDatePresetCustom(filterId: number): boolean {
return this.datePresetSelected.get(filterId) === 'custom';
}
isFilterRemovable(filter: ActiveFilter): boolean {
return filter.definition.removable !== false;
}
openCal(filterId: number, isRange: boolean): void {
const attr = isRange ? `data-filter-cal-range` : `data-filter-cal`;
const calHost = this.el.nativeElement.querySelector(`[${attr}="${filterId}"]`);
if (calHost) {
const btn = calHost.querySelector('.ui-calendar-button') || calHost.querySelector('button');
btn?.click();
}
}
clearDate(event: Event, filter: ActiveFilter): void {
event.stopPropagation();
filter.value = filter.valueOperator === 'range' ? null : null;
this.emitChange();
}
private buildAvailableFilters(): void {
const activeKeys = new Set(this.activeFilters.map(f => f.definition.key));
this.availableFilters = [
{ label: '-- Select Filter --', value: null },
...this.filterDefinitions
.filter(f => !activeKeys.has(f.key))
.map(f => ({ label: f.label, value: f.key }))
];
}
private emitChange(): void {
const event: FilterChangeEvent = {
filters: [...this.activeFilters],
query: buildFilterQuery(this.activeFilters)
};
if (this.autoSaveOnChange) { this.saveState(); }
this.filtersChanged.emit(event);
}
private getDefaultValue(def: FilterDefinition, op: ValueOperator): any {
switch (def.dataType) {
case 'text': return '';
case 'number': return null;
case 'select': return null;
case 'select-multi': return [];
case 'date': return op === 'range' ? null : null;
case 'date-preset': return null;
default: return null;
}
}
private saveState(): void {
if (!this.stateKey) { return; }
if (!this.activeFilters.length) {
this.clearState();
return;
}
const state = this.activeFilters.map(f => ({
key: f.definition.key,
value: f.value,
operator: f.operator,
valueOperator: f.valueOperator,
datePreset: this.datePresetSelected.get(f.id) || null
}));
sessionStorage.setItem(this.stateKey, JSON.stringify(state));
}
private clearState(): void {
if (!this.stateKey) { return; }
sessionStorage.removeItem(this.stateKey);
}
private applyDefaultFilters(): void {
if (!this.defaultFilters?.length) { return; }
for (const df of this.defaultFilters) {
const def = this.filterDefinitions.find(f => f.key === df.key);
if (!def) { continue; }
const filter = this.createFilter(def, df.value);
this.activeFilters.push(filter);
if (def.dataType === 'date-preset') {
this.datePresetSelected.set(filter.id, df.value);
}
}
this.ensureRequiredFilters();
if (this.activeFilters.length) {
this.buildAvailableFilters();
this.submit();
}
}
private restoreState(): void {
if (!this.stateKey) { return; }
this.stateRestored = true;
const raw = sessionStorage.getItem(this.stateKey);
if (!raw) {
this.applyDefaultFilters();
return;
}
let saved: any[];
try { saved = JSON.parse(raw); } catch {
this.applyDefaultFilters();
return;
}
if (!Array.isArray(saved) || !saved.length) {
this.applyDefaultFilters();
return;
}
for (const entry of saved) {
const def = this.filterDefinitions.find(f => f.key === entry.key);
if (!def) { continue; }
const filter: ActiveFilter = {
...this.createFilter(def, this.deserializeValue(entry.value, def.dataType, entry.valueOperator)),
operator: entry.operator || 'and',
valueOperator: entry.valueOperator || DEFAULT_VALUE_OPERATOR[def.dataType]
};
this.activeFilters.push(filter);
if (def.dataType === 'date-preset' && entry.datePreset) {
this.datePresetSelected.set(filter.id, entry.datePreset);
}
}
this.ensureRequiredFilters();
if (this.activeFilters.length) {
this.buildAvailableFilters();
this.submit();
}
}
private ensureRequiredFilters(): void {
const activeKeys = new Set(this.activeFilters.map((filter: ActiveFilter) => filter.definition.key));
this.filterDefinitions
.filter((definition: FilterDefinition) => definition.removable === false && !activeKeys.has(definition.key))
.forEach((definition: FilterDefinition) => {
const defaultFilter = this.defaultFilters.find(df => df.key === definition.key);
const filter = this.createFilter(definition, defaultFilter ? defaultFilter.value : undefined);
this.activeFilters.push(filter);
if (definition.dataType === 'date-preset' && filter.value != null) {
this.datePresetSelected.set(filter.id, filter.value);
}
});
}
private createFilter(definition: FilterDefinition, value?: any): ActiveFilter {
const defaultOp = DEFAULT_VALUE_OPERATOR[definition.dataType];
return {
id: this.nextId++,
definition,
value: value !== undefined ? value : this.getDefaultValue(definition, defaultOp),
operator: 'and',
valueOperator: defaultOp
};
}
private resetFilter(filter: ActiveFilter): ActiveFilter {
const defaultFilter = this.defaultFilters.find(df => df.key === filter.definition.key);
const defaultOp = DEFAULT_VALUE_OPERATOR[filter.definition.dataType];
return {
...filter,
value: defaultFilter ? defaultFilter.value : this.getDefaultValue(filter.definition, defaultOp),
operator: 'and',
valueOperator: defaultOp
};
}
private deserializeValue(value: any, dataType: FilterDataType, valueOperator: string): any {
if (value == null) { return value; }
if (dataType === 'date' && valueOperator === 'range' && Array.isArray(value)) {
return value.map(v => v ? new Date(v) : null);
}
if (dataType === 'date' && typeof value === 'string') {
return new Date(value);
}
if (dataType === 'date-preset' && Array.isArray(value)) {
return value.map(v => v ? new Date(v) : null);
}
return value;
}
}

View File

@ -4,7 +4,7 @@ export enum RoleIds { ADMIN = "0", APP = "1", APP_ADM = "2", CLIENT = "3", OFFIC
export enum JobStatus { NEW = 0, READY = 1, DOWNLOADED = 2, SPRAYED = 3, ARCHIVED = 9 };
export enum Units { OZ = 0, GAL, LB, LIT, KG, /*GR, CC, PT*/ };
export enum DRAW { SPRAY, PIVOT, XCL, WAYPOINT, BUFFER, PLACE, OBSTACLE, ABLINE };
export enum DRAW { SPRAY, PIVOT, XCL, WAYPOINT, BUFFER, PLACE, OBSTACLE, ABLINE, EDGE_BUFFER };
export enum PANE {
GeoItems = 'GeoItems', SprayZones = 'SprayZones', XCLZones = 'XCLZones', GridLines = 'GridLines', FlightPaths = 'FlightPaths', SprayData = 'SprayData',
Tracks = 'Tracks', ABLine = 'ABLine', Obstacles = 'Obstacles'
@ -628,6 +628,7 @@ export const globals = Object.freeze({
xclZone: $localize`:@@xclZone:Exclusion Zone`,
waypoint: $localize`:@@waypoint:WayPoint`,
bufferZone: $localize`:@@bufferZone:Buffer Zone`,
edgeBufferZone: $localize`:@@edgeBufferZone:Advanced Buffer Tools`,
placeMark: $localize`:@@placeMark:PlaceMark`,
obstacle: $localize`:@@obstacle:Obstacle`,
userObstacle: $localize`:@@userObstacle:User Obstacles`,

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