Merge pull request #206 from Tria-plc/freight_feature/priority

Freight feature/priority
This commit is contained in:
marshal
2026-06-17 22:25:19 +03:00
committed by GitHub
10 changed files with 1054 additions and 504 deletions

View File

@@ -23,6 +23,7 @@ import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { SubmitBookingResponse } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import { REQUIRED_DOC_FIELDS } from "./constants";
@@ -60,6 +61,8 @@ export function DraftBookingView({
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [docError, setDocError] = useState("");
const [priceChangeModal, setPriceChangeModal] =
useState<SubmitBookingResponse | null>(null);
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
const uploadedCodes = useMemo(
@@ -72,9 +75,11 @@ export function DraftBookingView({
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,
api.bookings.generatePrice.queryOptions({
input: { id: booking.id },
enabled:
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
!booking.pricingBreakdown,
}),
);
const pricing = (booking.pricingBreakdown ??
@@ -82,8 +87,17 @@ export function DraftBookingView({
null) as Freight.PricingBreakdown | null;
const uploadMutation = useMutation({
mutationFn: (files: Record<string, File | File[] | null>) =>
api.bookings.uploadDocuments.call({ id: booking.id, files }),
mutationFn: async (files: Record<string, File | File[] | null>) => {
if (booking.status === "CHANGES_REQUESTED") {
const result = await api.bookings.update.call({
id: booking.id,
dto: {},
documents: files,
});
return result.booking;
}
return api.bookings.uploadDocuments.call({ id: booking.id, files });
},
onSuccess: () => {
setSelectedFiles({});
setDocError("");
@@ -93,7 +107,20 @@ export function DraftBookingView({
const submitMutation = useMutation({
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChangeModal(result);
return;
}
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
});
const confirmSubmitMutation = useMutation({
mutationFn: () => api.bookings.confirmSubmit.call({ id: booking.id }),
onSuccess: () => {
setPriceChangeModal(null);
onBookingUpdated();
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
@@ -155,7 +182,12 @@ export function DraftBookingView({
/>
<MutationErrors
mutations={[uploadMutation, submitMutation, cancelMutation]}
mutations={[
uploadMutation,
submitMutation,
confirmSubmitMutation,
cancelMutation,
]}
/>
<StatusHero booking={booking}>
@@ -163,7 +195,9 @@ export function DraftBookingView({
booking.latestChangeRequestNote ? (
<ActionRequiredBanner
title="Review the requested changes, then resubmit."
onAction={() => navigate(`/bookings/${booking.id}/edit`)}
onAction={() =>
navigate(`/bookings/${booking.id}/edit?section=documents`)
}
>
{booking.latestChangeRequestNote}
</ActionRequiredBanner>
@@ -260,6 +294,8 @@ export function DraftBookingView({
const isUploaded = uploadedCodes.has(doc.key);
const selected = selectedFiles[doc.key];
const file = booking.files?.find((f) => f.code === doc.key);
const allowReplace =
!isUploaded || booking.status === "CHANGES_REQUESTED";
return (
<DocRow
key={doc.key}
@@ -276,13 +312,19 @@ export function DraftBookingView({
isUploaded ? "verified" : selected ? "ready" : "missing"
}
action={
isUploaded ? (
isUploaded && !allowReplace ? (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
) : (
<>
{isUploaded && (
<IconSquare
href={file?.signedUrl ?? file?.url}
icon={<Download size={16} />}
/>
)}
<input
ref={(el) => {
fileInputRefs.current[doc.key] = el;
@@ -318,7 +360,7 @@ export function DraftBookingView({
},
}}
>
{selected ? "Change" : "Add"}
{selected ? "Change" : isUploaded ? "Replace" : "Add"}
</Button>
{selected && (
<ActionIcon
@@ -375,6 +417,72 @@ export function DraftBookingView({
}
/>
<Modal
opened={priceChangeModal !== null}
onClose={() => setPriceChangeModal(null)}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{priceChangeModal && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceChangeModal.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{priceChangeModal.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
{priceChangeModal.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeModal.totalAmount.toLocaleString()}{" "}
{priceChangeModal.currency}
</Text>
</Group>
{priceChangeModal.lineItems && priceChangeModal.lineItems.length > 0 && (
<Stack gap={4}>
{priceChangeModal.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setPriceChangeModal(null)}
>
Review later
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
Confirm & submit
</Button>
</Group>
</Stack>
)}
</Modal>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}

View File

@@ -15,6 +15,7 @@ import {
SimpleGrid,
Stack,
Switch,
Tabs,
Text,
Textarea,
TextInput,
@@ -36,7 +37,7 @@ import {
} from "lucide-react";
import { useMemo, useRef, type ReactNode } from "react";
import { Controller, useForm } from "react-hook-form";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
CountChip,
DocRow,
@@ -52,12 +53,32 @@ import {
type BookingFormValues,
} from "./new-booking-form/schema";
import { SelectField } from "./new-booking-form/shared";
import { Step5CargoDetails } from "./new-booking-form/steps";
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
function yardNameFromBooking(
yard: { label?: string; code?: string; name?: string } | undefined | null,
const EDIT_SECTIONS = [
"service",
"route",
"cargo",
"schedule",
"documents",
"notes",
] as const;
type EditSection = (typeof EDIT_SECTIONS)[number];
function isEditSection(value: string | null): value is EditSection {
return EDIT_SECTIONS.includes(value as EditSection);
}
function yardIdFromBooking(
yard: Freight.IYard | null | undefined,
referenceData: Freight.BookingReferenceData,
): string {
return yard?.label ?? yard?.name ?? yard?.code ?? "";
if (yard?.id) return yard.id;
const label = yard?.label ?? "";
return (
referenceData.yard.find((y) => y.name === label || y.id === label)?.id ?? ""
);
}
/** Fallback container type for a size, used only when a booking row has no
@@ -108,14 +129,18 @@ function mapBookingToFormValues(
booking.equipmentReturn === "WITH_RETURN"
? "with_return"
: "without_return",
originYard: yardNameFromBooking(booking.originYard),
destinationYard: yardNameFromBooking(booking.destinationYard),
originYard: yardIdFromBooking(booking.originYard, referenceData),
destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
isHazardous: booking.isHazardous ?? false,
isRefrigerated: booking.isRefrigerated ?? false,
shippingLine: (booking as any).shippingLine?.name ?? "",
consolidationEnabled: booking.allowConsolidation ?? false,
scheduledDate: booking.scheduledDate
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
: "",
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
notes: "",
containers: [],
} as BookingFormInputValues;
@@ -237,9 +262,20 @@ const DIRECTION_LABEL: Record<string, string> = {
export default function EditBookingPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const docInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
const sectionParam = searchParams.get("section");
const activeSection: EditSection = isEditSection(sectionParam)
? sectionParam
: "service";
function setSection(section: EditSection) {
setSearchParams({ section });
window.scrollTo({ top: 0, behavior: "smooth" });
}
const bookingQuery = useQuery(
api.bookings.get.queryOptions({
input: { id: id! },
@@ -269,18 +305,17 @@ export default function EditBookingPage() {
const updateMutation = useMutation({
mutationFn: async (payload: Partial<CreateBookingPayload>) => {
const result = await api.bookings.update.call({ id: id!, dto: payload });
// Upload any newly attached documents against the existing booking.
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
const hasDocuments = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocuments) {
await api.bookings.uploadDocuments.call({ id: id!, files: documents });
const newDocuments: BookingDocuments = {};
for (const [key, value] of Object.entries(documents)) {
if (value) newDocuments[key] = value;
}
return result;
return api.bookings.update.call({
id: id!,
dto: payload,
documents:
Object.keys(newDocuments).length > 0 ? newDocuments : undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
@@ -304,9 +339,9 @@ export default function EditBookingPage() {
);
const direction = useMemo(() => {
const origin = referenceData?.yard.find((y) => y.name === originYard);
const origin = referenceData?.yard.find((y) => y.id === originYard);
const destination = referenceData?.yard.find(
(y) => y.name === destinationYard,
(y) => y.id === destinationYard,
);
return getRouteDirection(origin, destination);
}, [originYard, destinationYard, referenceData]);
@@ -314,7 +349,7 @@ export default function EditBookingPage() {
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({
value: y.name,
value: y.id,
label: y.name,
country: y.country,
}));
@@ -338,14 +373,9 @@ export default function EditBookingPage() {
};
const handleSubmit = form.handleSubmit((data) => {
const yards = referenceData?.yard ?? [];
const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? [];
const containerGroups = referenceData?.containers ?? [];
const findYardId = (name: string): string =>
yards.find((y) => y.name === name)?.id ?? "";
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
@@ -369,10 +399,15 @@ export default function EditBookingPage() {
)
: Number(data.cargoWeight || 0);
const selectedSvc = services.find((s) => s.id === data.serviceTypeId);
const selectedSvc = referenceData?.service.find(
(s) => s.id === data.serviceTypeId,
);
const apiPayload: Partial<CreateBookingPayload> = {
scheduledDate: new Date().toISOString().slice(0, 10),
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: undefined,
trainScheduleId: data.trainScheduleId || undefined,
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -380,8 +415,8 @@ export default function EditBookingPage() {
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
originYardId: findYardId(data.originYard),
destinationYardId: findYardId(data.destinationYard),
originYardId: data.originYard,
destinationYardId: data.destinationYard,
tradeDirection:
direction === "EXPORT"
? "EXPORT"
@@ -393,7 +428,6 @@ export default function EditBookingPage() {
isHazardous: data.isHazardous,
paymentCurrency: "USD",
allowConsolidation: data.consolidationEnabled,
// @ts-ignore
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
@@ -505,9 +539,34 @@ export default function EditBookingPage() {
</Alert>
)}
<Stack gap={36} mt="xl">
{/* ── Section 1: Service ── */}
<Stack gap="md">
{booking.status === "CHANGES_REQUESTED" && (
<Alert color="orange" icon={<AlertCircle size={16} />} radius="md" mt="lg">
<Text size="sm" fw={600}>
Staff requested changes
</Text>
<Text size="sm" mt={4}>
Update the sections below and save. Then return to the booking page to
resubmit for review.
</Text>
</Alert>
)}
<Tabs
value={activeSection}
onChange={(value) => value && setSection(value as EditSection)}
mt="xl"
>
<Tabs.List mb="lg" style={{ flexWrap: "wrap" }}>
<Tabs.Tab value="service">Service</Tabs.Tab>
<Tabs.Tab value="route">Route</Tabs.Tab>
<Tabs.Tab value="cargo">Cargo</Tabs.Tab>
<Tabs.Tab value="schedule">Schedule</Tabs.Tab>
<Tabs.Tab value="documents">Documents</Tabs.Tab>
<Tabs.Tab value="notes">Notes</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="service">
<Stack gap="md">
<SectionHeading
title="Service"
description="Select the service combination and configure trucking options."
@@ -648,10 +707,9 @@ export default function EditBookingPage() {
</Paper>
)}
</Stack>
</Tabs.Panel>
<Divider />
{/* ── Section 3: Route ── */}
<Tabs.Panel value="route">
<Stack gap="md">
<SectionHeading
title="Route"
@@ -746,10 +804,9 @@ export default function EditBookingPage() {
/>
</Paper>
</Stack>
</Tabs.Panel>
<Divider />
{/* ── Section 4: Cargo ── */}
<Tabs.Panel value="cargo">
<Box>
<Step5CargoDetails
form={form}
@@ -758,10 +815,13 @@ export default function EditBookingPage() {
isLoading={!referenceData}
/>
</Box>
</Tabs.Panel>
<Divider />
<Tabs.Panel value="schedule">
<StepScheduling form={form} referenceData={referenceData} />
</Tabs.Panel>
{/* ── Section 5: Documents ── */}
<Tabs.Panel value="documents">
<Stack gap="md">
<SectionHeading
title="Documents"
@@ -855,10 +915,9 @@ export default function EditBookingPage() {
</Box>
</Paper>
</Stack>
</Tabs.Panel>
<Divider />
{/* ── Section 6: Notes ── */}
<Tabs.Panel value="notes">
<Stack gap="md">
<SectionHeading
title="Notes"
@@ -878,7 +937,8 @@ export default function EditBookingPage() {
)}
/>
</Stack>
</Stack>
</Tabs.Panel>
</Tabs>
{/* ── Submit ── */}
<Group

View File

@@ -188,6 +188,21 @@ function PrimaryAction({
</Button>
);
}
if (status === "CHANGES_REQUESTED") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
color="orange"
rightSection={<ArrowRight size={14} />}
onClick={() => onNavigate(`/bookings/${id}/edit?section=documents`)}
>
Review changes
</Button>
);
}
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
@@ -235,7 +250,7 @@ function useStatusCount(statuses: string | undefined): number | undefined {
staleTime: 30_000,
}),
);
return data?.total;
return data?.meta?.total;
}
function StatCard({
@@ -354,7 +369,7 @@ export default function MyBookings() {
};
const allItems = data?.items ?? [];
const total = data?.total ?? allItems.length;
const total = data?.meta?.total ?? allItems.length;
// Server handles status + pagination; reference search is applied on the page.
const rows = useMemo(() => {

View File

@@ -1,7 +1,9 @@
import { api } from "@/services/api";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type {
CreateBookingPayload,
GeneratePriceResponse,
SubmitBookingResponse,
} from "@/services/bookings.service";
import { zodResolver } from "@hookform/resolvers/zod";
import {
@@ -35,6 +37,7 @@ import {
getRouteDirection,
initialBookingFormValues,
stepFields,
type BookingDocuments,
type BookingFormValues,
} from "./new-booking-form/schema";
import { StepIndicator } from "./new-booking-form/StepIndicator";
@@ -48,6 +51,8 @@ import {
StepScheduling,
} from "./new-booking-form/steps";
type PriceModalMode = "submit" | "draft";
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
);
}
const createMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const persistAndPriceMutation = useMutation({
mutationFn: async ({
payload,
mode,
existingBookingId,
}: {
payload: CreateBookingPayload;
mode: PriceModalMode;
existingBookingId: string | null;
}) => {
const documents = (form.getValues("documents") ?? {}) as BookingDocuments;
let bookingId = existingBookingId;
// Documents can't ride along with creation — upload them against the
// new booking id once it exists. Optional here; the booking detail page
// remains the catch-all for any docs the user skips.
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,
});
if (bookingId) {
await api.bookings.update.call({ id: bookingId, dto: payload, documents });
} else {
const booking = await api.bookings.create.call({ payload, documents });
bookingId = booking.id;
}
return booking;
const pricing = await api.bookings.generatePrice.call({ id: bookingId });
return { bookingId, pricing, mode };
},
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${booking.id}`);
},
});
const createAndPriceMutation = useMutation({
mutationFn: async (payload: CreateBookingPayload) => {
const booking = await api.bookings.create.call(payload);
const documents = form.getValues("documents") ?? {};
const hasDocs = Object.values(documents).some((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
);
if (hasDocs) {
await api.bookings.uploadDocuments.call({
id: booking.id,
files: documents,
});
}
const pricing = await api.bookings.generatePrice.call({ id: booking.id });
return { bookingId: booking.id, pricing };
},
onSuccess: ({ bookingId, pricing }) => {
onSuccess: ({ bookingId, pricing, mode }) => {
setPriceBookingId(bookingId);
setPricingData(pricing);
setPricingPhase("ready");
setPriceModalMode(mode);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
},
onError: () => {
setPricingPhase("idle");
},
});
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
await api.bookings.submit.call({ id: priceBookingId });
return api.bookings.submit.call({ id: priceBookingId });
},
onSuccess: (result) => {
if (result.priceChanged) {
setPriceChangeResult(result);
return;
}
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
});
const confirmSubmitMutation = useMutation({
mutationFn: async () => {
if (!priceBookingId) throw new Error("No booking to confirm");
return api.bookings.confirmSubmit.call({ id: priceBookingId });
},
onSuccess: () => {
setPriceChangeResult(null);
setPriceModalMode(null);
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
navigate(`/bookings/${priceBookingId}`);
},
@@ -188,22 +186,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],
);
const [pricingPhase, setPricingPhase] = useState<
"idle" | "generating" | "ready"
>("idle");
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
null,
);
const [priceBookingId, setPriceBookingId] = useState<string | null>(null);
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
null,
);
const [priceChangeResult, setPriceChangeResult] =
useState<SubmitBookingResponse | null>(null);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
if (step === 6 && !hasAllRequiredDocuments(form.getValues("documents"))) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
return;
}
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
}
@@ -265,7 +264,9 @@ export default function NewBookingPage() {
)!;
return {
scheduledDate: new Date().toISOString(),
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -313,25 +314,55 @@ export default function NewBookingPage() {
};
}
const handleDraftSubmit = form.handleSubmit((data) => {
const handleSaveDraft = form.handleSubmit((data) => {
try {
const apiPayload = buildApiPayload(data);
createMutation.mutate(apiPayload);
persistAndPriceMutation.mutate({
payload: apiPayload,
mode: "draft",
existingBookingId: priceBookingId,
});
} catch {
// validation error already handled
}
});
const handleGeneratePrice = form.handleSubmit((data) => {
const handleSubmitBooking = form.handleSubmit((data) => {
if (!hasAllRequiredDocuments(data.documents)) {
form.setError("documents", {
type: "manual",
message: "Upload all four required documents.",
});
setStep(6);
return;
}
try {
const apiPayload = buildApiPayload(data);
setPricingPhase("generating");
createAndPriceMutation.mutate(apiPayload);
persistAndPriceMutation.mutate({
payload: apiPayload,
mode: "submit",
existingBookingId: priceBookingId,
});
} catch {
// validation error already handled
}
});
const isPricing =
persistAndPriceMutation.isPending || confirmMutation.isPending;
function closePriceModal() {
setPriceModalMode(null);
if (priceModalMode === "draft" && priceBookingId) {
navigate(`/bookings/${priceBookingId}`);
}
}
function handleDraftModalOk() {
setPriceModalMode(null);
if (priceBookingId) navigate(`/bookings/${priceBookingId}`);
}
return (
<Box
style={{
@@ -376,14 +407,14 @@ export default function NewBookingPage() {
id="new-booking-form"
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={handleDraftSubmit}
onSubmit={(e) => e.preventDefault()}
>
<Box flex={1} p="24px">
<Box mb="lg">
<StepIndicator step={step} />
</Box>
{createMutation.isError && (
{persistAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
@@ -391,29 +422,11 @@ export default function NewBookingPage() {
mb="lg"
>
<Text size="sm" fw={600}>
Failed to save draft
Failed to save booking or generate price
</Text>
<Text size="sm" mt={4} c="red.7">
{createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{createAndPriceMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to generate price estimate
</Text>
<Text size="sm" mt={4} c="red.7">
{createAndPriceMutation.error instanceof Error
? createAndPriceMutation.error.message
{persistAndPriceMutation.error instanceof Error
? persistAndPriceMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
@@ -450,17 +463,16 @@ export default function NewBookingPage() {
setStep={setStep}
direction={direction!}
referenceData={referenceData}
pricingPhase={pricingPhase}
pricingData={pricingData}
onConfirm={() => confirmMutation.mutate()}
onContinueLater={
priceBookingId
? () => navigate(`/bookings/${priceBookingId}`)
: undefined
onSaveDraft={handleSaveDraft}
onSubmit={handleSubmitBooking}
saveDraftPending={
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "draft"
}
submitPending={
persistAndPriceMutation.isPending &&
persistAndPriceMutation.variables?.mode === "submit"
}
onAbort={() => setCancelDialogOpen(true)}
confirmPending={confirmMutation.isPending}
abortPending={abortMutation.isPending}
/>
)}
</Box>
@@ -501,51 +513,151 @@ export default function NewBookingPage() {
>
Continue
</Button>
) : pricingPhase === "idle" ? (
<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={createAndPriceMutation.isPending}
leftSection={
createAndPriceMutation.isPending ? undefined : (
<Send size={16} />
)
}
onClick={() => handleGeneratePrice()}
>
{createAndPriceMutation.isPending
? "Generating price…"
: "Submit"}
</Button>
)}
</Group>
) : pricingPhase === "generating" ? (
<Button type="button" color="edr-green" radius="md" loading>
Generating price estimate
) : (
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={handleSubmitBooking}
loading={isPricing}
>
Submit
</Button>
) : null}
)}
</Group>
</Box>
</form>
<Modal
opened={priceModalMode !== null && pricingData !== null}
onClose={closePriceModal}
title={
<Text fw={700}>
{priceModalMode === "submit"
? "Confirm booking submission"
: "Draft saved — price estimate"}
</Text>
}
radius="lg"
centered
size="md"
>
{pricingData && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceModalMode === "submit"
? "Review the price estimate below. Confirm to submit your booking for EDR staff review."
: "Your booking has been saved as a draft. Here is the estimated price."}
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between">
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Group justify="space-between" pt="xs">
<Text fw={800} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" p="xs" className="rounded bg-orange-50">
{pricingData.warnings.join(", ")}
</Text>
)}
<Group justify="flex-end" gap="sm" mt="md">
{priceModalMode === "submit" ? (
<>
<Button
variant="default"
radius="md"
onClick={closePriceModal}
disabled={confirmMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={() => confirmMutation.mutate()}
loading={confirmMutation.isPending}
>
Confirm & submit
</Button>
</>
) : (
<Button color="edr-green" radius="md" onClick={handleDraftModalOk}>
OK
</Button>
)}
</Group>
</Stack>
)}
</Modal>
<Modal
opened={priceChangeResult !== null}
onClose={() => setPriceChangeResult(null)}
title={<Text fw={700}>Price has changed</Text>}
radius="lg"
centered
>
{priceChangeResult && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{priceChangeResult.message ??
"The booking price has been updated. Confirm to submit with the new total."}
</Text>
{priceChangeResult.previousTotalAmount !== undefined && (
<Group justify="space-between">
<Text size="sm" c="dimmed">
Previous total
</Text>
<Text size="sm" td="line-through">
{priceChangeResult.previousTotalAmount.toLocaleString()}{" "}
{priceChangeResult.currency}
</Text>
</Group>
)}
<Group justify="space-between">
<Text fw={700}>New total</Text>
<Text fw={800} c="edr-green">
{priceChangeResult.totalAmount.toLocaleString()}{" "}
{priceChangeResult.currency}
</Text>
</Group>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => setPriceChangeResult(null)}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
loading={confirmSubmitMutation.isPending}
onClick={() => confirmSubmitMutation.mutate()}
>
Confirm & submit
</Button>
</Group>
</Stack>
)}
</Modal>
<Modal
opened={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}

View File

@@ -14,9 +14,7 @@ export const STEPS = [
/**
* Shipment documents collected during booking creation. The fileKeys mirror
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
* here shows up as "Uploaded" on the booking detail page. All optional in this
* flow — the detail page remains the catch-all for uploading them later.
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
*/
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
@@ -34,7 +32,7 @@ function docField(
fileKey,
fileLabel,
helpText: null,
isRequired: false,
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
code: "booking_documents",
label: "Booking Documents",
description:
"Attach your shipment documents now, or skip and upload them later from the booking page.",
"Attach all four required shipment documents before submitting your booking.",
entity: "booking",
fields: [
docField("commercial_invoice", "Commercial Invoice", 1),

View File

@@ -115,7 +115,7 @@ export function Step2ServiceType({
icon={<Truck size={18} />}
title="First Mile — Pick-up"
description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
checked={field.value}
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
@@ -157,7 +157,7 @@ export function Step2ServiceType({
icon={<Truck size={18} />}
title="Last Mile — Delivery"
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
checked={field.value}
checked={field.value ?? false}
onChange={(value) => {
field.onChange(value);
if (!value) {
@@ -225,7 +225,7 @@ export function Step2ServiceType({
icon={<FileText size={18} />}
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value}
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}

View File

@@ -1,27 +1,46 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import {
Badge,
Box,
Button,
Card,
Divider,
Group,
Loader,
SimpleGrid,
Paper,
Stack,
Table,
Text,
Textarea,
} from "@mantine/core";
import { Check, Send, XCircle, FileText, Route, Package, Truck } from "lucide-react";
import { format } from "date-fns";
import {
Calendar,
CheckCircle2,
Circle,
ClipboardCheck,
FileText,
Package,
Pencil,
Route,
Send,
Truck,
} from "lucide-react";
import type { Freight } from "@/types";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import {
BookingFormInputValues,
BOOKING_DOCS_SETTING,
type BookingDocuments,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import { ClipboardCheck } from "lucide-react";
import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
export const REVIEW_STEP_TARGETS = {
contract: 1,
service: 2,
route: 3,
cargo: 4,
schedule: 5,
documents: 6,
} as const;
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -29,113 +48,135 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
function OverviewSection({
icon,
title,
onEdit,
children,
}: {
icon: React.ReactNode;
title: string;
onEdit: () => void;
children: React.ReactNode;
}) {
return (
<Paper radius={16} p="lg" withBorder className="border-gray-200 bg-white">
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<Box
className="flex items-center justify-center rounded-lg"
style={{
width: 36,
height: 36,
backgroundColor: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
}}
>
{icon}
</Box>
<Text fw={700} size="sm" c="#10202F">
{title}
</Text>
</Group>
<Button
type="button"
variant="subtle"
color="edr-green"
size="compact-xs"
leftSection={<Pencil size={13} />}
onClick={onEdit}
>
Edit
</Button>
</Group>
{children}
</Paper>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" align="flex-start" wrap="nowrap" py={4}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" className="tracking-wide">
{label}
</Text>
<Text size="sm" fw={500} ta="right" maw="60%">
{value || "—"}
</Text>
</Group>
);
}
function ReadinessItem({
done,
label,
}: {
done: boolean;
label: string;
}) {
return (
<Group gap="sm" wrap="nowrap">
{done ? (
<CheckCircle2 size={18} className="shrink-0 text-emerald-600" />
) : (
<Circle size={18} className="shrink-0 text-gray-300" />
)}
<Text size="sm" c={done ? "dark" : "dimmed"}>
{label}
</Text>
</Group>
);
}
export function Step8Review({
form,
setStep,
direction,
referenceData,
pricingPhase = "idle",
pricingData,
onConfirm,
onContinueLater,
onAbort,
confirmPending = false,
abortPending = false,
onSaveDraft,
onSubmit,
saveDraftPending = false,
submitPending = false,
}: {
form: BookingForm;
setStep: (step: number) => void;
direction: Freight.ScheduleTradeDirection;
referenceData?: Freight.BookingReferenceData;
pricingPhase?: "idle" | "generating" | "ready";
pricingData?: GeneratePriceResponse | null;
onConfirm?: () => void;
onContinueLater?: () => void;
onAbort?: () => void;
confirmPending?: boolean;
abortPending?: boolean;
onSaveDraft?: () => void;
onSubmit?: () => void;
saveDraftPending?: boolean;
submitPending?: boolean;
}) {
const values = form.watch();
const serviceType = referenceData?.service.find(
(s) => s.id === values.serviceTypeId,
);
function CompactRow({
label,
value,
target,
}: {
label: string;
value: string;
target: number;
}) {
return (
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
{label}
</Text>
<Text size="sm" fw={500} className="truncate">
{value || "—"}
</Text>
</div>
<button
type="button"
onClick={() => setStep(target)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
>
Edit
</button>
</div>
);
}
function CompactCard({
icon: Icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="md" p="sm" withBorder className="border-gray-200 bg-white hover:shadow-sm transition-shadow">
<Group gap="xs" mb="xs" wrap="nowrap">
<Box c="edr-green">{Icon}</Box>
<Text size="xs" fw={700} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Group>
<Stack gap="xs">{children}</Stack>
</Card>
);
}
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.containerType || c.type}`)
.join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: 0;
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: Number(values.cargoWeight || 0);
const documents = (values.documents ?? {}) as BookingDocuments;
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
const value = documents[f.fileKey];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}).length;
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
const allDocsReady = hasAllRequiredDocuments(documents);
const cargoValue = (() => {
if (values.cargoType === "container") return containerSummary;
if (values.cargoType === "container") return "Container freight";
if (!referenceData) return "";
const path = values.cargoTypePath ?? [];
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
@@ -144,227 +185,312 @@ export function Step8Review({
return child ? `${group.name}${child.name}` : group.name;
})();
const originYardName = referenceData?.yard.find(
(y) => y.id === values.originYard,
)?.name ?? values.originYard;
const originYardName =
referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
values.originYard;
const destinationYardName = referenceData?.yard.find(
(y) => y.id === values.destinationYard,
)?.name ?? values.destinationYard;
const destinationYardName =
referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
values.destinationYard;
const scheduleLabel = values.scheduledDate
? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
: "—";
const directionLabel = direction
? direction.charAt(0) + direction.slice(1).toLowerCase()
: "—";
return (
<Stack gap="md">
<Stack gap="lg">
<StepHeader
icon={<ClipboardCheck size={22} />}
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
description="Review your booking overview before sending it for EDR staff review."
/>
{/* Pricing Card - Prominent at top */}
{pricingPhase === "generating" && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
<Group justify="center" py="lg">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Generating price estimate
</Text>
</Group>
</Card>
)}
{pricingPhase === "ready" && pricingData && (
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
<Stack gap="sm">
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
💳 Price Breakdown
</Text>
<Stack gap="xs">
{pricingData.lineItems.map((item) => (
<Group key={item.code} justify="space-between" py={2}>
<Text size="sm" c="dimmed">
{item.description}
</Text>
<Text size="sm" fw={600}>
{item.amount.toLocaleString()} {item.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="xs" />
<Group justify="space-between" py={2}>
<Text fw={700} size="md">
Total
</Text>
<Text fw={800} size="lg" c="edr-green">
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
</Text>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Left — booking summary */}
<Stack gap="md" className="min-w-0 flex-1">
<Paper
radius={20}
p="lg"
className="border border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40"
>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={4}>
<Text size="xs" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
Booking overview
</Text>
<Text fw={800} size="xl" c="#10202F">
{values.contractType === "new" ? "New Contract" : "Contract Renewal"}
</Text>
<Text size="sm" c="dimmed">
{serviceType?.name ?? "—"} · {originYardName} {destinationYardName}
</Text>
</Stack>
<Badge size="lg" variant="light" color="edr-green" radius="md">
{directionLabel}
</Badge>
</Group>
{pricingData.warnings.length > 0 && (
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
{pricingData.warnings.join(", ")}
</Text>
</Paper>
<OverviewSection
icon={<Package size={18} />}
title="Contract & Service"
onEdit={() => setStep(REVIEW_STEP_TARGETS.contract)}
>
<DetailRow
label="Contract"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
/>
{values.contractType === "renewal" && values.previousContractRef && (
<DetailRow label="Previous ref" value={values.previousContractRef} />
)}
<Group mt="md">
<Button
color="edr-green"
radius="md"
leftSection={<Check size={16} />}
onClick={onConfirm}
loading={confirmPending}
className="flex-1"
>
{confirmPending ? "Confirming…" : "Confirm"}
</Button>
<Button
variant="outline"
color="edr-green"
radius="md"
leftSection={<Send size={16} />}
onClick={onContinueLater}
className="flex-1"
>
Continue later
</Button>
<Button
variant="outline"
color="red"
radius="md"
leftSection={!abortPending ? <XCircle size={16} /> : undefined}
onClick={onAbort}
loading={abortPending}
>
Abort
</Button>
</Group>
</Stack>
</Card>
)}
{/* Review Details - Compact Cards Grid */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="sm" mt="md">
<CompactCard icon={<Package size={16} />} title="Contract & Service">
<CompactRow
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<CompactRow label="Service" value={serviceType?.name ?? ""} target={2} />
</CompactCard>
<CompactCard icon={<Route size={16} />} title="Route">
<CompactRow
label="Origin → Destination"
value={`${originYardName}${destinationYardName}`}
target={3}
/>
<CompactRow
label="Workflow"
value={
direction ? direction.charAt(0).toUpperCase() + direction.slice(1) : ""
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Truck size={16} />} title="Logistics">
<CompactRow
label="First Mile"
value={
values.firstMile.enabled ? values.firstMile.pickUpAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Last Mile"
value={
values.lastMile.enabled ? values.lastMile.deliveryAddress : "Not requested"
}
target={2}
/>
<CompactRow
label="Equipment Return"
value={
values.equipmentReturn === "with_return" ? "With Return" : "Without Return"
}
target={2}
/>
<CompactRow
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Cargo Details">
<CompactRow
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<CompactRow label="Cargo Type" value={cargoValue} target={4} />
<CompactRow
label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</CompactCard>
<CompactCard icon={<Package size={16} />} title="Containers">
<CompactRow
label="Count & Type"
value={containerSummary || "—"}
target={4}
/>
<CompactRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
target={4}
/>
</CompactCard>
<CompactCard icon={<FileText size={16} />} title="Documents">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Text size="10px" c="dimmed" fw={600} tt="uppercase" className="mb-1 tracking-wider">
Attached
</Text>
<Text size="sm" fw={500}>
{docsAttached > 0
? `${docsAttached} of ${docsTotal}`
: "None"}
</Text>
</div>
<button
<DetailRow label="Service" value={serviceType?.name ?? ""} />
<Button
type="button"
onClick={() => setStep(5)}
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
variant="subtle"
size="compact-xs"
color="gray"
mt={4}
onClick={() => setStep(REVIEW_STEP_TARGETS.service)}
>
Edit
</button>
</div>
</CompactCard>
</SimpleGrid>
Edit service options
</Button>
</OverviewSection>
{/* Notes */}
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
id="notes"
label="Additional Notes"
placeholder="Any special instructions or notes for EDR operations…"
rows={2}
radius="md"
size="sm"
<OverviewSection
icon={<Route size={18} />}
title="Route"
onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
>
<DetailRow
label="Corridor"
value={`${originYardName}${destinationYardName}`}
/>
<DetailRow label="Trade direction" value={directionLabel} />
<DetailRow label="Shipping line" value={values.shippingLine || "—"} />
<DetailRow
label="Modifiers"
value={
[values.isHazardous && "Hazardous", values.isRefrigerated && "Refrigerated"]
.filter(Boolean)
.join(", ") || "None"
}
/>
</OverviewSection>
<OverviewSection
icon={<Truck size={18} />}
title="Logistics"
onEdit={() => setStep(REVIEW_STEP_TARGETS.service)}
>
<DetailRow
label="First mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
/>
<DetailRow
label="Last mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
/>
<DetailRow
label="Equipment return"
value={
values.equipmentReturn === "with_return"
? "With return"
: "Without return"
}
/>
<DetailRow
label="Customs clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
/>
</OverviewSection>
<OverviewSection
icon={<Calendar size={18} />}
title="Schedule"
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Shipment date" value={scheduleLabel} />
<DetailRow
label="Train schedule"
value={values.trainScheduleId ? "Selected" : "—"}
/>
</OverviewSection>
<OverviewSection
icon={<Package size={18} />}
title="Cargo"
onEdit={() => setStep(REVIEW_STEP_TARGETS.cargo)}
>
<DetailRow label="Freight type" value={cargoValue} />
<DetailRow
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
/>
<DetailRow
label="Consolidation"
value={values.consolidationEnabled ? "Allowed" : "Not allowed"}
/>
{values.cargoType === "container" && values.containers.length > 0 && (
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM (t)</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{values.containers
.filter((c) => +c.qty > 0)
.map((c, i) => (
<Table.Tr key={i}>
<Table.Td>{c.containerType || c.type}</Table.Td>
<Table.Td>{c.qty}</Table.Td>
<Table.Td>{c.vgm}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{containerSummary && (
<DetailRow label="Summary" value={containerSummary} />
)}
</OverviewSection>
<OverviewSection
icon={<FileText size={18} />}
title="Documents"
onEdit={() => setStep(REVIEW_STEP_TARGETS.documents)}
>
<Stack gap="xs">
{BOOKING_DOCS_SETTING.fields.map((field) => {
const file = documents[field.fileKey];
const attached = Array.isArray(file)
? file.length > 0
: Boolean(file);
const fileName = attached
? Array.isArray(file)
? file[0]?.name
: (file as File)?.name
: null;
return (
<Group key={field.fileKey} justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{attached ? (
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
) : (
<Circle size={16} className="text-red-400 shrink-0" />
)}
<Text size="sm">{field.fileLabel}</Text>
</Group>
<Text size="xs" c={attached ? "dimmed" : "red"} className="truncate max-w-[45%]">
{fileName ?? "Missing"}
</Text>
</Group>
);
})}
</Stack>
<Text size="xs" c="dimmed" mt="sm">
{docsAttached} of {BOOKING_DOCS_SETTING.fields.length} attached
</Text>
</OverviewSection>
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
label="Additional notes"
placeholder="Any special instructions for EDR operations…"
rows={3}
radius="md"
/>
)}
/>
)}
/>
</Stack>
{/* Right — sticky actions */}
<Box className="w-full shrink-0 lg:w-[340px] lg:sticky lg:top-24">
<Stack gap="md">
<Paper radius={20} p="lg" withBorder bg="white">
<Text fw={800} size="sm" mb="md" c="#10202F">
Submission readiness
</Text>
<Stack gap="sm">
<ReadinessItem done={Boolean(values.serviceTypeId)} label="Service configured" />
<ReadinessItem
done={Boolean(values.originYard && values.destinationYard)}
label="Route selected"
/>
<ReadinessItem
done={Boolean(values.scheduledDate && values.trainScheduleId)}
label="Schedule selected"
/>
<ReadinessItem
done={
values.cargoType === "container"
? values.containers.some((c) => +c.qty > 0)
: Boolean(values.cargoWeight)
}
label="Cargo details complete"
/>
<ReadinessItem
done={allDocsReady}
label="All 4 documents attached"
/>
</Stack>
</Paper>
<Paper radius={20} p="lg" withBorder bg="white">
<Text size="sm" c="dimmed" mb="md">
{allDocsReady
? "Ready to submit. You'll review the price estimate before final submission."
: "Upload all four documents to enable submission."}
</Text>
<Stack gap="sm">
<Button
type="button"
color="edr-green"
radius="md"
fullWidth
size="md"
leftSection={<Send size={16} />}
onClick={onSubmit}
loading={submitPending}
disabled={!allDocsReady || submitPending}
>
Submit
</Button>
<Button
type="button"
variant="outline"
color="edr-green"
radius="md"
fullWidth
onClick={onSaveDraft}
loading={saveDraftPending}
disabled={submitPending}
>
Save as draft
</Button>
</Stack>
</Paper>
</Stack>
</Box>
</div>
</Stack>
);
}

View File

@@ -13,7 +13,9 @@ import {
BookingListFilter,
CreateBookingPayload,
GeneratePriceResponse,
SubmitBookingResponse,
} from "./bookings.service";
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import {
paymentsService,
InitiatePaymentPayload,
@@ -147,16 +149,23 @@ export const api = {
({ id }) => bookingsService.tracking(id),
),
create: endpoint<CreateBookingPayload, Freight.IBooking>(
"bookings",
"create",
bookingsService.create,
create: endpoint<
{ payload: CreateBookingPayload; documents?: BookingDocuments },
Freight.IBooking
>("bookings", "create", ({ payload, documents }) =>
bookingsService.create(payload, documents),
),
update: endpoint<
{ id: string; dto: Partial<CreateBookingPayload> },
{
id: string;
dto: Partial<CreateBookingPayload>;
documents?: BookingDocuments;
},
{ booking: Freight.IBooking; warnings: string[] }
>("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)),
>("bookings", "update", ({ id, dto, documents }) =>
bookingsService.update(id, dto, documents),
),
referenceData: endpoint<void, Freight.BookingReferenceData>(
"bookings",
@@ -180,12 +189,18 @@ export const api = {
({ id }) => bookingsService.generatePrice(id),
),
submit: endpoint<{ id: string }, Freight.IBooking>(
submit: endpoint<{ id: string }, SubmitBookingResponse>(
"bookings",
"submit",
({ id }) => bookingsService.submit(id),
),
confirmSubmit: endpoint<{ id: string }, SubmitBookingResponse>(
"bookings",
"confirmSubmit",
({ id }) => bookingsService.confirmSubmit(id),
),
uploadDocuments: endpoint<
{ id: string; files: Record<string, File | File[] | null> },
Freight.IBooking

View File

@@ -0,0 +1,88 @@
import type { CreateBookingPayload } from "./bookings.service";
import { BOOKING_DOCS_SETTING, type BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
function appendValue(formData: FormData, key: string, value: unknown) {
if (value === undefined || value === null) return;
if (typeof value === "boolean") {
formData.append(key, value ? "true" : "false");
return;
}
if (typeof value === "number") {
formData.append(key, String(value));
return;
}
if (typeof value === "string") {
formData.append(key, value);
return;
}
}
function appendContainers(
formData: FormData,
containers: NonNullable<CreateBookingPayload["containers"]>,
) {
containers.forEach((container, index) => {
formData.append(
`containers[${index}][containerTypeId]`,
container.containerTypeId,
);
formData.append(
`containers[${index}][quantity]`,
String(container.quantity),
);
formData.append(
`containers[${index}][vgmPerUnitTons]`,
String(container.vgmPerUnitTons),
);
});
}
function appendDocuments(
formData: FormData,
documents?: Record<string, File | File[] | null>,
) {
if (!documents) return;
for (const [key, fileOrFiles] of Object.entries(documents)) {
if (!fileOrFiles) continue;
if (Array.isArray(fileOrFiles)) {
for (const file of fileOrFiles) {
formData.append(key, file);
}
} else {
formData.append(key, fileOrFiles);
}
}
}
/** Flatten a booking payload (and optional document files) into multipart FormData. */
export function buildBookingFormData(
payload: Partial<CreateBookingPayload>,
documents?: BookingDocuments,
): FormData {
const formData = new FormData();
const skipKeys = new Set(["containers", "freightShapeValidation"]);
for (const [key, value] of Object.entries(payload)) {
if (skipKeys.has(key)) continue;
appendValue(formData, key, value);
}
if (payload.containers?.length) {
appendContainers(formData, payload.containers);
}
appendDocuments(formData, documents);
return formData;
}
/** Returns true when every required booking document field has a file attached. */
export function hasAllRequiredDocuments(
documents: BookingDocuments | undefined | null,
): boolean {
const docs = documents ?? {};
return BOOKING_DOCS_SETTING.fields.every((field) => {
const value = docs[field.fileKey];
if (Array.isArray(value)) return value.length > 0;
return Boolean(value);
});
}

View File

@@ -1,6 +1,8 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import { buildBookingFormData } from "./booking-form-data";
import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
@@ -45,6 +47,17 @@ export interface GeneratePriceResponse {
warnings: string[];
}
export interface SubmitBookingResponse {
bookingId: string;
status: string;
priceChanged: boolean;
previousTotalAmount?: number;
totalAmount: number;
currency: string;
lineItems?: PriceLineItem[];
message?: string;
}
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
@@ -77,8 +90,14 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;
},
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
const { data } = await client.post("/api/bookings", payload);
create: async (
payload: CreateBookingPayload,
documents?: BookingDocuments,
): Promise<Freight.IBooking> => {
const formData = buildBookingFormData(payload, documents);
const { data } = await client.post("/api/bookings", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data.booking;
},
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
@@ -88,8 +107,12 @@ export const bookingsService = {
update: async (
id: string,
payload: Partial<CreateBookingPayload>,
documents?: BookingDocuments,
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
const { data } = await client.patch(`/api/bookings/${id}`, payload);
const formData = buildBookingFormData(payload, documents);
const { data } = await client.patch(`/api/bookings/${id}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data;
},
@@ -107,11 +130,16 @@ export const bookingsService = {
return data.data;
},
submit: async (id: string): Promise<Freight.IBooking> => {
submit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data;
},
confirmSubmit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
return data.data;
},
uploadDocuments: async (
id: string,
files: Record<string, File | File[] | null>,