Compare commits
1 Commits
feature/da
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 30c4bc3c3e |
@ -1,89 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
# 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
35362
Development/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -22,8 +22,6 @@
|
|||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
[value]="dt.filters[col.field]?.value">
|
[value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { User } from '../models/user.model';
|
|||||||
import * as fromUsers from '../reducers';
|
import * as fromUsers from '../reducers';
|
||||||
import * as userActions from '../actions/account.actions';
|
import * as userActions from '../actions/account.actions';
|
||||||
|
|
||||||
import { RoleIds, Roles, globals, OperationalStatus, Labels } from '@app/shared/global';
|
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global';
|
||||||
import { BaseComp } from '@app/shared/base/base.component';
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
import { Utils } from '@app/shared/utils';
|
import { Utils } from '@app/shared/utils';
|
||||||
|
|
||||||
@ -21,15 +21,6 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
readonly resolveFieldData = Utils.resolveFieldData;
|
readonly resolveFieldData = Utils.resolveFieldData;
|
||||||
readonly KIND = 'kind';
|
readonly KIND = 'kind';
|
||||||
readonly ACTIVE = OperationalStatus.ACTIVE;
|
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>;
|
accounts: Array<User>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
currAcc: User;
|
currAcc: User;
|
||||||
|
|||||||
@ -69,11 +69,6 @@ const routes: Routes = [
|
|||||||
loadChildren: () => import('./tools/tools.module').then(m => m.ToolsModule),
|
loadChildren: () => import('./tools/tools.module').then(m => m.ToolsModule),
|
||||||
runGuardsAndResolvers: 'always',
|
runGuardsAndResolvers: 'always',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'dlq',
|
|
||||||
loadChildren: () => import('./tools/dlq-monitor/dlq-monitor.module').then(m => m.DlqMonitorModule),
|
|
||||||
runGuardsAndResolvers: 'always',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'track',
|
path: 'track',
|
||||||
loadChildren: () => import('./track/track.module').then(m => m.TrackModule),
|
loadChildren: () => import('./track/track.module').then(m => m.TrackModule),
|
||||||
@ -95,11 +90,6 @@ const routes: Routes = [
|
|||||||
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
|
loadChildren: () => import('./settings/settings.module').then(m => m.SettingsModule),
|
||||||
runGuardsAndResolvers: 'always'
|
runGuardsAndResolvers: 'always'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'api-keys',
|
|
||||||
loadChildren: () => import('./settings/api-keys/api-keys.module').then(m => m.ApiKeysModule),
|
|
||||||
runGuardsAndResolvers: 'always'
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -27,16 +27,7 @@
|
|||||||
|
|
||||||
<app-topbar></app-topbar>
|
<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="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>
|
<app-menu></app-menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -41,15 +41,6 @@ export class AppMenuComponent implements OnInit {
|
|||||||
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
|
{ id: 'customers', label: $localize`:@@customers:Customers`, icon: 'assignment_ind', routerLink: ['/customers'] },
|
||||||
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
|
{ id: 'partners', label: $localize`:@@partnerMgnt:Partner Management`, icon: 'business', routerLink: ['/partners'] },
|
||||||
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
|
{ label: $localize`:@@billing:Billing`, icon: 'monetization_on', routerLink: ['/billing'] },
|
||||||
{
|
|
||||||
id: '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',
|
id: 'settings',
|
||||||
label: $localize`:@@settings:Settings`, icon: 'settings',
|
label: $localize`:@@settings:Settings`, icon: 'settings',
|
||||||
@ -218,10 +209,7 @@ export class AppMenuComponent implements OnInit {
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
|
{ id: 'upload', label: $localize`:@@uploadJobData:Upload Job Data`, icon: 'cloud_upload', routerLink: ['/tools/upload'] },
|
||||||
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
|
{ id: 'areaLib', label: $localize`:@@manageAreasLib:Manage Areas Library`, icon: 'folder_special', routerLink: ['/tools/areas'] },
|
||||||
{ id: 'settings', label: $localize`:@@settings:Settings`, icon: 'settings', routerLink: ['/tools/settings'] },
|
{ 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'] }]
|
|
||||||
: [] )
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -8,18 +8,15 @@
|
|||||||
|
|
||||||
.account-summary-info .account-username {
|
.account-summary-info .account-username {
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
text-align:center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-summary-info .account-type {
|
.account-summary-info .account-type {
|
||||||
margin-right: 0.5em;
|
margin-right: 0.5em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
text-align:center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-summary-info .account-contact {
|
.account-summary-info .account-contact {
|
||||||
color: #ffd700;
|
color: #ffd700;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
text-align:center;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { Client } from "../models/client.model";
|
|||||||
export const FETCH = '[CLIENTS] Fetch clients';
|
export const FETCH = '[CLIENTS] Fetch clients';
|
||||||
export class Fetch implements Action {
|
export class Fetch implements Action {
|
||||||
type: typeof FETCH = FETCH;
|
type: typeof FETCH = FETCH;
|
||||||
constructor(readonly payload?: { filters?: string; useCache?: boolean }) { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FETCH_SUCCESS = '[CLIENTS] Fetch clients success';
|
export const FETCH_SUCCESS = '[CLIENTS] Fetch clients success';
|
||||||
|
|||||||
@ -4,87 +4,3 @@ Ref:https://stackoverflow.com/questions/48675497/how-to-disable-the-option-to-de
|
|||||||
tr.ui-state-highlight {
|
tr.ui-state-highlight {
|
||||||
pointer-events: none;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +1,9 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p-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">
|
<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">
|
<ng-template pTemplate="caption">
|
||||||
<div class="ui-g ui-g-nopad cache-ttl-caption">
|
<span class="table-caption-1" i18n="@@clientList">Client List</span>
|
||||||
<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>
|
||||||
<ng-template pTemplate="header" let-columns>
|
<ng-template pTemplate="header" let-columns>
|
||||||
<tr>
|
<tr>
|
||||||
@ -34,10 +16,6 @@
|
|||||||
<i class="ui-icon-search"></i>
|
<i class="ui-icon-search"></i>
|
||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -11,9 +11,6 @@ import { RoleIds, globals } from '../../shared/global';
|
|||||||
import { JobService } from '../../domain/services/job.service';
|
import { JobService } from '../../domain/services/job.service';
|
||||||
import { Utils } from 'src/app/shared/utils';
|
import { Utils } from 'src/app/shared/utils';
|
||||||
import { BaseComp } from 'src/app/shared/base/base.component';
|
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({
|
@Component({
|
||||||
@ -32,20 +29,6 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
|||||||
cols: any[];
|
cols: any[];
|
||||||
loading$ = this.store.select(fromClients.isLoading);
|
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 {
|
get canWrite(): boolean {
|
||||||
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]);
|
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]);
|
||||||
}
|
}
|
||||||
@ -53,11 +36,9 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly route: ActivatedRoute,
|
private readonly route: ActivatedRoute,
|
||||||
private readonly jobService: JobService,
|
private readonly jobService: JobService,
|
||||||
private readonly clientCache: ClientCacheService,
|
|
||||||
private readonly listReturnCache: ListReturnCacheService,
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.cacheTtlSeconds = Math.round(this.clientCache.getTtlMs() / 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@ -77,51 +58,7 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
|||||||
this.currClient = client;
|
this.currClient = client;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.useCacheOnReturn = this.listReturnCache.startVisit('clients');
|
this.store.dispatch(new clientActions.Fetch());
|
||||||
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) {
|
onRowSelect(event) {
|
||||||
@ -137,7 +74,6 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
editClient() {
|
editClient() {
|
||||||
this.listReturnCache.markPending('clients');
|
|
||||||
this.router.navigate(['client', this.currClient._id], { relativeTo: this.route });
|
this.router.navigate(['client', this.currClient._id], { relativeTo: this.route });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import { DropdownModule } from 'primeng/dropdown';
|
|||||||
|
|
||||||
import { TableModule } from 'primeng/table';
|
import { TableModule } from 'primeng/table';
|
||||||
import { ToastModule } from 'primeng/toast';
|
import { ToastModule } from 'primeng/toast';
|
||||||
import { AccordionModule } from 'primeng/accordion';
|
|
||||||
|
|
||||||
import { StoreModule } from '@ngrx/store';
|
import { StoreModule } from '@ngrx/store';
|
||||||
import { EffectsModule } from '@ngrx/effects';
|
import { EffectsModule } from '@ngrx/effects';
|
||||||
@ -29,7 +28,7 @@ import { AppSharedModule } from '../shared/app-shared.module';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule, TableModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule, InputTextModule,
|
CommonModule, TableModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule, InputTextModule,
|
||||||
CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AccordionModule, AppSharedModule,
|
CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AppSharedModule,
|
||||||
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
|
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
|
||||||
EffectsModule.forFeature([ClientEffects]),
|
EffectsModule.forFeature([ClientEffects]),
|
||||||
ClientsRoutingModule
|
ClientsRoutingModule
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import { ClientService } from '@app/domain/services/client.service';
|
|||||||
import { AuthService } from '@app/domain/services/auth.service';
|
import { AuthService } from '@app/domain/services/auth.service';
|
||||||
import { AppMessageService } from '@app/shared/app-message.service';
|
import { AppMessageService } from '@app/shared/app-message.service';
|
||||||
import { globals } from '@app/shared/global';
|
import { globals } from '@app/shared/global';
|
||||||
import { ClientCacheService } from '@app/domain/services/client-cache.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClientEffects {
|
export class ClientEffects {
|
||||||
@ -18,20 +17,15 @@ export class ClientEffects {
|
|||||||
private readonly actions$: Actions,
|
private readonly actions$: Actions,
|
||||||
private readonly clientSvc: ClientService,
|
private readonly clientSvc: ClientService,
|
||||||
private readonly authSvc: AuthService,
|
private readonly authSvc: AuthService,
|
||||||
private readonly msgSvc: AppMessageService,
|
private readonly msgSvc: AppMessageService
|
||||||
private readonly clientCache: ClientCacheService
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Effect()
|
@Effect()
|
||||||
loadClients$: Observable<Action> = this.actions$.pipe(
|
loadClients$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType<clientActions.Fetch>(clientActions.FETCH),
|
ofType<clientActions.Fetch>(clientActions.FETCH),
|
||||||
switchMap(({ payload }) =>
|
switchMap(() =>
|
||||||
this.clientSvc.loadClients({
|
this.clientSvc.loadClients({ byPuid: this.authSvc.user.parent }).pipe(
|
||||||
byPuid: this.authSvc.user.parent,
|
|
||||||
useCache: payload?.useCache,
|
|
||||||
...(payload?.filters ? { filters: payload.filters } : {})
|
|
||||||
}).pipe(
|
|
||||||
map(clients => new clientActions.FetchSuccess(clients)),
|
map(clients => new clientActions.FetchSuccess(clients)),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients));
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients));
|
||||||
@ -46,10 +40,7 @@ export class ClientEffects {
|
|||||||
ofType<clientActions.Create>(clientActions.CREATE),
|
ofType<clientActions.Create>(clientActions.CREATE),
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
this.clientSvc.saveClient(payload).pipe(
|
this.clientSvc.saveClient(payload).pipe(
|
||||||
map((client) => {
|
map((client) => new clientActions.CreateSuccess(client)),
|
||||||
this.clientCache.invalidate();
|
|
||||||
return new clientActions.CreateSuccess(client);
|
|
||||||
}),
|
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.client));
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.client));
|
||||||
return of(new clientActions.CreateFailed())
|
return of(new clientActions.CreateFailed())
|
||||||
@ -63,9 +54,7 @@ export class ClientEffects {
|
|||||||
ofType<clientActions.Update>(clientActions.UPDATE),
|
ofType<clientActions.Update>(clientActions.UPDATE),
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
this.clientSvc.saveClient(payload).pipe(
|
this.clientSvc.saveClient(payload).pipe(
|
||||||
map(() => {
|
map(() => new clientActions.UpdateSuccess(payload)),
|
||||||
return new clientActions.UpdateSuccess(payload);
|
|
||||||
}),
|
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.client));
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.client));
|
||||||
return of(new clientActions.UpdateFailed());
|
return of(new clientActions.UpdateFailed());
|
||||||
@ -79,10 +68,7 @@ export class ClientEffects {
|
|||||||
ofType<clientActions.Delete>(clientActions.DELETE),
|
ofType<clientActions.Delete>(clientActions.DELETE),
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
this.clientSvc.deleteClient(payload).pipe(
|
this.clientSvc.deleteClient(payload).pipe(
|
||||||
map(() => {
|
map(() => new clientActions.DeleteSuccess(payload)),
|
||||||
this.clientCache.invalidate();
|
|
||||||
return new clientActions.DeleteSuccess(payload);
|
|
||||||
}),
|
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.client));
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.client));
|
||||||
return of(new clientActions.UpdateFailed())
|
return of(new clientActions.UpdateFailed())
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { Customer } from "../models/customer.model";
|
|||||||
export const FETCH = '[CUSTOMERS] Fetch customers';
|
export const FETCH = '[CUSTOMERS] Fetch customers';
|
||||||
export class Fetch implements Action {
|
export class Fetch implements Action {
|
||||||
type: typeof FETCH = FETCH;
|
type: typeof FETCH = FETCH;
|
||||||
constructor(readonly payload?: { filters?: string; useCache?: boolean }) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FETCH_SUCCESS = '[CUSTOMERS] Fetch customers success';
|
export const FETCH_SUCCESS = '[CUSTOMERS] Fetch customers success';
|
||||||
|
|||||||
@ -90,12 +90,6 @@
|
|||||||
required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
|
required="true" i18n-title="@@accessAccount" title="Access Account" showActive="true">
|
||||||
</agm-account-editor>
|
</agm-account-editor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 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">
|
<div class="ui-g-12 toolbar padtop1 ui-fluid">
|
||||||
<button pButton [disabled]="form.invalid || partnerLoading" type="button" style="width:auto"
|
<button pButton [disabled]="form.invalid || partnerLoading" type="button" style="width:auto"
|
||||||
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
|
[icon]="isNew ? 'ui-icon-plus' : 'ui-icon-save'" [label]="isNew ? globals.create : globals.save"
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +1,13 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p-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">
|
<p-table #dt [value]="customers" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[10, 15, 30]" [alwaysShowPaginator]="true" [(selection)]="curCust" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="ctb-ops" [responsive]="true">
|
||||||
<ng-template pTemplate="caption">
|
<ng-template pTemplate="caption">
|
||||||
<div class="ui-g ui-g-nopad cache-ttl-caption">
|
<div class="ui-g ui-g-nopad">
|
||||||
<div class="ui-g-6 cc-field-label cache-ttl-caption-title">
|
<div class="ui-g-6 cc-field-label">
|
||||||
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@customerList">Customer List</span>
|
<span class="table-caption-1" i18n="@@customerList">Customer List</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui-g-6 cc-field-label cache-ttl-caption-controls">
|
<div class="ui-g-6 cc-field-label">
|
||||||
<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>
|
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label>
|
||||||
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
|
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
|
||||||
</div>
|
</div>
|
||||||
@ -42,10 +30,6 @@
|
|||||||
|
|
||||||
<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>
|
<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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -10,9 +10,6 @@ import * as customerActions from '../actions/customer.actions';
|
|||||||
import { globals, OperationalStatus } from '@app/shared/global';
|
import { globals, OperationalStatus } from '@app/shared/global';
|
||||||
|
|
||||||
import { BaseComp } from '@app/shared/base/base.component';
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
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({
|
@Component({
|
||||||
selector: 'agm-customer-list',
|
selector: 'agm-customer-list',
|
||||||
@ -37,19 +34,11 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
totalItems;
|
totalItems;
|
||||||
isSelfSignup = false;
|
isSelfSignup = false;
|
||||||
|
|
||||||
searchAccordionOpen = sessionStorage.getItem('customers-list-accordion') === 'true';
|
|
||||||
private lastFiltersQuery: Record<string, any> | undefined;
|
|
||||||
private useCacheOnReturn = false;
|
|
||||||
cacheTtlSeconds: number;
|
|
||||||
customerFilterDefinitions: FilterDefinition[];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly route: ActivatedRoute,
|
private readonly route: ActivatedRoute,
|
||||||
private readonly customerCache: CustomerCacheService,
|
|
||||||
private readonly listReturnCache: ListReturnCacheService,
|
|
||||||
) {
|
) {
|
||||||
super();
|
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.totalItems = { '=0': '', '=1': '1 ' + $localize`:@@customer:customer`.toLocaleLowerCase(), 'other': $localize`:@@total#Customers:Total: # customers` };
|
||||||
|
|
||||||
this.statuses = [
|
this.statuses = [
|
||||||
@ -67,14 +56,6 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
{ field: this.ACTIVE, header: globals.active, width: '9%' },
|
{ field: this.ACTIVE, header: globals.active, width: '9%' },
|
||||||
{ field: this.PARTNER_NAME, header: globals.partner, 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() {
|
ngOnInit() {
|
||||||
@ -89,19 +70,7 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
this.curCust = cust;
|
this.curCust = cust;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.useCacheOnReturn = this.listReturnCache.startVisit('customers');
|
this.store.dispatch(new customerActions.Fetch());
|
||||||
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[]) {
|
private setCustomersAndPartners(customers: Customer[]) {
|
||||||
@ -132,28 +101,6 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
this.store.dispatch(new customerActions.Select(event.data));
|
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() {
|
get canEdit() {
|
||||||
return (this.curCust && this.curCust._id !== '0');
|
return (this.curCust && this.curCust._id !== '0');
|
||||||
}
|
}
|
||||||
@ -163,7 +110,6 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
}
|
}
|
||||||
|
|
||||||
editCustomer() {
|
editCustomer() {
|
||||||
this.listReturnCache.markPending('customers');
|
|
||||||
this.router.navigate(['customer', this.curCust._id], { relativeTo: this.route });
|
this.router.navigate(['customer', this.curCust._id], { relativeTo: this.route });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,10 +12,8 @@ import { MessageModule } from 'primeng/message';
|
|||||||
import { TableModule } from 'primeng/table';
|
import { TableModule } from 'primeng/table';
|
||||||
import { ToastModule } from 'primeng/toast';
|
import { ToastModule } from 'primeng/toast';
|
||||||
import { MessagesModule } from 'primeng/messages';
|
import { MessagesModule } from 'primeng/messages';
|
||||||
import { AccordionModule } from 'primeng/accordion';
|
|
||||||
|
|
||||||
import { AppSharedModule } from '../shared/app-shared.module';
|
import { AppSharedModule } from '../shared/app-shared.module';
|
||||||
import { ApiKeySharedModule } from '../settings/api-keys/api-key-shared.module';
|
|
||||||
|
|
||||||
import { StoreModule } from '@ngrx/store';
|
import { StoreModule } from '@ngrx/store';
|
||||||
import { EffectsModule } from '@ngrx/effects';
|
import { EffectsModule } from '@ngrx/effects';
|
||||||
@ -43,8 +41,6 @@ import { TrialComponent } from './trial/trial.component';
|
|||||||
SplitButtonModule,
|
SplitButtonModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
AppSharedModule,
|
AppSharedModule,
|
||||||
ApiKeySharedModule,
|
|
||||||
AccordionModule,
|
|
||||||
|
|
||||||
StoreModule.forFeature(fromCustomers.FEATURE_KEY, fromCustomers.reducer),
|
StoreModule.forFeature(fromCustomers.FEATURE_KEY, fromCustomers.reducer),
|
||||||
EffectsModule.forFeature([CustomerEffects]),
|
EffectsModule.forFeature([CustomerEffects]),
|
||||||
|
|||||||
@ -9,23 +9,21 @@ import * as customerActions from '../actions/customer.actions';
|
|||||||
import { CustomerService } from '@app/domain/services/customer.service';
|
import { CustomerService } from '@app/domain/services/customer.service';
|
||||||
import { AppMessageService } from '@app/shared/app-message.service';
|
import { AppMessageService } from '@app/shared/app-message.service';
|
||||||
import { globals } from '@app/shared/global';
|
import { globals } from '@app/shared/global';
|
||||||
import { CustomerCacheService } from '@app/domain/services/customer-cache.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerEffects {
|
export class CustomerEffects {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly actions$: Actions,
|
private readonly actions$: Actions,
|
||||||
private readonly customerSvc: CustomerService,
|
private readonly customerSvc: CustomerService,
|
||||||
private readonly msgSvc: AppMessageService,
|
private readonly msgSvc: AppMessageService
|
||||||
private readonly customerCache: CustomerCacheService
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Effect()
|
@Effect()
|
||||||
loadCustomers$: Observable<Action> = this.actions$.pipe(
|
loadCustomers$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType<customerActions.Fetch>(customerActions.FETCH),
|
ofType<customerActions.Fetch>(customerActions.FETCH),
|
||||||
switchMap(({ payload }) =>
|
switchMap(() =>
|
||||||
this.customerSvc.loadCustomers(payload?.filters, payload?.useCache).pipe(
|
this.customerSvc.loadCustomers().pipe(
|
||||||
map(customers => new customerActions.FetchSuccess(customers)),
|
map(customers => new customerActions.FetchSuccess(customers)),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.customers));
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.customers));
|
||||||
@ -40,10 +38,7 @@ export class CustomerEffects {
|
|||||||
ofType<customerActions.Create>(customerActions.CREATE),
|
ofType<customerActions.Create>(customerActions.CREATE),
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
this.customerSvc.saveCustomer(payload).pipe(
|
this.customerSvc.saveCustomer(payload).pipe(
|
||||||
map((customer) => {
|
map((customer) => new customerActions.CreateSuccess(customer)),
|
||||||
this.customerCache.invalidate();
|
|
||||||
return new customerActions.CreateSuccess(customer);
|
|
||||||
}),
|
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.customer));
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.customer));
|
||||||
return of(new customerActions.CreateFailed())
|
return of(new customerActions.CreateFailed())
|
||||||
@ -57,9 +52,7 @@ export class CustomerEffects {
|
|||||||
ofType<customerActions.Update>(customerActions.UPDATE),
|
ofType<customerActions.Update>(customerActions.UPDATE),
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
this.customerSvc.saveCustomer(payload).pipe(
|
this.customerSvc.saveCustomer(payload).pipe(
|
||||||
map(() => {
|
map(() => new customerActions.UpdateSuccess(payload)),
|
||||||
return new customerActions.UpdateSuccess(payload);
|
|
||||||
}),
|
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.customer));
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.customer));
|
||||||
return of(new customerActions.UpdateFailed());
|
return of(new customerActions.UpdateFailed());
|
||||||
@ -73,10 +66,7 @@ export class CustomerEffects {
|
|||||||
ofType<customerActions.Delete>(customerActions.DELETE),
|
ofType<customerActions.Delete>(customerActions.DELETE),
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
this.customerSvc.deleteCustomer(payload).pipe(
|
this.customerSvc.deleteCustomer(payload).pipe(
|
||||||
map(() => {
|
map(() => new customerActions.DeleteSuccess(payload)),
|
||||||
this.customerCache.invalidate();
|
|
||||||
return new customerActions.DeleteSuccess(payload);
|
|
||||||
}),
|
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.customer));
|
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.customer));
|
||||||
return of(new customerActions.UpdateFailed())
|
return of(new customerActions.UpdateFailed())
|
||||||
|
|||||||
@ -24,7 +24,6 @@ export interface IAppConfig {
|
|||||||
|
|
||||||
noPopup: boolean;
|
noPopup: boolean;
|
||||||
trialDays: [number];
|
trialDays: [number];
|
||||||
browserListCacheTtlMs?: number;
|
|
||||||
/** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */
|
/** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */
|
||||||
promoMinExpiryDays?: number;
|
promoMinExpiryDays?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
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`, {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -130,8 +130,6 @@ export class AppConfigService {
|
|||||||
}
|
}
|
||||||
if (Utils.isNulOrUndef(settings['matType']))
|
if (Utils.isNulOrUndef(settings['matType']))
|
||||||
settings['matType'] = MatType.LIQUID;
|
settings['matType'] = MatType.LIQUID;
|
||||||
if (Utils.isNulOrUndef(settings['browserListCacheTtlMs']))
|
|
||||||
settings['browserListCacheTtlMs'] = 60 * 1000;
|
|
||||||
|
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.wasSetDefault = true;
|
this.wasSetDefault = true;
|
||||||
|
|||||||
@ -1,137 +0,0 @@
|
|||||||
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 */ });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +1,11 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { switchMap, tap } from 'rxjs/operators';
|
|
||||||
|
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Client } from '../../client/models/client.model';
|
import { Client } from '../../client/models/client.model';
|
||||||
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
||||||
import { ClientCacheService } from './client-cache.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClientService {
|
export class ClientService {
|
||||||
@ -16,35 +14,12 @@ export class ClientService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private store: Store<{}>,
|
private store: Store<{}>,
|
||||||
private http: HttpClient,
|
private http: HttpClient
|
||||||
private readonly clientCache: ClientCacheService
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
loadClients(options?: LoadClientOps): Observable<Client[]> {
|
loadClients(options?: LoadClientOps): Observable<Client[]> {
|
||||||
const cacheKey = JSON.stringify({
|
return this.http.post<Client[]>(this.clientURL + '/search', options);
|
||||||
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> {
|
getClient(id: string): Observable<Client> {
|
||||||
@ -78,8 +53,6 @@ export class ClientService {
|
|||||||
|
|
||||||
export interface LoadClientOps {
|
export interface LoadClientOps {
|
||||||
byPuid: string;
|
byPuid: string;
|
||||||
filters?: string;
|
|
||||||
useCache?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientWithSetting extends Client {
|
export interface ClientWithSetting extends Client {
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { switchMap, tap } from 'rxjs/operators';
|
|
||||||
import { Customer } from '../../customers/models/customer.model';
|
import { Customer } from '../../customers/models/customer.model';
|
||||||
import { CustomerCacheService } from './customer-cache.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerService {
|
export class CustomerService {
|
||||||
@ -11,30 +9,12 @@ export class CustomerService {
|
|||||||
private readonly customerURL = '/customers';
|
private readonly customerURL = '/customers';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private http: HttpClient,
|
private http: HttpClient
|
||||||
private readonly customerCache: CustomerCacheService
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCustomers(filters?: string, useCache: boolean = false): Observable<Customer[]> {
|
loadCustomers(): Observable<Customer[]> {
|
||||||
const cacheKey = filters || '';
|
return this.http.get<Customer[]>(this.customerURL);
|
||||||
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> {
|
getCustomer(id: string, view?: string): Observable<Customer> {
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,11 +5,10 @@ import { Observable, of } from 'rxjs';
|
|||||||
import { Client, Invoice } from '@app/invoices/models/invoice.model';
|
import { Client, Invoice } from '@app/invoices/models/invoice.model';
|
||||||
import { CostingItem } from '@app/invoices/models/costing-item.model';
|
import { CostingItem } from '@app/invoices/models/costing-item.model';
|
||||||
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
||||||
import { catchError, map, switchMap, tap } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
import { AppMessageService } from '@app/shared/app-message.service';
|
import { AppMessageService } from '@app/shared/app-message.service';
|
||||||
import { RouterUtilsService } from '@app/shared/router-utils.service';
|
import { RouterUtilsService } from '@app/shared/router-utils.service';
|
||||||
import { Utils } from '@app/shared/utils';
|
import { Utils } from '@app/shared/utils';
|
||||||
import { InvoiceCacheService } from './invoice-cache.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InvoiceService {
|
export class InvoiceService {
|
||||||
@ -29,8 +28,7 @@ export class InvoiceService {
|
|||||||
constructor(
|
constructor(
|
||||||
private http: HttpClient,
|
private http: HttpClient,
|
||||||
private readonly appMsgSvc: AppMessageService,
|
private readonly appMsgSvc: AppMessageService,
|
||||||
private readonly routerUtils: RouterUtilsService,
|
private readonly routerUtils: RouterUtilsService
|
||||||
private readonly invoiceCache: InvoiceCacheService
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Setting
|
// Setting
|
||||||
@ -73,25 +71,8 @@ export class InvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Invoice
|
// Invoice
|
||||||
getInvoices(filters?: string, useCache: boolean = false): Observable<Invoice[]> {
|
getInvoices(): Observable<Invoice[]> {
|
||||||
const cacheKey = filters || '';
|
return this.http.get<Invoice[]>(this.invoiceURL);
|
||||||
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> {
|
getInvoiceById(id): Observable<Invoice> {
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -2,12 +2,11 @@ import { Injectable } from '@angular/core';
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map, switchMap, tap } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model';
|
import { IJob, IUIJob, JobLog, RptOption, toJob } from '../../job/models/job.model';
|
||||||
import { AppFile } from '../models/shared.model';
|
import { AppFile } from '../models/shared.model';
|
||||||
import { UpdateJobOps } from '../../job/actions/job.actions';
|
import { UpdateJobOps } from '../../job/actions/job.actions';
|
||||||
import { JobCacheService } from './job-cache.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobService {
|
export class JobService {
|
||||||
@ -15,55 +14,27 @@ export class JobService {
|
|||||||
private readonly jobURL = '/jobs';
|
private readonly jobURL = '/jobs';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private http: HttpClient,
|
private http: HttpClient
|
||||||
private jobCache: JobCacheService
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
loadJobs(ops: any): Observable<IJob[]> {
|
loadJobs(ops: any): Observable<IJob[]> {
|
||||||
let _ops = new HttpParams()
|
let _ops = new HttpParams()
|
||||||
.set('jpo', ops?.jobsByPilot || 'false');
|
.set('clientId', ops?.clientId || '')
|
||||||
|
.set('jpo', ops?.jobsByPilot || 'false')
|
||||||
|
.set('status', ops?.status || '');
|
||||||
|
|
||||||
if (ops?.filters != null) {
|
if (ops?.byTime?.length === 2) {
|
||||||
// Filter-submit path: all filtering is encoded in the filters param
|
for (const time of ops.byTime) {
|
||||||
_ops = _ops.set('filters', ops.filters);
|
if (time) {
|
||||||
} else {
|
_ops = _ops.append('byTime', time.toISOString());
|
||||||
// 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) {
|
|
||||||
_ops = _ops.append('byTime', time.toISOString());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
_ops = _ops.append('byTime', ops?.byTime?.[0] || '');
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
_ops = _ops.append('byTime', ops?.byTime[0] || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = _ops.toString();
|
return this.http.get<IJob[]>(this.jobURL, { params: _ops });
|
||||||
|
|
||||||
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> {
|
getJob(id: number, withItems: boolean = false, withLines?: boolean): Observable<IUIJob> {
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
.ui-g-12.ui-sm-12.ui-md-12.ui-lg-10.ui-xl-10 {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
@ -16,22 +16,6 @@
|
|||||||
<i class="ui-icon-search"></i>
|
<i class="ui-icon-search"></i>
|
||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<p-dropdown *ngIf="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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -81,9 +65,9 @@
|
|||||||
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
|
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template let-item pTemplate="item">
|
<ng-template let-item pTemplate="item">
|
||||||
<div style="display:flex; align-items:center; justify-content:center; gap:.5em;">
|
<div class="ui-helper-clearfix" style="position:relative;">
|
||||||
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
<div class="color-box" style="margin-left:3px" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||||
<span>{{item.label}}</span>
|
<div style="float:right; margin-right: .15em;">{{item.label}}</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</p-dropdown>
|
</p-dropdown>
|
||||||
|
|||||||
@ -34,7 +34,6 @@ export class CropListComponent extends BaseComp implements OnInit, AfterViewInit
|
|||||||
loading$ = this.store.select(fromEntity.getCropsLoading);
|
loading$ = this.store.select(fromEntity.getCropsLoading);
|
||||||
|
|
||||||
sprZoneColors: SelectItem[] = [...GC.selSprZoneColors];
|
sprZoneColors: SelectItem[] = [...GC.selSprZoneColors];
|
||||||
colorFilterOpts: SelectItem[] = [GC.selAll, ...GC.selSprZoneColors];
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@ -17,10 +17,6 @@
|
|||||||
<i class="ui-icon-search"></i>
|
<i class="ui-icon-search"></i>
|
||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -17,15 +17,6 @@
|
|||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<p-dropdown *ngIf="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 === '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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -30,11 +30,6 @@ export class ProductListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
|
|
||||||
prodTypes: SelectItem[] = [GC.selAll, ...GC.selProdTypes];
|
prodTypes: SelectItem[] = [GC.selAll, ...GC.selProdTypes];
|
||||||
prodTypes2: SelectItem[] = [...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[] = [
|
rateUnits: SelectItem[] = [
|
||||||
{ label: 'oz/ac', value: 0 },
|
{ label: 'oz/ac', value: 0 },
|
||||||
{ label: 'gal/ac', value: 1 },
|
{ label: 'gal/ac', value: 1 },
|
||||||
@ -66,7 +61,7 @@ export class ProductListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.sub$ = this.store.pipe(select(fromEntity.getAllProducts))
|
this.sub$ = this.store.pipe(select(fromEntity.getAllProducts))
|
||||||
.subscribe((items) => {
|
.subscribe((items) => {
|
||||||
this.products = items.map(p => ({ ...p, rateStr: this.getRate(p.rate) }));
|
this.products = items;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sub$.add(this.appActions.ofTypes([productActions.CREATE_SUCCESS, productActions.UPDATE_SUCCESS]).subscribe((action) => {
|
this.sub$.add(this.appActions.ofTypes([productActions.CREATE_SUCCESS, productActions.UPDATE_SUCCESS]).subscribe((action) => {
|
||||||
|
|||||||
@ -60,31 +60,6 @@
|
|||||||
[ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
|
[ngTemplateOutletContext]="{numOfVehicle: pkgLimit?.airCraft?.numOfVehicle || 0}"></ng-container>
|
||||||
<p-dropdown *ngIf="col.field === VEHICLE_TYPE" [options]="acTypes" [ngModel]="dt.filters[col.field]?.value"
|
<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>
|
(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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { ConfirmationService, SelectItem } from 'primeng/api';
|
|||||||
import { Vehicle } from '../../models/vehicle.model';
|
import { Vehicle } from '../../models/vehicle.model';
|
||||||
import * as vehicleActions from '../../actions/vehicle.actions';
|
import * as vehicleActions from '../../actions/vehicle.actions';
|
||||||
import * as fromEntity from '../../reducers';
|
import * as fromEntity from '../../reducers';
|
||||||
import { GC, RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
|
import { RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
|
||||||
import { DateUtils, Utils } from '@app/shared/utils';
|
import { DateUtils, Utils } from '@app/shared/utils';
|
||||||
import { BaseComp } from '@app/shared/base/base.component';
|
import { BaseComp } from '@app/shared/base/base.component';
|
||||||
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
|
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
|
||||||
@ -59,8 +59,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
@ViewChild('updateBtn') updateBtn: ElementRef;
|
@ViewChild('updateBtn') updateBtn: ElementRef;
|
||||||
cols: any[] = [];
|
cols: any[] = [];
|
||||||
acTypes: SelectItem[];
|
acTypes: SelectItem[];
|
||||||
activeOpts: SelectItem[];
|
|
||||||
colorFilterOpts: SelectItem[];
|
|
||||||
loading$ = this.store.select(fromEntity.getVehiclesLoading);
|
loading$ = this.store.select(fromEntity.getVehiclesLoading);
|
||||||
trkLimit: Limit;
|
trkLimit: Limit;
|
||||||
pkgLimit: Limit;
|
pkgLimit: Limit;
|
||||||
@ -143,26 +141,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
|
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
|
||||||
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
|
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
|
||||||
];
|
];
|
||||||
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() {
|
ngOnInit() {
|
||||||
@ -521,7 +499,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
|||||||
initVehList() {
|
initVehList() {
|
||||||
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
|
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
|
||||||
map((vehicles) => {
|
map((vehicles) => {
|
||||||
this.vehicles = vehicles.map(v => ({ ...v, sourceSystem: v.partnerInfo?.metadata?.partnerSystem || SourceSystem.AGNAV }));
|
this.vehicles = vehicles;
|
||||||
this.vehSelLastUpdated = this.createVehSelections(vehicles);
|
this.vehSelLastUpdated = this.createVehSelections(vehicles);
|
||||||
this.vehiclesChanged = this.isVehSelChanged();
|
this.vehiclesChanged = this.isVehSelChanged();
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@ export const FETCH = '[INVOICES] Fetch invoices';
|
|||||||
|
|
||||||
export class Fetch implements Action {
|
export class Fetch implements Action {
|
||||||
type: typeof FETCH = FETCH;
|
type: typeof FETCH = FETCH;
|
||||||
constructor(readonly payload?: { filters?: string; useCache?: boolean }) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FETCH_SUCCESS = '[INVOICES] Fetch invoices success';
|
export const FETCH_SUCCESS = '[INVOICES] Fetch invoices success';
|
||||||
|
|||||||
@ -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">
|
<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">
|
<ng-template pTemplate="caption">
|
||||||
<div class="ui-g ui-g-nopad">
|
<div class="ui-g ui-g-nopad">
|
||||||
<div class="ui-g-12 ui-g-nopad" style="text-align: center">
|
<div class="ui-g-6 ui-g-nopad" style="text-align: left">
|
||||||
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@costingItems">Costing Items</span>
|
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@costingItems">Costing Items</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -23,7 +23,6 @@
|
|||||||
<input pInputText type="text" (input)="ci.filter($event.target.value, col.field, col.filterMatchMode)" [value]="ci.filters[col.field]?.value">
|
<input pInputText type="text" (input)="ci.filter($event.target.value, col.field, col.filterMatchMode)" [value]="ci.filters[col.field]?.value">
|
||||||
</div>
|
</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 === '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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -35,17 +35,6 @@ export class CostingItemComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
costingTypes;
|
costingTypes;
|
||||||
costingItemTypeOpt;
|
costingItemTypeOpt;
|
||||||
amountUnits;
|
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;
|
currencyUnit;
|
||||||
totalCostingItems;
|
totalCostingItems;
|
||||||
isNewItem = true;
|
isNewItem = true;
|
||||||
|
|||||||
@ -7,15 +7,13 @@ import { Action } from '@ngrx/store';
|
|||||||
import * as invoiceActions from '../actions/invoice.actions';
|
import * as invoiceActions from '../actions/invoice.actions';
|
||||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||||
import { globals } from '@app/shared/global';
|
import { globals } from '@app/shared/global';
|
||||||
import { InvoiceCacheService } from '@app/domain/services/invoice-cache.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InvoiceEffects {
|
export class InvoiceEffects {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly actions$: Actions,
|
private readonly actions$: Actions,
|
||||||
private readonly invoiceSvc: InvoiceService,
|
private readonly invoiceSvc: InvoiceService,
|
||||||
private readonly msgSvc: AppMessageService,
|
private readonly msgSvc: AppMessageService
|
||||||
private readonly invoiceCache: InvoiceCacheService
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,7 +24,6 @@ export class InvoiceEffects {
|
|||||||
const isNew = true;
|
const isNew = true;
|
||||||
return this.invoiceSvc.saveInvoice(payload, isNew).pipe(
|
return this.invoiceSvc.saveInvoice(payload, isNew).pipe(
|
||||||
map(invoice => {
|
map(invoice => {
|
||||||
this.invoiceCache.invalidate();
|
|
||||||
this.msgSvc.addSuccessMsg(globals.doThingsSuccess.replace('#do#', globals.create).replace('#thing#', globals.invoice));
|
this.msgSvc.addSuccessMsg(globals.doThingsSuccess.replace('#do#', globals.create).replace('#thing#', globals.invoice));
|
||||||
return new invoiceActions.CreateSuccess(invoice);
|
return new invoiceActions.CreateSuccess(invoice);
|
||||||
}),
|
}),
|
||||||
@ -59,8 +56,8 @@ export class InvoiceEffects {
|
|||||||
@Effect()
|
@Effect()
|
||||||
loadInvoice$: Observable<Action> = this.actions$.pipe(
|
loadInvoice$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType<invoiceActions.Fetch>(invoiceActions.FETCH),
|
ofType<invoiceActions.Fetch>(invoiceActions.FETCH),
|
||||||
switchMap(({ payload }) => {
|
switchMap(() => {
|
||||||
return this.invoiceSvc.getInvoices(payload?.filters, payload?.useCache).pipe(
|
return this.invoiceSvc.getInvoices().pipe(
|
||||||
map(res => {
|
map(res => {
|
||||||
return new invoiceActions.FetchSuccess(res);
|
return new invoiceActions.FetchSuccess(res);
|
||||||
}),
|
}),
|
||||||
@ -78,7 +75,6 @@ export class InvoiceEffects {
|
|||||||
switchMap(({ payload }) => {
|
switchMap(({ payload }) => {
|
||||||
return this.invoiceSvc.deleteInvoice(payload).pipe(
|
return this.invoiceSvc.deleteInvoice(payload).pipe(
|
||||||
map((res: any[]) => {
|
map((res: any[]) => {
|
||||||
this.invoiceCache.invalidate();
|
|
||||||
return new invoiceActions.DeleteSuccess(res?.map(i => i._id));
|
return new invoiceActions.DeleteSuccess(res?.map(i => i._id));
|
||||||
}),
|
}),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
|
|||||||
@ -11,87 +11,3 @@
|
|||||||
border: 1px solid #4caf50;
|
border: 1px solid #4caf50;
|
||||||
background-color: #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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,25 +1,11 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card clearfix">
|
<div class="card clearfix">
|
||||||
<p-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">
|
<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">
|
<ng-template pTemplate="caption">
|
||||||
<div class="ui-g ui-g-nopad cache-ttl-caption">
|
<div class="ui-g ui-g-nopad">
|
||||||
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-title">
|
<div class="ui-g-6 ui-g-nopad text-left">
|
||||||
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@invoiceList">Invoice List</span>
|
<span class="table-caption-1" style="line-height: 1.35em;" 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>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@ -16,9 +16,6 @@ import { FilterUtils } from 'primeng/utils';
|
|||||||
import { DateUtils, Utils } from '@app/shared/utils';
|
import { DateUtils, Utils } from '@app/shared/utils';
|
||||||
import { RestoreTableState } from '@app/shared/restore-table-state';
|
import { RestoreTableState } from '@app/shared/restore-table-state';
|
||||||
import { GAService } from '@app/shared/ga.service';
|
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({
|
@Component({
|
||||||
selector: 'agm-invoices-list',
|
selector: 'agm-invoices-list',
|
||||||
@ -46,23 +43,13 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
|
|
||||||
readonly invoiceStatus = invoiceStatus;
|
readonly invoiceStatus = invoiceStatus;
|
||||||
|
|
||||||
searchAccordionOpen = sessionStorage.getItem('invoices-list-accordion') === 'true';
|
|
||||||
private lastFiltersQuery: Record<string, any> | undefined;
|
|
||||||
private useCacheOnReturn = false;
|
|
||||||
cacheTtlSeconds: number;
|
|
||||||
|
|
||||||
invoiceFilterDefinitions: FilterDefinition[];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly route: ActivatedRoute,
|
private readonly route: ActivatedRoute,
|
||||||
private readonly datePipe: DatePipe,
|
private readonly datePipe: DatePipe,
|
||||||
private readonly invoiceSvc: InvoiceService,
|
private readonly invoiceSvc: InvoiceService,
|
||||||
private readonly restoreTableSvc: RestoreTableState,
|
private readonly restoreTableSvc: RestoreTableState
|
||||||
private readonly invoiceCache: InvoiceCacheService,
|
|
||||||
private readonly listReturnCache: ListReturnCacheService
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.cacheTtlSeconds = Math.round(this.invoiceCache.getTtlMs() / 1000);
|
|
||||||
this.totalInvoices = {
|
this.totalInvoices = {
|
||||||
'=0': '',
|
'=0': '',
|
||||||
'=1': $localize`:@@total#invoice:Total: # invoice`,
|
'=1': $localize`:@@total#invoice:Total: # invoice`,
|
||||||
@ -87,14 +74,6 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
];
|
];
|
||||||
|
|
||||||
this.statusFilter = [];
|
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 {
|
ngOnInit(): void {
|
||||||
@ -118,19 +97,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.useCacheOnReturn = this.listReturnCache.startVisit('invoices');
|
this.store.dispatch(new invoiceActions.Fetch());
|
||||||
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 => {
|
FilterUtils[this.openDateFilter] = (value, filter): boolean => {
|
||||||
if (filter === undefined || filter === null) {
|
if (filter === undefined || filter === null) {
|
||||||
return true;
|
return true;
|
||||||
@ -213,28 +180,6 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
this.restoreTableSvc.restoreTableFirst(this.dt);
|
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) {
|
onPageChange(e) {
|
||||||
this.restoreTableSvc.onPageChange(this.dt, e);
|
this.restoreTableSvc.onPageChange(this.dt, e);
|
||||||
}
|
}
|
||||||
@ -263,7 +208,6 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
|
|
||||||
editInvoice(invoice: Invoice) {
|
editInvoice(invoice: Invoice) {
|
||||||
this.selectInvoice(invoice);
|
this.selectInvoice(invoice);
|
||||||
this.listReturnCache.markPending('invoices');
|
|
||||||
|
|
||||||
// Track invoice selection
|
// Track invoice selection
|
||||||
this.gaSvc.trackInvoiceSelected({
|
this.gaSvc.trackInvoiceSelected({
|
||||||
@ -281,7 +225,6 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
|||||||
|
|
||||||
viewInvoice(invoice: Invoice) {
|
viewInvoice(invoice: Invoice) {
|
||||||
this.selectInvoice(invoice);
|
this.selectInvoice(invoice);
|
||||||
this.listReturnCache.markPending('invoices');
|
|
||||||
|
|
||||||
// Track invoice selection
|
// Track invoice selection
|
||||||
this.gaSvc.trackInvoiceSelected({
|
this.gaSvc.trackInvoiceSelected({
|
||||||
|
|||||||
@ -39,7 +39,6 @@ import { CurrencyNamePipe } from '@app/invoices/pipes/currency-name.pipe';
|
|||||||
import { ScrollPanelModule } from 'primeng/scrollpanel';
|
import { ScrollPanelModule } from 'primeng/scrollpanel';
|
||||||
import { CurrencyCodePositionPipe } from '@app/invoices/pipes/currency-code-position.pipe';
|
import { CurrencyCodePositionPipe } from '@app/invoices/pipes/currency-code-position.pipe';
|
||||||
import { InvoiceStatusPipe } from '@app/invoices/pipes/invoice-status.pipe';
|
import { InvoiceStatusPipe } from '@app/invoices/pipes/invoice-status.pipe';
|
||||||
import { AccordionModule } from 'primeng/accordion';
|
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@ -67,7 +66,6 @@ import { AccordionModule } from 'primeng/accordion';
|
|||||||
EffectsModule.forFeature([SettingEffects, InvoiceEffects, CostingItemEffects, JobEffects]),
|
EffectsModule.forFeature([SettingEffects, InvoiceEffects, CostingItemEffects, JobEffects]),
|
||||||
PanelModule,
|
PanelModule,
|
||||||
ScrollPanelModule,
|
ScrollPanelModule,
|
||||||
AccordionModule,
|
|
||||||
],
|
],
|
||||||
declarations: [InvoicesListComponent, InvoicesMgtComponent, SettingsComponent, CustomerSettingsListComponent, CustomerSettingsComponent, InvoiceEditComponent, CostingItemComponent, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe, InvoiceDetailComponent],
|
declarations: [InvoicesListComponent, InvoicesMgtComponent, SettingsComponent, CustomerSettingsListComponent, CustomerSettingsComponent, InvoiceEditComponent, CostingItemComponent, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe, InvoiceDetailComponent],
|
||||||
exports: [CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe],
|
exports: [CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe],
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<div class="ui-g ui-fluid">
|
<div class="ui-g ui-fluid" style="max-width: 1025px;">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card card-w-title">
|
<div class="card card-w-title">
|
||||||
<h1 i18n="@@invoiceSettings">Invoice Settings</h1>
|
<h1 i18n="@@invoiceSettings">Invoice Settings</h1>
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { toJob } from '../models/job.model';
|
|||||||
import * as jobActions from '../actions/job.actions';
|
import * as jobActions from '../actions/job.actions';
|
||||||
|
|
||||||
import { JobService } from '@app/domain/services/job.service';
|
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 { AppMessageService } from '@app/shared/app-message.service';
|
||||||
|
|
||||||
import { globals } from '@app/shared/global';
|
import { globals } from '@app/shared/global';
|
||||||
@ -20,7 +19,6 @@ export class JobEffects {
|
|||||||
|
|
||||||
private readonly actions$: Actions,
|
private readonly actions$: Actions,
|
||||||
private readonly jobSvc: JobService,
|
private readonly jobSvc: JobService,
|
||||||
private readonly jobCache: JobCacheService,
|
|
||||||
private readonly msgSvc: AppMessageService,
|
private readonly msgSvc: AppMessageService,
|
||||||
private readonly gaSvc: GAService
|
private readonly gaSvc: GAService
|
||||||
) {
|
) {
|
||||||
@ -65,7 +63,6 @@ export class JobEffects {
|
|||||||
priority: 'medium' // Default priority
|
priority: 'medium' // Default priority
|
||||||
});
|
});
|
||||||
|
|
||||||
this.jobCache.invalidate();
|
|
||||||
return new jobActions.CreateSuccess(job);
|
return new jobActions.CreateSuccess(job);
|
||||||
}),
|
}),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
@ -142,7 +139,6 @@ export class JobEffects {
|
|||||||
Math.floor((new Date().getTime() - new Date(payload.createdAt).getTime()) / (1000 * 60 * 60)) : 0
|
Math.floor((new Date().getTime() - new Date(payload.createdAt).getTime()) / (1000 * 60 * 60)) : 0
|
||||||
});
|
});
|
||||||
|
|
||||||
this.jobCache.invalidate();
|
|
||||||
return new jobActions.DeleteSuccess(payload)
|
return new jobActions.DeleteSuccess(payload)
|
||||||
}),
|
}),
|
||||||
catchError(err => {
|
catchError(err => {
|
||||||
|
|||||||
@ -8,76 +8,14 @@
|
|||||||
.inline-flex-end {
|
.inline-flex-end {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cache-ttl-caption-controls {
|
:host ::ng-deep .ui-calendar input,
|
||||||
display: flex;
|
:host ::ng-deep .ui-calendar .ui-datepicker-trigger {
|
||||||
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;
|
opacity: 0;
|
||||||
visibility: hidden;
|
height: 1px;
|
||||||
pointer-events: none;
|
width: 1px;
|
||||||
z-index: 1000;
|
overflow: hidden;
|
||||||
transition: opacity 0.15s ease;
|
position: absolute;
|
||||||
}
|
pointer-events: auto;
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,13 +1,7 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card clearfix">
|
<div class="card clearfix">
|
||||||
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
<p-table #dt [value]="jobs" [columns]="cols" selectionMode="single" (firstChange)="restoreTableFirst()"
|
||||||
<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)"
|
(onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" (onRowSelect)="onRowSelect($event)"
|
||||||
(onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5"
|
(onRowUnselect)="onRowSelect($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5"
|
||||||
[rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" [(selection)]="currentJob" stateStorage="session"
|
[rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" [(selection)]="currentJob" stateStorage="session"
|
||||||
@ -36,19 +30,10 @@
|
|||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)"
|
||||||
[value]="dt.filters[col.field]?.value">
|
[value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<p-dropdown #cl *ngIf="col.field === 'client.name' && !filterClientLocked" name="clients" [options]="clients" optionLabel="label"
|
<p-dropdown #cl *ngIf="col.field === 'client.name'" name="clients" [options]="clients" optionLabel="label"
|
||||||
[ngModel]="currClient" filter="true" [emptyFilterMessage]="globals.emptyFilterMsg"></p-dropdown>
|
[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"
|
<p-dropdown *ngIf="col.field === 'status'" [options]="status" [ngModel]="statusFilter"
|
||||||
(onChange)="handleStatusFilter($event.value)"></p-dropdown>
|
(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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -101,14 +86,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #dropdowns>
|
<ng-template #dropdowns>
|
||||||
<div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad cache-ttl-caption-controls">
|
<div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad">
|
||||||
<div class="ui-g-12 inline-flex-end">
|
<div class="ui-g-8 ui-lg-8 ui-md-12 ui-sm-12 inline-flex-end">
|
||||||
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
|
<div class="ui-g">
|
||||||
(blur)="updateCacheTtl()" style="width: 3.5rem; margin-right: 6px;">
|
<div class="ui-g-12">
|
||||||
<span class="cache-ttl-help" tabindex="0">
|
<span i18n="@@filtJobsByCreatedDate">Filter Jobs By Created Date</span>
|
||||||
<span class="cache-ttl-help-icon">?</span>
|
<p-calendar #calendar [(ngModel)]="selCalDate" selectionMode="range" [readonlyInput]="true"
|
||||||
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
|
[showButtonBar]="true" [showIcon]="true" (onClose)="onCalClose()"></p-calendar>
|
||||||
</span>
|
</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">
|
||||||
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
|
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
|
||||||
(onChange)="reloadChanged($event.value)">
|
(onChange)="reloadChanged($event.value)">
|
||||||
</p-dropdown>
|
</p-dropdown>
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { Subscription, interval } from 'rxjs';
|
|||||||
import { SelectItem } from 'primeng/api';
|
import { SelectItem } from 'primeng/api';
|
||||||
import { Dropdown } from 'primeng/dropdown';
|
import { Dropdown } from 'primeng/dropdown';
|
||||||
import { Table } from 'primeng/table';
|
import { Table } from 'primeng/table';
|
||||||
import { FilterUtils } from 'primeng/utils';
|
|
||||||
|
|
||||||
import { IUIJob } from '../models/job.model';
|
import { IUIJob } from '../models/job.model';
|
||||||
import * as jobActions from '../actions/job.actions';
|
import * as jobActions from '../actions/job.actions';
|
||||||
@ -27,10 +26,8 @@ import { Acre } from '@app/domain/models/subscription.model';
|
|||||||
import { SUB, SubTexts, SubType } from '@app/profile/common';
|
import { SUB, SubTexts, SubType } from '@app/profile/common';
|
||||||
import { InvoiceService } from '@app/domain/services/invoice.service';
|
import { InvoiceService } from '@app/domain/services/invoice.service';
|
||||||
import { RestoreTableState } from '@app/shared/restore-table-state';
|
import { RestoreTableState } from '@app/shared/restore-table-state';
|
||||||
|
import { SubscriptionService } from '@app/domain/services/subscription.service';
|
||||||
import { GAService } from '@app/shared/ga.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({
|
@Component({
|
||||||
@ -41,48 +38,23 @@ import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/
|
|||||||
export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
|
export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
|
||||||
globals = globals;
|
globals = globals;
|
||||||
readonly dropdownStyle = { 'min-width': '170px', 'color': 'black' };
|
readonly dropdownStyle = { 'min-width': '170px', 'color': 'black' };
|
||||||
|
readonly customeDate = 'customDate';
|
||||||
|
|
||||||
jobs: Array<IUIJob> = [];
|
jobs: Array<IUIJob> = [];
|
||||||
filteredJobs: Array<IUIJob> = [];
|
|
||||||
currentJob: IUIJob;
|
currentJob: IUIJob;
|
||||||
currClient: SelectItem;
|
currClient: SelectItem;
|
||||||
filterClientLocked = false;
|
|
||||||
clients: SelectItem[];
|
clients: SelectItem[];
|
||||||
defaultInvoiceSetting;
|
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('dt') public dt: Table;
|
||||||
private _cl: Dropdown;
|
@ViewChild('cl') public cl: Dropdown;
|
||||||
@ViewChild('cl') set cl(dropdown: Dropdown) {
|
@ViewChild('calendar') calendar: any;
|
||||||
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];
|
rows1Page = [10, 15, 30, 60, 100];
|
||||||
cols: any[];
|
cols: any[];
|
||||||
|
|
||||||
status: SelectItem[] = [GC.selAll, ...GC.selJobStatuses];
|
status: SelectItem[] = [GC.selAll, ...GC.selJobStatuses];
|
||||||
statusFilter;
|
statusFilter;
|
||||||
startDateFilter: Date;
|
|
||||||
endDateFilter: Date;
|
|
||||||
reloadOps: SelectItem[];
|
reloadOps: SelectItem[];
|
||||||
reloadBy = 0;
|
reloadBy = 0;
|
||||||
reload$: Subscription;
|
reload$: Subscription;
|
||||||
@ -91,8 +63,13 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
totalJobs;
|
totalJobs;
|
||||||
|
|
||||||
acre: Acre;
|
acre: Acre;
|
||||||
|
dateOptions: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
selDate: string;
|
||||||
|
|
||||||
searchAccordionOpen = sessionStorage.getItem('job-list-accordion') === 'true';
|
selCalDate: [Date, Date];
|
||||||
|
|
||||||
get canWrite(): boolean {
|
get canWrite(): boolean {
|
||||||
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]);
|
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]);
|
||||||
@ -108,13 +85,11 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
private readonly datePipe: DatePipe,
|
private readonly datePipe: DatePipe,
|
||||||
private readonly invoiceSvc: InvoiceService,
|
private readonly invoiceSvc: InvoiceService,
|
||||||
private readonly restoreTableSvc: RestoreTableState,
|
private readonly restoreTableSvc: RestoreTableState,
|
||||||
private readonly gaService: GAService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly jobCache: JobCacheService,
|
private readonly gaService: GAService
|
||||||
private readonly listReturnCache: ListReturnCacheService
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.currClient = ({ label: globals.all, value: null });
|
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.totalJobs = { '=0': '', '=1': '1 ' + $localize`:@@job:job`.toLocaleLowerCase(), 'other': $localize`:@@total#Jobs:Total: # jobs` };
|
||||||
|
|
||||||
this.status = [
|
this.status = [
|
||||||
@ -155,26 +130,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
this.showStatusPlus = !this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR]);
|
this.showStatusPlus = !this.authSvc.hasRole([RoleIds.CLIENT, RoleIds.INSPECTOR]);
|
||||||
this.defaultInvoiceSetting = this.invoiceSvc.defaultSetting;
|
this.defaultInvoiceSetting = this.invoiceSvc.defaultSetting;
|
||||||
|
|
||||||
(FilterUtils as any)['dateIs'] = (value: any, filter: any): boolean => {
|
this.dateOptions = this.subscriptionService.getDateOptions();
|
||||||
if (!filter) { return true; }
|
this.dateOptions.push({ label: $localize`:@@customDate:Custom Date`, value: this.customeDate });
|
||||||
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() {
|
ngOnInit() {
|
||||||
@ -187,8 +144,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
this.clients = clients.map(it => ({ value: it._id, label: it.name }));
|
this.clients = clients.map(it => ({ value: it._id, label: it.name }));
|
||||||
if (!this.isClientUser) {
|
if (!this.isClientUser) {
|
||||||
this.clients.unshift(({ label: globals.all, value: null }));
|
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 => {
|
this.sub$.add(this.store.pipe(select(fromClients.getSelectedClient)).subscribe(client => {
|
||||||
@ -201,7 +156,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
}
|
}
|
||||||
})); this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
|
})); this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
|
||||||
this.jobs = jobs;
|
this.jobs = jobs;
|
||||||
this.filteredJobs = jobs;
|
|
||||||
}));
|
}));
|
||||||
this.sub$.add(this.store.pipe(select(fromJobs.getSelectedJob)).subscribe((job) => {
|
this.sub$.add(this.store.pipe(select(fromJobs.getSelectedJob)).subscribe((job) => {
|
||||||
this.currentJob = job;
|
this.currentJob = job;
|
||||||
@ -223,8 +177,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
this.acre = pkg[effectiveLookupKey]?.acre;
|
this.acre = pkg[effectiveLookupKey]?.acre;
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.useCacheOnReturn = this.listReturnCache.startVisit('jobs');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
@ -238,7 +190,32 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
const invoiced = listFilter.filters.invoiceStatus?.value;
|
const invoiced = listFilter.filters.invoiceStatus?.value;
|
||||||
this.restoreStatusState(status, invoiced);
|
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(() => {
|
setTimeout(() => {
|
||||||
|
this.fetchJobsByClient(this.currClient.value);
|
||||||
if (this.dt.rows >= this.dt.totalRecords) {
|
if (this.dt.rows >= this.dt.totalRecords) {
|
||||||
this.dt.first = 0;
|
this.dt.first = 0;
|
||||||
}
|
}
|
||||||
@ -281,34 +258,22 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
[jobListStatus.INVOICED]: jobInvoiceStatus.INVOICED
|
[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;
|
const statusValue = statusMap[this.statusFilter] ?? jobListStatus.ALL;
|
||||||
this.store.dispatch(new jobActions.Fetch({
|
this.store.dispatch(new jobActions.Fetch({
|
||||||
clientId: clientId,
|
clientId: clientId,
|
||||||
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||||
byTime: this.currentByTime,
|
byTime,
|
||||||
status: statusValue,
|
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() {
|
restoreTableFirst() {
|
||||||
this.restoreTableSvc.restoreTableFirst(this.dt);
|
this.restoreTableSvc.restoreTableFirst(this.dt);
|
||||||
}
|
}
|
||||||
@ -362,7 +327,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
|
|
||||||
duplicateJob() {
|
duplicateJob() {
|
||||||
if (this.canAddNew) {
|
if (this.canAddNew) {
|
||||||
this.listReturnCache.markPending('jobs');
|
|
||||||
// Track bulk action (duplicate)
|
// Track bulk action (duplicate)
|
||||||
this.gaService.trackJobBulkAction({
|
this.gaService.trackJobBulkAction({
|
||||||
user_id: this.authSvc.user?._id || 'anonymous',
|
user_id: this.authSvc.user?._id || 'anonymous',
|
||||||
@ -379,12 +343,10 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
}
|
}
|
||||||
|
|
||||||
editJob() {
|
editJob() {
|
||||||
this.listReturnCache.markPending('jobs');
|
|
||||||
this.router.navigate([`./${this.currentJob._id}/edit`], { relativeTo: this.route });
|
this.router.navigate([`./${this.currentJob._id}/edit`], { relativeTo: this.route });
|
||||||
}
|
}
|
||||||
|
|
||||||
editJobMap() {
|
editJobMap() {
|
||||||
this.listReturnCache.markPending('jobs');
|
|
||||||
this.router.navigate([`./${this.currentJob._id}/editMap`, { flag: 0 }], { relativeTo: this.route });
|
this.router.navigate([`./${this.currentJob._id}/editMap`, { flag: 0 }], { relativeTo: this.route });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -402,18 +364,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
|
|
||||||
reloadJobs() {
|
reloadJobs() {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
this.jobCache.invalidate();
|
|
||||||
this.useCacheOnReturn = false;
|
|
||||||
|
|
||||||
if (this.lastFiltersQuery) {
|
this.fetchJobsByClient(this.currClient && this.currClient.value);
|
||||||
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
|
// Track job list reload
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -469,35 +421,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
this.router.navigate(['/clients']);
|
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) {
|
getUsers(byUsers) {
|
||||||
if (!byUsers || !Array.isArray(byUsers) || byUsers.length === 0) {
|
if (!byUsers || !Array.isArray(byUsers) || byUsers.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
@ -564,6 +487,96 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
}, 100);
|
}, 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
|
// Helper method to count active filters
|
||||||
private getActiveFilterCount(): number {
|
private getActiveFilterCount(): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@ -579,7 +592,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check date filter
|
// Check date filter
|
||||||
if (this.currentByTime && this.currentByTime.length > 0) {
|
if (this.selDate && this.selDate !== this.dateOptions[0]?.value) {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -596,6 +609,16 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
|||||||
return count;
|
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() {
|
ngOnDestroy() {
|
||||||
super.ngOnDestroy();
|
super.ngOnDestroy();
|
||||||
if (this.reload$) {
|
if (this.reload$) {
|
||||||
|
|||||||
@ -23,14 +23,11 @@ import { TooltipModule } from 'primeng/tooltip';
|
|||||||
import { TabViewModule } from 'primeng/tabview';
|
import { TabViewModule } from 'primeng/tabview';
|
||||||
import { SliderModule } from 'primeng/slider';
|
import { SliderModule } from 'primeng/slider';
|
||||||
import { OrderListModule } from 'primeng/orderlist';
|
import { OrderListModule } from 'primeng/orderlist';
|
||||||
import { AccordionModule } from 'primeng/accordion';
|
|
||||||
|
|
||||||
import { StoreModule } from '@ngrx/store';
|
import { StoreModule } from '@ngrx/store';
|
||||||
import { EffectsModule } from '@ngrx/effects';
|
import { EffectsModule } from '@ngrx/effects';
|
||||||
import * as fromJobs from './reducers/jobs.reducer';
|
import * as fromJobs from './reducers/jobs.reducer';
|
||||||
import { JobEffects } from './effects/job.effects';
|
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 { JobMgtComponent } from './job-mgt.component';
|
||||||
import { AppSharedModule } from '../shared/app-shared.module';
|
import { AppSharedModule } from '../shared/app-shared.module';
|
||||||
@ -47,13 +44,12 @@ import { InvoicesModule } from '@app/invoices/invoices.module';
|
|||||||
LeafletModule,
|
LeafletModule,
|
||||||
PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule,
|
PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule,
|
||||||
CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule,
|
CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule,
|
||||||
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule, AccordionModule,
|
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule,
|
||||||
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule,
|
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule,
|
||||||
|
|
||||||
JobsRoutingModule,
|
JobsRoutingModule,
|
||||||
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
|
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
|
||||||
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
|
EffectsModule.forFeature([JobEffects]), InvoicesModule,
|
||||||
EffectsModule.forFeature([JobEffects, ClientEffects]), InvoicesModule,
|
|
||||||
],
|
],
|
||||||
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
|
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
|
||||||
providers: [DatePipe],
|
providers: [DatePipe],
|
||||||
|
|||||||
@ -23,10 +23,6 @@
|
|||||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||||
</div>
|
</div>
|
||||||
<p-dropdown *ngIf="col.field === 'active'" [options]="statuses" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
<p-dropdown *ngIf="col.field === '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>
|
<span *ngSwitchDefault></span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,8 +1,3 @@
|
|||||||
*:focus {
|
*:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-g-12.ui-lg-10.ui-xl-8 {
|
|
||||||
min-width: 19rem;
|
|
||||||
width:100vw;
|
|
||||||
}
|
|
||||||
@ -1,19 +1,22 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12 ui-lg-10 ui-xl-8">
|
<div class="ui-g-12 ui-lg-10 ui-xl-8" style="margin: auto;">
|
||||||
<div class="ui-g">
|
<div class="ui-g" style="padding: 1em;">
|
||||||
|
<h1 style="margin-bottom: 1em;" i18n="@@billingAddresses">Billing Addresses</h1>
|
||||||
<div class="ui-g-12 card in-card-pad">
|
<div class="ui-g-12 card in-card-pad">
|
||||||
<h1 class="large-font align-vertical" i18n="@@selBillingAddress" style="margin-bottom:1rem;">Billing Addresses</h1>
|
<p class="large-font align-vertical" i18n="@@selBillingAddress">Select a billing address</p>
|
||||||
<hr style="width: 100%;margin-bottom:1rem;" />
|
|
||||||
|
|
||||||
<ng-container *ngIf="user.addresses?.length > 0">
|
<div class="ui-g-12 card in-card-pad">
|
||||||
<ng-container *ngTemplateOutlet="header"></ng-container>
|
<ng-container *ngIf="user.addresses?.length > 0">
|
||||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
<ng-container *ngTemplateOutlet="header"></ng-container>
|
||||||
</ng-container>
|
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||||
<span class="ui-message ui-messages-error" style="width: 100%; font-size: 1em;">{{error}}</span>
|
</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%;" />
|
<hr style="width: 100%;" />
|
||||||
|
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12" style="text-align: right;">
|
||||||
<ng-container *ngTemplateOutlet="btn"></ng-container>
|
<ng-container *ngTemplateOutlet="btn"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -23,7 +26,7 @@
|
|||||||
|
|
||||||
<ng-template #header>
|
<ng-template #header>
|
||||||
<div class="ui-g ui-g-nopad" style="justify-content: space-around;">
|
<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">Select a billing address</ng-container></strong></div>
|
<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-2 ui-sm-12 ui-g-nopad row-space"><strong><ng-container i18n="@@name">Name</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 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>
|
</div>
|
||||||
@ -52,8 +55,9 @@
|
|||||||
|
|
||||||
<ng-template #btn>
|
<ng-template #btn>
|
||||||
<button pButton type="button" i18n-label="@@back" label="Back" class="inline-space" (click)="gotoMySubs()"></button>
|
<button pButton type="button" i18n-label="@@back" label="Back" class="inline-space" (click)="gotoMySubs()"></button>
|
||||||
<button type="button" pButton icon="ui-icon-plus" i18n-label="@@addAdr" label="Add Address" (click)="add()"></button>
|
<ng-container *ngIf="user.addresses?.length > 1">
|
||||||
<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>
|
<button pButton type="button" [disabled]="!selectedAddress || selectedAddress.isBilling" [label]="SubTexts.labelChngBilAddr" (click)="changeBilAdr(selectedAddress)"></button>
|
||||||
|
</ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<p-dialog [(visible)]="displayAddressDialog" [style]="{'width': '600px'}" [contentStyle]="{'overflow':'visible'}" resizable="false" modal="true">
|
<p-dialog [(visible)]="displayAddressDialog" [style]="{'width': '600px'}" [contentStyle]="{'overflow':'visible'}" resizable="false" modal="true">
|
||||||
|
|||||||
@ -1,67 +1,61 @@
|
|||||||
<ng-container *ngIf="isCompLoaded(); else err">
|
<ng-container *ngIf="isCompLoaded(); else err">
|
||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12 ui-md-11 ui-lg-10 ui-xl-8" style="margin: auto;;min-width: 564px">
|
||||||
<div class="card clearfix">
|
<div class="ui-g">
|
||||||
<div class="ui-g">
|
<h1 style="margin-bottom: 1em;" i18n="@@pmtHist">Payment history</h1>
|
||||||
<div class="ui-g-12 ui-md-11 ui-lg-10 ui-xl-8" style="margin: auto;">
|
<div class="ui-g ui-g-12">
|
||||||
|
<div class="ui-g-12">
|
||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<h1 style="margin-bottom: 1em;" i18n="@@pmtHist">Payment history</h1>
|
<div class="ui-g-8"><ng-container i18n="@@pmtHistMsg">If you recently made a payment, please allow 24 hours for the payment to appear in the history.</ng-container></div>
|
||||||
<div class="ui-g ui-g-12">
|
<div class="ui-g-4" style="display: flex; justify-content: end;">
|
||||||
<div class="ui-g-12">
|
<p-dropdown [options]="options" [(ngModel)]="optKey" (onChange)="onDateChange($event)"></p-dropdown>
|
||||||
<div class="ui-g">
|
|
||||||
<div class="ui-g-8"><ng-container i18n="@@pmtHistMsg">If you recently made a payment, please allow 24 hours for the payment to appear in the history.</ng-container></div>
|
|
||||||
<div class="ui-g-4" style="display: flex; justify-content: end;">
|
|
||||||
<p-dropdown [options]="options" [(ngModel)]="optKey" (onChange)="onDateChange($event)"></p-dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ui-g-12">
|
|
||||||
<p-table (sortFunction)="customSort($event)" [customSort]="true" [value]="payments" [columns]="cols" [paginator]="true" [responsive]="true" [rows]="10" [rowsPerPageOptions]="[5,10,20]" [sortField]="date" sortOrder="-1">
|
|
||||||
<ng-template pTemplate="header">
|
|
||||||
<tr>
|
|
||||||
<th [pSortableColumn]="col.field" class="pm-history-header" *ngFor="let col of cols" [width]="col.width">
|
|
||||||
{{col.header}}
|
|
||||||
<p-sortIcon [field]="col.field"></p-sortIcon>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
|
||||||
<tr>
|
|
||||||
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
|
||||||
<span class="ui-column-title">{{col.header}}</span>
|
|
||||||
<span *ngSwitchCase="date">{{rowData[col.field] | tsToDate: lang}}</span>
|
|
||||||
|
|
||||||
<span *ngSwitchCase="TYPE">
|
|
||||||
<span *ngIf="rowData.object === InvType.INVOICE" i18n="@@bill">Bill</span>
|
|
||||||
<span *ngIf="rowData.object === InvType.CHARGE" i18n="@@refund">Refund</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span *ngSwitchCase="AMT_DUE">
|
|
||||||
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
|
||||||
{{rowData.amount_due | usCurrency}}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
|
||||||
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
|
||||||
</ng-container>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span *ngSwitchCase="AMT_PAID">
|
|
||||||
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
|
||||||
{{rowData.amount_paid | usCurrency}}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
|
||||||
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
|
||||||
</ng-container>
|
|
||||||
</span>
|
|
||||||
<span *ngSwitchCase="ACTIONS"><button (click)="gotoPaymentDetail(rowData)" pButton icon="ui-icon-zoom-in"></button></span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</ng-template>
|
|
||||||
</p-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ui-g-12">
|
||||||
|
<p-table (sortFunction)="customSort($event)" [customSort]="true" [value]="payments" [columns]="cols" [paginator]="true" [responsive]="true" [rows]="10" [rowsPerPageOptions]="[5,10,20]" [sortField]="date" sortOrder="-1">
|
||||||
|
<ng-template pTemplate="header">
|
||||||
|
<tr>
|
||||||
|
<th [pSortableColumn]="col.field" class="pm-history-header" *ngFor="let col of cols" [width]="col.width">
|
||||||
|
{{col.header}}
|
||||||
|
<p-sortIcon [field]="col.field"></p-sortIcon>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template pTemplate="body" let-rowData let-columns="columns">
|
||||||
|
<tr>
|
||||||
|
<td *ngFor="let col of columns" [ngSwitch]="col.field">
|
||||||
|
<span class="ui-column-title">{{col.header}}</span>
|
||||||
|
<span *ngSwitchCase="date">{{rowData[col.field] | tsToDate: lang}}</span>
|
||||||
|
|
||||||
|
<span *ngSwitchCase="TYPE">
|
||||||
|
<span *ngIf="rowData.object === InvType.INVOICE" i18n="@@bill">Bill</span>
|
||||||
|
<span *ngIf="rowData.object === InvType.CHARGE" i18n="@@refund">Refund</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span *ngSwitchCase="AMT_DUE">
|
||||||
|
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
||||||
|
{{rowData.amount_due | usCurrency}}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
||||||
|
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
||||||
|
</ng-container>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span *ngSwitchCase="AMT_PAID">
|
||||||
|
<ng-container *ngIf="rowData.object === InvType.INVOICE">
|
||||||
|
{{rowData.amount_paid | usCurrency}}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="rowData.object === InvType.CHARGE">
|
||||||
|
{{rowData.amount_refunded | usCurrency | creditCurrency}}
|
||||||
|
</ng-container>
|
||||||
|
</span>
|
||||||
|
<span *ngSwitchCase="ACTIONS"><button (click)="gotoPaymentDetail(rowData)" pButton icon="ui-icon-zoom-in"></button></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-template>
|
||||||
|
</p-table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<div class="ui-g">
|
<div class="ui-g" style="max-width: 1025px">
|
||||||
<div class="ui-g-12">
|
<div class="ui-g-12">
|
||||||
<div class="card card-w-title">
|
<div class="card card-w-title">
|
||||||
<h1>{{ isApplicator ? globals.applProfile : globals.userProfile }}</h1>
|
<h1>{{ isApplicator ? globals.applProfile : globals.userProfile }}</h1>
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
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 }>()
|
|
||||||
);
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
@ -1,244 +0,0 @@
|
|||||||
<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"> <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>
|
|
||||||
@ -1,310 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
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' };
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
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
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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);
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { SharedModule } from 'primeng/api';
|
import { SharedModule } from 'primeng/api';
|
||||||
import { InputTextModule } from 'primeng/inputtext';
|
import { InputTextModule } from 'primeng/inputtext';
|
||||||
@ -14,7 +14,6 @@ import { MessageModule } from 'primeng/message';
|
|||||||
import { RadioButtonModule } from 'primeng/radiobutton';
|
import { RadioButtonModule } from 'primeng/radiobutton';
|
||||||
import { CalendarModule } from 'primeng/calendar';
|
import { CalendarModule } from 'primeng/calendar';
|
||||||
import { DialogModule } from 'primeng/dialog';
|
import { DialogModule } from 'primeng/dialog';
|
||||||
import { MultiSelectModule } from 'primeng/multiselect';
|
|
||||||
|
|
||||||
import { LengthUnitPipe } from './pipes/length-unit.pipe';
|
import { LengthUnitPipe } from './pipes/length-unit.pipe';
|
||||||
import { RateUnitPipe } from './pipes/rate-unit.pipe';
|
import { RateUnitPipe } from './pipes/rate-unit.pipe';
|
||||||
@ -77,14 +76,12 @@ import { BadgeComponent } from './badge/badge.component';
|
|||||||
import { PromoLabelComponent } from './promo-label/promo-label.component';
|
import { PromoLabelComponent } from './promo-label/promo-label.component';
|
||||||
import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component';
|
import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component';
|
||||||
import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component';
|
import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component';
|
||||||
import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.component';
|
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule,
|
CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule,
|
||||||
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule,
|
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule
|
||||||
MultiSelectModule, FormsModule
|
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
LengthUnitPipe, RateUnitPipe, UserTypePipe, AreaUnitPipe, NoCommaPipe,
|
LengthUnitPipe, RateUnitPipe, UserTypePipe, AreaUnitPipe, NoCommaPipe,
|
||||||
@ -93,19 +90,18 @@ import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.componen
|
|||||||
JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
||||||
DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent,
|
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,
|
PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
|
||||||
DynamicFilterComponent
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule, FormsModule,
|
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule,
|
||||||
InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, RadioButtonModule, MultiSelectModule,
|
InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, CheckboxModule, MessagesModule, MessageModule, InputNumberModule, RadioButtonModule,
|
||||||
ItemEditorComponent, ProductEditorComponent, AccountEditorComponent, DisplayConfigComponent, CropEditorComponent,
|
ItemEditorComponent, ProductEditorComponent, AccountEditorComponent, DisplayConfigComponent, CropEditorComponent,
|
||||||
LengthUnitPipe, RateUnitPipe, AreaUnitPipe, UserTypePipe, NoCommaPipe, UniqueUserValidatorDirective, UnitPipe, ProductTypePipe,
|
LengthUnitPipe, RateUnitPipe, AreaUnitPipe, UserTypePipe, NoCommaPipe, UniqueUserValidatorDirective, UnitPipe, ProductTypePipe,
|
||||||
ActivityPipe, CoordinatePipe, SpeedPipe, LengthPipe, TemperaturePipe, AppRatePipe, DistancePipe, JobStatusPipe,
|
ActivityPipe, CoordinatePipe, SpeedPipe, LengthPipe, TemperaturePipe, AppRatePipe, DistancePipe, JobStatusPipe,
|
||||||
VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, AppVolumePipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, AppVolumePipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
||||||
DebounceDirective, UnitIdUniqueDirective,
|
DebounceDirective, UnitIdUniqueDirective,
|
||||||
ProfileFormComponent, CreditcardFormComponent, CardInfoComponent,
|
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]
|
providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe]
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,213 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,378 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +1,3 @@
|
|||||||
.ui-g.ui-g-12 {
|
|
||||||
padding: 6px 0px 0px 0px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
min-width: 19rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui-confirmdialog-message ul {
|
.ui-confirmdialog-message ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<div class="ui-g ui-g-12">
|
<div class="ui-g ui-g-12" style="padding: 6px 0px 0px 0px; margin-bottom: 0">
|
||||||
<div class="ui-g-3 ui-sm-12 ui-md-4 ui-lg-3 ui-xl-3" style="margin-top: 8px">
|
<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 class="flex-row">
|
||||||
<div style="margin-bottom: 4px; display: inline-block;flex-grow: 0;">
|
<div style="margin-bottom: 4px; display: inline-block;flex-grow: 0;">
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
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 { }
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,210 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
@ -1,231 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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 { }
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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 } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -20,7 +20,6 @@ export class ToolsCanactiveGuard implements CanActivate {
|
|||||||
// TODO: find a way to re-use this logic across multiple feature modules
|
// TODO: find a way to re-use this logic across multiple feature modules
|
||||||
|
|
||||||
// Make sure clients loaded first
|
// Make sure clients loaded first
|
||||||
if (this.authSvc.isAdmin) return of(true);
|
|
||||||
if (this.authSvc.isClientUser) return of(true);
|
if (this.authSvc.isClientUser) return of(true);
|
||||||
|
|
||||||
return forkJoin(
|
return forkJoin(
|
||||||
|
|||||||
@ -14,13 +14,12 @@ import { SettingsComponent } from './settings/settings.component';
|
|||||||
import { SettingsGuard } from '../domain/guards/settings-guard.service';
|
import { SettingsGuard } from '../domain/guards/settings-guard.service';
|
||||||
import { GMapLoadGuard } from '../domain/guards/gmap-load.guard';
|
import { GMapLoadGuard } from '../domain/guards/gmap-load.guard';
|
||||||
|
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: ToolsMgtComponent,
|
component: ToolsMgtComponent,
|
||||||
data: {
|
data: {
|
||||||
roles: [RoleIds.ADMIN, RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]
|
roles: [RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER, RoleIds.PILOT, RoleIds.CLIENT]
|
||||||
},
|
},
|
||||||
canActivate: [AuthGuard, SettingsGuard, ToolsCanactiveGuard],
|
canActivate: [AuthGuard, SettingsGuard, ToolsCanactiveGuard],
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
.ui-g-12.ui-md-12.ui-lg-11.ui-xl-11 {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
@ -2,60 +2,6 @@
|
|||||||
box-sizing: border-box;
|
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-right,
|
||||||
.resize-handle-top {
|
.resize-handle-top {
|
||||||
position : absolute;
|
position : absolute;
|
||||||
@ -78,16 +24,6 @@
|
|||||||
cursor: row-resize;
|
cursor: row-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle-bottom {
|
|
||||||
position : absolute;
|
|
||||||
background-color: #e6eee6;
|
|
||||||
height : 10px;
|
|
||||||
width : 100%;
|
|
||||||
bottom : 0;
|
|
||||||
left : 0;
|
|
||||||
cursor : row-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-panel,
|
.left-panel,
|
||||||
.bottom-panel {
|
.bottom-panel {
|
||||||
z-index : 1001;
|
z-index : 1001;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<div class="ui-g ui-g-12" style="padding: 6px 0px 0px 0px; margin-bottom: 0">
|
<div class="ui-g ui-g-12" style="padding: 6px 0px 0px 0px; margin-bottom: 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;">
|
<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;">
|
||||||
<!-- Resizeable left panel -->
|
<!-- Resizeable left panel -->
|
||||||
<div #leftPanel class="left-panel" [ngStyle]="leftPStyle" mwlResizable [mouseMoveThrottleMS]="50" [validateResize]="validatePLeft" [enableGhostResize]="true" (resizeEnd)="onLeftPResizeEnd($event)">
|
<div #leftPanel class="left-panel" [ngStyle]="leftPStyle" mwlResizable [mouseMoveThrottleMS]="50" [validateResize]="validatePLeft" [enableGhostResize]="true" (resizeEnd)="onLeftPResizeEnd($event)">
|
||||||
<div #leftPContent class="panel-content">
|
<div #leftPContent class="panel-content">
|
||||||
@ -198,7 +198,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="resize-handle-bottom" mwlResizeHandle [resizeEdges]="{ bottom: true }"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -486,26 +486,6 @@ 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'])
|
@HostListener('window:keyup', ['$event'])
|
||||||
keyEvent(e: KeyboardEvent) {
|
keyEvent(e: KeyboardEvent) {
|
||||||
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === "h") {
|
if (e.ctrlKey && e.altKey && e.key.toLowerCase() === "h") {
|
||||||
@ -1066,12 +1046,6 @@ export class TrackComponent extends MapBaseComp implements OnInit, AfterViewInit
|
|||||||
|
|
||||||
/* Handle Dynamic UI resizing */
|
/* Handle Dynamic UI resizing */
|
||||||
validatePLeft(e) {
|
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 (
|
if (
|
||||||
e.rectangle.width && e.rectangle.height &&
|
e.rectangle.width && e.rectangle.height &&
|
||||||
(e.rectangle.width < MIN_PANEL_PX || e.rectangle.right > MAX_LP_WIDTH_PX
|
(e.rectangle.width < MIN_PANEL_PX || e.rectangle.right > MAX_LP_WIDTH_PX
|
||||||
@ -1082,21 +1056,12 @@ export class TrackComponent extends MapBaseComp implements OnInit, AfterViewInit
|
|||||||
}
|
}
|
||||||
onLeftPResizeEnd(e) {
|
onLeftPResizeEnd(e) {
|
||||||
this.zone.runOutsideAngular(() => {
|
this.zone.runOutsideAngular(() => {
|
||||||
if (window.innerWidth <= 640) {
|
this.leftPStyle = {
|
||||||
// Small screen: apply height from bottom-edge drag
|
position: 'absolute',
|
||||||
if (e.rectangle.height) {
|
left: `${e.rectangle.left}px`,
|
||||||
this.leftPStyle = {
|
width: `${e.rectangle.width}px`,
|
||||||
height: `${e.rectangle.height}px`,
|
};
|
||||||
};
|
this.panelState.left.width = e.rectangle.width;
|
||||||
}
|
|
||||||
} 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) {
|
onLeftPToggle(e: Event) {
|
||||||
|
|||||||
@ -12,10 +12,6 @@ body {
|
|||||||
letter-spacing: unset;
|
letter-spacing: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
|
||||||
min-width: 19rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid unneeded outline for links
|
// Avoid unneeded outline for links
|
||||||
a:focus {
|
a:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
@ -350,55 +346,6 @@ body .layout-container .topbar {
|
|||||||
z-index: 2000;
|
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 {
|
body .ui-growl {
|
||||||
top: 120px;
|
top: 120px;
|
||||||
z-index: 2021;
|
z-index: 2021;
|
||||||
@ -748,11 +695,6 @@ body .slim-popup .leaflet-popup-content {
|
|||||||
border: green solid 1px;
|
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 {
|
.slim-accordion.ui-accordion .ui-accordion-header>a {
|
||||||
padding: .1em 1em;
|
padding: .1em 1em;
|
||||||
}
|
}
|
||||||
@ -762,11 +704,6 @@ body .slim-popup .leaflet-popup-content {
|
|||||||
color: darkgreen;
|
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-active .pi,
|
||||||
.ui-accordion.ui-accordion .ui-state-highlight .pi {
|
.ui-accordion.ui-accordion .ui-state-highlight .pi {
|
||||||
color: unset;
|
color: unset;
|
||||||
@ -1586,8 +1523,8 @@ body .ui-table .ui-table-tbody>tr>td {
|
|||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// EXPIRY WARNING — RESPONSIVE PLACEMENT
|
// EXPIRY WARNING — RESPONSIVE PLACEMENT
|
||||||
// At >640px: shown inline in topbar (account-summary-info)
|
// At >1024px: shown in topbar (account-summary-info), content banner hidden
|
||||||
// At ≤640px: hidden entirely (screen too narrow)
|
// At ≤1024px: hidden in topbar (too narrow), shown as sticky bar below toolbar
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Global expiry-warning pill styles (used in topbar and content banner)
|
// Global expiry-warning pill styles (used in topbar and content banner)
|
||||||
@ -1623,141 +1560,34 @@ body .ui-table .ui-table-tbody>tr>td {
|
|||||||
display: none;
|
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) {
|
@media (max-width: 1024px) {
|
||||||
// Hide the yellow-arrow button; replaced by the left-side tab
|
// Hide topbar warning on small screens
|
||||||
.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 {
|
.topbar-right .expiry-warning {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show expiry warning as a fixed full-width banner just below the topbar
|
// Sticky banner in normal flow — sits just below topbar, content scrolls under it
|
||||||
.content-expiry-banner {
|
.content-expiry-banner {
|
||||||
display: flex !important;
|
display: flex;
|
||||||
position: fixed;
|
justify-content: flex-end;
|
||||||
top: 75px;
|
position: sticky;
|
||||||
left: 0;
|
top: 80px;
|
||||||
right: 0;
|
|
||||||
z-index: 1999;
|
z-index: 1999;
|
||||||
justify-content: center;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
.expiry-warning {
|
|
||||||
display: block !important;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
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: 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 {
|
.account-summary-info {
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
font-size: 0.9rem;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.expiry-warning {
|
||||||
display: none !important;
|
display: inline-block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-top: 0;
|
||||||
|
// margin-right: 6px;
|
||||||
|
border-radius: 0 0 0 4px;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1856,21 +1686,6 @@ body .ui-table .ui-table-tbody>tr>td {
|
|||||||
// Pattern matches PrimeNG's ui-datatable-stacked responsive behavior
|
// Pattern matches PrimeNG's ui-datatable-stacked responsive behavior
|
||||||
// Headers (ui-column-title) left-aligned, values flow naturally after
|
// 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) {
|
@media (max-width: 767px) {
|
||||||
body .ui-table.ui-table-responsive {
|
body .ui-table.ui-table-responsive {
|
||||||
|
|
||||||
@ -1905,7 +1720,6 @@ body .ui-table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SHARED CONSTRAINT MESSAGE COMPONENT SYSTEM
|
// SHARED CONSTRAINT MESSAGE COMPONENT SYSTEM
|
||||||
// AgMission Project Color Palette Compliance
|
// AgMission Project Color Palette Compliance
|
||||||
|
|||||||
Binary file not shown.
BIN
Development/libs/ultima-ng-9.0.0.zip
Normal file
BIN
Development/libs/ultima-ng-9.0.0.zip
Normal file
Binary file not shown.
@ -511,32 +511,3 @@ require('dotenv').config({ path: envPath });
|
|||||||
- Keep README files synchronized with actual implementation
|
- Keep README files synchronized with actual implementation
|
||||||
|
|
||||||
**DLQ Testing**: Use `docs/Partner_DLQ_API.postman_collection.json` to test all 6 queue-native endpoints.
|
**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
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
engine-strict=false
|
engine-strict=true
|
||||||
@ -0,0 +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
|
||||||
|
@ -0,0 +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
|
||||||
|
2
Development/server/.vscode/launch.json
vendored
2
Development/server/.vscode/launch.json
vendored
@ -22,7 +22,7 @@
|
|||||||
"DEBUG": "agm:*",
|
"DEBUG": "agm:*",
|
||||||
"PRODUCTION": "false"
|
"PRODUCTION": "false"
|
||||||
},
|
},
|
||||||
"program": "${workspaceFolder}/scripts/importCustStripeSubs.js",
|
"program": "${workspaceFolder}/workers/importCustStripeSubs.js",
|
||||||
"args": [
|
"args": [
|
||||||
"cus_SIX3z3yexFrh6q", //"cus_RyON6s93uk5Wxh", // Replace with your Stripe customer ID
|
"cus_SIX3z3yexFrh6q", //"cus_RyON6s93uk5Wxh", // Replace with your Stripe customer ID
|
||||||
//"--dry-run"
|
//"--dry-run"
|
||||||
|
|||||||
@ -158,7 +158,7 @@ Quick links:
|
|||||||
### Web Dashboard
|
### Web Dashboard
|
||||||
|
|
||||||
```
|
```
|
||||||
https://localhost:4100/dlq-monitor.html
|
http://localhost:4100/dlq-monitor.html
|
||||||
```
|
```
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
@ -212,7 +212,7 @@ For complete API documentation, see [docs/DLQ_API_REFERENCE.md](./docs/DLQ_API_R
|
|||||||
|
|
||||||
### Automated Processing
|
### Automated Processing
|
||||||
|
|
||||||
**Use the web dashboard** at `https://localhost:4100/dlq-monitor.html` or API endpoints:
|
**Use the web dashboard** at `http://localhost:4100/dlq-monitor.html` or API endpoints:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Retry all DLQ messages
|
# Retry all DLQ messages
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user