mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat(bookings): implement booking editing functionality
This commit is contained in:
@@ -32,6 +32,7 @@ import MyBookings from "./pages/bookings/MyBookings";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
import BillingPage from "./pages/billing/BillingPage";
|
||||
import { useEffect } from "react";
|
||||
@@ -106,6 +107,7 @@ const App = () => {
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
<Route path="/bookings" element={<MyBookings />} />
|
||||
<Route path="/bookings/new" element={<NewBookingPage />} />
|
||||
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
|
||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||
<Route path="/bookings/:id/contract" element={<BookingContractPage />} />
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
|
||||
@@ -403,20 +403,30 @@ function DraftBookingView({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmitRequest}
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
{submitMutation.isPending ? (
|
||||
<LoaderCircle className="animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 />
|
||||
)}
|
||||
{submitMutation.isPending
|
||||
? "Submitting..."
|
||||
: "Confirm Booking Request"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmitRequest}
|
||||
disabled={submitMutation.isPending}
|
||||
>
|
||||
{submitMutation.isPending ? (
|
||||
<LoaderCircle className="animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 />
|
||||
)}
|
||||
{submitMutation.isPending
|
||||
? "Submitting..."
|
||||
: "Confirm Booking Request"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LoaderCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CreateBookingPayload,
|
||||
} from "@/services/bookings.service";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
STEPS,
|
||||
bookingFormSchema,
|
||||
getRouteDirection,
|
||||
initialBookingFormValues,
|
||||
stepFields,
|
||||
type BookingFormValues,
|
||||
type RouteDirection,
|
||||
} from "./new-booking-form/schema";
|
||||
import { StepIndicator } from "./new-booking-form/StepIndicator";
|
||||
import {
|
||||
Step1ContractType,
|
||||
Step2ServiceType,
|
||||
Step4Route,
|
||||
Step5CargoDetails,
|
||||
Step8Review,
|
||||
} from "./new-booking-form/steps";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string {
|
||||
return yard?.label ?? yard?.name ?? yard?.code ?? "";
|
||||
}
|
||||
|
||||
function mapBookingToFormValues(
|
||||
booking: Freight.IBooking,
|
||||
referenceData: Freight.BookingReferenceData,
|
||||
): BookingFormInputValues {
|
||||
const vals: BookingFormInputValues = {
|
||||
...initialBookingFormValues,
|
||||
contractType: (booking.contractType?.toLowerCase() as "new" | "renewal") ?? "new",
|
||||
previousContractRef: booking.previousContractId ?? "",
|
||||
serviceType:
|
||||
booking.serviceType === "RAIL_AND_FORWARDING" ? "rail_forwarding" : "rail",
|
||||
firstMile: {
|
||||
enabled: booking.firstMileEnabled ?? false,
|
||||
pickUpAddress: booking.firstMilePickupAddress ?? "",
|
||||
},
|
||||
lastMile: {
|
||||
enabled: booking.lastMileEnabled ?? false,
|
||||
deliveryAddress: booking.lastMileDeliveryAddress ?? "",
|
||||
},
|
||||
equipmentReturn:
|
||||
booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return",
|
||||
originYard: yardNameFromBooking(booking.originYard),
|
||||
destinationYard: yardNameFromBooking(booking.destinationYard),
|
||||
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,
|
||||
notes: "",
|
||||
termsAccepted: false,
|
||||
freightType: "",
|
||||
bulkCommoditytype: "",
|
||||
containers: [],
|
||||
};
|
||||
|
||||
const bookingCargoTypeId = (booking as any).cargoTypeId as string | undefined;
|
||||
if (booking.freightType === "BULK" && bookingCargoTypeId) {
|
||||
for (const group of referenceData.cargo_type) {
|
||||
const child = group.children?.find((c) => c.id === bookingCargoTypeId);
|
||||
if (child) {
|
||||
vals.freightType = group.code.toLowerCase();
|
||||
vals.bulkCommoditytype = child.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (booking.freightType === "CONTAINER" && booking.containers && booking.containers.length > 0) {
|
||||
vals.containers = booking.containers.map((c) => ({
|
||||
type: c.type === "40ft" ? "40ft" : "20ft" as const,
|
||||
containerType: "",
|
||||
qty: String(c.qty),
|
||||
vgm: String(c.vgm),
|
||||
}));
|
||||
}
|
||||
|
||||
return vals;
|
||||
}
|
||||
|
||||
export default function EditBookingPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
const bookingQuery = useQuery(
|
||||
api.bookings.get.queryOptions({
|
||||
input: { id: id! },
|
||||
enabled: !!id,
|
||||
}),
|
||||
);
|
||||
|
||||
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||
api.bookings.referenceData.queryOptions({
|
||||
enabled: !!bookingQuery.data,
|
||||
}),
|
||||
);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: Partial<CreateBookingPayload>) =>
|
||||
api.bookings.update.call({ id: id!, dto: payload }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) });
|
||||
navigate(`/bookings/${id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const booking = bookingQuery.data;
|
||||
|
||||
const formValues = useMemo((): BookingFormInputValues | undefined => {
|
||||
if (!booking || !referenceData) return undefined;
|
||||
return mapBookingToFormValues(booking, referenceData);
|
||||
}, [booking, referenceData]);
|
||||
|
||||
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
|
||||
defaultValues: initialBookingFormValues,
|
||||
values: formValues,
|
||||
resolver: zodResolver(bookingFormSchema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
|
||||
const direction: RouteDirection = useMemo(
|
||||
() => getRouteDirection(originYard, destinationYard),
|
||||
[originYard, destinationYard],
|
||||
);
|
||||
|
||||
async function handleContinue() {
|
||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||
if (!valid) return;
|
||||
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
|
||||
}
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
const yards = referenceData?.yard ?? [];
|
||||
const services = referenceData?.service ?? [];
|
||||
const shippingLines = referenceData?.shipping_line ?? [];
|
||||
const cargoTree = referenceData?.cargo_type ?? [];
|
||||
const containerGroups = referenceData?.containers ?? [];
|
||||
|
||||
const findYardId = (name: string): string =>
|
||||
yards.find((y) => y.name === name)?.id ?? "";
|
||||
|
||||
const findServiceTypeId = (): string => {
|
||||
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
|
||||
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
|
||||
};
|
||||
|
||||
const findShippingLineId = (name: string): string | undefined =>
|
||||
shippingLines.find((l) => l.name === name)?.id;
|
||||
|
||||
const selectedChild =
|
||||
data.cargoType !== "container" && data.bulkCommoditytype
|
||||
? cargoTree
|
||||
.find((g) => g.code.toLowerCase() === data.freightType)
|
||||
?.children?.find((c) => c.name === data.bulkCommoditytype)
|
||||
: undefined;
|
||||
|
||||
const cargoTypeId =
|
||||
data.cargoType === "container"
|
||||
? undefined
|
||||
: selectedChild?.id ?? "";
|
||||
|
||||
const findContainerTypeId = (name: string): string => {
|
||||
for (const group of containerGroups) {
|
||||
const ct = group.types.find((t) => t.name === name);
|
||||
if (ct) return ct.id;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const totalWeight =
|
||||
data.cargoType === "container"
|
||||
? data.containers.reduce(
|
||||
(acc, c) => acc + Number(c.vgm || 0) * Number(c.qty || 0),
|
||||
0,
|
||||
)
|
||||
: Number(data.cargoWeight || 0);
|
||||
|
||||
const apiPayload: Partial<CreateBookingPayload> = {
|
||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
serviceTypeId: findServiceTypeId(),
|
||||
equipmentReturn:
|
||||
data.equipmentReturn === "with_return"
|
||||
? "WITH_RETURN"
|
||||
: "WITHOUT_RETURN",
|
||||
originYardId: findYardId(data.originYard),
|
||||
destinationYardId: findYardId(data.destinationYard),
|
||||
tradeDirection:
|
||||
direction === "export"
|
||||
? "EXPORT"
|
||||
: direction === "domestic"
|
||||
? "DOMESTIC"
|
||||
: "IMPORT",
|
||||
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: data.isHazardous,
|
||||
paymentCurrency: "USD",
|
||||
allowConsolidation: data.consolidationEnabled,
|
||||
// @ts-ignore
|
||||
freightType:
|
||||
data.cargoType === "container"
|
||||
? ("CONTAINER" as const)
|
||||
: ("BULK" as const),
|
||||
containers:
|
||||
data.cargoType === "container"
|
||||
? data.containers.map((c) => ({
|
||||
containerTypeId: findContainerTypeId(c.containerType),
|
||||
quantity: Number(c.qty || 1),
|
||||
vgmPerUnitTons: Number(c.vgm || 0),
|
||||
}))
|
||||
: [],
|
||||
...(data.previousContractRef
|
||||
? { previousContractId: data.previousContractRef }
|
||||
: {}),
|
||||
...(data.contractType === "renewal" && data.previousContractRef
|
||||
? { pnrCode: data.previousContractRef }
|
||||
: {}),
|
||||
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
|
||||
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
|
||||
: {}),
|
||||
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
|
||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||
: {}),
|
||||
...(data.shippingLine
|
||||
? { shippingLineId: findShippingLineId(data.shippingLine) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
updateMutation.mutate(apiPayload);
|
||||
});
|
||||
|
||||
if (bookingQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bookingQuery.isError || !booking) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-destructive/20 bg-destructive/10 p-12 text-center">
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<h2 className="text-lg font-bold text-foreground">Failed to load booking</h2>
|
||||
<Button variant="outline" onClick={() => navigate("/bookings")}>
|
||||
Back to Bookings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!formValues) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
id="edit-booking-form"
|
||||
className="flex flex-col"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="sticky top-0 z-20">
|
||||
<div className="mx-auto max-w-4xl space-y-3 px-6 pt-4">
|
||||
<StepIndicator step={step} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
{updateMutation.isError && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="font-semibold">Failed to save changes</p>
|
||||
<p className="mt-1 text-red-600">
|
||||
{updateMutation.error instanceof Error
|
||||
? updateMutation.error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && <Step1ContractType form={form} />}
|
||||
{step === 2 && <Step2ServiceType form={form} />}
|
||||
{step === 3 && (
|
||||
<Step4Route
|
||||
form={form}
|
||||
referenceData={referenceData}
|
||||
isLoading={refDataLoading}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<Step5CargoDetails
|
||||
form={form}
|
||||
direction={direction}
|
||||
referenceData={referenceData}
|
||||
isLoading={refDataLoading}
|
||||
/>
|
||||
)}
|
||||
{step === 5 && (
|
||||
<Step8Review form={form} setStep={setStep} direction={direction} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-20 border-t border-border bg-background px-6 py-4">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setStep((currentStep) => Math.max(1, currentStep - 1))}
|
||||
disabled={step === 1}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
{step < STEPS.length ? (
|
||||
<Button type="button" onClick={handleContinue}>
|
||||
Continue
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-booking-form"
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check />
|
||||
)}
|
||||
{updateMutation.isPending ? "Saving Changes..." : "Save Changes"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -138,10 +138,7 @@ export default function NewBookingPage() {
|
||||
const cargoTypeId =
|
||||
data.cargoType === "container"
|
||||
? findContainerCargoTypeId()
|
||||
: (findCargoTypeId(data.bulkCommoditytype) ??
|
||||
cargoTree.find((g) => g.code.toLowerCase() === data.freightType)
|
||||
?.id ??
|
||||
"");
|
||||
: selectedChild?.id ?? "";
|
||||
|
||||
const cargoFreeText =
|
||||
data.cargoType === "container"
|
||||
|
||||
@@ -134,6 +134,11 @@ export const api = {
|
||||
bookingsService.create,
|
||||
),
|
||||
|
||||
update: endpoint<
|
||||
{ id: string; dto: Partial<CreateBookingPayload> },
|
||||
{ booking: Freight.IBooking; warnings: string[] }
|
||||
>("bookings", "update", ({ id, dto }) => bookingsService.update(id, dto)),
|
||||
|
||||
referenceData: endpoint<void, Freight.BookingReferenceData>(
|
||||
"bookings",
|
||||
"referenceData",
|
||||
|
||||
@@ -74,6 +74,14 @@ export const bookingsService = {
|
||||
const { data } = await client.get("/api/bookings/reference-data");
|
||||
return data.data;
|
||||
},
|
||||
update: async (
|
||||
id: string,
|
||||
payload: Partial<CreateBookingPayload>,
|
||||
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
|
||||
const { data } = await client.patch(`/api/bookings/${id}`, payload);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
remove: async (id: string): Promise<void> => {
|
||||
await client.delete(`/api/bookings/${id}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user