mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
Merge pull request #435 from Tria-plc/freight/feature/first_mile_invoice
Freight/feature/first mile invoice
This commit is contained in:
@@ -1,164 +0,0 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
|
||||
export interface ContainerAllocationRow {
|
||||
id: string;
|
||||
type: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface FirstMileContainerAllocationTableProps {
|
||||
firstMileId: string;
|
||||
containers: ContainerAllocationRow[];
|
||||
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual container-to-vehicle allocation table for first-mile pickups.
|
||||
* Displays containers with type/qty, vehicle dropdown per row, and save action.
|
||||
*/
|
||||
export function FirstMileContainerAllocationTable({
|
||||
firstMileId,
|
||||
containers,
|
||||
onSave,
|
||||
}: FirstMileContainerAllocationTableProps) {
|
||||
const [allocations, setAllocations] = useState<Record<string, string | null>>(
|
||||
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
|
||||
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
|
||||
queryKey: ["vehicles", "free"],
|
||||
queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }),
|
||||
});
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
vehicles.map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.plateNumber} (${v.vehicleType})`,
|
||||
description: `${v.model} · ${v.manufacturer}`,
|
||||
})),
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
const saveAllocation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const mappings = containers
|
||||
.filter((c) => allocations[c.id])
|
||||
.map((c) => ({
|
||||
containerId: c.id,
|
||||
vehicleId: allocations[c.id]!,
|
||||
}));
|
||||
|
||||
if (mappings.length === 0) {
|
||||
throw new Error("No containers allocated to vehicles");
|
||||
}
|
||||
|
||||
await onSave(mappings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Container allocations saved");
|
||||
setAllocations(
|
||||
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save allocations",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const allocatedCount = Object.values(allocations).filter(Boolean).length;
|
||||
const allAllocated = allocatedCount === containers.length;
|
||||
|
||||
if (vehiclesLoading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{vehicles.length === 0 && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||
No free vehicles available. Free up or add vehicles before allocating containers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container ID</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Assigned Vehicle</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.map((container) => (
|
||||
<Table.Tr key={container.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{container.id}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{container.type}</Table.Td>
|
||||
<Table.Td>{container.qty}</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Select vehicle"
|
||||
data={vehicleOptions}
|
||||
value={allocations[container.id] ?? null}
|
||||
onChange={(value) =>
|
||||
setAllocations((prev) => ({
|
||||
...prev,
|
||||
[container.id]: value,
|
||||
}))
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
disabled={vehicles.length === 0}
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{allocatedCount} of {containers.length} containers allocated
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={saveAllocation.isPending}
|
||||
disabled={allocatedCount === 0 || vehicles.length === 0}
|
||||
onClick={() => saveAllocation.mutate()}
|
||||
>
|
||||
Save Allocations
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
|
||||
export interface LastMileContainerRow {
|
||||
id: string;
|
||||
type: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
/** One vehicle (with trailer) carries at most this many containers. */
|
||||
const CONTAINERS_PER_VEHICLE = 2;
|
||||
|
||||
export interface LastMileContainerAllocationTableProps {
|
||||
containers: LastMileContainerRow[];
|
||||
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual container-to-vehicle allocation table for last-mile deliveries.
|
||||
* Displays containers with type/qty, vehicle dropdown per row, and save action.
|
||||
*/
|
||||
export function LastMileContainerAllocationTable({
|
||||
containers,
|
||||
onSave,
|
||||
}: LastMileContainerAllocationTableProps) {
|
||||
const [allocations, setAllocations] = useState<Record<string, string | null>>(
|
||||
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
|
||||
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
|
||||
queryKey: ["vehicles", "free"],
|
||||
queryFn: () =>
|
||||
vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
vehicles.map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.plateNumber} (${v.vehicleType})`,
|
||||
description: `${v.model} · ${v.manufacturer}`,
|
||||
})),
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
const saveAllocation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const mappings = containers
|
||||
.filter((c) => allocations[c.id])
|
||||
.map((c) => ({
|
||||
containerId: c.id,
|
||||
vehicleId: allocations[c.id]!,
|
||||
}));
|
||||
|
||||
if (mappings.length === 0) {
|
||||
throw new Error("No containers allocated to vehicles");
|
||||
}
|
||||
|
||||
await onSave(mappings);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Container allocations saved");
|
||||
setAllocations(
|
||||
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save allocations",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const allocatedCount = Object.values(allocations).filter(Boolean).length;
|
||||
|
||||
// Containers already loaded onto each vehicle, to enforce the 2-per-vehicle cap.
|
||||
const loadByVehicle = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const c of containers) {
|
||||
const v = allocations[c.id];
|
||||
if (v) map[v] = (map[v] ?? 0) + (c.qty || 1);
|
||||
}
|
||||
return map;
|
||||
}, [allocations, containers]);
|
||||
|
||||
/** Options for a given row: a vehicle is disabled if assigning this container
|
||||
* to it would exceed its 2-container capacity. */
|
||||
const optionsForRow = (row: LastMileContainerRow) =>
|
||||
vehicleOptions.map((o) => {
|
||||
const already = loadByVehicle[o.value] ?? 0;
|
||||
const selfHere = allocations[row.id] === o.value ? row.qty || 1 : 0;
|
||||
const over = already - selfHere + (row.qty || 1) > CONTAINERS_PER_VEHICLE;
|
||||
return { ...o, disabled: over };
|
||||
});
|
||||
|
||||
if (vehiclesLoading) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{vehicles.length === 0 && (
|
||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
||||
No free vehicles available. Free up or add vehicles before allocating containers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container ID</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Assigned Vehicle</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{containers.map((container) => (
|
||||
<Table.Tr key={container.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{container.id}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{container.type}</Table.Td>
|
||||
<Table.Td>{container.qty}</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Select vehicle"
|
||||
data={optionsForRow(container)}
|
||||
value={allocations[container.id] ?? null}
|
||||
onChange={(value) =>
|
||||
setAllocations((prev) => ({
|
||||
...prev,
|
||||
[container.id]: value,
|
||||
}))
|
||||
}
|
||||
searchable
|
||||
clearable
|
||||
disabled={vehicles.length === 0}
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
{allocatedCount} of {containers.length} containers allocated · max{" "}
|
||||
{CONTAINERS_PER_VEHICLE} per vehicle
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={saveAllocation.isPending}
|
||||
disabled={allocatedCount === 0 || vehicles.length === 0}
|
||||
onClick={() => saveAllocation.mutate()}
|
||||
>
|
||||
Save Allocations
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,8 @@ export interface FirstMileRecord {
|
||||
vehicleId?: string | null;
|
||||
booking?: FirstMileBooking | null;
|
||||
vehicle?: FirstMileVehicle | null;
|
||||
/** Present only when an invoice has actually been generated (not on distance). */
|
||||
invoice?: { id: string; number: string; status: string } | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -66,4 +68,6 @@ export const firstMileService = {
|
||||
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
||||
remove: (id: string) =>
|
||||
api.delete<void>(FM.BY_ID(id)),
|
||||
generateInvoice: (id: string) =>
|
||||
api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`),
|
||||
};
|
||||
|
||||
@@ -57,7 +57,15 @@ export interface LastMileRecord {
|
||||
booking?: LastMileBooking | null;
|
||||
vehicle?: LastMileVehicle | null;
|
||||
/** Full set of vehicles serving this delivery (multi-truck). */
|
||||
vehicleAssignments?: Array<{ id: string; vehicleId: string; vehicle?: LastMileVehicle | null }>;
|
||||
vehicleAssignments?: Array<{
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
distanceKm?: number | null;
|
||||
vehicle?: LastMileVehicle | null;
|
||||
}>;
|
||||
/** Present only when an invoice has actually been generated (not on distance). */
|
||||
invoice?: { id: string; number: string; status: string } | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -79,6 +87,15 @@ export const lastMileService = {
|
||||
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
||||
remove: (id: string) =>
|
||||
api.delete<void>(LM.BY_ID(id)),
|
||||
setVehicles: (id: string, vehicleIds: string[]) =>
|
||||
api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicleIds }),
|
||||
setVehicles: (
|
||||
id: string,
|
||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
||||
setDistances: (
|
||||
id: string,
|
||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||
remainingPayment?: number,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
||||
generateInvoice: (id: string) =>
|
||||
api.post<{ id: string; invoiceNumber?: string } | null>(`${LM.BASE}/${id}/invoice`),
|
||||
};
|
||||
|
||||
@@ -201,7 +201,7 @@ export interface BookingDetail {
|
||||
company?: BookingNamedRef & Partial<BookingCompany>;
|
||||
originYard?: BookingNamedRef;
|
||||
destinationYard?: BookingNamedRef;
|
||||
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean };
|
||||
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
|
||||
cargoType?: BookingNamedRef;
|
||||
shippingLine?: BookingNamedRef;
|
||||
bookingContainers?: BookingContainerLine[];
|
||||
|
||||
Reference in New Issue
Block a user