From 4a4d3077d7f880a95c72f6f90a4dbccb17382c84 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 14 Aug 2026 13:18:46 +0000 Subject: [PATCH 01/10] feat: Stripe-style filter bar for freight backoffice (pilot: contracts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/common/dto/pagination-query.dto.ts | 12 + .../src/common/utils/facets.util.ts | 48 +++ .../src/common/utils/pagination.util.spec.ts | 76 ++++ .../src/common/utils/pagination.util.ts | 29 ++ .../3540000000000-FilterableListIndexes.ts | 56 +++ .../modules/bookings/bookings.repository.ts | 54 ++- .../src/modules/bookings/bookings.service.ts | 5 +- .../bookings/dto/booking-list-summary.dto.ts | 15 + .../modules/contracts/contracts.repository.ts | 49 ++- .../modules/contracts/contracts.service.ts | 12 +- .../dto/contract-list-summary.dto.ts | 18 + .../src/modules/drivers/drivers.repository.ts | 46 --- .../modules/vehicles/vehicles.repository.ts | 46 --- .../src/components/filters/FilterBar.tsx | 121 ++++++ .../src/components/filters/FilterPill.tsx | 88 +++++ .../components/filters/MoreFiltersMenu.tsx | 82 +++++ .../src/components/filters/OperatorSelect.tsx | 25 ++ .../src/components/filters/SavedViews.tsx | 107 ++++++ .../src/components/filters/SortControl.tsx | 40 ++ .../components/filters/bodies/BooleanBody.tsx | 32 ++ .../components/filters/bodies/DateBody.tsx | 87 +++++ .../components/filters/bodies/EnumBody.tsx | 128 +++++++ .../components/filters/bodies/NumberBody.tsx | 39 ++ .../components/filters/bodies/TextBody.tsx | 39 ++ .../src/components/filters/dates.ts | 31 ++ .../src/components/filters/index.ts | 10 + .../src/components/filters/types.ts | 101 +++++ .../src/components/filters/url.test.ts | 170 +++++++++ .../backoffice/src/components/filters/url.ts | 117 ++++++ .../src/components/filters/useFilters.ts | 219 +++++++++++ .../pages/contracts/ContractRequestsPage.tsx | 346 +++++------------- .../src/services/contracts.service.ts | 7 + 32 files changed, 1879 insertions(+), 376 deletions(-) create mode 100644 apps/edr-freight-api/src/common/utils/facets.util.ts create mode 100644 apps/edr-freight-api/src/common/utils/pagination.util.spec.ts create mode 100644 apps/edr-freight-api/src/migrations/3540000000000-FilterableListIndexes.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/bodies/NumberBody.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/bodies/TextBody.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/dates.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/index.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/types.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/url.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/url.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/useFilters.ts diff --git a/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts index 997ced76d..e705b019d 100644 --- a/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts +++ b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts @@ -41,4 +41,16 @@ export class PaginationQueryDto { @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; } diff --git a/apps/edr-freight-api/src/common/utils/facets.util.ts b/apps/edr-freight-api/src/common/utils/facets.util.ts new file mode 100644 index 000000000..bc80c4b93 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/facets.util.ts @@ -0,0 +1,48 @@ +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( + base: () => SelectQueryBuilder, + applyFilters: (qb: SelectQueryBuilder, omit?: string) => void, + columns: Record, +): Promise> { + 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); +} diff --git a/apps/edr-freight-api/src/common/utils/pagination.util.spec.ts b/apps/edr-freight-api/src/common/utils/pagination.util.spec.ts new file mode 100644 index 000000000..05bfe7585 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/pagination.util.spec.ts @@ -0,0 +1,76 @@ +import { SelectQueryBuilder } from 'typeorm'; +import { applySort, buildPaginationMeta, normalizePagination } from './pagination.util'; + +/** Minimal fake — just enough of the SelectQueryBuilder chain applySort touches. */ +function fakeQb() { + const calls: Array<{ method: string; args: unknown[] }> = []; + const qb = { + alias: 'contract', + orderBy(...args: unknown[]) { + calls.push({ method: 'orderBy', args }); + return qb; + }, + addOrderBy(...args: unknown[]) { + calls.push({ method: 'addOrderBy', args }); + return qb; + }, + }; + return { qb: qb as unknown as SelectQueryBuilder, calls }; +} + +const SORTABLE = { + createdAt: 'contract.createdAt', + contractValidUntil: 'contract.contractValidUntil', +}; + +describe('applySort', () => { + it('resolves a whitelisted sortBy to its column', () => { + const { qb, calls } = fakeQb(); + applySort(qb, { sortBy: 'contractValidUntil', sortOrder: 'ASC' }, SORTABLE, 'createdAt'); + expect(calls[0]).toEqual({ + method: 'orderBy', + args: ['contract.contractValidUntil', 'ASC'], + }); + }); + + it('falls back to the default column for an unknown sortBy instead of throwing', () => { + const { qb, calls } = fakeQb(); + // A stale bookmark or shared link naming a removed/renamed column must + // never 400 — it should silently behave as if sortBy were absent. + expect(() => + applySort(qb, { sortBy: "id; DROP TABLE contracts; --" }, SORTABLE, 'createdAt'), + ).not.toThrow(); + expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] }); + }); + + it('defaults sortOrder to DESC when absent or not ASC', () => { + const { qb, calls } = fakeQb(); + applySort(qb, {}, SORTABLE, 'createdAt'); + expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] }); + }); + + it('always appends an id ASC tiebreaker', () => { + const { qb, calls } = fakeQb(); + applySort(qb, { sortBy: 'createdAt' }, SORTABLE, 'createdAt'); + expect(calls[1]).toEqual({ method: 'addOrderBy', args: ['contract.id', 'ASC'] }); + }); +}); + +describe('normalizePagination / buildPaginationMeta', () => { + it('clamps page to >= 1 and pageSize to the configured max', () => { + const p = normalizePagination({ page: 0, pageSize: 999 }, { maxPageSize: 100 }); + expect(p).toEqual({ page: 1, pageSize: 100, skip: 0, take: 100 }); + }); + + it('computes hasNextPage/hasPreviousPage from total', () => { + const meta = buildPaginationMeta(45, 2, 20); + expect(meta).toEqual({ + page: 2, + pageSize: 20, + total: 45, + totalPages: 3, + hasNextPage: true, + hasPreviousPage: true, + }); + }); +}); diff --git a/apps/edr-freight-api/src/common/utils/pagination.util.ts b/apps/edr-freight-api/src/common/utils/pagination.util.ts index 310b2da6d..ca4ed35a1 100644 --- a/apps/edr-freight-api/src/common/utils/pagination.util.ts +++ b/apps/edr-freight-api/src/common/utils/pagination.util.ts @@ -83,3 +83,32 @@ export function paginateArray( meta: buildPaginationMeta(rows.length, page, pageSize), }; } + +/** + * Apply `ORDER BY` from a query DTO's `sortBy`/`sortOrder`, resolved against a + * whitelist — never interpolate `sortBy` into a query builder directly, it is + * unvalidated user input and an unwhitelisted `orderBy(\`alias.${sortBy}\`)` + * is a SQL-injection primitive (see the deleted `findAllWithFilters` methods + * on drivers/vehicles repositories, which had exactly that bug). + * + * An unknown `sortBy` falls back to `fallback` instead of throwing — a stale + * bookmark or shared link should never 400. + * + * Always appends `id ASC` as a tiebreaker: sorting by a non-unique column + * (status, createdAt on bulk-imported rows) without one can drop or + * duplicate rows across pages once LIMIT/OFFSET is involved. + * + * @param sortable public sort key -> "alias.column" SQL reference. Also + * doubles as the Swagger enum / frontend's sortable-column list. + * @param fallback a key that must exist in `sortable`. + */ +export function applySort( + qb: SelectQueryBuilder, + query: { sortBy?: string; sortOrder?: 'ASC' | 'DESC' }, + sortable: Record, + fallback: string, +): SelectQueryBuilder { + const column = (query.sortBy && sortable[query.sortBy]) || sortable[fallback]; + qb.orderBy(column, query.sortOrder === 'ASC' ? 'ASC' : 'DESC'); + return qb.addOrderBy(`${qb.alias}.id`, 'ASC'); +} diff --git a/apps/edr-freight-api/src/migrations/3540000000000-FilterableListIndexes.ts b/apps/edr-freight-api/src/migrations/3540000000000-FilterableListIndexes.ts new file mode 100644 index 000000000..1569b13d0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3540000000000-FilterableListIndexes.ts @@ -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 { + 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 { + 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`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index ea1523882..c029e36d4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -12,6 +12,7 @@ import { SelectQueryBuilder, } from 'typeorm'; +import { computeFacets, FacetBucket } from '../../common/utils/facets.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; @@ -857,6 +858,29 @@ export class BookingsRepository extends BaseRepository { }; } + /** + * 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 (see `applyListFilters`'s `omit` param). `bookingType` + * is derived from `contract_kind` (see the comment in `applyListFilters`), + * not a plain column, so it facets on the same CASE expression the filter + * itself applies. + */ + async getFacets(options: BookingListFilterOptions): Promise> { + return computeFacets( + () => this.repository.createQueryBuilder('booking').where('booking.deleted_at IS NULL'), + (qb, omit) => this.applyListFilters(qb, options, omit as keyof BookingListFilterOptions), + { + status: 'booking.status', + freightType: 'booking.freight_type', + tradeDirection: 'booking.trade_direction', + paymentStatus: 'booking.payment_status', + bookingType: + "CASE WHEN booking.contract_kind = 'GENERAL' THEN 'GENERAL_CONTRACT' ELSE 'ONE_TIME' END", + }, + ); + } + async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('booking') @@ -915,16 +939,24 @@ export class BookingsRepository extends BaseRepository { return { inQueue, onThisPage, needsAction, urgent }; } + /** + * @param omit skip this one predicate — used by `getFacets` so a facet's + * own filter doesn't hide its own sibling values. Every other caller + * (list, summary metrics) passes nothing. + */ private applyListFilters( qb: SelectQueryBuilder, options: BookingListFilterOptions, + omit?: keyof BookingListFilterOptions | 'status', ): void { - if (options.statuses?.length) { - qb.andWhere('booking.status IN (:...statuses)', { - statuses: options.statuses, - }); - } else if (options.status) { - qb.andWhere('booking.status = :status', { status: options.status }); + if (omit !== 'status') { + if (options.statuses?.length) { + qb.andWhere('booking.status IN (:...statuses)', { + statuses: options.statuses, + }); + } else if (options.status) { + qb.andWhere('booking.status = :status', { status: options.status }); + } } if (options.companyId) { @@ -957,12 +989,12 @@ export class BookingsRepository extends BaseRepository { cargoTypeId: options.cargoTypeId, }); } - if (options.freightType) { + if (omit !== 'freightType' && options.freightType) { qb.andWhere('booking.freight_type = :freightType', { freightType: options.freightType, }); } - if (options.bookingType) { + if (omit !== 'bookingType' && options.bookingType) { // The stored booking_type column is 'ONE_TIME' for every row (contract // drawdowns included — see contract-booking.service create), so the // one-time vs general split keys on the denormalized contract_kind: @@ -1011,12 +1043,12 @@ export class BookingsRepository extends BaseRepository { } else if (options.isGovernment === 'false') { qb.andWhere('booking.is_government = FALSE'); } - if (options.tradeDirection) { + if (omit !== 'tradeDirection' && options.tradeDirection) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, }); } - if (options.tradeDirections) { + if (omit !== 'tradeDirection' && options.tradeDirections) { applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections); } if (options.paymentCurrency) { @@ -1024,7 +1056,7 @@ export class BookingsRepository extends BaseRepository { paymentCurrency: options.paymentCurrency, }); } - if (options.paymentStatus) { + if (omit !== 'paymentStatus' && options.paymentStatus) { qb.andWhere('booking.payment_status = :paymentStatus', { paymentStatus: options.paymentStatus, }); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 4f26c4415..a9e9dfc34 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -2051,8 +2051,9 @@ export class BookingsService { consolidationPaired: filter.consolidationPaired, }; - const [statusCounts, metrics] = await Promise.all([ + const [statusCounts, facets, metrics] = await Promise.all([ this.bookingsRepository.getStatusCounts(), + this.bookingsRepository.getFacets(listFilter), this.bookingsRepository.getListSummaryMetrics({ ...listFilter, page, @@ -2064,7 +2065,9 @@ export class BookingsService { return { metrics, + // Tabs stay unfiltered (whole-set) on purpose — see the DTO comment. tabs: mapStatusCountsToTabs(statusCounts), + facets, }; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts index 30ca4bdb7..582cde166 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { FacetBucket } from '../../../common/utils/facets.util'; export class BookingListSummaryMetricsDto { @ApiProperty({ example: 42 }) @@ -31,4 +32,18 @@ export class BookingListSummaryDto { @ApiProperty({ type: BookingListSummaryTabsDto }) tabs!: BookingListSummaryTabsDto; + + /** + * Per-column value counts for the filter bar's enum popovers, scoped to + * every OTHER currently-active filter (own predicate omitted per column — + * see `BookingsRepository.getFacets`). Unlike `tabs`, which is + * deliberately unfiltered so tab counts stay stable while you filter + * within a tab, these move with the filter set. + */ + @ApiProperty({ + description: 'Facet counts keyed by filter field, for the pill filter bar', + type: 'object', + additionalProperties: { type: 'array', items: { type: 'object' } }, + }) + facets!: Record; } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 3e76581a8..f618986c4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -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 { 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, 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 { 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 { 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 { } } + /** + * 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> { + 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). */ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index cacfb5732..5798d44ef 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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. */ diff --git a/apps/edr-freight-api/src/modules/contracts/dto/contract-list-summary.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/contract-list-summary.dto.ts index 78b7d97c6..12e4e5aca 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/contract-list-summary.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/contract-list-summary.dto.ts @@ -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; + + /** + * 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; } diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.repository.ts b/apps/edr-freight-api/src/modules/drivers/drivers.repository.ts index 64be88b61..d6ff938f1 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.repository.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.repository.ts @@ -29,52 +29,6 @@ export class DriversRepository extends BaseRepository { return this.repository.findOne({ where: { id } }); } - async findAllWithFilters(query: { - page?: number; - pageSize?: number; - search?: string; - status?: string; - sortBy?: string; - sortOrder?: 'ASC' | 'DESC'; - }) { - const page = query.page || 1; - const pageSize = query.pageSize || 10; - const skip = (page - 1) * pageSize; - - let queryBuilder = this.repository.createQueryBuilder('driver'); - - if (query.search) { - queryBuilder = queryBuilder.where( - '(driver.firstName ILIKE :search OR driver.lastName ILIKE :search OR driver.email ILIKE :search OR driver.phoneNumber ILIKE :search OR driver.licenseNumber ILIKE :search)', - { search: `%${query.search}%` }, - ); - } - - if (query.status) { - queryBuilder = queryBuilder.andWhere('driver.status = :status', { - status: query.status, - }); - } - - const sortBy = query.sortBy || 'createdAt'; - const sortOrder = query.sortOrder || 'DESC'; - - queryBuilder = queryBuilder - .orderBy(`driver.${sortBy}`, sortOrder) - .skip(skip) - .take(pageSize); - - const [data, total] = await queryBuilder.getManyAndCount(); - - return { - data, - total, - page, - pageSize, - totalPages: Math.ceil(total / pageSize), - }; - } - async createDriver(driverData: any): Promise { const driver = this.repository.create(driverData); const result = await this.repository.save(driver); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts index 1080480fa..c5af8cee2 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts @@ -21,52 +21,6 @@ export class VehiclesRepository extends BaseRepository { return this.repository.findOne({ where: { id } }); } - async findAllWithFilters(query: { - page?: number; - pageSize?: number; - search?: string; - status?: string; - sortBy?: string; - sortOrder?: 'ASC' | 'DESC'; - }) { - const page = query.page || 1; - const pageSize = query.pageSize || 10; - const skip = (page - 1) * pageSize; - - let queryBuilder = this.repository.createQueryBuilder('vehicle'); - - if (query.search) { - queryBuilder = queryBuilder.where( - '(vehicle.plateNumber ILIKE :search OR vehicle.manufacturer ILIKE :search OR vehicle.model ILIKE :search)', - { search: `%${query.search}%` }, - ); - } - - if (query.status) { - queryBuilder = queryBuilder.andWhere('vehicle.status = :status', { - status: query.status, - }); - } - - const sortBy = query.sortBy || 'createdAt'; - const sortOrder = query.sortOrder || 'DESC'; - - queryBuilder = queryBuilder - .orderBy(`vehicle.${sortBy}`, sortOrder) - .skip(skip) - .take(pageSize); - - const [data, total] = await queryBuilder.getManyAndCount(); - - return { - data, - total, - page, - pageSize, - totalPages: Math.ceil(total / pageSize), - }; - } - async createVehicle(vehicleData: Partial): Promise { const vehicle = this.repository.create(vehicleData); return this.repository.save(vehicle); diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx new file mode 100644 index 000000000..96aaaa22d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx @@ -0,0 +1,121 @@ +import { useState, type ReactNode } from "react"; +import { Anchor, Divider, Group, TextInput } from "@mantine/core"; +import { Search, Trash2 } from "lucide-react"; + +import type { FilterDef, SortOption } from "./types"; +import type { UseFilters } from "./useFilters"; +import { FilterPill } from "./FilterPill"; +import { MoreFiltersMenu } from "./MoreFiltersMenu"; +import { SavedViews } from "./SavedViews"; +import { SortControl } from "./SortControl"; + +export interface FilterBarProps { + defs: FilterDef[]; + controls: UseFilters; + searchPlaceholder?: string; + showSearch?: boolean; + /** value already "field:DIR" — the page's existing SORT_OPTIONS, moved not rewritten. */ + sortOptions?: SortOption[]; + /** localStorage namespace for saved views. Omit to hide the control. */ + viewId?: string; + /** Escape hatch: tabs, row count, a "New" button — rendered at the far right. */ + children?: ReactNode; +} + +export function FilterBar({ + defs, + controls, + searchPlaceholder = "Search…", + showSearch = true, + sortOptions, + viewId, + children, +}: FilterBarProps) { + // Filters just picked from "More filters" render as an already-open pill + // until the popover closes, then fall back to the ordinary pinned/active split. + const [justPicked, setJustPicked] = useState([]); + + const pinned = defs.filter((d) => !d.secondary || controls.values[d.key] || justPicked.includes(d.key)); + const secondary = defs.filter((d) => !pinned.includes(d)); + // Applied filters read first, left to right — a stable partition keeps + // each group in its original def order rather than resorting on every apply. + const orderedPinned = [ + ...pinned.filter((d) => controls.values[d.key]), + ...pinned.filter((d) => !controls.values[d.key]), + ]; + + return ( + // Two independent flex zones, not one big wrapping Group: the left side + // (search + pills + more filters + clear) wraps to as many lines as it + // needs; the right side (sort) stays put on the first line — `nowrap` + + // `flexShrink: 0` on the right zone stop it from ever getting pushed + // down when the left side overflows. +
+ + {viewId && ( + + )} + + {showSearch && ( + } + value={controls.searchText} + onChange={(e) => controls.setSearchText(e.currentTarget.value)} + size="xs" + radius="lg" + style={{ minWidth: 220 }} + /> + )} + + {orderedPinned.map((def) => ( + controls.setFilter(def.key, v)} + autoOpen={justPicked.includes(def.key)} + /> + ))} + + setJustPicked((prev) => [...prev, key])} + /> + + {controls.activeCount > 0 && ( + { + controls.clearFilters(); + setJustPicked([]); + }} + style={{ display: "inline-flex", alignItems: "center", gap: 4 }} + > + + Clear + + )} + + + {/* Sorting is a different kind of control (view order, not scope) — + cut off from the filter pills by a vertical divider and pinned to + the right, independent of how the left side wraps. */} + + {children} + {sortOptions && sortOptions.length > 0 && ( + <> + + + + )} + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx new file mode 100644 index 000000000..40ba372f8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { ActionIcon, Button, Popover } from "@mantine/core"; +import { Plus, X } from "lucide-react"; + +import type { FilterDef, FilterValue } from "./types"; +import { BooleanBody } from "./bodies/BooleanBody"; +import { DateBody } from "./bodies/DateBody"; +import { EnumBody } from "./bodies/EnumBody"; +import { NumberBody } from "./bodies/NumberBody"; +import { TextBody } from "./bodies/TextBody"; + +const BODIES: Record> = { + text: TextBody, + enum: EnumBody, + date: DateBody, + number: NumberBody, + boolean: BooleanBody, +}; + +function formatValue(def: FilterDef, value: FilterValue): string { + if (def.format) return def.format(value, def); + if (def.type === "enum") { + const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v); + return labels.join(", "); + } + if (def.type === "date" && value.v.length === 2) { + return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`; + } + return value.v.join(", "); +} + +export interface FilterPillProps { + def: FilterDef; + value: FilterValue | undefined; + onChange: (v: FilterValue | undefined) => void; + /** Opened immediately (used when picked from "More filters"). */ + autoOpen?: boolean; +} + +export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) { + const [opened, setOpened] = useState(Boolean(autoOpen)); + const Body = BODIES[def.type]; + const active = Boolean(value); + + return ( + + + + + + setOpened(false)} /> + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx b/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx new file mode 100644 index 000000000..2dc6c1240 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx @@ -0,0 +1,82 @@ +import { useMemo, useState } from "react"; +import { Button, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core"; +import { Plus, Search } from "lucide-react"; + +import type { FilterDef } from "./types"; + +export interface MoreFiltersMenuProps { + defs: FilterDef[]; + /** Called with the picked def's key — the caller pins it and opens its popover. */ + onPick: (key: string) => void; +} + +/** Searchable list over the page's secondary/inactive filters. Plain filter + list, + * not cmdk — a handful of static strings doesn't need a Combobox store. */ +export function MoreFiltersMenu({ defs, onPick }: MoreFiltersMenuProps) { + const [opened, setOpened] = useState(false); + const [query, setQuery] = useState(""); + + const visible = useMemo( + () => defs.filter((d) => d.label.toLowerCase().includes(query.toLowerCase())), + [defs, query], + ); + + if (defs.length === 0) return null; + + return ( + + + + + + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + size="sm" + autoFocus + /> + + + {visible.map((d) => ( + { + setOpened(false); + setQuery(""); + onPick(d.key); + }} + > + + + {d.label} + + + ))} + {visible.length === 0 && ( + + No matching filters + + )} + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx b/apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx new file mode 100644 index 000000000..a769ebf93 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx @@ -0,0 +1,25 @@ +import { SegmentedControl } from "@mantine/core"; +import { DEFAULT_OP, OPERATOR_LABELS, type FilterDef, type Operator } from "./types"; + +export interface OperatorSelectProps { + def: FilterDef; + value: Operator; + onChange: (op: Operator) => void; +} + +/** Renders nothing when a def has <= 1 operator — most defs, by design: type-aware + * operators are a capability, not a dropdown forced into every popover. */ +export function OperatorSelect({ def, value, onChange }: OperatorSelectProps) { + const operators = def.operators ?? [DEFAULT_OP[def.type]]; + if (operators.length <= 1) return null; + return ( + onChange(v as Operator)} + data={operators.map((op) => ({ value: op, label: OPERATOR_LABELS[op] }))} + mb="xs" + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx new file mode 100644 index 000000000..826356159 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/SavedViews.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { ActionIcon, Button, Menu, Modal, Stack, Text, TextInput } from "@mantine/core"; +import { useLocalStorage } from "@mantine/hooks"; +import { Bookmark, Check, Save, Trash2 } from "lucide-react"; + +interface SavedView { + id: string; + name: string; + query: string; +} + +export interface SavedViewsProps { + /** localStorage namespace — one page, not one user (single staff login per + * browser profile). ponytail: add ":" if shared-terminal login appears. */ + viewId: string; + currentQueryString: () => string; + applyQueryString: (query: string) => void; +} + +/** URL always wins: this menu only ever WRITES the URL, on click. Nothing + * reads a saved view at mount, so a shared link always beats a saved view — + * there is no "which one applies" branch to get wrong. */ +export function SavedViews({ viewId, currentQueryString, applyQueryString }: SavedViewsProps) { + const [views, setViews] = useLocalStorage({ + key: `edr:saved-views:${viewId}`, + defaultValue: [], + }); + const [saveOpen, setSaveOpen] = useState(false); + const [name, setName] = useState(""); + + const activeQuery = currentQueryString(); + const active = views.find((v) => v.query === activeQuery); + + const save = () => { + if (!name.trim()) return; + setViews((prev) => [ + ...prev, + { id: crypto.randomUUID(), name: name.trim(), query: currentQueryString() }, + ]); + setName(""); + setSaveOpen(false); + }; + + const remove = (id: string) => setViews((prev) => prev.filter((v) => v.id !== id)); + + return ( + <> + + + + + + {views.length === 0 && ( + + + No saved views yet + + + )} + {views.map((v) => ( + : } + rightSection={ + { + e.stopPropagation(); + remove(v.id); + }} + > + + + } + onClick={() => applyQueryString(v.query)} + > + {v.name} + + ))} + + } onClick={() => setSaveOpen(true)}> + Save current view… + + + + + setSaveOpen(false)} title="Save current view" size="sm"> + + setName(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && save()} + autoFocus + /> + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx new file mode 100644 index 000000000..cdb9dde33 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx @@ -0,0 +1,40 @@ +import { Button, Menu } from "@mantine/core"; +import { ArrowUpDown, Check } from "lucide-react"; + +import type { SortOption } from "./types"; + +export interface SortControlProps { + options: SortOption[]; + value: string; + onChange: (value: string) => void; +} + +/** A control, not a form field — Menu (not Select) gives the check-mark + + * trigger-label read Stripe's sort control has. Rendered only when a page + * passes sortOptions; inventing options for an endpoint without sortBy + * support would ship a control that silently does nothing. */ +export function SortControl({ options, value, onChange }: SortControlProps) { + if (options.length === 0) return null; + const current = options.find((o) => o.value === value); + + return ( + + + + + + {options.map((o) => ( + : } + onClick={() => onChange(o.value)} + > + {o.label} + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx new file mode 100644 index 000000000..fc59fa3bb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { Button, Radio, Stack } from "@mantine/core"; + +import { DEFAULT_OP } from "../types"; +import type { BooleanFilterDef, Operator } from "../types"; +import { OperatorSelect } from "../OperatorSelect"; +import type { FilterBodyProps } from "./TextBody"; + +export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps) { + const [op, setOp] = useState(value?.op ?? DEFAULT_OP.boolean); + const [v, setV] = useState(value?.v[0] ?? ""); + + const apply = () => { + onChange(v ? { op, v: [v] } : undefined); + onClose(); + }; + + return ( + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx new file mode 100644 index 000000000..a7c90c8c9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { Button, Stack } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; + +import { getDateRangePresets } from "@/components/common/dateRangePresets"; +import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates"; +import { DEFAULT_OP } from "../types"; +import type { DateFilterDef, Operator } from "../types"; +import { OperatorSelect } from "../OperatorSelect"; +import type { FilterBodyProps } from "./TextBody"; + +// ponytail: Gregorian only. Record-management pages need the Ethiopian +// calendar (see shared/common/form/fields/AmharicDatePicker.tsx) — add an +// i18n.language !== "en" branch here when this body is first wired into a +// record-management page (Phase 4 of the filter-bar rollout). +export function DateBody({ def, value, onChange, onClose }: FilterBodyProps) { + const [op, setOp] = useState(value?.op ?? DEFAULT_OP.date); + // Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects. + const [from, setFrom] = useState(value?.v[0]?.slice(0, 10) ?? null); + const [to, setTo] = useState(value?.v[1]?.slice(0, 10) ?? null); + + const apply = () => { + if (op === "between") { + onChange( + from && to + ? { op, v: [startOfDayIso(parseDateStr(from)), endOfDayIso(parseDateStr(to))] } + : undefined, + ); + } else { + onChange( + from + ? { + op, + v: [ + op === "before" + ? startOfDayIso(parseDateStr(from)) + : endOfDayIso(parseDateStr(from)), + ], + } + : undefined, + ); + } + onClose(); + }; + + // This popover already lives inside FilterPill's own Popover. Mantine's + // DatePickerInput opens ITS calendar in a separate portal by default, so a + // click on a day registers as "outside" the outer Popover and closes the + // whole filter before the range can be picked (or Apply reached) — the + // reported "date picker doesn't work". Keeping the calendar un-portalled + // renders it inside the outer popover's own DOM subtree instead, so + // outside-click detection sees it as inside. + const nestedPopoverProps = { withinPortal: false } as const; + + return ( + + + {op === "between" ? ( + { + setFrom(f); + setTo(t); + }} + presets={getDateRangePresets()} + popoverProps={nestedPopoverProps} + clearable + autoFocus + /> + ) : ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx new file mode 100644 index 000000000..459246dff --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx @@ -0,0 +1,128 @@ +import { useMemo, useState } from "react"; +import { Button, Checkbox, Group, Radio, Stack, Text, TextInput, UnstyledButton } from "@mantine/core"; +import { Search } from "lucide-react"; + +import { DEFAULT_OP } from "../types"; +import type { EnumFilterDef, Operator } from "../types"; +import { OperatorSelect } from "../OperatorSelect"; +import type { FilterBodyProps } from "./TextBody"; + +/** How many options before a search box appears above the list. */ +const SEARCH_THRESHOLD = 8; + +/** + * Stretches the Checkbox/Radio's native