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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import type { CreateBookingPayload } from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Box, Button, Group, Text, Title } from "@mantine/core"; import { Alert, Box, Button, Group, Text, Title } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; 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>({ const form = useForm<BookingFormInputValues, any, BookingFormValues>({
defaultValues: initialBookingFormValues, defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema), resolver: zodResolver(bookingFormSchema),
@@ -116,6 +141,15 @@ export default function NewBookingPage() {
return route; return route;
}, [originYard, destinationYard]); }, [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() { async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true }); const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return; if (!valid) return;
@@ -123,14 +157,14 @@ export default function NewBookingPage() {
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
} }
const handleSubmit = form.handleSubmit((data) => { function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
if (data.contractType === "renewal" && !data.previousContractRef) { if (data.contractType === "renewal" && !data.previousContractRef) {
form.setError("previousContractRef", { form.setError("previousContractRef", {
type: "manual", type: "manual",
message: "Select a previous contract reference.", message: "Select a previous contract reference.",
}); });
setStep(1); setStep(1);
return; throw new Error("Validation failed");
} }
const totalWeight = const totalWeight =
@@ -141,7 +175,6 @@ export default function NewBookingPage() {
) )
: Number(data.cargoWeight || 0); : Number(data.cargoWeight || 0);
// ── Reference data lookups ──────────────────────────────────────────
const shippingLines = referenceData?.shipping_line ?? []; const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? []; const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? []; const containerGroups = referenceData?.containers ?? [];
@@ -174,8 +207,7 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId, (s) => s.id === data.serviceTypeId,
)!; )!;
// ── Build API payload ─────────────────────────────────────────────── return {
const apiPayload: CreateBookingPayload = {
scheduledDate: new Date().toISOString(), scheduledDate: new Date().toISOString(),
contractType: contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"], data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
@@ -222,8 +254,24 @@ export default function NewBookingPage() {
: {}), : {}),
...(cargoFreeText ? { cargoFreeText } : {}), ...(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 ( return (
@@ -270,7 +318,7 @@ export default function NewBookingPage() {
id="new-booking-form" id="new-booking-form"
className="flex flex-col" className="flex flex-col"
style={{ flex: 1 }} style={{ flex: 1 }}
onSubmit={handleSubmit} onSubmit={handleDraftSubmit}
> >
<Box flex={1} p="24px"> <Box flex={1} p="24px">
<Box mb="lg"> <Box mb="lg">
@@ -295,6 +343,24 @@ export default function NewBookingPage() {
</Alert> </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 && ( {step === 1 && (
<Step1ContractType form={form} referenceData={referenceData} /> <Step1ContractType form={form} referenceData={referenceData} />
)} )}
@@ -367,18 +433,35 @@ export default function NewBookingPage() {
Continue Continue
</Button> </Button>
) : ( ) : (
<Button <Group>
type="submit" <Button
form="new-booking-form" type="submit"
color="edr-green" form="new-booking-form"
radius="md" variant={hasDocuments ? "outline" : "filled"}
loading={createMutation.isPending} color="edr-green"
leftSection={ radius="md"
createMutation.isPending ? undefined : <Check size={16} /> loading={createMutation.isPending}
} leftSection={
> createMutation.isPending ? undefined : <Check size={16} />
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"} }
</Button> >
{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> </Group>
</Box> </Box>