mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
contrat,booking,global logestic
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Center,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Flag, ListChecks } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import {
|
||||
useBookingMilestones,
|
||||
useCompleteMilestone,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function BookingMilestonesPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: booking } = useBookingDetail(id);
|
||||
const { data: milestones, isLoading } = useBookingMilestones(id);
|
||||
const complete = useCompleteMilestone(id ?? "");
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const list = milestones ?? [];
|
||||
const total = list.length;
|
||||
const completed = list.filter((m) => m.status === "COMPLETED").length;
|
||||
const pct = total === 0 ? 0 : Math.round((completed / total) * 100);
|
||||
return { total, completed, pct };
|
||||
}, [milestones]);
|
||||
|
||||
const reference = booking?.reference ?? "Shipment";
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={`${reference} milestones`}
|
||||
subtitle="Track and advance the Global Logistics clearance milestones for this shipment."
|
||||
backTo={id ? `/dashboard/booking-requests/${id}` : undefined}
|
||||
breadcrumbs={[
|
||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||
{ label: reference },
|
||||
{ label: "Milestones" },
|
||||
]}
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ListChecks size={13} />}
|
||||
>
|
||||
{stats.completed}/{stats.total} done
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<SectionCard icon={Flag} title="Clearance milestones">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={milestones ?? []}
|
||||
busy={complete.isPending}
|
||||
onComplete={(code, note) =>
|
||||
complete.mutate({ code, note })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard icon={ListChecks} title="Progress" accent="edr-green">
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
complete
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Box w="100%">
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Milestones
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.completed}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={stats.pct}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canCreateContractBooking } from "@/lib/permissions";
|
||||
import { Button } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const docs = (clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "customer",
|
||||
);
|
||||
const total = docs.length;
|
||||
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
|
||||
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
|
||||
const pending = total - approved - queried;
|
||||
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
|
||||
return { total, approved, queried, pending, pct };
|
||||
}, [clearance]);
|
||||
|
||||
const reference = contract?.reference ?? "Clearance";
|
||||
const canBook =
|
||||
clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" &&
|
||||
canCreateContractBooking(user);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py={80} gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !clearance) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance not found"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Contract Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
We couldn’t load this contract’s clearance.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={reference}
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Contract Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
clearance.allApproved ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
>
|
||||
All approved
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
leftSection={<Clock size={13} />}
|
||||
>
|
||||
Review pending
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
action={
|
||||
canBook ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contracts/${id}/create-booking`)
|
||||
}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ContractClearanceReviewSection contractId={id!} hideSummary />
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceHero({
|
||||
contract,
|
||||
stats,
|
||||
}: {
|
||||
contract: ReturnType<typeof useContractDetail>["data"];
|
||||
stats: { pct: number; approved: number; total: number };
|
||||
}) {
|
||||
const direction = contract?.tradeDirection ?? "—";
|
||||
const routes = [...(contract?.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const origin =
|
||||
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
|
||||
const last = routes[routes.length - 1] ?? routes[0];
|
||||
const destination =
|
||||
last?.destinationYard?.label ??
|
||||
last?.destinationYard?.code ??
|
||||
"Destination";
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||
<ShieldCheck size={26} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={800} fz={20} c="edr-text" truncate>
|
||||
{contract?.reference ?? "Clearance"}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={direction === "IMPORT" ? "edr-green" : "gray"}
|
||||
radius="sm"
|
||||
>
|
||||
{direction}
|
||||
</Badge>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={12} />}
|
||||
>
|
||||
Customs
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap={8} mt={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{origin}
|
||||
</Text>
|
||||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{destination}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Document review
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stats.approved}/{stats.total}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressStat({
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={2} align="center">
|
||||
<Text fw={700} fz={18} c="edr-text">
|
||||
{value}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text fz="11px" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
Truck,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type Region = "ET" | "DJ";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
contractKind: string;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
yard?: { label?: string; code?: string; name?: string } | null,
|
||||
fallback = "—",
|
||||
): string {
|
||||
if (!yard) return fallback;
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const first = routes[0];
|
||||
const last = routes[routes.length - 1] ?? first;
|
||||
return {
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
tradeDirection: contract.tradeDirection ?? "—",
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
contractKind: contract.contractKind,
|
||||
};
|
||||
}
|
||||
|
||||
function DirectionIcon({ direction }: { direction: string }) {
|
||||
const isImport = direction === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={isImport ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={15} strokeWidth={1.9} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContractClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [region, setRegion] = useState<Region>("ET");
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useContractClearanceQueue(region);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
[data?.items],
|
||||
);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
|
||||
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return allRows;
|
||||
return allRows.filter(
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}, [allRows, query]);
|
||||
|
||||
const total = rows.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return rows.slice(start, start + pagination.pageSize);
|
||||
}, [rows, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const openDetail = useCallback(
|
||||
(id: string) => navigate(`/dashboard/contracts/clearance/${id}`),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<ClearanceRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{r.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{r.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.destinationLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={r.tradeDirection} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{r.freightType}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: () => (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
size: 56,
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Contract Clearance"
|
||||
subtitle="Review pre-booking clearance documents on contracts before Global Logistics creates the shipment booking."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{counts.all} awaiting review
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Awaiting review",
|
||||
value: counts.all,
|
||||
icon: Inbox,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Import",
|
||||
value: counts.import,
|
||||
icon: Truck,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Export",
|
||||
value: counts.export,
|
||||
icon: ShipWheel,
|
||||
color: "gray",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference, customer, or route…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={region}
|
||||
onChange={(v) => {
|
||||
setRegion(v as Region);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
data={[
|
||||
{ value: "ET", label: "Ethiopia" },
|
||||
{ value: "DJ", label: "Djibouti" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={view}
|
||||
onChange={(v) => setView(v as ViewMode)}
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<TableIcon size={15} />
|
||||
<Box visibleFrom="sm">Table</Box>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<LayoutGrid size={15} />
|
||||
<Box visibleFrom="sm">Cards</Box>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{view === "table" ? (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
<DataTable<ClearanceRow, unknown>
|
||||
columns={columns}
|
||||
data={pagedRows}
|
||||
status={
|
||||
isLoading ? "loading" : isError ? "error" : "success"
|
||||
}
|
||||
onRowClick={(row) => openDetail(row.id)}
|
||||
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>
|
||||
) : (
|
||||
<ClearanceCardGrid
|
||||
rows={pagedRows}
|
||||
loading={isLoading}
|
||||
onOpen={openDetail}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceCardGrid({
|
||||
rows,
|
||||
loading,
|
||||
onOpen,
|
||||
}: {
|
||||
rows: ClearanceRow[];
|
||||
loading: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box px="md" py="xl">
|
||||
<Text c="dimmed" ta="center">
|
||||
Loading…
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<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 awaiting review.</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
pb="md"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: "var(--mantine-spacing-md)",
|
||||
}}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceCard({
|
||||
row,
|
||||
onOpen,
|
||||
}: {
|
||||
row: ClearanceRow;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
p="md"
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
style={{ cursor: "pointer", transition: "all 120ms ease" }}
|
||||
className="hover:border-edr-green-4 hover:shadow-md"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
|
||||
<FileText size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" c="edr-text" truncate>
|
||||
{row.reference}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<User size={11} className="shrink-0 opacity-70" />
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{row.customerLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
Under review
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
mt="md"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-edr-card-6)",
|
||||
border: "1px solid var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" justify="center">
|
||||
<Text size="sm" fw={600} truncate maw={130}>
|
||||
{row.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={600} truncate maw={130}>
|
||||
{row.destinationLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between" mt="md" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<DirectionIcon direction={row.tradeDirection} />
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{row.freightType}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{row.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Box as BoxIcon,
|
||||
Building2,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
FileText,
|
||||
Flame,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
Snowflake,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
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 ContractRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: contract,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Center mih="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader color="gray" />
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Loading contract…
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Container size="sm" py="xl">
|
||||
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
|
||||
<Center>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
color: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
>
|
||||
<FileText size={32} />
|
||||
</Box>
|
||||
</Center>
|
||||
<Text fw={700} size="lg" mt="lg">
|
||||
Contract not found
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
This request may have been removed or the link is invalid.
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
mt="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/contract-requests")}
|
||||
>
|
||||
Back to contract requests
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const statusMeta = getContractStatusMeta(contract.status);
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
const showApprovalCard =
|
||||
contract.status === "PENDING_APPROVAL" ||
|
||||
contract.status === "APPROVED" ||
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Contract requests", href: "/dashboard/contract-requests" },
|
||||
{ label: contract.reference },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
{/* Hero */}
|
||||
<Paper radius="xl" p="xl" style={{ position: "relative", overflow: "hidden" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/contract-requests")}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
loading={isFetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 1, color: "#B26C09" }}
|
||||
>
|
||||
Contract reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
{contract.reference}
|
||||
</Title>
|
||||
<ContractStatusBadge
|
||||
status={contract.status}
|
||||
isRenewal={Boolean(contract.renewalOfId)}
|
||||
/>
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="lg" mt={4}>
|
||||
<MetaItem icon={Building2} text={customerLabel} />
|
||||
<MetaItem
|
||||
icon={Calendar}
|
||||
text={`Created ${formatDate(contract.createdAt)}`}
|
||||
/>
|
||||
{contract.contractValidUntil ? (
|
||||
<MetaItem
|
||||
icon={CalendarClock}
|
||||
text={`Valid until ${formatDate(contract.contractValidUntil)}`}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ContractWorkflowStepper
|
||||
status={contract.status}
|
||||
title={statusMeta.title}
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={RouteIcon} title="Routes">
|
||||
{routes.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No routes on this contract.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{routes.map((r) => (
|
||||
<Group
|
||||
key={r.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{r.originYard?.label ??
|
||||
r.originYard?.code ??
|
||||
"Origin"}
|
||||
</Text>
|
||||
<ArrowRight
|
||||
size={15}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<Text size="sm" fw={600} truncate maw={160}>
|
||||
{r.destinationYard?.label ??
|
||||
r.destinationYard?.code ??
|
||||
"Destination"}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.km != null ? (
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{r.km} km
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Package} title="Cargo scope">
|
||||
<Group gap="sm" mb="md">
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.tradeDirection}
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
|
||||
{contract.freightType}
|
||||
</Badge>
|
||||
{contract.isHazardous ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<Flame size={12} />}
|
||||
>
|
||||
Hazardous
|
||||
</Badge>
|
||||
) : null}
|
||||
{contract.isReefer ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="cyan"
|
||||
radius="sm"
|
||||
leftSection={<Snowflake size={12} />}
|
||||
>
|
||||
Reefer
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{(contract.cargoScope ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No cargo scope lines.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{(contract.cargoScope ?? []).map((s) => (
|
||||
<Group key={s.id} gap={8} wrap="nowrap">
|
||||
<BoxIcon
|
||||
size={15}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
<Text size="sm">
|
||||
{s.containerSize ??
|
||||
s.cargoFreeText ??
|
||||
s.cargoTypeId ??
|
||||
"Cargo"}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{contract.pricingBreakdown?.lineItems?.length ? (
|
||||
<SectionCard icon={Receipt} title="Unit rates">
|
||||
<Stack gap="xs">
|
||||
{contract.pricingBreakdown.lineItems.map((li) => (
|
||||
<Group
|
||||
key={li.code}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text size="sm" truncate>
|
||||
{li.label}
|
||||
{li.containerSize ? ` · ${li.containerSize}` : ""}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{contract.pricingBreakdown?.currency} {li.unitPrice} /{" "}
|
||||
{li.unit}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
{contract.contractSummary ? (
|
||||
<SectionCard icon={FileText} title="Contract summary">
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{contract.contractSummary}
|
||||
</Text>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action rail */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<ContractActionsToolbar
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
/>
|
||||
{showApprovalCard && (
|
||||
<ContractApprovalStepsCard
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({
|
||||
icon: Icon,
|
||||
text,
|
||||
}: {
|
||||
icon: typeof Building2;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={600} c="dark">
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
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 } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
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(",");
|
||||
}
|
||||
|
||||
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 [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
||||
|
||||
const tabStatuses = getStatusesForTab(activeTab);
|
||||
|
||||
const filter: ContractListFilter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } =
|
||||
useContractList(filter);
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
refetch: refetchSummary,
|
||||
} = useContractListSummary(filter);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const items = (data?.items ?? []).map(toContractListRow);
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
(c) =>
|
||||
c.reference.toLowerCase().includes(q) ||
|
||||
c.customerLabel.toLowerCase().includes(q),
|
||||
);
|
||||
}, [data?.items, query]);
|
||||
|
||||
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}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<FileText className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.reference}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{c.customerLabel}
|
||||
</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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<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)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user