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">
|
[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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -4,3 +4,87 @@ Ref:https://stackoverflow.com/questions/48675497/how-to-disable-the-option-to-de
|
|||||||
tr.ui-state-highlight {
|
tr.ui-state-highlight {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cache-ttl-caption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ttl-caption-title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ttl-caption-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ttl-help {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ttl-help-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: help;
|
||||||
|
color: #fff;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ttl-help-text {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
width: 220px;
|
||||||
|
white-space: normal;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #323232;
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ttl-help:hover .cache-ttl-help-text,
|
||||||
|
.cache-ttl-help:focus .cache-ttl-help-text,
|
||||||
|
.cache-ttl-help:focus-within .cache-ttl-help-text {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cache-ttl-caption-title,
|
||||||
|
.cache-ttl-caption-controls {
|
||||||
|
width: auto;
|
||||||
|
float: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-ttl-caption-controls {
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,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>
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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">
|
||||||
<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>
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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]),
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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 { 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 {
|
||||||
|
|||||||
@ -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 { 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> {
|
||||||
|
|||||||
@ -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 { 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> {
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export class JobService {
|
|||||||
|
|
||||||
const cacheKey = _ops.toString();
|
const cacheKey = _ops.toString();
|
||||||
|
|
||||||
|
if (ops?.useCache) {
|
||||||
return this.jobCache.get(cacheKey).pipe(
|
return this.jobCache.get(cacheKey).pipe(
|
||||||
switchMap(cached => {
|
switchMap(cached => {
|
||||||
if (cached !== null) {
|
if (cached !== null) {
|
||||||
@ -60,6 +61,11 @@ export class JobService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.http.get<IJob[]>(this.jobURL, { params: _ops }).pipe(
|
||||||
|
tap(data => this.jobCache.put(cacheKey, data))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getJob(id: number, withItems: boolean = false, withLines?: boolean): Observable<IUIJob> {
|
getJob(id: number, withItems: boolean = false, withLines?: boolean): Observable<IUIJob> {
|
||||||
let httpParams = new HttpParams().set('withItems', withItems.toString());
|
let httpParams = new HttpParams().set('withItems', withItems.toString());
|
||||||
if (withLines)
|
if (withLines)
|
||||||
|
|||||||
@ -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>
|
<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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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",
|
"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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user