From 0dbec4784b6278824e7423a760df207f549301d3 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Thu, 10 Jul 2025 09:39:55 +0000 Subject: [PATCH] Feature: Paged customer list. --- barker/barker/routers/customer.py | 99 ++++++++++-- barker/barker/schemas/customer.py | 14 ++ bookie/src/app/core/paged-result.ts | 17 ++ .../app/customers/customer-list.resolver.ts | 10 +- .../customer-list/customer-list-datasource.ts | 149 +++++++++++++++++- .../customer-list.component.html | 26 ++- .../customer-list/customer-list.component.ts | 96 +++++++++-- bookie/src/app/customers/customer.routes.ts | 3 +- bookie/src/app/customers/customer.service.ts | 15 +- 9 files changed, 390 insertions(+), 39 deletions(-) create mode 100644 bookie/src/app/core/paged-result.ts diff --git a/barker/barker/routers/customer.py b/barker/barker/routers/customer.py index c3bf5b2..38fa1e6 100644 --- a/barker/barker/routers/customer.py +++ b/barker/barker/routers/customer.py @@ -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) diff --git a/barker/barker/schemas/customer.py b/barker/barker/schemas/customer.py index 82d51e2..8953942 100644 --- a/barker/barker/schemas/customer.py +++ b/barker/barker/schemas/customer.py @@ -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) diff --git a/bookie/src/app/core/paged-result.ts b/bookie/src/app/core/paged-result.ts new file mode 100644 index 0000000..2f8341f --- /dev/null +++ b/bookie/src/app/core/paged-result.ts @@ -0,0 +1,17 @@ +export class PagedResult { + 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); + } +} diff --git a/bookie/src/app/customers/customer-list.resolver.ts b/bookie/src/app/customers/customer-list.resolver.ts index 3fa3990..3bf4d20 100644 --- a/bookie/src/app/customers/customer-list.resolver.ts +++ b/bookie/src/app/customers/customer-list.resolver.ts @@ -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 = () => { - return inject(CustomerService).list(); +export const customerListResolver: ResolveFn> = (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); }; diff --git a/bookie/src/app/customers/customer-list/customer-list-datasource.ts b/bookie/src/app/customers/customer-list/customer-list-datasource.ts index d5417f2..4de705f 100644 --- a/bookie/src/app/customers/customer-list/customer-list-datasource.ts +++ b/bookie/src/app/customers/customer-list/customer-list-datasource.ts @@ -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 { - constructor(public data: Customer[]) { + constructor( + public data: PagedResult, + private paginator?: MatPaginator, + private sort?: MatSort, + ) { super(); } connect(): Observable { - 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 { +// private filterValue = ''; +// constructor(public data: Customer[], +// private readonly filter: Observable, +// private paginator?: MatPaginator, +// private sort?: MatSort, + +// ) { +// super(); +// this.filter = filter.pipe( +// tap((x) => { +// this.filterValue = x; +// }), +// ); +// } + +// connect(): Observable { +// const dataMutations: (EventEmitter | EventEmitter)[] = []; +// 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(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( +// key: K, +// isAsc = true +// ): (a: T, b: T) => number { +// return (a, b) => compareValues(a[key], b[key], isAsc); +// } + +// // function getNestedValue(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( +// // 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); +// // }; +// // } diff --git a/bookie/src/app/customers/customer-list/customer-list.component.html b/bookie/src/app/customers/customer-list/customer-list.component.html index 83f6011..6d277ed 100644 --- a/bookie/src/app/customers/customer-list/customer-list.component.html +++ b/bookie/src/app/customers/customer-list/customer-list.component.html @@ -9,10 +9,18 @@ - +
+
+ + Filter + + +
+
+ - Name + Name {{ row.name }} @@ -20,19 +28,19 @@ - Phone + Phone {{ row.phone }} - Address + Address {{ row.address }} - Print in Bill + Print in Bill {{ row.printInBill }} @@ -51,5 +59,13 @@ + +
diff --git a/bookie/src/app/customers/customer-list/customer-list.component.ts b/bookie/src/app/customers/customer-list/customer-list.component.ts index 856ffc6..2dda4e7 100644 --- a/bookie/src/app/customers/customer-list/customer-list.component.ts +++ b/bookie/src/app/customers/customer-list/customer-list.component.ts @@ -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; + @ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator; + @ViewChild(MatSort, { static: true }) sort!: MatSort; + + data: PagedResult = new PagedResult([], 0, 0, 0); + filter: Observable; + dataSource: CustomerListDatasource; + form: FormGroup<{ + filter: FormControl; + }>; - 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('', { 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 }; + 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); } } diff --git a/bookie/src/app/customers/customer.routes.ts b/bookie/src/app/customers/customer.routes.ts index 31ecc87..09a1dfe 100644 --- a/bookie/src/app/customers/customer.routes.ts +++ b/bookie/src/app/customers/customer.routes.ts @@ -15,8 +15,9 @@ export const routes: Routes = [ permission: 'Customers', }, resolve: { - list: customerListResolver, + data: customerListResolver, }, + runGuardsAndResolvers: 'always', }, { path: 'new', diff --git a/bookie/src/app/customers/customer.service.ts b/bookie/src/app/customers/customer.service.ts index f9e0f07..b3e98dd 100644 --- a/bookie/src/app/customers/customer.service.ts +++ b/bookie/src/app/customers/customer.service.ts @@ -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; } - list(): Observable { + list(q: string | null, page = 0, size = 50, field = 'name', direction = 'asc'): Observable> { + const params = { + ...(q ? { q } : {}), + p: page, + s: size, + f: field, + d: direction, + }; + console.log('CustomerService.list', params); return this.http - .get(`${url}/list`) - .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable; + .get>(`${url}/list`, { params }) + .pipe(catchError(this.log.handleError(serviceName, 'list'))) as Observable>; } save(customer: Customer): Observable {