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 decimal import Decimal
from typing import Any
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.orm import Session
from sqlalchemy.orm import InstrumentedAttribute, Session, contains_eager
from ..core.security import get_current_active_user as get_user
from ..db.session import SessionFuture
@ -97,29 +98,99 @@ def show_blank(
return blank_customer_info(sc)
@router.get("/list", response_model=list[schemas.Customer])
def show_list(user: UserToken = Depends(get_user)) -> list[schemas.Customer]:
@router.get("/list", response_model=schemas.PagedCustomers)
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:
sc = db.execute(select(SaleCategory).order_by(SaleCategory.name)).scalars().all()
return [
customer_info(item, sc) for item in db.execute(select(Customer).order_by(Customer.name)).scalars().all()
]
customers, total = customer_list(filter, p, s, sort_field, sort_direction, db)
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])
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),
) -> list[schemas.Customer]:
query = select(Customer)
if q is not None:
for item in q.split():
query = query.where(or_(Customer.name.ilike(f"%{item}%"), Customer.phone.ilike(f"%{item}%")))
# 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
query = query.order_by(Customer.name)
filter = [] if q is None else [x.strip() for x in q.split()]
with SessionFuture() as db:
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)

View File

@ -40,3 +40,17 @@ class CustomerLink(BaseModel):
class CustomerBlank(CustomerIn):
name: str
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 { Customer } from '../core/customer';
import { PagedResult } from '../core/paged-result';
import { CustomerService } from './customer.service';
export const customerListResolver: ResolveFn<Customer[]> = () => {
return inject(CustomerService).list();
export const customerListResolver: ResolveFn<PagedResult<Customer>> = (route) => {
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 { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { Observable, of as observableOf } from 'rxjs';
import { PagedResult } from 'src/app/core/paged-result';
import { Customer } from '../../core/customer';
export class CustomerListDatasource extends DataSource<Customer> {
constructor(public data: Customer[]) {
constructor(
public data: PagedResult<Customer>,
private paginator?: MatPaginator,
private sort?: MatSort,
) {
super();
}
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() {}
}
// 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-header>
<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 -->
<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"
><a [routerLink]="['/customers', row.id]">{{ row.name }}</a></mat-cell
>
@ -20,19 +28,19 @@
<!-- Phone Column -->
<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>
</ng-container>
<!-- Address Column -->
<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>
</ng-container>
<!-- Print In Bill Column -->
<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>
</ng-container>
@ -51,5 +59,13 @@
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</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>

View File

@ -1,10 +1,16 @@
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 { MatCardModule } from '@angular/material/card';
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 { 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 { CustomerListDatasource } from './customer-list-datasource';
@ -13,22 +19,88 @@ import { CustomerListDatasource } from './customer-list-datasource';
selector: 'app-customer-list',
templateUrl: './customer-list.component.html',
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 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. */
displayedColumns = ['name', 'phone', 'address', 'printInBill', 'discounts'];
ngOnInit() {
this.route.data.subscribe((value) => {
const data = value as { list: Customer[] };
data.list.forEach((c) => (c.discounts = c.discounts.filter((d) => d.discount !== 0)));
this.list = data.list;
constructor() {
this.form = new FormGroup({
filter: new FormControl<string>('', { nonNullable: true }),
});
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',
},
resolve: {
list: customerListResolver,
data: customerListResolver,
},
runGuardsAndResolvers: 'always',
},
{
path: 'new',

View File

@ -5,6 +5,7 @@ import { catchError } from 'rxjs/operators';
import { Customer } from '../core/customer';
import { ErrorLoggerService } from '../core/error-logger.service';
import { PagedResult } from '../core/paged-result';
const httpOptions = {
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>;
}
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
.get<Customer[]>(`${url}/list`)
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<Customer[]>;
.get<PagedResult<Customer>>(`${url}/list`, { params })
.pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable<PagedResult<Customer>>;
}
save(customer: Customer): Observable<Customer> {