mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
fix first/last mile
This commit is contained in:
@@ -208,6 +208,20 @@ const billingIssues = (r: FirstMileRecord) => {
|
||||
];
|
||||
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
||||
};
|
||||
/**
|
||||
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
|
||||
* vehicle missing either field cannot produce an invoice line. Returns the
|
||||
* human-readable gap, or null when the vehicle is billable.
|
||||
*/
|
||||
const pricingGap = (
|
||||
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
|
||||
): string | null => {
|
||||
if (!v) return null;
|
||||
const missing: string[] = [];
|
||||
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
|
||||
if (!String(v.currency ?? "").trim()) missing.push("Currency");
|
||||
return missing.length ? missing.join(" and ") : null;
|
||||
};
|
||||
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
|
||||
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
||||
const cargoDesc = (r: FirstMileRecord) => {
|
||||
@@ -681,6 +695,22 @@ const FirstMilePage = () => {
|
||||
return opts;
|
||||
}, [vehicleOptions, activeRecord]);
|
||||
|
||||
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
|
||||
// below rather than silently billing 0 once distances are entered.
|
||||
const pricingGapById = useMemo(() => {
|
||||
const map = new Map<string, string | null>();
|
||||
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
|
||||
if (v?.id) map.set(v.id, pricingGap(v));
|
||||
};
|
||||
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
|
||||
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
|
||||
add(activeRecord?.vehicle);
|
||||
return map;
|
||||
}, [vehiclesData, activeRecord]);
|
||||
|
||||
const vehicleLabelFor = (id: string) =>
|
||||
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
|
||||
|
||||
// Full booking (with container units) for the assign modal's container dropdown.
|
||||
// Fetched on open so container numbers show regardless of what the list embeds.
|
||||
const { data: assignBooking } = useQuery({
|
||||
@@ -932,6 +962,21 @@ const FirstMilePage = () => {
|
||||
|
||||
if (!targetIds.length) return;
|
||||
|
||||
// Backstop for rows the Select guard never saw (pre-filled reassignments).
|
||||
const unpriced = vehicles
|
||||
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
|
||||
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
|
||||
if (unpriced.length) {
|
||||
toast({
|
||||
title: "Vehicle is not priced",
|
||||
description: `${unpriced
|
||||
.map((v) => `${v.label} (${v.gap} not set)`)
|
||||
.join("; ")} — set it on the vehicle before assigning.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||
.then(() => {
|
||||
@@ -1365,9 +1410,18 @@ const FirstMilePage = () => {
|
||||
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||
)}
|
||||
value={row.vehicleId}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
||||
}
|
||||
onChange={(v) => {
|
||||
const gap = v ? pricingGapById.get(v) : null;
|
||||
if (v && gap) {
|
||||
toast({
|
||||
title: "Vehicle is not priced",
|
||||
description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
|
||||
@@ -251,6 +251,20 @@ const billingIssues = (r: LastMileRecord) => {
|
||||
];
|
||||
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
||||
};
|
||||
/**
|
||||
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
|
||||
* vehicle missing either field cannot produce an invoice line. Returns the
|
||||
* human-readable gap, or null when the vehicle is billable.
|
||||
*/
|
||||
const pricingGap = (
|
||||
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
|
||||
): string | null => {
|
||||
if (!v) return null;
|
||||
const missing: string[] = [];
|
||||
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
|
||||
if (!String(v.currency ?? "").trim()) missing.push("Currency");
|
||||
return missing.length ? missing.join(" and ") : null;
|
||||
};
|
||||
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||
const cargoDesc = (r: LastMileRecord) => {
|
||||
@@ -870,6 +884,22 @@ const LastMilePage = () => {
|
||||
return opts;
|
||||
}, [vehicleOptions, activeRecord]);
|
||||
|
||||
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
|
||||
// below rather than silently billing 0 once distances are entered.
|
||||
const pricingGapById = useMemo(() => {
|
||||
const map = new Map<string, string | null>();
|
||||
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
|
||||
if (v?.id) map.set(v.id, pricingGap(v));
|
||||
};
|
||||
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
|
||||
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
|
||||
add(activeRecord?.vehicle);
|
||||
return map;
|
||||
}, [vehiclesData, activeRecord]);
|
||||
|
||||
const vehicleLabelFor = (id: string) =>
|
||||
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
|
||||
|
||||
// Full booking (with container units) for the assign modal's container dropdown.
|
||||
// Fetched on open so container numbers show regardless of what the list embeds.
|
||||
const { data: assignBooking } = useQuery({
|
||||
@@ -1018,6 +1048,21 @@ const LastMilePage = () => {
|
||||
|
||||
if (!targetIds.length) return;
|
||||
|
||||
// Backstop for rows the Select guard never saw (pre-filled reassignments).
|
||||
const unpriced = vehicles
|
||||
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
|
||||
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
|
||||
if (unpriced.length) {
|
||||
toast({
|
||||
title: "Vehicle is not priced",
|
||||
description: `${unpriced
|
||||
.map((v) => `${v.label} (${v.gap} not set)`)
|
||||
.join("; ")} — set it on the vehicle before assigning.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||
.then(() => {
|
||||
@@ -1738,9 +1783,18 @@ const LastMilePage = () => {
|
||||
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||
)}
|
||||
value={row.vehicleId}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
||||
}
|
||||
onChange={(v) => {
|
||||
const gap = v ? pricingGapById.get(v) : null;
|
||||
if (v && gap) {
|
||||
toast({
|
||||
title: "Vehicle is not priced",
|
||||
description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
|
||||
Reference in New Issue
Block a user