feat(bookings): implement booking editing functionality

This commit is contained in:
ghost2023
2026-06-06 11:13:46 +03:00
parent 4994e19d57
commit af6804eb91
6 changed files with 414 additions and 18 deletions

View File

@@ -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 />} />

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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",

View File

@@ -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}`);
},