mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
fix
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import { useMemo } 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,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
@@ -19,9 +21,14 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
History,
|
||||
Route,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Upload,
|
||||
Truck,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
@@ -29,6 +36,7 @@ import {
|
||||
import { driversService } from "@/services/drivers.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const fmtDate = (iso?: string | null) => {
|
||||
if (!iso) return "—";
|
||||
@@ -55,6 +63,116 @@ const Loading = () => (
|
||||
<Center py="xl"><Loader size="sm" /></Center>
|
||||
);
|
||||
|
||||
const fmtSize = (bytes: number) => {
|
||||
if (!bytes) return "—";
|
||||
const kb = bytes / 1024;
|
||||
return kb < 1024 ? `${kb.toFixed(0)} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** Driver documents upload + view area (files stored under code "driver_docs"). */
|
||||
const DriverDocuments = ({ driverId }: { driverId: string }) => {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: docs = [], isLoading } = useQuery({
|
||||
queryKey: ["driver", driverId, "documents"],
|
||||
queryFn: () => driversService.listDocuments(driverId).then((r) => r.data ?? []),
|
||||
enabled: Boolean(driverId),
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (files: File[]) => driversService.uploadDocuments(driverId, files),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Documents uploaded" });
|
||||
void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const description =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
"Upload failed";
|
||||
toast({ title: "Upload failed", description, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (fileId: string) => driversService.removeDocument(driverId, fileId),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Document deleted" });
|
||||
void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] });
|
||||
},
|
||||
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
|
||||
});
|
||||
|
||||
// /files/:id is a public inline-serving route; open directly for preview/download.
|
||||
const fileUrl = (fileId: string, download = false) =>
|
||||
`${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`;
|
||||
|
||||
return (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600} size="sm">Documents ({docs.length})</Text>
|
||||
<FileButton multiple onChange={(files) => files.length && uploadMutation.mutate(files)}>
|
||||
{(props) => (
|
||||
<Button {...props} size="xs" leftSection={<Upload size={14} />} loading={uploadMutation.isPending}>
|
||||
Upload
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : docs.length === 0 ? (
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">No documents uploaded yet.</Text>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Uploaded</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{docs.map((doc) => (
|
||||
<Table.Tr key={doc.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<FileText size={15} />
|
||||
<Text size="sm" truncate>{doc.name}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{fmtSize(doc.size)}</Table.Td>
|
||||
<Table.Td>{fmtDate(doc.createdAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" aria-label="View" onClick={() => window.open(fileUrl(doc.id), "_blank")}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" aria-label="Download" onClick={() => window.open(fileUrl(doc.id, true), "_blank")}>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Delete"
|
||||
loading={removeMutation.isPending && removeMutation.variables === doc.id}
|
||||
onClick={() => removeMutation.mutate(doc.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const DriverDetailPage = () => {
|
||||
const { id = "" } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -106,6 +224,7 @@ const DriverDetailPage = () => {
|
||||
<Tabs.Tab value="vehicles" leftSection={<Truck size={14} />}>Vehicles</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
|
||||
<Tabs.Tab value="trips" leftSection={<Route size={14} />}>Trips</Tabs.Tab>
|
||||
<Tabs.Tab value="documents" leftSection={<FileText size={14} />}>Documents</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
@@ -149,6 +268,10 @@ const DriverDetailPage = () => {
|
||||
<Tabs.Panel value="trips" pt="lg">
|
||||
<TripsTab driverId={id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="documents" pt="lg">
|
||||
<DriverDocuments driverId={id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { ColumnDef } from "@edr/ui-common";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Autocomplete,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -54,7 +54,6 @@ import {
|
||||
} from "@/services/first-mile.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
|
||||
@@ -191,7 +190,24 @@ const isPostPaymentPending = (r: FirstMileRecord) =>
|
||||
|
||||
// Map API record → display fields used in modals and trip slip
|
||||
const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId;
|
||||
const currencyOf = (r: FirstMileRecord) => r.booking?.paymentCurrency ?? "ETB";
|
||||
const currencyOf = (r: FirstMileRecord) =>
|
||||
r.vehicle?.currency ??
|
||||
r.vehicleAssignments?.[0]?.vehicle?.currency ??
|
||||
r.booking?.paymentCurrency ??
|
||||
"ETB";
|
||||
|
||||
type FmAssignment = NonNullable<FirstMileRecord["vehicleAssignments"]>[number];
|
||||
const truckShort = (a: FmAssignment) =>
|
||||
a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
|
||||
/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */
|
||||
const billingIssues = (r: FirstMileRecord) => {
|
||||
const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0);
|
||||
const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort);
|
||||
const currencies = [
|
||||
...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))),
|
||||
];
|
||||
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
||||
};
|
||||
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
|
||||
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
||||
const cargoDesc = (r: FirstMileRecord) => {
|
||||
@@ -480,6 +496,8 @@ const FirstMilePage = () => {
|
||||
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||
// Per-vehicle actual distance, keyed by vehicleId.
|
||||
const [distanceRows, setDistanceRows] = useState<Record<string, string>>({});
|
||||
// Record pending invoice-generation confirmation (shows a summary first).
|
||||
const [invoiceConfirm, setInvoiceConfirm] = useState<FirstMileRecord | null>(null);
|
||||
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
|
||||
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
|
||||
|
||||
@@ -500,14 +518,6 @@ const FirstMilePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: ratesData } = useQuery({
|
||||
queryKey: ["rates", "FIRST_MILE"],
|
||||
queryFn: async () => {
|
||||
const res = await ratesService.getByType("FIRST_MILE");
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
|
||||
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
|
||||
@@ -572,10 +582,17 @@ const FirstMilePage = () => {
|
||||
distances: Array<{ vehicleId: string; distanceKm: number }>;
|
||||
remainingPayment?: number;
|
||||
}) => firstMileService.setDistances(id, distances, remainingPayment),
|
||||
onSuccess: () => {
|
||||
onSuccess: (res) => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
|
||||
const updated = res?.data as FirstMileRecord | undefined;
|
||||
closeDistance();
|
||||
// Every truck has a distance and it isn't billed yet → offer to invoice now.
|
||||
const trucks = updated?.vehicleAssignments ?? [];
|
||||
const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0);
|
||||
if (updated && allFilled && !updated.invoice) {
|
||||
setInvoiceConfirm(updated);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Update failed", variant: "destructive" });
|
||||
@@ -783,18 +800,9 @@ const FirstMilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
|
||||
let remainingPayment: number | undefined;
|
||||
if (ratesData?.data) {
|
||||
const firstMileRate = ratesData.data.find(
|
||||
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
|
||||
);
|
||||
if (firstMileRate) {
|
||||
remainingPayment = total * parseFloat(firstMileRate.rateValue);
|
||||
}
|
||||
}
|
||||
|
||||
setDistancesMutation.mutate({ id: activeId, distances, remainingPayment });
|
||||
// Amount is computed server-side per truck (distance × the vehicle's
|
||||
// price/km, in the vehicle's currency) — no flat FIRST_MILE rate.
|
||||
setDistancesMutation.mutate({ id: activeId, distances });
|
||||
};
|
||||
|
||||
const matchesFilter = (r: FirstMileRecord) => {
|
||||
@@ -849,6 +857,33 @@ const FirstMilePage = () => {
|
||||
return filteredRecords.slice(start, start + pagination.pageSize);
|
||||
}, [filteredRecords, pagination]);
|
||||
|
||||
// Billing problems on the leg pending invoice confirmation.
|
||||
const confirmIssues = invoiceConfirm
|
||||
? billingIssues(invoiceConfirm)
|
||||
: { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] };
|
||||
|
||||
// Guard invoice generation: block mixed currency, warn (but proceed) on trucks
|
||||
// priced at 0/km.
|
||||
const handleGenerateInvoice = (r: FirstMileRecord) => {
|
||||
const { zeroPrice, mixedCurrency, currencies } = billingIssues(r);
|
||||
if (mixedCurrency) {
|
||||
toast({
|
||||
title: "Mixed truck currencies",
|
||||
description: `Trucks use ${currencies.join(", ")}. Assign trucks that share one currency.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (zeroPrice.length) {
|
||||
toast({
|
||||
title: "Truck has no price/km",
|
||||
description: `${zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
generateInvoiceMutation.mutate(r.id);
|
||||
};
|
||||
|
||||
const openAssign = (id: string | null) => {
|
||||
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
||||
const rec = records.find((r) => r.id === resolved);
|
||||
@@ -1173,7 +1208,7 @@ const FirstMilePage = () => {
|
||||
!(row.original.exactKm != null && row.original.exactKm > 0) ||
|
||||
Boolean(row.original.invoice)
|
||||
}
|
||||
onClick={() => generateInvoiceMutation.mutate(row.original.id)}
|
||||
onClick={() => handleGenerateInvoice(row.original)}
|
||||
>
|
||||
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
|
||||
</Menu.Item>
|
||||
@@ -1337,21 +1372,29 @@ const FirstMilePage = () => {
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
/>
|
||||
<Autocomplete
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder="Container number"
|
||||
data={containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
)}
|
||||
value={row.containerNumber}
|
||||
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)),
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
{vehicleRows.length > 1 && (
|
||||
<ActionIcon
|
||||
@@ -1740,6 +1783,77 @@ const FirstMilePage = () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Generate Invoice — confirmation summary */}
|
||||
<Modal
|
||||
opened={Boolean(invoiceConfirm)}
|
||||
onClose={() => setInvoiceConfirm(null)}
|
||||
title={<Text fw={600}>Generate Invoice</Text>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
{invoiceConfirm && (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">{bookingRef(invoiceConfirm)}</Text>
|
||||
<Text size="sm" c="dimmed">{customerName(invoiceConfirm)}</Text>
|
||||
</Group>
|
||||
<Card withBorder padding="sm" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap={6}>
|
||||
{(invoiceConfirm.vehicleAssignments ?? []).map((a) => {
|
||||
const v = a.vehicle;
|
||||
const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
|
||||
return (
|
||||
<Group key={a.id} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">
|
||||
{label}
|
||||
{a.containerNumber ? ` · ${a.containerNumber}` : ""}
|
||||
</Text>
|
||||
<Text size="sm">{a.distanceKm != null ? `${a.distanceKm} km` : "—"}</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Total distance</Text>
|
||||
<Text size="sm" fw={500}>{invoiceConfirm.exactKm ?? 0} km</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Invoice amount</Text>
|
||||
<Text fw={700}>{formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))}</Text>
|
||||
</Group>
|
||||
{confirmIssues.mixedCurrency && (
|
||||
<Alert color="red" variant="light" title="Mixed truck currencies">
|
||||
Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing.
|
||||
</Alert>
|
||||
)}
|
||||
{confirmIssues.zeroPrice.length > 0 && (
|
||||
<Alert color="yellow" variant="light" title="Truck has no price/km">
|
||||
{confirmIssues.zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle.
|
||||
</Alert>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
Generate the delivery-fee invoice now, or close and generate later from the row actions.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setInvoiceConfirm(null)}>Later</Button>
|
||||
<Button
|
||||
loading={generateInvoiceMutation.isPending}
|
||||
disabled={confirmIssues.mixedCurrency}
|
||||
onClick={() =>
|
||||
generateInvoiceMutation.mutate(invoiceConfirm.id, {
|
||||
onSuccess: () => setInvoiceConfirm(null),
|
||||
})
|
||||
}
|
||||
>
|
||||
Generate Invoice
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@ import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -56,7 +55,6 @@ import {
|
||||
} from "@/services/last-mile.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||
|
||||
@@ -233,7 +231,24 @@ const computeLastMileSteps = (
|
||||
};
|
||||
|
||||
const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
|
||||
const currencyOf = (r: LastMileRecord) => r.booking?.paymentCurrency ?? "ETB";
|
||||
const currencyOf = (r: LastMileRecord) =>
|
||||
r.vehicle?.currency ??
|
||||
r.vehicleAssignments?.[0]?.vehicle?.currency ??
|
||||
r.booking?.paymentCurrency ??
|
||||
"ETB";
|
||||
|
||||
type LmAssignment = NonNullable<LastMileRecord["vehicleAssignments"]>[number];
|
||||
const truckShort = (a: LmAssignment) =>
|
||||
a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
|
||||
/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */
|
||||
const billingIssues = (r: LastMileRecord) => {
|
||||
const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0);
|
||||
const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort);
|
||||
const currencies = [
|
||||
...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))),
|
||||
];
|
||||
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
||||
};
|
||||
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||
const cargoDesc = (r: LastMileRecord) => {
|
||||
@@ -581,14 +596,6 @@ const LastMilePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: ratesData } = useQuery({
|
||||
queryKey: ["rates", "LAST_MILE"],
|
||||
queryFn: async () => {
|
||||
const res = await ratesService.getByType("LAST_MILE");
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const records = listData?.data ?? [];
|
||||
const existingLastMileBookingIds = useMemo(
|
||||
() => new Set(records.map((record) => record.bookingId)),
|
||||
@@ -667,10 +674,17 @@ const LastMilePage = () => {
|
||||
distances: Array<{ vehicleId: string; distanceKm: number }>;
|
||||
remainingPayment?: number;
|
||||
}) => lastMileService.setDistances(id, distances, remainingPayment),
|
||||
onSuccess: () => {
|
||||
onSuccess: (res) => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
|
||||
const updated = res?.data as LastMileRecord | undefined;
|
||||
closeDistance();
|
||||
// Every truck has a distance and it isn't billed yet → offer to invoice now.
|
||||
const trucks = updated?.vehicleAssignments ?? [];
|
||||
const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0);
|
||||
if (updated && allFilled && !updated.invoice) {
|
||||
setInvoiceConfirm(updated);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Update failed", variant: "destructive" });
|
||||
@@ -802,18 +816,9 @@ const LastMilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
|
||||
let remainingPayment: number | undefined;
|
||||
if (ratesData?.data) {
|
||||
const lastMileRate = ratesData.data.find(
|
||||
(r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
|
||||
);
|
||||
if (lastMileRate) {
|
||||
remainingPayment = total * parseFloat(lastMileRate.rateValue);
|
||||
}
|
||||
}
|
||||
|
||||
distanceMutation.mutate({ id: activeId, distances, remainingPayment });
|
||||
// Amount is computed server-side per truck (distance × the vehicle's
|
||||
// price/km, in the vehicle's currency) — no flat LAST_MILE rate.
|
||||
distanceMutation.mutate({ id: activeId, distances });
|
||||
};
|
||||
|
||||
const activeRecord = useMemo(
|
||||
@@ -929,6 +934,11 @@ const LastMilePage = () => {
|
||||
[records],
|
||||
);
|
||||
|
||||
// Billing problems on the leg pending invoice confirmation.
|
||||
const confirmIssues = invoiceConfirm
|
||||
? billingIssues(invoiceConfirm)
|
||||
: { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] };
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return records.filter((r) => {
|
||||
@@ -1697,21 +1707,29 @@ const LastMilePage = () => {
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
/>
|
||||
<Autocomplete
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder="Container number"
|
||||
data={containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
)}
|
||||
value={row.containerNumber}
|
||||
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)),
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
{vehicleRows.length > 1 && (
|
||||
<ActionIcon
|
||||
@@ -1993,6 +2011,16 @@ const LastMilePage = () => {
|
||||
<Text fw={600}>Invoice amount</Text>
|
||||
<Text fw={700}>{formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))}</Text>
|
||||
</Group>
|
||||
{confirmIssues.mixedCurrency && (
|
||||
<Alert color="red" variant="light" title="Mixed truck currencies">
|
||||
Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing.
|
||||
</Alert>
|
||||
)}
|
||||
{confirmIssues.zeroPrice.length > 0 && (
|
||||
<Alert color="yellow" variant="light" title="Truck has no price/km">
|
||||
{confirmIssues.zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle.
|
||||
</Alert>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
This creates the delivery-fee invoice. Confirm the distances and amount are correct.
|
||||
</Text>
|
||||
@@ -2000,6 +2028,7 @@ const LastMilePage = () => {
|
||||
<Button variant="default" onClick={() => setInvoiceConfirm(null)}>Cancel</Button>
|
||||
<Button
|
||||
loading={generateInvoiceMutation.isPending}
|
||||
disabled={confirmIssues.mixedCurrency}
|
||||
onClick={() =>
|
||||
generateInvoiceMutation.mutate(invoiceConfirm.id, {
|
||||
onSuccess: () => setInvoiceConfirm(null),
|
||||
|
||||
@@ -39,6 +39,17 @@ export type SaveDriverPayload = Omit<
|
||||
'id' | 'createdAt' | 'updatedAt' | 'totalTrips' | 'rating'
|
||||
>;
|
||||
|
||||
/** A stored driver document (code "driver_docs"). */
|
||||
export interface DriverDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
code: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const driversService = {
|
||||
getAll: (filters: DriverListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -59,4 +70,18 @@ export const driversService = {
|
||||
update: (id: string, data: Partial<SaveDriverPayload>) =>
|
||||
apiClient.patch(URL_CONSTANTS.DRIVERS.BY_ID(id), data),
|
||||
delete: (id: string) => apiClient.delete(URL_CONSTANTS.DRIVERS.BY_ID(id)),
|
||||
|
||||
// ── Driver documents (upload area code "driver_docs") ──
|
||||
listDocuments: (id: string) =>
|
||||
apiClient.get<DriverDocument[]>(`${URL_CONSTANTS.DRIVERS.BY_ID(id)}/documents`),
|
||||
uploadDocuments: (id: string, files: File[]) => {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append('files', f);
|
||||
return apiClient.post<DriverDocument[]>(
|
||||
`${URL_CONSTANTS.DRIVERS.BY_ID(id)}/documents`,
|
||||
form,
|
||||
);
|
||||
},
|
||||
removeDocument: (id: string, fileId: string) =>
|
||||
apiClient.delete(`${URL_CONSTANTS.DRIVERS.BY_ID(id)}/documents/${fileId}`),
|
||||
};
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface FirstMileVehicle {
|
||||
trailerPlateNo?: string | null;
|
||||
assignedDriverId?: string | null;
|
||||
assignedDriverName?: string | null;
|
||||
pricePerKm?: number | string | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
export interface FirstMileRecord {
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface LastMileVehicle {
|
||||
trailerPlateNo?: string | null;
|
||||
assignedDriverId?: string | null;
|
||||
assignedDriverName?: string | null;
|
||||
pricePerKm?: number | string | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
export interface LastMileRecord {
|
||||
|
||||
Reference in New Issue
Block a user