style: ui fixes to booking detail page

This commit is contained in:
Nathnael
2026-06-11 07:11:43 +00:00
parent 83edb1e063
commit 2cd585a3ac
12 changed files with 567 additions and 218 deletions

View File

@@ -29,7 +29,11 @@ import { CardTitle, PageShell, SectionCard } from "./components/layout";
import { CountChip, DocRow, IconSquare } from "./components/Documents"; import { CountChip, DocRow, IconSquare } from "./components/Documents";
import { EstimateCard } from "./components/pricing"; import { EstimateCard } from "./components/pricing";
import { HeaderButton, PageHeader } from "./components/PageHeader"; import { HeaderButton, PageHeader } from "./components/PageHeader";
import { MutationErrors, NoticeBanner } from "./components/Notices"; import {
ActionRequiredBanner,
MutationErrors,
NoticeBanner,
} from "./components/Notices";
import { ScheduleCard } from "./components/ScheduleCard"; import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero"; import { StatusHero } from "./components/StatusHero";
@@ -132,30 +136,39 @@ export function DraftBookingView({
<PageHeader <PageHeader
booking={booking} booking={booking}
actions={ actions={
<HeaderButton <>
dark <HeaderButton
icon={<Pencil size={16} />} icon={<Download size={16} />}
label="Continue editing" label="Documents"
onClick={() => navigate(`/bookings/${booking.id}/edit`)} onClick={() =>
/> documentsRef.current?.scrollIntoView({ behavior: "smooth" })
}
/>
<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 <MutationErrors
mutations={[uploadMutation, submitMutation, cancelMutation]} mutations={[uploadMutation, submitMutation, cancelMutation]}
/> />
<StatusHero booking={booking} /> <StatusHero booking={booking}>
{booking.status === "CHANGES_REQUESTED" &&
booking.latestChangeRequestNote ? (
<ActionRequiredBanner
title="Review the requested changes, then resubmit."
onAction={() => navigate(`/bookings/${booking.id}/edit`)}
>
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
) : undefined}
</StatusHero>
<BodyGrid <BodyGrid
left={ left={
@@ -169,48 +182,34 @@ export function DraftBookingView({
</Text> </Text>
</Group> </Group>
<Stack gap={4}> <Stack gap={4}>
<StepLine <StepLine index={1} done title="Cargo & route details" />
index={1}
done
title="Booking details"
desc="Route, cargo and service are set."
action={
<StepGhostButton
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
>
Edit
</StepGhostButton>
}
/>
<StepLine <StepLine
index={2} index={2}
active={!allDocsUploaded} active={!allDocsUploaded}
done={allDocsUploaded} done={allDocsUploaded}
highlight title={
title="Upload documents"
desc={
allDocsUploaded allDocsUploaded
? "All required documents uploaded." ? "Required documents"
: `${uploadedCount} of ${REQUIRED_DOC_FIELDS.length} uploaded.` : "Upload required documents"
} }
desc={`${uploadedCount} of ${REQUIRED_DOC_FIELDS.length} uploaded.`}
action={ action={
!allDocsUploaded && ( <StepGhostButton
<StepGhostButton icon={<Upload size={16} color="#334155" />}
onClick={() => onClick={() =>
documentsRef.current?.scrollIntoView({ documentsRef.current?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
}) })
} }
> >
Upload Add documents
</StepGhostButton> </StepGhostButton>
)
} }
/> />
<StepLine <StepLine
index={3} index={3}
done={false} active={allDocsUploaded}
title="Submit for review" title="Review & submit"
desc="Send your booking to EDR staff." desc="Send your booking to EDR staff."
/> />
</Stack> </Stack>
@@ -223,7 +222,10 @@ export function DraftBookingView({
onClick={handleSubmitRequest} onClick={handleSubmitRequest}
disabled={submitMutation.isPending} disabled={submitMutation.isPending}
loading={submitMutation.isPending} loading={submitMutation.isPending}
styles={{ root: { height: 46 }, label: { fontWeight: 700 } }} styles={{
root: { height: 46 },
label: { fontSize: 14, fontWeight: 800 },
}}
> >
{submitMutation.isPending ? "Submitting…" : "Submit for review"} {submitMutation.isPending ? "Submitting…" : "Submit for review"}
</Button> </Button>
@@ -268,7 +270,7 @@ export function DraftBookingView({
? (file?.name ?? "Uploaded") ? (file?.name ?? "Uploaded")
: selected : selected
? selected.name ? selected.name
: "Required · not uploaded" : "Not added yet"
} }
status={ status={
isUploaded ? "verified" : selected ? "ready" : "missing" isUploaded ? "verified" : selected ? "ready" : "missing"
@@ -297,22 +299,26 @@ export function DraftBookingView({
/> />
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Button <Button
variant="default" variant="white"
radius={9} radius={9}
leftSection={<Upload size={14} />} leftSection={<Upload size={16} color="#334155" />}
onClick={() => onClick={() =>
fileInputRefs.current[doc.key]?.click() fileInputRefs.current[doc.key]?.click()
} }
styles={{ styles={{
root: { height: 34, paddingInline: 12 }, root: {
height: 34,
paddingInline: 13,
border: "1.5px solid #CBD5E1",
},
label: { label: {
fontSize: 12.5, fontSize: 12.5,
fontWeight: 700, fontWeight: 700,
color: "#475569", color: "#334155",
}, },
}} }}
> >
{selected ? "Change" : "Choose"} {selected ? "Change" : "Add"}
</Button> </Button>
{selected && ( {selected && (
<ActionIcon <ActionIcon
@@ -361,7 +367,7 @@ export function DraftBookingView({
<EstimateCard <EstimateCard
pricing={pricing} pricing={pricing}
title="Estimated Cost" title="Estimated Cost"
chip="Pending review" chip="Not invoiced"
/> />
<ScheduleCard booking={booking} title="Schedule & Service" /> <ScheduleCard booking={booking} title="Schedule & Service" />
<SupportCard onCancel={() => setCancelDialogOpen(true)} /> <SupportCard onCancel={() => setCancelDialogOpen(true)} />

View File

@@ -10,12 +10,14 @@ import { ActivityCard } from "./components/ActivityCard";
import { ContractCard } from "./components/ContractCard"; import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents"; import { DocRow, IconSquare } from "./components/Documents";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout"; import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import { CancelledBanner } from "./components/Notices";
import { HeaderButton, PageHeader } from "./components/PageHeader"; import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentCard } from "./components/pricing"; import { PaymentCard } from "./components/pricing";
import { ScheduleCard } from "./components/ScheduleCard"; import { ScheduleCard } from "./components/ScheduleCard";
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
import { StatusHero } from "./components/StatusHero"; import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard"; import { SupportCard } from "./components/SupportCard";
import { fmtDate, isNegative } from "./utils";
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -49,7 +51,23 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
} }
/> />
<StatusHero booking={booking} /> {isNegative(status) ? (
<CancelledBanner
pillLabel={status === "REJECTED" ? "Rejected" : "Cancelled"}
title={`This booking was ${
status === "REJECTED" ? "rejected" : "cancelled"
} on ${fmtDate(booking.updatedAt)}.`}
subtitle={
status === "REJECTED"
? "This booking request has been rejected."
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
onRebook={() => navigate("/bookings/new")}
/>
) : (
<StatusHero booking={booking} />
)}
<ContractCard booking={booking} navigate={navigate} /> <ContractCard booking={booking} navigate={navigate} />

View File

@@ -1,5 +1,4 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { Train } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -59,16 +58,11 @@ export function ActivityCard({ booking }: { booking: Freight.IBooking }) {
style={{ style={{
width: 18, width: 18,
height: 18, height: 18,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999, borderRadius: 999,
backgroundColor: first ? "#0EA371" : "#D4E9DF", backgroundColor: first ? "#0EA371" : "#D4E9DF",
boxShadow: first ? "0 0 0 4px #BFE8D4" : undefined, boxShadow: first ? "0 0 0 4px #BFE8D4" : undefined,
}} }}
> />
{first && <Train size={10} color="#fff" />}
</Box>
{!lastItem && ( {!lastItem && (
<Box <Box
flex={1} flex={1}
@@ -91,13 +85,15 @@ export function ActivityCard({ booking }: { booking: Freight.IBooking }) {
style={{ style={{
borderRadius: 999, borderRadius: 999,
backgroundColor: "#ECF6F1", backgroundColor: "#ECF6F1",
border: "1px solid #CDEBDD",
padding: "3px 9px", padding: "3px 9px",
fontSize: 10.5, fontSize: 10.5,
fontWeight: 800, fontWeight: 800,
letterSpacing: 0.3,
color: "#0A6F4D", color: "#0A6F4D",
}} }}
> >
Latest Current
</Box> </Box>
)} )}
</Group> </Group>

View File

@@ -10,29 +10,27 @@ const CONTRACT_CONFIG: Record<
title: string; title: string;
description: string; description: string;
buttonLabel?: string; buttonLabel?: string;
urgent?: boolean;
} }
> = { > = {
APPROVED_PENDING_SIGNATURE: { APPROVED_PENDING_SIGNATURE: {
title: "Contract being prepared", title: "Your contract is being prepared.",
description: description:
"Your booking has been approved. The contract will be available shortly.", "Your booking has been approved. The contract will be available shortly.",
}, },
CONTRACT_READY: { CONTRACT_READY: {
title: "Action required — sign your contract", title: "Review and sign your contract to proceed.",
description: description:
"Your contract is ready. Review the agreement and apply your digital signature to proceed.", "Once all parties sign, we'll issue your payment reference (PNR) and schedule the cargo.",
buttonLabel: "View & sign contract", buttonLabel: "Review & Sign",
urgent: true,
}, },
SIGNED_CUSTOMER: { SIGNED_CUSTOMER: {
title: "You have signed the contract", title: "You have signed the contract.",
description: description:
"Your signature has been submitted. Awaiting the final staff signature.", "Your signature has been submitted. Awaiting the final staff signature.",
buttonLabel: "View contract", buttonLabel: "View contract",
}, },
FULLY_EXECUTED: { FULLY_EXECUTED: {
title: "Contract fully executed", title: "Contract fully executed — proceed to payment.",
description: description:
"The contract has been signed by all parties. You can now proceed to payment.", "The contract has been signed by all parties. You can now proceed to payment.",
buttonLabel: "View contract", buttonLabel: "View contract",
@@ -48,37 +46,54 @@ export function ContractCard({
}) { }) {
const c = CONTRACT_CONFIG[booking.status as string]; const c = CONTRACT_CONFIG[booking.status as string];
if (!c) return null; if (!c) return null;
const urgent = c.urgent;
return ( return (
<Paper <Paper
radius={20} radius={16}
p="lg" px={22}
withBorder={!urgent} py={20}
bg={urgent ? "#0EA371" : "#ECF6F1"} bg="#F1FAF6"
style={{ borderColor: urgent ? "transparent" : "#CDEBDD" }} style={{ border: "1px solid #CFEBDD" }}
> >
<Group justify="space-between" align="center" wrap="wrap" gap="md"> <Group justify="space-between" align="center" wrap="wrap" gap={20}>
<Group gap={16} align="center" wrap="nowrap"> <Group gap={16} align="center" wrap="nowrap">
<Box <Box
style={{ style={{
width: 44, flexShrink: 0,
height: 44, width: 46,
height: 46,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
borderRadius: 12, borderRadius: 13,
backgroundColor: "#fff", backgroundColor: "#fff",
color: urgent ? "#0EA371" : "#0A6F4D", border: "1px solid #CDEBDD",
color: "#0A6F4D",
}} }}
> >
<FileSignature size={22} /> <FileSignature size={22} />
</Box> </Box>
<Box> <Box miw={0}>
<Text fz="15px" fw={800} c={urgent ? "#fff" : "#10202F"}> <Box
component="span"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#0A6F4D",
padding: "4px 10px",
fontSize: 10.5,
fontWeight: 800,
letterSpacing: 0.3,
color: "#fff",
textTransform: "uppercase",
}}
>
What's next
</Box>
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
{c.title} {c.title}
</Text> </Text>
<Text mt={2} fz="13px" c={urgent ? "rgba(255,255,255,0.8)" : "#6B7C8E"}> <Text mt={2} fz="13px" c="#5B6B7A">
{c.description} {c.description}
</Text> </Text>
</Box> </Box>
@@ -87,10 +102,10 @@ export function ContractCard({
<Button <Button
onClick={() => navigate(`/bookings/${booking.id}/contract`)} onClick={() => navigate(`/bookings/${booking.id}/contract`)}
radius={10} radius={10}
color={urgent ? "#0C1A2B" : "edr-green"} color="edr-green"
leftSection={<FileSignature size={15} />} leftSection={<FileSignature size={18} />}
styles={{ styles={{
root: { height: 42, paddingInline: 16 }, root: { height: 42, paddingInline: 18 },
label: { fontSize: 13, fontWeight: 700 }, label: { fontSize: 13, fontWeight: 700 },
}} }}
> >

View File

@@ -1,7 +1,33 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { CheckCircle2, FileText } from "lucide-react"; import { CheckCircle2, FileText, MinusCircle } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
type DocStatus = "verified" | "ready" | "missing";
const STATUS_PILL: Record<
DocStatus,
{ label: string; bg: string; color: string; border?: string; icon?: ReactNode }
> = {
verified: {
label: "Uploaded",
bg: "#ECF6F1",
color: "#0A6F4D",
border: "#CDEBDD",
icon: <CheckCircle2 size={13} />,
},
ready: {
label: "Ready",
bg: "#EAF1FB",
color: "#2E5B96",
},
missing: {
label: "Not added",
bg: "#F1F4F7",
color: "#9AA8B5",
icon: <MinusCircle size={13} />,
},
};
export function DocRow({ export function DocRow({
title, title,
meta, meta,
@@ -11,12 +37,15 @@ export function DocRow({
}: { }: {
title: string; title: string;
meta: string; meta: string;
status: "verified" | "ready" | "missing"; status: DocStatus;
action: ReactNode; action: ReactNode;
last?: boolean; last?: boolean;
}) { }) {
const missing = status === "missing";
const tileBg = status === "ready" ? "#EAF1FB" : "#F1F4F7"; const tileBg = status === "ready" ? "#EAF1FB" : "#F1F4F7";
const tileFg = status === "ready" ? "#2E5B96" : "#475569"; const tileFg =
status === "ready" ? "#2E5B96" : missing ? "#9AA8B5" : "#475569";
const pill = STATUS_PILL[status];
return ( return (
<Group <Group
@@ -41,52 +70,36 @@ export function DocRow({
color: tileFg, color: tileFg,
}} }}
> >
<FileText size={18} /> <FileText size={20} />
</Box> </Box>
<Box miw={0} flex={1}> <Box miw={0} flex={1}>
<Text truncate fz="13.5px" fw={700} c="#10202F"> <Text truncate fz="13.5px" fw={700} c={missing ? "#6B7C8E" : "#10202F"}>
{title} {title}
</Text> </Text>
<Text truncate fz="12px" c="#9AA8B5"> <Text truncate fz="12px" c="#9AA8B5">
{meta} {meta}
</Text> </Text>
</Box> </Box>
{status === "verified" && ( <Group
<Group component="span"
component="span" gap={6}
gap={6} align="center"
align="center" wrap="nowrap"
wrap="nowrap" style={{
style={{ display: "inline-flex",
display: "inline-flex", flexShrink: 0,
borderRadius: 999, borderRadius: 999,
backgroundColor: "#ECF6F1", backgroundColor: pill.bg,
padding: "5px 11px", border: pill.border ? `1px solid ${pill.border}` : undefined,
fontSize: 11.5, padding: "5px 11px",
fontWeight: 700, fontSize: 11.5,
color: "#0A6F4D", fontWeight: 700,
}} color: pill.color,
> }}
<CheckCircle2 size={13} /> >
Verified {pill.icon}
</Group> {pill.label}
)} </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} {action}
</Group> </Group>
); );
@@ -147,15 +160,15 @@ export function CountChip({
style={{ style={{
display: "inline-flex", display: "inline-flex",
borderRadius: 999, borderRadius: 999,
padding: "5px 11px", padding: "5px 10px",
fontSize: 11.5, fontSize: 11.5,
fontWeight: 700, fontWeight: 700,
backgroundColor: done ? "#ECF6F1" : "#FDF3E0", backgroundColor: done ? "#ECF6F1" : "#F1F4F7",
color: done ? "#0A6F4D" : "#9A5B00", color: done ? "#0A6F4D" : "#6B7C8E",
}} }}
> >
{done && <CheckCircle2 size={13} />} {done && <CheckCircle2 size={13} />}
{uploaded}/{total} uploaded {uploaded} of {total} added
</Group> </Group>
); );
} }

View File

@@ -1,5 +1,5 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { AlertCircle } from "lucide-react"; import { AlertCircle, AlertTriangle, PencilLine, StickyNote, XCircle } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export function NoticeBanner({ export function NoticeBanner({
@@ -51,6 +51,194 @@ export function NoticeBanner({
); );
} }
export function ActionRequiredBanner({
title,
children,
onAction,
}: {
title: string;
children: ReactNode;
onAction?: () => void;
}) {
return (
<Group
justify="space-between"
align="center"
wrap="wrap"
gap={24}
px={22}
py={20}
style={{
borderRadius: 16,
backgroundColor: "#FDF3E0",
border: "1px solid #F4D9A8",
}}
>
<Group gap={16} align="center" wrap="nowrap" miw={0} flex={1}>
<Box
style={{
flexShrink: 0,
width: 44,
height: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
backgroundColor: "#fff",
border: "1px solid #F4D9A8",
color: "#C77F12",
}}
>
<AlertTriangle size={23} />
</Box>
<Box miw={0}>
<Text
fz="10.5px"
fw={800}
c="#B07A2A"
tt="uppercase"
style={{ letterSpacing: 0.6 }}
>
Action required · You
</Text>
<Text mt={5} fz="15.5px" fw={700} c="#10202F">
{title}
</Text>
<Text mt={2} fz="13px" c="#7A6A4E" style={{ lineHeight: 1.45 }}>
{children}
</Text>
</Box>
</Group>
{onAction && (
<Button
onClick={onAction}
radius={10}
color="#F2A516"
leftSection={<PencilLine size={18} color="#5A3D08" />}
styles={{
root: { height: 42, paddingInline: 18, flexShrink: 0 },
label: { fontSize: 13, fontWeight: 800, color: "#3D2A05" },
}}
>
Review & Resubmit
</Button>
)}
</Group>
);
}
export function CancelledBanner({
pillLabel = "Cancelled",
title,
subtitle,
reason,
onRebook,
}: {
pillLabel?: string;
title: string;
subtitle: string;
reason?: string | null;
onRebook?: () => void;
}) {
return (
<Paper
radius={16}
p={20}
bg="#FCEEEA"
style={{ border: "1px solid #F3C6BB" }}
>
<Stack gap={14}>
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
<Group gap={16} align="center" wrap="nowrap" miw={0}>
<Box
style={{
flexShrink: 0,
width: 46,
height: 46,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 13,
backgroundColor: "#fff",
border: "1px solid #F3C6BB",
color: "#D93C15",
}}
>
<XCircle size={24} />
</Box>
<Box miw={0}>
<Box
component="span"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#D93C15",
padding: "4px 10px",
fontSize: 10.5,
fontWeight: 800,
letterSpacing: 0.3,
color: "#fff",
textTransform: "uppercase",
}}
>
{pillLabel}
</Box>
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
{title}
</Text>
<Text mt={2} fz="13px" c="#6B7C8E">
{subtitle}
</Text>
</Box>
</Group>
{onRebook && (
<Button
onClick={onRebook}
radius={10}
color="edr-green"
styles={{
root: { height: 42, paddingInline: 18 },
label: { fontSize: 13, fontWeight: 700 },
}}
>
Rebook shipment
</Button>
)}
</Group>
{reason && (
<Group
gap={11}
align="flex-start"
wrap="nowrap"
p={14}
style={{
borderRadius: 12,
backgroundColor: "#fff",
border: "1px solid #F3C6BB",
}}
>
<StickyNote size={20} color="#B0341A" style={{ flexShrink: 0 }} />
<Box miw={0}>
<Text
fz="10.5px"
fw={700}
c="#B0341A"
tt="uppercase"
style={{ letterSpacing: 0.5 }}
>
Reason for cancellation
</Text>
<Text mt={3} fz="13.5px" c="#3D2A26" style={{ lineHeight: 1.45 }}>
{reason}
</Text>
</Box>
</Group>
)}
</Stack>
</Paper>
);
}
export function MutationErrors({ export function MutationErrors({
mutations, mutations,
}: { }: {

View File

@@ -27,7 +27,7 @@ export function PageHeader({
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md"> <Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={8} miw={0}> <Stack gap={8} miw={0}>
<Group gap={12} align="center" wrap="wrap"> <Group gap={12} align="center" wrap="wrap">
<Text fz="18px" fw={800}> <Text fz="26px" fw={800} c="#10202F">
{booking.reference} {booking.reference}
</Text> </Text>
<Group <Group

View File

@@ -3,11 +3,49 @@ import type { ReactNode } from "react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { fmtDate } from "../utils"; import { fmtDate, isDraftLike, isNegative } from "../utils";
import { CardTitle, SectionCard } from "./layout"; import { CardTitle, SectionCard } from "./layout";
type Row = { label: string; value: ReactNode; muted?: boolean }; type Row = { label: string; value: ReactNode; muted?: boolean };
function StatusPill({ status }: { status: string }) {
const negative = isNegative(status);
const draft = isDraftLike(status);
const dot = negative ? "#C0392B" : draft ? "#94A3B8" : "#0EA371";
const color = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
const bg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1";
const border = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD";
const label = status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase());
return (
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
border: `1px solid ${border}`,
padding: "4px 10px",
fontSize: 11.5,
fontWeight: 700,
color,
}}
>
<Box
component="span"
style={{ width: 6, height: 6, borderRadius: 999, backgroundColor: dot }}
/>
{label}
</Group>
);
}
export function ScheduleCard({ export function ScheduleCard({
booking, booking,
title, title,
@@ -30,6 +68,11 @@ export function ScheduleCard({
muted: !booking.trainId, muted: !booking.trainId,
}; };
const statusRow: Row = {
label: "Status",
value: <StatusPill status={booking.status as string} />,
};
const rows: Row[] = consignment const rows: Row[] = consignment
? [ ? [
{ label: "Consignment ID", value: booking.reference }, { label: "Consignment ID", value: booking.reference },
@@ -40,6 +83,7 @@ export function ScheduleCard({
{ label: "Consolidation", value: consolidation }, { label: "Consolidation", value: consolidation },
] ]
: [ : [
statusRow,
{ label: "Service", value: service }, { label: "Service", value: service },
{ label: "Equipment return", value: equipmentReturn }, { label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) }, { label: "Proposed date", value: fmtDate(booking.scheduledDate) },

View File

@@ -1,5 +1,5 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { AlertTriangle, Check, Clock, FileText, History } from "lucide-react"; import { AlertTriangle, Check, FileText, History } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -7,7 +7,13 @@ import { PROGRESS_STAGES, STATUS_MAP } from "../constants";
import { fmtDate, isDraftLike, isNegative } from "../utils"; import { fmtDate, isDraftLike, isNegative } from "../utils";
import { SectionCard } from "./layout"; import { SectionCard } from "./layout";
export function StatusHero({ booking }: { booking: Freight.IBooking }) { export function StatusHero({
booking,
children,
}: {
booking: Freight.IBooking;
children?: React.ReactNode;
}) {
const status = booking.status as string; const status = booking.status as string;
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT; const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
const negative = isNegative(status); const negative = isNegative(status);
@@ -34,7 +40,7 @@ export function StatusHero({ booking }: { booking: Freight.IBooking }) {
); );
return ( return (
<SectionCard> <SectionCard p={28}>
<Group justify="space-between" align="center" wrap="wrap" gap="md"> <Group justify="space-between" align="center" wrap="wrap" gap="md">
<Group gap={16} align="center" wrap="nowrap"> <Group gap={16} align="center" wrap="nowrap">
<Box <Box
@@ -52,10 +58,10 @@ export function StatusHero({ booking }: { booking: Freight.IBooking }) {
<HeroIcon size={26} /> <HeroIcon size={26} />
</Box> </Box>
<Box> <Box>
<Text size="lg" fw={700}> <Text fz="21px" fw={800} c="#10202F">
{cfg.title} {cfg.title}
</Text> </Text>
<Text mt={4} fz="14px" c="#6B7C8E"> <Text mt={5} fz="14px" c="#6B7C8E">
{cfg.description} {cfg.description}
</Text> </Text>
</Box> </Box>
@@ -72,25 +78,33 @@ export function StatusHero({ booking }: { booking: Freight.IBooking }) {
backgroundColor: "#F7FAFC", backgroundColor: "#F7FAFC",
}} }}
> >
<Clock size={20} color="#0A6F4D" /> <History size={22} color="#64748B" />
<Box> <Box>
<Text fz="12px" fw={600}> <Text
fz="10.5px"
fw={700}
c="#9AA8B5"
tt="uppercase"
style={{ letterSpacing: 0.6 }}
>
{chipLabel} {chipLabel}
</Text> </Text>
<Text fz="13px" fw={700}> <Text fz="13.5px" fw={700} c="#10202F">
{chipValue} {chipValue}
</Text> </Text>
</Box> </Box>
</Group> </Group>
</Group> </Group>
<Box my={24} h={1} w="100%" bg="#EEF2F6" /> <Box my={26} h={1} w="100%" bg="#EEF2F6" />
<ProgressTracker {children ?? (
current={cfg.stage} <ProgressTracker
tone={draft ? "ink" : "green"} current={cfg.stage}
negative={negative} tone={draft ? "ink" : "green"}
/> negative={negative}
/>
)}
</SectionCard> </SectionCard>
); );
} }
@@ -185,7 +199,7 @@ function ProgressTracker({
</Box> </Box>
<Text <Text
fz="13.5px" fz="13.5px"
fw={700} fw={state === "active" ? 800 : 700}
ta="center" ta="center"
c={state === "idle" ? "#9AA8B5" : "#10202F"} c={state === "idle" ? "#9AA8B5" : "#10202F"}
> >
@@ -193,7 +207,7 @@ function ProgressTracker({
</Text> </Text>
<Text <Text
fz="11.5px" fz="11.5px"
fw={500} fw={state === "active" ? 700 : 500}
ta="center" ta="center"
c={state === "active" ? activeSub : "#9AA8B5"} c={state === "active" ? activeSub : "#9AA8B5"}
> >

View File

@@ -2,6 +2,41 @@ import { Box, Button, Group, Text } from "@mantine/core";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
function StepCircle({
index,
done,
active,
}: {
index: number;
done?: boolean;
active?: boolean;
}) {
const style: React.CSSProperties = done
? { backgroundColor: "#0EA371", color: "#fff" }
: active
? { backgroundColor: "#0C1A2B", color: "#fff" }
: { backgroundColor: "#EEF2F6", color: "#9AA8B5" };
return (
<Box
style={{
flexShrink: 0,
width: 26,
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
...style,
}}
>
{done ? <Check size={16} strokeWidth={3} /> : index}
</Box>
);
}
export function StepLine({ export function StepLine({
index, index,
title, title,
@@ -9,63 +44,49 @@ export function StepLine({
action, action,
done, done,
active, active,
highlight,
}: { }: {
index: number; index: number;
title: string; title: string;
desc: string; desc?: string;
action?: ReactNode; action?: ReactNode;
done?: boolean; done?: boolean;
active?: boolean; active?: boolean;
highlight?: boolean;
}) { }) {
const badgeStyle: React.CSSProperties = done if (active) {
? { backgroundColor: "#0EA371", color: "#fff" } return (
: active <Group
? { backgroundColor: "#0C1A2B", color: "#fff" } gap={14}
: { align="center"
backgroundColor: "#EEF2F6", justify="space-between"
color: "#9AA8B5", wrap="nowrap"
border: "1px solid #E1E7EE", px={14}
}; py={12}
style={{ borderRadius: 12, backgroundColor: "#F4F7FA" }}
>
<Group gap={14} align="center" wrap="nowrap" miw={0}>
<StepCircle index={index} active />
<Box miw={0}>
<Text fz="14px" fw={800} c="#10202F">
{title}
</Text>
{desc && (
<Text mt={2} fz="12.5px" c="#6B7C8E">
{desc}
</Text>
)}
</Box>
</Group>
{action}
</Group>
);
}
return ( return (
<Group <Group gap={14} align="center" wrap="nowrap" px={12} py={10}>
gap={14} <StepCircle index={index} done={done} />
align="center" <Text fz="14px" fw={700} c={done ? "#6B7C8E" : "#9AA8B5"}>
wrap="nowrap" {title}
px="sm" </Text>
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> </Group>
); );
} }
@@ -73,18 +94,26 @@ export function StepLine({
export function StepGhostButton({ export function StepGhostButton({
children, children,
onClick, onClick,
icon,
}: { }: {
children: ReactNode; children: ReactNode;
onClick?: () => void; onClick?: () => void;
icon?: ReactNode;
}) { }) {
return ( return (
<Button <Button
variant="default" variant="white"
radius={9} radius={9}
onClick={onClick} onClick={onClick}
leftSection={icon}
styles={{ styles={{
root: { height: 32, paddingInline: 12 }, root: {
label: { fontSize: 12.5, fontWeight: 700, color: "#475569" }, height: 34,
paddingInline: 14,
border: "1.5px solid #CBD5E1",
flexShrink: 0,
},
label: { fontSize: 12.5, fontWeight: 700, color: "#334155" },
}} }}
> >
{children} {children}

View File

@@ -4,7 +4,7 @@ import type { ReactNode, Ref } from "react";
export function PageShell({ children }: { children: ReactNode }) { export function PageShell({ children }: { children: ReactNode }) {
return ( return (
<Box mih="100vh"> <Box mih="100vh">
<Box mx="auto" w="100%" px="lg" py={28}> <Box mx="auto" w="100%" px={32} pt={28} pb={32}>
<Stack gap="lg">{children}</Stack> <Stack gap="lg">{children}</Stack>
</Box> </Box>
</Box> </Box>
@@ -48,7 +48,7 @@ export function SectionCard({ children, ref, ...props }: SectionCardProps) {
export function CardTitle({ children }: { children: ReactNode }) { export function CardTitle({ children }: { children: ReactNode }) {
return ( return (
<Text size="lg" fw={700}> <Text fz="16px" fw={800} c="#10202F">
{children} {children}
</Text> </Text>
); );

View File

@@ -1,5 +1,5 @@
import { Box, Button, Group, Stack, Text } from "@mantine/core"; import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { FileText } from "lucide-react"; import { CheckCircle2, Clock, FileText } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -41,24 +41,45 @@ export function EstimateCard({
<SectionCard p={22}> <SectionCard p={22}>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
<Box <Group
component="span" component="span"
gap={6}
align="center"
wrap="nowrap"
style={{ style={{
display: "inline-flex",
borderRadius: 999, borderRadius: 999,
backgroundColor: "#F1F4F7", backgroundColor: "#F1F4F7",
padding: "5px 11px", padding: "5px 11px",
fontSize: 11.5, fontSize: 11.5,
fontWeight: 700, fontWeight: 700,
color: "#475569", color: "#6B7C8E",
}} }}
> >
<Clock size={13} />
{chip} {chip}
</Box> </Group>
</Group> </Group>
<Box mt={12}> <Box mt={12}>
<Text fz="26px" fw={800} c="#10202F"> <Group gap={8} align="flex-end" wrap="nowrap">
{priceTotal(pricing)} <Text fz="26px" fw={800} c="#10202F" lh={1.1}>
</Text> {priceTotal(pricing)}
</Text>
<Box
component="span"
mb={4}
style={{
borderRadius: 6,
backgroundColor: "#F1F4F7",
padding: "3px 7px",
fontSize: 11,
fontWeight: 700,
color: "#6B7C8E",
}}
>
est.
</Box>
</Group>
<Text mt={4} fz="12.5px" c="#9AA8B5"> <Text mt={4} fz="12.5px" c="#9AA8B5">
A firm price is confirmed after EDR reviews your booking. A firm price is confirmed after EDR reviews your booking.
</Text> </Text>
@@ -101,9 +122,13 @@ export function PaymentCard({
<SectionCard p={22}> <SectionCard p={22}>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<CardTitle>Payment</CardTitle> <CardTitle>Payment</CardTitle>
<Box <Group
component="span" component="span"
gap={6}
align="center"
wrap="nowrap"
style={{ style={{
display: "inline-flex",
borderRadius: 999, borderRadius: 999,
padding: "5px 11px", padding: "5px 11px",
fontSize: 11.5, fontSize: 11.5,
@@ -113,10 +138,11 @@ export function PaymentCard({
border: paid ? "1px solid #CDEBDD" : undefined, border: paid ? "1px solid #CDEBDD" : undefined,
}} }}
> >
{paid && <CheckCircle2 size={13} />}
{paid {paid
? "Paid" ? "Paid"
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
</Box> </Group>
</Group> </Group>
<Box mt={12}> <Box mt={12}>
<Text fz="26px" fw={800} c="#10202F"> <Text fz="26px" fw={800} c="#10202F">