mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 14:50:57 +00:00
refactor(bookings): Migrate new booking API payload to use reference data IDs
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user