mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
- 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.
488 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|