This commit is contained in:
natib21
2026-07-03 20:43:26 +00:00
parent f5e68f5a61
commit 4ddf03e357
4 changed files with 53 additions and 27 deletions

View File

@@ -307,6 +307,16 @@ export class BillingService {
});
}
/** Invoices for a batch of source records (e.g. many last-mile legs), so a
* list can show which records already have an invoice without N+1 queries. */
findBySourceIds(source: string, sourceIds: string[]): Promise<Invoice[]> {
if (!sourceIds.length) return Promise.resolve([]);
return this.invoices.findAll({
where: { source, sourceId: In(sourceIds) },
order: { createdAt: "DESC" },
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
async findForUser(
userId: string,

View File

@@ -12,7 +12,7 @@ import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileRepository } from './last-mile.repository';
import { InvoiceEventPayload } from '../billing/billing.service';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
@@ -46,8 +46,28 @@ export class LastMileService {
private readonly smsClient: SmsClientService,
private readonly dataSource: DataSource,
private readonly history: FleetHistoryService,
private readonly billing: BillingService,
) {}
/** Attach real invoice info (number/status) to records so the UI can show an
* invoice link only when one actually exists — NOT merely because distance
* was entered. Batched to avoid N+1. */
private async attachInvoices(records: LastMile[]): Promise<void> {
const invoices = await this.billing.findBySourceIds(
'last_mile',
records.map((r) => r.id),
);
const byId = new Map<string, { number: string; status: string }>();
for (const inv of invoices) {
if (!byId.has(inv.sourceId)) {
byId.set(inv.sourceId, { number: inv.invoiceNumber, status: String(inv.status) });
}
}
for (const r of records) {
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
}
}
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
private async vehicleInfo(
@@ -159,6 +179,8 @@ export class LastMileService {
take: pageSize,
});
await this.attachInvoices(data);
return {
data,
meta: {
@@ -183,6 +205,8 @@ export class LastMileService {
throw new NotFoundException(`Last-mile record ${id} not found`);
}
await this.attachInvoices([record]);
return record;
}

View File

@@ -1086,35 +1086,25 @@ const LastMilePage = () => {
header: "Invoice",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
const isPaid = (row.original as any).paid;
if (!hasDistance) {
// Only show an invoice once it's actually been generated — NOT merely
// because distance was entered.
const invoice = row.original.invoice;
if (!invoice) {
return <Text c="dimmed"></Text>;
}
if (isPaid) {
return (
<Group gap="xs" wrap="nowrap">
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
<Badge color="green" variant="light" size="sm">Paid</Badge>
</Group>
);
}
const isPaid = (row.original as any).paid || invoice.status === "Paid";
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
<Group gap="xs" wrap="nowrap">
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
{invoice.number}
</UnstyledButton>
{isPaid && <Badge color="green" variant="light" size="sm">Paid</Badge>}
</Group>
);
},
},

View File

@@ -64,6 +64,8 @@ export interface LastMileRecord {
distanceKm?: number | null;
vehicle?: LastMileVehicle | null;
}>;
/** Present only when an invoice has actually been generated (not on distance). */
invoice?: { number: string; status: string } | null;
createdAt: string;
updatedAt: string;
}