mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(", ");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] }) },
|
||||
],
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user