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:
Nathnael
2026-08-14 13:18:46 +00:00
parent ab5a4117df
commit 4a4d3077d7
32 changed files with 1879 additions and 376 deletions

View File

@@ -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). */

View File

@@ -791,8 +791,8 @@ export class ContractsService {
createdTo: filter.createdTo,
};
const [statusCounts, metrics] = await Promise.all([
this.contractsRepository.getStatusCounts(),
const [facets, metrics] = await Promise.all([
this.contractsRepository.getFacets(listFilter),
this.contractsRepository.getListSummaryMetrics({
...listFilter,
page,
@@ -801,7 +801,13 @@ export class ContractsService {
}),
]);
return { metrics, statusCounts };
// statusCounts kept for existing callers; now filter-scoped like every
// other facet instead of the unfiltered global count `getStatusCounts` gave.
const statusCounts = Object.fromEntries(
(facets.status ?? []).map((b) => [b.value, b.count]),
);
return { metrics, statusCounts, facets };
}
/** Get a single contract by ID with relations and signed file URLs. */

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { FacetBucket } from '../../../common/utils/facets.util';
export class ContractListSummaryMetricsDto {
@ApiProperty({ example: 42 })
@@ -15,6 +16,23 @@ export class ContractListSummaryDto {
@ApiProperty({ type: ContractListSummaryMetricsDto })
metrics!: ContractListSummaryMetricsDto;
/** @deprecated use `facets.status` — kept for existing callers, computed
* from the same filter-scoped query now instead of `getStatusCounts`'s
* unfiltered global count. */
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
statusCounts!: Record<string, number>;
/**
* Per-column value counts for the filter bar's enum popovers, scoped to
* every OTHER currently-active filter (each column's own predicate is
* omitted from its own count — see `ContractsRepository.getFacets`).
* Absent/omitted keys mean the frontend falls back to its static option
* list with no counts, never an error.
*/
@ApiProperty({
description: 'Facet counts keyed by filter field, for the pill filter bar',
type: 'object',
additionalProperties: { type: 'array', items: { type: 'object' } },
})
facets!: Record<string, FacetBucket[]>;
}