Merge pull request #54 from Tria-plc/freight/feature/ui-sync

onboarding and bookin ui page
This commit is contained in:
yaschalew10
2026-05-29 16:40:26 +03:00
committed by GitHub
13 changed files with 761 additions and 716 deletions

View File

@@ -20,9 +20,11 @@ import {
const userSchema = z.object({ const userSchema = z.object({
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
username: z.string().min(3, "Username must be at least 3 characters"),
countryCode: z.string().min(1, "Country code is required"), countryCode: z.string().min(1, "Country code is required"),
phone: z.string().min(9, "Phone number is too short").max(9, "Phone number is too long"), phone: z
.string()
.min(9, "Phone number is too short")
.max(9, "Phone number is too long"),
userType: z.string(), userType: z.string(),
name: z.object({ name: z.object({
en: z.string().min(2, "Name is required"), en: z.string().min(2, "Name is required"),
@@ -46,7 +48,6 @@ export default function SignupPage() {
resolver: zodResolver(userSchema), resolver: zodResolver(userSchema),
defaultValues: { defaultValues: {
email: "", email: "",
username: "",
countryCode: "+251", countryCode: "+251",
phone: "", phone: "",
userType: userType.individual, userType: userType.individual,
@@ -58,10 +59,12 @@ export default function SignupPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const normalizedPhone = data.phone.startsWith("0") ? data.phone.slice(1) : data.phone; const normalizedPhone = data.phone.startsWith("0")
? data.phone.slice(1)
: data.phone;
const payload: SignupPayload = { const payload: SignupPayload = {
email: data.email, email: data.email,
username: data.username, username: data.email,
phoneNumber: `${data.countryCode}${normalizedPhone}`, phoneNumber: `${data.countryCode}${normalizedPhone}`,
userType: data.userType, userType: data.userType,
name: { en: data.name.en, am: data.name.am ?? "" }, name: { en: data.name.en, am: data.name.am ?? "" },
@@ -92,7 +95,12 @@ export default function SignupPage() {
"Enterprise-grade operations", "Enterprise-grade operations",
"Multi-corridor freight monitoring", "Multi-corridor freight monitoring",
], ],
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" }, stats: {
label: "Active Corridors",
value: "24+",
footer: "Operational",
progress: "w-[95%]",
},
}} }}
> >
<div className="mb-6"> <div className="mb-6">
@@ -125,31 +133,17 @@ export default function SignupPage() {
<FieldError errors={[errors.name?.en]} /> <FieldError errors={[errors.name?.en]} />
</Field> </Field>
<div className="grid grid-cols-2 gap-4"> <Field data-invalid={Boolean(errors.email)}>
<Field data-invalid={Boolean(errors.username)}> <FieldLabel>Email Address</FieldLabel>
<FieldLabel>Username</FieldLabel> <Input
<Input type="email"
type="text" placeholder="john@example.com"
placeholder="john_doe" disabled={loading}
disabled={loading} aria-invalid={Boolean(errors.email)}
aria-invalid={Boolean(errors.username)} {...register("email")}
{...register("username")} />
/> <FieldError errors={[errors.email]} />
<FieldError errors={[errors.username]} /> </Field>
</Field>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel>Email Address</FieldLabel>
<Input
type="email"
placeholder="john@example.com"
disabled={loading}
aria-invalid={Boolean(errors.email)}
{...register("email")}
/>
<FieldError errors={[errors.email]} />
</Field>
</div>
<PhoneInput <PhoneInput
disabled={loading} disabled={loading}
@@ -160,12 +154,7 @@ export default function SignupPage() {
/> />
</FieldGroup> </FieldGroup>
<Button <Button type="submit" disabled={loading} size="lg" className="w-full">
type="submit"
disabled={loading}
size="lg"
className="w-full"
>
{loading ? ( {loading ? (
<> <>
<Loader2 className="animate-spin" data-icon="inline-start" /> <Loader2 className="animate-spin" data-icon="inline-start" />

View File

@@ -1,15 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; import { Check, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@edr/ui-common"; import { Button } from "@edr/ui-common";
import Breadcrumbs from "@/components/Breadcrumbs"; import Breadcrumbs from "@/components/Breadcrumbs";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service"; import type { CreateBookingPayload } from "@/services/bookings.service";
import { import {
MOCK_VALID_CONTRACTS,
STEPS, STEPS,
bookingFormSchema, bookingFormSchema,
calcWagons, calcWagons,
@@ -22,11 +21,8 @@ import { StepIndicator } from "./new-booking-form/StepIndicator";
import { import {
Step1ContractType, Step1ContractType,
Step2ServiceType, Step2ServiceType,
Step3FirstLastMile,
Step4Route, Step4Route,
Step5CargoDetails, Step5CargoDetails,
Step6WagonAllocation,
Step7Documents,
Step8Review, Step8Review,
} from "./new-booking-form/steps"; } from "./new-booking-form/steps";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
@@ -35,8 +31,6 @@ export default function NewBookingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [renewalValidating, setRenewalValidating] = useState(false);
const [renewalValid, setRenewalValid] = useState<boolean | null>(null);
const { customer } = useAuth(); const { customer } = useAuth();
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (payload: CreateBookingPayload) => mutationFn: (payload: CreateBookingPayload) =>
@@ -56,7 +50,6 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard"); const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard"); const destinationYard = form.watch("destinationYard");
const containers = form.watch("containers"); const containers = form.watch("containers");
const previousContractRef = form.watch("previousContractRef");
const direction = useMemo( const direction = useMemo(
() => getRouteDirection(originYard, destinationYard), () => getRouteDirection(originYard, destinationYard),
@@ -68,63 +61,18 @@ export default function NewBookingPage() {
return calcWagons(containers); return calcWagons(containers);
}, [containers]); }, [containers]);
useEffect(() => {
setRenewalValid(null);
}, [previousContractRef]);
function validateRenewal() {
const previousContractRef = form.getValues("previousContractRef").trim();
if (!previousContractRef) {
form.setError("previousContractRef", {
type: "manual",
message: "Enter a previous contract reference.",
});
return;
}
setRenewalValidating(true);
setRenewalValid(null);
setTimeout(() => {
const valid = MOCK_VALID_CONTRACTS.includes(
previousContractRef.toUpperCase(),
);
setRenewalValidating(false);
setRenewalValid(valid);
if (!valid) {
form.setError("previousContractRef", {
type: "manual",
message: "Contract Reference Number not found or unauthorized.",
});
} else {
form.clearErrors("previousContractRef");
}
}, 1200);
}
async function handleContinue() { async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true }); const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return; if (!valid) return;
if (step === 1 && form.getValues("contractType") === "renewal") {
if (renewalValid !== true) {
form.setError("previousContractRef", {
type: "manual",
message:
"Validate the previous contract reference before continuing.",
});
return;
}
}
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
} }
const handleSubmit = form.handleSubmit((data) => { const handleSubmit = form.handleSubmit((data) => {
if (data.contractType === "renewal" && renewalValid !== true) { if (data.contractType === "renewal" && !data.previousContractRef) {
form.setError("previousContractRef", { form.setError("previousContractRef", {
type: "manual", type: "manual",
message: "Validate the previous contract reference before submitting.", message: "Select a previous contract reference.",
}); });
setStep(1); setStep(1);
return; return;
@@ -149,19 +97,22 @@ export default function NewBookingPage() {
data.contractType.toUpperCase() as CreateBookingPayload["contractType"], data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
previousContractId: data.previousContractRef || undefined, previousContractId: data.previousContractRef || undefined,
serviceType: serviceType:
data.serviceType === "rail" ? "RAIL_ONLY" : "RAIL_AND_FORWARDING", data.service.serviceType === "rail"
firstMileEnabled: data.firstMileEnabled, ? "RAIL_ONLY"
firstMilePickupAddress: data.firstMileEnabled : "RAIL_AND_FORWARDING",
? data.pickUpAddress ...(data.service.serviceType === "rail"
: undefined, ? {}
lastMileEnabled: data.lastMileEnabled, : {
lastMileDeliveryAddress: data.lastMileEnabled firstMileEnabled: data.firstMile.enabled,
? data.deliveryAddress firstMilePickupAddress: data.firstMile.pickUpAddress ?? undefined,
: undefined, lastMileEnabled: data.lastMile.enabled,
equipmentReturn: lastMileDeliveryAddress: data.lastMile.deliveryAddress ?? undefined,
data.equipmentReturn === "with_return" equipmentReturn:
? ("WITH_RETURN" as const) data.equipmentReturn === "with_return"
: ("WITHOUT_RETURN" as const), ? ("WITH_RETURN" as const)
: ("WITHOUT_RETURN" as const),
customsClearingEnabled: data.customsClearingEnabled,
}),
originStation: data.originYard, originStation: data.originYard,
destinationStation: data.destinationYard, destinationStation: data.destinationYard,
cargoTotalWeightVgm: totalWeight, cargoTotalWeightVgm: totalWeight,
@@ -220,43 +171,22 @@ export default function NewBookingPage() {
className="flex flex-col" className="flex flex-col"
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<div className="sticky top-0 z-20 border-b border-border bg-background"> <div className="sticky top-0 z-20">
<div className="mx-auto max-w-4xl space-y-3 px-6 py-3"> <div className="mx-auto max-w-4xl space-y-3 px-6 pt-4">
<Breadcrumbs
items={[
{ label: "Bookings", href: "/bookings" },
{ label: "New Contract Request" },
]}
/>
<StepIndicator step={step} /> <StepIndicator step={step} />
</div> </div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="mx-auto max-w-4xl px-6 py-8"> <div className="mx-auto max-w-4xl px-6 py-8">
{step === 1 && ( {step === 1 && <Step1ContractType form={form} />}
<Step1ContractType
form={form}
renewalValid={renewalValid}
renewalValidating={renewalValidating}
onValidate={validateRenewal}
/>
)}
{step === 2 && <Step2ServiceType form={form} />} {step === 2 && <Step2ServiceType form={form} />}
{step === 3 && <Step3FirstLastMile form={form} />} {step === 3 && <Step4Route form={form} />}
{step === 4 && <Step4Route form={form} />} {step === 4 && (
{step === 5 && (
<Step5CargoDetails form={form} direction={direction} /> <Step5CargoDetails form={form} direction={direction} />
)} )}
{step === 6 && <Step6WagonAllocation form={form} wagons={wagons} />} {step === 5 && (
{step === 7 && <Step7Documents form={form} />} <Step8Review form={form} setStep={setStep} direction={direction} />
{step === 8 && (
<Step8Review
form={form}
setStep={setStep}
wagons={wagons}
direction={direction}
/>
)} )}
</div> </div>
</div> </div>
@@ -277,11 +207,11 @@ export default function NewBookingPage() {
{step < STEPS.length ? ( {step < STEPS.length ? (
<Button type="button" onClick={handleContinue}> <Button type="button" onClick={handleContinue}>
Continue Continue
<ChevronRight className="ml-1 h-4 w-4" /> <ChevronRight />
</Button> </Button>
) : ( ) : (
<Button type="submit" form="new-booking-form"> <Button type="submit" form="new-booking-form">
<CheckCircle2 className="mr-2 h-4 w-4" /> <Check />
Submit Contract Request Submit Contract Request
</Button> </Button>
)} )}

View File

@@ -1,3 +1,4 @@
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
export const STATIONS = [ export const STATIONS = [
@@ -42,123 +43,69 @@ export const MOCK_VALID_CONTRACTS = [
"EDR-2022-55442", "EDR-2022-55442",
]; ];
export const REQUIRED_DOC_KEYS = [ export const CONTAINER_TYPES = [
"tin_certificate", "Dry Container",
"business_license", "High Cubic",
"business_registration", "Reefer Container",
// "national_id", "Open Top",
"Flat Rack",
"Tank Container",
"Open Side",
] as const;
export const SHIPPING_LINES = [
"MSC",
"CMA CGM",
"Evergreen",
"COSCO",
"Hapag-Lloyd",
"ONE",
"Yang Ming",
"ZIM",
"Messina Line",
"Safmarine",
"Wan Hai",
"Ethiopian Shipping Lines (ESLSE)",
] as const; ] as const;
export const STEPS = [ export const STEPS = [
{ id: 1, label: "Contract Type", short: "Contract" }, { id: 1, label: "Contract Type", short: "Contract" },
{ id: 2, label: "Service Type", short: "Service" }, { id: 2, label: "Service Type & Mile", short: "Service" },
{ id: 3, label: "First & Last Mile", short: "Mile" }, { id: 3, label: "Route", short: "Route" },
{ id: 4, label: "Route", short: "Route" }, { id: 4, label: "Cargo Details", short: "Cargo" },
{ id: 5, label: "Cargo Details", short: "Cargo" }, { id: 5, label: "Review & Submit", short: "Submit" },
{ id: 6, label: "Wagon Allocation", short: "Wagons" },
{ id: 7, label: "Documents", short: "Docs" },
{ id: 8, label: "Review & Submit", short: "Submit" },
] as const; ] as const;
export const BOOKING_DOCS_SETTING = {
id: "booking-compliance",
createdAt: "",
updatedAt: "",
deletedAt: null,
code: "booking_compliance_docs",
label: "Compliance Documents",
description:
"Upload your company's legal credentials. All mandatory documents must be submitted before the contract request can be reviewed by EDR Line Staff.",
entity: "booking" as const,
fields: [
{
id: "f1",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText:
"Tax Identification Number certificate issued by ERCA (10-digit TIN).",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 1,
},
{
id: "f2",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "business_license",
fileLabel: "Business / Investment License",
helpText:
"Current business or investment license issued by the relevant government authority.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 2,
},
{
id: "f3",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "business_registration",
fileLabel: "Business Registration Certificate",
helpText: "Certificate of registration from the relevant authority.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 3,
},
{
id: "f5",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "booking-compliance",
fileKey: "power_of_attorney",
fileLabel: "Power of Attorney (PoA)",
helpText:
"Required only if a representative is signing on behalf of the company.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 5,
order: 5,
},
],
};
const fileValueSchema = z.union([
z.custom<File>(),
z.array(z.custom<File>()),
z.null(),
]);
export const bookingFormSchema = z 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."), serviceType: z.enum(["rail", "rail_forwarding"], "Select a service type."),
firstMileEnabled: z.boolean(), firstMile: z
pickUpAddress: z.string(), .object({
lastMileEnabled: z.boolean(), enabled: z.boolean().default(false),
deliveryAddress: z.string(), pickUpAddress: z.string(),
equipmentReturn: z.enum(["with_return", "without_return"]), })
originYard: z.string(), .refine((data) => !(data.enabled && !data.pickUpAddress.trim()), {
destinationYard: z.string(), message: "Enter the pick-up address.",
path: ["pickUpAddress"],
}),
lastMile: z
.object({
enabled: z.boolean().default(false),
deliveryAddress: z.string(),
})
.refine((data) => !(data.enabled && !data.deliveryAddress.trim()), {
message: "Enter the delivery address.",
path: ["deliveryAddress"],
}),
equipmentReturn: z
.enum(["with_return", "without_return"])
.default("with_return"),
customsClearingEnabled: z.boolean().default(false),
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(), cargoWeight: z.string(),
freightType: z.enum(["bulk", "break_bulk"]).optional(), freightType: z.enum(["bulk", "break_bulk"]).optional(),
@@ -171,142 +118,116 @@ export const bookingFormSchema = z
containers: z.array( containers: z.array(
z.object({ z.object({
type: z.enum(["20ft", "40ft"]), type: z.enum(["20ft", "40ft"]),
containerType: z.string().min(1, "Select a container type."),
qty: z qty: z
.string() .string()
.refine((q) => q.length !== 0, "Quantity is required.")
.refine((q) => !isNaN(+q), "Enter a valid Number") .refine((q) => !isNaN(+q), "Enter a valid Number")
.refine((qty) => Number(qty) >= 1, "Must be greater than 0"), .refine((qty) => Number(qty) >= 1, "Must be greater than 0"),
vgm: z vgm: z
.string() .string()
.refine((vgm) => vgm.length !== 0, "VGM is required.")
.refine((vgm) => !isNaN(+vgm), "Enter a valid Number") .refine((vgm) => !isNaN(+vgm), "Enter a valid Number")
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"), .refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
}), }),
), ),
consolidationEnabled: z.boolean(), consolidationEnabled: z.boolean(),
documents: z.record(z.string(), fileValueSchema),
notes: z.string(), notes: z.string(),
termsAccepted: z.boolean(), termsAccepted: z.boolean(),
}) })
.superRefine((data, ctx) => { .refine(
if (data.contractType === "renewal" && !data.previousContractRef.trim()) { (data) =>
ctx.addIssue({ !(data.contractType === "renewal" && !data.previousContractRef.trim()),
code: "custom", {
path: ["previousContractRef"], message: "Enter a previous contract reference.",
message: "Enter a previous contract reference.", path: ["previousContractRef"],
}); },
} )
.refine((data) => data.originYard !== "", {
if (data.firstMileEnabled && !data.pickUpAddress.trim()) { message: "Select an origin yard.",
ctx.addIssue({ path: ["originYard"],
code: "custom", })
path: ["pickUpAddress"], .refine((data) => data.destinationYard !== "", {
message: "Enter the pick-up address.", message: "Select a destination yard.",
}); path: ["destinationYard"],
} })
.refine(
if (data.lastMileEnabled && !data.deliveryAddress.trim()) { (data) =>
ctx.addIssue({ !(
code: "custom", data.originYard &&
path: ["deliveryAddress"], data.destinationYard &&
message: "Enter the delivery address.", data.originYard === data.destinationYard
}); ),
} {
message: "Destination must be different from origin.",
if (!data.originYard) { path: ["destinationYard"],
ctx.addIssue({ },
code: "custom", )
path: ["originYard"], .refine((data) => !(data.cargoType === "bulk" && !data.freightType), {
message: "Select an origin yard.", message: "Select a freight type.",
}); path: ["freightType"],
} })
.refine(
if (!data.destinationYard) { (data) =>
ctx.addIssue({ !(
code: "custom", data.cargoType === "bulk" &&
path: ["destinationYard"], data.freightType === "bulk" &&
message: "Select a destination yard.", !data.bulkCommodity
}); ),
} { message: "Select a commodity.", path: ["bulkCommodity"] },
)
if ( .refine(
data.originYard && (data) =>
data.destinationYard && !(
data.originYard === data.destinationYard data.cargoType === "bulk" &&
) { data.freightType === "bulk" &&
ctx.addIssue({ data.bulkCommodity === "Others" &&
code: "custom", !data.bulkCommodityOther.trim()
path: ["destinationYard"], ),
message: "Destination must be different from origin.", { message: "Specify the commodity.", path: ["bulkCommodityOther"] },
}); )
} .refine(
(data) =>
if (data.cargoType === "bulk") { !(
if (!data.freightType) { data.cargoType === "bulk" &&
ctx.addIssue({ data.freightType === "break_bulk" &&
code: "custom", !data.breakBulkType
path: ["freightType"], ),
message: "Select a freight type.", { message: "Select a break-bulk type.", path: ["breakBulkType"] },
}); )
} .refine(
(data) =>
if (data.freightType === "bulk") { !(
if (!data.bulkCommodity) { data.cargoType === "bulk" &&
ctx.addIssue({ data.freightType === "break_bulk" &&
code: "custom", data.breakBulkType === "Others" &&
path: ["bulkCommodity"], !data.breakBulkTypeOther.trim()
message: "Select a commodity.", ),
}); {
} message: "Specify the break-bulk type.",
if ( path: ["breakBulkTypeOther"],
data.bulkCommodity === "Others" && },
!data.bulkCommodityOther.trim() )
) { .refine(
ctx.addIssue({ (data) => {
code: "custom", if (data.cargoType !== "bulk") return true;
path: ["bulkCommodityOther"],
message: "Specify the commodity.",
});
}
}
if (data.freightType === "break_bulk") {
if (!data.breakBulkType) {
ctx.addIssue({
code: "custom",
path: ["breakBulkType"],
message: "Select a break-bulk type.",
});
}
if (
data.breakBulkType === "Others" &&
!data.breakBulkTypeOther.trim()
) {
ctx.addIssue({
code: "custom",
path: ["breakBulkTypeOther"],
message: "Specify the break-bulk type.",
});
}
}
const cargoWeight = Number(data.cargoWeight); const cargoWeight = Number(data.cargoWeight);
if (!data.cargoWeight || Number.isNaN(cargoWeight) || cargoWeight <= 0) { return (
ctx.addIssue({ !!data.cargoWeight && !Number.isNaN(cargoWeight) && cargoWeight > 0
code: "custom", );
path: ["cargoWeight"], },
message: "Enter a cargo weight greater than 0.", { message: "Enter a cargo weight greater than 0.", path: ["cargoWeight"] },
}); )
} .refine(
} (data) => !(data.cargoType === "container" && data.containers.length === 0),
{ message: "Add at least one container.", path: ["containers"] },
)
.refine((data) => data.termsAccepted, {
message: "Accept the freight contract terms to submit.",
path: ["termsAccepted"],
})
.superRefine((data, ctx) => {
if (data.cargoType === "container") { if (data.cargoType === "container") {
if (data.containers.length === 0) {
ctx.addIssue({
code: "custom",
path: ["containers"],
message: "Add at least one container.",
});
}
data.containers.forEach((c, i) => { data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) { if (!c.qty || +c.qty < 1) {
ctx.addIssue({ ctx.addIssue({
@@ -325,39 +246,26 @@ export const bookingFormSchema = z
} }
}); });
} }
for (const key of REQUIRED_DOC_KEYS) {
const value = data.documents[key];
const hasFile = Array.isArray(value) ? value.length > 0 : Boolean(value);
if (!hasFile) {
ctx.addIssue({
code: "custom",
path: ["documents", key],
message: "Upload this required document.",
});
}
}
if (!data.termsAccepted) {
ctx.addIssue({
code: "custom",
path: ["termsAccepted"],
message: "Accept the freight contract terms to submit.",
});
}
}); });
export type BookingFormValues = z.infer<typeof bookingFormSchema>; export type BookingFormValues = z.infer<typeof bookingFormSchema>;
export const initialBookingFormValues: Partial<BookingFormValues> = { export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
previousContractRef: "", previousContractRef: "",
firstMileEnabled: false,
pickUpAddress: "", firstMile: {
lastMileEnabled: false, enabled: false,
deliveryAddress: "", pickUpAddress: "",
},
lastMile: {
enabled: false,
deliveryAddress: "",
},
equipmentReturn: "with_return", equipmentReturn: "with_return",
customsClearingEnabled: false,
originYard: "", originYard: "",
destinationYard: "", destinationYard: "",
shippingLine: "",
cargoWeight: "", cargoWeight: "",
bulkCommodity: "", bulkCommodity: "",
bulkCommodityOther: "", bulkCommodityOther: "",
@@ -365,25 +273,29 @@ export const initialBookingFormValues: Partial<BookingFormValues> = {
breakBulkTypeOther: "", breakBulkTypeOther: "",
isHazardous: false, isHazardous: false,
isRefrigerated: false, isRefrigerated: false,
containers: [{ type: "20ft", qty: "1", vgm: "" }], containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
consolidationEnabled: false, consolidationEnabled: false,
documents: {},
notes: "", notes: "",
termsAccepted: false, termsAccepted: false,
}; };
export const stepFields: Record<number, Array<keyof BookingFormValues>> = { export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"], 1: ["contractType", "previousContractRef"],
2: ["serviceType"], 2: [
3: [ "serviceType",
"firstMileEnabled", "firstMile",
"pickUpAddress", "lastMile",
"lastMileEnabled",
"deliveryAddress",
"equipmentReturn", "equipmentReturn",
"customsClearingEnabled",
], ],
4: ["originYard", "destinationYard", "isHazardous", "isRefrigerated"], 3: [
5: [ "originYard",
"destinationYard",
"isHazardous",
"isRefrigerated",
"shippingLine",
],
4: [
"cargoType", "cargoType",
"cargoWeight", "cargoWeight",
"freightType", "freightType",
@@ -392,16 +304,16 @@ export const stepFields: Record<number, Array<keyof BookingFormValues>> = {
"breakBulkType", "breakBulkType",
"breakBulkTypeOther", "breakBulkTypeOther",
"containers", "containers",
"consolidationEnabled",
], ],
6: ["consolidationEnabled"], 5: ["notes", "termsAccepted"],
7: ["documents"],
8: ["notes", "termsAccepted"],
}; };
export type RouteDirection = "import" | "export" | "domestic" | null; export type RouteDirection = "import" | "export" | "domestic" | null;
export interface ContainerConfig { export interface ContainerConfig {
type: "20ft" | "40ft"; type: "20ft" | "40ft";
containerType: string;
qty: string; qty: string;
vgm: string; vgm: string;
} }

View File

@@ -22,18 +22,8 @@ import {
SelectValue, SelectValue,
} from "@edr/ui-common"; } from "@edr/ui-common";
import type { BookingFormValues } from "./schema"; import type { BookingFormValues } from "./schema";
import { REQUIRED_DOC_KEYS } from "./schema";
import { cn } from "@/lib/utils"; 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 } }) { export function OptionFieldError({ error }: { error?: { message?: string } }) {
return <FieldError errors={[error]} />; return <FieldError errors={[error]} />;
} }
@@ -160,6 +150,8 @@ export function SelectField({
); );
} }
export { SelectItem };
export function SelectOptions({ options }: { options: readonly string[] }) { export function SelectOptions({ options }: { options: readonly string[] }) {
return ( return (
<> <>

View File

@@ -1,22 +1,19 @@
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Loader2, RefreshCw } from "lucide-react"; import { FileText, RefreshCw } from "lucide-react";
import { Button, Field, FieldLabel, Input } from "@edr/ui-common"; import { Field } from "@edr/ui-common";
import { type BookingFormValues } from "./schema"; import { MOCK_VALID_CONTRACTS, type BookingFormValues } from "./schema";
import { AlertBox, OptionCard, OptionFieldError, StepHeader } from "./shared"; import {
AlertBox,
OptionCard,
OptionFieldError,
SelectField,
SelectItem,
StepHeader,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>; type BookingForm = UseFormReturn<BookingFormValues>;
export function Step1ContractType({ export function Step1ContractType({ form }: { form: BookingForm }) {
form,
renewalValid,
renewalValidating,
onValidate,
}: {
form: BookingForm;
renewalValid: boolean | null;
renewalValidating: boolean;
onValidate: () => void;
}) {
const contractType = form.watch("contractType"); const contractType = form.watch("contractType");
const previousContractRef = form.watch("previousContractRef"); const previousContractRef = form.watch("previousContractRef");
@@ -38,6 +35,7 @@ export function Step1ContractType({
onClick={() => { onClick={() => {
field.onChange("new"); field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]); form.clearErrors(["contractType", "previousContractRef"]);
form.setValue("previousContractRef", "");
}} }}
> >
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
@@ -45,7 +43,7 @@ export function Step1ContractType({
</div> </div>
<p className="font-semibold">New Contract</p> <p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Blank contract form. A draft ID is auto-generated. Create a new contract.
</p> </p>
</OptionCard> </OptionCard>
@@ -61,7 +59,7 @@ export function Step1ContractType({
</div> </div>
<p className="font-semibold">Contract Renewal</p> <p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Enter a previous reference to auto-populate historical Select a previous reference to auto-populate historical
parameters. parameters.
</p> </p>
</OptionCard> </OptionCard>
@@ -77,46 +75,26 @@ export function Step1ContractType({
name="previousContractRef" name="previousContractRef"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <SelectField
<FieldLabel htmlFor="previousContractRef"> field={field}
Previous Contract Reference Number error={fieldState.error}
</FieldLabel> label="Previous Contract Reference Number"
<div className="flex gap-2"> placeholder="Select a contract..."
<Input >
{...field} {MOCK_VALID_CONTRACTS.map((ref) => (
id="previousContractRef" <SelectItem key={ref} value={ref}>
aria-invalid={fieldState.invalid} {ref}
placeholder="e.g. EDR-2024-10001" </SelectItem>
className="font-mono" ))}
/> </SelectField>
<Button
type="button"
variant="outline"
onClick={onValidate}
disabled={!previousContractRef || renewalValidating}
>
{renewalValidating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Validate"
)}
</Button>
</div>
</Field>
)} )}
/> />
{renewalValid === true && ( {previousContractRef && (
<AlertBox tone="success"> <AlertBox tone="success">
<strong>Contract found.</strong> Company details, route, and wagon <strong>Contract found.</strong> Company details, route, and wagon
preferences will be pre-filled. preferences will be pre-filled.
</AlertBox> </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>
)} )}
</div> </div>

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { Package, Train } from "lucide-react"; import { FileText, Package, Train, Truck } from "lucide-react";
import { Badge, Field, FieldError } from "@edr/ui-common"; import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
import { type BookingFormValues } from "./schema"; import { type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared"; import { OptionCard, OptionFieldError, StepHeader } from "./shared";
@@ -8,12 +9,67 @@ type BookingForm = UseFormReturn<BookingFormValues>;
export function Step2ServiceType({ form }: { form: BookingForm }) { export function Step2ServiceType({ form }: { form: BookingForm }) {
const serviceType = form.watch("serviceType"); const serviceType = form.watch("serviceType");
const firstMileEnabled = form.watch("firstMile.enabled");
const lastMileEnabled = form.watch("lastMile.enabled");
const prevServiceType = useRef(serviceType);
useEffect(() => {
const prev = prevServiceType.current;
prevServiceType.current = serviceType;
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,
});
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";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<StepHeader <StepHeader
title="Service Type" title="Service Type"
description="Select the service combination you require." description="Select the service combination and configure trucking options."
/> />
<Controller <Controller
@@ -46,9 +102,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10"> <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" /> <Package className="h-4 w-4 text-primary" />
</div> </div>
<p className="font-semibold"> <p className="font-semibold">Logistics</p>
Rail Transport & Freight Forwarding
</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Rail transport plus documentation, customs liaison, and a Rail transport plus documentation, customs liaison, and a
dedicated coordinator. dedicated coordinator.
@@ -63,10 +117,172 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
)} )}
/> />
<p className="text-xs text-muted-foreground"> {showServiceSections && (
Customs and Clearance Service cannot be selected independently. It must <div className="divide-y divide-border rounded-xl border border-border">
be bundled with a Rail Transport service. <div className="p-4">
</p> <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-muted-foreground" />
<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 (Door to Port) to the
origin rail yard.
</p>
</div>
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</div>
)}
/>
{firstMileEnabled && (
<Controller
name="firstMile.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="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-muted-foreground" />
<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 (Port to Door).
</p>
</div>
</div>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
/>
</div>
)}
/>
{lastMileEnabled && (
<Controller
name="lastMile.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>
{lastMileEnabled && (
<div className="p-4">
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
</div>
<Switch
checked={field.value === "with_return"}
onCheckedChange={(value) => {
field.onChange(
value ? "with_return" : "without_return",
);
}}
/>
</div>
)}
/>
</div>
)}
<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-muted-foreground" />
<div>
<p className="text-sm font-medium">
Customs Clearing Service
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
EDR handles customs documentation and clearance on your
behalf.
</p>
</div>
</div>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</div>
)}
/>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { Field, FieldError, Input, Switch } from "@edr/ui-common"; import { Field, FieldError, Input, Switch } from "@edr/ui-common";
import { type BookingFormValues } from "./schema"; import { type BookingFormValues } from "./schema";
import { OptionCard, StepHeader } from "./shared"; import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>; type BookingForm = UseFormReturn<BookingFormValues>;
@@ -27,7 +27,8 @@ export function Step3FirstLastMile({ form }: { form: BookingForm }) {
<div> <div>
<p className="text-sm font-medium">First Mile - Pick-up</p> <p className="text-sm font-medium">First Mile - Pick-up</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Truck pick-up from your premises to the origin rail yard. Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p> </p>
</div> </div>
<Switch <Switch
@@ -73,7 +74,7 @@ export function Step3FirstLastMile({ form }: { form: BookingForm }) {
<p className="text-sm font-medium">Last Mile - Delivery</p> <p className="text-sm font-medium">Last Mile - Delivery</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Truck delivery from the destination rail yard to the final Truck delivery from the destination rail yard to the final
address. address (Port to Door).
</p> </p>
</div> </div>
<Switch <Switch
@@ -107,40 +108,35 @@ export function Step3FirstLastMile({ form }: { form: BookingForm }) {
)} )}
/> />
)} )}
</div>
<div className="p-4"> {lastMileEnabled && (
<p className="mb-3 text-sm font-medium">Equipment Return</p> <div className="mt-4 border-t border-border pt-4">
<p className="mb-3 text-xs text-muted-foreground"> <Controller
Declare whether the container asset will be returned after name="equipmentReturn"
unloading. control={form.control}
</p> render={({ field }) => (
<Controller <div className="flex items-start justify-between gap-4">
name="equipmentReturn" <div>
control={form.control} <p className="text-sm font-medium">Equipment Return</p>
render={({ field }) => ( <p className="mt-0.5 text-xs text-muted-foreground">
<div className="grid gap-2 sm:grid-cols-2"> {field.value === "with_return"
<OptionCard ? "Container returned to EDR after unloading."
selected={equipmentReturn === "with_return"} : "Container retained by the customer after delivery."}
onClick={() => field.onChange("with_return")} </p>
> </div>
<p className="text-sm font-semibold">With Return</p> <Switch
<p className="mt-0.5 text-xs text-muted-foreground"> checked={field.value === "with_return"}
Container returned to EDR after unloading. onCheckedChange={(value) => {
</p> field.onChange(
</OptionCard> value ? "with_return" : "without_return",
<OptionCard );
selected={equipmentReturn === "without_return"} }}
onClick={() => field.onChange("without_return")} />
> </div>
<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. </div>
</p> )}
</OptionCard>
</div>
)}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,12 @@
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react"; import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common"; import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema"; import {
SHIPPING_LINES,
type BookingFormValues,
getRouteDirection,
STATIONS,
} from "./schema";
import { import {
AlertBox, AlertBox,
SelectField, SelectField,
@@ -11,6 +16,7 @@ import {
} from "./shared"; } from "./shared";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings"; import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import { DropdownOption } from "@/types/dropdownSettings"; import { DropdownOption } from "@/types/dropdownSettings";
import { useEffect } from "react";
type BookingForm = UseFormReturn<BookingFormValues>; type BookingForm = UseFormReturn<BookingFormValues>;
@@ -39,6 +45,12 @@ export function Step4Route({ form }: { form: BookingForm }) {
}; };
const stationSelectDisabled = stationsLoading || stationOptions.length === 0; const stationSelectDisabled = stationsLoading || stationOptions.length === 0;
useEffect(() => {
if (direction === "domestic") {
form.setValue("shippingLine", "", { shouldDirty: true });
}
}, [direction]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<StepHeader <StepHeader
@@ -106,6 +118,22 @@ export function Step4Route({ form }: { form: BookingForm }) {
)} )}
</div> </div>
{direction && direction != "domestic" && (
<Controller
name="shippingLine"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Shipping Line"
placeholder="Select shipping line..."
>
<SelectOptions options={SHIPPING_LINES} />
</SelectField>
)}
/>
)}
<Separator /> <Separator />
<div className="space-y-0 divide-y divide-border"> <div className="space-y-0 divide-y divide-border">

View File

@@ -1,16 +1,11 @@
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import { import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common";
Button,
Field,
FieldError,
FieldLabel,
Input,
Separator,
} from "@edr/ui-common";
import { import {
BREAK_BULK_TYPES, BREAK_BULK_TYPES,
BULK_COMMODITIES, BULK_COMMODITIES,
CONTAINER_TYPES,
calcWagons,
type BookingFormValues, type BookingFormValues,
type RouteDirection, type RouteDirection,
} from "./schema"; } from "./schema";
@@ -81,7 +76,6 @@ export function Step5CargoDetails({
form.setValue("freightType", undefined, { form.setValue("freightType", undefined, {
shouldDirty: true, 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"> <div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
@@ -96,11 +90,7 @@ export function Step5CargoDetails({
selected={cargoType === "bulk"} selected={cargoType === "bulk"}
onClick={() => { onClick={() => {
field.onChange("bulk"); field.onChange("bulk");
form.setValue( form.setValue("containers", [], { shouldDirty: true });
"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"> <div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
@@ -118,173 +108,162 @@ export function Step5CargoDetails({
/> />
</div> </div>
<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(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 === "bulk" && ( {cargoType === "bulk" && (
<> <div className="space-y-3">
<Separator /> <StepLabel>Freight Type *</StepLabel>
<div className="space-y-3"> <Controller
<StepLabel>Freight Type *</StepLabel> name="freightType"
<Controller control={form.control}
name="freightType" render={({ field, fieldState }) => (
control={form.control} <Field data-invalid={fieldState.invalid}>
render={({ field, fieldState }) => ( <div className="grid gap-3 sm:grid-cols-2">
<Field data-invalid={fieldState.invalid}> <OptionCard
<div className="grid gap-3 sm:grid-cols-2"> selected={freightType === "bulk"}
<OptionCard onClick={() => field.onChange("bulk")}
selected={freightType === "bulk"} >
onClick={() => field.onChange("bulk")} <p className="font-semibold">Bulk</p>
> <p className="mt-0.5 text-xs text-muted-foreground">
<p className="font-semibold">Bulk</p> Coffee, fertilizer, grain, ore, etc.
<p className="mt-0.5 text-xs text-muted-foreground"> </p>
Coffee, fertilizer, grain, ore, etc. </OptionCard>
</p> <OptionCard
</OptionCard> selected={freightType === "break_bulk"}
<OptionCard onClick={() => field.onChange("break_bulk")}
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">
<p className="font-semibold">Break-Bulk</p> Machinery, vehicles, project cargo, etc.
<p className="mt-0.5 text-xs text-muted-foreground"> </p>
Machinery, vehicles, project cargo, etc. </OptionCard>
</p> </div>
</OptionCard> <FieldError errors={[fieldState.error]} />
</div> </Field>
<FieldError errors={[fieldState.error]} /> )}
</Field> />
)}
/>
{freightType === "bulk" && ( {freightType === "bulk" && (
<div className="space-y-2"> <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 <Controller
name="bulkCommodity" name="bulkCommodityOther"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<SelectField <Field data-invalid={fieldState.invalid}>
field={field} <Input
error={fieldState.error} {...field}
label="Commodity *" aria-invalid={fieldState.invalid}
placeholder="Select commodity *" placeholder="Specify commodity *"
> />
<SelectOptions options={BULK_COMMODITIES} /> <FieldError errors={[fieldState.error]} />
</SelectField> </Field>
)} )}
/> />
{bulkCommodity === "Others" && ( )}
<Controller </div>
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" && ( {freightType === "break_bulk" && (
<div className="space-y-2"> <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 <Controller
name="breakBulkType" name="breakBulkTypeOther"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<SelectField <Field data-invalid={fieldState.invalid}>
field={field} <Input
error={fieldState.error} {...field}
label="Break-bulk type *" aria-invalid={fieldState.invalid}
placeholder="Select type *" placeholder="Specify break-bulk type *"
> />
<SelectOptions options={BREAK_BULK_TYPES} /> <FieldError errors={[fieldState.error]} />
</SelectField> </Field>
)} )}
/> />
{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>
</div> )}
</> </div>
)} )}
{cargoType === "container" && ( {cargoType === "container" && (
<> <>
<Separator />
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<StepLabel>Container Configuration</StepLabel> <StepLabel>Containers</StepLabel>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => append({ type: "20ft", qty: "1", vgm: "0" })} onClick={() =>
append({
type: "20ft",
containerType: "",
qty: "1",
vgm: "",
})
}
> >
<Plus className="mr-1 h-3.5 w-3.5" /> <Plus className="mr-1 h-3.5 w-3.5" />
Add Container Add Container
</Button> </Button>
</div> </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) => { {fields.map((field, index) => {
const containerType = containers[index]?.type; const containerType = containers[index]?.type;
const vgm = containers[index]?.vgm ?? 0; const vgm = containers[index]?.vgm ?? 0;
@@ -293,12 +272,9 @@ export function Step5CargoDetails({
return ( return (
<div <div
key={field.id} key={field.id}
className="space-y-3 rounded-xl border border-border p-4" className="rounded-xl flex flex-col border border-border p-3 gap-2"
> >
<div className="flex items-center justify-between"> <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 && ( {fields.length > 1 && (
<button <button
type="button" type="button"
@@ -315,7 +291,6 @@ export function Step5CargoDetails({
control={form.control} control={form.control}
render={({ field: typeField, fieldState }) => ( render={({ field: typeField, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <Field data-invalid={fieldState.invalid}>
<FieldLabel>Container Type *</FieldLabel>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{[ {[
{ {
@@ -352,7 +327,7 @@ export function Step5CargoDetails({
)} )}
/> />
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-3">
<Controller <Controller
name={`containers.${index}.qty`} name={`containers.${index}.qty`}
control={form.control} control={form.control}
@@ -407,7 +382,7 @@ export function Step5CargoDetails({
control={form.control} control={form.control}
render={({ field: vgmField, fieldState }) => ( render={({ field: vgmField, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <Field data-invalid={fieldState.invalid}>
<FieldLabel>VGM (Tons) *</FieldLabel> <FieldLabel>Tons*</FieldLabel>
<Input <Input
value={vgmField.value ?? 0} value={vgmField.value ?? 0}
onChange={(e) => vgmField.onChange(e.target.value)} onChange={(e) => vgmField.onChange(e.target.value)}
@@ -422,6 +397,21 @@ export function Step5CargoDetails({
</Field> </Field>
)} )}
/> />
<Controller
name={`containers.${index}.containerType`}
control={form.control}
render={({ field: ctField, fieldState }) => (
<SelectField
field={ctField}
error={fieldState.error}
label="Container Type *"
placeholder="Select type..."
>
<SelectOptions options={CONTAINER_TYPES} />
</SelectField>
)}
/>
</div> </div>
{alert && ( {alert && (
@@ -433,6 +423,29 @@ export function Step5CargoDetails({
); );
})} })}
</div> </div>
{(() => {
const result = calcWagons(containers ?? []);
if (result.hasOddUnit) {
return (
<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>
);
}
return null;
})()}
</> </>
)} )}
</div> </div>

View File

@@ -11,24 +11,21 @@ import {
Textarea, Textarea,
} from "@edr/ui-common"; } from "@edr/ui-common";
import { import {
REQUIRED_DOC_KEYS,
type BookingFormValues, type BookingFormValues,
type RouteDirection, type RouteDirection,
type WagonCalcResult, type WagonCalcResult,
} from "./schema"; } from "./schema";
import { getUploadedRequiredCount, StepHeader } from "./shared"; import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>; type BookingForm = UseFormReturn<BookingFormValues>;
export function Step8Review({ export function Step8Review({
form, form,
setStep, setStep,
wagons,
direction, direction,
}: { }: {
form: BookingForm; form: BookingForm;
setStep: (step: number) => void; setStep: (step: number) => void;
wagons: WagonCalcResult | null;
direction: RouteDirection; direction: RouteDirection;
}) { }) {
const values = form.watch(); const values = form.watch();
@@ -63,13 +60,16 @@ export function Step8Review({
const containerSummary = const containerSummary =
values.cargoType === "container" && values.containers.length > 0 values.cargoType === "container" && values.containers.length > 0
? values.containers ? values.containers
.filter((c) => c.qty > 0) .filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`) .map((c) => `${c.qty} × ${c.type}`)
.join(", ") .join(", ")
: ""; : "";
const totalVgm = const totalVgm =
values.cargoType === "container" values.cargoType === "container"
? values.containers.reduce((sum, c) => sum + (c.qty || 0) * (c.vgm || 0), 0) ? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0,
)
: 0; : 0;
const cargoValue = const cargoValue =
@@ -80,8 +80,6 @@ export function Step8Review({
: values.freightType === "break_bulk" : values.freightType === "break_bulk"
? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}` ? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}`
: ""; : "";
const uploadedCount = getUploadedRequiredCount(values.documents);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<StepHeader <StepHeader
@@ -91,7 +89,7 @@ export function Step8Review({
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<Card className="gap-0 overflow-hidden py-0"> <Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3"> <CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Contract & Service Contract & Service
</CardTitle> </CardTitle>
@@ -102,11 +100,6 @@ export function Step8Review({
value={values.contractType === "new" ? "New Contract" : "Renewal"} value={values.contractType === "new" ? "New Contract" : "Renewal"}
target={1} target={1}
/> />
<Row
label="Contract ID"
value={values.draftContractId || values.previousContractRef}
target={1}
/>
<Row <Row
label="Service" label="Service"
value={ value={
@@ -122,7 +115,7 @@ export function Step8Review({
</Card> </Card>
<Card className="gap-0 overflow-hidden py-0"> <Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3"> <CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
First & Last Mile First & Last Mile
</CardTitle> </CardTitle>
@@ -131,18 +124,20 @@ export function Step8Review({
<Row <Row
label="First Mile" label="First Mile"
value={ value={
values.firstMileEnabled ? values.pickUpAddress : "Not requested" values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
} }
target={3} target={2}
/> />
<Row <Row
label="Last Mile" label="Last Mile"
value={ value={
values.lastMileEnabled values.lastMile.enabled
? values.deliveryAddress ? values.lastMile.deliveryAddress
: "Not requested" : "Not requested"
} }
target={3} target={2}
/> />
<Row <Row
label="Equipment Return" label="Equipment Return"
@@ -151,13 +146,20 @@ export function Step8Review({
? "With Return" ? "With Return"
: "Without Return" : "Without Return"
} }
target={3} target={2}
/>
<Row
label="Customs Clearing"
value={
values.customsClearingEnabled ? "Enabled" : "Not requested"
}
target={2}
/> />
</CardContent> </CardContent>
</Card> </Card>
<Card className="gap-0 overflow-hidden py-0"> <Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3"> <CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Route & Cargo Route & Cargo
</CardTitle> </CardTitle>
@@ -166,7 +168,7 @@ export function Step8Review({
<Row <Row
label="Route" label="Route"
value={`${values.originYard} -> ${values.destinationYard}`} value={`${values.originYard} -> ${values.destinationYard}`}
target={4} target={3}
/> />
<Row <Row
label="Workflow" label="Workflow"
@@ -175,14 +177,14 @@ export function Step8Review({
? direction.charAt(0).toUpperCase() + direction.slice(1) ? direction.charAt(0).toUpperCase() + direction.slice(1)
: "" : ""
} }
target={4} target={3}
/> />
<Row <Row
label="Weight (VGM)" label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""} value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={5} target={4}
/> />
<Row label="Cargo" value={cargoValue} target={5} /> <Row label="Cargo" value={cargoValue} target={4} />
<Row <Row
label="Modifiers" label="Modifiers"
value={ value={
@@ -193,13 +195,13 @@ export function Step8Review({
.filter(Boolean) .filter(Boolean)
.join(", ") || "None" .join(", ") || "None"
} }
target={4} target={3}
/> />
</CardContent> </CardContent>
</Card> </Card>
<Card className="gap-0 overflow-hidden py-0"> <Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3"> <CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Container & Wagons Container & Wagons
</CardTitle> </CardTitle>
@@ -208,26 +210,12 @@ export function Step8Review({
<Row <Row
label="Containers" label="Containers"
value={containerSummary || "-"} value={containerSummary || "-"}
target={5} target={4}
/> />
<Row <Row
label="Total VGM" label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""} value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={5} target={4}
/>
<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> </CardContent>
</Card> </Card>

View File

@@ -1,8 +1,5 @@
export { Step1ContractType } from "./step1-contract-type"; export { Step1ContractType } from "./step1-contract-type";
export { Step2ServiceType } from "./step2-service-type"; export { Step2ServiceType } from "./step2-service-type";
export { Step3FirstLastMile } from "./step3-first-last-mile";
export { Step4Route } from "./step4-route"; export { Step4Route } from "./step4-route";
export { Step5CargoDetails } from "./step5-cargo-details"; export { Step5CargoDetails } from "./step5-cargo-details";
export { Step6WagonAllocation } from "./step6-wagon-allocation";
export { Step7Documents } from "./step7-documents";
export { Step8Review } from "./step8-review"; export { Step8Review } from "./step8-review";

View File

@@ -221,6 +221,7 @@ export interface CreateBookingDto {
lastMileDeliveryAddress?: string; lastMileDeliveryAddress?: string;
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN"; equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
customsClearingEnabled?: boolean;
originStation: string; originStation: string;
destinationStation: string; destinationStation: string;
cargoTotalWeightVgm: number; cargoTotalWeightVgm: number;

5
tasks.md Normal file
View File

@@ -0,0 +1,5 @@
- In ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx the input field for the previous contract ref should be a select field with the list of contracts from the API
- the "Equipment Return" field in ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx should be a toggle like Last Mile - Delivery and should appear only if the last mile is toggled. and also add "( Door to Port)" to first mile description and vice versa to the last mile
- add Type of container- Dry container, high cubic containers , reefer containers, open top containers, flat rack, tank container, open side containers to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
- merge the "Unpaired 20ft Container" from ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx to step 4 and fully remove the step 5
- add Type of Shipping lines - MSC, CMA CGM, Evergreen, COSCO, Hapag-Lloyd, ONE, Yang Ming, ZIM, Messina Line, Safmarine, Wan Hai, Ethiopian Shipping Lines (ESLSE) to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx