mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add goverment booking
This commit is contained in:
@@ -15,7 +15,6 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
@@ -25,16 +24,13 @@ import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
CalendarClock,
|
||||
Container as ContainerIcon,
|
||||
Flame,
|
||||
Info,
|
||||
Layers,
|
||||
MapPin,
|
||||
Package,
|
||||
Plus,
|
||||
Settings2,
|
||||
Ship,
|
||||
Trash2,
|
||||
Weight,
|
||||
@@ -48,8 +44,6 @@ import { api as appApi } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { customersService } from "@/services/customers.service";
|
||||
|
||||
type FreightType = "CONTAINER" | "BULK";
|
||||
|
||||
interface RefNamed {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -103,24 +97,17 @@ const newLine = (): ContainerLine => ({
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`;
|
||||
|
||||
type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
type TradeDirection = "IMPORT" | "EXPORT";
|
||||
|
||||
function deriveTradeDirectionFromYards(
|
||||
origin?: RefNamed | null,
|
||||
destination?: RefNamed | null,
|
||||
): TradeDirection | null {
|
||||
const originCountry = origin?.country?.trim();
|
||||
const destinationCountry = destination?.country?.trim();
|
||||
if (!originCountry || !destinationCountry) return null;
|
||||
if (originCountry === "Djibouti") return "IMPORT";
|
||||
if (destinationCountry === "Djibouti" && originCountry !== "Djibouti") return "EXPORT";
|
||||
return "DOMESTIC";
|
||||
}
|
||||
|
||||
const tradeDirectionLabel: Record<TradeDirection, string> = {
|
||||
IMPORT: "Import",
|
||||
EXPORT: "Export",
|
||||
DOMESTIC: "Domestic",
|
||||
// The direction fixes which end of the route is inside Ethiopia — Djibouti
|
||||
// yards stand in for "outside" (the port), mirroring the customer portal and
|
||||
// the backend's deriveTradeDirection.
|
||||
const countriesFor: Record<
|
||||
TradeDirection,
|
||||
{ origin: string; destination: string }
|
||||
> = {
|
||||
IMPORT: { origin: "Djibouti", destination: "Ethiopia" },
|
||||
EXPORT: { origin: "Ethiopia", destination: "Djibouti" },
|
||||
};
|
||||
|
||||
const parseBookingError = (error: unknown, fallback: string) => {
|
||||
@@ -183,7 +170,8 @@ export default function NewBookingPage() {
|
||||
// Government bookings bill to a real government company + an explicit profile.
|
||||
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
|
||||
const [govProfileId, setGovProfileId] = useState<string | null>(null);
|
||||
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
||||
// Container freight only for now — bulk is disabled on this page.
|
||||
const [tradeDirection, setTradeDirection] = useState<TradeDirection>("IMPORT");
|
||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
||||
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||
@@ -191,21 +179,8 @@ export default function NewBookingPage() {
|
||||
const [scheduledDay, setScheduledDay] = useState<string | null>(null);
|
||||
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
|
||||
|
||||
// container freight
|
||||
const [lines, setLines] = useState<ContainerLine[]>([newLine()]);
|
||||
|
||||
// bulk freight
|
||||
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
|
||||
const [cargoFreeText, setCargoFreeText] = useState("");
|
||||
const [bulkWeight, setBulkWeight] = useState<number>(100);
|
||||
|
||||
// extra options
|
||||
const [equipmentReturn, setEquipmentReturn] = useState("NA");
|
||||
const [isHazardous, setIsHazardous] = useState(false);
|
||||
const [shippingLineId, setShippingLineId] = useState<string | null>(null);
|
||||
const [firstMilePickupAddress, setFirstMile] = useState("");
|
||||
const [lastMileDeliveryAddress, setLastMile] = useState("");
|
||||
|
||||
const { data: refData, isLoading } = useQuery({
|
||||
queryKey: ["bookings", "reference-data"],
|
||||
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
|
||||
@@ -283,16 +258,35 @@ export default function NewBookingPage() {
|
||||
|
||||
const yardRecords = refData?.yard ?? [];
|
||||
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
||||
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
||||
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
||||
|
||||
// Yards filtered to the side of the route the direction dictates (import:
|
||||
// Djibouti → Ethiopia; export: the reverse), excluding the yard already
|
||||
// picked on the other end so origin and destination can never match.
|
||||
const yardsForCountry = (country: string, excludeYardId: string | null) =>
|
||||
yardRecords
|
||||
.filter((y) => y.id !== excludeYardId && y.country?.trim() === country)
|
||||
.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||
const originYardOptions = yardsForCountry(
|
||||
countriesFor[tradeDirection].origin,
|
||||
destinationYardId,
|
||||
);
|
||||
const destinationYardOptions = yardsForCountry(
|
||||
countriesFor[tradeDirection].destination,
|
||||
originYardId,
|
||||
);
|
||||
|
||||
// Switching direction flips which country each end must be in — clear both
|
||||
// ends so a stale yard can't contradict the chosen direction.
|
||||
useEffect(() => {
|
||||
setOriginYardId(null);
|
||||
setDestinationYardId(null);
|
||||
}, [tradeDirection]);
|
||||
|
||||
// Reset the day when the route changes — available days depend on the route.
|
||||
useEffect(() => {
|
||||
setScheduledDay(null);
|
||||
}, [originYardId, destinationYardId]);
|
||||
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
|
||||
const containerGroupData = useMemo(
|
||||
() =>
|
||||
@@ -306,23 +300,12 @@ export default function NewBookingPage() {
|
||||
[refData?.containers],
|
||||
);
|
||||
|
||||
const { cargoData } = useMemo(() => {
|
||||
const groups = refData?.cargo_type ?? [];
|
||||
const data = groups.map((g) => {
|
||||
if (g.children?.length) {
|
||||
return { group: g.name, items: g.children.map((c) => ({ value: c.id, label: c.name })) };
|
||||
}
|
||||
return { value: g.id, label: g.name };
|
||||
});
|
||||
return { cargoData: data };
|
||||
}, [refData?.cargo_type]);
|
||||
|
||||
const showFreeText = freightType === "BULK" && Boolean(cargoTypeId);
|
||||
|
||||
// ---- derived totals ----
|
||||
const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0);
|
||||
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
|
||||
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
|
||||
const cargoTotalWeightVgm = lines.reduce(
|
||||
(s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// 20ft containers ride two per wagon, so a booking must hold an even number
|
||||
// of them — odd counts would leave half a wagon waiting on a co-loader
|
||||
@@ -337,7 +320,7 @@ export default function NewBookingPage() {
|
||||
return String(size).includes("20") ? sum + (l.quantity || 0) : sum;
|
||||
}, 0);
|
||||
}, [refData?.containers, lines]);
|
||||
const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1;
|
||||
const hasOdd20ft = twentyFtCount % 2 === 1;
|
||||
|
||||
// ---- validation ----
|
||||
const lineValid = (l: ContainerLine) =>
|
||||
@@ -353,13 +336,11 @@ export default function NewBookingPage() {
|
||||
Boolean(originYardId) &&
|
||||
Boolean(destinationYardId) &&
|
||||
!sameYard &&
|
||||
Boolean(tradeDirection) &&
|
||||
Boolean(serviceTypeId) &&
|
||||
departureSatisfied &&
|
||||
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
||||
(freightType === "BULK"
|
||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||
: allLinesValid && !hasOdd20ft);
|
||||
allLinesValid &&
|
||||
!hasOdd20ft;
|
||||
|
||||
const updateLine = (key: string, patch: Partial<ContainerLine>) =>
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
||||
@@ -372,31 +353,23 @@ export default function NewBookingPage() {
|
||||
isGovernment,
|
||||
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
|
||||
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
|
||||
freightType,
|
||||
freightType: "CONTAINER",
|
||||
contractType: "NEW",
|
||||
equipmentReturn,
|
||||
tradeDirection: tradeDirection!,
|
||||
equipmentReturn: "NA",
|
||||
tradeDirection,
|
||||
paymentCurrency,
|
||||
isHazardous,
|
||||
isHazardous: false,
|
||||
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
// Day-level pool: no trainScheduleId — the engine assigns the train.
|
||||
serviceTypeId,
|
||||
shippingLineId: shippingLineId || undefined,
|
||||
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
||||
lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined,
|
||||
cargoTotalWeightVgm,
|
||||
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
|
||||
cargoFreeText: freightType === "BULK" ? cargoFreeText.trim() || undefined : undefined,
|
||||
containers:
|
||||
freightType === "CONTAINER"
|
||||
? lines.map((l) => ({
|
||||
containerTypeId: l.containerTypeId,
|
||||
quantity: l.quantity,
|
||||
vgmPerUnitTons: l.vgmPerUnitTons,
|
||||
}))
|
||||
: undefined,
|
||||
containers: lines.map((l) => ({
|
||||
containerTypeId: l.containerTypeId,
|
||||
quantity: l.quantity,
|
||||
vgmPerUnitTons: l.vgmPerUnitTons,
|
||||
})),
|
||||
}),
|
||||
onSuccess: async (booking) => {
|
||||
if (isGovernment) {
|
||||
@@ -489,51 +462,50 @@ export default function NewBookingPage() {
|
||||
nothingFoundMessage="No companies found"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={6}>
|
||||
Freight type
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={freightType}
|
||||
onChange={(v) => setFreightType(v as FreightType)}
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</FormSection>
|
||||
|
||||
<FormSection icon={MapPin} title="Route & service" subtitle="Origin, destination and service type" accent="blue">
|
||||
<FormSection icon={MapPin} title="Route & service" subtitle="Direction, origin, destination and service type" accent="blue">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={6}>
|
||||
Trade direction
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={tradeDirection}
|
||||
onChange={(v) => setTradeDirection(v as TradeDirection)}
|
||||
data={[
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
{ value: "EXPORT", label: "Export" },
|
||||
]}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{tradeDirection === "IMPORT"
|
||||
? "Origin in Djibouti, destination in Ethiopia."
|
||||
: "Origin in Ethiopia, destination in Djibouti."}
|
||||
</Text>
|
||||
</Box>
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Origin yard"
|
||||
placeholder="Select origin"
|
||||
data={yards}
|
||||
placeholder={`Select origin (${countriesFor[tradeDirection].origin})`}
|
||||
data={originYardOptions}
|
||||
value={originYardId}
|
||||
onChange={(v) => {
|
||||
setOriginYardId(v);
|
||||
}}
|
||||
onChange={setOriginYardId}
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
error={sameYard ? "Same as destination" : undefined}
|
||||
nothingFoundMessage={`No ${countriesFor[tradeDirection].origin} yards`}
|
||||
/>
|
||||
<Select
|
||||
label="Destination yard"
|
||||
placeholder="Select destination"
|
||||
data={yards}
|
||||
placeholder={`Select destination (${countriesFor[tradeDirection].destination})`}
|
||||
data={destinationYardOptions}
|
||||
value={destinationYardId}
|
||||
onChange={(v) => {
|
||||
setDestinationYardId(v);
|
||||
setScheduledDay(null);
|
||||
}}
|
||||
onChange={setDestinationYardId}
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
error={sameYard ? "Same as origin" : undefined}
|
||||
nothingFoundMessage={`No ${countriesFor[tradeDirection].destination} yards`}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
@@ -553,35 +525,15 @@ export default function NewBookingPage() {
|
||||
}
|
||||
description="Pick a day with a departure. The batch engine assigns the train by priority."
|
||||
/>
|
||||
<Group grow align="flex-end">
|
||||
<Select
|
||||
label="Service type"
|
||||
placeholder="Select service"
|
||||
data={services}
|
||||
value={serviceTypeId}
|
||||
onChange={setServiceTypeId}
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Trade direction
|
||||
</Text>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={
|
||||
tradeDirection === "DOMESTIC"
|
||||
? "grape"
|
||||
: tradeDirection === "EXPORT"
|
||||
? "orange"
|
||||
: "blue"
|
||||
}
|
||||
>
|
||||
{tradeDirection ? tradeDirectionLabel[tradeDirection] : "Select yards"}
|
||||
</Badge>
|
||||
</Box>
|
||||
</Group>
|
||||
<Select
|
||||
label="Service type"
|
||||
placeholder="Select service"
|
||||
data={services}
|
||||
value={serviceTypeId}
|
||||
onChange={setServiceTypeId}
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</FormSection>
|
||||
|
||||
@@ -616,8 +568,7 @@ export default function NewBookingPage() {
|
||||
</FormSection>
|
||||
|
||||
{/* Cargo */}
|
||||
{freightType === "CONTAINER" ? (
|
||||
<FormSection
|
||||
<FormSection
|
||||
icon={ContainerIcon}
|
||||
title="Container lines"
|
||||
subtitle="Add one row per container type"
|
||||
@@ -646,6 +597,14 @@ export default function NewBookingPage() {
|
||||
<Text fw={700} c="dimmed" size="sm" w={22} ta="center" style={{ flexShrink: 0 }}>
|
||||
{idx + 1}
|
||||
</Text>
|
||||
<NumberInput
|
||||
label="Qty"
|
||||
value={line.quantity}
|
||||
onChange={(v) => updateLine(line.key, { quantity: Number(v) || 0 })}
|
||||
min={1}
|
||||
step={1}
|
||||
style={{ width: 90, flexShrink: 0 }}
|
||||
/>
|
||||
<Select
|
||||
label="Container type"
|
||||
placeholder="Select type"
|
||||
@@ -657,15 +616,7 @@ export default function NewBookingPage() {
|
||||
style={{ flex: 2, minWidth: 160 }}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Qty"
|
||||
value={line.quantity}
|
||||
onChange={(v) => updateLine(line.key, { quantity: Number(v) || 0 })}
|
||||
min={1}
|
||||
step={1}
|
||||
style={{ width: 90, flexShrink: 0 }}
|
||||
/>
|
||||
<NumberInput
|
||||
label="VGM / unit"
|
||||
label="Weight / unit"
|
||||
value={line.vgmPerUnitTons}
|
||||
onChange={(v) => updateLine(line.key, { vgmPerUnitTons: Number(v) || 0 })}
|
||||
min={0}
|
||||
@@ -708,95 +659,14 @@ export default function NewBookingPage() {
|
||||
Add container line
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
Total VGM:{" "}
|
||||
Total weight:{" "}
|
||||
<Text span fw={700} c="dark">
|
||||
{fmtTons(containerWeight)}
|
||||
{fmtTons(cargoTotalWeightVgm)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormSection>
|
||||
) : (
|
||||
<FormSection icon={Boxes} title="Bulk cargo" subtitle="Select the bulk cargo type and total weight" accent="orange">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Cargo type"
|
||||
placeholder="Select bulk cargo type"
|
||||
data={cargoData}
|
||||
value={cargoTypeId}
|
||||
onChange={(value) => setCargoTypeId(value as string | null)}
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{showFreeText ? (
|
||||
<Textarea
|
||||
label="Cargo description"
|
||||
placeholder="Describe the cargo"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={cargoFreeText}
|
||||
onChange={(e) => setCargoFreeText(e.currentTarget.value)}
|
||||
/>
|
||||
) : null}
|
||||
<NumberInput
|
||||
label="Total weight (VGM)"
|
||||
value={bulkWeight}
|
||||
onChange={(v) => setBulkWeight(Number(v) || 0)}
|
||||
min={0}
|
||||
suffix=" t"
|
||||
decimalScale={2}
|
||||
/>
|
||||
</Stack>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
<FormSection icon={Settings2} title="Additional details" subtitle="Optional — equipment, handling and references" accent="indigo">
|
||||
<Stack gap="md">
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Equipment return"
|
||||
data={[
|
||||
{ value: "NA", label: "Not applicable" },
|
||||
{ value: "WITH_RETURN", label: "With return" },
|
||||
{ value: "WITHOUT_RETURN", label: "Without return" },
|
||||
]}
|
||||
value={equipmentReturn}
|
||||
onChange={(v) => setEquipmentReturn(v ?? "NA")}
|
||||
/>
|
||||
<Select
|
||||
label="Shipping line (optional)"
|
||||
placeholder="Select shipping line"
|
||||
data={shippingLines}
|
||||
value={shippingLineId}
|
||||
onChange={setShippingLineId}
|
||||
searchable
|
||||
clearable
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="First-mile pickup (optional)"
|
||||
placeholder="Pickup address"
|
||||
value={firstMilePickupAddress}
|
||||
onChange={(e) => setFirstMile(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last-mile delivery (optional)"
|
||||
placeholder="Delivery address"
|
||||
value={lastMileDeliveryAddress}
|
||||
onChange={(e) => setLastMile(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
<Switch
|
||||
label="Hazardous cargo"
|
||||
checked={isHazardous}
|
||||
onChange={(e) => setIsHazardous(e.currentTarget.checked)}
|
||||
thumbIcon={isHazardous ? <Flame size={12} /> : undefined}
|
||||
color="red"
|
||||
/>
|
||||
</Stack>
|
||||
</FormSection>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -812,32 +682,23 @@ export default function NewBookingPage() {
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" mb="md">
|
||||
<Badge variant="light" color={freightType === "BULK" ? "orange" : "teal"} radius="sm">
|
||||
{freightType === "BULK" ? "Bulk" : "Container"}
|
||||
<Badge variant="light" color="teal" radius="sm">
|
||||
Container
|
||||
</Badge>
|
||||
<Badge variant="light" color="blue" radius="sm">
|
||||
{tradeDirection}
|
||||
<Badge variant="light" color={tradeDirection === "EXPORT" ? "orange" : "blue"} radius="sm">
|
||||
{tradeDirection === "EXPORT" ? "Export" : "Import"}
|
||||
</Badge>
|
||||
{isGovernment ? (
|
||||
<Badge variant="light" color="grape" radius="sm">
|
||||
Government
|
||||
</Badge>
|
||||
) : null}
|
||||
{isHazardous ? (
|
||||
<Badge variant="light" color="red" radius="sm" leftSection={<Flame size={10} />}>
|
||||
Hazardous
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Stack gap={8}>
|
||||
{freightType === "CONTAINER" ? (
|
||||
<>
|
||||
<SummaryRow label="Container lines" value={String(lines.length)} />
|
||||
<SummaryRow label="Total containers" value={String(totalContainers)} />
|
||||
</>
|
||||
) : null}
|
||||
<SummaryRow label="Total VGM weight" value={fmtTons(cargoTotalWeightVgm)} strong />
|
||||
<SummaryRow label="Container lines" value={String(lines.length)} />
|
||||
<SummaryRow label="Total containers" value={String(totalContainers)} />
|
||||
<SummaryRow label="Total weight" value={fmtTons(cargoTotalWeightVgm)} strong />
|
||||
<Divider my={4} />
|
||||
<SummaryRow
|
||||
label="Route"
|
||||
|
||||
Reference in New Issue
Block a user