mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +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 { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import type { SubmitBookingResponse } from "@/services/bookings.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { REQUIRED_DOC_FIELDS } from "./constants";
|
import { REQUIRED_DOC_FIELDS } from "./constants";
|
||||||
@@ -60,6 +61,8 @@ export function DraftBookingView({
|
|||||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
const [cancelReason, setCancelReason] = useState("");
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
const [docError, setDocError] = useState("");
|
const [docError, setDocError] = useState("");
|
||||||
|
const [priceChangeModal, setPriceChangeModal] =
|
||||||
|
useState<SubmitBookingResponse | null>(null);
|
||||||
|
|
||||||
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
||||||
const uploadedCodes = useMemo(
|
const uploadedCodes = useMemo(
|
||||||
@@ -72,9 +75,11 @@ export function DraftBookingView({
|
|||||||
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
|
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
|
||||||
|
|
||||||
const { data: generatedPricing } = useQuery(
|
const { data: generatedPricing } = useQuery(
|
||||||
api.bookings.generatePrice.queryOptions({ input: { id: booking.id },
|
api.bookings.generatePrice.queryOptions({
|
||||||
|
input: { id: booking.id },
|
||||||
enabled: booking.status === "DRAFT" && !booking.pricingBreakdown,
|
enabled:
|
||||||
|
(booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") &&
|
||||||
|
!booking.pricingBreakdown,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const pricing = (booking.pricingBreakdown ??
|
const pricing = (booking.pricingBreakdown ??
|
||||||
@@ -82,8 +87,17 @@ export function DraftBookingView({
|
|||||||
null) as Freight.PricingBreakdown | null;
|
null) as Freight.PricingBreakdown | null;
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
mutationFn: async (files: Record<string, File | File[] | null>) => {
|
||||||
api.bookings.uploadDocuments.call({ id: booking.id, files }),
|
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: () => {
|
onSuccess: () => {
|
||||||
setSelectedFiles({});
|
setSelectedFiles({});
|
||||||
setDocError("");
|
setDocError("");
|
||||||
@@ -93,7 +107,20 @@ export function DraftBookingView({
|
|||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: () => api.bookings.submit.call({ id: booking.id }),
|
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: () => {
|
onSuccess: () => {
|
||||||
|
setPriceChangeModal(null);
|
||||||
onBookingUpdated();
|
onBookingUpdated();
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
},
|
},
|
||||||
@@ -155,7 +182,12 @@ export function DraftBookingView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MutationErrors
|
<MutationErrors
|
||||||
mutations={[uploadMutation, submitMutation, cancelMutation]}
|
mutations={[
|
||||||
|
uploadMutation,
|
||||||
|
submitMutation,
|
||||||
|
confirmSubmitMutation,
|
||||||
|
cancelMutation,
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusHero booking={booking}>
|
<StatusHero booking={booking}>
|
||||||
@@ -163,7 +195,9 @@ export function DraftBookingView({
|
|||||||
booking.latestChangeRequestNote ? (
|
booking.latestChangeRequestNote ? (
|
||||||
<ActionRequiredBanner
|
<ActionRequiredBanner
|
||||||
title="Review the requested changes, then resubmit."
|
title="Review the requested changes, then resubmit."
|
||||||
onAction={() => navigate(`/bookings/${booking.id}/edit`)}
|
onAction={() =>
|
||||||
|
navigate(`/bookings/${booking.id}/edit?section=documents`)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{booking.latestChangeRequestNote}
|
{booking.latestChangeRequestNote}
|
||||||
</ActionRequiredBanner>
|
</ActionRequiredBanner>
|
||||||
@@ -260,6 +294,8 @@ export function DraftBookingView({
|
|||||||
const isUploaded = uploadedCodes.has(doc.key);
|
const isUploaded = uploadedCodes.has(doc.key);
|
||||||
const selected = selectedFiles[doc.key];
|
const selected = selectedFiles[doc.key];
|
||||||
const file = booking.files?.find((f) => f.code === doc.key);
|
const file = booking.files?.find((f) => f.code === doc.key);
|
||||||
|
const allowReplace =
|
||||||
|
!isUploaded || booking.status === "CHANGES_REQUESTED";
|
||||||
return (
|
return (
|
||||||
<DocRow
|
<DocRow
|
||||||
key={doc.key}
|
key={doc.key}
|
||||||
@@ -276,13 +312,19 @@ export function DraftBookingView({
|
|||||||
isUploaded ? "verified" : selected ? "ready" : "missing"
|
isUploaded ? "verified" : selected ? "ready" : "missing"
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
isUploaded ? (
|
isUploaded && !allowReplace ? (
|
||||||
<IconSquare
|
<IconSquare
|
||||||
href={file?.signedUrl ?? file?.url}
|
href={file?.signedUrl ?? file?.url}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{isUploaded && (
|
||||||
|
<IconSquare
|
||||||
|
href={file?.signedUrl ?? file?.url}
|
||||||
|
icon={<Download size={16} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
fileInputRefs.current[doc.key] = el;
|
fileInputRefs.current[doc.key] = el;
|
||||||
@@ -318,7 +360,7 @@ export function DraftBookingView({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected ? "Change" : "Add"}
|
{selected ? "Change" : isUploaded ? "Replace" : "Add"}
|
||||||
</Button>
|
</Button>
|
||||||
{selected && (
|
{selected && (
|
||||||
<ActionIcon
|
<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
|
<Modal
|
||||||
opened={cancelDialogOpen}
|
opened={cancelDialogOpen}
|
||||||
onClose={() => setCancelDialogOpen(false)}
|
onClose={() => setCancelDialogOpen(false)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -36,7 +37,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useRef, type ReactNode } from "react";
|
import { useMemo, useRef, type ReactNode } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
CountChip,
|
CountChip,
|
||||||
DocRow,
|
DocRow,
|
||||||
@@ -52,12 +53,32 @@ import {
|
|||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
} from "./new-booking-form/schema";
|
} from "./new-booking-form/schema";
|
||||||
import { SelectField } from "./new-booking-form/shared";
|
import { SelectField } from "./new-booking-form/shared";
|
||||||
import { Step5CargoDetails } from "./new-booking-form/steps";
|
import { Step5CargoDetails, StepScheduling } from "./new-booking-form/steps";
|
||||||
|
|
||||||
function yardNameFromBooking(
|
const EDIT_SECTIONS = [
|
||||||
yard: { label?: string; code?: string; name?: string } | undefined | null,
|
"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 {
|
): 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
|
/** Fallback container type for a size, used only when a booking row has no
|
||||||
@@ -108,14 +129,18 @@ function mapBookingToFormValues(
|
|||||||
booking.equipmentReturn === "WITH_RETURN"
|
booking.equipmentReturn === "WITH_RETURN"
|
||||||
? "with_return"
|
? "with_return"
|
||||||
: "without_return",
|
: "without_return",
|
||||||
originYard: yardNameFromBooking(booking.originYard),
|
originYard: yardIdFromBooking(booking.originYard, referenceData),
|
||||||
destinationYard: yardNameFromBooking(booking.destinationYard),
|
destinationYard: yardIdFromBooking(booking.destinationYard, referenceData),
|
||||||
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
|
cargoType: booking.freightType === "BULK" ? "bulk" : "container",
|
||||||
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
||||||
isHazardous: booking.isHazardous ?? false,
|
isHazardous: booking.isHazardous ?? false,
|
||||||
isRefrigerated: booking.isRefrigerated ?? false,
|
isRefrigerated: booking.isRefrigerated ?? false,
|
||||||
shippingLine: (booking as any).shippingLine?.name ?? "",
|
shippingLine: (booking as any).shippingLine?.name ?? "",
|
||||||
consolidationEnabled: booking.allowConsolidation ?? false,
|
consolidationEnabled: booking.allowConsolidation ?? false,
|
||||||
|
scheduledDate: booking.scheduledDate
|
||||||
|
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
|
||||||
|
: "",
|
||||||
|
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
|
||||||
notes: "",
|
notes: "",
|
||||||
containers: [],
|
containers: [],
|
||||||
} as BookingFormInputValues;
|
} as BookingFormInputValues;
|
||||||
@@ -237,9 +262,20 @@ const DIRECTION_LABEL: Record<string, string> = {
|
|||||||
export default function EditBookingPage() {
|
export default function EditBookingPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const docInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
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(
|
const bookingQuery = useQuery(
|
||||||
api.bookings.get.queryOptions({
|
api.bookings.get.queryOptions({
|
||||||
input: { id: id! },
|
input: { id: id! },
|
||||||
@@ -269,18 +305,17 @@ export default function EditBookingPage() {
|
|||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: async (payload: Partial<CreateBookingPayload>) => {
|
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 documents = (form.getValues("documents") ?? {}) as BookingDocuments;
|
||||||
const hasDocuments = Object.values(documents).some((value) =>
|
const newDocuments: BookingDocuments = {};
|
||||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
for (const [key, value] of Object.entries(documents)) {
|
||||||
);
|
if (value) newDocuments[key] = value;
|
||||||
if (hasDocuments) {
|
|
||||||
await api.bookings.uploadDocuments.call({ id: id!, files: documents });
|
|
||||||
}
|
}
|
||||||
|
return api.bookings.update.call({
|
||||||
return result;
|
id: id!,
|
||||||
|
dto: payload,
|
||||||
|
documents:
|
||||||
|
Object.keys(newDocuments).length > 0 ? newDocuments : undefined,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
@@ -304,9 +339,9 @@ export default function EditBookingPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const direction = useMemo(() => {
|
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(
|
const destination = referenceData?.yard.find(
|
||||||
(y) => y.name === destinationYard,
|
(y) => y.id === destinationYard,
|
||||||
);
|
);
|
||||||
return getRouteDirection(origin, destination);
|
return getRouteDirection(origin, destination);
|
||||||
}, [originYard, destinationYard, referenceData]);
|
}, [originYard, destinationYard, referenceData]);
|
||||||
@@ -314,7 +349,7 @@ export default function EditBookingPage() {
|
|||||||
const yardOptions = useMemo(() => {
|
const yardOptions = useMemo(() => {
|
||||||
if (!referenceData?.yard) return [];
|
if (!referenceData?.yard) return [];
|
||||||
return referenceData.yard.map((y) => ({
|
return referenceData.yard.map((y) => ({
|
||||||
value: y.name,
|
value: y.id,
|
||||||
label: y.name,
|
label: y.name,
|
||||||
country: y.country,
|
country: y.country,
|
||||||
}));
|
}));
|
||||||
@@ -338,14 +373,9 @@ export default function EditBookingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit((data) => {
|
const handleSubmit = form.handleSubmit((data) => {
|
||||||
const yards = referenceData?.yard ?? [];
|
|
||||||
const services = referenceData?.service ?? [];
|
|
||||||
const shippingLines = referenceData?.shipping_line ?? [];
|
const shippingLines = referenceData?.shipping_line ?? [];
|
||||||
const containerGroups = referenceData?.containers ?? [];
|
const containerGroups = referenceData?.containers ?? [];
|
||||||
|
|
||||||
const findYardId = (name: string): string =>
|
|
||||||
yards.find((y) => y.name === name)?.id ?? "";
|
|
||||||
|
|
||||||
const findShippingLineId = (name: string): string | undefined =>
|
const findShippingLineId = (name: string): string | undefined =>
|
||||||
shippingLines.find((l) => l.name === name)?.id;
|
shippingLines.find((l) => l.name === name)?.id;
|
||||||
|
|
||||||
@@ -369,10 +399,15 @@ export default function EditBookingPage() {
|
|||||||
)
|
)
|
||||||
: Number(data.cargoWeight || 0);
|
: 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> = {
|
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:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
serviceTypeId: data.serviceTypeId,
|
serviceTypeId: data.serviceTypeId,
|
||||||
@@ -380,8 +415,8 @@ export default function EditBookingPage() {
|
|||||||
data.equipmentReturn === "with_return"
|
data.equipmentReturn === "with_return"
|
||||||
? "WITH_RETURN"
|
? "WITH_RETURN"
|
||||||
: "WITHOUT_RETURN",
|
: "WITHOUT_RETURN",
|
||||||
originYardId: findYardId(data.originYard),
|
originYardId: data.originYard,
|
||||||
destinationYardId: findYardId(data.destinationYard),
|
destinationYardId: data.destinationYard,
|
||||||
tradeDirection:
|
tradeDirection:
|
||||||
direction === "EXPORT"
|
direction === "EXPORT"
|
||||||
? "EXPORT"
|
? "EXPORT"
|
||||||
@@ -393,7 +428,6 @@ export default function EditBookingPage() {
|
|||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: "USD",
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
// @ts-ignore
|
|
||||||
freightType:
|
freightType:
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? ("CONTAINER" as const)
|
? ("CONTAINER" as const)
|
||||||
@@ -505,9 +539,34 @@ export default function EditBookingPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack gap={36} mt="xl">
|
{booking.status === "CHANGES_REQUESTED" && (
|
||||||
{/* ── Section 1: Service ── */}
|
<Alert color="orange" icon={<AlertCircle size={16} />} radius="md" mt="lg">
|
||||||
<Stack gap="md">
|
<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
|
<SectionHeading
|
||||||
title="Service"
|
title="Service"
|
||||||
description="Select the service combination and configure trucking options."
|
description="Select the service combination and configure trucking options."
|
||||||
@@ -648,10 +707,9 @@ export default function EditBookingPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Divider />
|
<Tabs.Panel value="route">
|
||||||
|
|
||||||
{/* ── Section 3: Route ── */}
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Route"
|
title="Route"
|
||||||
@@ -746,10 +804,9 @@ export default function EditBookingPage() {
|
|||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Divider />
|
<Tabs.Panel value="cargo">
|
||||||
|
|
||||||
{/* ── Section 4: Cargo ── */}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Step5CargoDetails
|
<Step5CargoDetails
|
||||||
form={form}
|
form={form}
|
||||||
@@ -758,10 +815,13 @@ export default function EditBookingPage() {
|
|||||||
isLoading={!referenceData}
|
isLoading={!referenceData}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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">
|
<Stack gap="md">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Documents"
|
title="Documents"
|
||||||
@@ -855,10 +915,9 @@ export default function EditBookingPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Divider />
|
<Tabs.Panel value="notes">
|
||||||
|
|
||||||
{/* ── Section 6: Notes ── */}
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title="Notes"
|
title="Notes"
|
||||||
@@ -878,7 +937,8 @@ export default function EditBookingPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* ── Submit ── */}
|
{/* ── Submit ── */}
|
||||||
<Group
|
<Group
|
||||||
|
|||||||
@@ -188,6 +188,21 @@ function PrimaryAction({
|
|||||||
</Button>
|
</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") {
|
if (status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID") {
|
||||||
return <PayNowButton booking={booking} />;
|
return <PayNowButton booking={booking} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
|
||||||
import type {
|
import type {
|
||||||
CreateBookingPayload,
|
CreateBookingPayload,
|
||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
|
SubmitBookingResponse,
|
||||||
} from "@/services/bookings.service";
|
} from "@/services/bookings.service";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
stepFields,
|
stepFields,
|
||||||
|
type BookingDocuments,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
} from "./new-booking-form/schema";
|
} from "./new-booking-form/schema";
|
||||||
import { StepIndicator } from "./new-booking-form/StepIndicator";
|
import { StepIndicator } from "./new-booking-form/StepIndicator";
|
||||||
@@ -48,6 +51,8 @@ import {
|
|||||||
StepScheduling,
|
StepScheduling,
|
||||||
} from "./new-booking-form/steps";
|
} from "./new-booking-form/steps";
|
||||||
|
|
||||||
|
type PriceModalMode = "submit" | "draft";
|
||||||
|
|
||||||
export default function NewBookingPage() {
|
export default function NewBookingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -91,68 +96,61 @@ export default function NewBookingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const persistAndPriceMutation = useMutation({
|
||||||
mutationFn: async (payload: CreateBookingPayload) => {
|
mutationFn: async ({
|
||||||
const booking = await api.bookings.create.call(payload);
|
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
|
if (bookingId) {
|
||||||
// new booking id once it exists. Optional here; the booking detail page
|
await api.bookings.update.call({ id: bookingId, dto: payload, documents });
|
||||||
// remains the catch-all for any docs the user skips.
|
} else {
|
||||||
const documents = form.getValues("documents") ?? {};
|
const booking = await api.bookings.create.call({ payload, documents });
|
||||||
const hasDocuments = Object.values(documents).some((value) =>
|
bookingId = booking.id;
|
||||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
|
||||||
);
|
|
||||||
if (hasDocuments) {
|
|
||||||
await api.bookings.uploadDocuments.call({
|
|
||||||
id: booking.id,
|
|
||||||
files: documents,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return booking;
|
const pricing = await api.bookings.generatePrice.call({ id: bookingId });
|
||||||
|
return { bookingId, pricing, mode };
|
||||||
},
|
},
|
||||||
onSuccess: (booking) => {
|
onSuccess: ({ bookingId, pricing, mode }) => {
|
||||||
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 }) => {
|
|
||||||
setPriceBookingId(bookingId);
|
setPriceBookingId(bookingId);
|
||||||
setPricingData(pricing);
|
setPricingData(pricing);
|
||||||
setPricingPhase("ready");
|
setPriceModalMode(mode);
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
},
|
},
|
||||||
onError: () => {
|
|
||||||
setPricingPhase("idle");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmMutation = useMutation({
|
const confirmMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!priceBookingId) throw new Error("No booking to confirm");
|
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: () => {
|
onSuccess: () => {
|
||||||
|
setPriceChangeResult(null);
|
||||||
|
setPriceModalMode(null);
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
navigate(`/bookings/${priceBookingId}`);
|
navigate(`/bookings/${priceBookingId}`);
|
||||||
},
|
},
|
||||||
@@ -188,22 +186,15 @@ export default function NewBookingPage() {
|
|||||||
return route;
|
return route;
|
||||||
}, [originYard, destinationYard]);
|
}, [originYard, destinationYard]);
|
||||||
|
|
||||||
const docValues = form.watch("documents") ?? {};
|
|
||||||
const hasDocuments = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(docValues).some((value) =>
|
|
||||||
Array.isArray(value) ? value.length > 0 : Boolean(value),
|
|
||||||
),
|
|
||||||
[docValues],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [pricingPhase, setPricingPhase] = useState<
|
|
||||||
"idle" | "generating" | "ready"
|
|
||||||
>("idle");
|
|
||||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [priceBookingId, setPriceBookingId] = useState<string | 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 [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
const [cancelReason, setCancelReason] = useState("");
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
|
||||||
@@ -211,6 +202,14 @@ export default function NewBookingPage() {
|
|||||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||||
if (!valid) return;
|
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));
|
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +264,9 @@ export default function NewBookingPage() {
|
|||||||
)!;
|
)!;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduledDate: new Date().toISOString(),
|
scheduledDate: data.scheduledDate
|
||||||
|
? new Date(data.scheduledDate).toISOString()
|
||||||
|
: new Date().toISOString(),
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
serviceTypeId: data.serviceTypeId,
|
serviceTypeId: data.serviceTypeId,
|
||||||
@@ -313,25 +314,55 @@ export default function NewBookingPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDraftSubmit = form.handleSubmit((data) => {
|
const handleSaveDraft = form.handleSubmit((data) => {
|
||||||
try {
|
try {
|
||||||
const apiPayload = buildApiPayload(data);
|
const apiPayload = buildApiPayload(data);
|
||||||
createMutation.mutate(apiPayload);
|
persistAndPriceMutation.mutate({
|
||||||
|
payload: apiPayload,
|
||||||
|
mode: "draft",
|
||||||
|
existingBookingId: priceBookingId,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// validation error already handled
|
// 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 {
|
try {
|
||||||
const apiPayload = buildApiPayload(data);
|
const apiPayload = buildApiPayload(data);
|
||||||
setPricingPhase("generating");
|
persistAndPriceMutation.mutate({
|
||||||
createAndPriceMutation.mutate(apiPayload);
|
payload: apiPayload,
|
||||||
|
mode: "submit",
|
||||||
|
existingBookingId: priceBookingId,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// validation error already handled
|
// 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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
@@ -376,14 +407,14 @@ export default function NewBookingPage() {
|
|||||||
id="new-booking-form"
|
id="new-booking-form"
|
||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onSubmit={handleDraftSubmit}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<Box flex={1} p="24px">
|
<Box flex={1} p="24px">
|
||||||
<Box mb="lg">
|
<Box mb="lg">
|
||||||
<StepIndicator step={step} />
|
<StepIndicator step={step} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{createMutation.isError && (
|
{persistAndPriceMutation.isError && (
|
||||||
<Alert
|
<Alert
|
||||||
color="red"
|
color="red"
|
||||||
icon={<AlertCircle size={16} />}
|
icon={<AlertCircle size={16} />}
|
||||||
@@ -391,29 +422,11 @@ export default function NewBookingPage() {
|
|||||||
mb="lg"
|
mb="lg"
|
||||||
>
|
>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
Failed to save draft
|
Failed to save booking or generate price
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" mt={4} c="red.7">
|
<Text size="sm" mt={4} c="red.7">
|
||||||
{createMutation.error instanceof Error
|
{persistAndPriceMutation.error instanceof Error
|
||||||
? createMutation.error.message
|
? persistAndPriceMutation.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
|
|
||||||
: "An unexpected error occurred. Please try again."}
|
: "An unexpected error occurred. Please try again."}
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -450,17 +463,16 @@ export default function NewBookingPage() {
|
|||||||
setStep={setStep}
|
setStep={setStep}
|
||||||
direction={direction!}
|
direction={direction!}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
pricingPhase={pricingPhase}
|
onSaveDraft={handleSaveDraft}
|
||||||
pricingData={pricingData}
|
onSubmit={handleSubmitBooking}
|
||||||
onConfirm={() => confirmMutation.mutate()}
|
saveDraftPending={
|
||||||
onContinueLater={
|
persistAndPriceMutation.isPending &&
|
||||||
priceBookingId
|
persistAndPriceMutation.variables?.mode === "draft"
|
||||||
? () => navigate(`/bookings/${priceBookingId}`)
|
}
|
||||||
: undefined
|
submitPending={
|
||||||
|
persistAndPriceMutation.isPending &&
|
||||||
|
persistAndPriceMutation.variables?.mode === "submit"
|
||||||
}
|
}
|
||||||
onAbort={() => setCancelDialogOpen(true)}
|
|
||||||
confirmPending={confirmMutation.isPending}
|
|
||||||
abortPending={abortMutation.isPending}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -501,51 +513,151 @@ export default function NewBookingPage() {
|
|||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
) : pricingPhase === "idle" ? (
|
) : (
|
||||||
<Group>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="submit"
|
color="edr-green"
|
||||||
form="new-booking-form"
|
radius="md"
|
||||||
variant={hasDocuments ? "outline" : "filled"}
|
leftSection={<Send size={16} />}
|
||||||
color="edr-green"
|
onClick={handleSubmitBooking}
|
||||||
radius="md"
|
loading={isPricing}
|
||||||
loading={createMutation.isPending}
|
>
|
||||||
leftSection={
|
Submit
|
||||||
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>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
</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
|
<Modal
|
||||||
opened={cancelDialogOpen}
|
opened={cancelDialogOpen}
|
||||||
onClose={() => setCancelDialogOpen(false)}
|
onClose={() => setCancelDialogOpen(false)}
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ export const STEPS = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shipment documents collected during booking creation. The fileKeys mirror
|
* Shipment documents collected during booking creation. The fileKeys mirror
|
||||||
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts so anything attached
|
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
|
const DOC_SETTING_TS = "2024-01-01T00:00:00.000Z";
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@ function docField(
|
|||||||
fileKey,
|
fileKey,
|
||||||
fileLabel,
|
fileLabel,
|
||||||
helpText: null,
|
helpText: null,
|
||||||
isRequired: false,
|
isRequired: true,
|
||||||
isMultiple: false,
|
isMultiple: false,
|
||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||||
@@ -51,7 +49,7 @@ export const BOOKING_DOCS_SETTING: Freight.IFileUploadSetting = {
|
|||||||
code: "booking_documents",
|
code: "booking_documents",
|
||||||
label: "Booking Documents",
|
label: "Booking Documents",
|
||||||
description:
|
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",
|
entity: "booking",
|
||||||
fields: [
|
fields: [
|
||||||
docField("commercial_invoice", "Commercial Invoice", 1),
|
docField("commercial_invoice", "Commercial Invoice", 1),
|
||||||
|
|||||||
@@ -1,27 +1,46 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
Divider,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Paper,
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
Stack,
|
||||||
|
Table,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from "@mantine/core";
|
} 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 {
|
import {
|
||||||
BookingFormInputValues,
|
|
||||||
BOOKING_DOCS_SETTING,
|
BOOKING_DOCS_SETTING,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
|
type BookingFormInputValues,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { StepHeader } from "./shared";
|
import { StepHeader } from "./shared";
|
||||||
import { ClipboardCheck } from "lucide-react";
|
|
||||||
import type { Freight } from "@/types";
|
export const REVIEW_STEP_TARGETS = {
|
||||||
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
contract: 1,
|
||||||
|
service: 2,
|
||||||
|
route: 3,
|
||||||
|
cargo: 4,
|
||||||
|
schedule: 5,
|
||||||
|
documents: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<
|
type BookingForm = UseFormReturn<
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
@@ -29,113 +48,135 @@ type BookingForm = UseFormReturn<
|
|||||||
BookingFormValues
|
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({
|
export function Step8Review({
|
||||||
form,
|
form,
|
||||||
setStep,
|
setStep,
|
||||||
direction,
|
direction,
|
||||||
referenceData,
|
referenceData,
|
||||||
pricingPhase = "idle",
|
onSaveDraft,
|
||||||
pricingData,
|
onSubmit,
|
||||||
onConfirm,
|
saveDraftPending = false,
|
||||||
onContinueLater,
|
submitPending = false,
|
||||||
onAbort,
|
|
||||||
confirmPending = false,
|
|
||||||
abortPending = false,
|
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
setStep: (step: number) => void;
|
setStep: (step: number) => void;
|
||||||
direction: Freight.ScheduleTradeDirection;
|
direction: Freight.ScheduleTradeDirection;
|
||||||
referenceData?: Freight.BookingReferenceData;
|
referenceData?: Freight.BookingReferenceData;
|
||||||
pricingPhase?: "idle" | "generating" | "ready";
|
onSaveDraft?: () => void;
|
||||||
pricingData?: GeneratePriceResponse | null;
|
onSubmit?: () => void;
|
||||||
onConfirm?: () => void;
|
saveDraftPending?: boolean;
|
||||||
onContinueLater?: () => void;
|
submitPending?: boolean;
|
||||||
onAbort?: () => void;
|
|
||||||
confirmPending?: boolean;
|
|
||||||
abortPending?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const values = form.watch();
|
const values = form.watch();
|
||||||
const serviceType = referenceData?.service.find(
|
const serviceType = referenceData?.service.find(
|
||||||
(s) => s.id === values.serviceTypeId,
|
(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 =
|
const containerSummary =
|
||||||
values.cargoType === "container" && values.containers.length > 0
|
values.cargoType === "container" && values.containers.length > 0
|
||||||
? values.containers
|
? values.containers
|
||||||
.filter((c) => +c.qty > 0)
|
.filter((c) => +c.qty > 0)
|
||||||
.map((c) => `${c.qty} × ${c.type}`)
|
.map((c) => `${c.qty} × ${c.containerType || c.type}`)
|
||||||
.join(", ")
|
.join(", ")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const totalVgm =
|
const totalVgm =
|
||||||
values.cargoType === "container"
|
values.cargoType === "container"
|
||||||
? values.containers.reduce(
|
? values.containers.reduce(
|
||||||
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
|
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
: 0;
|
: Number(values.cargoWeight || 0);
|
||||||
|
|
||||||
const documents = (values.documents ?? {}) as BookingDocuments;
|
const documents = (values.documents ?? {}) as BookingDocuments;
|
||||||
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
|
const docsAttached = BOOKING_DOCS_SETTING.fields.filter((f) => {
|
||||||
const value = documents[f.fileKey];
|
const value = documents[f.fileKey];
|
||||||
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
||||||
}).length;
|
}).length;
|
||||||
const docsTotal = BOOKING_DOCS_SETTING.fields.length;
|
const allDocsReady = hasAllRequiredDocuments(documents);
|
||||||
|
|
||||||
const cargoValue = (() => {
|
const cargoValue = (() => {
|
||||||
if (values.cargoType === "container") return containerSummary;
|
if (values.cargoType === "container") return "Container freight";
|
||||||
if (!referenceData) return "";
|
if (!referenceData) return "";
|
||||||
const path = values.cargoTypePath ?? [];
|
const path = values.cargoTypePath ?? [];
|
||||||
const group = referenceData.cargo_type.find((g) => g.id === path[0]);
|
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;
|
return child ? `${group.name} — ${child.name}` : group.name;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const originYardName = referenceData?.yard.find(
|
const originYardName =
|
||||||
(y) => y.id === values.originYard,
|
referenceData?.yard.find((y) => y.id === values.originYard)?.name ??
|
||||||
)?.name ?? values.originYard;
|
values.originYard;
|
||||||
|
|
||||||
const destinationYardName = referenceData?.yard.find(
|
const destinationYardName =
|
||||||
(y) => y.id === values.destinationYard,
|
referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ??
|
||||||
)?.name ?? values.destinationYard;
|
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 (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="lg">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={<ClipboardCheck size={22} />}
|
icon={<ClipboardCheck size={22} />}
|
||||||
title="Review & Submit"
|
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 */}
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||||
{pricingPhase === "generating" && (
|
{/* Left — booking summary */}
|
||||||
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2">
|
<Stack gap="md" className="min-w-0 flex-1">
|
||||||
<Group justify="center" py="lg">
|
<Paper
|
||||||
<Loader size="sm" />
|
radius={20}
|
||||||
<Text size="sm" c="dimmed">
|
p="lg"
|
||||||
Generating price estimate…
|
className="border border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40"
|
||||||
</Text>
|
>
|
||||||
</Group>
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
</Card>
|
<Stack gap={4}>
|
||||||
)}
|
<Text size="xs" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
|
||||||
|
Booking overview
|
||||||
{pricingPhase === "ready" && pricingData && (
|
</Text>
|
||||||
<Card radius="lg" withBorder p="lg" className="border-edr-green border-2 bg-gradient-to-br from-white to-emerald-50/30">
|
<Text fw={800} size="xl" c="#10202F">
|
||||||
<Stack gap="sm">
|
{values.contractType === "new" ? "New Contract" : "Contract Renewal"}
|
||||||
<Text size="sm" fw={700} tt="uppercase" c="edr-green" className="tracking-wider">
|
</Text>
|
||||||
💳 Price Breakdown
|
<Text size="sm" c="dimmed">
|
||||||
</Text>
|
{serviceType?.name ?? "—"} · {originYardName} → {destinationYardName}
|
||||||
<Stack gap="xs">
|
</Text>
|
||||||
{pricingData.lineItems.map((item) => (
|
</Stack>
|
||||||
<Group key={item.code} justify="space-between" py={2}>
|
<Badge size="lg" variant="light" color="edr-green" radius="md">
|
||||||
<Text size="sm" c="dimmed">
|
{directionLabel}
|
||||||
{item.description}
|
</Badge>
|
||||||
</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>
|
|
||||||
</Group>
|
</Group>
|
||||||
{pricingData.warnings.length > 0 && (
|
</Paper>
|
||||||
<Text size="xs" c="orange.7" mt="xs" p="xs" className="bg-orange-50 rounded">
|
|
||||||
⚠️ {pricingData.warnings.join(", ")}
|
<OverviewSection
|
||||||
</Text>
|
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">
|
<DetailRow label="Service" value={serviceType?.name ?? ""} />
|
||||||
<Button
|
<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
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStep(5)}
|
variant="subtle"
|
||||||
className="shrink-0 text-10px font-medium text-emerald-600 hover:underline whitespace-nowrap ml-2 mt-2"
|
size="compact-xs"
|
||||||
|
color="gray"
|
||||||
|
mt={4}
|
||||||
|
onClick={() => setStep(REVIEW_STEP_TARGETS.service)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit service options
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</OverviewSection>
|
||||||
</CompactCard>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{/* Notes */}
|
<OverviewSection
|
||||||
<Controller
|
icon={<Route size={18} />}
|
||||||
name="notes"
|
title="Route"
|
||||||
control={form.control}
|
onEdit={() => setStep(REVIEW_STEP_TARGETS.route)}
|
||||||
render={({ field }) => (
|
>
|
||||||
<Textarea
|
<DetailRow
|
||||||
{...field}
|
label="Corridor"
|
||||||
id="notes"
|
value={`${originYardName} → ${destinationYardName}`}
|
||||||
label="Additional Notes"
|
/>
|
||||||
placeholder="Any special instructions or notes for EDR operations…"
|
<DetailRow label="Trade direction" value={directionLabel} />
|
||||||
rows={2}
|
<DetailRow label="Shipping line" value={values.shippingLine || "—"} />
|
||||||
radius="md"
|
<DetailRow
|
||||||
size="sm"
|
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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
BookingListFilter,
|
BookingListFilter,
|
||||||
CreateBookingPayload,
|
CreateBookingPayload,
|
||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
|
SubmitBookingResponse,
|
||||||
} from "./bookings.service";
|
} from "./bookings.service";
|
||||||
|
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||||
import {
|
import {
|
||||||
paymentsService,
|
paymentsService,
|
||||||
InitiatePaymentPayload,
|
InitiatePaymentPayload,
|
||||||
@@ -147,16 +149,23 @@ export const api = {
|
|||||||
({ id }) => bookingsService.tracking(id),
|
({ id }) => bookingsService.tracking(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
create: endpoint<CreateBookingPayload, Freight.IBooking>(
|
create: endpoint<
|
||||||
"bookings",
|
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
||||||
"create",
|
Freight.IBooking
|
||||||
bookingsService.create,
|
>("bookings", "create", ({ payload, documents }) =>
|
||||||
|
bookingsService.create(payload, documents),
|
||||||
),
|
),
|
||||||
|
|
||||||
update: endpoint<
|
update: endpoint<
|
||||||
{ id: string; dto: Partial<CreateBookingPayload> },
|
{
|
||||||
|
id: string;
|
||||||
|
dto: Partial<CreateBookingPayload>;
|
||||||
|
documents?: BookingDocuments;
|
||||||
|
},
|
||||||
{ booking: Freight.IBooking; warnings: string[] }
|
{ 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>(
|
referenceData: endpoint<void, Freight.BookingReferenceData>(
|
||||||
"bookings",
|
"bookings",
|
||||||
@@ -180,12 +189,18 @@ export const api = {
|
|||||||
({ id }) => bookingsService.generatePrice(id),
|
({ id }) => bookingsService.generatePrice(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
submit: endpoint<{ id: string }, Freight.IBooking>(
|
submit: endpoint<{ id: string }, SubmitBookingResponse>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"submit",
|
"submit",
|
||||||
({ id }) => bookingsService.submit(id),
|
({ id }) => bookingsService.submit(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
confirmSubmit: endpoint<{ id: string }, SubmitBookingResponse>(
|
||||||
|
"bookings",
|
||||||
|
"confirmSubmit",
|
||||||
|
({ id }) => bookingsService.confirmSubmit(id),
|
||||||
|
),
|
||||||
|
|
||||||
uploadDocuments: endpoint<
|
uploadDocuments: endpoint<
|
||||||
{ id: string; files: Record<string, File | File[] | null> },
|
{ id: string; files: Record<string, File | File[] | null> },
|
||||||
Freight.IBooking
|
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 type { Freight, PaginatedResponse } from "@edr/types";
|
||||||
|
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
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";
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
const B = URL_CONSTANTS.BOOKINGS;
|
const B = URL_CONSTANTS.BOOKINGS;
|
||||||
@@ -45,6 +47,17 @@ export interface GeneratePriceResponse {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubmitBookingResponse {
|
||||||
|
bookingId: string;
|
||||||
|
status: string;
|
||||||
|
priceChanged: boolean;
|
||||||
|
previousTotalAmount?: number;
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
lineItems?: PriceLineItem[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignContractPayload {
|
export interface SignContractPayload {
|
||||||
role: "CUSTOMER" | "STAFF";
|
role: "CUSTOMER" | "STAFF";
|
||||||
signatureImageBase64: string;
|
signatureImageBase64: string;
|
||||||
@@ -77,8 +90,14 @@ export const bookingsService = {
|
|||||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
create: async (
|
||||||
const { data } = await client.post("/api/bookings", payload);
|
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;
|
return data.data.booking;
|
||||||
},
|
},
|
||||||
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
|
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
|
||||||
@@ -88,8 +107,12 @@ export const bookingsService = {
|
|||||||
update: async (
|
update: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: Partial<CreateBookingPayload>,
|
payload: Partial<CreateBookingPayload>,
|
||||||
|
documents?: BookingDocuments,
|
||||||
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
|
): 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;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -107,11 +130,16 @@ export const bookingsService = {
|
|||||||
return data.data;
|
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`);
|
const { data } = await client.post(`/api/bookings/${id}/submit`);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
confirmSubmit: async (id: string): Promise<SubmitBookingResponse> => {
|
||||||
|
const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
uploadDocuments: async (
|
uploadDocuments: async (
|
||||||
id: string,
|
id: string,
|
||||||
files: Record<string, File | File[] | null>,
|
files: Record<string, File | File[] | null>,
|
||||||
|
|||||||
Reference in New Issue
Block a user