feat(rates): last-mile rate rules with bulk per-ton-km and container distance bands

- rates: min_km/max_km columns, PER_TON_KM unit, ETB|USD currency for last mile
- extend UQ_rates_pattern with band start; overlap + shape validation
- shared last-mile charge resolver; approve-dialog price estimate endpoint
- delivery-fee invoice prices via rules, falls back to vehicle price/km
- backoffice: last-mile rate form (mode, container type, band, currency)
This commit is contained in:
Hagernesh
2026-08-05 22:12:40 +00:00
parent 6125f644b1
commit 5c8c68e990
25 changed files with 880 additions and 20 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import {
Badge,
Box,
@@ -76,6 +76,21 @@ export function LastMileRequestsPanel() {
queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data,
});
// Rule-based estimate for the approve dialog (estimated km × live last-mile
// rates). Prefills the advance once, without clobbering a typed value.
const { data: estimate } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.priceEstimate(approveTarget?.id ?? ""),
queryFn: async () =>
(await lastMileRequestsService.priceEstimate(approveTarget!.id)).data,
enabled: Boolean(approveTarget),
});
useEffect(() => {
if (approveTarget && estimate?.total != null && advanceAmount === "") {
setAdvanceAmount(estimate.total);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [estimate, approveTarget]);
const invalidate = () =>
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
@@ -227,11 +242,29 @@ export function LastMileRequestsPanel() {
<Modal
opened={Boolean(approveTarget)}
onClose={() => setApproveTarget(null)}
onClose={() => {
setApproveTarget(null);
setAdvanceAmount("");
}}
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
centered
>
<Stack gap="md">
{estimate?.total != null && (
<Stack gap={4}>
{estimate.lines.map((line) => (
<Text key={line.description} size="xs" c="dimmed">
{line.description} {line.amount.toLocaleString()}
</Text>
))}
<Text size="sm" fw={600}>
Estimated total: {estimate.total.toLocaleString()} {estimate.currency}
{estimate.estimatedKm != null
? ` · ${estimate.estimatedKm} km (straight-line estimate)`
: ""}
</Text>
</Stack>
)}
<NumberInput
label="Advance amount"
placeholder="0.00"
@@ -241,7 +274,13 @@ export function LastMileRequestsPanel() {
onChange={setAdvanceAmount}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setApproveTarget(null)}>
<Button
variant="default"
onClick={() => {
setApproveTarget(null);
setAdvanceAmount("");
}}
>
Cancel
</Button>
<Button