all changes from April 23 2026

This commit is contained in:
Devin Major 2026-04-23 14:24:54 -04:00
parent 40e405ac57
commit fbfa44ba97
48 changed files with 2118 additions and 1268 deletions

View File

@ -23,10 +23,7 @@
[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 === ACTIVE" [options]="activeOpts" [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 === KIND"> <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>
<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>

View File

@ -7,7 +7,7 @@ import { User } from '../models/user.model';
import * as fromUsers from '../reducers'; import * as fromUsers from '../reducers';
import * as userActions from '../actions/account.actions'; import * as userActions from '../actions/account.actions';
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global'; import { RoleIds, Roles, globals, OperationalStatus, Labels } from '@app/shared/global';
import { BaseComp } from '@app/shared/base/base.component'; import { BaseComp } from '@app/shared/base/base.component';
import { Utils } from '@app/shared/utils'; import { Utils } from '@app/shared/utils';
@ -26,6 +26,10 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
{ label: globals.active, value: true }, { label: globals.active, value: true },
{ label: globals.notActive, value: false }, { 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;

View File

@ -4,6 +4,7 @@ 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';

View File

@ -3,4 +3,88 @@ To disable deselect or reselect an item on a table row
Ref:https://stackoverflow.com/questions/48675497/how-to-disable-the-option-to-deselect-a-row-on-turbotable-component*/ Ref:https://stackoverflow.com/questions/48675497/how-to-disable-the-option-to-deselect-a-row-on-turbotable-component*/
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;
}
} }

View File

@ -1,9 +1,27 @@
<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">
<span class="table-caption-1" i18n="@@clientList">Client List</span> <div class="ui-g ui-g-nopad cache-ttl-caption">
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-title">
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@clientList">Client List</span>
</div>
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-controls">
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
(blur)="updateCacheTtl()" style="width: 3.5rem;">
<span class="cache-ttl-help" tabindex="0">
<span class="cache-ttl-help-icon">?</span>
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
</span>
</div>
</div>
</ng-template> </ng-template>
<ng-template pTemplate="header" let-columns> <ng-template pTemplate="header" let-columns>
<tr> <tr>

View File

@ -11,6 +11,9 @@ 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({
@ -29,6 +32,20 @@ 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]);
} }
@ -36,9 +53,11 @@ 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() {
@ -58,7 +77,51 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
this.currClient = client; this.currClient = client;
})); }));
this.store.dispatch(new clientActions.Fetch()); this.useCacheOnReturn = this.listReturnCache.startVisit('clients');
const savedFilters = sessionStorage.getItem('client-list-last-filters');
if (savedFilters) {
try {
this.lastFiltersQuery = JSON.parse(savedFilters);
} catch (_err) {
this.lastFiltersQuery = undefined;
}
}
this.store.dispatch(savedFilters
? new clientActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
: new clientActions.Fetch({ useCache: this.useCacheOnReturn })
);
}
onAccordionToggle(expanded: boolean) {
sessionStorage.setItem('client-list-accordion', String(expanded));
}
updateCacheTtl(): void {
const ttlMs = this.clientCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
}
onFiltersSubmit(event: FilterChangeEvent) {
const q = { ...event.query };
const filtersStr = JSON.stringify(q);
const prevFilters = sessionStorage.getItem('client-list-last-filters');
if (filtersStr !== prevFilters) {
this.clientCache.invalidate();
this.useCacheOnReturn = false;
}
this.lastFiltersQuery = q;
sessionStorage.setItem('client-list-last-filters', filtersStr);
this.store.dispatch(new clientActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
}
reloadClients() {
this.clientCache.invalidate();
this.useCacheOnReturn = false;
if (this.lastFiltersQuery) {
this.store.dispatch(new clientActions.Fetch({ filters: JSON.stringify(this.lastFiltersQuery), useCache: false }));
} else {
this.store.dispatch(new clientActions.Fetch({ useCache: false }));
}
} }
onRowSelect(event) { onRowSelect(event) {
@ -74,6 +137,7 @@ 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 });
} }

View File

@ -13,6 +13,7 @@ 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';
@ -28,7 +29,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, AppSharedModule, CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AccordionModule, AppSharedModule,
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer), StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
EffectsModule.forFeature([ClientEffects]), EffectsModule.forFeature([ClientEffects]),
ClientsRoutingModule ClientsRoutingModule

View File

@ -10,6 +10,7 @@ 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 {
@ -17,15 +18,20 @@ 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(() => switchMap(({ payload }) =>
this.clientSvc.loadClients({ byPuid: this.authSvc.user.parent }).pipe( this.clientSvc.loadClients({
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));
@ -40,7 +46,10 @@ 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) => new clientActions.CreateSuccess(client)), map((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())
@ -54,7 +63,9 @@ 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(() => new clientActions.UpdateSuccess(payload)), map(() => {
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());
@ -68,7 +79,10 @@ 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(() => new clientActions.DeleteSuccess(payload)), map(() => {
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())

View File

@ -4,6 +4,7 @@ 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';

View File

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

View File

@ -1,13 +1,25 @@
<div class="ui-g"> <div class="ui-g">
<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"> <div class="ui-g ui-g-nopad cache-ttl-caption">
<div class="ui-g-6 cc-field-label"> <div class="ui-g-6 cc-field-label cache-ttl-caption-title">
<span class="table-caption-1" i18n="@@customerList">Customer List</span> <span class="table-caption-1" style="display:block; text-align:left;" i18n="@@customerList">Customer List</span>
</div> </div>
<div class="ui-g-6 cc-field-label"> <div class="ui-g-6 cc-field-label cache-ttl-caption-controls">
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
(blur)="updateCacheTtl()" style="width: 3.5rem; margin-right: 8px;">
<span class="cache-ttl-help" tabindex="0">
<span class="cache-ttl-help-icon">?</span>
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
</span>
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label> <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>
@ -34,14 +46,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, 'contains')" [value]="dt.filters[col.field]?.value"> <input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div> </div>
<div class="input-with-icon" *ngIf="col.field === 'totalJobs'">
<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 === CREATED">
<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>

View File

@ -10,6 +10,9 @@ 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',
@ -34,11 +37,19 @@ 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 = [
@ -56,6 +67,14 @@ 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() {
@ -70,7 +89,19 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
this.curCust = cust; this.curCust = cust;
})); }));
this.store.dispatch(new customerActions.Fetch()); this.useCacheOnReturn = this.listReturnCache.startVisit('customers');
const savedFilters = sessionStorage.getItem('customers-list-last-filters');
if (savedFilters) {
try {
this.lastFiltersQuery = JSON.parse(savedFilters);
} catch (_err) {
this.lastFiltersQuery = undefined;
}
}
this.store.dispatch(savedFilters
? new customerActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
: new customerActions.Fetch({ useCache: this.useCacheOnReturn })
);
} }
private setCustomersAndPartners(customers: Customer[]) { private setCustomersAndPartners(customers: Customer[]) {
@ -101,6 +132,28 @@ 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');
} }
@ -110,6 +163,7 @@ 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 });
} }

View File

@ -12,6 +12,7 @@ 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 { ApiKeySharedModule } from '../settings/api-keys/api-key-shared.module';
@ -43,6 +44,7 @@ import { TrialComponent } from './trial/trial.component';
TableModule, TableModule,
AppSharedModule, AppSharedModule,
ApiKeySharedModule, ApiKeySharedModule,
AccordionModule,
StoreModule.forFeature(fromCustomers.FEATURE_KEY, fromCustomers.reducer), StoreModule.forFeature(fromCustomers.FEATURE_KEY, fromCustomers.reducer),
EffectsModule.forFeature([CustomerEffects]), EffectsModule.forFeature([CustomerEffects]),

View File

@ -9,21 +9,23 @@ import * as customerActions from '../actions/customer.actions';
import { CustomerService } from '@app/domain/services/customer.service'; import { 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(() => switchMap(({ payload }) =>
this.customerSvc.loadCustomers().pipe( this.customerSvc.loadCustomers(payload?.filters, payload?.useCache).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));
@ -38,7 +40,10 @@ 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) => new customerActions.CreateSuccess(customer)), map((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())
@ -52,7 +57,9 @@ 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(() => new customerActions.UpdateSuccess(payload)), map(() => {
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());
@ -66,7 +73,10 @@ 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(() => new customerActions.DeleteSuccess(payload)), map(() => {
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())

View File

@ -24,6 +24,7 @@ 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;
} }

View File

@ -130,6 +130,8 @@ 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;

View File

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { from, Observable, of } from 'rxjs'; import { from, Observable, of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators'; import { catchError, switchMap } from 'rxjs/operators';
import { AppConfigService } from './app-config.service';
/** Shape of every entry stored in Cache Storage. */ /** Shape of every entry stored in Cache Storage. */
export interface BrowserCacheEntry<T> { export interface BrowserCacheEntry<T> {
@ -34,6 +35,37 @@ export interface BrowserCacheEntry<T> {
export class BrowserCacheService { export class BrowserCacheService {
private readonly supported = typeof caches !== 'undefined'; 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. * Build the pseudo-URL used as the cache key inside a named Cache bucket.
@ -49,12 +81,14 @@ export class BrowserCacheService {
* @param cacheName Name of the Cache Storage bucket (e.g. `'agm-jobs-list-v1'`). * @param cacheName Name of the Cache Storage bucket (e.g. `'agm-jobs-list-v1'`).
* @param key Entry key typically serialised query params. * @param key Entry key typically serialised query params.
* @param maxAgeMs Maximum age in milliseconds before the entry is treated as stale. * @param maxAgeMs Maximum age in milliseconds before the entry is treated as stale.
* Defaults to 60 000 (1 minute). * Defaults to `appConfig.browserListCacheTtlMs` or 60 000.
* @returns The cached value, or `null` when unavailable / stale / missing. * @returns The cached value, or `null` when unavailable / stale / missing.
*/ */
get<T>(cacheName: string, key: string, maxAgeMs: number = 60_000): Observable<T | null> { get<T>(cacheName: string, key: string, maxAgeMs?: number): Observable<T | null> {
if (!this.supported) return of(null); if (!this.supported) return of(null);
const effectiveMaxAgeMs = maxAgeMs ?? this.getTtl(cacheName);
return from(caches.open(cacheName)).pipe( return from(caches.open(cacheName)).pipe(
switchMap(cache => from(cache.match(this.entryUrl(cacheName, key)))), switchMap(cache => from(cache.match(this.entryUrl(cacheName, key)))),
switchMap(response => { switchMap(response => {
@ -63,7 +97,7 @@ export class BrowserCacheService {
}), }),
switchMap((entry: BrowserCacheEntry<T> | null) => { switchMap((entry: BrowserCacheEntry<T> | null) => {
if (!entry) return of(null); if (!entry) return of(null);
if (Date.now() - entry.cachedAt > maxAgeMs) return of(null); if (Date.now() - entry.cachedAt > effectiveMaxAgeMs) return of(null);
return of(entry.data); return of(entry.data);
}), }),
catchError(() => of(null)) catchError(() => of(null))

View File

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

View File

@ -1,11 +1,13 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable, of } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { 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 {
@ -14,12 +16,35 @@ 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[]> {
return this.http.post<Client[]>(this.clientURL + '/search', options); const cacheKey = JSON.stringify({
byPuid: options?.byPuid,
filters: options?.filters || ''
});
const requestBody = {
byPuid: options?.byPuid,
...(options?.filters ? { filters: options.filters } : {})
};
if (options?.useCache) {
return this.clientCache.get(cacheKey).pipe(
switchMap(cached => {
if (cached !== null) return of(cached as Client[]);
return this.http.post<Client[]>(this.clientURL + '/search', requestBody).pipe(
tap(data => this.clientCache.put(cacheKey, data))
);
})
);
}
return this.http.post<Client[]>(this.clientURL + '/search', requestBody).pipe(
tap(data => this.clientCache.put(cacheKey, data))
);
} }
getClient(id: string): Observable<Client> { getClient(id: string): Observable<Client> {
@ -53,6 +78,8 @@ 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 {

View File

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

View File

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

View File

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

View File

@ -5,10 +5,11 @@ import { Observable, of } from 'rxjs';
import { Client, Invoice } from '@app/invoices/models/invoice.model'; import { 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 } from 'rxjs/operators'; import { catchError, map, switchMap, tap } 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 {
@ -28,7 +29,8 @@ 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
@ -71,8 +73,25 @@ export class InvoiceService {
} }
// Invoice // Invoice
getInvoices(): Observable<Invoice[]> { getInvoices(filters?: string, useCache: boolean = false): Observable<Invoice[]> {
return this.http.get<Invoice[]>(this.invoiceURL); const cacheKey = filters || '';
const params: any = {};
if (filters) params.filters = filters;
if (useCache) {
return this.invoiceCache.get(cacheKey).pipe(
switchMap(cached => {
if (cached !== null) return of(cached as Invoice[]);
return this.http.get<Invoice[]>(this.invoiceURL, { params }).pipe(
tap(data => this.invoiceCache.put(cacheKey, data))
);
})
);
}
return this.http.get<Invoice[]>(this.invoiceURL, { params }).pipe(
tap(data => this.invoiceCache.put(cacheKey, data))
);
} }
getInvoiceById(id): Observable<Invoice> { getInvoiceById(id): Observable<Invoice> {

View File

@ -3,7 +3,6 @@ import { Observable } from 'rxjs';
import { BrowserCacheService } from './browser-cache.service'; import { BrowserCacheService } from './browser-cache.service';
const CACHE_NAME = 'agm-jobs-list-v1'; const CACHE_NAME = 'agm-jobs-list-v1';
const MAX_AGE_MS = 60_000; // 1 minute
/** /**
* Jobs-list-specific facade over {@link BrowserCacheService}. * Jobs-list-specific facade over {@link BrowserCacheService}.
@ -16,9 +15,17 @@ export class JobCacheService {
constructor(private readonly browserCache: BrowserCacheService) {} 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. */ /** Return cached jobs for the given query-param string, or null if stale/missing. */
get(queryParams: string): Observable<any[] | null> { get(queryParams: string): Observable<any[] | null> {
return this.browserCache.get<any[]>(CACHE_NAME, queryParams, MAX_AGE_MS); return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
} }
/** Store a fresh jobs list for the given query-param string. */ /** Store a fresh jobs list for the given query-param string. */

View File

@ -45,18 +45,24 @@ export class JobService {
const cacheKey = _ops.toString(); const cacheKey = _ops.toString();
return this.jobCache.get(cacheKey).pipe( if (ops?.useCache) {
switchMap(cached => { return this.jobCache.get(cacheKey).pipe(
if (cached !== null) { switchMap(cached => {
return new Observable<IJob[]>(observer => { if (cached !== null) {
observer.next(cached as IJob[]); return new Observable<IJob[]>(observer => {
observer.complete(); 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))
}) );
})
);
}
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
tap(data => this.jobCache.put(cacheKey, data))
); );
} }

View File

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

View File

@ -16,7 +16,18 @@
<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')"></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 === 'desc'"> <div class="input-with-icon" *ngIf="col.field === 'desc'">
<i class="ui-icon-search"></i> <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"> <input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
@ -70,9 +81,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 class="ui-helper-clearfix" style="position:relative;"> <div style="display:flex; align-items:center; justify-content:center; gap:.5em;">
<div class="color-box" style="margin-left:3px" [ngStyle]="{ 'background-color': item.value }"></div> <div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
<div style="float:right; margin-right: .15em;">{{item.label}}</div> <span>{{item.label}}</span>
</div> </div>
</ng-template> </ng-template>
</p-dropdown> </p-dropdown>

View File

@ -62,12 +62,21 @@
(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" <p-dropdown *ngIf="col.field === ACTIVE" [options]="activeOpts" [ngModel]="dt.filters[col.field]?.value"
(onChange)="dt.filter($event.value, ACTIVE, 'equals')"></p-dropdown> (onChange)="dt.filter($event.value, ACTIVE, 'equals')"></p-dropdown>
<div class="input-with-icon" *ngIf="col.field === SOURCE_SYSTEM"> <p-dropdown *ngIf="col.field === SOURCE_SYSTEM" [options]="sourceSystemOpts" [ngModel]="dt.filters[col.field]?.value"
<i class="ui-icon-search"></i> (onChange)="dt.filter($event.value, SOURCE_SYSTEM, 'equals')"></p-dropdown>
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">
</div>
<p-dropdown *ngIf="col.field === COLOR" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value" <p-dropdown *ngIf="col.field === COLOR" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value"
(onChange)="dt.filter($event.value, COLOR, 'equals')"></p-dropdown> (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"> <div class="input-with-icon" *ngIf="col.field === MODEL">
<i class="ui-icon-search"></i> <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"> <input pInputText type="text" (input)="dt.filter($event.target.value, col.field, 'contains')" [value]="dt.filters[col.field]?.value">

View File

@ -151,6 +151,20 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
this.colorFilterOpts = [GC.selAll, ...GC.selSprZoneColors]; 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() {
this.user = this.route.snapshot.data['user']; this.user = this.route.snapshot.data['user'];
this.clearNeedReview(); this.clearNeedReview();

View File

@ -5,6 +5,7 @@ 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';

View File

@ -24,10 +24,6 @@
</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> <p-dropdown *ngIf="col.field === 'unit'" [options]="unitFilterOpts" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'unit', 'equals')"></p-dropdown>
<div class="input-with-icon" *ngIf="col.field === 'price'">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="ci.filter($event.target.value, col.field, 'contains')" [value]="ci.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span> <span *ngSwitchDefault></span>
</th> </th>
</tr> </tr>

View File

@ -7,13 +7,15 @@ 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
) { ) {
} }
@ -24,6 +26,7 @@ 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);
}), }),
@ -56,8 +59,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(() => { switchMap(({ payload }) => {
return this.invoiceSvc.getInvoices().pipe( return this.invoiceSvc.getInvoices(payload?.filters, payload?.useCache).pipe(
map(res => { map(res => {
return new invoiceActions.FetchSuccess(res); return new invoiceActions.FetchSuccess(res);
}), }),
@ -75,6 +78,7 @@ 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 => {

View File

@ -11,3 +11,87 @@
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;
}
}

View File

@ -1,11 +1,25 @@
<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"> <div class="ui-g ui-g-nopad cache-ttl-caption">
<div class="ui-g-12 ui-g-nopad text-center"> <div class="ui-g-6 ui-sm-12 cache-ttl-caption-title">
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@invoiceList">Invoice List</span> <span class="table-caption-1" style="display:block; text-align:left;" i18n="@@invoiceList">Invoice List</span>
</div>
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-controls">
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
(blur)="updateCacheTtl()" style="width: 3.5rem;">
<span class="cache-ttl-help" tabindex="0">
<span class="cache-ttl-help-icon">?</span>
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
</span>
</div> </div>
</div> </div>
</ng-template> </ng-template>
@ -22,10 +36,6 @@
<p-calendar #odf *ngIf="col.field == 'openDate'" selectionMode="range" [(ngModel)]="openDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(openDateRange, col.field, openDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(openDateRange, col.field, openDateFilter)"></p-calendar> <p-calendar #odf *ngIf="col.field == 'openDate'" selectionMode="range" [(ngModel)]="openDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(openDateRange, col.field, openDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(openDateRange, col.field, openDateFilter)"></p-calendar>
<p-calendar #ddf *ngIf="col.field == 'dueDate'" selectionMode="range" [(ngModel)]="dueDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(dueDateRange, col.field, dueDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(dueDateRange, col.field, dueDateFilter)"></p-calendar> <p-calendar #ddf *ngIf="col.field == 'dueDate'" selectionMode="range" [(ngModel)]="dueDateRange" [locale]="locale" placeholder=" " [readonlyInput]="true" [showButtonBar]="true" [showIcon]="true" [dateFormat]="locale.dateFormat" (onSelect)="handleCalDateRange(dueDateRange, col.field, dueDateFilter)" (onClearClick)="il.filter('', col.field, 'equals')" (onClose)="closeCal(dueDateRange, col.field, dueDateFilter)"></p-calendar>
<p-multiSelect *ngIf="col.field === 'status'" [options]="status" [(ngModel)]="statusFilter" i18n-defaultLabel="@@all" defaultLabel="All" (onChange)="onStatusFilter($event)"></p-multiSelect> <p-multiSelect *ngIf="col.field === 'status'" [options]="status" [(ngModel)]="statusFilter" i18n-defaultLabel="@@all" defaultLabel="All" (onChange)="onStatusFilter($event)"></p-multiSelect>
<div class="input-with-icon" *ngIf="col.field === 'totalAmount'">
<i class="ui-icon-search"></i>
<input pInputText type="text" (input)="onTextFilter($event, col.field, col.filterMatchMode)" [value]="il.filters[col.field]?.value">
</div>
<span *ngSwitchDefault></span> <span *ngSwitchDefault></span>
</th> </th>
</tr> </tr>

View File

@ -16,6 +16,9 @@ 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',
@ -43,13 +46,23 @@ 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`,
@ -74,6 +87,14 @@ 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 {
@ -97,7 +118,19 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
}); });
} }
}); });
this.store.dispatch(new invoiceActions.Fetch()); this.useCacheOnReturn = this.listReturnCache.startVisit('invoices');
const savedFilters = sessionStorage.getItem('invoices-list-last-filters');
if (savedFilters) {
try {
this.lastFiltersQuery = JSON.parse(savedFilters);
} catch (_err) {
this.lastFiltersQuery = undefined;
}
}
this.store.dispatch(savedFilters
? new invoiceActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
: new invoiceActions.Fetch({ useCache: this.useCacheOnReturn })
);
FilterUtils[this.openDateFilter] = (value, filter): boolean => { FilterUtils[this.openDateFilter] = (value, filter): boolean => {
if (filter === undefined || filter === null) { if (filter === undefined || filter === null) {
return true; return true;
@ -180,6 +213,28 @@ 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);
} }
@ -208,6 +263,7 @@ 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({
@ -225,6 +281,7 @@ 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({

View File

@ -39,6 +39,7 @@ 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({
@ -66,6 +67,7 @@ import { InvoiceStatusPipe } from '@app/invoices/pipes/invoice-status.pipe';
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],

View File

@ -109,7 +109,6 @@ export class JobEffects {
save_method: 'manual' save_method: 'manual'
}); });
this.jobCache.invalidate();
return new jobActions.UpdateSuccess(updatedJob) return new jobActions.UpdateSuccess(updatedJob)
}), }),
catchError(err => { catchError(err => {

View File

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

View File

@ -101,8 +101,14 @@
</div> </div>
<ng-template #dropdowns> <ng-template #dropdowns>
<div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad"> <div class="ui-g ui-g-6 ui-sm-12 ui-g-nopad cache-ttl-caption-controls">
<div class="ui-g-12 inline-flex-end"> <div class="ui-g-12 inline-flex-end">
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
(blur)="updateCacheTtl()" style="width: 3.5rem; margin-right: 6px;">
<span class="cache-ttl-help" tabindex="0">
<span class="cache-ttl-help-icon">?</span>
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
</span>
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy" <p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
(onChange)="reloadChanged($event.value)"> (onChange)="reloadChanged($event.value)">
</p-dropdown> </p-dropdown>

View File

@ -29,6 +29,7 @@ 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 { GAService } from '@app/shared/ga.service'; import { GAService } from '@app/shared/ga.service';
import { JobCacheService } from '@app/domain/services/job-cache.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'; import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
@ -51,6 +52,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
private currentByTime: string[] | undefined; private currentByTime: string[] | undefined;
private lastFiltersQuery: Record<string, any> | undefined; private lastFiltersQuery: Record<string, any> | undefined;
private useCacheOnReturn = false;
cacheTtlSeconds: number;
jobFilterDefinitions: FilterDefinition[] = []; jobFilterDefinitions: FilterDefinition[] = [];
readonly defaultDynamicFilters = [ readonly defaultDynamicFilters = [
@ -106,10 +109,12 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
private readonly invoiceSvc: InvoiceService, private readonly invoiceSvc: InvoiceService,
private readonly restoreTableSvc: RestoreTableState, private readonly restoreTableSvc: RestoreTableState,
private readonly gaService: GAService, private readonly gaService: GAService,
private readonly jobCache: JobCacheService private readonly jobCache: JobCacheService,
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 = [
@ -218,6 +223,8 @@ 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 {
@ -279,7 +286,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
clientId: clientId, clientId: clientId,
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot), jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
byTime: this.currentByTime, byTime: this.currentByTime,
status: statusValue status: statusValue,
useCache: this.useCacheOnReturn
})); }));
} }
@ -296,6 +304,11 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
sessionStorage.setItem('job-list-accordion', String(expanded)); 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);
} }
@ -349,6 +362,7 @@ 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',
@ -365,10 +379,12 @@ 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 });
} }
@ -387,11 +403,13 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
reloadJobs() { reloadJobs() {
const startTime = performance.now(); const startTime = performance.now();
this.jobCache.invalidate(); this.jobCache.invalidate();
this.useCacheOnReturn = false;
if (this.lastFiltersQuery) { if (this.lastFiltersQuery) {
this.store.dispatch(new jobActions.Fetch({ this.store.dispatch(new jobActions.Fetch({
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot), jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
filters: JSON.stringify(this.lastFiltersQuery) filters: JSON.stringify(this.lastFiltersQuery),
useCache: false
})); }));
} else { } else {
this.fetchJobsByClient(this.currClient && this.currClient.value); this.fetchJobsByClient(this.currClient && this.currClient.value);
@ -465,10 +483,18 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
this.currClient = matchedClient || { label: globals.all, value: null }; this.currClient = matchedClient || { label: globals.all, value: null };
this.filterClientLocked = !!clientId; 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; this.lastFiltersQuery = q;
sessionStorage.setItem('job-list-last-filters', filtersStr);
this.store.dispatch(new jobActions.Fetch({ this.store.dispatch(new jobActions.Fetch({
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot), jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
filters: JSON.stringify(q) filters: filtersStr,
useCache: this.useCacheOnReturn
})); }));
} }

View File

@ -7,9 +7,19 @@ const Client = require('../model/client'),
{ updateUser_put } = require('./user'), // Import user controller functions { updateUser_put } = require('./user'), // Import user controller functions
cache = require('../helpers/mem_cache'), cache = require('../helpers/mem_cache'),
utils = require('../helpers/utils'), utils = require('../helpers/utils'),
{ buildDynamicFilter } = require('../helpers/dynamic_filter'),
{ Errors, UserTypes } = require('../helpers/constants'), { Errors, UserTypes } = require('../helpers/constants'),
{ AppError, AppParamError } = require('../helpers/app_error'); { AppError, AppParamError } = require('../helpers/app_error');
const CLIENT_FILTER_SCHEMA = {
name: 'text',
username: 'text',
email: 'text',
phone: 'text',
contact: 'text',
address: 'text',
};
async function createClient_post(req, res) { async function createClient_post(req, res) {
const _client = req.body; const _client = req.body;
delete _client._id; delete _client._id;
@ -57,7 +67,15 @@ async function deleteClient(req, res) {
async function search_post(req, res) { async function search_post(req, res) {
if (!utils.isObjectId(req.body.byPuid)) AppParamError.throw(Errors.INVALID_PUID); if (!utils.isObjectId(req.body.byPuid)) AppParamError.throw(Errors.INVALID_PUID);
const clients = await Client.find({ parent: ObjectId(req.body.byPuid), markedDelete: { $ne: true } }, '-password', { lean: true }) const baseFilter = { parent: ObjectId(req.body.byPuid), markedDelete: { $ne: true } };
let dynFilter = {};
if (req.body.filters) {
try {
dynFilter = buildDynamicFilter(req.body.filters, CLIENT_FILTER_SCHEMA);
} catch (_e) { /* ignore invalid filter */ }
}
const clients = await Client.find({ ...baseFilter, ...dynFilter }, '-password', { lean: true })
.populate({ path: 'Country', select: 'code name -_id' }); .populate({ path: 'Country', select: 'code name -_id' });
res.json(clients); res.json(clients);

View File

@ -11,13 +11,25 @@ const Customer = require('../model/customer'),
{ AppParamError } = require('../helpers/app_error'), { AppParamError } = require('../helpers/app_error'),
{ validateTrial } = require('../helpers/subscription_util'), { validateTrial } = require('../helpers/subscription_util'),
moment = require('moment'), moment = require('moment'),
{ buildDynamicFilter } = require('../helpers/dynamic_filter'),
debug = require('debug')('agm:controllers-customer'); debug = require('debug')('agm:controllers-customer');
const CUSTOMER_FILTER_SCHEMA = {
name: 'text',
username: 'text',
email: 'text',
contact: 'text',
createdAt: 'date-preset',
};
async function getCustomers_get(req, res) { async function getCustomers_get(req, res) {
const filtersStr = req.query.filters || '';
const dynamicFilter = filtersStr ? buildDynamicFilter(filtersStr, CUSTOMER_FILTER_SCHEMA) : {};
const customers = await Customer.aggregate( const customers = await Customer.aggregate(
[ [
{ $match: { kind: UserTypes.APP, markedDelete: { $ne: true } } }, { $match: { kind: UserTypes.APP, markedDelete: { $ne: true }, ...dynamicFilter } },
{ {
$lookup: { $lookup: {
from: 'jobs', // Reference the Job collection from: 'jobs', // Reference the Job collection
@ -182,8 +194,7 @@ async function updateCustomer_put(req, res) {
uiValue[1].premium = customer.premium; uiValue[1].premium = customer.premium;
} }
} }
} } res.json(customer);
res.json(customer);
} }
async function deleteCustomer(req, res) { async function deleteCustomer(req, res) {
@ -201,7 +212,6 @@ async function deleteCustomer(req, res) {
cache.delete(u[0]); cache.delete(u[0]);
} }
cache.delete(_id); cache.delete(_id);
res.json({ message: 'deleted' }); res.json({ message: 'deleted' });
} }

View File

@ -16,7 +16,16 @@ const
mongoUtil = require('../helpers/mongo'), mongoUtil = require('../helpers/mongo'),
assert = require('assert'), assert = require('assert'),
path = require('path'), path = require('path'),
{ flattenDeep } = require('lodash'); { flattenDeep } = require('lodash'),
{ buildDynamicFilter } = require('../helpers/dynamic_filter');
const INVOICE_FILTER_SCHEMA = {
code: 'text',
status: 'select-multi',
openDate: 'date',
dueDate: 'date',
createdAt: 'date-preset',
};
function getParamsUpdateJobs(invoice) { function getParamsUpdateJobs(invoice) {
assert(!utils.isEmptyObj(invoice), AppInputError.create()); assert(!utils.isEmptyObj(invoice), AppInputError.create());
@ -45,10 +54,16 @@ async function getInvoices_get(req, res) {
const isClientRole = req.ut === UserTypes.CLIENT; const isClientRole = req.ut === UserTypes.CLIENT;
const userId = req.uid; const userId = req.uid;
const filtersStr = req.query.filters || '';
const filter = { byPuid: puid }; const filter = { byPuid: puid };
if (isClientRole) filter['clients.billTo'] = userId; if (isClientRole) filter['clients.billTo'] = userId;
if (filtersStr) {
const dynamicFilter = buildDynamicFilter(filtersStr, INVOICE_FILTER_SCHEMA);
Object.assign(filter, dynamicFilter);
}
const invoices = await Invoice.find(filter) const invoices = await Invoice.find(filter)
.populate({ path: 'clients.billTo', select: 'name email -kind' }) .populate({ path: 'clients.billTo', select: 'name email -kind' })
.select('code status createdAt openDate dueDate paymentTerm clients.subTotal clients.discount clients.split clients.taxRate'); .select('code status createdAt openDate dueDate paymentTerm clients.subTotal clients.discount clients.split clients.taxRate');
@ -637,12 +652,14 @@ async function deleteInvoicesByIds(invoiceIds, puid) {
} }
async function deleteInvoiceById(req, res) { async function deleteInvoiceById(req, res) {
const removedInvIds = await deleteInvoicesByIds([req.params?.id], req.userInfo?.puid); const puid = req.userInfo?.puid;
const removedInvIds = await deleteInvoicesByIds([req.params?.id], puid);
res.json(removedInvIds.length ? removedInvIds[0] : []); res.json(removedInvIds.length ? removedInvIds[0] : []);
} }
async function deleteInvoices(req, res) { async function deleteInvoices(req, res) {
const removedInvIds = await deleteInvoicesByIds(req?.body?.invoiceIds, req.userInfo?.puid); const puid = req.userInfo?.puid;
const removedInvIds = await deleteInvoicesByIds(req?.body?.invoiceIds, puid);
res.json(removedInvIds); res.json(removedInvIds);
} }

View File

@ -4,7 +4,6 @@ module.exports = function (locals) {
const const
async = require('async'), async = require('async'),
assert = require('assert'), assert = require('assert'),
crypto = require('crypto'),
debug = require('debug')('agm:job'), debug = require('debug')('agm:job'),
ObjectId = require('mongodb').ObjectId, ObjectId = require('mongodb').ObjectId,
{ Job, JobLog, App, AppFile, AppDetail, Customer, JobAssign, Vehicle, Pilot, RptVar, User } = require('../model'), { Job, JobLog, App, AppFile, AppDetail, Customer, JobAssign, Vehicle, Pilot, RptVar, User } = require('../model'),
@ -30,7 +29,6 @@ module.exports = function (locals) {
{ AppParamError, AppError, AppAuthError, AppInputError } = require('../helpers/app_error'), { AppParamError, AppError, AppAuthError, AppInputError } = require('../helpers/app_error'),
{ getFormattedAddress, getDocumentCountry } = require('../helpers/user_helper'), { getFormattedAddress, getDocumentCountry } = require('../helpers/user_helper'),
env = require('../helpers/env'), env = require('../helpers/env'),
redisCache = require('../helpers/redis_cache'),
partnerSyncService = require('../services/partner_sync_service'), partnerSyncService = require('../services/partner_sync_service'),
taskQHelper = require('../helpers/job_queue').getInstance(), taskQHelper = require('../helpers/job_queue').getInstance(),
{ paginateWithCursor, validateCursorParams } = require('../helpers/cursor_pagination'), { paginateWithCursor, validateCursorParams } = require('../helpers/cursor_pagination'),
@ -50,37 +48,6 @@ module.exports = function (locals) {
status: 'numeric-enum', status: 'numeric-enum',
}; };
const JOBS_CACHE_TTL = env.JOBS_CACHE_TTL;
/**
* Build a deterministic cache key for the jobs list endpoint.
* Normalises query params by sorting keys so that identical filter sets always
* produce the same key regardless of the order params were appended.
* @param {string} userScope - 'admin' or the user's puid string
* @param {object} query - req.query
* @returns {string}
*/
function buildJobsListCacheKey(userScope, query) {
const normalized = JSON.stringify(
Object.fromEntries(Object.keys(query).sort().map(k => [k, query[k]]))
);
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
return `jobs:list:${userScope}:${hash}`;
}
/**
* Invalidate all cached jobs-list entries for a given puid scope and for the
* admin scope (since admin queries span all accounts).
* Fire-and-forget errors are silently swallowed so they never block a response.
* @param {string|ObjectId} puid
*/
function invalidateJobsListCache(puid) {
if (!JOBS_CACHE_TTL) return;
redisCache.delByPattern(`jobs:list:${puid}:*`).catch(() => {});
redisCache.delByPattern('jobs:list:admin:*').catch(() => {});
}
/** /**
* Handles the GET request to retrieve a list of jobs based on the provided filters. * Handles the GET request to retrieve a list of jobs based on the provided filters.
* *
@ -108,10 +75,6 @@ module.exports = function (locals) {
// Determine scope for cache key once so it can be reused for both read and write. // Determine scope for cache key once so it can be reused for both read and write.
const userScope = req.ut === UserTypes.ADMIN ? 'admin' : String(userInfo.puid); const userScope = req.ut === UserTypes.ADMIN ? 'admin' : String(userInfo.puid);
if (JOBS_CACHE_TTL) {
const cached = await redisCache.get(buildJobsListCacheKey(userScope, req.query));
if (cached) return res.json(cached);
}
const filtersJson = req.query['filters']; const filtersJson = req.query['filters'];
let filter = { markedDelete: { $in: [null, false] } }; let filter = { markedDelete: { $in: [null, false] } };
@ -204,9 +167,6 @@ module.exports = function (locals) {
]; ];
const jobs = await Job.aggregate(pipeline); const jobs = await Job.aggregate(pipeline);
if (JOBS_CACHE_TTL) {
redisCache.set(buildJobsListCacheKey(userScope, req.query), jobs, JOBS_CACHE_TTL).catch(() => {});
}
res.json(jobs); res.json(jobs);
} }
@ -258,7 +218,6 @@ module.exports = function (locals) {
insertedJob.operator = _job.operator; insertedJob.operator = _job.operator;
insertedJob.vehicle = _job.vehicle; insertedJob.vehicle = _job.vehicle;
res.json(insertedJob); res.json(insertedJob);
invalidateJobsListCache(req.userInfo?.puid);
} }
async function getJob_get(req, res) { async function getJob_get(req, res) {
@ -437,7 +396,6 @@ module.exports = function (locals) {
} }
res.json(retJob); res.json(retJob);
invalidateJobsListCache(req.userInfo?.puid);
} }
async function deleteJob(req, res) { async function deleteJob(req, res) {
@ -445,7 +403,6 @@ module.exports = function (locals) {
const puid = req.userInfo?.puid || job?.byPuid; const puid = req.userInfo?.puid || job?.byPuid;
if (job) await job.removeFull(); if (job) await job.removeFull();
res.json({ ok: true }).end(); res.json({ ok: true }).end();
invalidateJobsListCache(puid);
} }
/** /**

View File

@ -41,6 +41,7 @@ async function getAppConfig_get(req, res) {
} }
delete userSettings.userId; delete userSettings.userId;
userSettings.browserListCacheTtlMs = env.BROWSER_LIST_CACHE_TTL_MS;
if (isSysAdmin(userInfo.kind)) { if (isSysAdmin(userInfo.kind)) {
if (utils.isEmptyArray(userSettings.trialDays)) userSettings.trialDays = DEFAULT_TRIAL_DAYS; if (utils.isEmptyArray(userSettings.trialDays)) userSettings.trialDays = DEFAULT_TRIAL_DAYS;
userSettings.promoMinExpiryDays = env.PROMO_MIN_EXPIRY_DAYS; userSettings.promoMinExpiryDays = env.PROMO_MIN_EXPIRY_DAYS;

View File

@ -70,6 +70,7 @@ module.exports = {
APP_RATE_SKIPFAIL: utils.stringToBoolean(process.env.APP_RATE_SKIPFAIL), APP_RATE_SKIPFAIL: utils.stringToBoolean(process.env.APP_RATE_SKIPFAIL),
// true: trust all proxies, ['ip address', 'other ip address'] or number: number of proxies between user and server // true: trust all proxies, ['ip address', 'other ip address'] or number: number of proxies between user and server
APP_RATE_TRUST_PROXIES: Number(process.env.APP_RATE_TRUST_PROXIES) || 1, APP_RATE_TRUST_PROXIES: Number(process.env.APP_RATE_TRUST_PROXIES) || 1,
BROWSER_LIST_CACHE_TTL_MS: Number(process.env.BROWSER_LIST_CACHE_TTL_MS) || 60 * 1000,
// Make APP_URL default to prod host or local dev // Make APP_URL default to prod host or local dev
APP_URL: IS_PROD ? (process.env.APP_URL || 'https://agmission.agnav.com') : (process.env.APP_URL || 'http://localhost:4200'), APP_URL: IS_PROD ? (process.env.APP_URL || 'https://agmission.agnav.com') : (process.env.APP_URL || 'http://localhost:4200'),
@ -129,7 +130,6 @@ module.exports = {
QUEUE_NAME_PARTNER: getQueueName(process.env.QUEUE_NAME_PARTNER, 'partner_tasks'), QUEUE_NAME_PARTNER: getQueueName(process.env.QUEUE_NAME_PARTNER, 'partner_tasks'),
REDIS_PWD: process.env.REDIS_PWD, REDIS_PWD: process.env.REDIS_PWD,
JOBS_CACHE_TTL: Number(process.env.JOBS_CACHE_TTL) || 60, // seconds; set to 0 to disable
SMTP_HOST: process.env.SMTP_HOST, SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT, SMTP_PORT: process.env.SMTP_PORT,

File diff suppressed because it is too large Load Diff

View File

@ -64,7 +64,7 @@
"debug": "^4.1.1", "debug": "^4.1.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"email-templates": "11.0.3", "email-templates": "11.0.3",
"error-handler": "file:../../../@agn/error-handler", "error-handler": "file:../../../../@agn/error-handler",
"exceljs": "^4.2.1", "exceljs": "^4.2.1",
"express": "^4.18.1", "express": "^4.18.1",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",