mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
666 lines
21 KiB
TypeScript
666 lines
21 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Group,
|
|
MultiSelect,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
ThemeIcon,
|
|
} from "@mantine/core";
|
|
import { DateInput } from "@mantine/dates";
|
|
import { useDebouncedValue } from "@mantine/hooks";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowRight,
|
|
CalendarClock,
|
|
CheckCircle2,
|
|
Clock,
|
|
FileText,
|
|
FilterX,
|
|
Inbox,
|
|
LayoutList,
|
|
RefreshCw,
|
|
Repeat,
|
|
Search,
|
|
User,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
|
|
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
|
import {
|
|
ContractStatusTabs,
|
|
type ContractStatusTabKey,
|
|
} from "@/components/contracts/ContractStatusTabs";
|
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|
import {
|
|
CONTRACT_LIST_TABS,
|
|
CONTRACT_STATUS_STYLES,
|
|
} from "@/features/contracts/contract-status.config";
|
|
import {
|
|
getStaffRowAction,
|
|
toContractListRow,
|
|
type ContractListRow,
|
|
} from "@/features/contracts/mapContractListRow";
|
|
import {
|
|
useContractList,
|
|
useContractListSummary,
|
|
} from "@/hooks/contracts/useContracts";
|
|
import type { ContractListFilter } from "@/services/contracts.service";
|
|
import {
|
|
Badge,
|
|
DataTable,
|
|
DataTableFooter,
|
|
usePagination,
|
|
type ColumnDef,
|
|
} from "@edr/ui-common";
|
|
|
|
function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
|
|
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
|
if (!match?.statuses?.length) return undefined;
|
|
return match.statuses.join(",");
|
|
}
|
|
|
|
/** Statuses selectable in the status filter for a given tab ("all" → every tab status). */
|
|
function getStatusOptionsForTab(
|
|
tab: ContractStatusTabKey,
|
|
): { value: string; label: string }[] {
|
|
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
|
|
const statuses = match?.statuses?.length
|
|
? match.statuses
|
|
: CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []);
|
|
return 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" },
|
|
];
|
|
|
|
/** Local start-of-day → ISO, for inclusive "from" date filters. */
|
|
function startOfDayIso(d: Date): string {
|
|
const x = new Date(d);
|
|
x.setHours(0, 0, 0, 0);
|
|
return x.toISOString();
|
|
}
|
|
|
|
/** Local end-of-day → ISO, for inclusive "to" date filters. */
|
|
function endOfDayIso(d: Date): string {
|
|
const x = new Date(d);
|
|
x.setHours(23, 59, 59, 999);
|
|
return x.toISOString();
|
|
}
|
|
|
|
function formatDate(value: string | null | undefined): string {
|
|
if (!value) return "—";
|
|
const d = new Date(value);
|
|
return Number.isNaN(d.getTime())
|
|
? "—"
|
|
: d.toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
export default function ContractRequestsPage() {
|
|
const navigate = useNavigate();
|
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
const [query, setQuery] = useState("");
|
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
|
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
|
// Filter controls (empty/null = "all").
|
|
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
|
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
|
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [kindFilter, setKindFilter] = useState<string | null>(null);
|
|
const [currencyFilter, setCurrencyFilter] = useState<string | null>(null);
|
|
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
|
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
|
const [sort, setSort] = useState<string>("createdAt:DESC");
|
|
|
|
const tabStatuses = getStatusesForTab(activeTab);
|
|
const statusOptions = useMemo(
|
|
() => getStatusOptionsForTab(activeTab),
|
|
[activeTab],
|
|
);
|
|
|
|
const resetPage = useCallback(() => {
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}, [setPagination, pagination.pageSize]);
|
|
|
|
const filter: ContractListFilter = useMemo(() => {
|
|
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
|
|
return {
|
|
page: pagination.pageIndex + 1,
|
|
pageSize: pagination.pageSize,
|
|
sortBy,
|
|
sortOrder,
|
|
tab: activeTab,
|
|
// Server-side free-text search (contract reference, customer name).
|
|
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
|
|
// Explicit status picks narrow within the tab; otherwise the tab's
|
|
// status group applies.
|
|
...(statusFilter.length
|
|
? { statuses: statusFilter.join(",") }
|
|
: tabStatuses
|
|
? { statuses: tabStatuses }
|
|
: {}),
|
|
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
|
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
|
...(kindFilter ? { contractKind: kindFilter } : {}),
|
|
...(currencyFilter ? { paymentCurrency: currencyFilter } : {}),
|
|
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
|
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
|
};
|
|
}, [
|
|
pagination.pageIndex,
|
|
pagination.pageSize,
|
|
activeTab,
|
|
tabStatuses,
|
|
debouncedQuery,
|
|
statusFilter,
|
|
directionFilter,
|
|
freightTypeFilter,
|
|
kindFilter,
|
|
currencyFilter,
|
|
createdFrom,
|
|
createdTo,
|
|
sort,
|
|
]);
|
|
|
|
const activeFilterCount =
|
|
(statusFilter.length ? 1 : 0) +
|
|
(directionFilter ? 1 : 0) +
|
|
(freightTypeFilter ? 1 : 0) +
|
|
(kindFilter ? 1 : 0) +
|
|
(currencyFilter ? 1 : 0) +
|
|
(createdFrom || createdTo ? 1 : 0);
|
|
|
|
const clearFilters = useCallback(() => {
|
|
setStatusFilter([]);
|
|
setDirectionFilter(null);
|
|
setFreightTypeFilter(null);
|
|
setKindFilter(null);
|
|
setCurrencyFilter(null);
|
|
setCreatedFrom(null);
|
|
setCreatedTo(null);
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}, [setPagination, pagination.pageSize]);
|
|
|
|
const { data, isLoading, isError, refetch, isFetching } =
|
|
useContractList(filter);
|
|
const {
|
|
data: summary,
|
|
isLoading: summaryLoading,
|
|
refetch: refetchSummary,
|
|
} = useContractListSummary(filter);
|
|
|
|
const rows = useMemo(
|
|
() => (data?.items ?? []).map(toContractListRow),
|
|
[data?.items],
|
|
);
|
|
|
|
const total = data?.total ?? 0;
|
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
|
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",
|
|
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="truncate font-medium text-foreground">
|
|
{c.customerLabel}
|
|
</p>
|
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
|
<FileText className="size-3 shrink-0 opacity-70" />
|
|
{c.reference}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "route",
|
|
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 className="max-w-[8rem] truncate">{c.originLabel}</span>
|
|
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
|
<span className="max-w-[8rem] truncate">
|
|
{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 uppercase backdrop-blur-sm"
|
|
>
|
|
{c.tradeDirection}
|
|
</Badge>
|
|
<Badge
|
|
variant="secondary"
|
|
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
|
>
|
|
{c.freightType}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
size: 200,
|
|
minSize: 180,
|
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
|
cell: ({ row }) => (
|
|
<div className="py-1">
|
|
<ContractStatusBadge
|
|
status={row.original.status}
|
|
isRenewal={row.original.isRenewal}
|
|
/>
|
|
</div>
|
|
),
|
|
meta: {
|
|
headerClassName: "min-w-[11rem]",
|
|
cellClassName: "min-w-[11rem]",
|
|
},
|
|
},
|
|
{
|
|
id: "approval",
|
|
header: () => <span className={bookingTable.headerCell}>Approval</span>,
|
|
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
|
|
},
|
|
{
|
|
id: "validity",
|
|
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
|
cell: ({ row }) => {
|
|
const c = row.original;
|
|
return (
|
|
<Stack gap={2}>
|
|
<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}
|
|
</Stack>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "kind",
|
|
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
|
cell: ({ row }) => {
|
|
const isGeneral = row.original.contractKind === "GENERAL";
|
|
return (
|
|
<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>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: () => <span className={bookingTable.headerCell}>Action</span>,
|
|
cell: ({ row }) => {
|
|
const action = getStaffRowAction(row.original);
|
|
if (!action) return null;
|
|
return (
|
|
<Button
|
|
size="compact-sm"
|
|
radius="md"
|
|
variant={action.variant === "filled" ? "filled" : action.variant}
|
|
color="edr-green"
|
|
onClick={(e) => {
|
|
// Don't let the row-click navigation fire as well.
|
|
e.stopPropagation();
|
|
navigate(action.to(row.original.id));
|
|
}}
|
|
>
|
|
{action.label}
|
|
</Button>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
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",
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<ContractStatusTabs
|
|
active={activeTab}
|
|
onChange={(tab) => {
|
|
setActiveTab(tab);
|
|
// Status picks belong to the previous tab's option set — reset.
|
|
setStatusFilter([]);
|
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
}}
|
|
counts={tabCounts}
|
|
/>
|
|
|
|
<Card p={0}>
|
|
<Stack gap={0}>
|
|
<Box px="md" pt="md" pb="sm" w="100%">
|
|
<Stack gap="sm">
|
|
<Group justify="space-between" gap="md" wrap="wrap">
|
|
<TextInput
|
|
placeholder="Search reference or customer…"
|
|
leftSection={<Search size={18} />}
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
resetPage();
|
|
}}
|
|
rightSection={
|
|
query && (
|
|
<ActionIcon
|
|
size="sm"
|
|
color="gray"
|
|
radius="md"
|
|
variant="transparent"
|
|
onClick={() => {
|
|
setQuery("");
|
|
resetPage();
|
|
}}
|
|
>
|
|
<X size={16} />
|
|
</ActionIcon>
|
|
)
|
|
}
|
|
style={{ flex: 1, minWidth: "200px" }}
|
|
radius="lg"
|
|
/>
|
|
<Select
|
|
data={SORT_OPTIONS}
|
|
value={sort}
|
|
onChange={(v) => {
|
|
setSort(v ?? "createdAt:DESC");
|
|
resetPage();
|
|
}}
|
|
allowDeselect={false}
|
|
radius="lg"
|
|
style={{ minWidth: 170 }}
|
|
aria-label="Sort contracts"
|
|
/>
|
|
<Text size="sm" c="dimmed">
|
|
{total} record{total !== 1 ? "s" : ""}
|
|
</Text>
|
|
</Group>
|
|
<Group gap="sm" wrap="wrap">
|
|
<MultiSelect
|
|
placeholder={
|
|
statusFilter.length ? undefined : "All statuses"
|
|
}
|
|
data={statusOptions}
|
|
value={statusFilter}
|
|
onChange={(v) => {
|
|
setStatusFilter(v);
|
|
resetPage();
|
|
}}
|
|
clearable
|
|
searchable
|
|
radius="lg"
|
|
style={{ minWidth: 220 }}
|
|
aria-label="Filter by status"
|
|
/>
|
|
<Select
|
|
placeholder="All directions"
|
|
data={TRADE_DIRECTION_OPTIONS}
|
|
value={directionFilter}
|
|
onChange={(v) => {
|
|
setDirectionFilter(v);
|
|
resetPage();
|
|
}}
|
|
clearable
|
|
radius="lg"
|
|
style={{ minWidth: 150 }}
|
|
aria-label="Filter by trade direction"
|
|
/>
|
|
<Select
|
|
placeholder="All freight types"
|
|
data={FREIGHT_TYPE_OPTIONS}
|
|
value={freightTypeFilter}
|
|
onChange={(v) => {
|
|
setFreightTypeFilter(v);
|
|
resetPage();
|
|
}}
|
|
clearable
|
|
radius="lg"
|
|
style={{ minWidth: 160 }}
|
|
aria-label="Filter by freight type"
|
|
/>
|
|
<Select
|
|
placeholder="All kinds"
|
|
data={CONTRACT_KIND_OPTIONS}
|
|
value={kindFilter}
|
|
onChange={(v) => {
|
|
setKindFilter(v);
|
|
resetPage();
|
|
}}
|
|
clearable
|
|
radius="lg"
|
|
style={{ minWidth: 160 }}
|
|
aria-label="Filter by contract kind"
|
|
/>
|
|
<Select
|
|
placeholder="All currencies"
|
|
data={CURRENCY_OPTIONS}
|
|
value={currencyFilter}
|
|
onChange={(v) => {
|
|
setCurrencyFilter(v);
|
|
resetPage();
|
|
}}
|
|
clearable
|
|
radius="lg"
|
|
style={{ minWidth: 140 }}
|
|
aria-label="Filter by payment currency"
|
|
/>
|
|
<DateInput
|
|
placeholder="Created from"
|
|
value={createdFrom}
|
|
onChange={(v) => {
|
|
setCreatedFrom(v ? new Date(v) : null);
|
|
resetPage();
|
|
}}
|
|
maxDate={createdTo ?? undefined}
|
|
clearable
|
|
radius="lg"
|
|
style={{ minWidth: 140 }}
|
|
aria-label="Created from"
|
|
/>
|
|
<DateInput
|
|
placeholder="Created to"
|
|
value={createdTo}
|
|
onChange={(v) => {
|
|
setCreatedTo(v ? new Date(v) : null);
|
|
resetPage();
|
|
}}
|
|
minDate={createdFrom ?? undefined}
|
|
clearable
|
|
radius="lg"
|
|
style={{ minWidth: 140 }}
|
|
aria-label="Created to"
|
|
/>
|
|
{activeFilterCount > 0 ? (
|
|
<Button
|
|
variant="subtle"
|
|
color="gray"
|
|
radius="lg"
|
|
leftSection={<FilterX size={16} />}
|
|
onClick={clearFilters}
|
|
>
|
|
Clear filters ({activeFilterCount})
|
|
</Button>
|
|
) : null}
|
|
</Group>
|
|
</Stack>
|
|
</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}
|
|
pagination={{
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount,
|
|
totalCount: total,
|
|
}}
|
|
tableOptions={{
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
manualPagination: true,
|
|
pageCount,
|
|
}}
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
footer={DataTableFooter}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
</Stack>
|
|
</PageContainer>
|
|
);
|
|
}
|