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

View File

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

View File

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

View File

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