contrat,booking,global logestic

This commit is contained in:
Marshal
2026-06-26 23:24:48 +00:00
parent f931342f31
commit 01d53c218c
105 changed files with 19573 additions and 909 deletions

View File

@@ -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>
);
}

View File

@@ -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 couldnt load this contracts 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}