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

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