feat(filters): route filter, select-styled trigger, tune default pin set

- 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.
This commit is contained in:
Nathnael
2026-08-15 07:23:43 +00:00
parent e5e75b4166
commit 839dfdbae3
10 changed files with 157 additions and 19 deletions

View File

@@ -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<Contract> {
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 },
);
}
}
/**

View File

@@ -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([

View File

@@ -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()

View File

@@ -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" }}
/>
)}

View File

@@ -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<FilterDef["type"], React.ComponentType<any>> = {
@@ -16,6 +17,7 @@ const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
date: DateBody,
number: NumberBody,
boolean: BooleanBody,
route: RouteBody,
};
export interface FilterPillProps {
@@ -37,18 +39,22 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
<Button
size="xs"
radius="xl"
// Inactive: "default" variant (solid border, opaque text) reads far
// less faint than a color-tinted outline — dashed border is the only
// thing marking it as "not set yet". Active: "light" (soft tinted
// fill), not "filled" — a whole row of solid green buttons was the
// "too loud" complaint; light keeps the active/inactive contrast
// without shouting.
// Inactive: styled like a closed Mantine Select trigger — solid
// (opaque, not dashed) border, label + trailing chevron, no leading
// "+" — just a smaller/pill-shaped version of that same control.
// Active: "light" (soft tinted fill), not "filled" — a whole row of
// solid green buttons was the "too loud" complaint; light keeps the
// active/inactive contrast without shouting. Popover side/position
// is untouched either way.
variant={active ? "light" : "default"}
color={active ? "edr-green" : undefined}
styles={active ? undefined : { root: { borderStyle: "dashed" } }}
leftSection={!active && <Plus size={12} />}
styles={
active
? undefined
: { root: { borderColor: "var(--mantine-color-gray-6)", color: "var(--mantine-color-gray-9)" } }
}
rightSection={
active && (
active ? (
<ActionIcon
component="span"
size={16}
@@ -62,6 +68,8 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
>
<X size={12} />
</ActionIcon>
) : (
<ChevronDown size={14} />
)
}
onClick={() => setOpened((o) => !o)}

View File

@@ -0,0 +1,50 @@
import { useState } from "react";
import { Button, Select, Stack } from "@mantine/core";
import { ArrowRight } from "lucide-react";
import type { RouteFilterDef } from "../types";
import type { FilterBodyProps } from "./TextBody";
/**
* 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.
*/
export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<RouteFilterDef>) {
const [origin, setOrigin] = useState<string | null>(value?.v[0] ?? null);
const [destination, setDestination] = useState<string | null>(value?.v[1] ?? null);
const apply = () => {
onChange(origin && destination ? { op: "is", v: [origin, destination] } : undefined);
onClose();
};
return (
<Stack gap="xs" w={240}>
<Select
label="Origin"
placeholder="Any"
data={def.options}
value={origin}
onChange={setOrigin}
searchable
clearable
autoFocus
/>
<ArrowRight size={14} className="text-gray-400" style={{ alignSelf: "center" }} />
<Select
label="Destination"
placeholder="Any"
data={def.options}
value={destination}
onChange={setDestination}
searchable
clearable
/>
<Button size="sm" onClick={apply} disabled={!(origin && destination)}>
Apply
</Button>
</Stack>
);
}

View File

@@ -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(", ");
}

View File

@@ -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<FilterType, Operator> = {
date: "between",
number: "is",
boolean: "is",
route: "is",
};
export const OPERATOR_LABELS: Record<Operator, string> = {
@@ -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 {

View File

@@ -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] }) },
],

View File

@@ -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, {