From 839dfdbae36bc39f87c7bf01a3d9bd595ae4876e Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 15 Aug 2026 07:23:43 +0000 Subject: [PATCH] feat(filters): route filter, select-styled trigger, tune default pin set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New "route" FilterType: RouteBody popover (searchable origin + destination selects, apply once both are picked), wired into ContractRequestsPage and BookingRequestsPage. Bookings already had server-side originYardId/destinationYardId; contracts gets both new (contract_routes is one-to-many, so origin/destination are separate EXISTS subqueries, not a join). - Inactive FilterPill trigger restyled to read like a closed Mantine Select (opaque solid border, trailing chevron) instead of a dashed "+" pill — trigger only, popover body/position unchanged. - Search box text set to regular weight. - A couple more filters (Direction, Freight) pinned by default per page on top of the existing always-pinned ones; the rest stay behind More filters. --- .../modules/contracts/contracts.repository.ts | 21 ++++++++ .../modules/contracts/contracts.service.ts | 4 ++ .../contracts/dto/filter-contract.dto.ts | 16 ++++++ .../src/components/filters/FilterBar.tsx | 1 + .../src/components/filters/FilterPill.tsx | 28 +++++++---- .../components/filters/bodies/RouteBody.tsx | 50 +++++++++++++++++++ .../src/components/filters/format.ts | 4 ++ .../src/components/filters/types.ts | 18 ++++++- .../pages/bookings/BookingRequestsPage.tsx | 10 ++-- .../pages/contracts/ContractRequestsPage.tsx | 24 +++++++-- 10 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx 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 f618986c4..d89d43250 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -48,6 +48,8 @@ export interface ContractListFilterOptions { hasClearanceDocuments?: boolean; createdFrom?: string; createdTo?: string; + originYardId?: string; + destinationYardId?: string; } @Injectable() @@ -489,6 +491,25 @@ export class ContractsRepository extends BaseRepository { if (options.createdTo) { qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo }); } + // Routes are one-to-many (a contract can list several lanes), so origin + // and destination each need their own EXISTS — a plain join would + // duplicate the contract row per matching route. + if (omit !== 'originYardId' && options.originYardId) { + qb.andWhere( + 'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' + + 'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' + + 'AND cr_o.origin_yard_id = :originYardId)', + { originYardId: options.originYardId }, + ); + } + if (omit !== 'destinationYardId' && options.destinationYardId) { + qb.andWhere( + 'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' + + 'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' + + 'AND cr_d.destination_yard_id = :destinationYardId)', + { destinationYardId: options.destinationYardId }, + ); + } } /** 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 5798d44ef..3411414e9 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -768,6 +768,8 @@ export class ContractsService { paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, + originYardId: filter.originYardId, + destinationYardId: filter.destinationYardId, search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -789,6 +791,8 @@ export class ContractsService { paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, + originYardId: filter.originYardId, + destinationYardId: filter.destinationYardId, }; const [facets, metrics] = await Promise.all([ diff --git a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts index 8950e7eb3..a03d52afb 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts @@ -61,6 +61,22 @@ export class FilterContractDto { @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: 'Only contracts with a route starting at this yard.', + }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Only contracts with a route ending at this yard.', + }) + @IsOptional() + @IsUUID() + destinationYardId?: string; + @ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' }) @IsOptional() @IsDateString() diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx index a712d189e..6a0aaac96 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx @@ -84,6 +84,7 @@ export function FilterBar({ onChange={(e) => controls.setSearchText(e.currentTarget.value)} size="xs" radius="lg" + styles={{ input: { fontWeight: 400 } }} style={{ minWidth: 160, flex: "1 1 160px" }} /> )} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx index 7a4a76998..0275f67ad 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { ActionIcon, Button, Popover } from "@mantine/core"; -import { Plus, X } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; import type { FilterDef, FilterValue } from "./types"; import { formatFilterValue } from "./format"; @@ -8,6 +8,7 @@ import { BooleanBody } from "./bodies/BooleanBody"; import { DateBody } from "./bodies/DateBody"; import { EnumBody } from "./bodies/EnumBody"; import { NumberBody } from "./bodies/NumberBody"; +import { RouteBody } from "./bodies/RouteBody"; import { TextBody } from "./bodies/TextBody"; const BODIES: Record> = { @@ -16,6 +17,7 @@ const BODIES: Record> = { date: DateBody, number: NumberBody, boolean: BooleanBody, + route: RouteBody, }; export interface FilterPillProps { @@ -37,18 +39,22 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/format.ts b/apps/edr-freight-web/backoffice/src/components/filters/format.ts index c3ca16798..24489abf8 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/format.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/format.ts @@ -13,6 +13,10 @@ export function formatFilterValue(def: FilterDef, value: FilterValue): string { if (def.type === "date" && value.v.length === 2) { return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`; } + if (def.type === "route" && value.v.length === 2) { + const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id; + return `${label(value.v[0])} → ${label(value.v[1])}`; + } return value.v.join(", "); } diff --git a/apps/edr-freight-web/backoffice/src/components/filters/types.ts b/apps/edr-freight-web/backoffice/src/components/filters/types.ts index 597d74f6f..9f0c35d43 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/types.ts @@ -1,4 +1,4 @@ -export type FilterType = "text" | "enum" | "date" | "number" | "boolean"; +export type FilterType = "text" | "enum" | "date" | "number" | "boolean" | "route"; export type Operator = "is" | "isNot" | "contains" | "between" | "before" | "after"; @@ -9,6 +9,7 @@ export const DEFAULT_OP: Record = { date: "between", number: "is", boolean: "is", + route: "is", }; export const OPERATOR_LABELS: Record = { @@ -87,12 +88,25 @@ export interface BooleanFilterDef extends FilterDefBase { falseLabel?: string; } +/** + * Origin + destination picked together as one pill — `v` is always the + * 2-slot pair `[originYardId, destinationYardId]`, never partial (the body's + * Apply button stays disabled until both sides are chosen, same rule + * `DateBody` uses for a `between` range). One shared `options` list drives + * both selects. + */ +export interface RouteFilterDef extends FilterDefBase { + type: "route"; + options: FilterOption[]; +} + export type FilterDef = | TextFilterDef | EnumFilterDef | DateFilterDef | NumberFilterDef - | BooleanFilterDef; + | BooleanFilterDef + | RouteFilterDef; /** A page's sort options — value is already `"field:DIR"`, the codebase's existing convention. */ export interface SortOption { diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index cef8ca21a..c1b5c85ac 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -136,9 +136,9 @@ export default function BookingRequestsPage() { { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { key: "tradeDirection", label: "Direction", type: "enum", multiple: false, - options: filterOptions(TRADE_DIRECTION_OPTIONS), secondary: true, + options: filterOptions(TRADE_DIRECTION_OPTIONS), }, - { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS, secondary: true }, + { key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS }, { key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true }, { // Wins over the `paymentStatus` filter above — the queue is by @@ -149,8 +149,10 @@ export default function BookingRequestsPage() { toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}), }, { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true }, - { key: "originYardId", label: "Origin", type: "enum", multiple: false, options: yardOptions, secondary: true }, - { key: "destinationYardId", label: "Destination", type: "enum", multiple: false, options: yardOptions, secondary: true }, + { + key: "route", label: "Route", type: "route", options: yardOptions, secondary: true, + toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), + }, { key: "created", label: "Created", type: "date", secondary: true, toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }) }, { key: "scheduled", label: "Scheduled", type: "date", secondary: true, toParams: ({ v }) => ({ scheduledFrom: v[0], scheduledTo: v[1] }) }, ], diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index e50a090a2..969c97d0c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -23,6 +23,8 @@ import { } from "lucide-react"; import { useCallback, useMemo, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; @@ -103,6 +105,16 @@ export default function ContractRequestsPage() { const navigate = useNavigate(); const { filterOptions } = useMyTradeAccess(); + // Yard options for the route filter (shared routes reference list, same + // query BookingRequestsPage uses). + const { data: yardRefs } = useQuery( + api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }), + ); + const yardOptions = useMemo( + () => (yardRefs ?? []).map((y) => ({ value: y.id, label: y.label ?? y.code })), + [yardRefs], + ); + // Static shape only (no facet counts) — this is what useFilters needs to // parse the URL and build API params. Counts are attached separately below, // for rendering only, once the summary query (which itself depends on @@ -123,7 +135,6 @@ export default function ContractRequestsPage() { type: "enum", multiple: false, options: filterOptions(TRADE_DIRECTION_OPTIONS), - secondary: true, }, { key: "freightType", @@ -131,7 +142,6 @@ export default function ContractRequestsPage() { type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS, - secondary: true, }, { key: "paymentCurrency", @@ -150,8 +160,16 @@ export default function ContractRequestsPage() { // onChange, so this just routes to the API's existing param names. toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }), }, + { + key: "route", + label: "Route", + type: "route", + options: yardOptions, + secondary: true, + toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), + }, ], - [filterOptions], + [filterOptions, yardOptions], ); const controls = useFilters(filterDefs, {