This commit is contained in:
natib21
2026-07-06 13:49:30 +00:00
parent 458198be11
commit 42710261e2
14 changed files with 517 additions and 78 deletions

View File

@@ -8,8 +8,11 @@ import {
Body,
Query,
ParseUUIDPipe,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
@@ -65,6 +68,31 @@ export class DriversController {
return this.fleetHistory.getDriverHistory(id);
}
@Post(':id/documents')
@FleetManage()
@ApiConsumes('multipart/form-data')
@UseInterceptors(AnyFilesInterceptor())
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.driversService.uploadDocuments(id, files ?? []);
}
@Get(':id/documents')
@ApiOperation({ summary: "List a driver's documents" })
listDocuments(@Param('id', ParseUUIDPipe) id: string) {
return this.driversService.listDocuments(id);
}
@Delete(':id/documents/:fileId')
@FleetManage()
@ApiOperation({ summary: 'Delete a driver document' })
removeDocument(@Param('fileId', ParseUUIDPipe) fileId: string) {
return this.driversService.removeDocument(fileId);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a driver' })

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
import { FilesModule } from '../files/files.module';
@Module({
imports: [TypeOrmModule.forFeature([Driver])],
imports: [TypeOrmModule.forFeature([Driver]), FilesModule],
providers: [DriversService],
controllers: [DriversController],
exports: [DriversService],

View File

@@ -6,6 +6,11 @@ import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
import { FilesService } from '../files/files.service';
/** Resource + code the driver-documents upload area is stored under. */
const DRIVER_DOCS_RESOURCE = 'driver';
const DRIVER_DOCS_CODE = 'driver_docs';
@Injectable()
export class DriversService {
@@ -13,8 +18,37 @@ export class DriversService {
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
private readonly history: FleetHistoryService,
private readonly filesService: FilesService,
) {}
/** Upload one or more driver documents (code "driver_docs"). */
async uploadDocuments(driverId: string, files: Express.Multer.File[]) {
const driver = await this.driverRepo.findOneBy({ id: driverId });
if (!driver) throw new NotFoundException(`Driver ${driverId} not found`);
if (!files?.length) throw new BadRequestException('No files provided');
return Promise.all(
files.map((file) =>
this.filesService.upload({
resourceId: driverId,
resource: DRIVER_DOCS_RESOURCE,
code: DRIVER_DOCS_CODE,
file,
}),
),
);
}
/** List a driver's uploaded documents (code "driver_docs"). */
async listDocuments(driverId: string) {
const all = await this.filesService.findByResource(driverId, DRIVER_DOCS_RESOURCE);
return all.filter((f) => f.code === DRIVER_DOCS_CODE);
}
/** Delete a single driver document by file id. */
async removeDocument(fileId: string): Promise<void> {
await this.filesService.remove(fileId);
}
async create(dto: CreateDriverDto): Promise<Driver> {
if (dto.faydaVerified !== true) {
throw new BadRequestException(

View File

@@ -119,6 +119,11 @@ export class FilesService {
return record;
}
/** Soft-delete a stored file row by id (object bytes are left in MinIO). */
async remove(id: string): Promise<void> {
await this.filesRepository.softDelete(id);
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource);
}

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -66,13 +66,37 @@ export class FirstMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
return this.billing.generateInvoice({
source: 'first_mile' as Freight.InvoiceSource,
sourceId: record.id,
type: 'DELIVERY_FEE',
companyId: fm.booking!.companyId,
companyProfileId: fm.booking!.companyProfileId || '',
currency: fm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || fm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -640,10 +640,24 @@ export class FirstMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// FIRST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.firstMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
@@ -63,6 +63,30 @@ export class LastMileInvoiceService {
return null;
}
// Reject mixed-currency truck sets — a single invoice can only be one
// currency, and amounts across currencies can't be summed.
const billableTrucks = (record.vehicleAssignments ?? []).filter(
(a) => Number(a.distanceKm) > 0,
);
const currencies = [
...new Set(
billableTrucks
.map((a) => (a.vehicle as { currency?: string } | undefined)?.currency)
.filter((c): c is string => Boolean(c)),
),
];
if (currencies.length > 1) {
throw new BadRequestException(
`Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`,
);
}
// Currency follows the truck (price/km is quoted per vehicle), falling back
// to the booking's currency, then ETB.
const truckCurrency =
(record.vehicle as { currency?: string } | undefined)?.currency ||
(record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency;
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
@@ -70,7 +94,7 @@ export class LastMileInvoiceService {
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: lm.booking!.paymentCurrency || 'ETB',
currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -510,10 +510,24 @@ export class LastMileService {
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
// Billing is per truck: amount = Σ (truck distance × truck price/km). The
// per-vehicle rate + currency live on the vehicle, so we ignore the legacy
// LAST_MILE flat rate and any client-sent amount. `remainingPayment` param
// kept only for signature back-compat.
void remainingPayment;
const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
relations: { vehicle: true },
});
const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0);
const amount = assignments.reduce(
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
0,
);
await this.lastMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
remainingPayment: amount,
} as any);
return this.findById(id);
}

View File

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

View File

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

View File

@@ -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),

View File

@@ -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}`),
};

View File

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

View File

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