mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +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;
|
hasClearanceDocuments?: boolean;
|
||||||
createdFrom?: string;
|
createdFrom?: string;
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
|
originYardId?: string;
|
||||||
|
destinationYardId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -489,6 +491,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
if (options.createdTo) {
|
if (options.createdTo) {
|
||||||
qb.andWhere('contract.created_at <= :createdTo', { createdTo: 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,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
createdFrom: filter.createdFrom,
|
createdFrom: filter.createdFrom,
|
||||||
createdTo: filter.createdTo,
|
createdTo: filter.createdTo,
|
||||||
|
originYardId: filter.originYardId,
|
||||||
|
destinationYardId: filter.destinationYardId,
|
||||||
search: filter.search,
|
search: filter.search,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
@@ -789,6 +791,8 @@ export class ContractsService {
|
|||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
createdFrom: filter.createdFrom,
|
createdFrom: filter.createdFrom,
|
||||||
createdTo: filter.createdTo,
|
createdTo: filter.createdTo,
|
||||||
|
originYardId: filter.originYardId,
|
||||||
|
destinationYardId: filter.destinationYardId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [facets, metrics] = await Promise.all([
|
const [facets, metrics] = await Promise.all([
|
||||||
|
|||||||
@@ -61,6 +61,22 @@ export class FilterContractDto {
|
|||||||
@IsIn([...PAYMENT_CURRENCIES])
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
paymentCurrency?: string;
|
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)' })
|
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export function FilterBar({
|
|||||||
onChange={(e) => controls.setSearchText(e.currentTarget.value)}
|
onChange={(e) => controls.setSearchText(e.currentTarget.value)}
|
||||||
size="xs"
|
size="xs"
|
||||||
radius="lg"
|
radius="lg"
|
||||||
|
styles={{ input: { fontWeight: 400 } }}
|
||||||
style={{ minWidth: 160, flex: "1 1 160px" }}
|
style={{ minWidth: 160, flex: "1 1 160px" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ActionIcon, Button, Popover } from "@mantine/core";
|
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 type { FilterDef, FilterValue } from "./types";
|
||||||
import { formatFilterValue } from "./format";
|
import { formatFilterValue } from "./format";
|
||||||
@@ -8,6 +8,7 @@ import { BooleanBody } from "./bodies/BooleanBody";
|
|||||||
import { DateBody } from "./bodies/DateBody";
|
import { DateBody } from "./bodies/DateBody";
|
||||||
import { EnumBody } from "./bodies/EnumBody";
|
import { EnumBody } from "./bodies/EnumBody";
|
||||||
import { NumberBody } from "./bodies/NumberBody";
|
import { NumberBody } from "./bodies/NumberBody";
|
||||||
|
import { RouteBody } from "./bodies/RouteBody";
|
||||||
import { TextBody } from "./bodies/TextBody";
|
import { TextBody } from "./bodies/TextBody";
|
||||||
|
|
||||||
const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
|
const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
|
||||||
@@ -16,6 +17,7 @@ const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
|
|||||||
date: DateBody,
|
date: DateBody,
|
||||||
number: NumberBody,
|
number: NumberBody,
|
||||||
boolean: BooleanBody,
|
boolean: BooleanBody,
|
||||||
|
route: RouteBody,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FilterPillProps {
|
export interface FilterPillProps {
|
||||||
@@ -37,18 +39,22 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
|
|||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
// Inactive: "default" variant (solid border, opaque text) reads far
|
// Inactive: styled like a closed Mantine Select trigger — solid
|
||||||
// less faint than a color-tinted outline — dashed border is the only
|
// (opaque, not dashed) border, label + trailing chevron, no leading
|
||||||
// thing marking it as "not set yet". Active: "light" (soft tinted
|
// "+" — just a smaller/pill-shaped version of that same control.
|
||||||
// fill), not "filled" — a whole row of solid green buttons was the
|
// Active: "light" (soft tinted fill), not "filled" — a whole row of
|
||||||
// "too loud" complaint; light keeps the active/inactive contrast
|
// solid green buttons was the "too loud" complaint; light keeps the
|
||||||
// without shouting.
|
// active/inactive contrast without shouting. Popover side/position
|
||||||
|
// is untouched either way.
|
||||||
variant={active ? "light" : "default"}
|
variant={active ? "light" : "default"}
|
||||||
color={active ? "edr-green" : undefined}
|
color={active ? "edr-green" : undefined}
|
||||||
styles={active ? undefined : { root: { borderStyle: "dashed" } }}
|
styles={
|
||||||
leftSection={!active && <Plus size={12} />}
|
active
|
||||||
|
? undefined
|
||||||
|
: { root: { borderColor: "var(--mantine-color-gray-6)", color: "var(--mantine-color-gray-9)" } }
|
||||||
|
}
|
||||||
rightSection={
|
rightSection={
|
||||||
active && (
|
active ? (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="span"
|
component="span"
|
||||||
size={16}
|
size={16}
|
||||||
@@ -62,6 +68,8 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
|
|||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={14} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onClick={() => setOpened((o) => !o)}
|
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) {
|
if (def.type === "date" && value.v.length === 2) {
|
||||||
return `${value.v[0].slice(0, 10)} → ${value.v[1].slice(0, 10)}`;
|
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(", ");
|
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";
|
export type Operator = "is" | "isNot" | "contains" | "between" | "before" | "after";
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ export const DEFAULT_OP: Record<FilterType, Operator> = {
|
|||||||
date: "between",
|
date: "between",
|
||||||
number: "is",
|
number: "is",
|
||||||
boolean: "is",
|
boolean: "is",
|
||||||
|
route: "is",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OPERATOR_LABELS: Record<Operator, string> = {
|
export const OPERATOR_LABELS: Record<Operator, string> = {
|
||||||
@@ -87,12 +88,25 @@ export interface BooleanFilterDef extends FilterDefBase {
|
|||||||
falseLabel?: string;
|
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 =
|
export type FilterDef =
|
||||||
| TextFilterDef
|
| TextFilterDef
|
||||||
| EnumFilterDef
|
| EnumFilterDef
|
||||||
| DateFilterDef
|
| DateFilterDef
|
||||||
| NumberFilterDef
|
| NumberFilterDef
|
||||||
| BooleanFilterDef;
|
| BooleanFilterDef
|
||||||
|
| RouteFilterDef;
|
||||||
|
|
||||||
/** A page's sort options — value is already `"field:DIR"`, the codebase's existing convention. */
|
/** A page's sort options — value is already `"field:DIR"`, the codebase's existing convention. */
|
||||||
export interface SortOption {
|
export interface SortOption {
|
||||||
|
|||||||
@@ -136,9 +136,9 @@ export default function BookingRequestsPage() {
|
|||||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||||
{
|
{
|
||||||
key: "tradeDirection", label: "Direction", type: "enum", multiple: false,
|
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 },
|
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true },
|
||||||
{
|
{
|
||||||
// Wins over the `paymentStatus` filter above — the queue is by
|
// 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" } : {}),
|
toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}),
|
||||||
},
|
},
|
||||||
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
|
{ 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: "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] }) },
|
{ key: "scheduled", label: "Scheduled", type: "date", secondary: true, toParams: ({ v }) => ({ scheduledFrom: v[0], scheduledTo: v[1] }) },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useMemo, type ReactNode } from "react";
|
import { useCallback, useMemo, type ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
@@ -103,6 +105,16 @@ export default function ContractRequestsPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { filterOptions } = useMyTradeAccess();
|
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
|
// Static shape only (no facet counts) — this is what useFilters needs to
|
||||||
// parse the URL and build API params. Counts are attached separately below,
|
// parse the URL and build API params. Counts are attached separately below,
|
||||||
// for rendering only, once the summary query (which itself depends on
|
// for rendering only, once the summary query (which itself depends on
|
||||||
@@ -123,7 +135,6 @@ export default function ContractRequestsPage() {
|
|||||||
type: "enum",
|
type: "enum",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
||||||
secondary: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "freightType",
|
key: "freightType",
|
||||||
@@ -131,7 +142,6 @@ export default function ContractRequestsPage() {
|
|||||||
type: "enum",
|
type: "enum",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
options: FREIGHT_TYPE_OPTIONS,
|
options: FREIGHT_TYPE_OPTIONS,
|
||||||
secondary: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "paymentCurrency",
|
key: "paymentCurrency",
|
||||||
@@ -150,8 +160,16 @@ export default function ContractRequestsPage() {
|
|||||||
// onChange, so this just routes to the API's existing param names.
|
// onChange, so this just routes to the API's existing param names.
|
||||||
toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }),
|
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, {
|
const controls = useFilters(filterDefs, {
|
||||||
|
|||||||
Reference in New Issue
Block a user