Merge pull request #1034 from Tria-plc/freight_feature/usermanagement

add goverment booking
This commit is contained in:
marshal
2026-07-31 04:08:39 +03:00
committed by GitHub

View File

@@ -15,7 +15,6 @@ import {
Stack, Stack,
Switch, Switch,
Text, Text,
Textarea,
TextInput, TextInput,
ThemeIcon, ThemeIcon,
Tooltip, Tooltip,
@@ -25,16 +24,13 @@ import { isAxiosError } from "axios";
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
Boxes,
CalendarClock, CalendarClock,
Container as ContainerIcon, Container as ContainerIcon,
Flame,
Info, Info,
Layers, Layers,
MapPin, MapPin,
Package, Package,
Plus, Plus,
Settings2,
Ship, Ship,
Trash2, Trash2,
Weight, Weight,
@@ -48,8 +44,6 @@ import { api as appApi } from "@/services/api";
import { bookingsService } from "@/services/bookings.service"; import { bookingsService } from "@/services/bookings.service";
import { customersService } from "@/services/customers.service"; import { customersService } from "@/services/customers.service";
type FreightType = "CONTAINER" | "BULK";
interface RefNamed { interface RefNamed {
id: string; id: string;
name: string; name: string;
@@ -103,24 +97,17 @@ const newLine = (): ContainerLine => ({
const fmtTons = (n: number) => const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`; `${n.toLocaleString(undefined, { maximumFractionDigits: 2 })} t`;
type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC"; type TradeDirection = "IMPORT" | "EXPORT";
function deriveTradeDirectionFromYards( // The direction fixes which end of the route is inside Ethiopia — Djibouti
origin?: RefNamed | null, // yards stand in for "outside" (the port), mirroring the customer portal and
destination?: RefNamed | null, // the backend's deriveTradeDirection.
): TradeDirection | null { const countriesFor: Record<
const originCountry = origin?.country?.trim(); TradeDirection,
const destinationCountry = destination?.country?.trim(); { origin: string; destination: string }
if (!originCountry || !destinationCountry) return null; > = {
if (originCountry === "Djibouti") return "IMPORT"; IMPORT: { origin: "Djibouti", destination: "Ethiopia" },
if (destinationCountry === "Djibouti" && originCountry !== "Djibouti") return "EXPORT"; EXPORT: { origin: "Ethiopia", destination: "Djibouti" },
return "DOMESTIC";
}
const tradeDirectionLabel: Record<TradeDirection, string> = {
IMPORT: "Import",
EXPORT: "Export",
DOMESTIC: "Domestic",
}; };
const parseBookingError = (error: unknown, fallback: string) => { 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. // Government bookings bill to a real government company + an explicit profile.
const [govCompanyId, setGovCompanyId] = useState<string | null>(null); const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
const [govProfileId, setGovProfileId] = 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 [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null); const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = 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 [scheduledDay, setScheduledDay] = useState<string | null>(null);
const [paymentCurrency, setPaymentCurrency] = useState("ETB"); const [paymentCurrency, setPaymentCurrency] = useState("ETB");
// container freight
const [lines, setLines] = useState<ContainerLine[]>([newLine()]); 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({ const { data: refData, isLoading } = useQuery({
queryKey: ["bookings", "reference-data"], queryKey: ["bookings", "reference-data"],
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>, queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
@@ -283,16 +258,35 @@ export default function NewBookingPage() {
const yardRecords = refData?.yard ?? []; const yardRecords = refData?.yard ?? [];
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code })); 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; // Yards filtered to the side of the route the direction dictates (import:
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard); // 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. // Reset the day when the route changes — available days depend on the route.
useEffect(() => { useEffect(() => {
setScheduledDay(null); setScheduledDay(null);
}, [originYardId, destinationYardId]); }, [originYardId, destinationYardId]);
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code })); 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( const containerGroupData = useMemo(
() => () =>
@@ -306,23 +300,12 @@ export default function NewBookingPage() {
[refData?.containers], [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 ---- // ---- derived totals ----
const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0); 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 = lines.reduce(
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight; (s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0),
0,
);
// 20ft containers ride two per wagon, so a booking must hold an even number // 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 // 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; return String(size).includes("20") ? sum + (l.quantity || 0) : sum;
}, 0); }, 0);
}, [refData?.containers, lines]); }, [refData?.containers, lines]);
const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1; const hasOdd20ft = twentyFtCount % 2 === 1;
// ---- validation ---- // ---- validation ----
const lineValid = (l: ContainerLine) => const lineValid = (l: ContainerLine) =>
@@ -353,13 +336,11 @@ export default function NewBookingPage() {
Boolean(originYardId) && Boolean(originYardId) &&
Boolean(destinationYardId) && Boolean(destinationYardId) &&
!sameYard && !sameYard &&
Boolean(tradeDirection) &&
Boolean(serviceTypeId) && Boolean(serviceTypeId) &&
departureSatisfied && departureSatisfied &&
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) && (isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
(freightType === "BULK" allLinesValid &&
? Boolean(cargoTypeId) && bulkWeight > 0 !hasOdd20ft;
: allLinesValid && !hasOdd20ft);
const updateLine = (key: string, patch: Partial<ContainerLine>) => const updateLine = (key: string, patch: Partial<ContainerLine>) =>
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))); setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
@@ -372,31 +353,23 @@ export default function NewBookingPage() {
isGovernment, isGovernment,
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined, companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
companyProfileId: isGovernment ? govProfileId || undefined : undefined, companyProfileId: isGovernment ? govProfileId || undefined : undefined,
freightType, freightType: "CONTAINER",
contractType: "NEW", contractType: "NEW",
equipmentReturn, equipmentReturn: "NA",
tradeDirection: tradeDirection!, tradeDirection,
paymentCurrency, paymentCurrency,
isHazardous, isHazardous: false,
scheduledDate: effectiveDepartureIso || new Date().toISOString(), scheduledDate: effectiveDepartureIso || new Date().toISOString(),
originYardId, originYardId,
destinationYardId, destinationYardId,
// Day-level pool: no trainScheduleId — the engine assigns the train. // Day-level pool: no trainScheduleId — the engine assigns the train.
serviceTypeId, serviceTypeId,
shippingLineId: shippingLineId || undefined,
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined,
cargoTotalWeightVgm, cargoTotalWeightVgm,
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined, containers: lines.map((l) => ({
cargoFreeText: freightType === "BULK" ? cargoFreeText.trim() || undefined : undefined, containerTypeId: l.containerTypeId,
containers: quantity: l.quantity,
freightType === "CONTAINER" vgmPerUnitTons: l.vgmPerUnitTons,
? lines.map((l) => ({ })),
containerTypeId: l.containerTypeId,
quantity: l.quantity,
vgmPerUnitTons: l.vgmPerUnitTons,
}))
: undefined,
}), }),
onSuccess: async (booking) => { onSuccess: async (booking) => {
if (isGovernment) { if (isGovernment) {
@@ -489,51 +462,50 @@ export default function NewBookingPage() {
nothingFoundMessage="No companies found" 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> </Stack>
</FormSection> </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"> <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"> <Group grow align="flex-start">
<Select <Select
label="Origin yard" label="Origin yard"
placeholder="Select origin" placeholder={`Select origin (${countriesFor[tradeDirection].origin})`}
data={yards} data={originYardOptions}
value={originYardId} value={originYardId}
onChange={(v) => { onChange={setOriginYardId}
setOriginYardId(v);
}}
searchable searchable
disabled={isLoading} disabled={isLoading}
error={sameYard ? "Same as destination" : undefined} nothingFoundMessage={`No ${countriesFor[tradeDirection].origin} yards`}
/> />
<Select <Select
label="Destination yard" label="Destination yard"
placeholder="Select destination" placeholder={`Select destination (${countriesFor[tradeDirection].destination})`}
data={yards} data={destinationYardOptions}
value={destinationYardId} value={destinationYardId}
onChange={(v) => { onChange={setDestinationYardId}
setDestinationYardId(v);
setScheduledDay(null);
}}
searchable searchable
disabled={isLoading} disabled={isLoading}
error={sameYard ? "Same as origin" : undefined} nothingFoundMessage={`No ${countriesFor[tradeDirection].destination} yards`}
/> />
</Group> </Group>
<Select <Select
@@ -553,35 +525,15 @@ export default function NewBookingPage() {
} }
description="Pick a day with a departure. The batch engine assigns the train by priority." description="Pick a day with a departure. The batch engine assigns the train by priority."
/> />
<Group grow align="flex-end"> <Select
<Select label="Service type"
label="Service type" placeholder="Select service"
placeholder="Select service" data={services}
data={services} value={serviceTypeId}
value={serviceTypeId} onChange={setServiceTypeId}
onChange={setServiceTypeId} searchable
searchable disabled={isLoading}
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>
</Stack> </Stack>
</FormSection> </FormSection>
@@ -616,8 +568,7 @@ export default function NewBookingPage() {
</FormSection> </FormSection>
{/* Cargo */} {/* Cargo */}
{freightType === "CONTAINER" ? ( <FormSection
<FormSection
icon={ContainerIcon} icon={ContainerIcon}
title="Container lines" title="Container lines"
subtitle="Add one row per container type" 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 }}> <Text fw={700} c="dimmed" size="sm" w={22} ta="center" style={{ flexShrink: 0 }}>
{idx + 1} {idx + 1}
</Text> </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 <Select
label="Container type" label="Container type"
placeholder="Select type" placeholder="Select type"
@@ -657,15 +616,7 @@ export default function NewBookingPage() {
style={{ flex: 2, minWidth: 160 }} style={{ flex: 2, minWidth: 160 }}
/> />
<NumberInput <NumberInput
label="Qty" label="Weight / unit"
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"
value={line.vgmPerUnitTons} value={line.vgmPerUnitTons}
onChange={(v) => updateLine(line.key, { vgmPerUnitTons: Number(v) || 0 })} onChange={(v) => updateLine(line.key, { vgmPerUnitTons: Number(v) || 0 })}
min={0} min={0}
@@ -708,95 +659,14 @@ export default function NewBookingPage() {
Add container line Add container line
</Button> </Button>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Total VGM:{" "} Total weight:{" "}
<Text span fw={700} c="dark"> <Text span fw={700} c="dark">
{fmtTons(containerWeight)} {fmtTons(cargoTotalWeightVgm)}
</Text> </Text>
</Text> </Text>
</Group> </Group>
</Stack> </Stack>
</FormSection> </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> </Stack>
</Grid.Col> </Grid.Col>
@@ -812,32 +682,23 @@ export default function NewBookingPage() {
</Group> </Group>
<Group gap="xs" mb="md"> <Group gap="xs" mb="md">
<Badge variant="light" color={freightType === "BULK" ? "orange" : "teal"} radius="sm"> <Badge variant="light" color="teal" radius="sm">
{freightType === "BULK" ? "Bulk" : "Container"} Container
</Badge> </Badge>
<Badge variant="light" color="blue" radius="sm"> <Badge variant="light" color={tradeDirection === "EXPORT" ? "orange" : "blue"} radius="sm">
{tradeDirection} {tradeDirection === "EXPORT" ? "Export" : "Import"}
</Badge> </Badge>
{isGovernment ? ( {isGovernment ? (
<Badge variant="light" color="grape" radius="sm"> <Badge variant="light" color="grape" radius="sm">
Government Government
</Badge> </Badge>
) : null} ) : null}
{isHazardous ? (
<Badge variant="light" color="red" radius="sm" leftSection={<Flame size={10} />}>
Hazardous
</Badge>
) : null}
</Group> </Group>
<Stack gap={8}> <Stack gap={8}>
{freightType === "CONTAINER" ? ( <SummaryRow label="Container lines" value={String(lines.length)} />
<> <SummaryRow label="Total containers" value={String(totalContainers)} />
<SummaryRow label="Container lines" value={String(lines.length)} /> <SummaryRow label="Total weight" value={fmtTons(cargoTotalWeightVgm)} strong />
<SummaryRow label="Total containers" value={String(totalContainers)} />
</>
) : null}
<SummaryRow label="Total VGM weight" value={fmtTons(cargoTotalWeightVgm)} strong />
<Divider my={4} /> <Divider my={4} />
<SummaryRow <SummaryRow
label="Route" label="Route"