mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add price change confirmation modal and support document replacement for change-requested bookings
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user