mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge pull request #328 from Tria-plc/freight/feature/vehicle_2
Freight/feature/vehicle 2
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Printer,
|
Printer,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Ruler,
|
||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -21,12 +22,14 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
|
NumberInput,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type { ArrivalQueueItem } from "@/types/warehouse";
|
import type { ArrivalQueueItem } from "@/types/warehouse";
|
||||||
import { warehouseService } from "@/services/warehouse.service";
|
import { warehouseService } from "@/services/warehouse.service";
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
lastMileService,
|
lastMileService,
|
||||||
} from "@/services/last-mile.service";
|
} from "@/services/last-mile.service";
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
|
import { ratesService } from "@/services/rates.service";
|
||||||
|
|
||||||
const formatPrice = (amount: number) =>
|
const formatPrice = (amount: number) =>
|
||||||
`ETB ${amount.toLocaleString("en-US", {
|
`ETB ${amount.toLocaleString("en-US", {
|
||||||
@@ -311,6 +315,11 @@ const LastMilePage = () => {
|
|||||||
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
||||||
const [arrivalSearch, setArrivalSearch] = useState("");
|
const [arrivalSearch, setArrivalSearch] = useState("");
|
||||||
|
|
||||||
|
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||||
|
const [distanceValue, setDistanceValue] = useState("");
|
||||||
|
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||||
|
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery({
|
const { data: listData, isLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -327,6 +336,14 @@ 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 records = listData?.data ?? [];
|
||||||
|
|
||||||
const vehicleOptions = useMemo(
|
const vehicleOptions = useMemo(
|
||||||
@@ -352,6 +369,21 @@ const LastMilePage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateDistanceMutation = useMutation({
|
||||||
|
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
|
||||||
|
lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() });
|
||||||
|
if (activeRecord) {
|
||||||
|
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
||||||
|
}
|
||||||
|
closeDistance();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: "Update failed", variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
|
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
|
||||||
queryKey: ["warehouse-inventory", "arrival-queue"],
|
queryKey: ["warehouse-inventory", "arrival-queue"],
|
||||||
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
||||||
@@ -422,6 +454,49 @@ const LastMilePage = () => {
|
|||||||
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue });
|
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDistance = (id: string) => {
|
||||||
|
setActiveId(id);
|
||||||
|
setDistanceValue("");
|
||||||
|
setDistanceOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDistance = () => {
|
||||||
|
setDistanceOpen(false);
|
||||||
|
setActiveId(null);
|
||||||
|
setDistanceValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInvoice = (record: LastMileRecord) => {
|
||||||
|
setInvoiceRecord(record);
|
||||||
|
setInvoiceOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeInvoice = () => {
|
||||||
|
setInvoiceOpen(false);
|
||||||
|
setInvoiceRecord(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDistance = () => {
|
||||||
|
const distance = parseFloat(distanceValue);
|
||||||
|
if (!activeId || isNaN(distance) || distance < 0) {
|
||||||
|
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const rateValue = parseFloat(lastMileRate.rateValue);
|
||||||
|
remainingPayment = distance * rateValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
|
||||||
|
};
|
||||||
|
|
||||||
const activeRecord = useMemo(
|
const activeRecord = useMemo(
|
||||||
() => records.find((r) => r.id === activeId) ?? null,
|
() => records.find((r) => r.id === activeId) ?? null,
|
||||||
[records, activeId],
|
[records, activeId],
|
||||||
@@ -639,6 +714,27 @@ const LastMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "invoice",
|
||||||
|
header: "Invoice",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
|
||||||
|
if (!hasDistance) {
|
||||||
|
return <Text c="dimmed">—</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => openInvoice(row.original)}
|
||||||
|
c="blue"
|
||||||
|
fw={500}
|
||||||
|
style={{ textDecoration: "underline", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
#345
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
@@ -704,6 +800,12 @@ const LastMilePage = () => {
|
|||||||
>
|
>
|
||||||
View detail
|
View detail
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Ruler size={15} />}
|
||||||
|
onClick={() => openDistance(row.original.id)}
|
||||||
|
>
|
||||||
|
Add distance
|
||||||
|
</Menu.Item>
|
||||||
{canPrint && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Printer size={15} />}
|
leftSection={<Printer size={15} />}
|
||||||
@@ -1000,6 +1102,134 @@ const LastMilePage = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Add Actual Distance modal */}
|
||||||
|
<Modal
|
||||||
|
opened={distanceOpen}
|
||||||
|
onClose={closeDistance}
|
||||||
|
title={<Text fw={600}>Add Actual Distance</Text>}
|
||||||
|
size="md"
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{activeRecord && (
|
||||||
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">Customer</Text>
|
||||||
|
<Text size="sm">{customerName(activeRecord)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
|
||||||
|
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<NumberInput
|
||||||
|
label="Actual Distance (KM)"
|
||||||
|
placeholder="Enter distance"
|
||||||
|
value={distanceValue}
|
||||||
|
onChange={(v) => setDistanceValue(String(v ?? ""))}
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
decimalScale={2}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={closeDistance}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveDistance}
|
||||||
|
loading={updateDistanceMutation.isPending}
|
||||||
|
disabled={!distanceValue}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</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={originYardName(invoiceRecord)} />
|
||||||
|
<InfoRow label="Destination" value={deliveryLocation(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>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user