mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
feat: Stripe-style filter bar for freight backoffice (pilot: contracts)
Replace the ad-hoc filter controls with a URL-linkable pill filter bar: each filter is a pill that opens a type-aware popover (text/enum/date/ number/boolean, each with the right operator set), overflow filters live behind a searchable "More filters" menu, sorting is a separate control, and filter state round-trips through the URL query string (shareable, back/forward-safe, backward compatible with existing ?statuses=A,B links). Frontend (apps/edr-freight-web/backoffice/src/components/filters/): - FilterDef schema + a pure url.ts codec (parse/serialize/toApiParams), with a 24-case round-trip + malformed-input test suite - useFilters hook driving react-query params straight from useSearchParams, debounced search, saved views in localStorage (@mantine/hooks useLocalStorage), page-reset-on-filter-change baked into one setSearchParams call instead of a separate effect - FilterBar/FilterPill/OperatorSelect/MoreFiltersMenu/SortControl + per-type popover bodies (Mantine) - ContractRequestsPage migrated end to end as the pilot Backend (apps/edr-freight-api): - pagination.util: applySort() — whitelisted sortBy resolved against a per-module column map (never interpolated), with a mandatory `id ASC` tiebreaker so paginating a non-unique sort can't drop/duplicate rows - facets.util: computeFacets() — one GROUP BY per enum column, each omitting its own predicate, so picking a value doesn't hide its siblings - contracts/bookings: list-summary now returns real filter-scoped facet counts (contracts' getStatusCounts was unfiltered/global; superseded) - deleted drivers/vehicles findAllWithFilters — dead code that interpolated an unwhitelisted sortBy straight into orderBy() - migration: missing bookings(status)/wagons(status) indexes + (created_at DESC, id ASC) partials on the hot list tables UI polish pass: inactive pill uses the opaque "default" variant instead of a faint tinted outline, active pill uses "light" not "filled", larger X hit target, applied filters sort first, sort control separated behind a divider on the right and wraps independently from the filter row, popover option rows are fully clickable (count moved inside the native label) with bigger hit area and font, fixed a real date-filter bug where the calendar's own portal falsely registered as an "outside click" and closed the popover, and fixed a timezone bug where bare YYYY-MM-DD strings were parsed as UTC instead of local time (shifts a day for EAT). Not in this commit: rollout to the other ~59 list pages, the Ethiopian- calendar DateBody branch, and the Family-B (client-side) bridge mode — tracked in the filter-bar plan.
This commit is contained in:
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
@@ -416,14 +417,22 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
return { inQueue, onThisPage, needsAction };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param omit skip this one predicate — used by `getFacets` so a facet's
|
||||
* own filter doesn't hide its own sibling values (see class doc on
|
||||
* `getFacets`). Every other list/summary/count caller passes nothing.
|
||||
*/
|
||||
private applyListFilters(
|
||||
qb: SelectQueryBuilder<Contract>,
|
||||
options: ContractListFilterOptions,
|
||||
omit?: keyof ContractListFilterOptions | 'status',
|
||||
): void {
|
||||
if (options.statuses?.length) {
|
||||
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
||||
} else if (options.status) {
|
||||
qb.andWhere('contract.status = :status', { status: options.status });
|
||||
if (omit !== 'status') {
|
||||
if (options.statuses?.length) {
|
||||
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
|
||||
} else if (options.status) {
|
||||
qb.andWhere('contract.status = :status', { status: options.status });
|
||||
}
|
||||
}
|
||||
if (options.companyId) {
|
||||
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
|
||||
@@ -433,7 +442,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
companyProfileId: options.companyProfileId,
|
||||
});
|
||||
}
|
||||
if (options.contractKind) {
|
||||
if (omit !== 'contractKind' && options.contractKind) {
|
||||
qb.andWhere('contract.contract_kind = :contractKind', {
|
||||
contractKind: options.contractKind,
|
||||
});
|
||||
@@ -454,20 +463,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
});
|
||||
}
|
||||
if (options.freightType) {
|
||||
if (omit !== 'freightType' && options.freightType) {
|
||||
qb.andWhere('contract.freight_type = :freightType', {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirection) {
|
||||
if (omit !== 'tradeDirection' && options.tradeDirection) {
|
||||
qb.andWhere('contract.trade_direction = :tradeDirection', {
|
||||
tradeDirection: options.tradeDirection,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirections) {
|
||||
if (omit !== 'tradeDirection' && options.tradeDirections) {
|
||||
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
|
||||
}
|
||||
if (options.paymentCurrency) {
|
||||
if (omit !== 'paymentCurrency' && options.paymentCurrency) {
|
||||
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
||||
paymentCurrency: options.paymentCurrency,
|
||||
});
|
||||
@@ -482,6 +491,28 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Facet counts for the filter bar's enum popovers: one `GROUP BY` per
|
||||
* column, each with every OTHER active filter applied but its own
|
||||
* predicate omitted — so selecting `status=SUBMITTED` still shows
|
||||
* `APPROVED: 8` in the status popover (to switch), while the freightType
|
||||
* popover reflects only the SUBMITTED-scoped set. Supersedes
|
||||
* `getStatusCounts`, which ignores the active filter entirely.
|
||||
*/
|
||||
async getFacets(options: ContractListFilterOptions): Promise<Record<string, FacetBucket[]>> {
|
||||
return computeFacets(
|
||||
() => this.repository.createQueryBuilder('contract').where('contract.deleted_at IS NULL'),
|
||||
(qb, omit) => this.applyListFilters(qb, options, omit as keyof ContractListFilterOptions),
|
||||
{
|
||||
status: 'contract.status',
|
||||
contractKind: 'contract.contract_kind',
|
||||
freightType: 'contract.freight_type',
|
||||
tradeDirection: 'contract.trade_direction',
|
||||
paymentCurrency: 'contract.payment_currency',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Approval steps ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Lowest-order pending approval step (sequential enforcement). */
|
||||
|
||||
Reference in New Issue
Block a user