Feature: Paged customer list.

This commit is contained in:
2025-07-10 09:39:55 +00:00
parent af684c976e
commit 0dbec4784b
9 changed files with 390 additions and 39 deletions

View File

@ -2,11 +2,12 @@ import uuid
from collections.abc import Sequence from collections.abc import Sequence
from decimal import Decimal from decimal import Decimal
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Security, status from fastapi import APIRouter, Depends, HTTPException, Security, status
from sqlalchemy import delete, or_, select from sqlalchemy import asc, delete, desc, func, or_, select
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import InstrumentedAttribute, Session, contains_eager
from ..core.security import get_current_active_user as get_user from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture from ..db.session import SessionFuture
@ -97,29 +98,99 @@ def show_blank(
return blank_customer_info(sc) return blank_customer_info(sc)
@router.get("/list", response_model=list[schemas.Customer]) @router.get("/list", response_model=schemas.PagedCustomers)
def show_list(user: UserToken = Depends(get_user)) -> list[schemas.Customer]: def show_list(
q: str | None = None,
p: int = 0, # Page number
s: int = 50, # Page size
f: str | None = None, # Sort field
d: str | None = None, # Sort direction
user: UserToken = Depends(get_user),
) -> schemas.PagedCustomers:
# Handle dynamic sorting safely
sort_field = ALLOWED_SORT_FIELDS.get(f or "name", Customer.name) # Default to name
sort_direction = desc if (d or "").lower() == "desc" else asc
filter = [] if q is None else [x.strip() for x in q.split()]
with SessionFuture() as db: with SessionFuture() as db:
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all() sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
return [ customers, total = customer_list(filter, p, s, sort_field, sort_direction, db)
customer_info(item, sc) for item in db.execute(select(Customer).order_by(Customer.name)).scalars().all() return schemas.PagedCustomers(
] items=[customer_info(item, sc) for item in customers],
page=p,
page_size=s,
total=total,
field=f,
sort_direction=d,
)
ALLOWED_SORT_FIELDS: dict[str, InstrumentedAttribute[Any]] = {
"name": Customer.name,
"phone": Customer.phone,
"address": Customer.address,
"printInBill": Customer.print_in_bill,
}
@router.get("/query", response_model=list[schemas.Customer]) @router.get("/query", response_model=list[schemas.Customer])
def show_term( def show_term(
q: str, q: str | None,
p: int = 0, # Page number
s: int = 25, # Page size
f: str | None = None, # Sort field
d: str | None = None, # Sort direction
current_user: UserToken = Depends(get_user), current_user: UserToken = Depends(get_user),
) -> list[schemas.Customer]: ) -> list[schemas.Customer]:
query = select(Customer) # Handle dynamic sorting safely
if q is not None: sort_field = ALLOWED_SORT_FIELDS.get(f or "name", Customer.name) # Default to name
for item in q.split(): sort_direction = desc if (d or "").lower() == "desc" else asc
query = query.where(or_(Customer.name.ilike(f"%{item}%"), Customer.phone.ilike(f"%{item}%")))
query = query.order_by(Customer.name) filter = [] if q is None else [x.strip() for x in q.split()]
with SessionFuture() as db: with SessionFuture() as db:
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all() sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
return [customer_info(item, sc) for item in db.execute(query).scalars().all()] customers, _ = customer_list(filter, p, s, sort_field, sort_direction, db)
return [customer_info(item, sc) for item in customers]
def customer_list(
filter: list[str],
page: int | None,
page_size: int | None,
sort_field: InstrumentedAttribute[Any] | None,
sort_direction: Any | None,
db: Session,
) -> tuple[Sequence[Customer], int]:
# Handle dynamic sorting safely
sort_field = sort_field or Customer.name # Default to name
sort_direction = sort_direction or asc
print(
f"Filter: {filter}, Page: {page}, Page Size: {page_size}, Sort Field: {sort_field}, Sort Direction: {sort_direction}"
)
id_query = select(Customer.id)
for item in filter:
id_query = id_query.where(or_(Customer.name.ilike(f"%{item}%"), Customer.phone.ilike(f"%{item}%")))
id_query = id_query.order_by(sort_direction(sort_field))
if page_size: # Paging
if page:
id_query = id_query.offset(page * page_size)
id_query = id_query.limit(page_size)
ids = db.execute(id_query).scalars().all()
total_query = select(func.count(Customer.id))
for item in filter:
total_query = total_query.where(or_(Customer.name.ilike(f"%{item}%"), Customer.phone.ilike(f"%{item}%")))
total = db.execute(total_query).scalar_one()
query = (
select(Customer)
.join(Customer.discounts, isouter=True)
.where(Customer.id.in_(ids))
.order_by(sort_direction(sort_field))
.options(contains_eager(Customer.discounts))
)
return db.execute(query).unique().scalars().all(), total
@router.get("/{id_}", response_model=schemas.Customer) @router.get("/{id_}", response_model=schemas.Customer)

View File

@ -40,3 +40,17 @@ class CustomerLink(BaseModel):
class CustomerBlank(CustomerIn): class CustomerBlank(CustomerIn):
name: str name: str
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True) model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
class PagedCustomers(BaseModel):
items: list[Customer]
total: int
page: int
page_size: int
field: str | None
sort_direction: str | None
model_config = ConfigDict(str_strip_whitespace=True, alias_generator=to_camel, populate_by_name=True)
def __len__(self) -> int:
return len(self.items)

View File

@ -0,0 +1,17 @@
export class PagedResult<T> {
items: T[];
page: number; // current page (zero-based)
pageSize: number; // items per page
total: number; // total items in all pages
totalPages: number;
field?: string; // active sort column
sortDirection?: 'asc' | 'desc'; // sort direction
constructor(items: T[], page: number, pageSize: number, total: number) {
this.items = items;
this.page = page;
this.pageSize = pageSize;
this.total = total;
this.totalPages = Math.ceil(total / pageSize);
}
}

View File

@ -2,8 +2,14 @@ import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router'; import { ResolveFn } from '@angular/router';
import { Customer } from '../core/customer'; import { Customer } from '../core/customer';
import { PagedResult } from '../core/paged-result';
import { CustomerService } from './customer.service'; import { CustomerService } from './customer.service';
export const customerListResolver: ResolveFn<Customer[]> = () => { export const customerListResolver: ResolveFn<PagedResult<Customer>> = (route) => {
return inject(CustomerService).list(); const q = route.queryParamMap.get('q');
const page = Number(route.queryParamMap.get('p') ?? 0);
const size = Number(route.queryParamMap.get('s') ?? 50);
const field = route.queryParamMap.get('f') ?? 'name';
const direction = route.queryParamMap.get('d') ?? 'asc';
return inject(CustomerService).list(q, page, size, field, direction);
}; };

View File

@ -1,16 +1,161 @@
import { DataSource } from '@angular/cdk/collections'; import { DataSource } from '@angular/cdk/collections';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { PagedResult } from 'src/app/core/paged-result';
import { Customer } from '../../core/customer'; import { Customer } from '../../core/customer';
export class CustomerListDatasource extends DataSource<Customer> { export class CustomerListDatasource extends DataSource<Customer> {
constructor(public data: Customer[]) { constructor(
public data: PagedResult<Customer>,
private paginator?: MatPaginator,
private sort?: MatSort,
) {
super(); super();
} }
connect(): Observable<Customer[]> { connect(): Observable<Customer[]> {
return observableOf(this.data); if (this.paginator) {
this.paginator.length = this.data.total;
if (this.data.pageSize !== undefined) {
this.paginator.pageSize = this.data.pageSize;
}
if (this.data.page !== undefined) {
this.paginator.pageIndex = this.data.page;
}
}
if (this.sort) {
if (this.data.field !== undefined) {
this.sort.active = this.data.field;
}
if (this.data.sortDirection !== undefined) {
this.sort.direction = this.data.sortDirection;
}
}
return observableOf(this.data.items);
} }
disconnect() {} disconnect() {}
} }
// import { DataSource } from '@angular/cdk/collections';
// import { map, merge, Observable, of as observableOf, tap } from 'rxjs';
// import { Customer } from '../../core/customer';
// import { MatPaginator, PageEvent } from '@angular/material/paginator';
// import { MatSort, Sort } from '@angular/material/sort';
// import { EventEmitter } from '@angular/core';
// export class CustomerListDatasource extends DataSource<Customer> {
// private filterValue = '';
// constructor(public data: Customer[],
// private readonly filter: Observable<string>,
// private paginator?: MatPaginator,
// private sort?: MatSort,
// ) {
// super();
// this.filter = filter.pipe(
// tap((x) => {
// this.filterValue = x;
// }),
// );
// }
// connect(): Observable<Customer[]> {
// const dataMutations: (EventEmitter<PageEvent> | EventEmitter<Sort>)[] = [];
// if (this.paginator) {
// dataMutations.push((this.paginator as MatPaginator).page);
// }
// if (this.sort) {
// dataMutations.push((this.sort as MatSort).sortChange);
// }
// return merge(observableOf(this.data), this.filter, ...dataMutations).pipe(
// map(() => this.getFilteredData([...this.data])),
// tap((x: Customer[]) => {
// if (this.paginator) {
// this.paginator.length = x.length;
// }
// }),
// map((x: Customer[]) => this.getPagedData(this.getSortedData(x))),
// );
// }
// disconnect() {}
// private getFilteredData(data: Customer[]): Customer[] {
// return this.filterValue.toLowerCase().split(' ').reduce(
// (p: Customer[], c: string) =>
// p.filter((x) => {
// const productString = `${x.name} ${x.phone}${x.address}`.toLowerCase();
// return productString.indexOf(c) !== -1;
// }),
// Object.assign([], data),
// );
// }
// private getPagedData(data: Customer[]) {
// if (this.paginator === undefined) {
// return data;
// }
// const startIndex = this.paginator.pageIndex * this.paginator.pageSize;
// return data.splice(startIndex, this.paginator.pageSize);
// }
// private getSortedData(data: Customer[]) {
// if (this.sort === undefined) {
// return data;
// }
// if (!this.sort.active || this.sort.direction === '') {
// return data;
// }
// const sort = this.sort as MatSort;
// console.log('Sorting by', sort.active, sort.direction);
// return data.sort(sortBy(sort.active as keyof Customer, sort.direction === 'asc'));
// }
// }
// function compareValues<T>(a: T, b: T, isAsc: boolean): number {
// if (a === b) return 0;
// if (a == null) return isAsc ? -1 : 1;
// if (b == null) return isAsc ? 1 : -1;
// if (typeof a === "string" && typeof b === "string") {
// return isAsc ? a.localeCompare(b) : b.localeCompare(a);
// }
// if (typeof a === "number" && typeof b === "number") {
// return isAsc ? a - b : b - a;
// }
// if (typeof a === "boolean" && typeof b === "boolean") {
// return isAsc ? (Number(a) - Number(b)) : (Number(b) - Number(a));
// }
// // Fallback
// const aStr = String(a);
// const bStr = String(b);
// return isAsc ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
// }
// function sortBy<T, K extends keyof T>(
// key: K,
// isAsc = true
// ): (a: T, b: T) => number {
// return (a, b) => compareValues(a[key], b[key], isAsc);
// }
// // function getNestedValue<T>(obj: T, path: string | string[]): any {
// // const keys = Array.isArray(path) ? path : path.split(".");
// // return keys.reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
// // }
// // function sortByNested<T>(
// // path: string | string[],
// // isAsc = true
// // ): (a: T, b: T) => number {
// // return (a, b) => {
// // const aValue = getNestedValue(a, path);
// // const bValue = getNestedValue(b, path);
// // return compareValues(aValue, bValue, isAsc);
// // };
// // }

View File

@ -9,10 +9,18 @@
</mat-card-title-group> </mat-card-title-group>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<mat-table #table [dataSource]="dataSource" aria-label="Elements"> <form [formGroup]="form" class="flex flex-col">
<div class="flex flex-row justify-around content-start items-start">
<mat-form-field class="flex-auto">
<mat-label>Filter</mat-label>
<input type="text" matInput #filterElement formControlName="filter" autocomplete="off" />
</mat-form-field>
</div>
</form>
<mat-table #table [dataSource]="dataSource" matSort aria-label="Elements">
<!-- Name Column --> <!-- Name Column -->
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>Name</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Name</mat-header-cell>
<mat-cell *matCellDef="let row" <mat-cell *matCellDef="let row"
><a [routerLink]="['/customers', row.id]">{{ row.name }}</a></mat-cell ><a [routerLink]="['/customers', row.id]">{{ row.name }}</a></mat-cell
> >
@ -20,19 +28,19 @@
<!-- Phone Column --> <!-- Phone Column -->
<ng-container matColumnDef="phone"> <ng-container matColumnDef="phone">
<mat-header-cell *matHeaderCellDef>Phone</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Phone</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.phone }}</mat-cell> <mat-cell *matCellDef="let row">{{ row.phone }}</mat-cell>
</ng-container> </ng-container>
<!-- Address Column --> <!-- Address Column -->
<ng-container matColumnDef="address"> <ng-container matColumnDef="address">
<mat-header-cell *matHeaderCellDef>Address</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Address</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.address }}</mat-cell> <mat-cell *matCellDef="let row">{{ row.address }}</mat-cell>
</ng-container> </ng-container>
<!-- Print In Bill Column --> <!-- Print In Bill Column -->
<ng-container matColumnDef="printInBill"> <ng-container matColumnDef="printInBill">
<mat-header-cell *matHeaderCellDef>Print in Bill</mat-header-cell> <mat-header-cell *matHeaderCellDef mat-sort-header>Print in Bill</mat-header-cell>
<mat-cell *matCellDef="let row">{{ row.printInBill }}</mat-cell> <mat-cell *matCellDef="let row">{{ row.printInBill }}</mat-cell>
</ng-container> </ng-container>
@ -51,5 +59,13 @@
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row> <mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table> </mat-table>
<mat-paginator
#paginator
[length]="dataSource.data.total"
[pageIndex]="0"
[pageSize]="50"
[pageSizeOptions]="[25, 50, 100, 250, 5000]"
>
</mat-paginator>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@ -1,10 +1,16 @@
import { PercentPipe } from '@angular/common'; import { PercentPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core'; import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject, AfterViewInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { debounceTime, distinctUntilChanged, Observable } from 'rxjs';
import { PagedResult } from 'src/app/core/paged-result';
import { Customer } from '../../core/customer'; import { Customer } from '../../core/customer';
import { CustomerListDatasource } from './customer-list-datasource'; import { CustomerListDatasource } from './customer-list-datasource';
@ -13,22 +19,88 @@ import { CustomerListDatasource } from './customer-list-datasource';
selector: 'app-customer-list', selector: 'app-customer-list',
templateUrl: './customer-list.component.html', templateUrl: './customer-list.component.html',
styleUrls: ['./customer-list.component.css'], styleUrls: ['./customer-list.component.css'],
imports: [MatButtonModule, MatCardModule, MatIconModule, MatTableModule, PercentPipe, RouterLink], imports: [
MatButtonModule,
MatCardModule,
MatIconModule,
MatTableModule,
PercentPipe,
RouterLink,
MatInputModule,
MatPaginatorModule,
MatSortModule,
ReactiveFormsModule,
],
}) })
export class CustomerListComponent implements OnInit { export class CustomerListComponent implements OnInit, AfterViewInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router);
private cdr = inject(ChangeDetectorRef);
@ViewChild('filterElement', { static: true }) filterElement!: ElementRef<HTMLInputElement>;
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
@ViewChild(MatSort, { static: true }) sort!: MatSort;
data: PagedResult<Customer> = new PagedResult<Customer>([], 0, 0, 0);
filter: Observable<string>;
dataSource: CustomerListDatasource;
form: FormGroup<{
filter: FormControl<string>;
}>;
dataSource: CustomerListDatasource = new CustomerListDatasource([]);
list: Customer[] = [];
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
displayedColumns = ['name', 'phone', 'address', 'printInBill', 'discounts']; displayedColumns = ['name', 'phone', 'address', 'printInBill', 'discounts'];
ngOnInit() { constructor() {
this.route.data.subscribe((value) => { this.form = new FormGroup({
const data = value as { list: Customer[] }; filter: new FormControl<string>('', { nonNullable: true }),
data.list.forEach((c) => (c.discounts = c.discounts.filter((d) => d.discount !== 0)));
this.list = data.list;
}); });
this.dataSource = new CustomerListDatasource(this.list); this.filter = this.form.controls.filter.valueChanges.pipe(debounceTime(150), distinctUntilChanged());
this.dataSource = new CustomerListDatasource(this.data, this.paginator, this.sort);
}
ngOnInit() {
const q = this.route.snapshot.queryParamMap.get('q');
if (q) {
this.form.controls.filter.setValue(q, { emitEvent: false }); // Prevent infinite loop
}
this.route.data.subscribe((value) => {
const data = value as { data: PagedResult<Customer> };
data.data.items.forEach((c) => (c.discounts = c.discounts.filter((d) => d.discount !== 0)));
this.data = data.data;
this.dataSource = new CustomerListDatasource(this.data, this.paginator, this.sort);
this.cdr.detectChanges();
});
this.sort.sortChange.subscribe({
next: () =>
this.router.navigate([], {
relativeTo: this.route,
queryParams: { f: this.sort?.active ?? 'name', d: this.sort?.direction },
queryParamsHandling: 'merge',
}),
});
this.paginator.page.subscribe({
next: () =>
this.router.navigate([], {
relativeTo: this.route,
queryParams: { s: this.paginator.pageSize, p: this.paginator.pageIndex },
queryParamsHandling: 'merge',
}),
});
this.filter.subscribe({
next: (value) => {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { q: value },
queryParamsHandling: 'merge',
});
},
});
}
ngAfterViewInit() {
setTimeout(() => {
this.filterElement.nativeElement.focus();
}, 0);
} }
} }

View File

@ -15,8 +15,9 @@ export const routes: Routes = [
permission: 'Customers', permission: 'Customers',
}, },
resolve: { resolve: {
list: customerListResolver, data: customerListResolver,
}, },
runGuardsAndResolvers: 'always',
}, },
{ {
path: 'new', path: 'new',

View File

@ -5,6 +5,7 @@ import { catchError } from 'rxjs/operators';
import { Customer } from '../core/customer'; import { Customer } from '../core/customer';
import { ErrorLoggerService } from '../core/error-logger.service'; import { ErrorLoggerService } from '../core/error-logger.service';
import { PagedResult } from '../core/paged-result';
const httpOptions = { const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' }), headers: new HttpHeaders({ 'Content-Type': 'application/json' }),
@ -26,10 +27,18 @@ export class CustomerService {
.pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<Customer>; .pipe(catchError(this.log.handleError(serviceName, `get id=${id}`))) as Observable<Customer>;
} }
list(): Observable<Customer[]> { list(q: string | null, page = 0, size = 50, field = 'name', direction = 'asc'): Observable<PagedResult<Customer>> {
const params = {
...(q ? { q } : {}),
p: page,
s: size,
f: field,
d: direction,
};
console.log('CustomerService.list', params);
return this.http return this.http
.get<Customer[]>(`${url}/list`) .get<PagedResult<Customer>>(`${url}/list`, { params })
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<Customer[]>; .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<PagedResult<Customer>>;
} }
save(customer: Customer): Observable<Customer> { save(customer: Customer): Observable<Customer> {