feat(bookings): Implement data-driven service type selection and configuration

This commit is contained in:
ghost2023
2026-06-13 12:26:06 +03:00
parent 44aef8c1b3
commit 3e2e07bb5f
4 changed files with 264 additions and 243 deletions

View File

@@ -106,16 +106,10 @@ export default function NewBookingPage() {
: Number(data.cargoWeight || 0); : Number(data.cargoWeight || 0);
// ── Reference data lookups ────────────────────────────────────────── // ── Reference data lookups ──────────────────────────────────────────
const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? []; const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? []; const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? []; const containerGroups = referenceData?.containers ?? [];
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 => const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id; shippingLines.find((l) => l.name === name)?.id;
@@ -127,32 +121,32 @@ export default function NewBookingPage() {
return ""; return "";
}; };
const selectedChild = const bulkChild = cargoTree
data.cargoType !== "container" && data.bulkCommoditytype .flatMap((g) => g.children ?? [])
? cargoTree .find((c) => c.id === data.bulkCommoditytype);
.find((g) => g.code.toLowerCase() === data.freightType)
?.children?.find((c) => c.name === data.bulkCommoditytype)
: undefined;
const cargoTypeId = selectedChild?.id; const cargoTypeId =
data.cargoType === "bulk" ? data.bulkCommoditytype : undefined;
const cargoFreeText = const cargoFreeText = bulkChild?.show_free_text_box
data.cargoType === "container" ? data.cargoFreeText
? undefined : undefined;
: selectedChild?.show_free_text_box
? data.cargoFreeText const serviceType = referenceData?.service.find(
: undefined; (s) => s.id === data.serviceTypeId,
)!;
// ── Build API payload ─────────────────────────────────────────────── // ── Build API payload ───────────────────────────────────────────────
const apiPayload: CreateBookingPayload = { const apiPayload: CreateBookingPayload = {
scheduledDate: new Date().toISOString(), scheduledDate: new Date().toISOString(),
contractType: contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"], data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: findServiceTypeId(), serviceTypeId: data.serviceTypeId,
equipmentReturn: equipmentReturn:
data.equipmentReturn === "with_return" data.equipmentReturn === "with_return"
? "WITH_RETURN" ? "WITH_RETURN"
: "WITHOUT_RETURN", : "WITHOUT_RETURN",
paymentCurrency: "USD",
originYardId: data.originYard, originYardId: data.originYard,
destinationYardId: data.destinationYard, destinationYardId: data.destinationYard,
tradeDirection: direction!, tradeDirection: direction!,
@@ -180,10 +174,10 @@ export default function NewBookingPage() {
...(data.contractType === "renewal" && data.previousContractRef ...(data.contractType === "renewal" && data.previousContractRef
? { pnrCode: data.previousContractRef } ? { pnrCode: data.previousContractRef }
: {}), : {}),
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled ...(serviceType.includesFirstMile && data.firstMile.enabled
? { firstMilePickupAddress: data.firstMile.pickUpAddress } ? { firstMilePickupAddress: data.firstMile.pickUpAddress }
: {}), : {}),
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled ...(serviceType.includesLastMile && data.lastMile.enabled
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress } ? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}), : {}),
...(data.shippingLine ...(data.shippingLine
@@ -265,7 +259,9 @@ export default function NewBookingPage() {
)} )}
{step === 1 && <Step1ContractType form={form} />} {step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />} {step === 2 && (
<Step2ServiceType referenceData={referenceData} form={form} />
)}
{step === 3 && ( {step === 3 && (
<Step4Route <Step4Route
form={form} form={form}

View File

@@ -74,7 +74,7 @@ export const bookingFormSchema = z
.object({ .object({
contractType: z.enum(["new", "renewal"], "Select a contract type."), contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(), previousContractRef: z.string(),
serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."), serviceTypeId: z.string("Select a service type."),
firstMile: z firstMile: z
.object({ .object({
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
@@ -247,7 +247,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = { export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"], 1: ["contractType", "previousContractRef"],
2: [ 2: [
"serviceType", "serviceTypeId",
"firstMile", "firstMile",
"lastMile", "lastMile",
"equipmentReturn", "equipmentReturn",

View File

@@ -1,18 +1,52 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Package, Train, Truck } from "lucide-react"; import { FileText, Train, Truck } from "lucide-react";
import { Badge, Switch, TextInput } from "@mantine/core"; import { Switch, TextInput } from "@mantine/core";
import { BookingFormInputValues, type BookingFormValues } from "./schema"; import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared"; import { OptionCard, OptionFieldError, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>; import type { Freight } from "@edr/types";
export function Step2ServiceType({ form }: { form: BookingForm }) { type BookingForm = UseFormReturn<
const serviceType = form.watch("serviceType"); BookingFormInputValues,
any,
BookingFormValues
>;
export function Step2ServiceType({
form,
referenceData,
}: {
form: BookingForm;
referenceData?: Freight.BookingReferenceData;
}) {
const serviceTypeId = form.watch("serviceTypeId");
const serviceType = referenceData?.service.find(
(s) => s.id === serviceTypeId,
);
const { includesCustoms, includesFirstMile, includesLastMile } =
serviceType ?? {};
const firstMileEnabled = form.watch("firstMile.enabled"); const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled"); const lastMileEnabled = form.watch("lastMile.enabled");
const prevServiceType = useRef(serviceType); const prevServiceType = useRef(serviceType);
useEffect(() => {
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "" },
{ shouldValidate: true },
);
}, [includesFirstMile]);
useEffect(() => {
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "" },
{ shouldValidate: true },
);
}, [includesLastMile]);
useEffect(() => { useEffect(() => {
const prev = prevServiceType.current; const prev = prevServiceType.current;
@@ -20,35 +54,12 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
if (!prev || prev === serviceType) return; if (!prev || prev === serviceType) return;
if (serviceType === "rail") { if (!includesCustoms)
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "" },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "" },
{ shouldDirty: true, shouldValidate: true },
);
form.setValue("equipmentReturn", "with_return", { shouldDirty: true });
form.setValue("customsClearingEnabled", false, { shouldDirty: true }); form.setValue("customsClearingEnabled", false, { shouldDirty: true });
} else if (serviceType === "rail_forwarding") { }, [serviceTypeId, form]);
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "" },
{ shouldDirty: false, shouldValidate: false },
);
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "" },
{ shouldDirty: false, shouldValidate: false },
);
}
}, [serviceType, form]);
const showServiceSections = serviceType === "rail_forwarding";
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<StepHeader <StepHeader
@@ -57,44 +68,29 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
/> />
<Controller <Controller
name="serviceType" name="serviceTypeId"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div> <div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<OptionCard {referenceData?.service
selected={serviceType === "rail"} .filter((s) => s.canBeBookedAlone)
onClick={() => field.onChange("rail")} .map((s) => {
> return (
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100"> <OptionCard
<Train className="h-4 w-4 text-indigo-600" /> selected={field.value === s.id}
</div> onClick={() => field.onChange(s.id)}
<p className="font-semibold">Rail Transport Only</p> >
<p className="mt-0.5 text-xs text-gray-500"> <div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
Rail transport along the EDR corridor, with optional <Train className="h-4 w-4 text-indigo-600" />
first/last mile trucking. </div>
</p> <p className="font-semibold">{s.serviceName}</p>
<Badge color="indigo" variant="light" mt="xs" size="sm"> <p className="mt-0.5 text-xs text-gray-500">
Option A {s.description}
</Badge> </p>
</OptionCard> </OptionCard>
);
<OptionCard })}
selected={serviceType === "rail_forwarding"}
onClick={() => field.onChange("rail_forwarding")}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-emerald-600" />
</div>
<p className="font-semibold">Logistics</p>
<p className="mt-0.5 text-xs text-gray-500">
Rail transport plus documentation, customs liaison, and a
dedicated coordinator.
</p>
<Badge color="edr-green" variant="light" mt="xs" size="sm">
Option B
</Badge>
</OptionCard>
</div> </div>
<OptionFieldError error={fieldState.error} /> <OptionFieldError error={fieldState.error} />
</div> </div>
@@ -104,112 +100,120 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
{showServiceSections && ( {showServiceSections && (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200"> <div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{/* First Mile */} {/* First Mile */}
<div className="p-4"> {includesFirstMile && (
<Controller <div className="p-4">
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">First Mile Pick-up</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
color="edr-green"
/>
</div>
)}
/>
{firstMileEnabled && (
<Controller <Controller
name="firstMile.pickUpAddress" name="firstMile.enabled"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field }) => (
<TextInput <div className="flex items-start justify-between gap-4">
{...field} <div className="flex items-start gap-3">
mt="sm" <Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
placeholder="Pick-up address *" <div>
error={fieldState.error?.message} <p className="text-sm font-medium">
radius="md" First Mile Pick-up
/> </p>
<p className="mt-0.5 text-xs text-gray-500">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
color="edr-green"
/>
</div>
)} )}
/> />
)} {firstMileEnabled && (
</div> <Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius="md"
/>
)}
/>
)}
</div>
)}
{/* Last Mile */} {/* Last Mile */}
<div className="p-4"> {includesLastMile && (
<Controller <div className="p-4">
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">Last Mile Delivery</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck delivery from the destination rail yard to the
final address (Port to Door).
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
color="edr-green"
/>
</div>
)}
/>
{lastMileEnabled && (
<Controller <Controller
name="lastMile.deliveryAddress" name="lastMile.enabled"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field }) => (
<TextInput <div className="flex items-start justify-between gap-4">
{...field} <div className="flex items-start gap-3">
mt="sm" <Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
placeholder="Delivery address *" <div>
error={fieldState.error?.message} <p className="text-sm font-medium">
radius="md" Last Mile Delivery
/> </p>
<p className="mt-0.5 text-xs text-gray-500">
Truck delivery from the destination rail yard to the
final address (Port to Door).
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
color="edr-green"
/>
</div>
)} )}
/> />
)} {lastMileEnabled && (
</div> <Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius="md"
/>
)}
/>
)}
</div>
)}
{/* Equipment Return */} {/* Equipment Return */}
{lastMileEnabled && ( {includesLastMile && lastMileEnabled && (
<div className="p-4"> <div className="p-4">
<Controller <Controller
name="equipmentReturn" name="equipmentReturn"
@@ -228,7 +232,9 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
checked={field.value === "with_return"} checked={field.value === "with_return"}
onChange={(e) => { onChange={(e) => {
field.onChange( field.onChange(
e.currentTarget.checked ? "with_return" : "without_return", e.currentTarget.checked
? "with_return"
: "without_return",
); );
}} }}
color="edr-green" color="edr-green"
@@ -240,31 +246,35 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
)} )}
{/* Customs Clearing */} {/* Customs Clearing */}
<div className="p-4"> {includesCustoms && (
<Controller <div className="p-4">
name="customsClearingEnabled" <Controller
control={form.control} name="customsClearingEnabled"
render={({ field }) => ( control={form.control}
<div className="flex items-start justify-between gap-4"> render={({ field }) => (
<div className="flex items-start gap-3"> <div className="flex items-start justify-between gap-4">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" /> <div className="flex items-start gap-3">
<div> <FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<p className="text-sm font-medium">Customs Clearing Service</p> <div>
<p className="mt-0.5 text-xs text-gray-500"> <p className="text-sm font-medium">
EDR handles customs documentation and clearance on your Customs Clearing Service
behalf. </p>
</p> <p className="mt-0.5 text-xs text-gray-500">
EDR handles customs documentation and clearance on
your behalf.
</p>
</div>
</div> </div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div> </div>
<Switch )}
checked={field.value} />
onChange={(e) => field.onChange(e.currentTarget.checked)} </div>
color="edr-green" )}
/>
</div>
)}
/>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -5,16 +5,16 @@ export * from "./dropdown_settings";
export * from "./overview"; export * from "./overview";
export enum TradeDirection { export enum TradeDirection {
IMPORT = 'IMPORT', IMPORT = "IMPORT",
EXPORT = 'EXPORT', EXPORT = "EXPORT",
BOTH = 'BOTH', BOTH = "BOTH",
} }
export enum PriorityType { export enum PriorityType {
USD_PAYER = 'USD_PAYER', USD_PAYER = "USD_PAYER",
RAIL_AND_FORWARDING = 'RAIL_AND_FORWARDING', RAIL_AND_FORWARDING = "RAIL_AND_FORWARDING",
GOVERNMENT_ACCOUNT = 'GOVERNMENT_ACCOUNT', GOVERNMENT_ACCOUNT = "GOVERNMENT_ACCOUNT",
HIGH_VOLUME_SHIPMENT = 'HIGH_VOLUME_SHIPMENT', HIGH_VOLUME_SHIPMENT = "HIGH_VOLUME_SHIPMENT",
} }
/** Bonus applied to government bookings so they outrank commercial priority. */ /** Bonus applied to government bookings so they outrank commercial priority. */
@@ -26,19 +26,19 @@ export interface GovernmentBookingFields {
} }
export enum ExceededAction { export enum ExceededAction {
WARNING_ONLY = 'WARNING_ONLY', WARNING_ONLY = "WARNING_ONLY",
HARD_BLOCK = 'HARD_BLOCK', HARD_BLOCK = "HARD_BLOCK",
} }
export enum CalculationMethod { export enum CalculationMethod {
PER_TON = 'PER_TON', PER_TON = "PER_TON",
FLAT_FEE = 'FLAT_FEE', FLAT_FEE = "FLAT_FEE",
PERCENTAGE = 'PERCENTAGE', PERCENTAGE = "PERCENTAGE",
} }
export enum FreightType { export enum FreightType {
Container = 'CONTAINER', Container = "CONTAINER",
Bulk = 'BULK', Bulk = "BULK",
} }
export enum BookingStatus { export enum BookingStatus {
@@ -390,6 +390,18 @@ export interface BookingReferenceService {
id: string; id: string;
name: string; name: string;
code: string; code: string;
serviceName: string;
description?: string | null | undefined;
canBeBookedAlone: boolean;
includesFirstMile: boolean;
includesLastMile: boolean;
includesCustoms: boolean;
priorityBonusPoints: number;
isActive: boolean;
displayOrder: number;
createdAt: string;
updatedAt: string;
deletedAt?: string | null | undefined;
} }
export interface BookingReferenceShippingLine { export interface BookingReferenceShippingLine {
@@ -462,31 +474,34 @@ export interface CreateBookingContainerDto {
} }
export interface CreateBookingDto { export interface CreateBookingDto {
reference?: string; freightShapeValidation?: boolean | undefined;
companyId?: string; reference?: string | undefined;
trainId?: string; isGovernment?: boolean | undefined;
trainScheduleId?: string; governmentInstitution?: string | undefined;
companyId?: string | undefined;
trainId?: string | undefined;
trainScheduleId?: string | undefined;
scheduledDate: string; scheduledDate: string;
contractType: "NEW" | "RENEWAL"; contractType: string;
previousContractId?: string; previousContractId?: string | undefined;
serviceTypeId: string; serviceTypeId: string;
firstMilePickupAddress?: string; firstMilePickupAddress?: string | undefined;
lastMileDeliveryAddress?: string; lastMileDeliveryAddress?: string | undefined;
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA"; equipmentReturn: string;
originYardId: string; originYardId: string;
destinationYardId: string; destinationYardId: string;
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC"; tradeDirection: string;
freightType: FreightType; freightType: string;
cargoTypeId?: string; cargoTypeId?: string | undefined;
cargoFreeText?: string; cargoFreeText?: string | undefined;
shippingLineId?: string; shippingLineId?: string | undefined;
cargoTotalWeightVgm: number; cargoTotalWeightVgm: number;
isHazardous?: boolean; isHazardous?: boolean | undefined;
paymentCurrency: "ETB" | "USD"; paymentCurrency: string;
pnrCode?: string; pnrCode?: string | undefined;
startDate?: string; startDate?: string | undefined;
endDate?: string; endDate?: string | undefined;
financialTerms?: string; financialTerms?: string | undefined;
containers?: CreateBookingContainerDto[]; containers?: CreateBookingContainerDto[];
allowConsolidation?: boolean; allowConsolidation?: boolean;
} }