mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
Reference in New Issue
Block a user