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

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Supports the Stripe-style pill filter bar: every list it lands on filters
* and sorts server-side now. `@Index` decorators alone do nothing —
* `synchronize: false` means an index exists only if a migration created it
* (see the `RepairSynchronizeDrift`-style gaps this closes).
*
* `idx_warehouse_inventory_status` already exists (FreightBaseline). Bookings
* and wagons have no plain `status` index — `idx_bookings_route_day` and
* `idx_wagons_readiness` only cover `status` as a trailing/partial column,
* not a standalone `WHERE status = $1`, and `status` is the single
* most-filtered column on both lists (bookings: 37 values).
*
* `(created_at DESC, id ASC)` partials match the default sort + id
* tiebreaker `applySort` now appends everywhere, and none of these tables
* had a created_at index at all.
*/
export class FilterableListIndexes3540000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_status
ON freight.bookings USING btree (status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_status
ON freight.wagons USING btree (status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_contracts_created_at_id
ON freight.contracts (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_created_at_id
ON freight.bookings (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_created_at_id
ON freight.warehouse_inventory (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagons_created_at_id
ON freight.wagons (created_at DESC, id ASC) WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_contracts_created_at_id`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_status`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_status`);
}
}