Merge pull request #26 from Tria-plc/freight/feature/rebuild_booking_form

Freight/feature/rebuild booking form
This commit is contained in:
Nathnael Wondisha
2026-05-22 17:19:36 +03:00
committed by GitHub
12 changed files with 1469 additions and 1367 deletions

View File

@@ -42,8 +42,7 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const containerType = form.watch("containerType");
const quantity = form.watch("quantity");
const containers = form.watch("containers");
const previousContractRef = form.watch("previousContractRef");
const contractId =
form.watch("draftContractId") || form.watch("previousContractRef");
@@ -54,9 +53,9 @@ export default function NewBookingPage() {
);
const wagons = useMemo(() => {
if (!containerType || !quantity) return null;
return calcWagons(containerType, parseInt(quantity) || 1);
}, [containerType, quantity]);
if (!containers || containers.length === 0) return null;
return calcWagons(containers);
}, [containers]);
useEffect(() => {
setRenewalValid(null);

View File

@@ -121,23 +121,6 @@ export const BOOKING_DOCS_SETTING = {
maxSizeMb: 5,
order: 3,
},
{
id: "f4",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "national_id",
fileLabel: "National ID / Passport",
helpText:
"Valid government-issued ID or passport of the authorized signatory.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 4,
},
{
id: "f5",
createdAt: "",
@@ -189,9 +172,19 @@ export const bookingFormSchema = z
breakBulkTypeOther: z.string(),
isHazardous: z.boolean(),
isRefrigerated: z.boolean(),
containerType: z.enum(["20ft", "40ft", ""]),
quantity: z.string(),
vgm: z.string(),
containers: z.array(
z.object({
type: z.enum(["20ft", "40ft"]),
qty: z
.string()
.refine((q) => !isNaN(+q), "Enter a valid Number")
.refine((qty) => Number(qty) >= 1, "Must be greater than 0"),
vgm: z
.string()
.refine((vgm) => !isNaN(+vgm), "Enter a valid Number")
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
}),
),
consolidationEnabled: z.boolean(),
documents: z.record(z.string(), fileValueSchema),
notes: z.string(),
@@ -342,31 +335,31 @@ export const bookingFormSchema = z
}
if (data.cargoType === "container") {
if (!data.containerType) {
if (data.containers.length === 0) {
ctx.addIssue({
code: "custom",
path: ["containerType"],
message: "Select a container type.",
path: ["containers"],
message: "Add at least one container.",
});
}
const quantity = Number(data.quantity);
if (!data.quantity || !Number.isInteger(quantity) || quantity < 1) {
ctx.addIssue({
code: "custom",
path: ["quantity"],
message: "Enter at least 1 container.",
});
}
data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) {
ctx.addIssue({
code: "custom",
path: ["containers", i, "qty"],
message: "Enter at least 1 container.",
});
}
const vgm = Number(data.vgm);
if (!data.vgm || Number.isNaN(vgm) || vgm <= 0) {
ctx.addIssue({
code: "custom",
path: ["vgm"],
message: "Enter VGM greater than 0.",
});
}
if (!c.vgm || +c.vgm <= 0) {
ctx.addIssue({
code: "custom",
path: ["containers", i, "vgm"],
message: "Enter VGM greater than 0.",
});
}
});
}
for (const key of REQUIRED_DOC_KEYS) {
@@ -413,9 +406,7 @@ export const initialBookingFormValues: BookingFormValues = {
breakBulkTypeOther: "",
isHazardous: false,
isRefrigerated: false,
containerType: "",
quantity: "1",
vgm: "",
containers: [{ type: "20ft", qty: "1", vgm: "" }],
consolidationEnabled: false,
documents: {},
notes: "",
@@ -441,9 +432,7 @@ export const stepFields: Record<number, Array<keyof BookingFormValues>> = {
"bulkCommodityOther",
"breakBulkType",
"breakBulkTypeOther",
"containerType",
"quantity",
"vgm",
"containers",
],
6: ["consolidationEnabled"],
7: ["documents"],
@@ -452,10 +441,23 @@ export const stepFields: Record<number, Array<keyof BookingFormValues>> = {
export type RouteDirection = "import" | "export" | "domestic" | null;
export interface ContainerConfig {
type: "20ft" | "40ft";
qty: string;
vgm: string;
}
export interface WagonConfig {
type: "20ft" | "40ft";
}
export interface WagonCalcResult {
totalWagons: number;
hasOddUnit: boolean;
sharedWagons: number;
wagonLayout: WagonConfig[];
ft40Wagons: number;
ft20Wagons: number;
}
export function genContractId(): string {
@@ -477,15 +479,23 @@ export function getRouteDirection(
return null;
}
export function calcWagons(
type: BookingFormValues["containerType"],
qty: number,
): WagonCalcResult {
if (type === "40ft") {
return { totalWagons: qty, hasOddUnit: false, sharedWagons: qty };
}
export function calcWagons(containers: ContainerConfig[]): WagonCalcResult {
const Ft40Wagons = containers
.filter((c) => c.type === "40ft")
.reduce((sum, c) => sum + Number(c.qty), 0);
const Ft20Wagons = containers
.filter((c) => c.type === "20ft")
.reduce((sum, c) => sum + Number(c.qty), 0);
const wagonLayout: WagonConfig[] = [];
let hasOddUnit = Ft20Wagons % 2 === 1;
let sharedWagons = Math.floor(Ft20Wagons / 2);
const pairs = Math.floor(qty / 2);
const odd = qty % 2;
return { totalWagons: pairs + odd, hasOddUnit: odd > 0, sharedWagons: pairs };
return {
totalWagons: sharedWagons + Ft40Wagons,
hasOddUnit,
sharedWagons,
ft40Wagons: Ft40Wagons,
ft20Wagons: Ft20Wagons,
wagonLayout,
};
}

View File

@@ -22,8 +22,22 @@ import {
SelectValue,
} from "@edr/ui-common";
import type { BookingFormValues } from "./schema";
import { REQUIRED_DOC_KEYS } from "./schema";
import { cn } from "@/lib/utils";
export function getUploadedRequiredCount(
documents: BookingFormValues["documents"],
) {
return REQUIRED_DOC_KEYS.filter((key) => {
const file = documents[key];
return Array.isArray(file) ? file.length > 0 : Boolean(file);
}).length;
}
export function OptionFieldError({ error }: { error?: { message?: string } }) {
return <FieldError errors={[error]} />;
}
export function OptionCard({
selected,
onClick,
@@ -151,7 +165,3 @@ export function SelectOptions({ options }: { options: readonly string[] }) {
</>
);
}
export function FormFieldDescription({ children }: { children: ReactNode }) {
return <FieldDescription>{children}</FieldDescription>;
}

View File

@@ -0,0 +1,140 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Loader2, RefreshCw } from "lucide-react";
import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common";
import { type BookingFormValues, genContractId } from "./schema";
import { AlertBox, OptionCard, OptionFieldError, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step1ContractType({
form,
renewalValid,
renewalValidating,
onValidate,
}: {
form: BookingForm;
renewalValid: boolean | null;
renewalValidating: boolean;
onValidate: () => void;
}) {
const contractType = form.watch("contractType");
const draftContractId = form.watch("draftContractId");
const previousContractRef = form.watch("previousContractRef");
const errors = form.formState.errors;
return (
<div className="space-y-6">
<StepHeader
title="Contract Type"
description="New contract or renewal of an existing one."
/>
<Controller
name="contractType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div className="grid gap-3 md:grid-cols-2">
<OptionCard
selected={field.value === "new"}
onClick={() => {
field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]);
if (!draftContractId) {
form.setValue("draftContractId", genContractId(), {
shouldDirty: true,
shouldValidate: true,
});
}
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<FileText className="h-4 w-4 text-primary" />
</div>
<p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Blank contract form. A draft ID is auto-generated.
</p>
{field.value === "new" && draftContractId && (
<p className="mt-2 font-mono text-xs font-semibold text-primary">
{draftContractId}
</p>
)}
</OptionCard>
<OptionCard
selected={field.value === "renewal"}
onClick={() => {
field.onChange("renewal");
form.clearErrors("contractType");
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100">
<RefreshCw className="h-4 w-4 text-sky-600" />
</div>
<p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Enter a previous reference to auto-populate historical
parameters.
</p>
</OptionCard>
</div>
<OptionFieldError error={fieldState.error} />
</Field>
)}
/>
{contractType === "renewal" && (
<div className="space-y-3 pt-1">
<Controller
name="previousContractRef"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="previousContractRef">
Previous Contract Reference Number
</FieldLabel>
<div className="flex gap-2">
<Input
{...field}
id="previousContractRef"
aria-invalid={fieldState.invalid}
placeholder="e.g. EDR-2024-10001"
className="font-mono"
/>
<Button
type="button"
variant="outline"
onClick={onValidate}
disabled={!previousContractRef || renewalValidating}
>
{renewalValidating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Validate"
)}
</Button>
</div>
<FieldError
errors={[fieldState.error, errors.draftContractId]}
/>
</Field>
)}
/>
{renewalValid === true && (
<AlertBox tone="success">
<strong>Contract found.</strong> Company details, route, and wagon
preferences will be pre-filled.
</AlertBox>
)}
{renewalValid === false && (
<AlertBox tone="error">
Contract Reference Number not found or unauthorized. Try{" "}
<span className="font-mono">EDR-2024-10001</span>.
</AlertBox>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Package, Train } from "lucide-react";
import { Badge, Field, FieldError } from "@edr/ui-common";
import { type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step2ServiceType({ form }: { form: BookingForm }) {
const serviceType = form.watch("serviceType");
return (
<div className="space-y-6">
<StepHeader
title="Service Type"
description="Select the service combination you require."
/>
<Controller
name="serviceType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<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-muted-foreground">
Rail transport along the EDR corridor, with optional
first/last mile trucking.
</p>
<Badge className="mt-2 bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
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-primary/10">
<Package className="h-4 w-4 text-primary" />
</div>
<p className="font-semibold">
Rail Transport & Freight Forwarding
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Rail transport plus documentation, customs liaison, and a
dedicated coordinator.
</p>
<Badge className="mt-2 bg-emerald-100 text-emerald-700 hover:bg-emerald-100">
Option B
</Badge>
</OptionCard>
</div>
<OptionFieldError error={fieldState.error} />
</Field>
)}
/>
<p className="text-xs text-muted-foreground">
Customs and Clearance Service cannot be selected independently. It must
be bundled with a Rail Transport service.
</p>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Field, FieldError, Input, Switch } from "@edr/ui-common";
import { type BookingFormValues } from "./schema";
import { OptionCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step3FirstLastMile({ form }: { form: BookingForm }) {
const firstMileEnabled = form.watch("firstMileEnabled");
const lastMileEnabled = form.watch("lastMileEnabled");
const equipmentReturn = form.watch("equipmentReturn");
return (
<div className="space-y-6">
<StepHeader
title="First & Last Mile"
description="Configure trucking and container return options."
/>
<div className="divide-y divide-border rounded-xl border border-border">
<div className="p-4">
<Controller
name="firstMileEnabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">First Mile - Pick-up</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck pick-up from your premises to the origin rail yard.
</p>
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</div>
)}
/>
{firstMileEnabled && (
<Controller
name="pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Pick-up address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
<div className="p-4">
<Controller
name="lastMileEnabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Last Mile - Delivery</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck delivery from the destination rail yard to the final
address.
</p>
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</div>
)}
/>
{lastMileEnabled && (
<Controller
name="deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Delivery address *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
<div className="p-4">
<p className="mb-3 text-sm font-medium">Equipment Return</p>
<p className="mb-3 text-xs text-muted-foreground">
Declare whether the container asset will be returned after
unloading.
</p>
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="grid gap-2 sm:grid-cols-2">
<OptionCard
selected={equipmentReturn === "with_return"}
onClick={() => field.onChange("with_return")}
>
<p className="text-sm font-semibold">With Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Container returned to EDR after unloading.
</p>
</OptionCard>
<OptionCard
selected={equipmentReturn === "without_return"}
onClick={() => field.onChange("without_return")}
>
<p className="text-sm font-semibold">Without Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Container retained by the customer after delivery.
</p>
</OptionCard>
</div>
)}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, Separator, Switch } from "@edr/ui-common";
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step4Route({ form }: { form: BookingForm }) {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const direction = getRouteDirection(originYard, destinationYard);
const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200",
import: "bg-amber-50 text-amber-800 border-amber-200",
domestic: "bg-muted text-muted-foreground border-border",
};
const directionLabel: Record<string, string> = {
export: "Export workflow (Ethiopia to Djibouti)",
import: "Import workflow (Djibouti to Ethiopia)",
domestic: "Domestic corridor",
};
return (
<div className="space-y-6">
<StepHeader
title="Route"
description="Select the origin and destination yards."
/>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name="originYard"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin Yard*"
placeholder="Select origin..."
>
<SelectOptions
options={STATIONS.filter((s) => s !== destinationYard)}
/>
</SelectField>
)}
/>
<Controller
name="destinationYard"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination Yard *"
placeholder="Select destination..."
>
<SelectOptions
options={STATIONS.filter((s) => s !== originYard)}
/>
</SelectField>
)}
/>
</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>
<Separator />
<div className="space-y-0 divide-y divide-border">
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Flame className="h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="text-sm font-medium">Hazardous Material</p>
<p className="text-xs text-muted-foreground">
Applies a Hazard Surcharge to the final bill.
</p>
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
<div>
<p className="text-sm font-medium">Refrigerated Cargo</p>
<p className="text-xs text-muted-foreground">
Temperature-controlled transport applies a Refrigerator
Surcharge.
</p>
</div>
</div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,438 @@
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import {
Button,
Field,
FieldError,
FieldLabel,
Input,
Separator,
} from "@edr/ui-common";
import {
BREAK_BULK_TYPES,
BULK_COMMODITIES,
type BookingFormValues,
type RouteDirection,
} from "./schema";
import {
AlertBox,
OptionCard,
SelectField,
SelectOptions,
StepHeader,
StepLabel,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step5CargoDetails({
form,
direction,
}: {
form: BookingForm;
direction: RouteDirection;
}) {
const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType");
const bulkCommodity = form.watch("bulkCommodity");
const breakBulkType = form.watch("breakBulkType");
const containers = form.watch("containers");
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "containers",
});
function getOverweightAlert(
type: "20ft" | "40ft",
vgm: number,
): string | null {
if (type === "20ft" && vgm > 0) {
const limit = direction === "export" ? 25 : 20;
if (vgm > limit) {
return `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`;
}
}
if (type === "40ft" && vgm > 32.5) {
return `VGM ${vgm}t exceeds the 32.5t global limit for a 40ft container. An Overweight Surcharge will apply.`;
}
return null;
}
return (
<div className="space-y-6">
<StepHeader
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
<div className="space-y-3">
<StepLabel>Cargo Type *</StepLabel>
<Controller
name="cargoType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div className="grid gap-3 sm:grid-cols-2">
<OptionCard
selected={cargoType === "container"}
onClick={() => {
field.onChange("container");
form.setValue("freightType", "", { shouldDirty: true });
form.setValue("cargoWeight", "", { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-4 w-4 text-primary" />
</div>
<p className="font-semibold">Container</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Pre-packed containerized cargo (20ft / 40ft).
</p>
</OptionCard>
<OptionCard
selected={cargoType === "bulk"}
onClick={() => {
field.onChange("bulk");
form.setValue(
"containers",
[{ type: "20ft", qty: "1", vgm: "0" }],
{ shouldDirty: true },
);
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
<Weight className="h-4 w-4 text-amber-600" />
</div>
<p className="font-semibold">Bulk</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Bulk commodities or break-bulk cargo.
</p>
</OptionCard>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</div>
{cargoType === "bulk" && (
<>
<Separator />
<div className="space-y-3">
<StepLabel>Freight Type *</StepLabel>
<Controller
name="freightType"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div className="grid gap-3 sm:grid-cols-2">
<OptionCard
selected={freightType === "bulk"}
onClick={() => field.onChange("bulk")}
>
<p className="font-semibold">Bulk</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Coffee, fertilizer, grain, ore, etc.
</p>
</OptionCard>
<OptionCard
selected={freightType === "break_bulk"}
onClick={() => field.onChange("break_bulk")}
>
<p className="font-semibold">Break-Bulk</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Machinery, vehicles, project cargo, etc.
</p>
</OptionCard>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
{freightType === "bulk" && (
<div className="space-y-2">
<Controller
name="bulkCommodity"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Commodity *"
placeholder="Select commodity *"
>
<SelectOptions options={BULK_COMMODITIES} />
</SelectField>
)}
/>
{bulkCommodity === "Others" && (
<Controller
name="bulkCommodityOther"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify commodity *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
)}
{freightType === "break_bulk" && (
<div className="space-y-2">
<Controller
name="breakBulkType"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Break-bulk type *"
placeholder="Select type *"
>
<SelectOptions options={BREAK_BULK_TYPES} />
</SelectField>
)}
/>
{breakBulkType === "Others" && (
<Controller
name="breakBulkTypeOther"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify break-bulk type *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
)}
</div>
<Separator />
<div className="space-y-3">
<StepLabel>Weight</StepLabel>
<Controller
name="cargoWeight"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="cargoWeight">
Total Cargo Weight - VGM (Tons) *
</FieldLabel>
<div className="relative">
<Weight className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
{...field}
id="cargoWeight"
type="number"
aria-invalid={fieldState.invalid}
placeholder="0.00"
className="pl-9"
min="0"
step="0.01"
/>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</div>
</>
)}
{cargoType === "container" && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center justify-between">
<StepLabel>Container Configuration</StepLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ type: "20ft", qty: "1", vgm: "0" })}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Container
</Button>
</div>
{direction && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5" />
Route detected as{" "}
<span className="font-medium capitalize text-foreground">
{direction}
</span>{" "}
workflow
</p>
)}
{fields.map((field, index) => {
const containerType = containers[index]?.type;
const vgm = containers[index]?.vgm ?? 0;
const alert = getOverweightAlert(containerType, +vgm);
return (
<div
key={field.id}
className="space-y-3 rounded-xl border border-border p-4"
>
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Container #{index + 1}
</p>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="text-xs text-destructive hover:underline"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<Controller
name={`containers.${index}.type`}
control={form.control}
render={({ field: typeField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Container Type *</FieldLabel>
<div className="grid gap-2 sm:grid-cols-2">
{[
{
val: "20ft" as const,
label: "20ft Container (TEU)",
limit:
direction === "export"
? "Max 25t per container"
: "Max 20t per container",
},
{
val: "40ft" as const,
label: "40ft Container (FEU)",
limit: "Max 32.5t per container",
},
].map((ct) => (
<OptionCard
key={ct.val}
selected={typeField.value === ct.val}
onClick={() => typeField.onChange(ct.val)}
>
<div className="mb-1 flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<p className="font-semibold">{ct.label}</p>
</div>
<p className="text-xs text-muted-foreground">
{ct.limit}
</p>
</OptionCard>
))}
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name={`containers.${index}.qty`}
control={form.control}
render={({ field: qtyField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>Quantity *</FieldLabel>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
qtyField.onChange(
Math.max(
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
>
-
</button>
<Input
value={qtyField.value ?? 1}
onChange={(e) =>
qtyField.onChange(e.target.value)
}
onBlur={qtyField.onBlur}
type="number"
aria-invalid={fieldState.invalid}
className="text-center"
min="1"
/>
<button
type="button"
onClick={() =>
qtyField.onChange(
(Number(qtyField.value ?? 1) + 1).toString(),
)
}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent"
>
+
</button>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<Controller
name={`containers.${index}.vgm`}
control={form.control}
render={({ field: vgmField, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>VGM (Tons) *</FieldLabel>
<Input
value={vgmField.value ?? 0}
onChange={(e) => vgmField.onChange(e.target.value)}
onBlur={vgmField.onBlur}
type="number"
aria-invalid={fieldState.invalid}
placeholder="e.g. 18.5"
min="0"
step="0.1"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</div>
{alert && (
<AlertBox tone="warning">
<strong>Overweight Alert:</strong> {alert}
</AlertBox>
)}
</div>
);
})}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { type UseFormReturn } from "react-hook-form";
import { type BookingFormValues, type WagonCalcResult } from "./schema";
import { AlertBox, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step6WagonAllocation({
form,
wagons,
}: {
form: BookingForm;
wagons: WagonCalcResult | null;
}) {
const containers = form.watch("containers") ?? [];
const totalContainers = containers.reduce(
(sum, c) => sum + Number(c.qty || 0),
0,
);
const containerSummary = containers
.filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ");
return (
<div className="space-y-6">
<StepHeader
title="Wagon Allocation"
description="System-calculated wagon requirements based on your container profile."
/>
{!wagons ? (
<AlertBox tone="info">
Complete the container configuration in the previous step to see wagon
allocation.
</AlertBox>
) : (
<>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl bg-primary/5 p-4 text-center">
<p className="text-3xl font-bold text-primary">
{wagons.totalWagons}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Wagons Required
</p>
</div>
<div className="rounded-xl bg-muted p-4 text-center">
<p className="text-3xl font-bold">{totalContainers}</p>
<p className="mt-1 text-xs text-muted-foreground">
{containerSummary || "Containers"}
</p>
</div>
<div className="rounded-xl bg-muted p-4 text-center">
<p className="text-3xl font-bold">{wagons.sharedWagons}</p>
<p className="mt-1 text-xs text-muted-foreground">Shared Slots</p>
</div>
</div>
<div>
<StepLabel>Wagon Layout</StepLabel>
<div className="mt-2 flex flex-wrap gap-2">
{new Array(wagons.ft40Wagons).fill(0).map((_, index) => (
<div
key={index}
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
border-primary/30 bg-primary/5 text-primary `}
>
1 × 40ft
</div>
))}
{new Array(wagons.sharedWagons).fill(0).map((_, index) => (
<div
key={index}
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold
border-amber-300 bg-amber-50 text-amber-700
`}
>
2 × 20ft
</div>
))}
{wagons.hasOddUnit && (
<div
className={`flex h-12 min-w-[100px] items-center justify-center rounded-lg border-2 px-3 text-xs font-semibold border-destructive! bg-destructive/10 text-destructive `}
>
1 × 20ft
</div>
)}
</div>
</div>
{wagons.hasOddUnit && (
<>
<AlertBox tone="warning">
<div className="flex items-start gap-2">
<div>
<p className="font-semibold">Unpaired 20ft Container</p>
<p className="mt-1 text-xs">
One 20ft container occupies only half a wagon. The wagon
will depart once a co-loader is found to fill the
remaining slot, which <strong>may delay departure</strong>{" "}
beyond the standard lead time.
</p>
</div>
</div>
</AlertBox>
</>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Field, FieldError, SmartFileInput } from "@edr/ui-common";
import {
BOOKING_DOCS_SETTING,
REQUIRED_DOC_KEYS,
type BookingFormValues,
} from "./schema";
import { getUploadedRequiredCount, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step7Documents({ form }: { form: BookingForm }) {
const documents = form.watch("documents");
const uploadedRequired = getUploadedRequiredCount(documents);
const documentErrors = form.formState.errors.documents as
| Record<string, { message?: string }>
| undefined;
const smartFileErrors = Object.fromEntries(
Object.entries(documentErrors ?? {}).map(([key, value]) => [
key,
value?.message ?? "",
]),
);
return (
<div className="space-y-6">
<StepHeader
title="Compliance Documents"
description="Upload your company's legal credentials for EDR contract eligibility verification (US-04)."
/>
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold ${uploadedRequired === REQUIRED_DOC_KEYS.length
? "bg-emerald-100 text-emerald-700"
: "bg-primary/10 text-primary"
}`}
>
{uploadedRequired}/{REQUIRED_DOC_KEYS.length}
</div>
<p className="text-sm text-muted-foreground">
{uploadedRequired < REQUIRED_DOC_KEYS.length
? `${REQUIRED_DOC_KEYS.length - uploadedRequired} mandatory document(s) still needed.`
: "All mandatory documents uploaded. Power of Attorney is optional."}
</p>
</div>
<Controller
name="documents"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<SmartFileInput
file={BOOKING_DOCS_SETTING}
value={field.value}
onChange={(value) => field.onChange(value)}
errors={smartFileErrors}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</div>
);
}

View File

@@ -0,0 +1,285 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { Check } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Field,
FieldError,
FieldLabel,
Textarea,
} from "@edr/ui-common";
import {
REQUIRED_DOC_KEYS,
type BookingFormValues,
type RouteDirection,
type WagonCalcResult,
} from "./schema";
import { getUploadedRequiredCount, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
export function Step8Review({
form,
setStep,
wagons,
direction,
}: {
form: BookingForm;
setStep: (step: number) => void;
wagons: WagonCalcResult | null;
direction: RouteDirection;
}) {
const values = form.watch();
const errors = form.formState.errors;
function Row({
label,
value,
target,
}: {
label: string;
value: string;
target: number;
}) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-0.5 truncate text-sm font-medium">{value || "-"}</p>
</div>
<button
type="button"
onClick={() => setStep(target)}
className="shrink-0 text-xs font-medium text-primary hover:underline"
>
Edit
</button>
</div>
);
}
const containerSummary =
values.cargoType === "container" && values.containers.length > 0
? values.containers
.filter((c) => c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`)
.join(", ")
: "";
const totalVgm =
values.cargoType === "container"
? values.containers.reduce((sum, c) => sum + (c.qty || 0) * (c.vgm || 0), 0)
: 0;
const cargoValue =
values.cargoType === "container"
? containerSummary
: values.freightType === "bulk"
? `Bulk - ${values.bulkCommodity === "Others" ? values.bulkCommodityOther : values.bulkCommodity}`
: values.freightType === "break_bulk"
? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}`
: "";
const uploadedCount = getUploadedRequiredCount(values.documents);
return (
<div className="space-y-6">
<StepHeader
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
/>
<div className="grid gap-4 md:grid-cols-2">
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Contract & Service
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Type"
value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1}
/>
<Row
label="Contract ID"
value={values.draftContractId || values.previousContractRef}
target={1}
/>
<Row
label="Service"
value={
values.serviceType === "rail"
? "Rail Only"
: values.serviceType === "rail_forwarding"
? "Rail + Forwarding"
: ""
}
target={2}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
First & Last Mile
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="First Mile"
value={
values.firstMileEnabled ? values.pickUpAddress : "Not requested"
}
target={3}
/>
<Row
label="Last Mile"
value={
values.lastMileEnabled
? values.deliveryAddress
: "Not requested"
}
target={3}
/>
<Row
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
}
target={3}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Route & Cargo
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Route"
value={`${values.originYard} -> ${values.destinationYard}`}
target={4}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={4}
/>
<Row
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={5}
/>
<Row label="Cargo" value={cargoValue} target={5} />
<Row
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean)
.join(", ") || "None"
}
target={4}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Container & Wagons
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Containers"
value={containerSummary || "-"}
target={5}
/>
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={5}
/>
<Row
label="Wagons"
value={
wagons
? `${wagons.totalWagons} wagon${wagons.totalWagons > 1 ? "s" : ""}`
: ""
}
target={6}
/>
<Row
label="Documents"
value={`${uploadedCount}/${REQUIRED_DOC_KEYS.length} mandatory uploaded`}
target={7}
/>
</CardContent>
</Card>
</div>
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="notes">Additional Notes</FieldLabel>
<Textarea
{...field}
id="notes"
placeholder="Any special instructions or notes for EDR operations..."
rows={3}
/>
</Field>
)}
/>
<Controller
name="termsAccepted"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<label className="flex cursor-pointer items-start gap-3">
<button
type="button"
role="checkbox"
aria-checked={field.value}
aria-invalid={fieldState.invalid}
onClick={() => field.onChange(!field.value)}
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition ${field.value ? "border-primary bg-primary" : "border-input"
}`}
>
{field.value && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</button>
<span className="text-sm text-muted-foreground">
I confirm the information is accurate and agree to EDR's{" "}
<span className="text-primary">
freight contract terms and conditions
</span>
.
</span>
</label>
<FieldError errors={[fieldState.error, errors.termsAccepted]} />
</Field>
)}
/>
</div>
);
}