mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #62 from Tria-plc/freight/feature/booking-api
WIP booking api integration
This commit is contained in:
@@ -1,14 +1,23 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Check, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LoaderCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import { api } from "@/services/api";
|
||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
STEPS,
|
||||
bookingFormSchema,
|
||||
calcWagons,
|
||||
@@ -32,6 +41,10 @@ export default function NewBookingPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState(1);
|
||||
const { customer } = useAuth();
|
||||
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||
api.bookings.referenceData.queryOptions(),
|
||||
);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: CreateBookingPayload) =>
|
||||
api.bookings.create.call(payload),
|
||||
@@ -41,7 +54,7 @@ export default function NewBookingPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<BookingFormValues>({
|
||||
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
|
||||
defaultValues: initialBookingFormValues,
|
||||
resolver: zodResolver(bookingFormSchema),
|
||||
mode: "onChange",
|
||||
@@ -49,18 +62,12 @@ export default function NewBookingPage() {
|
||||
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
const containers = form.watch("containers");
|
||||
|
||||
const direction = useMemo(
|
||||
() => getRouteDirection(originYard, destinationYard),
|
||||
[originYard, destinationYard],
|
||||
);
|
||||
|
||||
const wagons = useMemo(() => {
|
||||
if (!containers || containers.length === 0) return null;
|
||||
return calcWagons(containers);
|
||||
}, [containers]);
|
||||
|
||||
async function handleContinue() {
|
||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||
if (!valid) return;
|
||||
@@ -78,8 +85,6 @@ export default function NewBookingPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const reference = data.previousContractRef;
|
||||
|
||||
const totalWeight =
|
||||
data.cargoType === "container"
|
||||
? data.containers.reduce(
|
||||
@@ -88,59 +93,115 @@ export default function NewBookingPage() {
|
||||
)
|
||||
: Number(data.cargoWeight || 0);
|
||||
|
||||
const apiPayload = {
|
||||
reference,
|
||||
customerId: customer!.id,
|
||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||
totalAmount: 0,
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
previousContractId: data.previousContractRef || undefined,
|
||||
serviceType:
|
||||
data.service.serviceType === "rail"
|
||||
? "RAIL_ONLY"
|
||||
: "RAIL_AND_FORWARDING",
|
||||
...(data.service.serviceType === "rail"
|
||||
? {}
|
||||
: {
|
||||
firstMileEnabled: data.firstMile.enabled,
|
||||
firstMilePickupAddress: data.firstMile.pickUpAddress ?? undefined,
|
||||
lastMileEnabled: data.lastMile.enabled,
|
||||
lastMileDeliveryAddress: data.lastMile.deliveryAddress ?? undefined,
|
||||
equipmentReturn:
|
||||
data.equipmentReturn === "with_return"
|
||||
? ("WITH_RETURN" as const)
|
||||
: ("WITHOUT_RETURN" as const),
|
||||
customsClearingEnabled: data.customsClearingEnabled,
|
||||
}),
|
||||
originStation: data.originYard,
|
||||
destinationStation: data.destinationYard,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
freightType: data.cargoType === "container" ? "BREAK_BULK" : "BULK",
|
||||
freightSubtype:
|
||||
data.cargoType === "container"
|
||||
? undefined
|
||||
: data.freightType === "bulk"
|
||||
// ── Reference data lookups ──────────────────────────────────────────
|
||||
const yards = referenceData?.yard ?? [];
|
||||
const services = referenceData?.service ?? [];
|
||||
const shippingLines = referenceData?.shipping_line ?? [];
|
||||
const cargoTree = referenceData?.cargo_type ?? [];
|
||||
const containerGroups = referenceData?.containers ?? [];
|
||||
|
||||
const findYardId = (name: string): string =>
|
||||
yards.find((y) => y.name === name)?.id ?? "";
|
||||
|
||||
const findServiceTypeId = (): string => {
|
||||
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
|
||||
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
|
||||
};
|
||||
|
||||
const findShippingLineId = (name: string): string | undefined =>
|
||||
shippingLines.find((l) => l.name === name)?.id;
|
||||
|
||||
const findCargoTypeId = (name: string): string | undefined => {
|
||||
for (const group of cargoTree) {
|
||||
const child = group.children?.find((c) => c.name === name);
|
||||
if (child) return child.id;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const findContainerCargoTypeId = (): string => {
|
||||
const group = cargoTree.find(
|
||||
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
|
||||
);
|
||||
console.log(group, cargoTree);
|
||||
return group?.id ?? "";
|
||||
};
|
||||
|
||||
const findContainerTypeId = (name: string): string => {
|
||||
for (const group of containerGroups) {
|
||||
const ct = group.types.find((t) => t.name === name);
|
||||
if (ct) return ct.id;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const cargoTypeId =
|
||||
data.cargoType === "container"
|
||||
? findContainerCargoTypeId()
|
||||
: (findCargoTypeId(
|
||||
data.freightType === "bulk"
|
||||
? data.bulkCommodity
|
||||
: data.breakBulkType,
|
||||
isHazardous: data.isHazardous,
|
||||
isRefrigerated: data.isRefrigerated,
|
||||
) ?? "");
|
||||
|
||||
const cargoFreeText =
|
||||
data.cargoType === "container"
|
||||
? undefined
|
||||
: data.freightType === "bulk" && data.bulkCommodity === "Others"
|
||||
? data.bulkCommodityOther
|
||||
: data.freightType === "break_bulk" && data.breakBulkType === "Others"
|
||||
? data.breakBulkTypeOther
|
||||
: undefined;
|
||||
|
||||
// ── Build API payload ───────────────────────────────────────────────
|
||||
const apiPayload: CreateBookingPayload = {
|
||||
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
serviceTypeId: findServiceTypeId(),
|
||||
equipmentReturn:
|
||||
data.equipmentReturn === "with_return"
|
||||
? "WITH_RETURN"
|
||||
: "WITHOUT_RETURN",
|
||||
originYardId: findYardId(data.originYard),
|
||||
destinationYardId: findYardId(data.destinationYard),
|
||||
tradeDirection:
|
||||
getRouteDirection(data.originYard, data.destinationYard) === "export"
|
||||
direction === "export"
|
||||
? "EXPORT"
|
||||
: "IMPORT",
|
||||
: direction === "domestic"
|
||||
? "DOMESTIC"
|
||||
: "IMPORT",
|
||||
cargoTypeId,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: data.isHazardous,
|
||||
paymentCurrency: "USD",
|
||||
allowConsolidation: data.consolidationEnabled,
|
||||
...(data.cargoType === "container" && data.containers.length > 0
|
||||
? {
|
||||
containers: data.containers.map((c) => ({
|
||||
type: c.type === "40ft" ? ("40FT" as const) : ("20FT" as const),
|
||||
qty: Number(c.qty || 1),
|
||||
vgm: Number(c.vgm || 0),
|
||||
})),
|
||||
}
|
||||
containers:
|
||||
data.cargoType === "container"
|
||||
? data.containers.map((c) => ({
|
||||
containerTypeId: findContainerTypeId(c.containerType),
|
||||
quantity: Number(c.qty || 1),
|
||||
vgmPerUnitTons: Number(c.vgm || 0),
|
||||
}))
|
||||
: [],
|
||||
...(customer ? { customerId: customer.id } : {}),
|
||||
...(data.previousContractRef
|
||||
? { previousContractId: data.previousContractRef }
|
||||
: {}),
|
||||
} satisfies CreateBookingPayload;
|
||||
...(data.contractType === "renewal" && data.previousContractRef
|
||||
? { pnrCode: data.previousContractRef }
|
||||
: {}),
|
||||
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
|
||||
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
|
||||
: {}),
|
||||
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
|
||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||
: {}),
|
||||
...(data.shippingLine
|
||||
? { shippingLineId: findShippingLineId(data.shippingLine) }
|
||||
: {}),
|
||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||
};
|
||||
|
||||
createMutation.mutate(apiPayload);
|
||||
});
|
||||
@@ -179,11 +240,35 @@ export default function NewBookingPage() {
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
{createMutation.isError && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="font-semibold">Submission failed</p>
|
||||
<p className="mt-1 text-red-600">
|
||||
{createMutation.error instanceof Error
|
||||
? createMutation.error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && <Step1ContractType form={form} />}
|
||||
{step === 2 && <Step2ServiceType form={form} />}
|
||||
{step === 3 && <Step4Route form={form} />}
|
||||
{step === 3 && (
|
||||
<Step4Route
|
||||
form={form}
|
||||
referenceData={referenceData}
|
||||
isLoading={refDataLoading}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<Step5CargoDetails form={form} direction={direction} />
|
||||
<Step5CargoDetails
|
||||
form={form}
|
||||
direction={direction}
|
||||
referenceData={referenceData}
|
||||
isLoading={refDataLoading}
|
||||
/>
|
||||
)}
|
||||
{step === 5 && (
|
||||
<Step8Review form={form} setStep={setStep} direction={direction} />
|
||||
@@ -210,9 +295,19 @@ export default function NewBookingPage() {
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" form="new-booking-form">
|
||||
<Check />
|
||||
Submit Contract Request
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-booking-form"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check />
|
||||
)}
|
||||
{createMutation.isPending
|
||||
? "Submitting..."
|
||||
: "Submit Contract Request"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -249,6 +249,7 @@ export const bookingFormSchema = z
|
||||
});
|
||||
|
||||
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||
export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
|
||||
|
||||
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
previousContractRef: "",
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@edr/ui-common";
|
||||
import type { BookingFormValues } from "./schema";
|
||||
import type { BookingFormInputValues, BookingFormValues } from "./schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
||||
@@ -122,7 +122,7 @@ export function SelectField({
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
field: ControllerRenderProps<BookingFormValues>;
|
||||
field: ControllerRenderProps<BookingFormInputValues>;
|
||||
error?: RhfFieldError;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { Field } from "@edr/ui-common";
|
||||
import { MOCK_VALID_CONTRACTS, type BookingFormValues } from "./schema";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
MOCK_VALID_CONTRACTS,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import {
|
||||
AlertBox,
|
||||
OptionCard,
|
||||
@@ -11,7 +15,11 @@ import {
|
||||
StepHeader,
|
||||
} from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
any,
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
export function Step1ContractType({ form }: { form: BookingForm }) {
|
||||
const contractType = form.watch("contractType");
|
||||
|
||||
@@ -2,10 +2,14 @@ import { useEffect, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { FileText, Package, Train, Truck } from "lucide-react";
|
||||
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
|
||||
import { type BookingFormValues } from "./schema";
|
||||
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
||||
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
any,
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
export function Step2ServiceType({ form }: { form: BookingForm }) {
|
||||
const serviceType = form.watch("serviceType");
|
||||
|
||||
@@ -1,37 +1,50 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
||||
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
|
||||
import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
SHIPPING_LINES,
|
||||
BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
getRouteDirection,
|
||||
STATIONS,
|
||||
} from "./schema";
|
||||
import {
|
||||
AlertBox,
|
||||
SelectField,
|
||||
SelectOptions,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
||||
import { DropdownOption } from "@/types/dropdownSettings";
|
||||
import { useEffect } from "react";
|
||||
import { SelectField, StepHeader, StepLabel } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
any,
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
const STATION_DROPDOWN_CODE = "stations_ter";
|
||||
|
||||
export function Step4Route({ form }: { form: BookingForm }) {
|
||||
export function Step4Route({
|
||||
form,
|
||||
referenceData,
|
||||
isLoading,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
const {
|
||||
data: stationSetting,
|
||||
isLoading: stationsLoading,
|
||||
isError: stationsError,
|
||||
error: stationsFetchError,
|
||||
} = useDropdownSettingByCode(STATION_DROPDOWN_CODE);
|
||||
const stationOptions = getStationOptions(stationSetting?.children);
|
||||
|
||||
const yardOptions = useMemo(() => {
|
||||
if (!referenceData?.yard) return [];
|
||||
return referenceData.yard.map((y) => ({
|
||||
value: y.name,
|
||||
label: y.name,
|
||||
country: y.country,
|
||||
}));
|
||||
}, [referenceData]);
|
||||
|
||||
const shippingLineOptions = useMemo(() => {
|
||||
if (!referenceData?.shipping_line) return [];
|
||||
return referenceData.shipping_line.map((sl) => ({
|
||||
value: sl.name,
|
||||
label: sl.name,
|
||||
}));
|
||||
}, [referenceData]);
|
||||
|
||||
const direction = getRouteDirection(originYard, destinationYard);
|
||||
const directionStyle: Record<string, string> = {
|
||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
@@ -43,7 +56,6 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
||||
import: "Import workflow (outside country to inside country)",
|
||||
domestic: "Domestic corridor",
|
||||
};
|
||||
const stationSelectDisabled = stationsLoading || stationOptions.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (direction === "domestic") {
|
||||
@@ -51,6 +63,8 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
||||
}
|
||||
}, [direction]);
|
||||
|
||||
const stationSelectDisabled = yardOptions.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
@@ -58,65 +72,59 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
||||
description="Select the origin and destination yards."
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Route</StepLabel>
|
||||
<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..."
|
||||
disabled={stationSelectDisabled}
|
||||
>
|
||||
<StationSelectOptions
|
||||
options={stationOptions}
|
||||
excludeValue={destinationYard}
|
||||
isLoading={stationsLoading}
|
||||
/>
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="destinationYard"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Destination Yard *"
|
||||
placeholder="Select destination..."
|
||||
disabled={stationSelectDisabled}
|
||||
>
|
||||
<StationSelectOptions
|
||||
options={stationOptions}
|
||||
excludeValue={originYard}
|
||||
isLoading={stationsLoading}
|
||||
/>
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{stationsError && (
|
||||
<AlertBox tone="error">
|
||||
Failed to load stations from the API.{" "}
|
||||
{stationsFetchError instanceof Error
|
||||
? stationsFetchError.message
|
||||
: "Try again later."}
|
||||
</AlertBox>
|
||||
)}
|
||||
{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]}
|
||||
{isLoading ? (
|
||||
<LoadingSkeleton />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<StepLabel>Route</StepLabel>
|
||||
<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..."
|
||||
disabled={stationSelectDisabled}
|
||||
>
|
||||
<YardSelectOptions
|
||||
options={yardOptions}
|
||||
excludeValue={destinationYard}
|
||||
/>
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="destinationYard"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Destination Yard *"
|
||||
placeholder="Select destination..."
|
||||
disabled={stationSelectDisabled}
|
||||
>
|
||||
<YardSelectOptions
|
||||
options={yardOptions}
|
||||
excludeValue={originYard}
|
||||
/>
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{direction && direction != "domestic" && (
|
||||
<Controller
|
||||
@@ -129,7 +137,11 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
||||
label="Shipping Line"
|
||||
placeholder="Select shipping line..."
|
||||
>
|
||||
<SelectOptions options={SHIPPING_LINES} />
|
||||
{shippingLineOptions.map((sl) => (
|
||||
<SelectItem key={sl.value} value={sl.value}>
|
||||
{sl.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
@@ -179,23 +191,35 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
||||
);
|
||||
}
|
||||
|
||||
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
|
||||
return [...(options ?? [])].sort((a, b) => a.order - b.order);
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StationSelectOptions({
|
||||
function YardSelectOptions({
|
||||
options,
|
||||
excludeValue,
|
||||
isLoading,
|
||||
}: {
|
||||
options: DropdownOption[];
|
||||
options: Array<{ value: string; label: string; country: string }>;
|
||||
excludeValue: string;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<SelectItem value="__stations_loading" disabled>
|
||||
Loading stations...
|
||||
<SelectItem value="__yards_empty" disabled>
|
||||
No yards available
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
@@ -204,22 +228,10 @@ function StationSelectOptions({
|
||||
(option) => option.value !== excludeValue,
|
||||
);
|
||||
|
||||
if (availableOptions.length === 0) {
|
||||
return (
|
||||
<SelectItem value="__stations_empty" disabled>
|
||||
No stations available
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{availableOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
|
||||
import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common";
|
||||
import {
|
||||
BREAK_BULK_TYPES,
|
||||
BULK_COMMODITIES,
|
||||
CONTAINER_TYPES,
|
||||
Button,
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
Input,
|
||||
Skeleton,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
calcWagons,
|
||||
type BookingFormValues,
|
||||
type RouteDirection,
|
||||
@@ -13,19 +20,27 @@ import {
|
||||
AlertBox,
|
||||
OptionCard,
|
||||
SelectField,
|
||||
SelectOptions,
|
||||
SelectItem,
|
||||
StepHeader,
|
||||
StepLabel,
|
||||
} from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
any,
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
export function Step5CargoDetails({
|
||||
form,
|
||||
direction,
|
||||
referenceData,
|
||||
isLoading,
|
||||
}: {
|
||||
form: BookingForm;
|
||||
direction: RouteDirection;
|
||||
referenceData?: Freight.BookingReferenceData;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const cargoType = form.watch("cargoType");
|
||||
const freightType = form.watch("freightType");
|
||||
@@ -38,6 +53,20 @@ export function Step5CargoDetails({
|
||||
name: "containers",
|
||||
});
|
||||
|
||||
const containerTypeOptions = useMemo(() => {
|
||||
if (!referenceData?.containers) return [];
|
||||
return referenceData.containers.flatMap((group) =>
|
||||
group.types.map((t) => t.name),
|
||||
);
|
||||
}, [referenceData]);
|
||||
|
||||
const bulkCommodityOptions = useMemo(() => {
|
||||
if (!referenceData?.cargo_type) return [];
|
||||
return referenceData.cargo_type.flatMap(
|
||||
(group) => group.children?.map((c) => c.name) ?? [],
|
||||
);
|
||||
}, [referenceData]);
|
||||
|
||||
function getOverweightAlert(
|
||||
type: "20ft" | "40ft",
|
||||
vgm: number,
|
||||
@@ -54,6 +83,26 @@ export function Step5CargoDetails({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
title="Cargo Details"
|
||||
description="Define your cargo type, weight, and container configuration."
|
||||
/>
|
||||
<div className="space-y-4 rounded-xl border border-border p-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StepHeader
|
||||
@@ -181,7 +230,11 @@ export function Step5CargoDetails({
|
||||
label="Commodity *"
|
||||
placeholder="Select commodity *"
|
||||
>
|
||||
<SelectOptions options={BULK_COMMODITIES} />
|
||||
{bulkCommodityOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
@@ -216,7 +269,11 @@ export function Step5CargoDetails({
|
||||
label="Break-bulk type *"
|
||||
placeholder="Select type *"
|
||||
>
|
||||
<SelectOptions options={BREAK_BULK_TYPES} />
|
||||
{bulkCommodityOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
@@ -408,7 +465,11 @@ export function Step5CargoDetails({
|
||||
label="Container Type *"
|
||||
placeholder="Select type..."
|
||||
>
|
||||
<SelectOptions options={CONTAINER_TYPES} />
|
||||
{containerTypeOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectField>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -11,13 +11,17 @@ import {
|
||||
Textarea,
|
||||
} from "@edr/ui-common";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
type RouteDirection,
|
||||
type WagonCalcResult,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
|
||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
||||
type BookingForm = UseFormReturn<
|
||||
BookingFormInputValues,
|
||||
any,
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
export function Step8Review({
|
||||
form,
|
||||
|
||||
@@ -137,6 +137,12 @@ export const api = {
|
||||
bookingsService.create,
|
||||
),
|
||||
|
||||
referenceData: endpoint<void, Freight.BookingReferenceData>(
|
||||
"bookings",
|
||||
"referenceData",
|
||||
bookingsService.getReferenceData,
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||
bookingsService.remove(id),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,10 @@ export const bookingsService = {
|
||||
const { data } = await client.post("/api/bookings", payload);
|
||||
return data.data;
|
||||
},
|
||||
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
|
||||
const { data } = await client.get("/api/bookings/reference-data");
|
||||
return data.data;
|
||||
},
|
||||
remove: async (id: string): Promise<void> => {
|
||||
await client.delete(`/bookings/${id}`);
|
||||
},
|
||||
|
||||
@@ -216,45 +216,94 @@ export interface IInvoice extends BaseEntity {
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
// ── Reference Data (booking form catalog) ──────────────────────────────────────
|
||||
|
||||
export interface BookingReferenceYard {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface BookingReferenceContainerType {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
is_reefer: boolean;
|
||||
wagons_per_unit: number;
|
||||
}
|
||||
|
||||
export interface BookingReferenceContainerSizeGroup {
|
||||
size: string;
|
||||
types: BookingReferenceContainerType[];
|
||||
}
|
||||
|
||||
export interface BookingReferenceService {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface BookingReferenceShippingLine {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface BookingReferenceCargoTypeChild {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
show_free_text_box: boolean;
|
||||
}
|
||||
|
||||
export interface BookingReferenceCargoTypeGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
children?: BookingReferenceCargoTypeChild[];
|
||||
}
|
||||
|
||||
export interface BookingReferenceData {
|
||||
yard: BookingReferenceYard[];
|
||||
containers: BookingReferenceContainerSizeGroup[];
|
||||
service: BookingReferenceService[];
|
||||
shipping_line: BookingReferenceShippingLine[];
|
||||
cargo_type: BookingReferenceCargoTypeGroup[];
|
||||
}
|
||||
|
||||
// ── DTOs ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CreateBookingContainerDto {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
}
|
||||
|
||||
export interface CreateBookingDto {
|
||||
reference: string;
|
||||
customerId: string;
|
||||
reference?: string;
|
||||
customerId?: string;
|
||||
trainId?: string;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
paymentStatus?: string;
|
||||
contractType: "NEW" | "RENEWAL";
|
||||
previousContractId?: string;
|
||||
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
|
||||
|
||||
firstMileEnabled?: boolean;
|
||||
serviceTypeId: string;
|
||||
firstMilePickupAddress?: string;
|
||||
lastMileEnabled?: boolean;
|
||||
lastMileDeliveryAddress?: string;
|
||||
|
||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
||||
customsClearingEnabled?: boolean;
|
||||
originStation: string;
|
||||
destinationStation: string;
|
||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA";
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
cargoTypeId: string;
|
||||
cargoFreeText?: string;
|
||||
shippingLineId?: string;
|
||||
cargoTotalWeightVgm: number;
|
||||
|
||||
freightType: "BULK" | "BREAK_BULK";
|
||||
freightSubtype?: string;
|
||||
|
||||
isHazardous?: boolean;
|
||||
isRefrigerated?: boolean;
|
||||
|
||||
tradeDirection: "IMPORT" | "EXPORT";
|
||||
paymentCurrency: string;
|
||||
allowConsolidation?: boolean;
|
||||
|
||||
paymentCurrency: "ETB" | "USD";
|
||||
pnrCode?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
financialTerms?: string;
|
||||
|
||||
containers?: Array<{
|
||||
type: "20FT" | "40FT";
|
||||
qty: number;
|
||||
vgm: number;
|
||||
}>;
|
||||
containers: CreateBookingContainerDto[];
|
||||
allowConsolidation?: boolean;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
|
||||
export * from "./components/button";
|
||||
export * from "./components/input";
|
||||
export * from "./components/skeleton";
|
||||
export * from "./components/textarea";
|
||||
export * from "./components/label";
|
||||
export * from "./components/card";
|
||||
|
||||
Reference in New Issue
Block a user