refactor: booking page refactor

This commit is contained in:
Nathnael
2026-06-11 06:54:38 +00:00
parent 2250a468cb
commit 83edb1e063
18 changed files with 2177 additions and 1862 deletions

View File

@@ -0,0 +1,421 @@
import {
ActionIcon,
Box,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
Download,
Pencil,
Send,
Upload,
X,
XCircle,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
import { CardTitle, PageShell, SectionCard } from "./components/layout";
import { CountChip, DocRow, IconSquare } from "./components/Documents";
import { EstimateCard } from "./components/pricing";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { MutationErrors, NoticeBanner } from "./components/Notices";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { StepGhostButton, StepLine } from "./components/Steps";
import { SupportCard } from "./components/SupportCard";
import { BodyGrid } from "./components/layout";
export function DraftBookingView({
booking,
onBookingUpdated,
}: {
booking: Freight.IBooking;
onBookingUpdated: () => void;
}) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const documentsRef = useRef<HTMLDivElement>(null);
const [selectedFiles, setSelectedFiles] = useState<
Record<string, File | null>
>({});
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [docError, setDocError] = useState("");
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
const uploadedCodes = useMemo(
() => new Set(booking.files?.map((f) => f.code) ?? []),
[booking.files],
);
const uploadedCount = REQUIRED_DOC_FIELDS.filter((d) =>
uploadedCodes.has(d.key),
).length;
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
const { data: generatedPricing } = useQuery({
...api.bookings.generatePrice.queryOptions({ input: { id: booking.id } }),
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
});
const pricing = booking.pricingBreakdown ?? generatedPricing ?? null;
const uploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) =>
api.bookings.uploadDocuments.call({ id: booking.id, files }),
onSuccess: () => {
setSelectedFiles({});
setDocError("");
onBookingUpdated();
},
});
const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
onSuccess: () => {
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
});
const cancelMutation = useMutation({
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: booking.id, reason }),
onSuccess: () => {
setCancelDialogOpen(false);
onBookingUpdated();
},
});
function handleFileSelect(key: string, file: File | null) {
setSelectedFiles((prev) => ({ ...prev, [key]: file }));
}
function handleUploadAll() {
const filesToUpload: Record<string, File | null> = {};
for (const doc of REQUIRED_DOC_FIELDS) {
if (selectedFiles[doc.key])
filesToUpload[doc.key] = selectedFiles[doc.key]!;
}
if (Object.keys(filesToUpload).length === 0) return;
uploadMutation.mutate(filesToUpload);
}
function handleSubmitRequest() {
const missing = REQUIRED_DOC_FIELDS.filter(
(doc) => !uploadedCodes.has(doc.key),
);
if (missing.length > 0) {
setDocError("Please upload all required documents before submitting.");
documentsRef.current?.scrollIntoView({ behavior: "smooth" });
return;
}
submitMutation.mutate();
}
const completeStep = allDocsUploaded ? 3 : 2;
return (
<PageShell>
<PageHeader
booking={booking}
actions={
<HeaderButton
dark
icon={<Pencil size={16} />}
label="Continue editing"
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
/>
}
/>
{booking.status === "CHANGES_REQUESTED" &&
booking.latestChangeRequestNote && (
<NoticeBanner
tone="amber"
icon={<AlertCircle size={18} />}
title="Changes requested by staff"
>
{booking.latestChangeRequestNote}
</NoticeBanner>
)}
<MutationErrors
mutations={[uploadMutation, submitMutation, cancelMutation]}
/>
<StatusHero booking={booking} />
<BodyGrid
left={
<>
{/* Complete your booking */}
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Complete your booking</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
Step {completeStep} of 3
</Text>
</Group>
<Stack gap={4}>
<StepLine
index={1}
done
title="Booking details"
desc="Route, cargo and service are set."
action={
<StepGhostButton
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
>
Edit
</StepGhostButton>
}
/>
<StepLine
index={2}
active={!allDocsUploaded}
done={allDocsUploaded}
highlight
title="Upload documents"
desc={
allDocsUploaded
? "All required documents uploaded."
: `${uploadedCount} of ${REQUIRED_DOC_FIELDS.length} uploaded.`
}
action={
!allDocsUploaded && (
<StepGhostButton
onClick={() =>
documentsRef.current?.scrollIntoView({
behavior: "smooth",
})
}
>
Upload
</StepGhostButton>
)
}
/>
<StepLine
index={3}
done={false}
title="Submit for review"
desc="Send your booking to EDR staff."
/>
</Stack>
<Button
fullWidth
mt="lg"
radius={10}
color="#0C1A2B"
leftSection={<Send size={16} />}
onClick={handleSubmitRequest}
disabled={submitMutation.isPending}
loading={submitMutation.isPending}
styles={{ root: { height: 46 }, label: { fontWeight: 700 } }}
>
{submitMutation.isPending ? "Submitting…" : "Submit for review"}
</Button>
</SectionCard>
<ShipmentDetailsCard booking={booking} />
{/* Documents (uploadable) */}
<SectionCard ref={documentsRef}>
<Group justify="space-between" align="center" mb="md">
<Group gap={10} align="center">
<CardTitle>Documents</CardTitle>
<CountChip
uploaded={uploadedCount}
total={REQUIRED_DOC_FIELDS.length}
/>
</Group>
</Group>
{docError && (
<NoticeBanner
tone="red"
icon={<AlertCircle size={16} />}
style={{ marginBottom: 12 }}
>
{docError}
</NoticeBanner>
)}
<Box>
{REQUIRED_DOC_FIELDS.map((doc, i) => {
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key);
return (
<DocRow
key={doc.key}
last={i === REQUIRED_DOC_FIELDS.length - 1}
title={doc.label}
meta={
isUploaded
? (file?.name ?? "Uploaded")
: selected
? selected.name
: "Required · not uploaded"
}
status={
isUploaded ? "verified" : selected ? "ready" : "missing"
}
action={
isUploaded ? (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
) : (
<>
<input
ref={(el) => {
fileInputRefs.current[doc.key] = el;
}}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
style={{ display: "none" }}
onChange={(e) =>
handleFileSelect(
doc.key,
e.target.files?.[0] ?? null,
)
}
/>
<Group gap={6} wrap="nowrap">
<Button
variant="default"
radius={9}
leftSection={<Upload size={14} />}
onClick={() =>
fileInputRefs.current[doc.key]?.click()
}
styles={{
root: { height: 34, paddingInline: 12 },
label: {
fontSize: 12.5,
fontWeight: 700,
color: "#475569",
},
}}
>
{selected ? "Change" : "Choose"}
</Button>
{selected && (
<ActionIcon
variant="default"
radius={8}
w={34}
h={34}
onClick={() => handleFileSelect(doc.key, null)}
style={{ color: "#C0392B" }}
>
<X size={15} />
</ActionIcon>
)}
</Group>
</>
)
}
/>
);
})}
</Box>
{anyFileSelected && (
<Button
mt="md"
radius={10}
color="edr-green"
leftSection={
uploadMutation.isPending ? undefined : <Upload size={16} />
}
onClick={handleUploadAll}
disabled={uploadMutation.isPending}
loading={uploadMutation.isPending}
styles={{ root: { height: 44 }, label: { fontWeight: 700 } }}
>
{uploadMutation.isPending
? "Uploading…"
: "Upload selected documents"}
</Button>
)}
</SectionCard>
</>
}
right={
<>
<EstimateCard
pricing={pricing}
title="Estimated Cost"
chip="Pending review"
/>
<ScheduleCard booking={booking} title="Schedule & Service" />
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
</>
}
/>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
title={<Text fw={700}>Cancel booking</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Are you sure you want to cancel <strong>{booking.reference}</strong>
? This action cannot be undone.
</Text>
<TextInput
label="Reason for cancellation (optional)"
placeholder="e.g. Change of plans, duplicate booking…"
value={cancelReason}
onChange={(e) => setCancelReason(e.currentTarget.value)}
radius="md"
data-autofocus
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setCancelDialogOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius="md"
onClick={() =>
cancelMutation.mutate(
cancelReason.trim() || "Cancelled by customer",
)
}
disabled={cancelMutation.isPending}
loading={cancelMutation.isPending}
leftSection={
!cancelMutation.isPending ? <XCircle size={15} /> : undefined
}
>
Yes, cancel
</Button>
</Group>
</Stack>
</Modal>
</PageShell>
);
}

View File

@@ -0,0 +1,106 @@
import { Box, Group, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { CreditCard, Download } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { ActivityCard } from "./components/ActivityCard";
import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
const navigate = useNavigate();
const status = booking.status as string;
const payMutation = useMutation({
mutationFn: () => api.bookings.pay.call({ id: booking.id }),
onSuccess: (data) => {
if (data.redirectUrl) window.location.href = data.redirectUrl;
},
});
const pricing = booking.pricingBreakdown;
const canPay =
status === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID";
return (
<PageShell>
<PageHeader
booking={booking}
actions={
canPay && (
<HeaderButton
green
icon={<CreditCard size={16} />}
label={payMutation.isPending ? "Processing…" : "Pay now"}
onClick={() => payMutation.mutate()}
disabled={payMutation.isPending}
/>
)
}
/>
<StatusHero booking={booking} />
<ContractCard booking={booking} navigate={navigate} />
<BodyGrid
left={
<>
<ShipmentDetailsCard booking={booking} />
{booking.files && booking.files.length > 0 && (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Documents</CardTitle>
<Text fz="12.5px" fw={600} c="#9AA8B5">
{booking.files.length} files
</Text>
</Group>
<Box>
{booking.files.map((file, i) => (
<DocRow
key={file.id}
last={i === booking.files!.length - 1}
title={file.name}
meta={file.code.replace(/_/g, " ")}
status="verified"
action={
<IconSquare
href={file.signedUrl ?? file.url}
icon={<Download size={16} />}
/>
}
/>
))}
</Box>
</SectionCard>
)}
<ActivityCard booking={booking} />
</>
}
right={
<>
<PaymentCard booking={booking} pricing={pricing} />
<ScheduleCard
booking={booking}
title="Consignment & Schedule"
consignment
/>
<SupportCard />
</>
}
/>
</PageShell>
);
}

View File

@@ -0,0 +1,114 @@
import { Box, Group, Text } from "@mantine/core";
import { Train } from "lucide-react";
import type { Freight } from "@edr/types";
import { fmtDate } from "../utils";
import { CardTitle, SectionCard } from "./layout";
export function ActivityCard({ booking }: { booking: Freight.IBooking }) {
const events = [
booking.signedByCeoAt && {
at: booking.signedByCeoAt,
title: "Contract fully executed",
note: "Signed by all parties",
},
booking.signedByDirectorAt && {
at: booking.signedByDirectorAt,
title: "Director signed contract",
note: "Awaiting final signature",
},
booking.approvedByStaffAt && {
at: booking.approvedByStaffAt,
title: "Booking approved",
note: "Cleared by EDR staff",
},
{
at: booking.createdAt,
title: "Booking created",
note: "Request drafted by customer",
},
].filter(Boolean) as { at: string; title: string; note: string }[];
if (events.length === 0) return null;
return (
<SectionCard>
<Group justify="space-between" align="center" pb={18}>
<CardTitle>Activity</CardTitle>
<Text fz="13px" fw={700} c="#0A6F4D">
Full history
</Text>
</Group>
<Box>
{events.map((e, i) => {
const first = i === 0;
const lastItem = i === events.length - 1;
return (
<Group key={i} gap={14} align="stretch" wrap="nowrap">
<Box
w={22}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
}}
>
<Box
style={{
width: 18,
height: 18,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
backgroundColor: first ? "#0EA371" : "#D4E9DF",
boxShadow: first ? "0 0 0 4px #BFE8D4" : undefined,
}}
>
{first && <Train size={10} color="#fff" />}
</Box>
{!lastItem && (
<Box
flex={1}
style={{
width: 2,
borderRadius: 999,
backgroundColor: "#D4E9DF",
}}
/>
)}
</Box>
<Box miw={0} flex={1} pb={lastItem ? 0 : 20}>
<Group gap={9} align="center">
<Text fz="14px" fw={800} c={first ? "#0A6F4D" : "#10202F"}>
{e.title}
</Text>
{first && (
<Box
component="span"
style={{
borderRadius: 999,
backgroundColor: "#ECF6F1",
padding: "3px 9px",
fontSize: 10.5,
fontWeight: 800,
color: "#0A6F4D",
}}
>
Latest
</Box>
)}
</Group>
<Text mt={3} fz="12px" c="#9AA8B5">
{fmtDate(e.at)} · {e.note}
</Text>
</Box>
</Group>
);
})}
</Box>
</SectionCard>
);
}

View File

@@ -0,0 +1,103 @@
import { Box, Button, Group, Paper, Text } from "@mantine/core";
import { FileSignature } from "lucide-react";
import type { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
const CONTRACT_CONFIG: Record<
string,
{
title: string;
description: string;
buttonLabel?: string;
urgent?: boolean;
}
> = {
APPROVED_PENDING_SIGNATURE: {
title: "Contract being prepared",
description:
"Your booking has been approved. The contract will be available shortly.",
},
CONTRACT_READY: {
title: "Action required — sign your contract",
description:
"Your contract is ready. Review the agreement and apply your digital signature to proceed.",
buttonLabel: "View & sign contract",
urgent: true,
},
SIGNED_CUSTOMER: {
title: "You have signed the contract",
description:
"Your signature has been submitted. Awaiting the final staff signature.",
buttonLabel: "View contract",
},
FULLY_EXECUTED: {
title: "Contract fully executed",
description:
"The contract has been signed by all parties. You can now proceed to payment.",
buttonLabel: "View contract",
},
};
export function ContractCard({
booking,
navigate,
}: {
booking: Freight.IBooking;
navigate: ReturnType<typeof useNavigate>;
}) {
const c = CONTRACT_CONFIG[booking.status as string];
if (!c) return null;
const urgent = c.urgent;
return (
<Paper
radius={20}
p="lg"
withBorder={!urgent}
bg={urgent ? "#0EA371" : "#ECF6F1"}
style={{ borderColor: urgent ? "transparent" : "#CDEBDD" }}
>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Group gap={16} align="center" wrap="nowrap">
<Box
style={{
width: 44,
height: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
backgroundColor: "#fff",
color: urgent ? "#0EA371" : "#0A6F4D",
}}
>
<FileSignature size={22} />
</Box>
<Box>
<Text fz="15px" fw={800} c={urgent ? "#fff" : "#10202F"}>
{c.title}
</Text>
<Text mt={2} fz="13px" c={urgent ? "rgba(255,255,255,0.8)" : "#6B7C8E"}>
{c.description}
</Text>
</Box>
</Group>
{c.buttonLabel && (
<Button
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
radius={10}
color={urgent ? "#0C1A2B" : "edr-green"}
leftSection={<FileSignature size={15} />}
styles={{
root: { height: 42, paddingInline: 16 },
label: { fontSize: 13, fontWeight: 700 },
}}
>
{c.buttonLabel}
</Button>
)}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,161 @@
import { Box, Group, Text } from "@mantine/core";
import { CheckCircle2, FileText } from "lucide-react";
import type { ReactNode } from "react";
export function DocRow({
title,
meta,
status,
action,
last,
}: {
title: string;
meta: string;
status: "verified" | "ready" | "missing";
action: ReactNode;
last?: boolean;
}) {
const tileBg = status === "ready" ? "#EAF1FB" : "#F1F4F7";
const tileFg = status === "ready" ? "#2E5B96" : "#475569";
return (
<Group
gap={13}
align="center"
wrap="nowrap"
py={13}
style={{
borderBottom: last ? undefined : "1px solid #F2F5F8",
}}
>
<Box
style={{
flexShrink: 0,
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: tileBg,
color: tileFg,
}}
>
<FileText size={18} />
</Box>
<Box miw={0} flex={1}>
<Text truncate fz="13.5px" fw={700} c="#10202F">
{title}
</Text>
<Text truncate fz="12px" c="#9AA8B5">
{meta}
</Text>
</Box>
{status === "verified" && (
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#ECF6F1",
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
color: "#0A6F4D",
}}
>
<CheckCircle2 size={13} />
Verified
</Group>
)}
{status === "ready" && (
<Box
component="span"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#EAF1FB",
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
color: "#2E5B96",
}}
>
Ready
</Box>
)}
{action}
</Group>
);
}
export function IconSquare({
icon,
href,
}: {
icon: ReactNode;
href?: string | null;
}) {
const style: React.CSSProperties = {
flexShrink: 0,
width: 34,
height: 34,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
border: "1px solid #E6ECF2",
color: "#6B7C8E",
};
if (href) {
return (
<Box
component="a"
href={href}
target="_blank"
rel="noopener noreferrer"
style={style}
>
{icon}
</Box>
);
}
return (
<Box component="span" style={style}>
{icon}
</Box>
);
}
export function CountChip({
uploaded,
total,
}: {
uploaded: number;
total: number;
}) {
const done = uploaded === total;
return (
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
backgroundColor: done ? "#ECF6F1" : "#FDF3E0",
color: done ? "#0A6F4D" : "#9A5B00",
}}
>
{done && <CheckCircle2 size={13} />}
{uploaded}/{total} uploaded
</Group>
);
}

View File

@@ -0,0 +1,77 @@
import { Box, Group, Text } from "@mantine/core";
import { AlertCircle } from "lucide-react";
import type { ReactNode } from "react";
export function NoticeBanner({
tone,
icon,
title,
children,
style,
}: {
tone: "amber" | "red";
icon: ReactNode;
title?: string;
children: ReactNode;
style?: React.CSSProperties;
}) {
const palette =
tone === "amber"
? { border: "#F6E2BC", bg: "#FDF3E0", color: "#9A5B00" }
: { border: "#F3C8C1", bg: "#FBEAE7", color: "#A93226" };
return (
<Group
align="flex-start"
gap={12}
wrap="nowrap"
p="md"
style={{
borderRadius: 14,
border: `1px solid ${palette.border}`,
backgroundColor: palette.bg,
color: palette.color,
...style,
}}
>
<Box component="span" mt={2} style={{ flexShrink: 0 }}>
{icon}
</Box>
<Box miw={0}>
{title && (
<Text fz="13.5px" fw={800} c={palette.color}>
{title}
</Text>
)}
<Text fz="13px" c={palette.color} style={{ lineHeight: 1.45 }}>
{children}
</Text>
</Box>
</Group>
);
}
export function MutationErrors({
mutations,
}: {
mutations: { isError: boolean; error: unknown }[];
}) {
const errored = mutations.filter((m) => m.isError);
if (errored.length === 0) return null;
return (
<>
{errored.map((m, i) => (
<NoticeBanner
key={i}
tone="red"
icon={<AlertCircle size={18} />}
title="Something went wrong"
>
{m.error instanceof Error
? m.error.message
: "An unexpected error occurred."}
</NoticeBanner>
))}
</>
);
}

View File

@@ -0,0 +1,125 @@
import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { ArrowDownLeft, ArrowUpRight } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
export function PageHeader({
booking,
actions,
}: {
booking: Freight.IBooking;
actions: ReactNode;
}) {
const status = booking.status as string;
const negative = isNegative(status);
const draft = isDraftLike(status);
const dotColor = negative ? "#C0392B" : draft ? "#94A3B8" : "#0EA371";
const pillBg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1";
const pillBorder = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD";
const pillText = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
const isExport = booking.tradeDirection === "EXPORT";
return (
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={8} miw={0}>
<Group gap={12} align="center" wrap="wrap">
<Text fz="18px" fw={800}>
{booking.reference}
</Text>
<Group
component="span"
gap={7}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
padding: "6px 12px",
fontSize: 12,
fontWeight: 700,
backgroundColor: pillBg,
border: `1px solid ${pillBorder}`,
color: pillText,
}}
>
<Box
component="span"
style={{
width: 7,
height: 7,
borderRadius: 999,
backgroundColor: dotColor,
}}
/>
{status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())}
</Group>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#F1F4F7",
padding: "6px 11px",
fontSize: 12,
fontWeight: 700,
color: "#475569",
}}
>
{isExport ? <ArrowUpRight size={14} /> : <ArrowDownLeft size={14} />}
{isExport ? "Export" : "Import"}
</Group>
</Group>
<Text fz="14px" c="#6B7C8E">
{bookingSubtitle(booking)}
</Text>
</Stack>
<Group gap={12} wrap="wrap">
{actions}
</Group>
</Group>
);
}
export function HeaderButton({
label,
icon,
onClick,
dark,
green,
disabled,
}: {
label: string;
icon: ReactNode;
onClick?: () => void;
dark?: boolean;
green?: boolean;
disabled?: boolean;
}) {
return (
<Button
onClick={onClick}
disabled={disabled}
leftSection={icon}
radius={10}
variant={green || dark ? "filled" : "default"}
color={green ? "edr-green" : dark ? "#0C1A2B" : undefined}
styles={{
root: { height: 42, paddingInline: 16 },
label: {
fontSize: 13,
fontWeight: 700,
color: green || dark ? "#fff" : "#10202F",
},
}}
>
{label}
</Button>
);
}

View File

@@ -0,0 +1,84 @@
import { Box, Group, Text } from "@mantine/core";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { fmtDate } from "../utils";
import { CardTitle, SectionCard } from "./layout";
type Row = { label: string; value: ReactNode; muted?: boolean };
export function ScheduleCard({
booking,
title,
consignment,
}: {
booking: Freight.IBooking;
title: string;
consignment?: boolean;
}) {
const service =
booking.serviceType === "RAIL_AND_FORWARDING"
? "Rail + Forwarding"
: "Rail only";
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed";
const assignedTrain: Row = {
label: "Assigned train",
value: booking.trainId ?? "Not yet assigned",
muted: !booking.trainId,
};
const rows: Row[] = consignment
? [
{ label: "Consignment ID", value: booking.reference },
{ label: "Service", value: service },
{ label: "Equipment return", value: equipmentReturn },
assignedTrain,
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
{ label: "Consolidation", value: consolidation },
]
: [
{ label: "Service", value: service },
{ label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
assignedTrain,
{ label: "Consolidation", value: consolidation },
];
return (
<SectionCard p={22}>
<Box pb={8}>
<CardTitle>{title}</CardTitle>
</Box>
<Box>
{rows.map((r, i) => (
<Group
key={r.label}
justify="space-between"
align="center"
wrap="nowrap"
py="sm"
style={{
borderBottom:
i < rows.length - 1 ? "1px solid #F2F5F8" : undefined,
}}
>
<Text fz="13px" c="#9AA8B5">
{r.label}
</Text>
<Text
fz="13px"
fw={700}
c={r.muted ? "#9AA8B5" : "#10202F"}
fs={r.muted ? "italic" : undefined}
>
{r.value}
</Text>
</Group>
))}
</Box>
</SectionCard>
);
}

View File

@@ -0,0 +1,106 @@
import { Box, Group, Text } from "@mantine/core";
import { FileText } from "lucide-react";
import type { Freight } from "@edr/types";
import { containerSummary, fmtDate, yardLabel } from "../utils";
import { CardTitle, SectionCard } from "./layout";
export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) {
const rows: [string, string][][] = [
[
["Origin yard", yardLabel(booking.originYard)],
["Destination yard", yardLabel(booking.destinationYard)],
],
[
["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"],
["Commodity", booking.freightSubtype || "—"],
],
[
["Containers / load", containerSummary(booking)],
[
"Total weight (VGM)",
booking.cargoTotalWeightVgm ? `${booking.cargoTotalWeightVgm} t` : "—",
],
],
[
[
"Service type",
booking.serviceType === "RAIL_AND_FORWARDING"
? "Rail + Forwarding"
: "Rail only",
],
[
"Equipment return",
booking.equipmentReturn === "WITH_RETURN"
? "With return"
: "Without return",
],
],
[
[
"Trade direction",
booking.tradeDirection === "IMPORT" ? "Import" : "Export",
],
["Scheduled date", fmtDate(booking.scheduledDate)],
],
[
["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"],
["Assigned train", booking.trainId ?? "Not yet assigned"],
],
];
return (
<SectionCard>
<Group justify="space-between" align="center" pb={3}>
<CardTitle>Shipment Details</CardTitle>
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#F1F4F7",
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
color: "#475569",
}}
>
<FileText size={13} />
{booking.contractType === "RENEWAL"
? "Renewal contract"
: "New contract"}
</Group>
</Group>
<Box>
{rows.map((pair, i) => (
<Group
key={i}
gap={24}
align="flex-start"
wrap="nowrap"
py={13}
style={{
borderBottom:
i < rows.length - 1 ? "1px solid #F2F5F8" : undefined,
}}
>
{pair.map(([k, v]) => (
<Box key={k} miw={0} flex={1}>
<Text fz="11.5px" fw={600} c="#9AA8B5">
{k}
</Text>
<Text truncate mt={4} fz="14px" fw={700} c="#10202F">
{v}
</Text>
</Box>
))}
</Group>
))}
</Box>
</SectionCard>
);
}

View File

@@ -0,0 +1,213 @@
import { Box, Group, Text } from "@mantine/core";
import { AlertTriangle, Check, Clock, FileText, History } from "lucide-react";
import type { Freight } from "@edr/types";
import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
import { fmtDate, isDraftLike, isNegative } from "../utils";
import { SectionCard } from "./layout";
export function StatusHero({ booking }: { booking: Freight.IBooking }) {
const status = booking.status as string;
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
const negative = isNegative(status);
const draft = isDraftLike(status);
const tone: "green" | "slate" | "red" = negative
? "red"
: draft
? "slate"
: "green";
const tileBg =
tone === "red" ? "#FBEAE7" : tone === "slate" ? "#F1F4F7" : "#ECF6F1";
const tileFg =
tone === "red" ? "#C0392B" : tone === "slate" ? "#475569" : "#0EA371";
const HeroIcon = negative
? AlertTriangle
: draft
? FileText
: (PROGRESS_STAGES[cfg.stage]?.icon ?? History);
const chipLabel = draft ? "Last edited" : negative ? "Updated" : "Scheduled";
const chipValue = fmtDate(
draft || negative ? booking.updatedAt : booking.scheduledDate,
);
return (
<SectionCard>
<Group justify="space-between" align="center" wrap="wrap" gap="md">
<Group gap={16} align="center" wrap="nowrap">
<Box
style={{
width: 56,
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
backgroundColor: tileBg,
color: tileFg,
}}
>
<HeroIcon size={26} />
</Box>
<Box>
<Text size="lg" fw={700}>
{cfg.title}
</Text>
<Text mt={4} fz="14px" c="#6B7C8E">
{cfg.description}
</Text>
</Box>
</Group>
<Group
gap={11}
align="center"
wrap="nowrap"
px="md"
py="sm"
style={{
borderRadius: 14,
border: "1px solid #E6ECF2",
backgroundColor: "#F7FAFC",
}}
>
<Clock size={20} color="#0A6F4D" />
<Box>
<Text fz="12px" fw={600}>
{chipLabel}
</Text>
<Text fz="13px" fw={700}>
{chipValue}
</Text>
</Box>
</Group>
</Group>
<Box my={24} h={1} w="100%" bg="#EEF2F6" />
<ProgressTracker
current={cfg.stage}
tone={draft ? "ink" : "green"}
negative={negative}
/>
</SectionCard>
);
}
function ProgressTracker({
current,
tone = "green",
negative,
}: {
current: number;
tone?: "green" | "ink";
negative?: boolean;
}) {
const last = PROGRESS_STAGES.length - 1;
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
const activeSub = tone === "ink" ? "#475569" : "#0A6F4D";
return (
<Group align="flex-start" gap={0} wrap="nowrap" w="100%">
{PROGRESS_STAGES.map((stage, idx) => {
const state =
idx < current ? "done" : idx === current ? "active" : "idle";
const Icon = stage.icon;
const reachedLeft = current >= idx && current >= 0;
const reachedRight = current > idx && current >= 0;
const circleStyle: React.CSSProperties =
state === "idle"
? { backgroundColor: "#EEF2F6", border: "1px solid #E1E7EE" }
: {
backgroundColor: state === "active" ? activeFill : "#0EA371",
boxShadow:
state === "active" ? `0 0 0 4px ${activeRing}` : undefined,
};
return (
<Box
key={stage.label}
flex={1}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
<Box style={{ display: "flex", alignItems: "center", width: "100%" }}>
<Box
flex={1}
style={{
height: 3,
borderRadius: 999,
background:
idx === 0
? "transparent"
: reachedLeft
? "#0EA371"
: "#E1E7EE",
}}
/>
<Box
style={{
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
...circleStyle,
}}
>
{state === "done" ? (
<Check size={16} color="#fff" />
) : state === "active" ? (
<Icon size={16} color="#fff" />
) : null}
</Box>
<Box
flex={1}
style={{
height: 3,
borderRadius: 999,
background:
idx === last
? "transparent"
: reachedRight
? "#0EA371"
: "#E1E7EE",
}}
/>
</Box>
<Text
fz="13.5px"
fw={700}
ta="center"
c={state === "idle" ? "#9AA8B5" : "#10202F"}
>
{stage.label}
</Text>
<Text
fz="11.5px"
fw={500}
ta="center"
c={state === "active" ? activeSub : "#9AA8B5"}
>
{state === "done"
? "Completed"
: state === "active"
? negative
? "Stopped"
: "In progress"
: "Pending"}
</Text>
</Box>
);
})}
</Group>
);
}

View File

@@ -0,0 +1,93 @@
import { Box, Button, Group, Text } from "@mantine/core";
import { Check } from "lucide-react";
import type { ReactNode } from "react";
export function StepLine({
index,
title,
desc,
action,
done,
active,
highlight,
}: {
index: number;
title: string;
desc: string;
action?: ReactNode;
done?: boolean;
active?: boolean;
highlight?: boolean;
}) {
const badgeStyle: React.CSSProperties = done
? { backgroundColor: "#0EA371", color: "#fff" }
: active
? { backgroundColor: "#0C1A2B", color: "#fff" }
: {
backgroundColor: "#EEF2F6",
color: "#9AA8B5",
border: "1px solid #E1E7EE",
};
return (
<Group
gap={14}
align="center"
wrap="nowrap"
px="sm"
py={12}
style={{
borderRadius: 12,
backgroundColor: highlight ? "#F4F7FA" : undefined,
}}
>
<Box
style={{
flexShrink: 0,
width: 28,
height: 28,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
...badgeStyle,
}}
>
{done ? <Check size={14} /> : index}
</Box>
<Box miw={0} flex={1}>
<Text fz="13.5px" fw={700} c="#10202F">
{title}
</Text>
<Text fz="12.5px" c="#9AA8B5">
{desc}
</Text>
</Box>
{action}
</Group>
);
}
export function StepGhostButton({
children,
onClick,
}: {
children: ReactNode;
onClick?: () => void;
}) {
return (
<Button
variant="default"
radius={9}
onClick={onClick}
styles={{
root: { height: 32, paddingInline: 12 },
label: { fontSize: 12.5, fontWeight: 700, color: "#475569" },
}}
>
{children}
</Button>
);
}

View File

@@ -0,0 +1,61 @@
import { Box, Button, Group, Paper, Text } from "@mantine/core";
import { FileText, MessageSquare, XCircle } from "lucide-react";
export function SupportCard({ onCancel }: { onCancel?: () => void }) {
return (
<Paper radius={20} p={22} bg="#0C1A2B">
<Group gap={12} align="center" wrap="nowrap">
<Box
style={{
width: 42,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
backgroundColor: "#16273A",
}}
>
<MessageSquare size={20} color="#fff" />
</Box>
<Box>
<Text fz="15px" fw={800} c="#fff">
Need help?
</Text>
<Text fz="12px" c="#9AA8B5">
EDR operations team
</Text>
</Box>
</Group>
<Text mt={14} fz="13px" c="#C4D0DB" style={{ lineHeight: 1.45 }}>
Questions about this shipment, documents, or delivery? Our operations
team can help.
</Text>
<Group gap={10} mt="md" wrap="nowrap">
<Button
flex={1}
color="edr-green"
radius={10}
leftSection={<MessageSquare size={16} />}
styles={{ root: { height: 44 }, label: { fontWeight: 700 } }}
>
Contact
</Button>
<Button
onClick={onCancel}
radius={10}
color="#16273A"
leftSection={
onCancel ? <XCircle size={16} /> : <FileText size={16} />
}
styles={{
root: { height: 44, paddingInline: 16 },
label: { fontWeight: 700, color: "#fff" },
}}
>
{onCancel ? "Cancel" : "Report"}
</Button>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,55 @@
import { Box, Flex, Paper, Stack, Text, type PaperProps } from "@mantine/core";
import type { ReactNode, Ref } from "react";
export function PageShell({ children }: { children: ReactNode }) {
return (
<Box mih="100vh">
<Box mx="auto" w="100%" px="lg" py={28}>
<Stack gap="lg">{children}</Stack>
</Box>
</Box>
);
}
export function BodyGrid({ left, right }: { left: ReactNode; right: ReactNode }) {
return (
<Flex
direction={{ base: "column", lg: "row" }}
align={{ base: "stretch", lg: "flex-start" }}
gap={24}
>
<Box flex={1} miw={0} display="flex" style={{ flexDirection: "column", gap: 24 }}>
{left}
</Box>
<Box
w={360}
maw="100%"
display="flex"
style={{ flexDirection: "column", gap: 24, flexShrink: 0 }}
>
{right}
</Box>
</Flex>
);
}
interface SectionCardProps extends PaperProps {
children: ReactNode;
ref?: Ref<HTMLDivElement>;
}
export function SectionCard({ children, ref, ...props }: SectionCardProps) {
return (
<Paper ref={ref} radius={20} p="lg" withBorder bg="white" {...props}>
{children}
</Paper>
);
}
export function CardTitle({ children }: { children: ReactNode }) {
return (
<Text size="lg" fw={700}>
{children}
</Text>
);
}

View File

@@ -0,0 +1,162 @@
import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { FileText } from "lucide-react";
import type { Freight } from "@edr/types";
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
import { CardTitle, SectionCard } from "./layout";
function LineItems({ pricing }: { pricing: Pricing }) {
const items = priceLineItems(pricing);
if (items.length === 0) return null;
return (
<Stack gap={11}>
{items.map((it) => (
<Group key={it.label} justify="space-between" wrap="nowrap">
<Text fz="13px" c="#6B7C8E">
{it.label}
</Text>
<Text fz="13px" fw={600} c="#10202F">
{it.value}
</Text>
</Group>
))}
</Stack>
);
}
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
export function EstimateCard({
pricing,
title,
chip,
}: {
pricing: Pricing;
title: string;
chip: string;
}) {
const hasItems = priceLineItems(pricing).length > 0;
return (
<SectionCard p={22}>
<Group justify="space-between" align="center">
<CardTitle>{title}</CardTitle>
<Box
component="span"
style={{
borderRadius: 999,
backgroundColor: "#F1F4F7",
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
color: "#475569",
}}
>
{chip}
</Box>
</Group>
<Box mt={12}>
<Text fz="26px" fw={800} c="#10202F">
{priceTotal(pricing)}
</Text>
<Text mt={4} fz="12.5px" c="#9AA8B5">
A firm price is confirmed after EDR reviews your booking.
</Text>
</Box>
{hasItems && (
<>
<Divider />
<LineItems pricing={pricing} />
<Group
justify="space-between"
mt={12}
pt={14}
style={{ borderTop: "1px solid #EEF2F6" }}
>
<Text fz="14px" fw={800} c="#10202F">
Estimated total
</Text>
<Text fz="15px" fw={800} c="#10202F">
{priceTotal(pricing)}
</Text>
</Group>
</>
)}
</SectionCard>
);
}
export function PaymentCard({
booking,
pricing,
}: {
booking: Freight.IBooking;
pricing: Pricing;
}) {
const hasItems = priceLineItems(pricing).length > 0;
const paid = booking.paymentStatus === "PAID";
const total = priceTotal(pricing);
return (
<SectionCard p={22}>
<Group justify="space-between" align="center">
<CardTitle>Payment</CardTitle>
<Box
component="span"
style={{
borderRadius: 999,
padding: "5px 11px",
fontSize: 11.5,
fontWeight: 700,
backgroundColor: paid ? "#ECF6F1" : "#FDF3E0",
color: paid ? "#0A6F4D" : "#9A5B00",
border: paid ? "1px solid #CDEBDD" : undefined,
}}
>
{paid
? "Paid"
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
</Box>
</Group>
<Box mt={12}>
<Text fz="26px" fw={800} c="#10202F">
{total}
</Text>
{paid && (
<Text mt={4} fz="12.5px" c="#9AA8B5">
Paid · {fmtDate(booking.updatedAt)}
</Text>
)}
</Box>
{hasItems && (
<>
<Divider />
<LineItems pricing={pricing} />
<Group
justify="space-between"
mt={12}
pt={14}
style={{ borderTop: "1px solid #EEF2F6" }}
>
<Text fz="14px" fw={800} c="#10202F">
Total
</Text>
<Text fz="15px" fw={800} c="#0A6F4D">
{total}
</Text>
</Group>
</>
)}
<Button
fullWidth
mt={16}
variant="default"
radius={10}
leftSection={<FileText size={17} color="#475569" />}
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }}
>
Download invoice
</Button>
</SectionCard>
);
}

View File

@@ -0,0 +1,160 @@
import {
ClipboardCheck,
FileText,
PackageCheck,
ShieldCheck,
Train,
} from "lucide-react";
export const PROGRESS_STAGES = [
{
label: "Request",
icon: FileText,
statuses: ["DRAFT", "CHANGES_REQUESTED"],
},
{
label: "Submitted",
icon: ClipboardCheck,
statuses: ["SUBMITTED", "PENDING_APPROVAL"],
},
{
label: "Approved",
icon: ShieldCheck,
statuses: [
"APPROVED_PENDING_SIGNATURE",
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
],
},
{
label: "In Transit",
icon: Train,
statuses: [
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
"IN_TRANSIT",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
],
},
{
label: "Complete",
icon: PackageCheck,
statuses: ["COMPLETED", "DELIVERED"],
},
];
export const STATUS_MAP: Record<
string,
{ title: string; description: string; stage: number }
> = {
DRAFT: {
title: "Draft — not submitted",
description:
"This booking is being prepared and hasnt been submitted for review yet.",
stage: 0,
},
CHANGES_REQUESTED: {
title: "Changes requested",
description: "Staff has requested changes. Please review and resubmit.",
stage: 0,
},
SUBMITTED: {
title: "Submitted for review",
description: "Your booking has been submitted and is awaiting review.",
stage: 1,
},
PENDING_APPROVAL: {
title: "Pending approval",
description: "Your booking is moving through the approval process.",
stage: 1,
},
APPROVED_PENDING_SIGNATURE: {
title: "Approved — awaiting signature",
description: "Approved. Your contract will be ready to sign shortly.",
stage: 2,
},
APPROVED: {
title: "Approved",
description: "Your booking has been fully approved.",
stage: 2,
},
CONTRACT_READY: {
title: "Contract ready to sign",
description:
"Your contract is ready. Review and apply your signature to proceed.",
stage: 2,
},
SIGNED_CUSTOMER: {
title: "Signed — awaiting staff",
description:
"Your signature has been submitted. Awaiting the final staff signature.",
stage: 2,
},
FULLY_EXECUTED: {
title: "Contract fully executed",
description: "Signed by all parties. You can now proceed to payment.",
stage: 2,
},
PNR_GENERATED: {
title: "Payment reference generated",
description:
"A payment reference number has been generated for this booking.",
stage: 3,
},
PAYMENT_VERIFICATION_IN_PROGRESS: {
title: "Verifying payment",
description: "Your payment is being verified.",
stage: 3,
},
PAID: {
title: "Payment confirmed",
description: "Payment has been confirmed for this booking.",
stage: 3,
},
IN_TRANSIT: {
title: "Cargo moving",
description: "Your shipment is currently moving through the rail network.",
stage: 3,
},
PENDING_CONSOLIDATION: {
title: "Pending consolidation",
description: "Awaiting a consolidation partner shipment.",
stage: 3,
},
CONSOLIDATED: {
title: "Consolidated",
description: "Cargo has been consolidated with a partner shipment.",
stage: 3,
},
COMPLETED: {
title: "Service complete",
description: "Cargo delivered and service successfully terminated.",
stage: 4,
},
DELIVERED: {
title: "Service complete",
description: "Cargo delivered and service successfully terminated.",
stage: 4,
},
REJECTED: {
title: "Booking rejected",
description: "This booking request has been rejected.",
stage: -1,
},
CANCELLED: {
title: "Booking cancelled",
description: "This booking process has been terminated.",
stage: -1,
},
};
export const REQUIRED_DOC_FIELDS = [
{ key: "commercial_invoice", label: "Commercial Invoice" },
{ key: "packing_list", label: "Packing List" },
{ key: "certificate_of_origin", label: "Certificate of Origin" },
{ key: "letter_of_credit", label: "Letter of Credit / LC" },
];

View File

@@ -0,0 +1,86 @@
import { Box, Center, Loader, Stack, Text } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle } from "lucide-react";
import { useParams } from "react-router-dom";
import { api } from "@/services/api";
import { DraftBookingView } from "./DraftBookingView";
import { PageShell, SectionCard } from "./components/layout";
import { ReadonlyBookingView } from "./ReadonlyBookingView";
import { isDraftLike } from "./utils";
export default function BookingDetailPage() {
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const {
data: booking,
isLoading,
isError,
error,
} = useQuery(
api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }),
);
const refetchBooking = () => {
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: id! }),
});
};
if (isLoading) {
return (
<Center mih={400} p="xl">
<Stack align="center" gap="md">
<Loader color="edr-green" />
<Text size="sm" c="dimmed">
Loading booking details
</Text>
</Stack>
</Center>
);
}
if (isError || !booking) {
return (
<PageShell>
<SectionCard>
<Stack align="center" gap={8} py={48} ta="center">
<Box
style={{
width: 64,
height: 64,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
backgroundColor: "#FBEAE7",
color: "#C0392B",
}}
>
<AlertTriangle size={30} />
</Box>
<Text mt={4} fz="20px" fw={800} c="#10202F">
{isError ? "Failed to load booking" : "Booking not found"}
</Text>
{isError && (
<Text size="sm" c="dimmed">
{error instanceof Error
? error.message
: "An unexpected error occurred."}
</Text>
)}
</Stack>
</SectionCard>
</PageShell>
);
}
if (isDraftLike(booking.status)) {
return (
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
);
}
return <ReadonlyBookingView booking={booking} />;
}

View File

@@ -0,0 +1,50 @@
import { format } from "date-fns";
import type { Freight } from "@edr/types";
export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED";
export const isDraftLike = (s: string) =>
s === "DRAFT" || s === "CHANGES_REQUESTED";
export function fmtDate(value?: string | null) {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime()) ? "—" : format(d, "MMM d, yyyy");
}
export function yardLabel(y?: Freight.IBooking["originYard"]) {
return y?.label ?? y?.code ?? "—";
}
export function containerSummary(b: Freight.IBooking) {
if (b.containers?.length) {
return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", ");
}
return b.freightType === "BULK" ? "Bulk cargo" : "—";
}
export function bookingSubtitle(b: Freight.IBooking) {
const cargo =
b.freightSubtype ||
(b.freightType === "BULK" ? "Bulk freight" : "Container freight");
const load = containerSummary(b);
const route = `${yardLabel(b.originYard)}${yardLabel(b.destinationYard)}`;
return [cargo, load, route].filter((p) => p && p !== "—").join(" · ");
}
// ─── Pricing helpers ──────────────────────────────────────────────────────────
export type Pricing = Freight.PricingBreakdown | null | undefined;
export function priceLineItems(pricing: Pricing) {
return (pricing?.lineItems ?? []).map((li) => ({
label: li.description,
value: `${li.amount.toLocaleString()} ${li.currency}`,
}));
}
export function priceTotal(pricing: Pricing) {
if (!pricing) return "—";
const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0);
return `${total.toLocaleString()} ${pricing.currency}`;
}