fix(warehouses): detention groups by canonical truck type

Join truck_types via vehicles.truck_type_id (normalized legacy
vehicle_type only as fallback) so type renames can't unmatch detention
rules and FK-less vehicles keep billing.
This commit is contained in:
Hagernesh
2026-07-23 13:40:14 +00:00
parent 227a561e89
commit cf8a2e928d
41 changed files with 1495 additions and 109 deletions

View File

@@ -94,6 +94,9 @@ const FleetResourcePage = () => {
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
api.wagonTypes.list.queryOptions(),
);
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
api.truckTypes.list.queryOptions(),
);
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
@@ -169,17 +172,34 @@ const FleetResourcePage = () => {
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
);
// Carries capacity + trailer configuration so picking a truck type can
// pre-fill the vehicle's capacity and drop the trailer plate on a rigid type.
const truckTypeOpts = (
truckTypes as Array<{
id: string;
code: string;
name?: string;
capacityTons?: number | null;
hasTrailer?: boolean;
}>
).map((t) => ({
value: t.id,
label: t.name ? `${t.name} (${t.code})` : t.code,
meta: { capacityTons: t.capacityTons, hasTrailer: t.hasTrailer },
}));
registerFleetOptionLabels("currentYardId", yardOpts);
return {
wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
truckTypes: truckTypeOpts,
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
yards: yardOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
@@ -212,6 +232,7 @@ const FleetResourcePage = () => {
registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
registerFleetOptionLabels("locationId", dynamicOptions.yards);
registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
@@ -227,6 +248,7 @@ const FleetResourcePage = () => {
wagonTypesLoading ||
containerTypesLoading ||
cargoTypesLoading ||
truckTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;

View File

@@ -1,14 +1,18 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Container,
Group,
Loader,
NumberInput,
Radio,
Select,
SimpleGrid,
Stack,
Table,
@@ -20,6 +24,7 @@ import {
import {
ArrowLeft,
Fuel,
Gauge,
History,
Route,
Truck,
@@ -28,7 +33,13 @@ import {
} from "lucide-react";
import { api } from "@/auth/http";
import { vehiclesService } from "@/services/vehicles.service";
import { api as apiClient2 } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import {
vehiclesService,
type SaveVehiclePayload,
type Vehicle,
} from "@/services/vehicles.service";
import { driversService } from "@/services/drivers.service";
import { fleetHistoryService } from "@/services/fleet-history.service";
@@ -132,6 +143,7 @@ const VehicleDetailPage = () => {
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</Tabs.Tab>
<Tabs.Tab value="fuel" leftSection={<Fuel size={14} />}>Fuel</Tabs.Tab>
<Tabs.Tab value="operations" leftSection={<Gauge size={14} />}>Operations</Tabs.Tab>
<Tabs.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
</Tabs.List>
@@ -142,6 +154,8 @@ const VehicleDetailPage = () => {
<InfoRow label="Code" value={vehicle.code ?? "—"} />
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
<InfoRow label="VIN" value={vehicle.vin ?? "—"} />
<InfoRow label="Ownership" value={vehicle.ownership ?? "—"} />
<InfoRow label="Manufacturer" value={vehicle.manufacturer ?? "—"} />
<InfoRow label="Model" value={vehicle.model ?? "—"} />
<InfoRow label="Year" value={vehicle.year ?? "—"} />
@@ -172,6 +186,10 @@ const VehicleDetailPage = () => {
<FuelTab vehicleId={id} />
</Tabs.Panel>
<Tabs.Panel value="operations" pt="lg">
<OperationsTab vehicle={vehicle} />
</Tabs.Panel>
<Tabs.Panel value="mile" pt="lg">
<MileTab vehicleId={id} />
</Tabs.Panel>
@@ -181,6 +199,103 @@ const VehicleDetailPage = () => {
);
};
/**
* Where a truck is and what it costs to run are per-trip operational facts, not
* part of registering the vehicle — so they are edited here rather than on the
* Add Vehicle form. `pricePerKm` is live billing input: first/last-mile charges
* are `distance × pricePerKm`.
*/
const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
const { toast } = useToast();
const queryClient = useQueryClient();
const [form, setForm] = useState({
locationId: vehicle.locationId ?? "",
estimatedDistanceKm: vehicle.estimatedDistanceKm ?? "",
actualDistanceKm: vehicle.actualDistanceKm ?? "",
pricePerKm: vehicle.pricePerKm ?? "",
currency: vehicle.currency ?? "ETB",
});
const { data: yards = [], isLoading: yardsLoading } = useQuery(
apiClient2.routes.yards.queryOptions(),
);
const save = useMutation({
mutationFn: () =>
vehiclesService.update(vehicle.id, {
locationId: form.locationId || null,
// Empty means "not recorded" — send null so the column is unset rather
// than coerced to 0, which would read as a real measurement.
estimatedDistanceKm: form.estimatedDistanceKm === "" ? null : Number(form.estimatedDistanceKm),
actualDistanceKm: form.actualDistanceKm === "" ? null : Number(form.actualDistanceKm),
pricePerKm: form.pricePerKm === "" ? null : Number(form.pricePerKm),
currency: form.currency || null,
} as Partial<SaveVehiclePayload> & { locationId?: string | null }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["vehicle", vehicle.id] });
toast({ title: "Operational details saved" });
},
onError: () =>
toast({ title: "Could not save operational details", variant: "destructive" }),
});
return (
<Card withBorder radius="md" padding="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Location"
placeholder={yardsLoading ? "Loading yards..." : "Not set"}
data={(yards as Array<{ id: string; label?: string; code?: string }>).map((y) => ({
value: y.id,
label: y.label ?? y.code ?? y.id,
}))}
value={form.locationId || null}
onChange={(v) => setForm((f) => ({ ...f, locationId: v ?? "" }))}
disabled={yardsLoading}
searchable
clearable
/>
<NumberInput
label="Price per KM"
description="Used for first/last-mile billing"
value={form.pricePerKm}
onChange={(v) => setForm((f) => ({ ...f, pricePerKm: v as number | "" }))}
min={0}
/>
<NumberInput
label="Estimated Distance (KM)"
value={form.estimatedDistanceKm}
onChange={(v) => setForm((f) => ({ ...f, estimatedDistanceKm: v as number | "" }))}
min={0}
/>
<NumberInput
label="Actual Distance (KM)"
value={form.actualDistanceKm}
onChange={(v) => setForm((f) => ({ ...f, actualDistanceKm: v as number | "" }))}
min={0}
/>
<Radio.Group
label="Currency"
value={form.currency}
onChange={(v) => setForm((f) => ({ ...f, currency: v }))}
>
<Group gap="lg" mt={6}>
<Radio value="ETB" label="ETB" />
<Radio value="USD" label="USD" />
</Group>
</Radio.Group>
</SimpleGrid>
<Group justify="flex-end">
<Button onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
</Group>
</Stack>
</Card>
);
};
const DriverTab = ({
vehicleId,
driverId,

View File

@@ -30,10 +30,22 @@ export type FleetDynamicOptions =
| "wagonTypes"
| "containerTypes"
| "cargoTypes"
| "truckTypes"
| "wagons"
| "containers"
| "yards";
/**
* A dynamic select option that can carry the record it came from. Picking a
* truck type has to pull its capacity and trailer configuration into the form,
* which a bare {label, value} pair cannot express.
*/
export interface FleetSelectOption {
label: string;
value: string;
meta?: Record<string, unknown>;
}
export interface FleetResourceColumn {
id: string;
header: string;
@@ -45,6 +57,17 @@ export interface FleetResourceColumn {
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
/** Options carrying their source record, so `onOptionSelected` can read it. */
options?: FleetSelectOption[];
/**
* Patch merged into the form when this select changes — for values that are a
* property of the chosen option rather than typed per record (a vehicle's
* capacity comes from its truck type). Returns the fields to overwrite.
*/
onOptionSelected?: (
option: FleetSelectOption | undefined,
values: Record<string, unknown>,
) => Record<string, unknown>;
/**
* Read-only field whose value is computed from the other fields rather than
* typed. Rendered non-editable and recomputed on every change, so the stored

View File

@@ -10,6 +10,16 @@ const PLATE_PATTERN = {
message: "Use letters and numbers like ET-9875 or AA-8642",
};
/**
* Legacy static list. Truck configurations now live in `freight.truck_types`
* and are edited in the back office (Rule Engine → Truck Types), so the form
* loads them through `dynamicOptions: "truckTypes"` instead.
*
* Kept only for the driver "authorized vehicle types" multiselect, which stores
* free-text categories rather than truck-type ids.
*
* @deprecated prefer the managed truck types
*/
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
@@ -20,6 +30,11 @@ const VEHICLE_TYPE_OPTIONS = [
{ label: "Flatbed", value: "FLATBED" },
];
const OWNERSHIP_OPTIONS = [
{ label: "Owned", value: "OWNED" },
{ label: "Outsourced", value: "OUTSOURCED" },
];
const FUEL_TYPE_OPTIONS = [
{ label: "Petrol", value: "PETROL" },
{ label: "Diesel", value: "DIESEL" },
@@ -39,11 +54,6 @@ const VEHICLE_AVAILABILITY_OPTIONS = [
{ label: "Busy", value: "BUSY" },
];
const CURRENCY_OPTIONS = [
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
];
export const vehiclesConfig: FleetResourceConfig = {
slug: "vehicles",
label: "Vehicles",
@@ -72,35 +82,65 @@ export const vehiclesConfig: FleetResourceConfig = {
options: VEHICLE_AVAILABILITY_OPTIONS,
},
],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "vin", "status"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", size: 90 },
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 },
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
{ id: "model", header: "Model", accessorKey: "model", size: 100 },
{ id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 },
{ id: "truckTypeId", header: "Truck Type", accessorKey: "truckTypeId", format: "entityLabel", size: 130 },
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 },
{ id: "locationId", header: "Location", accessorKey: "locationId", size: 140 },
{ id: "ownership", header: "Ownership", accessorKey: "ownership", size: 100 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
{ id: "availability", header: "Availability", accessorKey: "availability", format: "statusBadge", size: 100 },
],
// Registration captures what the vehicle IS. Location, distances and haulage
// pricing are per-trip operational data and live on the vehicle's Operations
// tab instead (VehicleDetailPage) — they are not part of registering a truck.
formFields: [
{ name: "code", label: "Code", type: "text" },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
// The truck type decides whether a trailer exists at all: a rigid truck
// (Casoni) has none, so the field disappears and submits null. Mirrored
// server-side in VehiclesService — hiding a field is not enforcement.
{
name: "trailerPlateNo",
label: "Trailer Plate No",
type: "text",
pattern: PLATE_PATTERN,
showIf: (values) => values._hasTrailer !== false,
},
{
name: "truckTypeId",
label: "Truck Type",
type: "select",
required: true,
dynamicOptions: "truckTypes",
description: "Managed in Rule Engine → Truck Types",
// Capacity is a property of the type, not of each individual truck.
// `_hasTrailer` is form-local scratch (stripped before submit) that drives
// the trailer plate's visibility.
onOptionSelected: (option) => ({
_hasTrailer: option?.meta?.hasTrailer ?? true,
...(option?.meta?.capacityTons != null
? { capacity: option.meta.capacityTons }
: {}),
}),
},
{ name: "vin", label: "VIN", type: "text", description: "Vehicle Identification Number" },
{ name: "ownership", label: "Ownership", type: "radio", options: OWNERSHIP_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },
{ name: "year", label: "Year", type: "number", required: true },
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
{ name: "capacity", label: "Capacity", type: "number", required: true },
{ name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" },
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
{ name: "pricePerKm", label: "Price per KM", type: "number" },
{ name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS },
{
name: "capacity",
label: "Capacity (tons)",
type: "number",
required: true,
description: "Pre-filled from the truck type — override only for a one-off",
},
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
{ name: "description", label: "Description", type: "textarea" },
@@ -108,19 +148,15 @@ export const vehiclesConfig: FleetResourceConfig = {
emptyValues: {
code: "03-ET",
plateNumber: "",
powerPlateNo: "",
trailerPlateNo: "",
vehicleType: "TRUCK",
truckTypeId: "",
vin: "",
ownership: "OWNED",
manufacturer: "",
model: "",
year: new Date().getFullYear(),
fuelType: "DIESEL",
capacity: 0,
locationId: null,
estimatedDistanceKm: "",
actualDistanceKm: "",
pricePerKm: "",
currency: "ETB",
status: "ACTIVE",
availability: "FREE",
description: "",