Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx
Nathnael 4b4ab21ea1 fix(filters): auto-apply radios, route popover bug, date operators, polish
- Radio-based filters (single-select enum, boolean) apply the instant
  an option is picked and close — no Apply click needed. Checkbox
  (multi-select) keeps the explicit Apply, since picking several is a
  multi-step gesture.
- Route filter's Select dropdowns portal separately by default, so a
  click landed as "outside" the outer pill popover and closed it
  before a pick registered — same class of bug DateBody had.
  `comboboxProps={{ withinPortal: false }}` fixes it.
- Route filter now pinned by default on both pages instead of behind
  More filters.
- Date filter: widened to before/between/after (repository already
  applies each bound independently) via a new `dateRangeParams`
  helper — the old positional `{v[0]:from, v[1]:to}` mapping silently
  put a "before" pick in the from param. Added a calendar icon + sm
  sizing, and a wider popover so the range presets sidebar has room.
- Pill's clear (X) button enlarged; search box border/text opacity
  strengthened to match the pill trigger restyle.
2026-08-15 07:31:19 +00:00

488 lines
15 KiB
TypeScript

import { directionLabel } from "@/lib/utils";
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import {
ActionIcon,
Box,
Card,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertTriangle,
ArrowRight,
CalendarClock,
CheckCircle2,
Clock,
FileText,
Inbox,
LayoutList,
RefreshCw,
Repeat,
User,
} 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";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import {
CONTRACT_LIST_TABS,
CONTRACT_STATUS_STYLES,
contractCourt,
} from "@/features/contracts/contract-status.config";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import {
getStaffRowAction,
toContractListRow,
type ContractListRow,
} from "@/features/contracts/mapContractListRow";
import { formatDate, humanize } from "@/lib/format";
import {
useContractList,
useContractListSummary,
} from "@/hooks/contracts/useContracts";
import type { ContractListFilter } from "@/services/contracts.service";
import {
Badge,
DataTable,
DataTableFooter,
type ColumnDef,
} from "@edr/ui-common";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
(s) => ({
value: s,
label: CONTRACT_STATUS_STYLES[s]?.label ?? s,
}),
);
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
const CONTRACT_KIND_OPTIONS = [
{ value: "GENERAL", label: "General (recurring)" },
{ value: "ONE_TIME", label: "One-time" },
];
const CURRENCY_OPTIONS = [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
];
/** value = `${sortBy}:${sortOrder}` for the sort Select. */
const SORT_OPTIONS = [
{ value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" },
{ value: "contractValidUntil:ASC", label: "Expiring soonest" },
{ value: "contractValidUntil:DESC", label: "Expiring latest" },
];
/**
* Per-column widths — they must sum to the table's min-w (960px, set on the
* containerClassName below) because table-fixed distributes any difference.
*/
const COLUMN_WIDTHS = { contract: 250, route: 270, status: 250, validity: 190 };
const COLUMN_META = {
headerClassName: "whitespace-normal break-words",
cellClassName: "whitespace-normal break-words align-top",
};
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
// these params) has resolved.
const filterDefs: FilterDef[] = useMemo(
() => [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
key: "contractKind",
label: "Kind",
type: "enum",
multiple: false,
options: CONTRACT_KIND_OPTIONS,
},
{
key: "tradeDirection",
label: "Direction",
type: "enum",
multiple: false,
options: filterOptions(TRADE_DIRECTION_OPTIONS),
},
{
key: "freightType",
label: "Freight",
type: "enum",
multiple: false,
options: FREIGHT_TYPE_OPTIONS,
},
{
key: "paymentCurrency",
label: "Currency",
type: "enum",
multiple: false,
options: CURRENCY_OPTIONS,
secondary: true,
},
{
key: "created",
label: "Created",
type: "date",
secondary: true,
// Before/after are safe to expose: the repository applies
// createdFrom/createdTo independently, so a single-sided bound
// already works server-side.
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
{
key: "route",
label: "Route",
type: "route",
options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
},
],
[filterOptions, yardOptions],
);
const controls = useFilters(filterDefs, {
defaultSort: "createdAt:DESC",
pageSize: 10,
});
const filter: ContractListFilter = useMemo(
() => ({
...(controls.params as unknown as ContractListFilter),
// Kept as the React Query cache-key discriminator (tabs themselves are gone).
tab: "all",
}),
[controls.params],
);
const { data, isLoading, isError, refetch, isFetching } =
useContractList(filter);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useContractListSummary(filter);
const statusCounts = useMemo(
() => Object.fromEntries((summary?.facets?.status ?? []).map((b) => [b.value, b.count])),
[summary?.facets],
);
// filterDefs + counts, for the bar to render. Kept separate from filterDefs
// itself so the URL-parsing hook above never has to wait on this query.
const defs: FilterDef[] = useMemo(
() =>
filterDefs.map((d) =>
d.key === "statuses" && d.type === "enum" ? { ...d, counts: statusCounts } : d,
),
[filterDefs, statusCounts],
);
const rows = useMemo(
() => (data?.items ?? []).map(toContractListRow),
[data?.items],
);
const total = data?.total ?? 0;
const showEmpty = !isLoading && !isError && rows.length === 0;
const metrics = summary?.metrics;
const tabCounts = summary?.tabs;
const handleRefresh = useCallback(() => {
void refetch();
void refetchSummary();
}, [refetch, refetchSummary]);
const handleRowClick = useCallback(
(row: ContractListRow) => {
navigate(`/dashboard/contract-requests/${row.id}`);
},
[navigate],
);
const columns: ColumnDef<ContractListRow>[] = [
{
id: "contract",
size: COLUMN_WIDTHS.contract,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">{c.customerLabel}</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{c.reference}
</p>
</div>
</div>
);
},
},
{
id: "route",
size: COLUMN_WIDTHS.route,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span>{c.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span>{c.destinationLabel}</span>
</div>
<div className="flex gap-1.5">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium backdrop-blur-sm"
>
{directionLabel(c.tradeDirection)}
</Badge>
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{humanize(c.freightType)}
</Badge>
</div>
</div>
);
},
},
{
id: "status",
size: COLUMN_WIDTHS.status,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => {
const c = row.original;
const court = contractCourt(c.status);
const progress = formatContractApprovalProgress(
c.status,
c.approvalSteps,
);
const action = getStaffRowAction(c);
// One dimmed line: who it's waiting on, the staff verb, and the
// approval chain when one exists. "Open" adds nothing — row click
// already opens the detail page.
const pieces: ReactNode[] = [];
if (court) {
pieces.push(court === "customer" ? "With customer" : "With EDR");
}
if (action && action.variant === "filled") {
pieces.push(
// "View & sign" pointed at the contract-view page, not the
// detail page — keep that deep link as an inline link.
action.to(c.id).endsWith("/view") ? (
<button
type="button"
className="underline underline-offset-2 hover:text-primary"
onClick={(e) => {
e.stopPropagation();
navigate(action.to(c.id));
}}
>
{action.label}
</button>
) : (
action.label
),
);
}
if ((c.approvalSteps ?? []).length > 0) {
pieces.push(progress.label);
if (!progress.complete && progress.detail.startsWith("Next:")) {
pieces.push(progress.detail);
}
}
return (
<div className="space-y-1 py-1">
<ContractStatusBadge status={c.status} isRenewal={c.isRenewal} />
{pieces.length ? (
<div className="text-xs text-muted-foreground">
{pieces.map((piece, i) => (
<span key={i}>
{i > 0 ? " · " : null}
{piece}
</span>
))}
</div>
) : null}
</div>
);
},
},
{
id: "validity",
size: COLUMN_WIDTHS.validity,
meta: COLUMN_META,
header: () => <span className={bookingTable.headerCell}>Validity</span>,
cell: ({ row }) => {
const c = row.original;
const isGeneral = c.contractKind === "GENERAL";
return (
<Stack gap={2} align="flex-start">
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<CalendarClock className="size-3.5" />
{c.validUntil
? `Until ${formatDate(c.validUntil)}`
: c.validityDays
? `${c.validityDays} days`
: "—"}
</span>
{c.validFrom ? (
<Text size="xs" c="dimmed">
From {formatDate(c.validFrom)}
</Text>
) : null}
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
>
{isGeneral ? (
<span className="inline-flex items-center gap-1">
<Repeat className="size-3" /> General
</span>
) : (
"One-time"
)}
</Badge>
</Stack>
);
},
},
];
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Contract requests"
subtitle="Review, approve, and execute freight contract requests."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
loading={isFetching}
onClick={handleRefresh}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<KpiStrip
loading={summaryLoading}
items={[
{
label: "In queue",
value: metrics?.inQueue ?? 0,
icon: LayoutList,
color: "edr-green",
},
{
label: "Needs action",
value: metrics?.needsAction ?? 0,
icon: Clock,
color: "yellow",
},
{
label: "Urgent",
value: metrics?.urgent ?? 0,
icon: AlertTriangle,
color: "red",
},
{
label: "Closed",
value: tabCounts?.closed ?? 0,
icon: CheckCircle2,
color: "edr-green",
},
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="sm" pb="xs" w="100%">
<FilterBar
defs={defs}
controls={controls}
searchPlaceholder="Search reference or customer…"
sortOptions={SORT_OPTIONS}
viewId="contract-requests"
/>
</Box>
{showEmpty ? (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No contracts match this view.</Text>
</Stack>
) : (
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={
isLoading ? "loading" : isError ? "error" : "success"
}
onRowClick={handleRowClick}
{...controls.tableProps(total)}
// table-fixed makes the per-column widths stick; without
// it auto-layout re-widens columns once cells wrap.
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Card>
</Stack>
</PageContainer>
);
}