refactor(bookings): Migrate new booking API payload to use reference data IDs

This commit is contained in:
ghost2023
2026-06-01 16:37:28 +03:00
parent 217f1bf097
commit a267526be5
9 changed files with 218 additions and 108 deletions

View File

@@ -3,13 +3,21 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { Check, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
import {
AlertCircle,
Check,
CheckCircle2,
ChevronLeft,
ChevronRight,
LoaderCircle,
} from "lucide-react";
import { Button } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import Breadcrumbs from "@/components/Breadcrumbs";
import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
BookingFormInputValues,
STEPS,
bookingFormSchema,
calcWagons,
@@ -46,7 +54,7 @@ export default function NewBookingPage() {
},
});
const form = useForm<BookingFormValues>({
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema),
mode: "onChange",
@@ -77,8 +85,6 @@ export default function NewBookingPage() {
return;
}
const reference = data.previousContractRef;
const totalWeight =
data.cargoType === "container"
? data.containers.reduce(
@@ -87,59 +93,115 @@ export default function NewBookingPage() {
)
: Number(data.cargoWeight || 0);
const apiPayload = {
reference,
customerId: customer!.id,
scheduledDate: new Date().toISOString().slice(0, 10),
totalAmount: 0,
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
previousContractId: data.previousContractRef || undefined,
serviceType:
data.service.serviceType === "rail"
? "RAIL_ONLY"
: "RAIL_AND_FORWARDING",
...(data.service.serviceType === "rail"
? {}
: {
firstMileEnabled: data.firstMile.enabled,
firstMilePickupAddress: data.firstMile.pickUpAddress ?? undefined,
lastMileEnabled: data.lastMile.enabled,
lastMileDeliveryAddress: data.lastMile.deliveryAddress ?? undefined,
equipmentReturn:
data.equipmentReturn === "with_return"
? ("WITH_RETURN" as const)
: ("WITHOUT_RETURN" as const),
customsClearingEnabled: data.customsClearingEnabled,
}),
originStation: data.originYard,
destinationStation: data.destinationYard,
cargoTotalWeightVgm: totalWeight,
freightType: data.cargoType === "container" ? "BREAK_BULK" : "BULK",
freightSubtype:
data.cargoType === "container"
? undefined
: data.freightType === "bulk"
// ── Reference data lookups ──────────────────────────────────────────
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 findCargoTypeId = (name: string): string | undefined => {
for (const group of cargoTree) {
const child = group.children?.find((c) => c.name === name);
if (child) return child.id;
}
return undefined;
};
const findContainerCargoTypeId = (): string => {
const group = cargoTree.find(
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
);
console.log(group, cargoTree);
return group?.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 cargoTypeId =
data.cargoType === "container"
? findContainerCargoTypeId()
: (findCargoTypeId(
data.freightType === "bulk"
? data.bulkCommodity
: data.breakBulkType,
isHazardous: data.isHazardous,
isRefrigerated: data.isRefrigerated,
) ?? "");
const cargoFreeText =
data.cargoType === "container"
? undefined
: data.freightType === "bulk" && data.bulkCommodity === "Others"
? data.bulkCommodityOther
: data.freightType === "break_bulk" && data.breakBulkType === "Others"
? data.breakBulkTypeOther
: undefined;
// ── Build API payload ───────────────────────────────────────────────
const apiPayload: 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:
getRouteDirection(data.originYard, data.destinationYard) === "export"
direction === "export"
? "EXPORT"
: "IMPORT",
: direction === "domestic"
? "DOMESTIC"
: "IMPORT",
cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: "USD",
allowConsolidation: data.consolidationEnabled,
...(data.cargoType === "container" && data.containers.length > 0
? {
containers: data.containers.map((c) => ({
type: c.type === "40ft" ? ("40FT" as const) : ("20FT" as const),
qty: Number(c.qty || 1),
vgm: Number(c.vgm || 0),
})),
}
containers:
data.cargoType === "container"
? data.containers.map((c) => ({
containerTypeId: findContainerTypeId(c.containerType),
quantity: Number(c.qty || 1),
vgmPerUnitTons: Number(c.vgm || 0),
}))
: [],
...(customer ? { customerId: customer.id } : {}),
...(data.previousContractRef
? { previousContractId: data.previousContractRef }
: {}),
} satisfies CreateBookingPayload;
...(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) }
: {}),
...(cargoFreeText ? { cargoFreeText } : {}),
};
createMutation.mutate(apiPayload);
});
@@ -178,10 +240,27 @@ export default function NewBookingPage() {
<div className="flex-1">
<div className="mx-auto max-w-4xl px-6 py-8">
{createMutation.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">Submission failed</p>
<p className="mt-1 text-red-600">
{createMutation.error instanceof Error
? createMutation.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} />
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails
@@ -216,9 +295,19 @@ export default function NewBookingPage() {
<ChevronRight />
</Button>
) : (
<Button type="submit" form="new-booking-form">
<Check />
Submit Contract Request
<Button
type="submit"
form="new-booking-form"
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Check />
)}
{createMutation.isPending
? "Submitting..."
: "Submit Contract Request"}
</Button>
)}
</div>

View File

@@ -249,6 +249,7 @@ export const bookingFormSchema = z
});
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
previousContractRef: "",

View File

@@ -21,7 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from "@edr/ui-common";
import type { BookingFormValues } from "./schema";
import type { BookingFormInputValues, BookingFormValues } from "./schema";
import { cn } from "@/lib/utils";
export function OptionFieldError({ error }: { error?: { message?: string } }) {
@@ -122,7 +122,7 @@ export function SelectField({
disabled,
children,
}: {
field: ControllerRenderProps<BookingFormValues>;
field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError;
label: string;
placeholder: string;

View File

@@ -1,7 +1,11 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, RefreshCw } from "lucide-react";
import { Field } from "@edr/ui-common";
import { MOCK_VALID_CONTRACTS, type BookingFormValues } from "./schema";
import {
BookingFormInputValues,
MOCK_VALID_CONTRACTS,
type BookingFormValues,
} from "./schema";
import {
AlertBox,
OptionCard,
@@ -11,7 +15,11 @@ import {
StepHeader,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step1ContractType({ form }: { form: BookingForm }) {
const contractType = form.watch("contractType");

View File

@@ -2,10 +2,14 @@ import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Package, Train, Truck } from "lucide-react";
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
import { type BookingFormValues } from "./schema";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step2ServiceType({ form }: { form: BookingForm }) {
const serviceType = form.watch("serviceType");

View File

@@ -4,16 +4,17 @@ import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
type BookingFormValues,
getRouteDirection,
} from "./schema";
import {
SelectField,
StepHeader,
StepLabel,
} from "./shared";
import { SelectField, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step4Route({
form,
@@ -115,13 +116,13 @@ export function Step4Route({
/>
</div>
{direction && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
>
<MapPin className="h-3.5 w-3.5 shrink-0" />
{directionLabel[direction]}
</div>
)}
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
>
<MapPin className="h-3.5 w-3.5 shrink-0" />
{directionLabel[direction]}
</div>
)}
</div>
)}

View File

@@ -1,9 +1,17 @@
import { useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import { Button, Field, FieldError, FieldLabel, Input, Skeleton } from "@edr/ui-common";
import {
Button,
Field,
FieldError,
FieldLabel,
Input,
Skeleton,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
calcWagons,
type BookingFormValues,
type RouteDirection,
@@ -17,7 +25,11 @@ import {
StepLabel,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step5CargoDetails({
form,
@@ -50,8 +62,8 @@ export function Step5CargoDetails({
const bulkCommodityOptions = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.flatMap((group) =>
group.children?.map((c) => c.name) ?? [],
return referenceData.cargo_type.flatMap(
(group) => group.children?.map((c) => c.name) ?? [],
);
}, [referenceData]);

View File

@@ -11,13 +11,17 @@ import {
Textarea,
} from "@edr/ui-common";
import {
BookingFormInputValues,
type BookingFormValues,
type RouteDirection,
type WagonCalcResult,
} from "./schema";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step8Review({
form,

View File

@@ -274,45 +274,36 @@ export interface BookingReferenceData {
// ── DTOs ───────────────────────────────────────────────────────────────────────
export interface CreateBookingContainerDto {
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
}
export interface CreateBookingDto {
reference: string;
customerId: string;
reference?: string;
customerId?: string;
trainId?: string;
scheduledDate: string;
totalAmount: number;
paymentStatus?: string;
contractType: "NEW" | "RENEWAL";
previousContractId?: string;
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
firstMileEnabled?: boolean;
serviceTypeId: string;
firstMilePickupAddress?: string;
lastMileEnabled?: boolean;
lastMileDeliveryAddress?: string;
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
customsClearingEnabled?: boolean;
originStation: string;
destinationStation: string;
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA";
originYardId: string;
destinationYardId: string;
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
cargoTypeId: string;
cargoFreeText?: string;
shippingLineId?: string;
cargoTotalWeightVgm: number;
freightType: "BULK" | "BREAK_BULK";
freightSubtype?: string;
isHazardous?: boolean;
isRefrigerated?: boolean;
tradeDirection: "IMPORT" | "EXPORT";
paymentCurrency: string;
allowConsolidation?: boolean;
paymentCurrency: "ETB" | "USD";
pnrCode?: string;
startDate?: string;
endDate?: string;
financialTerms?: string;
containers?: Array<{
type: "20FT" | "40FT";
qty: number;
vgm: number;
}>;
containers: CreateBookingContainerDto[];
allowConsolidation?: boolean;
}