mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
refactor(workflow): Revamp booking progress stages and status mapping
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user