mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 10:52:53 +00:00
refactor: booking page refactor
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 hasn’t 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" },
|
||||
];
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user