Merge pull request #435 from Tria-plc/freight/feature/first_mile_invoice

Freight/feature/first mile invoice
This commit is contained in:
yaschalew10
2026-07-04 01:57:06 +03:00
committed by GitHub
20 changed files with 879 additions and 1219 deletions

View File

@@ -13,6 +13,7 @@ import {
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
@@ -33,11 +34,9 @@ import {
Text,
TextInput,
UnstyledButton,
Alert,
} from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
@@ -50,7 +49,6 @@ import {
import { bookingsService } from "@/services/bookings.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import { api } from "@/auth/http";
import type { BookingDetail } from "@/types/booking";
const formatPrice = (amount: number) =>
@@ -320,6 +318,7 @@ const buildTripSlipHtml = (record: FirstMileRecord) => {
const FirstMilePage = () => {
const { toast } = useToast();
const qc = useQueryClient();
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
@@ -343,13 +342,9 @@ const FirstMilePage = () => {
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.FIRST_MILE.list(),
@@ -438,6 +433,17 @@ const FirstMilePage = () => {
},
});
const generateInvoiceMutation = useMutation({
mutationFn: (id: string) => firstMileService.generateInvoice(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
toast({ title: "Invoice generated" });
},
onError: () => {
toast({ title: "Invoice generation failed", variant: "destructive" });
},
});
const acceptMutation = useMutation({
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
const res = await firstMileService.accept(reference);
@@ -462,20 +468,6 @@ const FirstMilePage = () => {
},
});
const allocateMutation = useMutation({
mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data),
onSuccess: () => {
toast({ title: "Containers allocated" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
setContainerAllocationOpen(false);
setContainerAllocationFirstMileId(null);
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
@@ -545,15 +537,6 @@ const FirstMilePage = () => {
setDistanceValue("");
};
const openInvoice = (record: FirstMileRecord) => {
setInvoiceRecord(record);
setInvoiceOpen(true);
};
const closeInvoice = () => {
setInvoiceOpen(false);
setInvoiceRecord(null);
};
const openWarehouseReceive = (record: FirstMileRecord) => {
setWarehouseReceiveRecord(record);
@@ -565,16 +548,6 @@ const FirstMilePage = () => {
setWarehouseReceiveRecord(null);
};
const openContainerAllocation = (firstMileId: string) => {
setContainerAllocationFirstMileId(firstMileId);
setContainerAllocationOpen(true);
};
const closeContainerAllocation = () => {
setContainerAllocationOpen(false);
setContainerAllocationFirstMileId(null);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
@@ -779,35 +752,28 @@ const FirstMilePage = () => {
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 once actually generated — not merely on distance.
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={() =>
invoice.id
? navigate(`/dashboard/invoices/${invoice.id}`)
: toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" })
}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
{invoice.number}
</UnstyledButton>
{isPaid && <Badge color="green" variant="light" size="sm">Paid</Badge>}
</Group>
);
},
},
@@ -881,16 +847,20 @@ const FirstMilePage = () => {
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
disabled={Boolean(row.original.invoice)}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
<Menu.Item
leftSection={<Receipt size={15} />}
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
onClick={() => openInvoice(row.original)}
disabled={
!(row.original.exactKm != null && row.original.exactKm > 0) ||
Boolean(row.original.invoice)
}
onClick={() => generateInvoiceMutation.mutate(row.original.id)}
>
Generate Invoice
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
</Menu.Item>
{canPrint && (
<Menu.Item
@@ -906,6 +876,7 @@ const FirstMilePage = () => {
<Menu.Item
leftSection={<Trash size={15} />}
color="red"
disabled={Boolean(row.original.invoice)}
onClick={() => {
if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) {
deleteMutation.mutate(row.original.id);
@@ -1295,137 +1266,6 @@ const FirstMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Invoice modal */}
<Modal
opened={invoiceOpen}
onClose={closeInvoice}
title={<Text fw={600}>Invoice #345</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{invoiceRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700}>EDR Freight</Text>
<Text fw={600} size="sm">Invoice #345</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
<InfoRow label="Pickup" value={pickupLocation(invoiceRecord)} />
<InfoRow label="Destination" value={destinationYardName(invoiceRecord)} />
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
</SimpleGrid>
<Divider />
<Group justify="flex-end">
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Post Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
</Group>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Advanced Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
</Group>
<Divider my="xs" />
{(() => {
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
const difference = postPayment - advancedPayment;
if (difference > 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Remaining to Pay</Text>
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
</Group>
);
} else if (difference < 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Refund</Text>
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
</Group>
);
} else {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Status</Text>
<Text fw={700} c="blue">Settled</Text>
</Group>
);
}
})()}
</Stack>
</Group>
</Stack>
</Card>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeInvoice}>Close</Button>
</Group>
</Stack>
</Modal>
{/* Container Allocation modal */}
<Modal
opened={containerAllocationOpen}
onClose={closeContainerAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
{/* Capacity guidance */}
{activeRecord.booking?.cargoType?.label === "BULK" ? (
<Alert color="blue" title="Bulk Cargo Allocation">
<Text size="sm">
Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows.
</Text>
<Text size="xs" c="dimmed" mt="xs">
Capacity: TBD TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
</Alert>
) : (
<Alert color="blue">
<Text size="sm">
One vehicle per container. Each container will be assigned to a single vehicle.
</Text>
</Alert>
)}
<Divider />
{/* Container table */}
<FirstMileContainerAllocationTable
firstMileId={activeRecord.id}
containers={[
// TODO: Get containers from booking/first-mile data
// For now placeholder with TODO comment
]}
onSave={async (allocations) => {
await allocateMutation.mutateAsync(allocations);
}}
/>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeContainerAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};