Compare commits

..

7 Commits

Author SHA1 Message Date
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
155 changed files with 75025 additions and 407 deletions

View File

@ -0,0 +1,89 @@
# Gitea Actions Server Tests
#
# Two jobs run on every push to any branch:
# 1. jest-integration Jest tests against the server's data methods (needs MongoDB)
# 2. mocha-unit Existing Mocha/Chai tests in tests/ and tests/utils/
#
# Prerequisites (Gitea repository secrets):
# DB_HOSTS MongoDB host(s), e.g. "127.0.0.1:27017"
# DB_NAME Must contain "test", e.g. "agmission_test"
# DB_USR MongoDB username
# DB_PWD MongoDB password
# DB_AUTH_SRC MongoDB auth source (default: "admin")
# TOKEN_SECRET JWT secret used by the server's auth helpers
name: Server Tests
on:
push:
branches:
- '**'
# ── Shared env-file step (inline, re-used by both jobs via heredoc) ──────────
jobs:
# ══════════════════════════════════════════════════════════════════════════
# 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).
- name: Run Mocha unit tests (tests/)
run: |
npx mocha --exit --timeout 120000 \
--require tests/setup.js \
'tests/test_*.js'
continue-on-error: false
# Run Mocha tests in tests/utils/
- name: Run Mocha utility tests (tests/utils/)
run: npm run test:utils
continue-on-error: false

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`

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

File diff suppressed because it is too large Load Diff

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,11 @@ 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: 'track',
loadChildren: () => import('./track/track.module').then(m => m.TrackModule),
@ -90,6 +95,11 @@ 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'
},
],
},
{

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

@ -41,6 +41,15 @@ export class AppMenuComponent implements OnInit {
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
{
id: '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',
@ -209,7 +218,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, RoleIds.APP_ADM])
? [{ 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

@ -90,6 +90,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

@ -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,13 +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">
<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>
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label>
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
</div>
@ -30,6 +42,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',
@ -34,11 +37,19 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
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,6 +67,14 @@ 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' },
];
}
ngOnInit() {
@ -70,7 +89,19 @@ 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[]) {
@ -101,6 +132,28 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
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 +163,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

@ -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

@ -0,0 +1,137 @@
import { Injectable } from '@angular/core';
import { from, Observable, of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AppConfigService } from './app-config.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 ttlStorageKey(cacheName: string): string {
return `browser-cache-ttl:${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

@ -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 },
{ 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

@ -23,11 +23,14 @@ 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 { 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';
@ -44,12 +47,13 @@ import { InvoicesModule } from '@app/invoices/invoices.module';
LeafletModule,
PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule,
CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule,
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule,
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule, AccordionModule,
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule,
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],
providers: [DatePipe],

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,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,171 @@
/* 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: #A5D6A7;
color: #2E7D32;
}
.badge-revoked {
background: #ffcdd2;
color: #b71c1c;
}
.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]="!selectedKey"
i18n-label="@@regenerateKey" label="Regenerate"
(click)="confirmRegenerate(selectedKey)">
</button>
<button *ngIf="isAdmin" type="button" pButton icon="ui-icon-block"
[disabled]="!selectedKey || !selectedKey.active"
i18n-label="@@revokeKey" label="Revoke"
(click)="confirmRevoke(selectedKey)">
</button>
<button type="button" pButton icon="ui-icon-trash"
[disabled]="!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,310 @@
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;
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.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.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, RoleIds.APP_ADM]
},
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,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';
@ -14,6 +14,7 @@ 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 +77,14 @@ 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';
@NgModule({
imports: [
CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule,
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule,
MultiSelectModule, FormsModule
],
declarations: [
LengthUnitPipe, RateUnitPipe, UserTypePipe, AreaUnitPipe, NoCommaPipe,
@ -90,18 +93,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
],
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
],
providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe]
})

View File

@ -0,0 +1,213 @@
: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 .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,132 @@
<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>
<button pButton type="button" icon="pi pi-times" class="ui-button-text remove-btn"
(click)="removeFilter(filter.id)">
</button>
</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, 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>
</ng-container>
</div><!-- /.filter-field-body -->
</div><!-- /.filter-field-inner -->
</div>
</div>
</div>

View File

@ -0,0 +1,378 @@
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[];
}
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; }
const hasValueOperator = VALUE_OPERATOR_OPTIONS[f.definition.dataType]?.length > 0;
query[f.definition.key] = {
value: f.value,
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 {
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.selectedFilterKey = null;
this.datePresetSelected.clear();
this.buildAvailableFilters();
this.clearState();
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';
}
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 '1m';
default: return null;
}
}
private saveState(): void {
if (!this.stateKey) { 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 defaultOp = DEFAULT_VALUE_OPERATOR[def.dataType];
const filter: ActiveFilter = {
id: this.nextId++,
definition: def,
value: df.value,
operator: 'and',
valueOperator: defaultOp
};
this.activeFilters.push(filter);
if (def.dataType === 'date-preset') {
this.datePresetSelected.set(filter.id, df.value);
}
}
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 = {
id: this.nextId++,
definition: def,
value: 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);
}
}
if (this.activeFilters.length) {
this.buildAvailableFilters();
this.submit();
}
}
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);
}
return value;
}
}

View File

@ -1,3 +1,9 @@
.ui-g.ui-g-12 {
padding: 6px 0px 0px 0px;
margin-bottom: 0;
min-width: 19rem;
}
.ui-confirmdialog-message ul {
margin: 0;
}

View File

@ -1,4 +1,4 @@
<div class="ui-g ui-g-12" style="padding: 6px 0px 0px 0px; margin-bottom: 0">
<div class="ui-g ui-g-12">
<div class="ui-g-3 ui-sm-12 ui-md-4 ui-lg-3 ui-xl-3" style="margin-top: 8px">
<div class="flex-row">
<div style="margin-bottom: 4px; display: inline-block;flex-grow: 0;">

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 { SettingsGuard } from '../../domain/guards/settings-guard.service';
import { RoleIds } from '../../shared/global';
import { DlqMonitorComponent } from './dlq-monitor.component';
const routes: Routes = [
{
path: '',
component: DlqMonitorComponent,
data: { roles: [RoleIds.ADMIN] },
canActivate: [AuthGuard, SettingsGuard]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: [AuthGuard]
})
export class DlqMonitorRoutingModule { }

View File

@ -0,0 +1,97 @@
/* Stats row */
.dlq-stats-row {
margin-bottom: 0.75rem;
}
.dlq-stat-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 0.25rem;
padding: 1rem 1.125rem;
margin: 0.25rem 0.25rem 0.25rem 0;
border-left: 4px solid #BDBDBD;
}
.dlq-stat-card.stat-success { border-left-color: #4caf50; }
.dlq-stat-card.stat-warning { border-left-color: #FFC107; }
.dlq-stat-card.stat-danger { border-left-color: #f44336; }
.dlq-stat-label {
font-size: 0.78em;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #757575;
font-weight: 600;
margin-bottom: 0.375rem;
}
.dlq-stat-value {
font-size: 2em;
font-weight: 700;
color: #212121;
line-height: 1.1;
}
.dlq-stat-card.stat-success .dlq-stat-value { color: #2E7D32; }
.dlq-stat-card.stat-warning .dlq-stat-value { color: #FF8F00; }
.dlq-stat-card.stat-danger .dlq-stat-value { color: #f44336; }
.dlq-stat-sub {
font-size: 0.82em;
color: #757575;
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.1875rem;
}
.dlq-stat-icon {
font-size: 0.875rem !important;
vertical-align: middle;
}
/* Messages table */
.dlq-messages-table {
margin-top: 0.75rem;
}
.dlq-msg-icon {
font-size: 0.9375rem !important;
color: #757575;
vertical-align: middle;
margin-right: 0.25rem;
}
.dlq-error-icon {
font-size: 0.875rem !important;
color: #f44336;
vertical-align: middle;
margin-right: 0.2rem;
}
.dlq-error-cell {
font-size: 0.85em;
color: #c62828;
font-family: monospace;
}
/* Category badges — uses agm-badge base from global styles */
.dlq-category-badge {
font-size: 0.75em !important;
vertical-align: middle;
}
.category-transient { background: #03A9F4; color: #fff; }
.category-validation { background: #f44336; color: #fff; }
.category-processing { background: #FF9800; color: #fff; }
.category-infrastructure { background: #757575; color: #fff; }
.category-partner_api { background: #4527A0; color: #fff; }
.category-unknown { background: #9E9E9E; color: #fff; }
/* Purge dialog warning */
.dlq-purge-warning {
color: #b71c1c;
margin-bottom: 0.625rem;
font-size: 0.95em;
}

View File

@ -0,0 +1,210 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card card-w-title">
<h1>Dead Letter Queue Monitor</h1>
<!-- Queue Selector & Last Updated -->
<div class="ui-g" style="align-items: center; margin-bottom: 8px;">
<div class="ui-g-12 ui-md-4 ui-lg-3 ui-g-nopad" style="display:flex; align-items:center; gap:10px;">
<label style="font-weight:bold; white-space:nowrap;">Queue:</label>
<p-dropdown
[options]="queues"
[(ngModel)]="selectedQueue"
(onChange)="refreshAll()"
[style]="{'width':'220px'}">
</p-dropdown>
</div>
<div class="ui-g-12 ui-md-8 ui-lg-9" *ngIf="lastUpdated" style="text-align:right; color:#757575; font-size:0.85em; padding-top:4px;">
Last updated: {{ lastUpdated | date:'medium' }}
</div>
</div>
<!-- Stats Row -->
<div class="ui-g dlq-stats-row">
<div class="ui-g-12 ui-sm-6 ui-md-3">
<div class="dlq-stat-card" [ngClass]="dlqStatusClass">
<div class="dlq-stat-label">DLQ Messages</div>
<div class="dlq-stat-value">{{ loadingStats ? '…' : dlqCount }}</div>
<div class="dlq-stat-sub">
<i class="material-icons dlq-stat-icon">{{ dlqCount >= 50 ? 'error' : dlqCount >= 20 ? 'warning' : 'check_circle' }}</i>
{{ loadingStats ? 'Loading…' : dlqStatusLabel }}
</div>
</div>
</div>
<div class="ui-g-12 ui-sm-6 ui-md-3">
<div class="dlq-stat-card">
<div class="dlq-stat-label">Retention Period</div>
<div class="dlq-stat-value">365</div>
<div class="dlq-stat-sub"><i class="material-icons dlq-stat-icon">schedule</i> days until auto-archive</div>
</div>
</div>
<div class="ui-g-12 ui-sm-6 ui-md-3">
<div class="dlq-stat-card">
<div class="dlq-stat-label">Alert Threshold</div>
<div class="dlq-stat-value">20</div>
<div class="dlq-stat-sub"><i class="material-icons dlq-stat-icon">notifications</i> messages before alert</div>
</div>
</div>
<div class="ui-g-12 ui-sm-6 ui-md-3">
<div class="dlq-stat-card">
<div class="dlq-stat-label">Consumers</div>
<div class="dlq-stat-value">{{ loadingStats ? '…' : consumerCount }}</div>
<div class="dlq-stat-sub"><i class="material-icons dlq-stat-icon">people</i> active</div>
</div>
</div>
</div>
<!-- Messages Table -->
<p-table #dt [value]="messages" [paginator]="true" [rows]="10" [rowsPerPageOptions]="[10, 20, 50]"
[alwaysShowPaginator]="true" [responsive]="true" styleClass="dlq-messages-table"
selectionMode="single" [(selection)]="selectedMessage">
<ng-template pTemplate="caption">
<div class="ui-g ui-g-nopad" style="align-items:center;">
<div class="ui-g-12 ui-g-nopad">
<span class="table-caption-1">Recent Messages</span>
</div>
</div>
</ng-template>
<ng-template pTemplate="header">
<tr>
<th>File</th>
<th>Partner</th>
<th>Category</th>
<th>Severity</th>
<th>Error</th>
</tr>
<tr>
<th class="ui-fluid">
<div class="input-with-icon">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, 'taskInfo.logFileName', 'contains')" placeholder="Filter...">
</div>
</th>
<th class="ui-fluid">
<div class="input-with-icon">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, '_partnerCode', 'contains')" placeholder="Filter...">
</div>
</th>
<th class="ui-fluid">
<p-dropdown [options]="categoryFilterOptions" [(ngModel)]="categoryFilter"
(onChange)="dt.filter($event.value, '_errorCategory', 'equals')"
[showClear]="true" placeholder="All"></p-dropdown>
</th>
<th class="ui-fluid">
<p-dropdown [options]="severityFilterOptions" [(ngModel)]="severityFilter"
(onChange)="dt.filter($event.value, '_severity', 'equals')"
[showClear]="true" placeholder="All"></p-dropdown>
</th>
<th class="ui-fluid">
<div class="input-with-icon">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="dt.filter($event.target.value, 'errorMessage', 'contains')" placeholder="Filter...">
</div>
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-msg>
<tr [pSelectableRow]="msg">
<td>
<span class="ui-column-title">File</span>
<i class="material-icons dlq-msg-icon">description</i>
{{ msg.taskInfo?.logFileName || 'Unknown' }}
</td>
<td>
<span class="ui-column-title">Partner</span>
{{ msg._partnerCode || 'N/A' }}
</td>
<td class="table-col-center">
<span class="ui-column-title">Category</span>
<span class="agm-badge dlq-category-badge" [ngClass]="getCategoryClass(msg)">
{{ msg._errorCategory }}
</span>
</td>
<td>
<span class="ui-column-title">Severity</span>
{{ msg._severity }}
</td>
<td class="dlq-error-cell">
<span class="ui-column-title">Error</span>
<span *ngIf="msg.errorMessage" title="{{ msg.errorMessage }}">
<i class="material-icons dlq-error-icon">error_outline</i>
{{ msg.errorMessage | slice:0:80 }}{{ msg.errorMessage.length > 80 ? '…' : '' }}
</span>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="5" style="text-align:center; color:#757575; padding:1.5rem;">
<i class="material-icons" style="vertical-align:middle; margin-right:0.25rem;">inbox</i> No messages in DLQ.
</td>
</tr>
</ng-template>
<ng-template pTemplate="paginatorleft" let-state>
{{ state.totalRecords }} message{{ state.totalRecords !== 1 ? 's' : '' }}
</ng-template>
</p-table>
<!-- Queue Operations Toolbar -->
<div class="ui-widget-header ui-helper-clearfix toolbar">
<button pButton type="button" icon="ui-icon-refresh" label="Refresh" class="blue-btn" (click)="refreshAll()"></button>
<button pButton type="button" icon="ui-icon-redo" label="Retry Message" class="green-btn" [disabled]="!selectedMessage" (click)="retrySelected()"></button>
<button pButton type="button" icon="ui-icon-redo" label="Retry All" class="green-btn" (click)="retryAll()"></button>
<button pButton type="button" icon="ui-icon-label" label="Retry by Header" class="green-btn" (click)="openRetryByHeaderDialog()"></button>
<button pButton type="button" icon="ui-icon-flash-on" label="Auto-Process" class="green-btn" (click)="processDLQ()"></button>
<button pButton type="button" icon="ui-icon-delete-forever" label="Purge" class="green-btn" (click)="openPurgeDialog()"></button>
</div>
</div>
</div>
</div>
<!-- Retry by Header Dialog -->
<p-dialog
header="Retry by Header"
[(visible)]="showRetryByHeaderDialog"
[modal]="true"
[style]="{width:'440px'}"
[closable]="true">
<div class="ui-g ui-g-fluid">
<div class="ui-g-12">
<label for="header-name">Header name <small>(e.g. x-partner-code)</small>:</label>
<input id="header-name" type="text" pInputText [(ngModel)]="headerName" style="width:100%;margin-top:4px;" placeholder="x-partner-code" />
</div>
<div class="ui-g-12" style="margin-top:12px;">
<label for="header-value">Header value <small>(e.g. SATLOC)</small>:</label>
<input id="header-value" type="text" pInputText [(ngModel)]="headerValue" style="width:100%;margin-top:4px;" placeholder="SATLOC" />
</div>
</div>
<p-footer>
<button pButton type="button" icon="ui-icon-close" label="Cancel" (click)="showRetryByHeaderDialog = false"></button>
<button pButton type="button" icon="ui-icon-redo" label="Retry" class="green-btn"
[disabled]="!headerName.trim() || !headerValue.trim()" (click)="submitRetryByHeader()"></button>
</p-footer>
</p-dialog>
<!-- Purge Confirmation Dialog -->
<p-dialog
header="Purge DLQ"
[(visible)]="showPurgeDialog"
[modal]="true"
[style]="{width:'460px'}"
[closable]="true">
<div class="ui-g ui-g-fluid">
<div class="ui-g-12">
<p class="dlq-purge-warning">
<i class="material-icons" style="vertical-align:middle; margin-right:4px;">warning</i>
This will permanently delete <strong>ALL</strong> messages from the queue.
Type <strong>PURGE</strong> to confirm.
</p>
<input type="text" pInputText [(ngModel)]="purgeConfirmText" style="width:100%;margin-top:4px;" placeholder="Type PURGE to confirm" />
</div>
</div>
<p-footer>
<button pButton type="button" icon="ui-icon-close" label="Cancel" (click)="showPurgeDialog = false"></button>
<button pButton type="button" icon="ui-icon-delete-forever" label="Purge" class="orange-btn"
[disabled]="purgeConfirmText !== 'PURGE'" (click)="submitPurge()"></button>
</p-footer>
</p-dialog>

View File

@ -0,0 +1,231 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { SelectItem } from 'primeng/api';
import { BaseComp } from '@app/shared/base/base.component';
import { DlqMonitorService, DlqStats, DlqMessage } from './dlq-monitor.service';
export interface DlqMessageRow extends DlqMessage {
_partnerCode: string;
_errorCategory: string;
_severity: string;
_queuePosition: number;
}
@Component({
selector: 'agm-dlq-monitor',
templateUrl: './dlq-monitor.component.html',
styleUrls: ['./dlq-monitor.component.css']
})
export class DlqMonitorComponent extends BaseComp implements OnInit, OnDestroy {
queues: SelectItem[] = [
{ label: 'dev_partner_tasks', value: 'dev_partner_tasks' },
{ label: 'partner_tasks', value: 'partner_tasks' }
];
selectedQueue = 'dev_partner_tasks';
stats: DlqStats | null = null;
messages: DlqMessageRow[] = [];
lastUpdated: Date | null = null;
loadingStats = false;
loadingMessages = false;
// Table selection & filter state
selectedMessage: DlqMessageRow | null = null;
categoryFilter: string = null;
severityFilter: string = null;
categoryFilterOptions: SelectItem[] = [
{ label: 'transient', value: 'transient' },
{ label: 'validation', value: 'validation' },
{ label: 'processing', value: 'processing' },
{ label: 'infrastructure', value: 'infrastructure' },
{ label: 'partner_api', value: 'partner_api' },
{ label: 'unknown', value: 'unknown' },
];
severityFilterOptions: SelectItem[] = [
{ label: 'low', value: 'low' },
{ label: 'medium', value: 'medium' },
{ label: 'high', value: 'high' },
{ label: 'critical', value: 'critical' },
];
// Retry by header dialog
showRetryByHeaderDialog = false;
headerName = '';
headerValue = '';
// Purge dialog
showPurgeDialog = false;
purgeConfirmText = '';
private refreshInterval: any;
constructor(private readonly dlqSvc: DlqMonitorService) {
super();
}
ngOnInit(): void {
this.refreshAll();
this.refreshInterval = setInterval(() => this.refreshAll(), 30000);
}
ngOnDestroy(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
get dlqCount(): number {
return this.stats?.dlq?.messageCount ?? 0;
}
get consumerCount(): number {
return this.stats?.dlq?.consumerCount ?? 0;
}
get dlqStatusLabel(): string {
if (this.dlqCount >= 50) return 'CRITICAL';
if (this.dlqCount >= 20) return 'WARNING';
return 'Normal';
}
get dlqStatusClass(): string {
if (this.dlqCount >= 50) return 'stat-danger';
if (this.dlqCount >= 20) return 'stat-warning';
return 'stat-success';
}
refreshAll(): void {
this.loadStats();
this.loadMessages();
}
private loadStats(): void {
this.loadingStats = true;
this.dlqSvc.getStats(this.selectedQueue).subscribe({
next: (data) => {
this.stats = data;
this.loadingStats = false;
},
error: (err) => {
this.msgSvc.addFailedMsg('Failed to load stats: ' + (err?.error?.error?.message || err.message));
this.loadingStats = false;
}
});
}
private loadMessages(): void {
this.loadingMessages = true;
this.dlqSvc.getMessages(this.selectedQueue, 20).subscribe({
next: (data) => {
this.messages = (data.messages || []).map((msg, index) => ({
...msg,
_partnerCode: (msg.headers && msg.headers['x-partner-code']) || '',
_errorCategory: (msg.headers && msg.headers['x-error-category']) || 'unknown',
_severity: (msg.headers && msg.headers['x-severity']) || 'low',
_queuePosition: index,
}));
this.selectedMessage = null;
this.lastUpdated = new Date();
this.loadingMessages = false;
},
error: (err) => {
this.msgSvc.addFailedMsg('Failed to load messages: ' + (err?.error?.error?.message || err.message));
this.loadingMessages = false;
}
});
}
retryAll(): void {
this.confirmSvc.confirm({
message: 'Retry all DLQ messages?',
header: 'Confirm Retry All',
icon: 'pi pi-exclamation-triangle',
accept: () => {
this.dlqSvc.retryAll(this.selectedQueue).subscribe({
next: (data) => {
this.msgSvc.addSuccessMsg(`Retried ${data.retriedCount} messages!`);
this.refreshAll();
},
error: (err) => this.msgSvc.addFailedMsg('Failed to retry: ' + (err?.error?.error?.message || err.message))
});
}
});
}
retrySelected(): void {
if (!this.selectedMessage) { return; }
this.retryByPosition(this.selectedMessage._queuePosition);
}
retryByPosition(position: number): void {
this.dlqSvc.retryByPosition(this.selectedQueue, position).subscribe({
next: () => {
this.msgSvc.addSuccessMsg(`Retried message at position ${position}!`);
this.refreshAll();
},
error: (err) => this.msgSvc.addFailedMsg('Failed to retry: ' + (err?.error?.error?.message || err.message))
});
}
openRetryByHeaderDialog(): void {
this.headerName = '';
this.headerValue = '';
this.showRetryByHeaderDialog = true;
}
submitRetryByHeader(): void {
if (!this.headerName.trim() || !this.headerValue.trim()) { return; }
this.showRetryByHeaderDialog = false;
this.dlqSvc.retryByHeader(this.selectedQueue, this.headerName.trim(), this.headerValue.trim()).subscribe({
next: (data) => {
this.msgSvc.addSuccessMsg(`Retried ${data.retriedCount} messages!`);
this.refreshAll();
},
error: (err) => this.msgSvc.addFailedMsg('Failed to retry by header: ' + (err?.error?.error?.message || err.message))
});
}
processDLQ(): void {
this.confirmSvc.confirm({
message: 'Auto-process DLQ? This will categorize errors and retry/archive messages.',
header: 'Confirm Auto-Process',
icon: 'pi pi-exclamation-triangle',
accept: () => {
this.dlqSvc.processDLQ(this.selectedQueue).subscribe({
next: (data) => {
this.msgSvc.addSuccessMsg(`Processed ${data.processed}: ${data.retried} retried, ${data.archived} archived`);
this.refreshAll();
},
error: (err) => this.msgSvc.addFailedMsg('Failed to process: ' + (err?.error?.error?.message || err.message))
});
}
});
}
openPurgeDialog(): void {
this.purgeConfirmText = '';
this.showPurgeDialog = true;
}
submitPurge(): void {
if (this.purgeConfirmText !== 'PURGE') { return; }
this.showPurgeDialog = false;
this.dlqSvc.purgeDLQ(this.selectedQueue).subscribe({
next: (data) => {
this.msgSvc.addSuccessMsg(`Purged ${data.purgedCount} messages`);
this.refreshAll();
},
error: (err) => this.msgSvc.addFailedMsg('Failed to purge: ' + (err?.error?.error?.message || err.message))
});
}
getMsgHeader(msg: DlqMessage, key: string): string {
return (msg.headers && msg.headers[key]) || null;
}
getCategoryClass(msg: DlqMessageRow): string {
return 'category-' + (msg._errorCategory || 'unknown').replace(/[^a-z0-9_]/gi, '_').toLowerCase();
}
}

View File

@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { DialogModule } from 'primeng-lts/dialog';
import { ConfirmDialogModule } from 'primeng-lts/confirmdialog';
import { ProgressSpinnerModule } from 'primeng-lts/progressspinner';
import { TableModule } from 'primeng-lts/table';
import { AppSharedModule } from '../../shared/app-shared.module';
import { DlqMonitorRoutingModule } from './dlq-monitor-routing.module';
import { DlqMonitorComponent } from './dlq-monitor.component';
@NgModule({
imports: [
AppSharedModule,
DialogModule,
ConfirmDialogModule,
ProgressSpinnerModule,
TableModule,
DlqMonitorRoutingModule
],
declarations: [DlqMonitorComponent]
})
export class DlqMonitorModule { }

View File

@ -0,0 +1,55 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface DlqStats {
dlq: {
messageCount: number;
consumerCount: number;
};
}
export interface DlqMessage {
taskInfo?: { logFileName?: string };
headers?: { [key: string]: string };
errorMessage?: string;
}
export interface DlqMessagesResponse {
messages: DlqMessage[];
}
@Injectable({ providedIn: 'root' })
export class DlqMonitorService {
private readonly base = '/dlq';
constructor(private readonly http: HttpClient) {}
getStats(queue: string): Observable<DlqStats> {
return this.http.get<DlqStats>(`${this.base}/${queue}/stats`);
}
getMessages(queue: string, limit = 20): Observable<DlqMessagesResponse> {
return this.http.get<DlqMessagesResponse>(`${this.base}/${queue}/messages?limit=${limit}`);
}
retryAll(queue: string): Observable<{ retriedCount: number }> {
return this.http.post<{ retriedCount: number }>(`${this.base}/${queue}/retryAll`, { maxMessages: 1000 });
}
retryByPosition(queue: string, position: number): Observable<any> {
return this.http.post<any>(`${this.base}/${queue}/retryByPosition`, { position });
}
retryByHeader(queue: string, headerName: string, headerValue: string): Observable<{ retriedCount: number }> {
return this.http.post<{ retriedCount: number }>(`${this.base}/${queue}/retryByHeader`, { headerName, headerValue, maxMessages: 100 });
}
processDLQ(queue: string): Observable<{ processed: number; retried: number; archived: number }> {
return this.http.post<{ processed: number; retried: number; archived: number }>(`${this.base}/${queue}/process`, { maxMessages: 100 });
}
purgeDLQ(queue: string): Observable<{ purgedCount: number }> {
return this.http.request<{ purgedCount: number }>('DELETE', `${this.base}/${queue}/purge`, { body: { confirm: true } });
}
}

View File

@ -20,6 +20,7 @@ export class ToolsCanactiveGuard implements CanActivate {
// TODO: find a way to re-use this logic across multiple feature modules
// Make sure clients loaded first
if (this.authSvc.isAdmin) return of(true);
if (this.authSvc.isClientUser) return of(true);
return forkJoin(

View File

@ -14,12 +14,13 @@ import { SettingsComponent } from './settings/settings.component';
import { SettingsGuard } from '../domain/guards/settings-guard.service';
import { GMapLoadGuard } from '../domain/guards/gmap-load.guard';
const routes: Routes = [
{
path: '',
component: ToolsMgtComponent,
data: {
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]
roles: [RoleIds.ADMIN, RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]
},
canActivate: [AuthGuard, SettingsGuard, ToolsCanactiveGuard],
children: [

View File

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

View File

@ -2,6 +2,60 @@
box-sizing: border-box;
}
.ui-g.ui-g-12 {
min-width: 19rem;
}
@media screen and (max-width: 40em) {
.left-panel-wrapper {
position: static !important;
margin-top: 8px !important;
}
.left-panel-wrapper .left-panel {
position: relative;
width: 100% !important;
left: auto !important;
height: 40vh;
overflow: hidden !important;
}
.left-panel-wrapper .panel-content {
height: 100%;
overflow-y: auto !important;
}
.left-panel-wrapper .resize-handle-right {
display: none;
}
.left-panel-wrapper .handle-right {
display: none;
}
.resize-handle-bottom {
display: block;
}
.resize-handle-bottom::after {
content : "\25B2\FE0E \25BC\FE0E";
position : absolute;
left : 50%;
top : 50%;
transform : translate(-50%, -50%);
font-size : .65em;
color : rgb(100, 100, 100);
pointer-events : none;
line-height : 1;
}
}
@media screen and (min-width: 40.063em) {
.resize-handle-bottom {
display: none;
}
}
.resize-handle-right,
.resize-handle-top {
position : absolute;
@ -24,6 +78,16 @@
cursor: row-resize;
}
.resize-handle-bottom {
position : absolute;
background-color: #e6eee6;
height : 10px;
width : 100%;
bottom : 0;
left : 0;
cursor : row-resize;
}
.left-panel,
.bottom-panel {
z-index : 1001;

View File

@ -1,5 +1,5 @@
<div class="ui-g ui-g-12" style="padding: 6px 0px 0px 0px; margin-bottom: 0">
<div class="ui-g-4 ui-sm-12 ui-md-4 ui-lg-3 ui-xl-3" style="margin-top:42px; position:absolute; padding: 0;">
<div class="left-panel-wrapper ui-g-4 ui-sm-12 ui-md-4 ui-lg-3 ui-xl-3" style="margin-top:42px; position:absolute; padding: 0;">
<!-- Resizeable left panel -->
<div #leftPanel class="left-panel" [ngStyle]="leftPStyle" mwlResizable [mouseMoveThrottleMS]="50" [validateResize]="validatePLeft" [enableGhostResize]="true" (resizeEnd)="onLeftPResizeEnd($event)">
<div #leftPContent class="panel-content">
@ -198,6 +198,7 @@
</button>
</div>
</div>
<div class="resize-handle-bottom" mwlResizeHandle [resizeEdges]="{ bottom: true }"></div>
</div>
</div>

View File

@ -486,6 +486,26 @@ export class TrackComponent extends MapBaseComp implements OnInit, AfterViewInit
}
}
@HostListener('window:resize', ['$event'])
resizeEvent(e: any) {
this.resetLeftPStyleForWidth();
this.updateMapSize(500);
}
private resetLeftPStyleForWidth() {
if (window.innerWidth <= 640) {
if (this.leftPStyle && this.leftPStyle['position'] === 'absolute') {
const h = this.leftPStyle['height'];
this.leftPStyle = h ? { height: h } : null;
}
} else {
if (!this.leftPStyle || this.leftPStyle['position'] !== 'absolute') {
const w = this.panelState.left && this.panelState.left.width ? this.panelState.left.width : MAX_LP_WIDTH_PX;
this.leftPStyle = { position: 'absolute', left: '0px', width: `${w}px` };
}
}
}
@HostListener('window:keyup', ['$event'])
keyEvent(e: KeyboardEvent) {
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === "h") {
@ -1046,6 +1066,12 @@ export class TrackComponent extends MapBaseComp implements OnInit, AfterViewInit
/* Handle Dynamic UI resizing */
validatePLeft(e) {
if (window.innerWidth <= 640) {
// Small screen: only validate height (bottom-edge drag)
if (e.rectangle.height && e.rectangle.height < MIN_PANEL_PX)
return false;
return true;
}
if (
e.rectangle.width && e.rectangle.height &&
(e.rectangle.width < MIN_PANEL_PX || e.rectangle.right > MAX_LP_WIDTH_PX
@ -1056,12 +1082,21 @@ export class TrackComponent extends MapBaseComp implements OnInit, AfterViewInit
}
onLeftPResizeEnd(e) {
this.zone.runOutsideAngular(() => {
if (window.innerWidth <= 640) {
// Small screen: apply height from bottom-edge drag
if (e.rectangle.height) {
this.leftPStyle = {
height: `${e.rectangle.height}px`,
};
}
} else {
this.leftPStyle = {
position: 'absolute',
left: `${e.rectangle.left}px`,
width: `${e.rectangle.width}px`,
};
this.panelState.left.width = e.rectangle.width;
}
});
}
onLeftPToggle(e: Event) {

View File

@ -12,6 +12,10 @@ body {
letter-spacing: unset;
}
.card {
min-width: 19rem;
}
// Avoid unneeded outline for links
a:focus {
outline: none;
@ -346,6 +350,55 @@ body .layout-container .topbar {
z-index: 2000;
}
// Always show a consistent hamburger button; never the apps/squares icon
.layout-container {
// Always show #topbar-menu-button, locked to 36px regardless of breakpoint
.topbar .topbar-right #topbar-menu-button {
display: block !important;
margin-left: 1rem;
i {
font-size: 36px !important;
}
}
// Hide the notification badge on the hamburger button
.topbar .topbar-right #topbar-menu-button .topbar-badge {
display: none !important;
}
// At wide screens the theme shows topbar-items inline (apps/squares icon).
// Override: hide it by default and only show it when activated by the hamburger,
// using the same dropdown behaviour as narrow screens.
.topbar-items {
float: none !important;
display: none !important;
position: absolute;
top: 75px;
right: 15px;
width: 275px;
&.topbar-items-visible {
display: block !important;
}
}
// When the panel is open, skip the intermediate apps/squares click:
// hide the clickable row header and always show the submenu links directly.
.topbar-items.topbar-items-visible .profile-item {
> a {
display: none !important;
}
> ul.ultima-menu {
display: block !important;
position: static;
width: 100%;
box-shadow: none;
animation: none;
}
}
}
body .ui-growl {
top: 120px;
z-index: 2021;
@ -695,6 +748,11 @@ body .slim-popup .leaflet-popup-content {
border: green solid 1px;
}
.agm-accordion .ui-accordion-header.ui-state-default.ui-corner-all.ui-state-active {
background-color: white;
border: green solid 1px;
}
.slim-accordion.ui-accordion .ui-accordion-header>a {
padding: .1em 1em;
}
@ -704,6 +762,11 @@ body .slim-popup .leaflet-popup-content {
color: darkgreen;
}
.agm-accordion.ui-accordion .ui-accordion-header.ui-state-active,
.agm-accordion.ui-accordion .ui-accordion-header.ui-state-active>a {
color: darkgreen;
}
.ui-accordion.ui-accordion .ui-state-active .pi,
.ui-accordion.ui-accordion .ui-state-highlight .pi {
color: unset;
@ -1523,8 +1586,8 @@ body .ui-table .ui-table-tbody>tr>td {
// ============================================================================
// EXPIRY WARNING RESPONSIVE PLACEMENT
// At >1024px: shown in topbar (account-summary-info), content banner hidden
// At 1024px: hidden in topbar (too narrow), shown as sticky bar below toolbar
// At >640px: shown inline in topbar (account-summary-info)
// At 640px: hidden entirely (screen too narrow)
// ============================================================================
// Global expiry-warning pill styles (used in topbar and content banner)
@ -1560,34 +1623,141 @@ body .ui-table .ui-table-tbody>tr>td {
display: none;
}
// Left-side menu tab hidden by default, shown only on mobile via media query below
#mobile-menu-tab {
display: none;
position: fixed;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 56px;
align-items: center;
justify-content: center;
background-color: #4CAF50;
border-radius: 0 6px 6px 0;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.35);
z-index: 101;
border: none;
padding: 0;
cursor: pointer;
color: #ffffff;
transition: background-color 0.2s ease;
&:hover {
background-color: #2E7D32;
}
.material-icons {
font-size: 20px;
}
}
// User info block inside the left panel only visible on small screens
.menu-user-info {
display: none;
}
@media (max-width: 1024px) {
// Hide topbar warning on small screens
// Hide the yellow-arrow button; replaced by the left-side tab
.layout-container .topbar .topbar-right #menu-button {
display: none !important;
}
// Show the left-side menu tab
#mobile-menu-tab {
display: flex;
}
}
@media (max-width: 640px) {
// Shrink logo panel so topbar-right has enough room
.layout-container .topbar {
.topbar-left {
width: 60px;
padding: 20px 10px;
}
.topbar-right {
width: calc(100% - 60px);
}
}
// Hide user info text and expiry warning in the topbar on small screens
.topbar-right .account-summary-info,
.topbar-right .expiry-warning {
display: none !important;
}
// Sticky banner in normal flow sits just below topbar, content scrolls under it
// Show expiry warning as a fixed full-width banner just below the topbar
.content-expiry-banner {
display: flex;
justify-content: flex-end;
position: sticky;
top: 80px;
display: flex !important;
position: fixed;
top: 75px;
left: 0;
right: 0;
z-index: 1999;
.account-summary-info {
padding-top: 0;
margin-right: 0;
}
justify-content: center;
pointer-events: none;
.expiry-warning {
display: inline-block;
display: block !important;
width: 100%;
text-align: center;
font-size: 0.85rem;
padding: 6px 10px;
margin-top: 0;
// margin-right: 6px;
border-radius: 0 0 0 4px;
padding: 6px 12px;
margin: 0;
border-radius: 0;
border-left: none;
border-right: none;
border-top: none;
cursor: pointer;
pointer-events: auto;
white-space: normal;
word-break: break-word;
word-break: normal;
overflow-wrap: normal;
}
}
// Push layout-main down to make room for the banner
.layout-container:has(.content-expiry-banner) .layout-main {
padding-top: 111px; // 75px topbar + ~36px banner
}
// Show user info (without warning) at the top of the left panel
.menu-user-info {
display: block;
padding: 16px 16px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
background-color: #2E7D32;
text-align: center;
.account-summary-info {
display: block;
text-align: center;
padding-top: 0;
font-size: 0.9rem;
}
.account-username {
display: block;
font-weight: 600;
margin-bottom: 2px;
}
.account-type {
display: block;
margin-bottom: 2px;
}
.account-contact {
display: block;
}
// Hide warning inside panel it's shown in the banner instead
.expiry-warning {
display: none !important;
}
}
}
@ -1686,6 +1856,21 @@ body .ui-table .ui-table-tbody>tr>td {
// Pattern matches PrimeNG's ui-datatable-stacked responsive behavior
// Headers (ui-column-title) left-aligned, values flow naturally after
// ============================================================================
// Utility
.full-width {
width: 100%;
}
// Scroll container: prevents table overflow; enforces a minimum usable width
p-table {
display: block;
}
body .ui-table {
min-width: 16.25rem;
}
@media (max-width: 767px) {
body .ui-table.ui-table-responsive {
@ -1720,6 +1905,7 @@ body .ui-table .ui-table-tbody>tr>td {
}
}
// ============================================================================
// SHARED CONSTRAINT MESSAGE COMPONENT SYSTEM
// AgMission Project Color Palette Compliance

BIN
Development/libs/phantomjs Executable file

Binary file not shown.

Binary file not shown.

View File

@ -511,3 +511,32 @@ require('dotenv').config({ path: envPath });
- Keep README files synchronized with actual implementation
**DLQ Testing**: Use `docs/Partner_DLQ_API.postman_collection.json` to test all 6 queue-native endpoints.
## Mermaid Diagram Standards (v11.12.0 Compatibility)
When creating Mermaid diagrams in documentation, follow these rules to avoid v11.12.0 syntax errors:
### Forbidden Syntax (v11.12.0 does NOT support):
- ❌ HTML line breaks in node text: `A["Text<br/>on lines"]` → FAILS
- ❌ Escaped quotes: `A{\"Text\"}` → FAILS
- ❌ `note` blocks in stateDiagram → FAILS
- ❌ Complex HTML formatting in labels
- ❌ Angle brackets in unquoted text: `-->|Text <key>|` → FAILS
- ❌ Long multi-line text in single node
### Required Syntax (v11.12.0 compatible):
- ✅ Plain text: `A[Simple text]`
- ✅ Single-line labels: `A[Text here]`
- ✅ Minimal quoting: use double quotes only when needed
- ✅ Split complex info across multiple connected nodes
- ✅ Use separate table/bullets below diagram for details
- ✅ For line breaks: create separate nodes and edges
- ✅ In sequenceDiagram: `participant A as Simple Name` (no `<br/>`)
### Best Practices:
1. Keep node labels to single line
2. No HTML line breaks (`<br/>`) anywhere
3. Use plain text for transition labels
4. Wrap special chars in quotes only if needed
5. Test in mermaid.live before committing
6. Place detailed explanations in supporting text, not in diagram nodes

View File

@ -1 +1 @@
engine-strict=true
engine-strict=false

View File

@ -1 +0,0 @@
jobId,orderNumber,jobName,sessionId,fileName,pilotName,timestampUtc,gpsTime,lat,lon,utmX,utmY,alt_m,groundSpeed_ms,heading,crossTrackError_m,lockedLine,hdop,satsInView,correctionId,waasId,sprayStat,flowRateApplied_Lmin,flowRateRequired_Lmin,appRateRequired_Lha,appRateApplied_Lha,swathWidth_m,boomPressure_psi,sprayOnLag_s,sprayOffLag_s,pulsesPerLitre,windSpeed_ms,windDir_deg,temp_c,humidity_pct
1 jobId orderNumber jobName sessionId fileName pilotName timestampUtc gpsTime lat lon utmX utmY alt_m groundSpeed_ms heading crossTrackError_m lockedLine hdop satsInView correctionId waasId sprayStat flowRateApplied_Lmin flowRateRequired_Lmin appRateRequired_Lha appRateApplied_Lha swathWidth_m boomPressure_psi sprayOnLag_s sprayOffLag_s pulsesPerLitre windSpeed_ms windDir_deg temp_c humidity_pct

View File

@ -1 +0,0 @@
jobId,orderNumber,jobName,sessionId,fileName,pilotName,timestampUtc,gpsTime,lat,lon,utmX,utmY,alt_m,groundSpeed_ms,heading,crossTrackError_m,lockedLine,hdop,satsInView,correctionId,waasId,sprayStat,flowRateApplied_Lmin,flowRateRequired_Lmin,appRateRequired_Lha,appRateApplied_Lha,swathWidth_m,boomPressure_psi,sprayOnLag_s,sprayOffLag_s,pulsesPerLitre,windSpeed_ms,windDir_deg,temp_c,humidity_pct
1 jobId orderNumber jobName sessionId fileName pilotName timestampUtc gpsTime lat lon utmX utmY alt_m groundSpeed_ms heading crossTrackError_m lockedLine hdop satsInView correctionId waasId sprayStat flowRateApplied_Lmin flowRateRequired_Lmin appRateRequired_Lha appRateApplied_Lha swathWidth_m boomPressure_psi sprayOnLag_s sprayOffLag_s pulsesPerLitre windSpeed_ms windDir_deg temp_c humidity_pct

View File

@ -22,7 +22,7 @@
"DEBUG": "agm:*",
"PRODUCTION": "false"
},
"program": "${workspaceFolder}/workers/importCustStripeSubs.js",
"program": "${workspaceFolder}/scripts/importCustStripeSubs.js",
"args": [
"cus_SIX3z3yexFrh6q", //"cus_RyON6s93uk5Wxh", // Replace with your Stripe customer ID
//"--dry-run"

View File

@ -158,7 +158,7 @@ Quick links:
### Web Dashboard
```
http://localhost:4100/dlq-monitor.html
https://localhost:4100/dlq-monitor.html
```
Features:
@ -212,7 +212,7 @@ For complete API documentation, see [docs/DLQ_API_REFERENCE.md](./docs/DLQ_API_R
### Automated Processing
**Use the web dashboard** at `http://localhost:4100/dlq-monitor.html` or API endpoints:
**Use the web dashboard** at `https://localhost:4100/dlq-monitor.html` or API endpoints:
```bash
# Retry all DLQ messages

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