diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index a8c55ecf4..69812182f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -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(); + 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} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index e5de54880..cd5d22c6b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -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(); + 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}