mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 06:40:57 +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:
@@ -126,6 +126,28 @@ const FleetFormDialog = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, recordId]);
|
||||
|
||||
// Seed the `_`-prefixed scratch that `onOptionSelected` derives (e.g.
|
||||
// _hasTrailer) for the value already on the record. Without this, editing a
|
||||
// rigid truck would show a Trailer Plate field until the type is re-picked.
|
||||
// Only scratch keys are written, so a stored one-off capacity is never
|
||||
// clobbered by the type's default; re-deriving from the live value is
|
||||
// idempotent, so this is safe to run again when the options finally load.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValues((current) => {
|
||||
const scratch: Record<string, unknown> = {};
|
||||
fields.forEach((field) => {
|
||||
if (!field.onOptionSelected) return;
|
||||
const selected = field.options?.find((o) => o.value === current[field.name]);
|
||||
if (!selected) return;
|
||||
Object.entries(field.onOptionSelected(selected, current)).forEach(([key, value]) => {
|
||||
if (key.startsWith("_")) scratch[key] = value;
|
||||
});
|
||||
});
|
||||
return Object.keys(scratch).length ? { ...current, ...scratch } : current;
|
||||
});
|
||||
}, [open, fields]);
|
||||
|
||||
// Receive the ?code&state relayed by the /callback popup, exchange it for
|
||||
// the verified identity, and prefill the matching form fields.
|
||||
useEffect(() => {
|
||||
@@ -202,18 +224,52 @@ const FleetFormDialog = ({
|
||||
|
||||
const faydaVerified = values.faydaVerified === true;
|
||||
|
||||
/**
|
||||
* Fields the current answers actually apply to — a rigid truck type (Casoni)
|
||||
* has no trailer, so its plate field disappears. Honoured in three places, not
|
||||
* just here: a hidden field must also skip validation (an invisible "required"
|
||||
* error blocks submit with nothing to fix) and must submit an explicit null
|
||||
* (so switching to a rigid type CLEARS the stored trailer plate rather than
|
||||
* stranding it on the row).
|
||||
*/
|
||||
const visibleFields = useMemo(
|
||||
() =>
|
||||
fields.filter((field) => {
|
||||
if (
|
||||
field.hideWhen &&
|
||||
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
field.showWhen &&
|
||||
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (field.showIf && !field.showIf(values)) return false;
|
||||
return true;
|
||||
}),
|
||||
[fields, values],
|
||||
);
|
||||
|
||||
const hiddenFieldNames = useMemo(() => {
|
||||
const visible = new Set(visibleFields.map((f) => f.name));
|
||||
return fields.filter((f) => !visible.has(f.name)).map((f) => f.name);
|
||||
}, [fields, visibleFields]);
|
||||
|
||||
const shortFields = useMemo(
|
||||
() => fields.filter((f) => f.type !== "textarea"),
|
||||
[fields],
|
||||
() => visibleFields.filter((f) => f.type !== "textarea"),
|
||||
[visibleFields],
|
||||
);
|
||||
const longFields = useMemo(
|
||||
() => fields.filter((f) => f.type === "textarea"),
|
||||
[fields],
|
||||
() => visibleFields.filter((f) => f.type === "textarea"),
|
||||
[visibleFields],
|
||||
);
|
||||
|
||||
const validate = () => {
|
||||
const next: Record<string, string> = {};
|
||||
fields.forEach((field) => {
|
||||
visibleFields.forEach((field) => {
|
||||
const value = values[field.name];
|
||||
const stringValue =
|
||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||
@@ -295,9 +351,19 @@ const FleetFormDialog = ({
|
||||
fields.forEach((field) => {
|
||||
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
|
||||
});
|
||||
// A field the answers hid no longer applies to this record — send an explicit
|
||||
// null so the column is unset, instead of leaving a stale value behind.
|
||||
hiddenFieldNames.forEach((name) => {
|
||||
submitted[name] = null;
|
||||
});
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(submitted)
|
||||
// `_`-prefixed keys are form-local scratch written by `onOptionSelected`
|
||||
// (e.g. _hasTrailer, which drives visibility). The API validates with
|
||||
// forbidNonWhitelisted, so an undeclared key would 400 the whole save.
|
||||
.filter(([key]) => !key.startsWith("_"))
|
||||
.map(([key, value]) => {
|
||||
if (hiddenFieldNames.includes(key)) return [key, null];
|
||||
if (value === FLEET_SELECT_NONE || value === "" || value == null)
|
||||
return [key, clearableByName[key] ? null : undefined];
|
||||
if (fieldTypeByName[key] === "number") {
|
||||
@@ -371,7 +437,15 @@ const FleetFormDialog = ({
|
||||
: String(value)
|
||||
}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
|
||||
setValues((current) => {
|
||||
const patch = field.onOptionSelected
|
||||
? field.onOptionSelected(
|
||||
field.options?.find((o) => o.value === next),
|
||||
current,
|
||||
)
|
||||
: {};
|
||||
return { ...current, [field.name]: next ?? "", ...patch };
|
||||
})
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
|
||||
@@ -417,6 +417,9 @@ export const URL_CONSTANTS = {
|
||||
WAGON_TYPES: "/wagon-types",
|
||||
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
|
||||
|
||||
TRUCK_TYPES: "/truck-types",
|
||||
TRUCK_TYPE_BY_ID: (id: string) => `/truck-types/${id}`,
|
||||
|
||||
PRIORITY_CONFIGS: "/priority-configs",
|
||||
PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`,
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -229,6 +229,21 @@ const cargoDesc = (r: FirstMileRecord) => {
|
||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
const cargoTypeName = (r: FirstMileRecord) =>
|
||||
r.booking?.cargoType?.cargoTypeName ??
|
||||
r.booking?.cargoType?.label ??
|
||||
r.booking?.cargoType?.name ??
|
||||
r.booking?.cargoFreeText ??
|
||||
"—";
|
||||
const isBulkBooking = (r: FirstMileRecord) => r.booking?.freightType === "BULK";
|
||||
const bookingTotalTons = (r: FirstMileRecord) => Number(r.booking?.cargoTotalWeightVgm) || 0;
|
||||
const trainScheduleLabel = (r: FirstMileRecord) => {
|
||||
const s = r.booking?.trainSchedule;
|
||||
if (!s?.trainNumber && !s?.departureDate) return "—";
|
||||
return [s.trainNumber, s.departureDate ? new Date(s.departureDate).toLocaleDateString() : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
};
|
||||
// First-mile destination is the origin yard (pickup → origin yard)
|
||||
const destinationYardName = (r: FirstMileRecord) =>
|
||||
r.booking?.originYard?.label ?? "—";
|
||||
@@ -277,7 +292,9 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
|
||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo Type" value={cargoTypeName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Train Schedule" value={trainScheduleLabel(record)} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment, currencyOf(record))} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
@@ -496,10 +513,11 @@ const FirstMilePage = () => {
|
||||
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
|
||||
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||
// Multi-vehicle assign: one row per truck — vehicle + its load (container for
|
||||
// container bookings; tonnes + optional item count for bulk).
|
||||
const [vehicleRows, setVehicleRows] = useState<
|
||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
||||
>([{ vehicleId: null, containerNumber: "" }]);
|
||||
Array<{ vehicleId: string | null; containerNumber: string; tons: number | ""; quantity: number | "" }>
|
||||
>([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
|
||||
@@ -925,13 +943,19 @@ const FirstMilePage = () => {
|
||||
? rec.vehicleAssignments.map((a, i) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
||||
tons: (a.tons != null ? Number(a.tons) : "") as number | "",
|
||||
quantity: (a.quantity != null ? Number(a.quantity) : "") as number | "",
|
||||
}))
|
||||
: rec?.vehicleId
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }];
|
||||
setBulkMode(false);
|
||||
setActiveId(resolved);
|
||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
||||
setVehicleRows(
|
||||
rows.length
|
||||
? rows
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "", quantity: "" }],
|
||||
);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -944,7 +968,7 @@ const FirstMilePage = () => {
|
||||
}
|
||||
setBulkMode(true);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -952,15 +976,41 @@ const FirstMilePage = () => {
|
||||
setAssignOpen(false);
|
||||
setBulkMode(false);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
const bulkCargo = activeRecord != null && isBulkBooking(activeRecord);
|
||||
if (bulkCargo && vehicleRows.some((r) => r.vehicleId && r.tons === "")) {
|
||||
toast({
|
||||
title: "Tonnes required",
|
||||
description: "Enter the tonnage each truck hauls — bulk assignment draws down the booking total.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (bulkCargo) {
|
||||
const total = bookingTotalTons(activeRecord);
|
||||
const assigning = vehicleRows.reduce((s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0), 0);
|
||||
if (total > 0 && assigning > total + 0.001) {
|
||||
toast({
|
||||
title: "Over booking tonnage",
|
||||
description: `Assigned ${assigning} t exceeds the booking's ${total} t.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const vehicles = vehicleRows
|
||||
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
||||
.filter((r): r is (typeof vehicleRows)[number] & { vehicleId: string } => Boolean(r.vehicleId))
|
||||
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
||||
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
||||
.map((r) => ({
|
||||
vehicleId: r.vehicleId,
|
||||
containerNumber: r.containerNumber.trim() || null,
|
||||
tons: bulkCargo && r.tons !== "" ? Number(r.tons) : null,
|
||||
quantity: bulkCargo && r.quantity !== "" ? Number(r.quantity) : null,
|
||||
}));
|
||||
const count = vehicles.length;
|
||||
const targetIds = bulkMode
|
||||
? selectedIds
|
||||
@@ -1432,30 +1482,63 @@ const FirstMilePage = () => {
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
/>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
),
|
||||
// keep a manual/legacy value selectable even if not in the booking
|
||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
||||
? [row.containerNumber]
|
||||
: []),
|
||||
]}
|
||||
value={row.containerNumber || null}
|
||||
onChange={(value) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
{!bulkMode && activeRecord && isBulkBooking(activeRecord) ? (
|
||||
<>
|
||||
<NumberInput
|
||||
style={{ flex: 0.8 }}
|
||||
label={i === 0 ? "Tonnes" : undefined}
|
||||
placeholder="t"
|
||||
min={0}
|
||||
value={row.tons}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
style={{ flex: 0.8 }}
|
||||
label={i === 0 ? "Items qty (pcs)" : undefined}
|
||||
placeholder="optional"
|
||||
min={0}
|
||||
value={row.quantity}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, quantity: v === "" ? "" : Number(v) } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
),
|
||||
// keep a manual/legacy value selectable even if not in the booking
|
||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
||||
? [row.containerNumber]
|
||||
: []),
|
||||
]}
|
||||
value={row.containerNumber || null}
|
||||
onChange={(value) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
{vehicleRows.length > 1 && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -1479,6 +1562,8 @@ const FirstMilePage = () => {
|
||||
vehicleId: null,
|
||||
containerNumber:
|
||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
||||
tons: "" as const,
|
||||
quantity: "" as const,
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -1491,6 +1576,22 @@ const FirstMilePage = () => {
|
||||
>
|
||||
Add vehicle
|
||||
</Button>
|
||||
{!bulkMode && activeRecord && isBulkBooking(activeRecord) && (() => {
|
||||
const total = bookingTotalTons(activeRecord);
|
||||
const assigning = vehicleRows.reduce(
|
||||
(s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0),
|
||||
0,
|
||||
);
|
||||
const remaining = Math.round((total - assigning) * 1000) / 1000;
|
||||
return (
|
||||
<Text size="sm" c={remaining < 0 ? "red" : "dimmed"}>
|
||||
Bulk drawdown: {assigning} t of {total} t assigned —{" "}
|
||||
<Text span fw={600} c={remaining < 0 ? "red" : undefined}>
|
||||
{remaining} t remaining
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
})()}
|
||||
</Stack>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeAssign}>Cancel</Button>
|
||||
|
||||
@@ -409,6 +409,43 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "truck-types",
|
||||
label: "Truck Types",
|
||||
category: "configuration",
|
||||
subtitle:
|
||||
"Configure the truck configurations vehicles are registered against — capacity and whether a trailer applies",
|
||||
searchPlaceholder: "Search truck types by name or code...",
|
||||
cardTitleKey: "name",
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
|
||||
{ id: "hasTrailer", header: "Has trailer", accessorKey: "hasTrailer" },
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
{ name: "code", label: "Code", type: "text", required: true },
|
||||
{ name: "name", label: "Name", type: "text", required: true },
|
||||
{
|
||||
name: "capacityTons",
|
||||
label: "Capacity (tons)",
|
||||
type: "number",
|
||||
optional: true,
|
||||
description: "Pre-fills the capacity of every vehicle registered on this type",
|
||||
},
|
||||
// Drives the trailer plate on vehicle registration: a rigid truck (Casoni)
|
||||
// has none, so registering one with a trailer plate is rejected.
|
||||
{
|
||||
name: "hasTrailer",
|
||||
label: "Pulls a trailer",
|
||||
type: "boolean",
|
||||
description: "Off for a rigid truck (e.g. Casoni) — its registration has no trailer plate",
|
||||
},
|
||||
{ name: "description", label: "Description", type: "textarea", optional: true },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "yard-distances",
|
||||
label: "Yard Distances",
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
FEE_RULE_BASIS_LABELS,
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
VEHICLE_TYPES,
|
||||
type AllocationRule,
|
||||
type FeeRule,
|
||||
type FeeRuleBasis,
|
||||
@@ -393,6 +392,12 @@ function FeeRules() {
|
||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
// Detention rules match on the truck-type CODE denormalised onto
|
||||
// vehicles.vehicle_type, so the options come from the managed truck types
|
||||
// rather than a hardcoded list that drifts the moment a type is added.
|
||||
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
|
||||
api.truckTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const update = useUpdateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
@@ -712,8 +717,9 @@ function FeeRules() {
|
||||
{isTruckDetention && (
|
||||
<Select
|
||||
label="Truck type"
|
||||
placeholder="Any truck type"
|
||||
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
|
||||
placeholder={truckTypesLoading ? 'Loading truck types...' : 'Any truck type'}
|
||||
data={truckTypes.map((t) => ({ value: t.code, label: `${t.name} (${t.code})` }))}
|
||||
disabled={truckTypesLoading}
|
||||
value={form.vehicleType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
|
||||
clearable
|
||||
|
||||
@@ -195,6 +195,7 @@ import {
|
||||
type UsedTrainNumbers,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
import { truckTypesService, type TruckType } from "./truck-types.service";
|
||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||
import {
|
||||
wagonService,
|
||||
@@ -2073,6 +2074,36 @@ export const api = {
|
||||
),
|
||||
},
|
||||
|
||||
truckTypes: {
|
||||
list: endpoint<void, TruckType[]>("truck-types", "list", () =>
|
||||
truckTypesService.getTruckTypes(),
|
||||
),
|
||||
|
||||
create: endpoint<Partial<TruckType>, TruckType>(
|
||||
"truck-types",
|
||||
"create",
|
||||
(payload) => truckTypesService.create(payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["truck-types"]],
|
||||
),
|
||||
|
||||
update: endpoint<{ id: string; data: Partial<TruckType> }, TruckType>(
|
||||
"truck-types",
|
||||
"update",
|
||||
({ id, data }) => truckTypesService.update(id, data).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["truck-types"]],
|
||||
),
|
||||
|
||||
remove: endpoint<string, void>(
|
||||
"truck-types",
|
||||
"remove",
|
||||
(id) => truckTypesService.delete(id).then(() => undefined),
|
||||
undefined,
|
||||
() => [["truck-types"]],
|
||||
),
|
||||
},
|
||||
|
||||
wagonTypes: {
|
||||
list: endpoint<void, WagonType[]>("wagon-types", "list", () =>
|
||||
wagonTypesService.getWagonTypes(),
|
||||
|
||||
@@ -22,7 +22,10 @@ export interface FirstMileBooking {
|
||||
serviceType?: { id: string; label?: string } | null;
|
||||
originYard?: { id: string; label?: string } | null;
|
||||
destinationYard?: { id: string; label?: string } | null;
|
||||
cargoType?: { id: string; label?: string } | null;
|
||||
cargoType?: { id: string; label?: string; cargoTypeName?: string; name?: string } | null;
|
||||
freightType?: string | null;
|
||||
/** Attached server-side: the train schedule this booking rides. */
|
||||
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
|
||||
/** Container lines — total container count drives how many trucks are needed. */
|
||||
bookingContainers?: Array<{
|
||||
id: string;
|
||||
@@ -68,6 +71,8 @@ export interface FirstMileRecord {
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
distanceKm?: number | null;
|
||||
tons?: number | null;
|
||||
quantity?: number | null;
|
||||
vehicle?: FirstMileVehicle | null;
|
||||
}>;
|
||||
/** Present only when an invoice has actually been generated (not on distance). */
|
||||
@@ -95,7 +100,12 @@ export const firstMileService = {
|
||||
api.delete<void>(FM.BY_ID(id)),
|
||||
setVehicles: (
|
||||
id: string,
|
||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
vehicles: Array<{
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
tons?: number | null;
|
||||
quantity?: number | null;
|
||||
}>,
|
||||
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
|
||||
setDistances: (
|
||||
id: string,
|
||||
|
||||
@@ -85,6 +85,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
|
||||
"truck-types": URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPES,
|
||||
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
|
||||
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
||||
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
|
||||
@@ -103,6 +104,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
||||
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
|
||||
case "wagon-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
|
||||
case "truck-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPE_BY_ID(id);
|
||||
case "priority-configs":
|
||||
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
|
||||
case "service-types":
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { api } from "../auth/http";
|
||||
|
||||
type ListResponse<T> = T[] | { data: T[] };
|
||||
|
||||
export interface TruckType {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
/** Pre-fills a vehicle's capacity — capacity belongs to the type, not each truck. */
|
||||
capacityTons: number | null;
|
||||
/** False for a rigid truck (e.g. Casoni), which has no trailer plate at all. */
|
||||
hasTrailer: boolean;
|
||||
description?: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const asList = <T>(payload: ListResponse<T>): T[] =>
|
||||
Array.isArray(payload) ? payload : payload.data;
|
||||
|
||||
export const truckTypesService = {
|
||||
async getTruckTypes() {
|
||||
const response = await api.get<ListResponse<TruckType>>('/truck-types', {
|
||||
params: { isActive: 'all', pageSize: 500 },
|
||||
});
|
||||
return asList(response.data);
|
||||
},
|
||||
create: (data: Partial<TruckType>) => api.post('/truck-types', data),
|
||||
update: (id: string, data: Partial<TruckType>) => api.patch(`/truck-types/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/truck-types/${id}`),
|
||||
};
|
||||
@@ -20,7 +20,14 @@ export interface Vehicle {
|
||||
id: string;
|
||||
plateNumber: string;
|
||||
registrationNumber: string;
|
||||
/** Denormalised truck-type code, written server-side. Register with `truckTypeId`. */
|
||||
vehicleType: VehicleType;
|
||||
/** Truck configuration from the managed truck types. */
|
||||
truckTypeId?: string | null;
|
||||
/** Vehicle Identification Number — unique across the fleet. */
|
||||
vin?: string | null;
|
||||
/** OWNED | OUTSOURCED. */
|
||||
ownership?: string | null;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
year: number;
|
||||
|
||||
@@ -2,6 +2,7 @@ export type RuleEngineResourceSlug =
|
||||
| "cargo-types"
|
||||
| "container-types"
|
||||
| "wagon-types"
|
||||
| "truck-types"
|
||||
| "priority-configs"
|
||||
| "service-types"
|
||||
| "weight-limit-rules"
|
||||
|
||||
Reference in New Issue
Block a user