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 af97d2fcf..5f1a2b86f 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx @@ -21,8 +21,9 @@ const BODIES: Record> = { }; // Most bodies fit a narrow popover; a date range needs room for the presets -// sidebar next to the calendar, so it gets a wider minimum. -const DROPDOWN_WIDTH: Partial> = { date: 340 }; +// sidebar next to the calendar, and a route needs room for two multi-selects' +// worth of yard chips, so both get a wider minimum. +const DROPDOWN_WIDTH: Partial> = { date: 340, route: 320 }; export interface FilterPillProps { def: FilterDef; diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx index 55097b05b..ff4e2e5ac 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/RouteBody.tsx @@ -1,59 +1,127 @@ import { useState } from "react"; -import { Button, Select, Stack } from "@mantine/core"; -import { ArrowRight } from "lucide-react"; +import { ActionIcon, Button, Group, MultiSelect, Stack, Text, Tooltip } from "@mantine/core"; +import { ArrowDown, ArrowUpDown } from "lucide-react"; import type { RouteFilterDef } from "../types"; +import { decodeRouteValue, encodeRouteValue, type RouteSelection } from "../route"; import type { FilterBodyProps } from "./TextBody"; +type Side = keyof RouteSelection; + /** - * Origin + destination picked together, each a searchable `Select` over the - * page's yard list — typing filters by yard name, same as any Mantine - * Select. No `OperatorSelect`: a route pair has exactly one operator ("is"), - * which is why DEFAULT_OP.route is the only entry the generic bar needs. + * Origin and destination as two independent multi-selects, either of which may + * be left empty. That is the whole point: "everything leaving Nagad" and + * "everything arriving at Gelan" are real questions an operator asks, and the + * previous body — two single Selects behind an Apply gated on + * `origin && destination` — could only ask the third one. + * + * Semantics are OR inside a side, AND across the two, which the hint line + * below spells out in words rather than making the user infer it from a + * checkbox list. + * + * No `OperatorSelect`: a route still has exactly one operator ("is"), which is + * why DEFAULT_OP.route is the only entry the generic bar needs. */ export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps) { - const [origin, setOrigin] = useState(value?.v[0] ?? null); - const [destination, setDestination] = useState(value?.v[1] ?? null); + const [sel, setSel] = useState(() => decodeRouteValue(value?.v ?? [])); + const [search, setSearch] = useState>({ origins: "", destinations: "" }); + + const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id; + const list = (ids: string[]) => ids.map(label).join(" or "); + + // Spelled out, because "OR within a side, AND across sides" is not something + // two stacked pickers communicate on their own. + const hint = !sel.origins.length && !sel.destinations.length + ? "Type to search stations. Pick an origin, a destination, or both." + : !sel.destinations.length + ? `Everything leaving ${list(sel.origins)}.` + : !sel.origins.length + ? `Everything arriving at ${list(sel.destinations)}.` + : `From ${list(sel.origins)} to ${list(sel.destinations)}.`; const apply = () => { - onChange(origin && destination ? { op: "is", v: [origin, destination] } : undefined); + onChange(encodeRouteValue(sel)); onClose(); }; - // This popover already lives inside FilterPill's own Popover. A Select's - // dropdown portals separately by default, so a click on an option registers - // as "outside" the outer Popover and closes the whole filter before a pick + // This popover already lives inside FilterPill's own Popover. A dropdown + // portals separately by default, so a click on an option registers as + // "outside" the outer Popover and closes the whole filter before a pick // lands — same nested-portal bug DateBody had. Un-portalling keeps it // inside the outer popover's DOM subtree instead. const comboboxProps = { withinPortal: false } as const; + /** + * The list stays shut until there is something typed. Two open triggers have + * to be neutralised for that, not one: `openOnFocus={false}` handles the + * focus, but MultiSelect's PillsInput root ALSO calls `openDropdown()` on + * every click, ungated — so the only reliable lever is driving + * `dropdownOpened` ourselves off the search text. + * + * Consequence worth knowing: Mantine clears the search on each pick + * (`clearSearchOnChange`, default true), so the list closes after one is + * chosen and typing reopens it. That is the intended resting state — the + * popover opens showing what is already selected, not a wall of stations. + */ + const sideProps = (side: Side) => ({ + data: def.options, + placeholder: sel[side].length ? "Add another" : "Any", + value: sel[side], + onChange: (next: string[]) => setSel((s) => ({ ...s, [side]: next })), + searchValue: search[side], + onSearchChange: (q: string) => setSearch((s) => ({ ...s, [side]: q })), + dropdownOpened: search[side].trim().length > 0, + openOnFocus: false, + comboboxProps, + searchable: true, + clearable: true, + hidePickedOptions: true, + maxDropdownHeight: 200, + nothingFoundMessage: "No station matches", + }); + return ( - - - + + + + + + + setSel((s) => ({ origins: s.destinations, destinations: s.origins }))} + > + + + + + + + + + {hint} + + + + + {/* Enabled even when empty: applying nothing removes the filter, which + is how every other body's Apply behaves. */} + + ); } 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 ebb9e167a..af3908be0 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/format.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/format.ts @@ -1,5 +1,6 @@ import { formatDate } from "@/lib/format"; import { parseFilters } from "./url"; +import { decodeRouteValue } from "./route"; import { OPERATOR_LABELS, type FilterDef, type FilterValue } from "./types"; /** Human-readable text for one filter's current value — same text a @@ -22,9 +23,18 @@ export function formatFilterValue(def: FilterDef, value: FilterValue): string { if (value.op === "between" && days.length === 2) return `${days[0]} → ${days[1]}`; return `${OPERATOR_LABELS[value.op]} ${days[0]}`; } - if (def.type === "route" && value.v.length === 2) { + if (def.type === "route") { const label = (id: string) => def.options.find((o) => o.value === id)?.label ?? id; - return `${label(value.v[0])} → ${label(value.v[1])}`; + // "Any" reads as an unconstrained end; a long side collapses to "first +N" + // so the pill can't grow past the rest of the bar. + const side = (ids: string[]) => + ids.length === 0 + ? "Any" + : ids.length <= 2 + ? ids.map(label).join(", ") + : `${label(ids[0])} +${ids.length - 1}`; + const { origins, destinations } = decodeRouteValue(value.v); + return `${side(origins)} → ${side(destinations)}`; } return value.v.join(", "); } diff --git a/apps/edr-freight-web/backoffice/src/components/filters/index.ts b/apps/edr-freight-web/backoffice/src/components/filters/index.ts index ea2c28fb2..8d63e6200 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/index.ts @@ -1,6 +1,7 @@ export * from "./types"; export * from "./url"; export * from "./dates"; +export * from "./route"; export * from "./format"; export * from "./clientFilter"; export * from "./ruleEngineFooterProps"; diff --git a/apps/edr-freight-web/backoffice/src/components/filters/route.test.ts b/apps/edr-freight-web/backoffice/src/components/filters/route.test.ts new file mode 100644 index 000000000..9b5c2675f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/route.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import type { FilterDef } from "./types"; +import { decodeRouteValue, encodeRouteValue, routeParams } from "./route"; +import { decodeFilterValue, encodeFilterValue, toApiParams } from "./url"; +import { formatFilterValue } from "./format"; + +const NAGAD = "11111111-1111-1111-1111-111111111111"; +const DMP = "22222222-2222-2222-2222-222222222222"; +const GELAN = "33333333-3333-3333-3333-333333333333"; + +const ROUTE: FilterDef = { + key: "route", + label: "Route", + type: "route", + options: [ + { value: NAGAD, label: "Nagad" }, + { value: DMP, label: "DMP" }, + { value: GELAN, label: "Gelan" }, + ], + toParams: routeParams("originYardId", "destinationYardId"), +}; + +describe("route filter value", () => { + it("round-trips each side independently, through the URL codec", () => { + const cases = [ + { origins: [NAGAD], destinations: [] }, + { origins: [], destinations: [GELAN] }, + { origins: [NAGAD, DMP], destinations: [GELAN] }, + ]; + for (const sel of cases) { + const value = encodeRouteValue(sel)!; + const raw = encodeFilterValue("route", value); + expect(decodeRouteValue(decodeFilterValue("route", raw)!.v)).toEqual(sel); + } + }); + + it("is no filter at all when both sides are empty", () => { + expect(encodeRouteValue({ origins: [], destinations: [] })).toBeUndefined(); + }); + + it("omits an unconstrained side rather than sending an empty param", () => { + const value = encodeRouteValue({ origins: [NAGAD, DMP], destinations: [] })!; + expect(toApiParams([ROUTE], { route: value })).toEqual({ + originYardId: `${NAGAD},${DMP}`, + destinationYardId: undefined, + }); + }); + + it("still reads the legacy untagged `route=,` pair", () => { + expect(decodeRouteValue([NAGAD, GELAN])).toEqual({ + origins: [NAGAD], + destinations: [GELAN], + }); + }); + + it("labels an empty side 'Any' in the pill", () => { + const value = encodeRouteValue({ origins: [], destinations: [GELAN] })!; + expect(formatFilterValue(ROUTE, value)).toBe("Any → Gelan"); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/filters/route.ts b/apps/edr-freight-web/backoffice/src/components/filters/route.ts new file mode 100644 index 000000000..4a0fab05f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/route.ts @@ -0,0 +1,67 @@ +import type { FilterValue } from "./types"; + +const ORIGIN = "o:"; +const DEST = "d:"; + +export interface RouteSelection { + origins: string[]; + destinations: string[]; +} + +/** + * A route filter's `v` is one flat, TAGGED list — `["o:", "d:", …]`. + * + * It has to be flat because `url.ts` knows exactly one encoding for a filter + * value: comma-split inside a single query param. The tags are what buy back + * the two sides, and with them the three things the old fixed + * `[origin, destination]` pair could not express: + * + * - origin only — "everything leaving Nagad" + * - destination only — "everything arriving at Gelan" + * - several yards per side — "leaving Nagad OR DMP, arriving Gelan OR Indode" + * + * Semantics: OR within a side, AND across the two. An empty side is not a + * filter at all (see {@link routeParams}), never "matches nothing". + */ +export function decodeRouteValue(v: string[]): RouteSelection { + const origins: string[] = []; + const destinations: string[] = []; + for (const entry of v) { + if (entry.startsWith(ORIGIN)) origins.push(entry.slice(ORIGIN.length)); + else if (entry.startsWith(DEST)) destinations.push(entry.slice(DEST.length)); + } + // Legacy `?route=,`: deep links and saved views + // written before the tags existed. Untagged, and always exactly the pair. + if (!origins.length && !destinations.length && v.length === 2) { + return { origins: [v[0]], destinations: [v[1]] }; + } + return { origins, destinations }; +} + +/** Inverse of {@link decodeRouteValue}. `undefined` when both sides are empty — that is "no filter". */ +export function encodeRouteValue(sel: RouteSelection): FilterValue | undefined { + const v = [ + ...sel.origins.map((id) => `${ORIGIN}${id}`), + ...sel.destinations.map((id) => `${DEST}${id}`), + ]; + return v.length ? { op: "is", v } : undefined; +} + +/** + * `toParams` for a route filter — each side onto its own comma-separated API + * param, mirroring `dateRangeParams`. An empty side maps to `undefined` so + * `cleanParams` drops the param entirely; sending `originYardId=` instead + * would have the server filter on an empty list. + */ +export function routeParams( + originKey: string, + destinationKey: string, +): (v: FilterValue) => Record { + return (value) => { + const { origins, destinations } = decodeRouteValue(value.v); + return { + [originKey]: origins.join(",") || undefined, + [destinationKey]: destinations.join(",") || undefined, + }; + }; +} 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 9f0c35d43..c3cb5cdb9 100644 --- a/apps/edr-freight-web/backoffice/src/components/filters/types.ts +++ b/apps/edr-freight-web/backoffice/src/components/filters/types.ts @@ -89,11 +89,11 @@ export interface BooleanFilterDef extends FilterDefBase { } /** - * 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. + * Origin + destination as one pill, each side holding any number of yards and + * either side allowed to be empty. `v` is the tagged flat list described in + * `route.ts` — use `decodeRouteValue` / `encodeRouteValue` to read or write it, + * and `routeParams(originKey, destinationKey)` as the def's `toParams`. One + * shared `options` list drives both sides. */ export interface RouteFilterDef extends FilterDefBase { type: "route"; 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 b7f64d213..dcb561570 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -28,7 +28,7 @@ import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { ExportButton } from "@/components/export/ExportButton"; import { formatDate, humanize } from "@/lib/format"; -import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters"; +import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. @@ -170,7 +170,7 @@ export default function BookingRequestsPage() { { key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true }, { key: "route", label: "Route", type: "route", options: yardOptions, - toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), + toParams: routeParams("originYardId", "destinationYardId"), }, { key: "created", label: "Created", type: "date", secondary: true, 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 4f761c6f0..455a6473e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -52,7 +52,7 @@ import { DataTableFooter, type ColumnDef, } from "@edr/ui-common"; -import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters"; +import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters"; import { ExportButton } from "@/components/export/ExportButton"; /** Every filterable status — the pill tabs are gone, so the select carries them all. */ @@ -184,7 +184,7 @@ export default function ContractRequestsPage() { label: "Route", type: "route", options: yardOptions, - toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }), + toParams: routeParams("originYardId", "destinationYardId"), }, ], [filterOptions, yardOptions, serviceTypeOptions],