mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
49 lines
2.0 KiB
TypeScript
49 lines
2.0 KiB
TypeScript
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
|
|
|
export interface FacetBucket {
|
|
value: string;
|
|
count: number;
|
|
}
|
|
|
|
/**
|
|
* One `GROUP BY` query per faceted column, each with every OTHER active
|
|
* filter applied but its OWN predicate omitted. That omission is the point:
|
|
* with `status=SUBMITTED` selected, the status facet still reports
|
|
* `APPROVED: 8` so the user can switch, while the freightType facet reflects
|
|
* only the SUBMITTED-scoped set. Omit `search` from nothing — it's a scope,
|
|
* not a pill, and stays applied in every facet.
|
|
*
|
|
* Capped at 50 buckets per column — FK-id facets (warehouseId, yardId) can
|
|
* have real cardinality; beyond 50 the frontend should fall back to a
|
|
* typeahead instead of a checkbox list. Never facet a column whose popover
|
|
* would need its own search box (references, plate numbers, free text).
|
|
*
|
|
* @param base builds a FRESH query builder (soft-delete guard only,
|
|
* no filters) — called once per facet column.
|
|
* @param applyFilters applies every filter to `qb`, using `omit` to skip
|
|
* one column's own predicate.
|
|
* @param columns facet key -> "alias.column" SQL reference.
|
|
*/
|
|
export async function computeFacets<T extends ObjectLiteral>(
|
|
base: () => SelectQueryBuilder<T>,
|
|
applyFilters: (qb: SelectQueryBuilder<T>, omit?: string) => void,
|
|
columns: Record<string, string>,
|
|
): Promise<Record<string, FacetBucket[]>> {
|
|
const entries = await Promise.all(
|
|
Object.entries(columns).map(async ([key, column]) => {
|
|
const qb = base();
|
|
applyFilters(qb, key);
|
|
const rows = await qb
|
|
.select(column, 'value')
|
|
.addSelect('COUNT(*)::int', 'count')
|
|
.andWhere(`${column} IS NOT NULL`)
|
|
.groupBy(column)
|
|
.orderBy('count', 'DESC')
|
|
.limit(50)
|
|
.getRawMany<{ value: string; count: number }>();
|
|
return [key, rows.map((r) => ({ value: String(r.value), count: Number(r.count) }))] as const;
|
|
}),
|
|
);
|
|
return Object.fromEntries(entries);
|
|
}
|