all changes from April 23 2026
This commit is contained in:
parent
40e405ac57
commit
fbfa44ba97
@ -23,10 +23,7 @@
|
||||
[value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown *ngIf="col.field === ACTIVE" [options]="activeOpts" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||
<div class="input-with-icon" *ngIf="col.field === KIND">
|
||||
<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>
|
||||
<p-dropdown *ngIf="col.field === KIND" [options]="kindOpts" [style]="{'width':'100%'}" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, col.field, 'equals')"></p-dropdown>
|
||||
<span *ngSwitchDefault></span>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -7,7 +7,7 @@ import { User } from '../models/user.model';
|
||||
import * as fromUsers from '../reducers';
|
||||
import * as userActions from '../actions/account.actions';
|
||||
|
||||
import { RoleIds, globals, OperationalStatus, Labels } from '@app/shared/global';
|
||||
import { RoleIds, Roles, globals, OperationalStatus, Labels } from '@app/shared/global';
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { Utils } from '@app/shared/utils';
|
||||
|
||||
@ -26,6 +26,10 @@ export class AccountListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
{ label: globals.active, value: true },
|
||||
{ label: globals.notActive, value: false },
|
||||
];
|
||||
kindOpts = [
|
||||
{ label: globals.all, value: null },
|
||||
...Object.entries(Roles).map(([value, label]) => ({ label: label as string, value }))
|
||||
];
|
||||
accounts: Array<User>;
|
||||
isLoading: boolean;
|
||||
currAcc: User;
|
||||
|
||||
@ -4,6 +4,7 @@ import { Client } from "../models/client.model";
|
||||
export const FETCH = '[CLIENTS] Fetch clients';
|
||||
export class Fetch implements Action {
|
||||
type: typeof FETCH = FETCH;
|
||||
constructor(readonly payload?: { filters?: string; useCache?: boolean }) { }
|
||||
}
|
||||
|
||||
export const FETCH_SUCCESS = '[CLIENTS] Fetch clients success';
|
||||
|
||||
@ -4,3 +4,87 @@ Ref:https://stackoverflow.com/questions/48675497/how-to-disable-the-option-to-de
|
||||
tr.ui-state-highlight {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cache-ttl-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.cache-ttl-help {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cache-ttl-help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
cursor: help;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cache-ttl-help-text {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 220px;
|
||||
white-space: normal;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: #323232;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
line-height: 1.35;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.cache-ttl-help:hover .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus-within .cache-ttl-help-text {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cache-ttl-caption-title,
|
||||
.cache-ttl-caption-controls {
|
||||
width: auto;
|
||||
float: none;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,27 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card">
|
||||
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||
<p-accordionTab i18n-header="@@searchClients" header="Search Clients" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
|
||||
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
|
||||
<agm-dynamic-filter [filterDefinitions]="clientFilterDefinitions" [locale]="locale" stateKey="client-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
<p-table #dt [value]="clients" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[15,30,50]" [alwaysShowPaginator]="false" [(selection)]="currClient" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="cltb-ops" [responsive]="true">
|
||||
<ng-template pTemplate="caption">
|
||||
<span class="table-caption-1" i18n="@@clientList">Client List</span>
|
||||
<div class="ui-g ui-g-nopad cache-ttl-caption">
|
||||
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-title">
|
||||
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@clientList">Client List</span>
|
||||
</div>
|
||||
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-controls">
|
||||
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
|
||||
(blur)="updateCacheTtl()" style="width: 3.5rem;">
|
||||
<span class="cache-ttl-help" tabindex="0">
|
||||
<span class="cache-ttl-help-icon">?</span>
|
||||
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="header" let-columns>
|
||||
<tr>
|
||||
|
||||
@ -11,6 +11,9 @@ import { RoleIds, globals } from '../../shared/global';
|
||||
import { JobService } from '../../domain/services/job.service';
|
||||
import { Utils } from 'src/app/shared/utils';
|
||||
import { BaseComp } from 'src/app/shared/base/base.component';
|
||||
import { ClientCacheService } from '@app/domain/services/client-cache.service';
|
||||
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
|
||||
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||
|
||||
|
||||
@Component({
|
||||
@ -29,6 +32,20 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
cols: any[];
|
||||
loading$ = this.store.select(fromClients.isLoading);
|
||||
|
||||
searchAccordionOpen = sessionStorage.getItem('client-list-accordion') === 'true';
|
||||
private lastFiltersQuery: Record<string, any> | undefined;
|
||||
private useCacheOnReturn = false;
|
||||
cacheTtlSeconds: number;
|
||||
|
||||
clientFilterDefinitions: FilterDefinition[] = [
|
||||
{ key: 'name', label: globals.name, dataType: 'text' },
|
||||
{ key: 'username', label: globals.userName, dataType: 'text' },
|
||||
{ key: 'email', label: globals.email, dataType: 'text' },
|
||||
{ key: 'phone', label: globals.phone + ' ' + $localize`:@@Num:N°`, dataType: 'text' },
|
||||
{ key: 'contact', label: globals.contact, dataType: 'text' },
|
||||
{ key: 'address', label: globals.address, dataType: 'text' },
|
||||
];
|
||||
|
||||
get canWrite(): boolean {
|
||||
return this.authSvc.hasRole([RoleIds.APP, RoleIds.APP_ADM, RoleIds.OFFICER]);
|
||||
}
|
||||
@ -36,9 +53,11 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly jobService: JobService,
|
||||
|
||||
private readonly clientCache: ClientCacheService,
|
||||
private readonly listReturnCache: ListReturnCacheService,
|
||||
) {
|
||||
super();
|
||||
this.cacheTtlSeconds = Math.round(this.clientCache.getTtlMs() / 1000);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -58,7 +77,51 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
this.currClient = client;
|
||||
}));
|
||||
|
||||
this.store.dispatch(new clientActions.Fetch());
|
||||
this.useCacheOnReturn = this.listReturnCache.startVisit('clients');
|
||||
const savedFilters = sessionStorage.getItem('client-list-last-filters');
|
||||
if (savedFilters) {
|
||||
try {
|
||||
this.lastFiltersQuery = JSON.parse(savedFilters);
|
||||
} catch (_err) {
|
||||
this.lastFiltersQuery = undefined;
|
||||
}
|
||||
}
|
||||
this.store.dispatch(savedFilters
|
||||
? new clientActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
|
||||
: new clientActions.Fetch({ useCache: this.useCacheOnReturn })
|
||||
);
|
||||
}
|
||||
|
||||
onAccordionToggle(expanded: boolean) {
|
||||
sessionStorage.setItem('client-list-accordion', String(expanded));
|
||||
}
|
||||
|
||||
updateCacheTtl(): void {
|
||||
const ttlMs = this.clientCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
|
||||
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
|
||||
}
|
||||
|
||||
onFiltersSubmit(event: FilterChangeEvent) {
|
||||
const q = { ...event.query };
|
||||
const filtersStr = JSON.stringify(q);
|
||||
const prevFilters = sessionStorage.getItem('client-list-last-filters');
|
||||
if (filtersStr !== prevFilters) {
|
||||
this.clientCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
}
|
||||
this.lastFiltersQuery = q;
|
||||
sessionStorage.setItem('client-list-last-filters', filtersStr);
|
||||
this.store.dispatch(new clientActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
|
||||
}
|
||||
|
||||
reloadClients() {
|
||||
this.clientCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
if (this.lastFiltersQuery) {
|
||||
this.store.dispatch(new clientActions.Fetch({ filters: JSON.stringify(this.lastFiltersQuery), useCache: false }));
|
||||
} else {
|
||||
this.store.dispatch(new clientActions.Fetch({ useCache: false }));
|
||||
}
|
||||
}
|
||||
|
||||
onRowSelect(event) {
|
||||
@ -74,6 +137,7 @@ export class ClientListComponent extends BaseComp implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
editClient() {
|
||||
this.listReturnCache.markPending('clients');
|
||||
this.router.navigate(['client', this.currClient._id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import { DropdownModule } from 'primeng/dropdown';
|
||||
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
@ -28,7 +29,7 @@ import { AppSharedModule } from '../shared/app-shared.module';
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule, TableModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule, InputTextModule,
|
||||
CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AppSharedModule,
|
||||
CheckboxModule, ToolbarModule, ButtonModule, DropdownModule, AccordionModule, AppSharedModule,
|
||||
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
|
||||
EffectsModule.forFeature([ClientEffects]),
|
||||
ClientsRoutingModule
|
||||
|
||||
@ -10,6 +10,7 @@ import { ClientService } from '@app/domain/services/client.service';
|
||||
import { AuthService } from '@app/domain/services/auth.service';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { ClientCacheService } from '@app/domain/services/client-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClientEffects {
|
||||
@ -17,15 +18,20 @@ export class ClientEffects {
|
||||
private readonly actions$: Actions,
|
||||
private readonly clientSvc: ClientService,
|
||||
private readonly authSvc: AuthService,
|
||||
private readonly msgSvc: AppMessageService
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly clientCache: ClientCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
@Effect()
|
||||
loadClients$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<clientActions.Fetch>(clientActions.FETCH),
|
||||
switchMap(() =>
|
||||
this.clientSvc.loadClients({ byPuid: this.authSvc.user.parent }).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.loadClients({
|
||||
byPuid: this.authSvc.user.parent,
|
||||
useCache: payload?.useCache,
|
||||
...(payload?.filters ? { filters: payload.filters } : {})
|
||||
}).pipe(
|
||||
map(clients => new clientActions.FetchSuccess(clients)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.clients));
|
||||
@ -40,7 +46,10 @@ export class ClientEffects {
|
||||
ofType<clientActions.Create>(clientActions.CREATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.saveClient(payload).pipe(
|
||||
map((client) => new clientActions.CreateSuccess(client)),
|
||||
map((client) => {
|
||||
this.clientCache.invalidate();
|
||||
return new clientActions.CreateSuccess(client);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.client));
|
||||
return of(new clientActions.CreateFailed())
|
||||
@ -54,7 +63,9 @@ export class ClientEffects {
|
||||
ofType<clientActions.Update>(clientActions.UPDATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.saveClient(payload).pipe(
|
||||
map(() => new clientActions.UpdateSuccess(payload)),
|
||||
map(() => {
|
||||
return new clientActions.UpdateSuccess(payload);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.client));
|
||||
return of(new clientActions.UpdateFailed());
|
||||
@ -68,7 +79,10 @@ export class ClientEffects {
|
||||
ofType<clientActions.Delete>(clientActions.DELETE),
|
||||
switchMap(({ payload }) =>
|
||||
this.clientSvc.deleteClient(payload).pipe(
|
||||
map(() => new clientActions.DeleteSuccess(payload)),
|
||||
map(() => {
|
||||
this.clientCache.invalidate();
|
||||
return new clientActions.DeleteSuccess(payload);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.client));
|
||||
return of(new clientActions.UpdateFailed())
|
||||
|
||||
@ -4,6 +4,7 @@ import { Customer } from "../models/customer.model";
|
||||
export const FETCH = '[CUSTOMERS] Fetch customers';
|
||||
export class Fetch implements Action {
|
||||
type: typeof FETCH = FETCH;
|
||||
constructor(readonly payload?: { filters?: string; useCache?: boolean }) {}
|
||||
}
|
||||
|
||||
export const FETCH_SUCCESS = '[CUSTOMERS] Fetch customers success';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,25 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card">
|
||||
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||
<p-accordionTab i18n-header="@@searchCustomers" header="Search Customers" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
|
||||
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
|
||||
<agm-dynamic-filter [filterDefinitions]="customerFilterDefinitions" [locale]="locale" stateKey="customers-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
<p-table #dt [value]="customers" [columns]="cols" selectionMode="single" (onRowSelect)="onRowSelect($event)" [paginator]="true" [rows]="15" [pageLinks]="5" [rowsPerPageOptions]="[10, 15, 30]" [alwaysShowPaginator]="true" [(selection)]="curCust" dataKey="_id" [resetPageOnSort]="false" stateStorage="session" stateKey="ctb-ops" [responsive]="true">
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="ui-g ui-g-nopad">
|
||||
<div class="ui-g-6 cc-field-label">
|
||||
<span class="table-caption-1" i18n="@@customerList">Customer List</span>
|
||||
<div class="ui-g ui-g-nopad cache-ttl-caption">
|
||||
<div class="ui-g-6 cc-field-label cache-ttl-caption-title">
|
||||
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@customerList">Customer List</span>
|
||||
</div>
|
||||
<div class="ui-g-6 cc-field-label">
|
||||
<div class="ui-g-6 cc-field-label cache-ttl-caption-controls">
|
||||
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
|
||||
(blur)="updateCacheTtl()" style="width: 3.5rem; margin-right: 8px;">
|
||||
<span class="cache-ttl-help" tabindex="0">
|
||||
<span class="cache-ttl-help-icon">?</span>
|
||||
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
|
||||
</span>
|
||||
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label>
|
||||
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
|
||||
</div>
|
||||
@ -34,14 +46,6 @@
|
||||
<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 === '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>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -10,6 +10,9 @@ import * as customerActions from '../actions/customer.actions';
|
||||
import { globals, OperationalStatus } from '@app/shared/global';
|
||||
|
||||
import { BaseComp } from '@app/shared/base/base.component';
|
||||
import { CustomerCacheService } from '@app/domain/services/customer-cache.service';
|
||||
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
|
||||
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-customer-list',
|
||||
@ -34,11 +37,19 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
totalItems;
|
||||
isSelfSignup = false;
|
||||
|
||||
searchAccordionOpen = sessionStorage.getItem('customers-list-accordion') === 'true';
|
||||
private lastFiltersQuery: Record<string, any> | undefined;
|
||||
private useCacheOnReturn = false;
|
||||
cacheTtlSeconds: number;
|
||||
customerFilterDefinitions: FilterDefinition[];
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
|
||||
private readonly customerCache: CustomerCacheService,
|
||||
private readonly listReturnCache: ListReturnCacheService,
|
||||
) {
|
||||
super();
|
||||
this.cacheTtlSeconds = Math.round(this.customerCache.getTtlMs() / 1000);
|
||||
this.totalItems = { '=0': '', '=1': '1 ' + $localize`:@@customer:customer`.toLocaleLowerCase(), 'other': $localize`:@@total#Customers:Total: # customers` };
|
||||
|
||||
this.statuses = [
|
||||
@ -56,6 +67,14 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
{ field: this.ACTIVE, header: globals.active, width: '9%' },
|
||||
{ field: this.PARTNER_NAME, header: globals.partner, width: '9%' }
|
||||
];
|
||||
|
||||
this.customerFilterDefinitions = [
|
||||
{ key: 'name', label: globals.name, dataType: 'text' },
|
||||
{ key: 'username', label: globals.userName, dataType: 'text' },
|
||||
{ key: 'email', label: globals.email, dataType: 'text' },
|
||||
{ key: 'contact', label: globals.contact, dataType: 'text' },
|
||||
{ key: 'createdAt', label: globals.from, dataType: 'date-preset' },
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@ -70,7 +89,19 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
this.curCust = cust;
|
||||
}));
|
||||
|
||||
this.store.dispatch(new customerActions.Fetch());
|
||||
this.useCacheOnReturn = this.listReturnCache.startVisit('customers');
|
||||
const savedFilters = sessionStorage.getItem('customers-list-last-filters');
|
||||
if (savedFilters) {
|
||||
try {
|
||||
this.lastFiltersQuery = JSON.parse(savedFilters);
|
||||
} catch (_err) {
|
||||
this.lastFiltersQuery = undefined;
|
||||
}
|
||||
}
|
||||
this.store.dispatch(savedFilters
|
||||
? new customerActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
|
||||
: new customerActions.Fetch({ useCache: this.useCacheOnReturn })
|
||||
);
|
||||
}
|
||||
|
||||
private setCustomersAndPartners(customers: Customer[]) {
|
||||
@ -101,6 +132,28 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
this.store.dispatch(new customerActions.Select(event.data));
|
||||
}
|
||||
|
||||
onAccordionToggle(expanded: boolean) {
|
||||
sessionStorage.setItem('customers-list-accordion', String(expanded));
|
||||
}
|
||||
|
||||
updateCacheTtl(): void {
|
||||
const ttlMs = this.customerCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
|
||||
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
|
||||
}
|
||||
|
||||
onFiltersSubmit(event: FilterChangeEvent) {
|
||||
const q = { ...event.query };
|
||||
const filtersStr = JSON.stringify(q);
|
||||
const prevFilters = sessionStorage.getItem('customers-list-last-filters');
|
||||
if (filtersStr !== prevFilters) {
|
||||
this.customerCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
}
|
||||
this.lastFiltersQuery = q;
|
||||
sessionStorage.setItem('customers-list-last-filters', filtersStr);
|
||||
this.store.dispatch(new customerActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
return (this.curCust && this.curCust._id !== '0');
|
||||
}
|
||||
@ -110,6 +163,7 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
}
|
||||
|
||||
editCustomer() {
|
||||
this.listReturnCache.markPending('customers');
|
||||
this.router.navigate(['customer', this.curCust._id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import { MessageModule } from 'primeng/message';
|
||||
import { TableModule } from 'primeng/table';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
import { MessagesModule } from 'primeng/messages';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
|
||||
import { AppSharedModule } from '../shared/app-shared.module';
|
||||
import { ApiKeySharedModule } from '../settings/api-keys/api-key-shared.module';
|
||||
@ -43,6 +44,7 @@ import { TrialComponent } from './trial/trial.component';
|
||||
TableModule,
|
||||
AppSharedModule,
|
||||
ApiKeySharedModule,
|
||||
AccordionModule,
|
||||
|
||||
StoreModule.forFeature(fromCustomers.FEATURE_KEY, fromCustomers.reducer),
|
||||
EffectsModule.forFeature([CustomerEffects]),
|
||||
|
||||
@ -9,21 +9,23 @@ import * as customerActions from '../actions/customer.actions';
|
||||
import { CustomerService } from '@app/domain/services/customer.service';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { CustomerCacheService } from '@app/domain/services/customer-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerEffects {
|
||||
constructor(
|
||||
private readonly actions$: Actions,
|
||||
private readonly customerSvc: CustomerService,
|
||||
private readonly msgSvc: AppMessageService
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly customerCache: CustomerCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
@Effect()
|
||||
loadCustomers$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<customerActions.Fetch>(customerActions.FETCH),
|
||||
switchMap(() =>
|
||||
this.customerSvc.loadCustomers().pipe(
|
||||
switchMap(({ payload }) =>
|
||||
this.customerSvc.loadCustomers(payload?.filters, payload?.useCache).pipe(
|
||||
map(customers => new customerActions.FetchSuccess(customers)),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.load).replace('#thing#', globals.customers));
|
||||
@ -38,7 +40,10 @@ export class CustomerEffects {
|
||||
ofType<customerActions.Create>(customerActions.CREATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.customerSvc.saveCustomer(payload).pipe(
|
||||
map((customer) => new customerActions.CreateSuccess(customer)),
|
||||
map((customer) => {
|
||||
this.customerCache.invalidate();
|
||||
return new customerActions.CreateSuccess(customer);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', globals.customer));
|
||||
return of(new customerActions.CreateFailed())
|
||||
@ -52,7 +57,9 @@ export class CustomerEffects {
|
||||
ofType<customerActions.Update>(customerActions.UPDATE),
|
||||
switchMap(({ payload }) =>
|
||||
this.customerSvc.saveCustomer(payload).pipe(
|
||||
map(() => new customerActions.UpdateSuccess(payload)),
|
||||
map(() => {
|
||||
return new customerActions.UpdateSuccess(payload);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.customer));
|
||||
return of(new customerActions.UpdateFailed());
|
||||
@ -66,7 +73,10 @@ export class CustomerEffects {
|
||||
ofType<customerActions.Delete>(customerActions.DELETE),
|
||||
switchMap(({ payload }) =>
|
||||
this.customerSvc.deleteCustomer(payload).pipe(
|
||||
map(() => new customerActions.DeleteSuccess(payload)),
|
||||
map(() => {
|
||||
this.customerCache.invalidate();
|
||||
return new customerActions.DeleteSuccess(payload);
|
||||
}),
|
||||
catchError(err => {
|
||||
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.delete).replace('#thing#', globals.customer));
|
||||
return of(new customerActions.UpdateFailed())
|
||||
|
||||
@ -24,6 +24,7 @@ export interface IAppConfig {
|
||||
|
||||
noPopup: boolean;
|
||||
trialDays: [number];
|
||||
browserListCacheTtlMs?: number;
|
||||
/** Grace-period days for promo Valid Until (sysadmin only). From PROMO_MIN_EXPIRY_DAYS env. */
|
||||
promoMinExpiryDays?: number;
|
||||
}
|
||||
|
||||
@ -130,6 +130,8 @@ export class AppConfigService {
|
||||
}
|
||||
if (Utils.isNulOrUndef(settings['matType']))
|
||||
settings['matType'] = MatType.LIQUID;
|
||||
if (Utils.isNulOrUndef(settings['browserListCacheTtlMs']))
|
||||
settings['browserListCacheTtlMs'] = 60 * 1000;
|
||||
|
||||
this.settings = settings;
|
||||
this.wasSetDefault = true;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { from, Observable, of } from 'rxjs';
|
||||
import { catchError, switchMap } from 'rxjs/operators';
|
||||
import { AppConfigService } from './app-config.service';
|
||||
|
||||
/** Shape of every entry stored in Cache Storage. */
|
||||
export interface BrowserCacheEntry<T> {
|
||||
@ -34,6 +35,37 @@ export interface BrowserCacheEntry<T> {
|
||||
export class BrowserCacheService {
|
||||
|
||||
private readonly supported = typeof caches !== 'undefined';
|
||||
private readonly fallbackMaxAgeMs = 60_000;
|
||||
|
||||
constructor(private readonly appConfig: AppConfigService) {}
|
||||
|
||||
private ttlStorageKey(cacheName: string): string {
|
||||
return `browser-cache-ttl:${cacheName}`;
|
||||
}
|
||||
|
||||
private get defaultMaxAgeMs(): number {
|
||||
return this.appConfig.settings?.browserListCacheTtlMs || this.fallbackMaxAgeMs;
|
||||
}
|
||||
|
||||
getTtl(cacheName: string): number {
|
||||
const storedValue = localStorage.getItem(this.ttlStorageKey(cacheName));
|
||||
if (storedValue === null) {
|
||||
return this.defaultMaxAgeMs;
|
||||
}
|
||||
|
||||
const parsedValue = Number(storedValue);
|
||||
return Number.isFinite(parsedValue) && parsedValue >= 0
|
||||
? parsedValue
|
||||
: this.defaultMaxAgeMs;
|
||||
}
|
||||
|
||||
setTtl(cacheName: string, ttlMs: number): number {
|
||||
const normalizedValue = Number.isFinite(ttlMs) && ttlMs >= 0
|
||||
? Math.floor(ttlMs)
|
||||
: this.defaultMaxAgeMs;
|
||||
localStorage.setItem(this.ttlStorageKey(cacheName), String(normalizedValue));
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the pseudo-URL used as the cache key inside a named Cache bucket.
|
||||
@ -49,12 +81,14 @@ export class BrowserCacheService {
|
||||
* @param cacheName Name of the Cache Storage bucket (e.g. `'agm-jobs-list-v1'`).
|
||||
* @param key Entry key — typically serialised query params.
|
||||
* @param maxAgeMs Maximum age in milliseconds before the entry is treated as stale.
|
||||
* Defaults to 60 000 (1 minute).
|
||||
* Defaults to `appConfig.browserListCacheTtlMs` or 60 000.
|
||||
* @returns The cached value, or `null` when unavailable / stale / missing.
|
||||
*/
|
||||
get<T>(cacheName: string, key: string, maxAgeMs: number = 60_000): Observable<T | null> {
|
||||
get<T>(cacheName: string, key: string, maxAgeMs?: number): Observable<T | null> {
|
||||
if (!this.supported) return of(null);
|
||||
|
||||
const effectiveMaxAgeMs = maxAgeMs ?? this.getTtl(cacheName);
|
||||
|
||||
return from(caches.open(cacheName)).pipe(
|
||||
switchMap(cache => from(cache.match(this.entryUrl(cacheName, key)))),
|
||||
switchMap(response => {
|
||||
@ -63,7 +97,7 @@ export class BrowserCacheService {
|
||||
}),
|
||||
switchMap((entry: BrowserCacheEntry<T> | 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);
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Client } from '../../client/models/client.model';
|
||||
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
||||
import { ClientCacheService } from './client-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClientService {
|
||||
@ -14,12 +16,35 @@ export class ClientService {
|
||||
|
||||
constructor(
|
||||
private store: Store<{}>,
|
||||
private http: HttpClient
|
||||
private http: HttpClient,
|
||||
private readonly clientCache: ClientCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
loadClients(options?: LoadClientOps): Observable<Client[]> {
|
||||
return this.http.post<Client[]>(this.clientURL + '/search', options);
|
||||
const cacheKey = JSON.stringify({
|
||||
byPuid: options?.byPuid,
|
||||
filters: options?.filters || ''
|
||||
});
|
||||
const requestBody = {
|
||||
byPuid: options?.byPuid,
|
||||
...(options?.filters ? { filters: options.filters } : {})
|
||||
};
|
||||
|
||||
if (options?.useCache) {
|
||||
return this.clientCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) return of(cached as Client[]);
|
||||
return this.http.post<Client[]>(this.clientURL + '/search', requestBody).pipe(
|
||||
tap(data => this.clientCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.post<Client[]>(this.clientURL + '/search', requestBody).pipe(
|
||||
tap(data => this.clientCache.put(cacheKey, data))
|
||||
);
|
||||
}
|
||||
|
||||
getClient(id: string): Observable<Client> {
|
||||
@ -53,6 +78,8 @@ export class ClientService {
|
||||
|
||||
export interface LoadClientOps {
|
||||
byPuid: string;
|
||||
filters?: string;
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
export interface ClientWithSetting extends Client {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { Customer } from '../../customers/models/customer.model';
|
||||
import { CustomerCacheService } from './customer-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerService {
|
||||
@ -9,12 +11,30 @@ export class CustomerService {
|
||||
private readonly customerURL = '/customers';
|
||||
|
||||
constructor(
|
||||
private http: HttpClient
|
||||
private http: HttpClient,
|
||||
private readonly customerCache: CustomerCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
loadCustomers(): Observable<Customer[]> {
|
||||
return this.http.get<Customer[]>(this.customerURL);
|
||||
loadCustomers(filters?: string, useCache: boolean = false): Observable<Customer[]> {
|
||||
const cacheKey = filters || '';
|
||||
const params: any = {};
|
||||
if (filters) params.filters = filters;
|
||||
|
||||
if (useCache) {
|
||||
return this.customerCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) return of(cached as Customer[]);
|
||||
return this.http.get<Customer[]>(this.customerURL, { params }).pipe(
|
||||
tap(data => this.customerCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<Customer[]>(this.customerURL, { params }).pipe(
|
||||
tap(data => this.customerCache.put(cacheKey, data))
|
||||
);
|
||||
}
|
||||
|
||||
getCustomer(id: string, view?: string): Observable<Customer> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,11 @@ import { Observable, of } from 'rxjs';
|
||||
import { Client, Invoice } from '@app/invoices/models/invoice.model';
|
||||
import { CostingItem } from '@app/invoices/models/costing-item.model';
|
||||
import { CustomerInvoiceSetting } from '@app/invoices/models/customer-invoice-setting.model';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { catchError, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { AppMessageService } from '@app/shared/app-message.service';
|
||||
import { RouterUtilsService } from '@app/shared/router-utils.service';
|
||||
import { Utils } from '@app/shared/utils';
|
||||
import { InvoiceCacheService } from './invoice-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceService {
|
||||
@ -28,7 +29,8 @@ export class InvoiceService {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private readonly appMsgSvc: AppMessageService,
|
||||
private readonly routerUtils: RouterUtilsService
|
||||
private readonly routerUtils: RouterUtilsService,
|
||||
private readonly invoiceCache: InvoiceCacheService
|
||||
) { }
|
||||
|
||||
// Setting
|
||||
@ -71,8 +73,25 @@ export class InvoiceService {
|
||||
}
|
||||
|
||||
// Invoice
|
||||
getInvoices(): Observable<Invoice[]> {
|
||||
return this.http.get<Invoice[]>(this.invoiceURL);
|
||||
getInvoices(filters?: string, useCache: boolean = false): Observable<Invoice[]> {
|
||||
const cacheKey = filters || '';
|
||||
const params: any = {};
|
||||
if (filters) params.filters = filters;
|
||||
|
||||
if (useCache) {
|
||||
return this.invoiceCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) return of(cached as Invoice[]);
|
||||
return this.http.get<Invoice[]>(this.invoiceURL, { params }).pipe(
|
||||
tap(data => this.invoiceCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<Invoice[]>(this.invoiceURL, { params }).pipe(
|
||||
tap(data => this.invoiceCache.put(cacheKey, data))
|
||||
);
|
||||
}
|
||||
|
||||
getInvoiceById(id): Observable<Invoice> {
|
||||
|
||||
@ -3,7 +3,6 @@ import { Observable } from 'rxjs';
|
||||
import { BrowserCacheService } from './browser-cache.service';
|
||||
|
||||
const CACHE_NAME = 'agm-jobs-list-v1';
|
||||
const MAX_AGE_MS = 60_000; // 1 minute
|
||||
|
||||
/**
|
||||
* Jobs-list-specific facade over {@link BrowserCacheService}.
|
||||
@ -16,9 +15,17 @@ export class JobCacheService {
|
||||
|
||||
constructor(private readonly browserCache: BrowserCacheService) {}
|
||||
|
||||
getTtlMs(): number {
|
||||
return this.browserCache.getTtl(CACHE_NAME);
|
||||
}
|
||||
|
||||
setTtlMs(ttlMs: number): number {
|
||||
return this.browserCache.setTtl(CACHE_NAME, ttlMs);
|
||||
}
|
||||
|
||||
/** Return cached jobs for the given query-param string, or null if stale/missing. */
|
||||
get(queryParams: string): Observable<any[] | null> {
|
||||
return this.browserCache.get<any[]>(CACHE_NAME, queryParams, MAX_AGE_MS);
|
||||
return this.browserCache.get<any[]>(CACHE_NAME, queryParams);
|
||||
}
|
||||
|
||||
/** Store a fresh jobs list for the given query-param string. */
|
||||
|
||||
@ -45,18 +45,24 @@ export class JobService {
|
||||
|
||||
const cacheKey = _ops.toString();
|
||||
|
||||
return this.jobCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) {
|
||||
return new Observable<IJob[]>(observer => {
|
||||
observer.next(cached as IJob[]);
|
||||
observer.complete();
|
||||
});
|
||||
}
|
||||
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
|
||||
tap(data => this.jobCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
if (ops?.useCache) {
|
||||
return this.jobCache.get(cacheKey).pipe(
|
||||
switchMap(cached => {
|
||||
if (cached !== null) {
|
||||
return new Observable<IJob[]>(observer => {
|
||||
observer.next(cached as IJob[]);
|
||||
observer.complete();
|
||||
});
|
||||
}
|
||||
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
|
||||
tap(data => this.jobCache.put(cacheKey, data))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
|
||||
tap(data => this.jobCache.put(cacheKey, data))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,18 @@
|
||||
<i class="ui-icon-search"></i>
|
||||
<input pInputText type="text" (input)="dt.filter($event.target.value, col.field, col.filterMatchMode)" [value]="dt.filters[col.field]?.value">
|
||||
</div>
|
||||
<p-dropdown *ngIf="col.field === 'color'" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value" (onChange)="dt.filter($event.value, 'color', 'equals')"></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'">
|
||||
<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">
|
||||
@ -70,9 +81,9 @@
|
||||
<span style="vertical-align:middle; margin-left: .5em">{{item.label}}</span>
|
||||
</ng-template>
|
||||
<ng-template let-item pTemplate="item">
|
||||
<div class="ui-helper-clearfix" style="position:relative;">
|
||||
<div class="color-box" style="margin-left:3px" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||
<div style="float:right; margin-right: .15em;">{{item.label}}</div>
|
||||
<div style="display:flex; align-items:center; justify-content:center; gap:.5em;">
|
||||
<div class="color-box" [ngStyle]="{ 'background-color': item.value }"></div>
|
||||
<span>{{item.label}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dropdown>
|
||||
|
||||
@ -62,12 +62,21 @@
|
||||
(onChange)="dt.filter($event.value, VEHICLE_TYPE, 'equals')"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === ACTIVE" [options]="activeOpts" [ngModel]="dt.filters[col.field]?.value"
|
||||
(onChange)="dt.filter($event.value, ACTIVE, 'equals')"></p-dropdown>
|
||||
<div class="input-with-icon" *ngIf="col.field === SOURCE_SYSTEM">
|
||||
<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>
|
||||
<p-dropdown *ngIf="col.field === SOURCE_SYSTEM" [options]="sourceSystemOpts" [ngModel]="dt.filters[col.field]?.value"
|
||||
(onChange)="dt.filter($event.value, SOURCE_SYSTEM, 'equals')"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === COLOR" [options]="colorFilterOpts" [ngModel]="dt.filters[col.field]?.value"
|
||||
(onChange)="dt.filter($event.value, COLOR, 'equals')"></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">
|
||||
<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">
|
||||
|
||||
@ -151,6 +151,20 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
|
||||
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() {
|
||||
this.user = this.route.snapshot.data['user'];
|
||||
this.clearNeedReview();
|
||||
|
||||
@ -5,6 +5,7 @@ export const FETCH = '[INVOICES] Fetch invoices';
|
||||
|
||||
export class Fetch implements Action {
|
||||
type: typeof FETCH = FETCH;
|
||||
constructor(readonly payload?: { filters?: string; useCache?: boolean }) {}
|
||||
}
|
||||
|
||||
export const FETCH_SUCCESS = '[INVOICES] Fetch invoices success';
|
||||
|
||||
@ -24,10 +24,6 @@
|
||||
</div>
|
||||
<p-dropdown *ngIf="col.field === 'type'" [options]="costingItemTypeOpt" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'type', 'equals')"></p-dropdown>
|
||||
<p-dropdown *ngIf="col.field === 'unit'" [options]="unitFilterOpts" [ngModel]="ci.filters[col.field]?.value" (onChange)="ci.filter($event.value, 'unit', 'equals')"></p-dropdown>
|
||||
<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>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -7,13 +7,15 @@ import { Action } from '@ngrx/store';
|
||||
import * as invoiceActions from '../actions/invoice.actions';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
import { globals } from '@app/shared/global';
|
||||
import { InvoiceCacheService } from '@app/domain/services/invoice-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceEffects {
|
||||
constructor(
|
||||
private readonly actions$: Actions,
|
||||
private readonly invoiceSvc: InvoiceService,
|
||||
private readonly msgSvc: AppMessageService
|
||||
private readonly msgSvc: AppMessageService,
|
||||
private readonly invoiceCache: InvoiceCacheService
|
||||
) {
|
||||
}
|
||||
|
||||
@ -24,6 +26,7 @@ export class InvoiceEffects {
|
||||
const isNew = true;
|
||||
return this.invoiceSvc.saveInvoice(payload, isNew).pipe(
|
||||
map(invoice => {
|
||||
this.invoiceCache.invalidate();
|
||||
this.msgSvc.addSuccessMsg(globals.doThingsSuccess.replace('#do#', globals.create).replace('#thing#', globals.invoice));
|
||||
return new invoiceActions.CreateSuccess(invoice);
|
||||
}),
|
||||
@ -56,8 +59,8 @@ export class InvoiceEffects {
|
||||
@Effect()
|
||||
loadInvoice$: Observable<Action> = this.actions$.pipe(
|
||||
ofType<invoiceActions.Fetch>(invoiceActions.FETCH),
|
||||
switchMap(() => {
|
||||
return this.invoiceSvc.getInvoices().pipe(
|
||||
switchMap(({ payload }) => {
|
||||
return this.invoiceSvc.getInvoices(payload?.filters, payload?.useCache).pipe(
|
||||
map(res => {
|
||||
return new invoiceActions.FetchSuccess(res);
|
||||
}),
|
||||
@ -75,6 +78,7 @@ export class InvoiceEffects {
|
||||
switchMap(({ payload }) => {
|
||||
return this.invoiceSvc.deleteInvoice(payload).pipe(
|
||||
map((res: any[]) => {
|
||||
this.invoiceCache.invalidate();
|
||||
return new invoiceActions.DeleteSuccess(res?.map(i => i._id));
|
||||
}),
|
||||
catchError(err => {
|
||||
|
||||
@ -11,3 +11,87 @@
|
||||
border: 1px solid #4caf50;
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.cache-ttl-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.cache-ttl-help {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cache-ttl-help-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
cursor: help;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cache-ttl-help-text {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 220px;
|
||||
white-space: normal;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: #323232;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
line-height: 1.35;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.cache-ttl-help:hover .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus .cache-ttl-help-text,
|
||||
.cache-ttl-help:focus-within .cache-ttl-help-text {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cache-ttl-caption-title,
|
||||
.cache-ttl-caption-controls {
|
||||
width: auto;
|
||||
float: none;
|
||||
}
|
||||
|
||||
.cache-ttl-caption-controls {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,25 @@
|
||||
<div class="ui-g">
|
||||
<div class="ui-g-12">
|
||||
<div class="card clearfix">
|
||||
<p-accordion styleClass="agm-accordion" [style]="{'display':'block', 'margin-bottom':'0.75rem'}">
|
||||
<p-accordionTab i18n-header="@@searchInvoices" header="Search Invoices" [transitionOptions]="'250ms'" [selected]="searchAccordionOpen"
|
||||
(selectedChange)="searchAccordionOpen = $event; onAccordionToggle($event)">
|
||||
<agm-dynamic-filter [filterDefinitions]="invoiceFilterDefinitions" [locale]="locale" stateKey="invoices-list-filters" (filtersSubmit)="onFiltersSubmit($event)"></agm-dynamic-filter>
|
||||
</p-accordionTab>
|
||||
</p-accordion>
|
||||
<p-table #il [value]="invoices" [columns]="cols" (firstChange)="restoreTableFirst()" (onPage)="onPageChange($event)" (onFilter)="restoreTableFirst()" selectionMode="multiple" (onRowSelect)="onSelectInvoice($event)" (onRowUnselect)="onUnselectInvoice($event)" [paginator]="true" [rows]="rows1Page[0]" [pageLinks]="5" [rowsPerPageOptions]="rows1Page" [alwaysShowPaginator]="true" stateStorage="session" stateKey="inv-ops" dataKey="_id" mutable="false" [responsive]="true" [resetPageOnSort]="false" [(selection)]="selectedInvoice">
|
||||
<ng-template pTemplate="caption">
|
||||
<div class="ui-g ui-g-nopad">
|
||||
<div class="ui-g-12 ui-g-nopad text-center">
|
||||
<span class="table-caption-1" style="line-height: 1.35em;" i18n="@@invoiceList">Invoice List</span>
|
||||
<div class="ui-g ui-g-nopad cache-ttl-caption">
|
||||
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-title">
|
||||
<span class="table-caption-1" style="display:block; text-align:left;" i18n="@@invoiceList">Invoice List</span>
|
||||
</div>
|
||||
<div class="ui-g-6 ui-sm-12 cache-ttl-caption-controls">
|
||||
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
|
||||
(blur)="updateCacheTtl()" style="width: 3.5rem;">
|
||||
<span class="cache-ttl-help" tabindex="0">
|
||||
<span class="cache-ttl-help-icon">?</span>
|
||||
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
@ -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 #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>
|
||||
<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>
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@ -16,6 +16,9 @@ import { FilterUtils } from 'primeng/utils';
|
||||
import { DateUtils, Utils } from '@app/shared/utils';
|
||||
import { RestoreTableState } from '@app/shared/restore-table-state';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
import { InvoiceCacheService } from '@app/domain/services/invoice-cache.service';
|
||||
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
|
||||
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'agm-invoices-list',
|
||||
@ -43,13 +46,23 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
readonly invoiceStatus = invoiceStatus;
|
||||
|
||||
searchAccordionOpen = sessionStorage.getItem('invoices-list-accordion') === 'true';
|
||||
private lastFiltersQuery: Record<string, any> | undefined;
|
||||
private useCacheOnReturn = false;
|
||||
cacheTtlSeconds: number;
|
||||
|
||||
invoiceFilterDefinitions: FilterDefinition[];
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly datePipe: DatePipe,
|
||||
private readonly invoiceSvc: InvoiceService,
|
||||
private readonly restoreTableSvc: RestoreTableState
|
||||
private readonly restoreTableSvc: RestoreTableState,
|
||||
private readonly invoiceCache: InvoiceCacheService,
|
||||
private readonly listReturnCache: ListReturnCacheService
|
||||
) {
|
||||
super();
|
||||
this.cacheTtlSeconds = Math.round(this.invoiceCache.getTtlMs() / 1000);
|
||||
this.totalInvoices = {
|
||||
'=0': '',
|
||||
'=1': $localize`:@@total#invoice:Total: # invoice`,
|
||||
@ -74,6 +87,14 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
];
|
||||
|
||||
this.statusFilter = [];
|
||||
|
||||
this.invoiceFilterDefinitions = [
|
||||
{ key: 'code', label: $localize`:@@invoiceNumber:Invoice Number`, dataType: 'text' },
|
||||
{ key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi', options: this.status },
|
||||
{ key: 'openDate', label: $localize`:@@openDate:Open Date`, dataType: 'date' },
|
||||
{ key: 'dueDate', label: $localize`:@@dueDate:Due Date`, dataType: 'date' },
|
||||
{ key: 'createdAt', label: $localize`:@@createdAt:Created Date`, dataType: 'date-preset' },
|
||||
];
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -97,7 +118,19 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
});
|
||||
}
|
||||
});
|
||||
this.store.dispatch(new invoiceActions.Fetch());
|
||||
this.useCacheOnReturn = this.listReturnCache.startVisit('invoices');
|
||||
const savedFilters = sessionStorage.getItem('invoices-list-last-filters');
|
||||
if (savedFilters) {
|
||||
try {
|
||||
this.lastFiltersQuery = JSON.parse(savedFilters);
|
||||
} catch (_err) {
|
||||
this.lastFiltersQuery = undefined;
|
||||
}
|
||||
}
|
||||
this.store.dispatch(savedFilters
|
||||
? new invoiceActions.Fetch({ filters: savedFilters, useCache: this.useCacheOnReturn })
|
||||
: new invoiceActions.Fetch({ useCache: this.useCacheOnReturn })
|
||||
);
|
||||
FilterUtils[this.openDateFilter] = (value, filter): boolean => {
|
||||
if (filter === undefined || filter === null) {
|
||||
return true;
|
||||
@ -180,6 +213,28 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
this.restoreTableSvc.restoreTableFirst(this.dt);
|
||||
}
|
||||
|
||||
onAccordionToggle(expanded: boolean) {
|
||||
sessionStorage.setItem('invoices-list-accordion', String(expanded));
|
||||
}
|
||||
|
||||
updateCacheTtl(): void {
|
||||
const ttlMs = this.invoiceCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
|
||||
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
|
||||
}
|
||||
|
||||
onFiltersSubmit(event: FilterChangeEvent) {
|
||||
const q = { ...event.query };
|
||||
const filtersStr = JSON.stringify(q);
|
||||
const prevFilters = sessionStorage.getItem('invoices-list-last-filters');
|
||||
if (filtersStr !== prevFilters) {
|
||||
this.invoiceCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
}
|
||||
this.lastFiltersQuery = q;
|
||||
sessionStorage.setItem('invoices-list-last-filters', filtersStr);
|
||||
this.store.dispatch(new invoiceActions.Fetch({ filters: filtersStr, useCache: this.useCacheOnReturn }));
|
||||
}
|
||||
|
||||
onPageChange(e) {
|
||||
this.restoreTableSvc.onPageChange(this.dt, e);
|
||||
}
|
||||
@ -208,6 +263,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
editInvoice(invoice: Invoice) {
|
||||
this.selectInvoice(invoice);
|
||||
this.listReturnCache.markPending('invoices');
|
||||
|
||||
// Track invoice selection
|
||||
this.gaSvc.trackInvoiceSelected({
|
||||
@ -225,6 +281,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
|
||||
viewInvoice(invoice: Invoice) {
|
||||
this.selectInvoice(invoice);
|
||||
this.listReturnCache.markPending('invoices');
|
||||
|
||||
// Track invoice selection
|
||||
this.gaSvc.trackInvoiceSelected({
|
||||
|
||||
@ -39,6 +39,7 @@ import { CurrencyNamePipe } from '@app/invoices/pipes/currency-name.pipe';
|
||||
import { ScrollPanelModule } from 'primeng/scrollpanel';
|
||||
import { CurrencyCodePositionPipe } from '@app/invoices/pipes/currency-code-position.pipe';
|
||||
import { InvoiceStatusPipe } from '@app/invoices/pipes/invoice-status.pipe';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@ -66,6 +67,7 @@ import { InvoiceStatusPipe } from '@app/invoices/pipes/invoice-status.pipe';
|
||||
EffectsModule.forFeature([SettingEffects, InvoiceEffects, CostingItemEffects, JobEffects]),
|
||||
PanelModule,
|
||||
ScrollPanelModule,
|
||||
AccordionModule,
|
||||
],
|
||||
declarations: [InvoicesListComponent, InvoicesMgtComponent, SettingsComponent, CustomerSettingsListComponent, CustomerSettingsComponent, InvoiceEditComponent, CostingItemComponent, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe, InvoiceDetailComponent],
|
||||
exports: [CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe, InvoiceStatusPipe],
|
||||
|
||||
@ -109,7 +109,6 @@ export class JobEffects {
|
||||
save_method: 'manual'
|
||||
});
|
||||
|
||||
this.jobCache.invalidate();
|
||||
return new jobActions.UpdateSuccess(updatedJob)
|
||||
}),
|
||||
catchError(err => {
|
||||
|
||||
@ -8,8 +8,76 @@
|
||||
.inline-flex-end {
|
||||
display: inline-flex;
|
||||
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 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cache-ttl-caption-controls {
|
||||
width: auto;
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
@ -101,8 +101,14 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<input pInputText type="number" min="0" step="1" placeholder="Cache TTL" [(ngModel)]="cacheTtlSeconds"
|
||||
(blur)="updateCacheTtl()" style="width: 3.5rem; margin-right: 6px;">
|
||||
<span class="cache-ttl-help" tabindex="0">
|
||||
<span class="cache-ttl-help-icon">?</span>
|
||||
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
|
||||
</span>
|
||||
<p-dropdown [options]="reloadOps" [style]="dropdownStyle" [(ngModel)]="reloadBy"
|
||||
(onChange)="reloadChanged($event.value)">
|
||||
</p-dropdown>
|
||||
|
||||
@ -29,6 +29,7 @@ import { InvoiceService } from '@app/domain/services/invoice.service';
|
||||
import { RestoreTableState } from '@app/shared/restore-table-state';
|
||||
import { GAService } from '@app/shared/ga.service';
|
||||
import { JobCacheService } from '@app/domain/services/job-cache.service';
|
||||
import { ListReturnCacheService } from '@app/domain/services/list-return-cache.service';
|
||||
import { FilterDefinition, FilterChangeEvent } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||||
|
||||
|
||||
@ -51,6 +52,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
|
||||
private currentByTime: string[] | undefined;
|
||||
private lastFiltersQuery: Record<string, any> | undefined;
|
||||
private useCacheOnReturn = false;
|
||||
cacheTtlSeconds: number;
|
||||
|
||||
jobFilterDefinitions: FilterDefinition[] = [];
|
||||
readonly defaultDynamicFilters = [
|
||||
@ -106,10 +109,12 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
private readonly invoiceSvc: InvoiceService,
|
||||
private readonly restoreTableSvc: RestoreTableState,
|
||||
private readonly gaService: GAService,
|
||||
private readonly jobCache: JobCacheService
|
||||
private readonly jobCache: JobCacheService,
|
||||
private readonly listReturnCache: ListReturnCacheService
|
||||
) {
|
||||
super();
|
||||
this.currClient = ({ label: globals.all, value: null });
|
||||
this.cacheTtlSeconds = Math.round(this.jobCache.getTtlMs() / 1000);
|
||||
this.totalJobs = { '=0': '', '=1': '1 ' + $localize`:@@job:job`.toLocaleLowerCase(), 'other': $localize`:@@total#Jobs:Total: # jobs` };
|
||||
|
||||
this.status = [
|
||||
@ -218,6 +223,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
this.acre = pkg[effectiveLookupKey]?.acre;
|
||||
}
|
||||
}));
|
||||
|
||||
this.useCacheOnReturn = this.listReturnCache.startVisit('jobs');
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@ -279,7 +286,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
clientId: clientId,
|
||||
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||
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));
|
||||
}
|
||||
|
||||
updateCacheTtl(): void {
|
||||
const ttlMs = this.jobCache.setTtlMs(Number(this.cacheTtlSeconds || 0) * 1000);
|
||||
this.cacheTtlSeconds = Math.round(ttlMs / 1000);
|
||||
}
|
||||
|
||||
restoreTableFirst() {
|
||||
this.restoreTableSvc.restoreTableFirst(this.dt);
|
||||
}
|
||||
@ -349,6 +362,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
|
||||
duplicateJob() {
|
||||
if (this.canAddNew) {
|
||||
this.listReturnCache.markPending('jobs');
|
||||
// Track bulk action (duplicate)
|
||||
this.gaService.trackJobBulkAction({
|
||||
user_id: this.authSvc.user?._id || 'anonymous',
|
||||
@ -365,10 +379,12 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
}
|
||||
|
||||
editJob() {
|
||||
this.listReturnCache.markPending('jobs');
|
||||
this.router.navigate([`./${this.currentJob._id}/edit`], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
editJobMap() {
|
||||
this.listReturnCache.markPending('jobs');
|
||||
this.router.navigate([`./${this.currentJob._id}/editMap`, { flag: 0 }], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
@ -387,11 +403,13 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
reloadJobs() {
|
||||
const startTime = performance.now();
|
||||
this.jobCache.invalidate();
|
||||
this.useCacheOnReturn = false;
|
||||
|
||||
if (this.lastFiltersQuery) {
|
||||
this.store.dispatch(new jobActions.Fetch({
|
||||
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||
filters: JSON.stringify(this.lastFiltersQuery)
|
||||
filters: JSON.stringify(this.lastFiltersQuery),
|
||||
useCache: false
|
||||
}));
|
||||
} else {
|
||||
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.filterClientLocked = !!clientId;
|
||||
|
||||
const filtersStr = JSON.stringify(q);
|
||||
const prevFilters = sessionStorage.getItem('job-list-last-filters');
|
||||
if (filtersStr !== prevFilters) {
|
||||
this.useCacheOnReturn = false;
|
||||
}
|
||||
|
||||
this.lastFiltersQuery = q;
|
||||
sessionStorage.setItem('job-list-last-filters', filtersStr);
|
||||
this.store.dispatch(new jobActions.Fetch({
|
||||
jobsByPilot: (this.authSvc.isPilotUser && this.settings.jobsByPilot),
|
||||
filters: JSON.stringify(q)
|
||||
filters: filtersStr,
|
||||
useCache: this.useCacheOnReturn
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -7,9 +7,19 @@ const Client = require('../model/client'),
|
||||
{ updateUser_put } = require('./user'), // Import user controller functions
|
||||
cache = require('../helpers/mem_cache'),
|
||||
utils = require('../helpers/utils'),
|
||||
{ buildDynamicFilter } = require('../helpers/dynamic_filter'),
|
||||
{ Errors, UserTypes } = require('../helpers/constants'),
|
||||
{ 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) {
|
||||
const _client = req.body;
|
||||
delete _client._id;
|
||||
@ -57,7 +67,15 @@ async function deleteClient(req, res) {
|
||||
async function search_post(req, res) {
|
||||
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' });
|
||||
|
||||
res.json(clients);
|
||||
|
||||
@ -11,13 +11,25 @@ const Customer = require('../model/customer'),
|
||||
{ AppParamError } = require('../helpers/app_error'),
|
||||
{ validateTrial } = require('../helpers/subscription_util'),
|
||||
moment = require('moment'),
|
||||
{ buildDynamicFilter } = require('../helpers/dynamic_filter'),
|
||||
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) {
|
||||
const filtersStr = req.query.filters || '';
|
||||
|
||||
const dynamicFilter = filtersStr ? buildDynamicFilter(filtersStr, CUSTOMER_FILTER_SCHEMA) : {};
|
||||
|
||||
const customers = await Customer.aggregate(
|
||||
[
|
||||
{ $match: { kind: UserTypes.APP, markedDelete: { $ne: true } } },
|
||||
{ $match: { kind: UserTypes.APP, markedDelete: { $ne: true }, ...dynamicFilter } },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'jobs', // Reference the Job collection
|
||||
@ -182,8 +194,7 @@ async function updateCustomer_put(req, res) {
|
||||
uiValue[1].premium = customer.premium;
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json(customer);
|
||||
} res.json(customer);
|
||||
}
|
||||
|
||||
async function deleteCustomer(req, res) {
|
||||
@ -201,7 +212,6 @@ async function deleteCustomer(req, res) {
|
||||
cache.delete(u[0]);
|
||||
}
|
||||
cache.delete(_id);
|
||||
|
||||
res.json({ message: 'deleted' });
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,16 @@ const
|
||||
mongoUtil = require('../helpers/mongo'),
|
||||
assert = require('assert'),
|
||||
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) {
|
||||
assert(!utils.isEmptyObj(invoice), AppInputError.create());
|
||||
@ -45,10 +54,16 @@ async function getInvoices_get(req, res) {
|
||||
|
||||
const isClientRole = req.ut === UserTypes.CLIENT;
|
||||
const userId = req.uid;
|
||||
const filtersStr = req.query.filters || '';
|
||||
|
||||
const filter = { byPuid: puid };
|
||||
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)
|
||||
.populate({ path: 'clients.billTo', select: 'name email -kind' })
|
||||
.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) {
|
||||
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] : []);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ module.exports = function (locals) {
|
||||
const
|
||||
async = require('async'),
|
||||
assert = require('assert'),
|
||||
crypto = require('crypto'),
|
||||
debug = require('debug')('agm:job'),
|
||||
ObjectId = require('mongodb').ObjectId,
|
||||
{ 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'),
|
||||
{ getFormattedAddress, getDocumentCountry } = require('../helpers/user_helper'),
|
||||
env = require('../helpers/env'),
|
||||
redisCache = require('../helpers/redis_cache'),
|
||||
partnerSyncService = require('../services/partner_sync_service'),
|
||||
taskQHelper = require('../helpers/job_queue').getInstance(),
|
||||
{ paginateWithCursor, validateCursorParams } = require('../helpers/cursor_pagination'),
|
||||
@ -50,37 +48,6 @@ module.exports = function (locals) {
|
||||
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.
|
||||
*
|
||||
@ -108,10 +75,6 @@ module.exports = function (locals) {
|
||||
|
||||
// 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);
|
||||
if (JOBS_CACHE_TTL) {
|
||||
const cached = await redisCache.get(buildJobsListCacheKey(userScope, req.query));
|
||||
if (cached) return res.json(cached);
|
||||
}
|
||||
|
||||
const filtersJson = req.query['filters'];
|
||||
let filter = { markedDelete: { $in: [null, false] } };
|
||||
@ -204,9 +167,6 @@ module.exports = function (locals) {
|
||||
];
|
||||
|
||||
const jobs = await Job.aggregate(pipeline);
|
||||
if (JOBS_CACHE_TTL) {
|
||||
redisCache.set(buildJobsListCacheKey(userScope, req.query), jobs, JOBS_CACHE_TTL).catch(() => {});
|
||||
}
|
||||
res.json(jobs);
|
||||
}
|
||||
|
||||
@ -258,7 +218,6 @@ module.exports = function (locals) {
|
||||
insertedJob.operator = _job.operator;
|
||||
insertedJob.vehicle = _job.vehicle;
|
||||
res.json(insertedJob);
|
||||
invalidateJobsListCache(req.userInfo?.puid);
|
||||
}
|
||||
|
||||
async function getJob_get(req, res) {
|
||||
@ -437,7 +396,6 @@ module.exports = function (locals) {
|
||||
}
|
||||
|
||||
res.json(retJob);
|
||||
invalidateJobsListCache(req.userInfo?.puid);
|
||||
}
|
||||
|
||||
async function deleteJob(req, res) {
|
||||
@ -445,7 +403,6 @@ module.exports = function (locals) {
|
||||
const puid = req.userInfo?.puid || job?.byPuid;
|
||||
if (job) await job.removeFull();
|
||||
res.json({ ok: true }).end();
|
||||
invalidateJobsListCache(puid);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -41,6 +41,7 @@ async function getAppConfig_get(req, res) {
|
||||
}
|
||||
|
||||
delete userSettings.userId;
|
||||
userSettings.browserListCacheTtlMs = env.BROWSER_LIST_CACHE_TTL_MS;
|
||||
if (isSysAdmin(userInfo.kind)) {
|
||||
if (utils.isEmptyArray(userSettings.trialDays)) userSettings.trialDays = DEFAULT_TRIAL_DAYS;
|
||||
userSettings.promoMinExpiryDays = env.PROMO_MIN_EXPIRY_DAYS;
|
||||
|
||||
@ -70,6 +70,7 @@ module.exports = {
|
||||
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
|
||||
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
|
||||
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'),
|
||||
|
||||
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_PORT: process.env.SMTP_PORT,
|
||||
|
||||
2227
Development/server/package-lock.json
generated
2227
Development/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -64,7 +64,7 @@
|
||||
"debug": "^4.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"email-templates": "11.0.3",
|
||||
"error-handler": "file:../../../@agn/error-handler",
|
||||
"error-handler": "file:../../../../@agn/error-handler",
|
||||
"exceljs": "^4.2.1",
|
||||
"express": "^4.18.1",
|
||||
"express-async-errors": "^3.1.1",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user