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,
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"