refactor(workflow): Revamp booking progress stages and status mapping

This commit is contained in:
ghost2023
2026-06-16 17:25:29 +03:00
parent 8098adedaa
commit f3284a13d0
5 changed files with 195 additions and 95 deletions

View File

@@ -488,7 +488,7 @@ export default function MyPortalPage() {
icon={Clock3}
label="Awaiting Payment"
value={outstandingInvoices.length.toString()}
delta={`${formatCurrency(totalOutstanding || 377500, "ETB")} due`}
delta={`${formatCurrency(totalOutstanding || 0, "ETB")} due`}
deltaColor="edr-amber-text"
divider
/>
@@ -506,9 +506,9 @@ export default function MyPortalPage() {
value={
dashboard
? formatCurrency(
dashboard.spendYtd,
dashboard.spendCurrency as Currency,
)
dashboard.spendYtd,
dashboard.spendCurrency as Currency,
)
: "—"
}
delta={
@@ -693,7 +693,9 @@ export default function MyPortalPage() {
) : (
<>
<Text fz={26} fw={800} c="edr-text">
{(dashboard?.freightVolume.totalTonnes ?? 0).toLocaleString()}{" "}
{(
dashboard?.freightVolume.totalTonnes ?? 0
).toLocaleString()}{" "}
t
</Text>
<Text fz={13} c="edr-muted">

View File

@@ -14,7 +14,7 @@ export function StatusHero({
booking: Freight.IBooking;
children?: React.ReactNode;
}) {
const status = booking.status as string;
const status = booking.status;
const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT;
const negative = isNegative(status);
const draft = isDraftLike(status);
@@ -45,7 +45,12 @@ export function StatusHero({
<Group gap={16} align="center" wrap="nowrap">
<div
className="flex items-center justify-center rounded-2xl shrink-0"
style={{ width: 56, height: 56, backgroundColor: tileBg, color: tileFg }}
style={{
width: 56,
height: 56,
backgroundColor: tileBg,
color: tileFg,
}}
>
<HeroIcon size={26} />
</div>
@@ -77,7 +82,7 @@ export function StatusHero({
>
{chipLabel}
</Text>
<Text fz="13.5px" fw={700} c="#10202F">
<Text fz="sm" fw={700} c="#10202F">
{chipValue}
</Text>
</Box>
@@ -114,8 +119,13 @@ function ProgressTracker({
return (
/* Scrollable on mobile so 5 stages never overflow */
<Box
className="overflow-x-auto"
style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" } as React.CSSProperties}
className="overflow-x-auto pt-2"
style={
{
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
} as React.CSSProperties
}
>
<div className="flex items-start" style={{ minWidth: 440 }}>
{PROGRESS_STAGES.map((stage, idx) => {
@@ -131,14 +141,15 @@ function ProgressTracker({
: state === "active"
? activeFill
: "#0EA371";
const circleBorder = state === "idle" ? "1px solid #E1E7EE" : undefined;
const circleBorder =
state === "idle" ? "1px solid #E1E7EE" : undefined;
const circleShadow =
state === "active" ? `0 0 0 4px ${activeRing}` : undefined;
return (
<div
key={stage.label}
className="flex flex-1 flex-col items-center gap-[10px]"
className="flex flex-1 flex-col items-center"
>
<div className="flex w-full items-center">
{/* left connector */}
@@ -147,15 +158,19 @@ function ProgressTracker({
style={{
height: 3,
background:
idx === 0 ? "transparent" : reachedLeft ? "#0EA371" : "#E1E7EE",
idx === 0
? "transparent"
: reachedLeft
? "#0EA371"
: "#E1E7EE",
}}
/>
{/* stage circle */}
<div
className="flex items-center justify-center rounded-full shrink-0"
className="flex items-center justify-center mb-2 rounded-full shrink-0"
style={{
width: 40,
height: 40,
width: 32,
height: 32,
backgroundColor: circleBg,
border: circleBorder,
boxShadow: circleShadow,
@@ -173,32 +188,22 @@ function ProgressTracker({
style={{
height: 3,
background:
idx === last ? "transparent" : reachedRight ? "#0EA371" : "#E1E7EE",
idx === last
? "transparent"
: reachedRight
? "#0EA371"
: "#E1E7EE",
}}
/>
</div>
<Text
fz="13.5px"
fw={state === "active" ? 800 : 700}
fz="14px"
fw={700}
ta="center"
c={state === "idle" ? "#9AA8B5" : "#10202F"}
>
{stage.label}
</Text>
<Text
fz="11.5px"
fw={state === "active" ? 700 : 500}
ta="center"
c={state === "active" ? activeSub : "#9AA8B5"}
>
{state === "done"
? "Completed"
: state === "active"
? negative
? "Stopped"
: "In progress"
: "Pending"}
</Text>
</div>
);
})}

View File

@@ -1,5 +1,5 @@
import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { CheckCircle2, Clock, FileText } from "lucide-react";
import { Box, Group, Stack, Text } from "@mantine/core";
import { CheckCircle2, Clock } from "lucide-react";
import type { Freight } from "@edr/types";
@@ -173,16 +173,16 @@ export function PaymentCard({
</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>
{/* <Button */}
{/* fullWidth */}
{/* mt={16} */}
{/* variant="default" */}
{/* radius={10} */}
{/* leftSection={<FileText size={17} color="#475569" />} */}
{/* styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" } }} */}
{/* > */}
{/* Download invoice */}
{/* </Button> */}
</SectionCard>
);
}

View File

@@ -3,6 +3,7 @@ import {
FileText,
PackageCheck,
ShieldCheck,
Ship,
Train,
} from "lucide-react";
@@ -15,32 +16,41 @@ export const PROGRESS_STAGES = [
{
label: "Submitted",
icon: ClipboardCheck,
statuses: ["SUBMITTED", "PENDING_APPROVAL"],
statuses: ["SUBMITTED"],
},
{
label: "Approved",
label: "Approval",
icon: ShieldCheck,
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", "APPROVED"],
},
{
label: "Contract",
icon: ShieldCheck,
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
},
{
label: "Payment",
icon: ShieldCheck,
statuses: [
"APPROVED_PENDING_SIGNATURE",
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"SELECTED_FOR_BATCH",
"PAYMENT_VERIFICATION_IN_PROGRESS",
],
},
{
label: "Loading",
icon: Ship,
statuses: [
"PAID",
"PNR_GENERATED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
],
},
{
label: "In Transit",
icon: Train,
statuses: [
"SELECTED_FOR_BATCH",
"EXPIRED",
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
"IN_TRANSIT",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
],
statuses: ["EXPIRED", "IN_TRANSIT"],
},
{
label: "Complete",
@@ -72,7 +82,7 @@ export const STATUS_MAP: Record<
PENDING_APPROVAL: {
title: "Pending approval",
description: "Your booking is moving through the approval process.",
stage: 1,
stage: 2,
},
APPROVED_PENDING_SIGNATURE: {
title: "Approved — awaiting signature",
@@ -88,71 +98,71 @@ export const STATUS_MAP: Record<
title: "Contract ready to sign",
description:
"Your contract is ready. Review and apply your signature to proceed.",
stage: 2,
stage: 3,
},
SIGNED_CUSTOMER: {
title: "Signed — awaiting staff",
description:
"Your signature has been submitted. Awaiting the final staff signature.",
stage: 2,
stage: 3,
},
FULLY_EXECUTED: {
title: "Contract fully executed",
description: "Signed by all parties. You can now proceed to payment.",
stage: 2,
stage: 4,
},
SELECTED_FOR_BATCH: {
title: "Selected for a train — payment due",
description:
"Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.",
stage: 3,
stage: 4,
},
EXPIRED: {
title: "Pay window expired",
description:
"The payment window was missed. You can move this booking to another schedule or cancel it.",
stage: 3,
stage: 6,
},
PNR_GENERATED: {
title: "Payment reference generated",
description:
"A payment reference number has been generated for this booking.",
stage: 3,
stage: 5,
},
PAYMENT_VERIFICATION_IN_PROGRESS: {
title: "Verifying payment",
description: "Your payment is being verified.",
stage: 3,
stage: 4,
},
PAID: {
title: "Payment confirmed",
description: "Payment has been confirmed for this booking.",
stage: 3,
stage: 5,
},
IN_TRANSIT: {
title: "Cargo moving",
description: "Your shipment is currently moving through the rail network.",
stage: 3,
stage: 6,
},
PENDING_CONSOLIDATION: {
title: "Pending consolidation",
description: "Awaiting a consolidation partner shipment.",
stage: 3,
stage: 5,
},
CONSOLIDATED: {
title: "Consolidated",
description: "Cargo has been consolidated with a partner shipment.",
stage: 3,
stage: 5,
},
COMPLETED: {
title: "Service complete",
description: "Cargo delivered and service successfully terminated.",
stage: 4,
stage: 7,
},
DELIVERED: {
title: "Service complete",
description: "Cargo delivered and service successfully terminated.",
stage: 4,
stage: 7,
},
REJECTED: {
title: "Booking rejected",

View File

@@ -3,7 +3,7 @@ import type { CreateBookingPayload } from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Box, Button, Group, Text, Title } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
import { AlertCircle, Check, ChevronLeft, ChevronRight, Send } from "lucide-react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
@@ -97,6 +97,31 @@ export default function NewBookingPage() {
},
});
const submitMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const documents = form.getValues("documents") ?? {};
const hasDocuments = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
}
await api.bookings.submit.call({ id: booking.id });
return booking;
},
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${booking.id}`);
},
});
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema),
@@ -116,6 +141,15 @@ export default function NewBookingPage() {
return route;
}, [originYard, destinationYard]);
const docValues = form.watch("documents") ?? {};
const hasDocuments = useMemo(
() =>
Object.values(docValues).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
),
[docValues],
);
async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
@@ -123,14 +157,14 @@ export default function NewBookingPage() {
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
const handleSubmit = form.handleSubmit((data) => {
function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
if (data.contractType === "renewal" && !data.previousContractRef) {
form.setError("previousContractRef", {
type: "manual",
message: "Select a previous contract reference.",
});
setStep(1);
return;
throw new Error("Validation failed");
}
const totalWeight =
@@ -141,7 +175,6 @@ export default function NewBookingPage() {
)
: Number(data.cargoWeight || 0);
// ── Reference data lookups ──────────────────────────────────────────
const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
@@ -174,8 +207,7 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId,
)!;
// ── Build API payload ───────────────────────────────────────────────
const apiPayload: CreateBookingPayload = {
return {
scheduledDate: new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
@@ -222,8 +254,24 @@ export default function NewBookingPage() {
: {}),
...(cargoFreeText ? { cargoFreeText } : {}),
};
}
createMutation.mutate(apiPayload);
const handleDraftSubmit = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
createMutation.mutate(apiPayload);
} catch {
// validation error already handled
}
});
const handleFullSubmit = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
submitMutation.mutate(apiPayload);
} catch {
// validation error already handled
}
});
return (
@@ -270,7 +318,7 @@ export default function NewBookingPage() {
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleSubmit}
onSubmit={handleDraftSubmit}
>
<Box flex={1} p="24px">
<Box mb="lg">
@@ -295,6 +343,24 @@ export default function NewBookingPage() {
</Alert>
)}
{submitMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to submit
</Text>
<Text size="sm" mt={4} c="red.7">
{submitMutation.error instanceof Error
? submitMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{step === 1 && (
<Step1ContractType form={form} referenceData={referenceData} />
)}
@@ -367,18 +433,35 @@ export default function NewBookingPage() {
Continue
</Button>
) : (
<Button
type="submit"
form="new-booking-form"
color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
>
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
</Button>
<Group>
<Button
type="submit"
form="new-booking-form"
variant={hasDocuments ? "outline" : "filled"}
color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
>
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
</Button>
{hasDocuments && (
<Button
type="button"
color="edr-green"
radius="md"
loading={submitMutation.isPending}
leftSection={
submitMutation.isPending ? undefined : <Send size={16} />
}
onClick={() => handleFullSubmit()}
>
{submitMutation.isPending ? "Submitting..." : "Submit"}
</Button>
)}
</Group>
)}
</Group>
</Box>