fix first/last mile

This commit is contained in:
natib21
2026-07-16 07:13:24 +00:00
parent c7e1ed5586
commit 312014b678
2 changed files with 114 additions and 6 deletions

View File

@@ -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}

View File

@@ -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}