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.
57 lines
2.0 KiB
TypeScript
57 lines
2.0 KiB
TypeScript
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
import { Transform } from 'class-transformer';
|
|
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
|
|
|
/**
|
|
* Base query DTO for every paginated list endpoint. Extend it and add the
|
|
* module's own filter fields; sort-field whitelists stay in the subclass
|
|
* because the allowed columns differ per resource.
|
|
*
|
|
* All list endpoints built on this return the shared `PaginatedResponse<T>`
|
|
* envelope from `@edr/types` (`items` + `meta`), produced by
|
|
* `common/utils/pagination.util.ts`.
|
|
*/
|
|
export class PaginationQueryDto {
|
|
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
|
@IsOptional()
|
|
@Transform(({ value }) => parseInt(String(value), 10) || 1)
|
|
@IsInt()
|
|
@Min(1)
|
|
page?: number;
|
|
|
|
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
|
@IsOptional()
|
|
@Transform(({ value }) => parseInt(String(value), 10) || 20)
|
|
@IsInt()
|
|
@Min(1)
|
|
@Max(100)
|
|
pageSize?: number;
|
|
|
|
@ApiPropertyOptional({
|
|
description: 'Free-text search, applied server-side (resource-specific columns).',
|
|
})
|
|
@IsOptional()
|
|
@Transform(({ value }) =>
|
|
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
|
)
|
|
search?: string;
|
|
|
|
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
|
@IsOptional()
|
|
@Transform(({ value }) => String(value).toUpperCase())
|
|
@IsIn(['ASC', 'DESC'])
|
|
sortOrder?: 'ASC' | 'DESC';
|
|
|
|
/**
|
|
* Column to sort by, as a public field name (not a raw SQL column). The
|
|
* actual whitelist lives in `applySort`'s `sortable` map at each call site,
|
|
* not here — a per-DTO `@IsIn` is opt-in and has been forgotten before.
|
|
* An unrecognized value falls back silently rather than 400ing, so a stale
|
|
* bookmark or shared link never breaks.
|
|
*/
|
|
@ApiPropertyOptional({ description: 'Public field name; unknown values fall back to the endpoint default.' })
|
|
@IsOptional()
|
|
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : undefined))
|
|
sortBy?: string;
|
|
}
|