This commit is contained in:
marshal
2026-07-02 13:02:33 +03:00
256 changed files with 15730 additions and 5422 deletions

View File

@@ -6,6 +6,7 @@ import {
FileText,
LayoutDashboard,
LayoutGrid,
MapPin,
Network,
Package,
PackageCheck,
@@ -70,6 +71,12 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import RoutesPage from "./pages/fleet/RoutesPage";
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
import { MaintenancePage } from "./pages/fleet/MaintenancePage";
import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage";
import { FleetDashboard } from "./pages/fleet/FleetDashboard";
import { TrackingPage } from "./pages/fleet/TrackingPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
@@ -193,6 +200,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Fleet Management",
items: [
{
label: "Fleet Dashboard",
href: "/dashboard/fleet-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Routes",
href: "/dashboard/routes",
@@ -229,6 +242,36 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Users />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Track Vehicles",
href: "/dashboard/tracking",
icon: <MapPin />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Fuel Purchases",
href: "/dashboard/fuel-purchases",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Fuel Analytics",
href: "/dashboard/fuel-stats",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Maintenance",
href: "/dashboard/maintenance",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Financial Reports",
href: "/dashboard/financial-reports",
icon: <Wallet />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
@@ -810,6 +853,54 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="fuel-purchases"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FuelPurchasePage />
</RequirePermission>
}
/>
<Route
path="fuel-stats"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FuelStatsPage />
</RequirePermission>
}
/>
<Route
path="maintenance"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<MaintenancePage />
</RequirePermission>
}
/>
<Route
path="financial-reports"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FinancialReportsPage />
</RequirePermission>
}
/>
<Route
path="fleet-dashboard"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetDashboard />
</RequirePermission>
}
/>
<Route
path="tracking"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrackingPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={

View File

@@ -0,0 +1,164 @@
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 ContainerAllocationTableProps {
bookingId: string;
containers: ContainerAllocationRow[];
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
}
/**
* Manual container-to-vehicle allocation table for freight bookings.
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function ContainerAllocationTable({
bookingId,
containers,
onSave,
}: ContainerAllocationTableProps) {
const [allocations, setAllocations] = useState<Record<string, string | null>>(
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
);
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
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 active vehicles available. 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>
);
}

View File

@@ -0,0 +1,164 @@
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", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
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 active vehicles available. 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>
);
}

View File

@@ -0,0 +1,164 @@
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;
}
export interface LastMileContainerAllocationTableProps {
lastMileId: string;
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({
lastMileId,
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", "active"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }),
});
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 active vehicles available. 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>
);
}

View File

@@ -23,11 +23,20 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
);
}
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
const bookingReference = item?.booking?.reference ?? '-';
const handoverReference = item?.handoverDocumentReference ?? noteLineValue(item?.notes, 'Handover Reference');
const handoverDate = item?.handoverDocumentDate ?? noteLineValue(item?.notes, 'Generated At');
const inventorySummary = [
item?.status?.replace(/_/g, ' '),
item?.grnNumber ? `GRN ${item.grnNumber}` : null,
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
handoverReference ? `Handover ${handoverReference}` : null,
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
]
.filter(Boolean)
@@ -61,11 +70,13 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={bookingReference} />
<DetailRow label="GRN" value={item.grnNumber ?? '-'} />
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
<DetailRow label="Handover reference" value={handoverReference || '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
@@ -83,6 +94,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
<DetailRow label="Handover generated" value={formatDate(handoverDate)} />
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
</SimpleGrid>

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
import {
ActionIcon,
Alert,
@@ -79,6 +79,45 @@ interface ReceiveInventoryModalProps {
onReceived?: () => void;
}
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(inventoryId);
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!grnNumber}
loading={loading}
onClick={openDocument}
>
{grnNumber ?? 'No GRN'}
</Button>
);
}
interface Location {
warehouseId: string;
yardId: string;
@@ -620,11 +659,13 @@ function EligibleTab({
const { toast } = useToast();
const qc = useQueryClient();
const { data: allRows = [], isLoading } = useQuery(
api.warehouses.eligibleBookings.queryOptions({ enabled }),
api.warehouses.eligibleBookings.queryOptions({
input: { direction },
enabled,
}),
);
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
const requestFirstMile = useMutation({
mutationFn: (reference: string) => firstMileService.accept(reference),
onSuccess: () => {
@@ -782,10 +823,24 @@ function EligibleTab({
return;
}
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
const totalContainerQuantity = selectedRows.reduce(
(sum, row) => sum + Number(row.containerQuantity ?? 0),
0,
);
const normalizedForm =
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
? {
...form,
unitCount: totalContainerQuantity,
}
: form;
setPendingReceiveIds(filteredIds);
setReceivedAt(new Date().toISOString());
setTruckForm(form);
setLockedTruckFields(lockedFields);
setTruckForm(normalizedForm);
setLockedTruckFields({
...lockedFields,
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
});
setPackagingFreightType(nextPackagingFreightType);
setTruckOpen(true);
};
@@ -798,18 +853,6 @@ function EligibleTab({
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
};
const loadPassedExport = async () => {
try {
const r = await loadPassed.mutateAsync(undefined);
toast({
title: `${r.loadedCount} loaded`,
description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined,
});
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
}
};
return (
<Stack gap="sm" mt="sm">
@@ -828,18 +871,6 @@ function EligibleTab({
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
</Text>
<Group gap="xs">
{direction === 'EXPORT' && (
<Button
size="compact-sm"
variant="light"
color="teal"
leftSection={<Truck size={14} />}
loading={loadPassed.isPending}
onClick={loadPassedExport}
>
Load Passed Export Items
</Button>
)}
<Button
size="compact-sm"
color={direction === 'EXPORT' ? 'edr-green' : undefined}
@@ -849,7 +880,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
>
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
{direction === 'EXPORT' ? 'Receive All for Loading' : 'Receive All to Warehouse'}
</Button>
<Button
size="compact-sm"
@@ -858,7 +889,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([...selected])}
>
Receive Selected
{direction === 'EXPORT' ? 'Receive Selected for Loading' : 'Receive Selected'}
</Button>
</Group>
</Group>
@@ -1000,7 +1031,7 @@ function EligibleTab({
loading={bulkReceive.isPending}
onClick={() => openTruckReceive([r.id])}
>
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
{canReceive ? (direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse') : 'Await First Mile'}
</Button>
)}
</Table.Td>
@@ -1015,7 +1046,7 @@ function EligibleTab({
<Modal
opened={truckOpen}
onClose={() => setTruckOpen(false)}
title="Receive to Warehouse"
title={direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse'}
centered
size="lg"
>
@@ -1175,6 +1206,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/>
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1201,7 +1233,13 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1322,6 +1360,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
/>
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1344,7 +1383,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1492,6 +1537,7 @@ function LoadedExportTab({
</Table.Th>
)}
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
@@ -1516,7 +1562,13 @@ function LoadedExportTab({
</Table.Td>
)}
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1866,12 +1918,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
bookingId: row.bookingId,
quantity: 1,
weight: Number(row.weight) || 0,
grnNumber: row.grnNumber,
status: row.currentStatus,
arrivedAt: row.arrivalTime,
unloadedAt: row.arrivalTime,
inspectionStatus: row.inspectionStatus,
releaseDate: row.releaseDate,
releaseOrderReference: row.releaseOrderReference,
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
booking: row.bookingId
? {
@@ -1902,6 +1957,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
try {
const response = await warehouseService.downloadHandoverDocument(row.id);
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
@@ -1910,6 +1966,20 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
}
};
const openReleaseDocument = async (row: ImportUnloadedItem) => {
setBusyId(row.id);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadReleaseDocument(row.id);
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between">
@@ -1959,6 +2029,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Arrival Time</Table.Th>
@@ -1987,7 +2058,13 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
@@ -2049,7 +2126,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
color="yellow"
onClick={() => setReleaseItem(toInventoryItem(r))}
>
Truck Arrival
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button>
</>
)}
@@ -2064,6 +2141,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Dispatch
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={14} />}
loading={busyId === r.id}
onClick={() => openReleaseDocument(r)}
>
Exit Paper
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
@@ -2082,7 +2171,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
leftSection={<FileText size={14} />}
onClick={() => openHandoverDocument(r)}
>
Handover
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
</Button>
)}
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
@@ -2398,7 +2487,12 @@ function ExportWarehouseTabs({
onChanged?: () => void;
}) {
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled }));
const { data: eligibleRows = [] } = useQuery(
api.warehouses.eligibleBookings.queryOptions({
input: { direction: 'EXPORT' },
enabled,
}),
);
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
@@ -46,6 +46,77 @@ const toIsoDateTime = (value: string) => {
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
};
const toLocalDateTimeInput = (value?: string | null) => {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const offsetMs = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
};
const generateReleaseReference = (item: WarehouseInventoryItem | null) => {
const bookingReference = item?.booking?.reference;
if (bookingReference) return `REL-${bookingReference.replace(/^BK-?/i, '')}`;
if (item?.bookingId) return `REL-${item.bookingId.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
return '';
};
const lineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
if (!value) return '';
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : '';
};
const splitContainerNumbers = (value: string | null | undefined) =>
(value ?? '')
.split(/[,;\n]+/)
.map((number) => number.trim())
.filter(Boolean);
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType;
return Boolean(item?.containerId || containerCount > 0 || freightType === 'CONTAINER');
};
const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedContainerNumber: string) => {
const savedNumbers = splitContainerNumbers(savedContainerNumber);
const itemNumbers = splitContainerNumbers(getItemContainerNumber(item));
const sourceNumbers = savedNumbers.length ? savedNumbers : itemNumbers;
const quantityCount = isContainerInventory(item, sourceNumbers.length) ? Number(item?.quantity ?? 0) : 0;
const expectedCount = Math.max(1, sourceNumbers.length, quantityCount);
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
};
const parseInspectionNote = (notes: string | null | undefined) => {
const marker = '[Exit Inspection]';
const index = notes?.lastIndexOf(marker) ?? -1;
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
return {
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
};
};
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
@@ -56,7 +127,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const [driverLicense, setDriverLicense] = useState('');
const [driverPhone, setDriverPhone] = useState('');
const [truckType, setTruckType] = useState('');
const [containerNumber, setContainerNumber] = useState('');
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>('');
const [grossWeight, setGrossWeight] = useState<number | ''>('');
@@ -66,26 +137,32 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
useEffect(() => {
if (opened) {
setReference(item?.releaseOrderReference ?? '');
setTruckPlateNumber('');
setTrailerPlateNumber('');
setDriverName('');
setDriverLicense('');
setDriverPhone('');
setTruckType('');
setContainerNumber('');
setGateInTime('');
setTareWeight('');
setGrossWeight('');
setNetWeight(item?.weight != null ? Number(item.weight) : '');
setGateOutTime('');
const inspection = parseInspectionNote(item?.notes);
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber);
setTrailerPlateNumber(inspection.trailerPlateNumber);
setDriverName(inspection.driverName);
setDriverLicense(inspection.driverLicense);
setDriverPhone(inspection.driverPhone);
setTruckType(inspection.truckType);
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
}
}, [opened, item]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isEntranceLocked = isExitStep;
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
const handleSubmit = async () => {
if (!item) return;
@@ -93,11 +170,19 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (tareWeight === '' || grossWeight === '') {
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
if (!gateInTime || tareWeight === '') {
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
return;
}
if (weightMismatch) {
if (isExitStep && (!gateOutTime || grossWeight === '')) {
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
return;
}
if (isExitStep && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
}
if (isExitStep && weightMismatch) {
toast({
variant: 'destructive',
title: 'Weight mismatch',
@@ -105,7 +190,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
});
return;
}
const pdfWindow = window.open('', '_blank');
const pdfWindow = isExitStep ? window.open('', '_blank') : null;
try {
const released = await releaseMutation.mutateAsync({
id: item.id,
@@ -119,14 +204,22 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
driverLicense: driverLicense.trim() || undefined,
driverPhone: driverPhone.trim() || undefined,
truckType: truckType.trim() || undefined,
containerNumber: containerNumber.trim() || undefined,
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight),
grossWeight: Number(grossWeight),
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
gateOutTime: toIsoDateTime(gateOutTime),
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
if (!isExitStep) {
toast({
title: 'Truck arrival saved',
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
});
onClose();
return;
}
setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data;
@@ -148,20 +241,27 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
};
return (
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
<Modal opened={opened} onClose={onClose} title={title} centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Register the customer truck and driver at arrival, record tare weight, then record gross
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
equal gross weight minus tare weight.
</Text>
{isExitStep ? (
<Text size="sm">
Record the truck leaving time and gross weight. The system recorded net weight is locked,
and the exit paper is generated only when it equals gross weight minus tare weight.
</Text>
) : (
<Text size="sm">
Register the customer truck and driver at arrival, then save gate in time and tare weight.
Reopen this form when the truck is leaving to complete the exit weighing.
</Text>
)}
</Alert>
<TextInput
label="Release document reference"
placeholder="e.g. REL-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
<Select
label="Registered first / last-mile truck"
@@ -169,6 +269,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
searchable
clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
disabled={isEntranceLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
@@ -182,35 +283,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
<TextInput
label="Trailer plate number"
value={trailerPlateNumber}
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
<Stack gap={6}>
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isEntranceLocked}
/>
))}
</SimpleGrid>
</Stack>
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
</Group>
<Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
</Text>
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
</Group>
{weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light">
@@ -225,7 +344,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Save Truck Arrival & View Exit Paper
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
</Button>
</Group>
</Stack>

View File

@@ -1,13 +1,17 @@
import { useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import {
getNextInventoryAction,
type InventoryAction,
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from './options';
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[];
@@ -46,6 +50,56 @@ const actionColor: Record<InventoryAction, string> = {
deliver: 'green',
};
const releaseActionLabel = (item: WarehouseInventoryItem) =>
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!item.grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(item.id);
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!item.grnNumber}
loading={loading}
onClick={openDocument}
>
{item.grnNumber ?? 'No GRN'}
</Button>
);
}
export function WarehouseInventoryTable({
items,
busyId,
@@ -90,6 +144,7 @@ export function WarehouseInventoryTable({
</Table.Th>
)}
<Table.Th>Booking</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th>
@@ -111,6 +166,7 @@ export function WarehouseInventoryTable({
item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) &&
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
const handoverReference = handoverDocumentReference(item);
return (
<Table.Tr key={item.id}>
@@ -136,6 +192,9 @@ export function WarehouseInventoryTable({
</Text>
)}
</Table.Td>
<Table.Td>
<GrnDocumentButton item={item} />
</Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
<Table.Td>{item.yard?.code ?? '-'}</Table.Td>
@@ -170,7 +229,7 @@ export function WarehouseInventoryTable({
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
@@ -224,7 +283,10 @@ export function WarehouseInventoryTable({
</Tooltip>
)}
{onHandoverDocument && canGenerateHandover && (
<Tooltip label="Generate customer handover document" withArrow>
<Tooltip
label={handoverReference ? `View handover document ${handoverReference}` : 'Generate customer handover document'}
withArrow
>
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
<FileText size={16} />
</ActionIcon>

View File

@@ -31,9 +31,6 @@ export interface WarehouseHandoverPdfContext {
const escapePdfText = (value: string) =>
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const money = (amount: unknown, currency = 'USD') =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const fmtDate = (value: unknown) => {
if (!value) return '-';
const date = new Date(value as string | Date);
@@ -96,12 +93,6 @@ const textOp = (
color = '0 0 0',
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
lineOp(60, 242, 535, 242),
textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN),
buildCircularSeal(452, 155, label),
];
const buildWarehouseOfficerSealBand = () => [
lineOp(60, 218, 535, 218),
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
@@ -146,57 +137,6 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
return new Blob([pdf], { type: 'application/pdf' });
}
export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') {
const paid = kind === 'RECEIPT' || invoice.status === 'PAID';
const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
const bookingReference = firstText(invoice.bookingReference);
const customerName = firstText(invoice.customerName);
const inventoryReference = firstText(invoice.inventoryReference);
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
const clearanceStatus = firstText(
invoice.clearanceStatus,
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
);
const lines: PdfLine[] = [
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' },
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
{ text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
...(invoice.items ?? []).flatMap((item) => [
{ text: item.description, bold: true, align: 'center' as const },
{
text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`,
yGap: 13,
align: 'center' as const,
},
]),
{ text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' },
{ text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' },
{ text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' },
{ text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' },
{ text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' },
{ text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' },
];
const authorizationOps = [
...buildAuthorizationBand('PAID'),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
const invoiceOps = [
lineOp(60, 242, 535, 242),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps);
}
const firstText = (...values: Array<unknown>) => {
for (const value of values) {
if (value !== null && value !== undefined && String(value).trim()) return String(value);

View File

@@ -100,6 +100,12 @@ export const QUERY_KEYS = {
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
},
VEHICLES: {
ROOT: ["vehicles"] as const,
list: (filter?: Record<string, unknown>) => ["vehicles", "list", filter ?? {}] as const,
byId: (id: string) => ["vehicles", "detail", id] as const,
},
FIRST_MILE: {
ROOT: ["first-mile"] as const,
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
@@ -137,4 +143,24 @@ export const QUERY_KEYS = {
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
},
FUEL: {
ROOT: ["fuel"] as const,
purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const,
},
MAINTENANCE: {
ROOT: ["maintenance"] as const,
schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const,
upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const,
history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const,
stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const,
},
FINANCIAL_REPORTS: {
ROOT: ["financial-reports"] as const,
fleet: (vehicleId?: string, months?: number) =>
["financial-reports", "fleet", vehicleId ?? "all", months ?? 12] as const,
},
} as const;

View File

@@ -423,6 +423,7 @@ export const URL_CONSTANTS = {
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk)

View File

@@ -1,6 +1,6 @@
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'http://localhost:3001';
/**
* URL that streams an uploaded file through the API by its UUID. Routes the

View File

@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
/**
* Scroll to the element whose `id` matches the URL hash. Retries for a short
* window so it still lands on sections that mount after an async fetch (there is
* no router-level hash handling). Deep-link targets give a card an `id`.
*/
export function useScrollToHash(): void {
const { hash } = useLocation();
useEffect(() => {
if (!hash) return;
const id = decodeURIComponent(hash.slice(1));
let tries = 0;
let timer: ReturnType<typeof setTimeout>;
const tick = () => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (tries++ < 20) timer = setTimeout(tick, 100);
};
timer = setTimeout(tick, 100);
return () => clearTimeout(timer);
}, [hash]);
}

View File

@@ -1,5 +1,7 @@
import { Container, Grid, Stack } from "@mantine/core";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
BookingApprovalCard,
@@ -16,10 +18,26 @@ import {
type BookingDetailView,
} from "@/components/bookings/detail";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ContainerAllocationTable from "@/components/ContainerAllocationTable";
import { api } from "@/services/api";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
const BookingDetailPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const allocateMutation = useMutation({
mutationFn: (data: any) =>
api.post(`/bookings/${id}/allocate-containers`, data),
onSuccess: () => {
toast.success("Containers allocated");
qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") });
},
onError: () => {
toast.error("Failed to allocate containers");
},
});
// Mock data - replace with actual API call
const booking: BookingDetailView = {
@@ -134,6 +152,17 @@ const BookingDetailPage = () => {
<BookingContainersCard
containers={booking.bookingContainers ?? []}
/>
<ContainerAllocationTable
bookingId={booking.id}
containers={(booking.bookingContainers ?? []).map((c) => ({
id: c.id,
type: c.containerType?.label ?? "Unknown",
qty: c.quantity,
}))}
onSave={(allocations) =>
allocateMutation.mutateAsync({ allocations })
}
/>
<BookingApprovalCard
steps={approvalSteps}
approvedCount={approvedCount}

View File

@@ -50,6 +50,7 @@ import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import toast from "react-hot-toast";
// Signature / generated-contract files are surfaced on the contract page, not
@@ -65,6 +66,8 @@ export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
const {
data: booking,
isLoading,
@@ -281,10 +284,12 @@ export default function BookingRequestDetailPage() {
<Stack gap="lg">
<BookingCompanyCard booking={booking} />
<BookingPricingSummary booking={booking} />
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
/>
<Box id="warehouse-payments">
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
/>
</Box>
<BookingActionsToolbar
booking={booking}
mutations={mutations}

View File

@@ -0,0 +1,251 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress, Container } from '@mantine/core';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/services/api';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
interface FuelStats {
vehicleId: string;
totalPurchases: number;
totalFuel: number;
totalCost: number;
averageCostPerLiter: number;
}
interface MaintenanceStats {
vehicleId: string;
totalCost: number;
numberOfMaintenanceItems: number;
averageCostPerMaintenance: number;
costByType: Record<string, number>;
}
interface CombinedReport {
vehicleId: string;
fuelCost: number;
maintenanceCost: number;
totalOperatingCost: number;
fuelPercentage: number;
maintenancePercentage: number;
}
export function FinancialReportsPage() {
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [months, setMonths] = useState('12');
const { data: vehicles } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
const { data: fuelStats } = useQuery({
queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null),
enabled: !!selectedVehicle,
});
const { data: maintenanceStats } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null),
enabled: !!selectedVehicle,
});
const vehicleOptions = useMemo(
() => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [],
[vehicles]
);
const report = useMemo(() => {
if (!fuelStats || !maintenanceStats) return null;
const fuelCost = Number(fuelStats.totalCost) || 0;
const maintenanceCost = Number(maintenanceStats.totalCost) || 0;
const total = fuelCost + maintenanceCost;
return {
vehicleId: selectedVehicle!,
fuelCost,
maintenanceCost,
totalOperatingCost: total,
fuelPercentage: total > 0 ? Math.round((fuelCost / total) * 100) : 0,
maintenancePercentage: total > 0 ? Math.round((maintenanceCost / total) * 100) : 0,
};
}, [fuelStats, maintenanceStats, selectedVehicle]);
const StatCard = ({ label, value }: { label: string; value: string }) => (
<Card withBorder>
<Card.Section p="md">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text fw={700} size="lg">
{value}
</Text>
</Card.Section>
</Card>
);
return (
<Container size="xl" py="xl" px="lg">
<Stack gap="md">
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Fleet Financial Analysis</Text>
</Card.Section>
<Card.Section p="md">
<Group>
<Select
label="Vehicle"
placeholder="Select a vehicle"
data={vehicleOptions}
value={selectedVehicle}
onChange={setSelectedVehicle}
style={{ flex: 1 }}
/>
<Select
label="Period"
data={[
{ label: 'Last 3 months', value: '3' },
{ label: 'Last 6 months', value: '6' },
{ label: 'Last 12 months', value: '12' },
]}
value={months}
onChange={v => setMonths(v || '12')}
style={{ flex: 1 }}
/>
</Group>
</Card.Section>
</Card>
{report && (
<>
<Grid>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Total Operating Cost" value={`$${report.totalOperatingCost.toFixed(2)}`} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Fuel Cost" value={`$${report.fuelCost.toFixed(2)}`} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Maintenance Cost" value={`$${report.maintenanceCost.toFixed(2)}`} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder>
<Card.Section p="md">
<Text size="sm" c="dimmed">
Monthly Avg
</Text>
<Text fw={700} size="lg">
${(report.totalOperatingCost / parseInt(months)).toFixed(2)}
</Text>
</Card.Section>
</Card>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Cost Breakdown</Text>
</Card.Section>
<Card.Section p="md">
<Stack gap="lg">
<Group justify="space-between">
<Stack gap={0}>
<Text size="sm" c="dimmed">
Fuel
</Text>
<Text fw={500}>{report.fuelPercentage}%</Text>
</Stack>
<RingProgress
sections={[{ value: report.fuelPercentage, color: 'edr-accent' }]}
label={
<Text size="xs" align="center">
{report.fuelPercentage}%
</Text>
}
size={100}
thickness={4}
/>
</Group>
<Group justify="space-between">
<Stack gap={0}>
<Text size="sm" c="dimmed">
Maintenance
</Text>
<Text fw={500}>{report.maintenancePercentage}%</Text>
</Stack>
<RingProgress
sections={[{ value: report.maintenancePercentage, color: 'edr-red' }]}
label={
<Text size="xs" align="center">
{report.maintenancePercentage}%
</Text>
}
size={100}
thickness={4}
/>
</Group>
</Stack>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Operational Insights</Text>
</Card.Section>
<Card.Section p="md">
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed">
Fuel Purchases
</Text>
<Text fw={500}>{fuelStats?.totalPurchases || 0} transactions</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Fuel Efficiency
</Text>
<Text fw={500}>
{fuelStats?.fuelEfficiency?.toFixed(2) || 'N/A'} km/L
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Maintenance Items
</Text>
<Text fw={500}>{maintenanceStats?.numberOfMaintenanceItems || 0} records</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Avg Maintenance Cost
</Text>
<Text fw={500}>${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'}</Text>
</div>
</Stack>
</Card.Section>
</Card>
</Grid.Col>
</Grid>
</>
)}
{!selectedVehicle && (
<Card>
<Card.Section p="md">
<Text c="dimmed">Select a vehicle to view financial reports</Text>
</Card.Section>
</Card>
)}
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,361 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs, Button } from '@mantine/core';
import { Truck, Fuel, Wrench, TrendingUp, AlertCircle, Users, User, MapPin, Calendar, BarChart3 } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/auth/http';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
interface Vehicle {
id: string;
registrationNumber: string;
plateNumber: string;
manufacturer: string;
model: string;
status?: string;
}
interface Driver {
id: string;
firstName: string;
lastName: string;
licenseNumber?: string;
email?: string;
phone?: string;
assignedVehicle?: string;
}
interface FleetMetrics {
totalVehicles: number;
activeVehicles: number;
maintenanceOverdue: number;
totalFuelSpend: number;
totalMaintenanceSpend: number;
averageFuelEfficiency: number;
costPerKm: number;
totalDrivers: number;
assignedDrivers: number;
}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: any) => (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}` }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
<Icon size={28} />
</ThemeIcon>
</Group>
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Group justify="space-between">
<Text fw={700} size="xl" c="edr-ink">
{value}
</Text>
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
</Group>
</Stack>
</Card>
);
export function FleetDashboard() {
const { data: vehicles = [] } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
const { data: drivers = [] } = useQuery({
queryKey: ['drivers'],
queryFn: async () => {
try {
const res = await api.get('/drivers');
return res.data || [];
} catch {
return [];
}
},
});
const { data: fuelStats } = useQuery({
queryKey: ['fleet-fuel-stats'],
queryFn: async () => {
try {
const res = await api.get('/fuel/stats');
return res.data || {};
} catch {
return {};
}
},
});
const { data: maintenanceStats } = useQuery({
queryKey: ['fleet-maintenance-stats'],
queryFn: async () => {
try {
const res = await api.get('/maintenance/stats');
return res.data || {};
} catch {
return {};
}
},
});
const metrics = useMemo((): FleetMetrics => {
const totalVehicles = (vehicles as Vehicle[]).length;
const activeVehicles = (vehicles as Vehicle[]).filter(v => v.status === 'ACTIVE').length;
const totalDrivers = (drivers as Driver[]).length;
const assignedDrivers = (drivers as Driver[]).filter(d => d.assignedVehicle).length;
const fuelTotal = fuelStats?.totalCost || 0;
const maintenanceTotal = maintenanceStats?.totalCost || 0;
return {
totalVehicles,
activeVehicles,
maintenanceOverdue: 0, // TODO: fetch from API
totalFuelSpend: fuelTotal,
totalMaintenanceSpend: maintenanceTotal,
averageFuelEfficiency: fuelStats?.averageEfficiency || 0,
costPerKm: (fuelTotal + maintenanceTotal) / 100000, // Placeholder
totalDrivers,
assignedDrivers,
};
}, [vehicles, drivers, fuelStats, maintenanceStats]);
const operatingCost = metrics.totalFuelSpend + metrics.totalMaintenanceSpend;
const fuelPercent = operatingCost > 0 ? Math.round((metrics.totalFuelSpend / operatingCost) * 100) : 0;
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Dashboard' }]} />
<Box mb="xl">
<Title order={1} mb="xs">
Fleet Management Dashboard
</Title>
<Text c="dimmed" size="sm">
Real-time fleet overview, vehicle & driver management
</Text>
</Box>
{/* Primary Metrics */}
<Grid mb="xl">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Fuel} label="Fuel Spend" value={`$${metrics.totalFuelSpend.toFixed(0)}`} color="edr-accent" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Wrench} label="Maintenance" value={`$${metrics.totalMaintenanceSpend.toFixed(0)}`} color="edr-red" />
</Grid.Col>
</Grid>
{/* Fleet Status */}
<Grid mb="lg">
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Fleet Status</Text>
</Card.Section>
<Card.Section p="md">
<Stack gap="lg">
<div>
<Group justify="space-between" mb="xs">
<Text size="sm">Active Vehicles</Text>
<Text fw={700}>{metrics.activeVehicles} / {metrics.totalVehicles}</Text>
</Group>
<Progress value={(metrics.activeVehicles / metrics.totalVehicles) * 100} color="edr-green" />
</div>
<div>
<Group justify="space-between" mb="xs">
<Text size="sm">Maintenance Overdue</Text>
<Badge color="edr-red">{metrics.maintenanceOverdue}</Badge>
</Group>
<Progress value={0} color="edr-red" />
</div>
<div>
<Group justify="space-between" mb="xs">
<Text size="sm">Idle / Under Maintenance</Text>
<Text fw={700}>{metrics.totalVehicles - metrics.activeVehicles}</Text>
</Group>
<Progress value={((metrics.totalVehicles - metrics.activeVehicles) / metrics.totalVehicles) * 100} color="edr-amber-soft" />
</div>
</Stack>
</Card.Section>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Operating Cost Breakdown</Text>
</Card.Section>
<Card.Section p="md">
<Stack gap="lg">
<Group justify="center">
<RingProgress
sections={[
{ value: fuelPercent, color: 'edr-accent' },
{ value: 100 - fuelPercent, color: 'edr-red' },
]}
label={
<div style={{ textAlign: 'center' }}>
<Text fw={700} size="sm">
${operatingCost.toFixed(0)}
</Text>
<Text size="xs" c="dimmed">
Total Cost
</Text>
</div>
}
size={120}
thickness={4}
/>
</Group>
<div>
<Group justify="space-between">
<Group gap="xs">
<ThemeIcon size="sm" color="edr-accent" variant="light">
<Fuel size={14} />
</ThemeIcon>
<Text size="sm">Fuel</Text>
</Group>
<Text fw={700}>{fuelPercent}%</Text>
</Group>
</div>
<div>
<Group justify="space-between">
<Group gap="xs">
<ThemeIcon size="sm" color="edr-red" variant="light">
<Wrench size={14} />
</ThemeIcon>
<Text size="sm">Maintenance</Text>
</Group>
<Text fw={700}>{100 - fuelPercent}%</Text>
</Group>
</div>
</Stack>
</Card.Section>
</Card>
</Grid.Col>
</Grid>
{/* Vehicles & Drivers Tabs */}
<Card withBorder>
<Tabs defaultValue="vehicles" p="md">
<Tabs.List>
<Tabs.Tab value="vehicles" leftSection={<Truck size={16} />}>
Vehicles ({(vehicles as Vehicle[]).length})
</Tabs.Tab>
<Tabs.Tab value="drivers" leftSection={<Users size={16} />}>
Drivers ({(drivers as Driver[]).length})
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="vehicles" pt="md">
{(vehicles as Vehicle[]).length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Registration</Table.Th>
<Table.Th>Plate</Table.Th>
<Table.Th>Model</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(vehicles as Vehicle[]).slice(0, 15).map(v => (
<Table.Tr key={v.id}>
<Table.Td fw={500}>{v.registrationNumber}</Table.Td>
<Table.Td>{v.plateNumber}</Table.Td>
<Table.Td>
{v.manufacturer} {v.model}
</Table.Td>
<Table.Td>
<Badge color={v.status === 'ACTIVE' ? 'edr-green' : v.status === 'MAINTENANCE' ? 'edr-amber-soft' : 'gray'}>
{v.status || 'UNKNOWN'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Group gap="xs" p="md">
<AlertCircle size={20} />
<Text c="dimmed">No vehicles in fleet</Text>
</Group>
)}
</Tabs.Panel>
<Tabs.Panel value="drivers" pt="md">
{(drivers as Driver[]).length > 0 ? (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>License</Table.Th>
<Table.Th>Contact</Table.Th>
<Table.Th>Assigned Vehicle</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(drivers as Driver[]).slice(0, 15).map(d => (
<Table.Tr key={d.id}>
<Table.Td fw={500}>
<Group gap="xs">
<ThemeIcon size="sm" radius="xl" variant="light" color="blue">
<User size={14} />
</ThemeIcon>
{d.firstName} {d.lastName}
</Group>
</Table.Td>
<Table.Td>{d.licenseNumber || 'N/A'}</Table.Td>
<Table.Td>
<Stack gap={0} size="xs">
{d.phone && (
<Text size="xs">
<Group gap={4} inline>
<MapPin size={12} /> {d.phone}
</Group>
</Text>
)}
{d.email && <Text size="xs">{d.email}</Text>}
</Stack>
</Table.Td>
<Table.Td>
{d.assignedVehicle ? (
<Badge color="edr-green">Assigned</Badge>
) : (
<Badge color="edr-slate">Unassigned</Badge>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Group gap="xs" p="md">
<AlertCircle size={20} />
<Text c="dimmed">No drivers in system</Text>
</Group>
)}
</Tabs.Panel>
</Tabs>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,316 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Box,
Button,
Card,
Container,
Group,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
TextInput,
Title,
Badge,
Grid,
} from "@mantine/core";
import { Plus, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
import { freightBrand } from "@/theme/freight-brand";
interface FuelPurchase {
id: string;
vehicleId: string;
vehicleName?: string;
purchaseDate: string;
liters: number;
costPerLiter: number;
totalCost: number;
fuelStation?: string;
paymentMethod: string;
odometerReading?: number;
receiptNumber?: string;
notes?: string;
}
export default function FuelPurchasePage() {
const { toast } = useToast();
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [formData, setFormData] = useState({
vehicleId: "",
purchaseDate: new Date().toISOString().split("T")[0],
liters: 0,
costPerLiter: 0,
fuelStation: "",
paymentMethod: "CASH",
odometerReading: undefined as number | undefined,
receiptNumber: "",
notes: "",
});
// Fetch vehicles
const { data: vehiclesData } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
// Fetch fuel purchases
const { data: purchasesData = [] } = useQuery({
queryKey: ["fuel-purchases"],
queryFn: async () => {
const res = await api.get("/fuel/purchases");
return res.data || [];
},
});
// Record purchase mutation
const recordMutation = useMutation({
mutationFn: async (data: typeof formData) => {
const res = await api.post("/fuel/purchases", {
...data,
liters: parseFloat(data.liters.toString()),
costPerLiter: parseFloat(data.costPerLiter.toString()),
});
return res.data;
},
onSuccess: () => {
toast({ title: "Fuel purchase recorded" });
setModalOpen(false);
setFormData({
vehicleId: "",
purchaseDate: new Date().toISOString().split("T")[0],
liters: 0,
costPerLiter: 0,
fuelStation: "",
paymentMethod: "CASH",
odometerReading: undefined,
receiptNumber: "",
notes: "",
});
qc.invalidateQueries({ queryKey: ["fuel-purchases"] });
},
onError: (error: any) => {
toast({
title: "Error recording purchase",
message: error?.response?.data?.message || "Failed to record fuel purchase",
color: "red",
});
},
});
const vehicleOptions =
vehiclesData?.map((v: VehicleType) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})) || [];
const totalCost = formData.liters * formData.costPerLiter;
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Fuel Management" }, { label: "Record Purchase" }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Fuel Purchases</Title>
<Button leftSection={<Plus size={16} />} onClick={() => setModalOpen(true)} color="edr-green">
Record Purchase
</Button>
</Group>
{/* Stats Cards */}
<Grid mb="lg">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Purchases
</Text>
<Text fw={700} size="lg">
{purchasesData.length}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Liters
</Text>
<Text fw={700} size="lg">
{purchasesData
.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0)
.toFixed(2)}{" "}
L
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Cost
</Text>
<Text fw={700} size="lg">
ETB {purchasesData
.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0)
.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Avg Price/L
</Text>
<Text fw={700} size="lg">
ETB{" "}
{(
purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0) /
purchasesData.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0) || 0
).toFixed(2)}
</Text>
</Card>
</Grid.Col>
</Grid>
{/* Purchases Table */}
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Vehicle</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th align="right">Liters</Table.Th>
<Table.Th align="right">Cost/L</Table.Th>
<Table.Th align="right">Total</Table.Th>
<Table.Th>Station</Table.Th>
<Table.Th>Payment</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(purchasesData as FuelPurchase[])?.map((purchase) => (
<Table.Tr key={purchase.id}>
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
<Table.Td align="right">{Number(purchase.liters).toFixed(2)}</Table.Td>
<Table.Td align="right">ETB {Number(purchase.costPerLiter).toFixed(2)}</Table.Td>
<Table.Td align="right">ETB {Number(purchase.totalCost).toLocaleString("en-US", { maximumFractionDigits: 2 })}</Table.Td>
<Table.Td>{purchase.fuelStation || "—"}</Table.Td>
<Table.Td>
<Badge size="sm">{purchase.paymentMethod}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
{/* Modal */}
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Record Fuel Purchase" size="lg">
<Stack gap="md">
<Select
label="Vehicle"
placeholder="Select vehicle"
data={vehicleOptions}
value={formData.vehicleId}
onChange={(val) => setFormData({ ...formData, vehicleId: val || "" })}
required
/>
<TextInput
label="Purchase Date"
type="date"
value={formData.purchaseDate}
onChange={(e) => setFormData({ ...formData, purchaseDate: e.currentTarget.value })}
required
/>
<NumberInput
label="Liters"
placeholder="0.00"
value={formData.liters}
onChange={(val) => setFormData({ ...formData, liters: val as number })}
decimalScale={2}
min={0}
required
/>
<NumberInput
label="Cost per Liter"
placeholder="0.00"
value={formData.costPerLiter}
onChange={(val) => setFormData({ ...formData, costPerLiter: val as number })}
decimalScale={2}
min={0}
required
/>
<Card withBorder bg="gray.0" padding="md">
<Text fw={600} size="lg">
Total Cost: ETB {totalCost.toFixed(2)}
</Text>
</Card>
<TextInput
label="Fuel Station"
placeholder="Station name"
value={formData.fuelStation}
onChange={(e) => setFormData({ ...formData, fuelStation: e.currentTarget.value })}
/>
<Select
label="Payment Method"
data={["CASH", "CARD", "FUEL_CARD", "TRANSFER", "CHEQUE"]}
value={formData.paymentMethod}
onChange={(val) => setFormData({ ...formData, paymentMethod: val || "CASH" })}
/>
<NumberInput
label="Odometer Reading (KM)"
placeholder="Optional"
value={formData.odometerReading}
onChange={(val) => setFormData({ ...formData, odometerReading: val as number | undefined })}
decimalScale={0}
min={0}
/>
<TextInput
label="Receipt Number"
placeholder="Optional"
value={formData.receiptNumber}
onChange={(e) => setFormData({ ...formData, receiptNumber: e.currentTarget.value })}
/>
<TextInput
label="Notes"
placeholder="Optional notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button
onClick={() => recordMutation.mutate(formData)}
loading={recordMutation.isPending}
disabled={!formData.vehicleId || formData.liters <= 0 || formData.costPerLiter <= 0}
>
Record Purchase
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,200 @@
import { useQuery } from "@tanstack/react-query";
import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, Badge } from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
import { useState } from "react";
interface FuelStats {
vehicleId: string;
totalPurchases: number;
totalLiters: number;
totalCost: number;
averagePricePerLiter: number;
dateRange: { startDate: string; endDate: string };
}
export default function FuelStatsPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string>("");
const [monthsBack, setMonthsBack] = useState<string>("12");
// Fetch vehicles
const { data: vehiclesData } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
// Fetch fuel stats
const { data: statsData } = useQuery({
queryKey: ["fuel-stats", selectedVehicleId, monthsBack],
queryFn: async () => {
if (!selectedVehicleId) return null;
const res = await api.get(`/fuel/stats/${selectedVehicleId}?months=${monthsBack}`);
return res.data;
},
enabled: !!selectedVehicleId,
});
const vehicleOptions =
vehiclesData?.map((v: VehicleType) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})) || [];
const selectedVehicle = vehiclesData?.find((v: VehicleType) => v.id === selectedVehicleId);
const costPerKm =
statsData && selectedVehicle?.actualDistanceKm
? (statsData.totalCost / selectedVehicle.actualDistanceKm).toFixed(2)
: "—";
const efficiency = statsData
? (statsData.totalLiters > 0 ? (selectedVehicle?.actualDistanceKm || 0) / statsData.totalLiters : 0).toFixed(2)
: "—";
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Fuel Management" }, { label: "Statistics" }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Fuel Consumption Analysis</Title>
</Group>
{/* Filters */}
<Card withBorder mb="lg" padding="md">
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Vehicle"
placeholder="Select vehicle to analyze"
data={vehicleOptions}
value={selectedVehicleId}
onChange={(val) => setSelectedVehicleId(val || "")}
searchable
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Period"
data={[
{ value: "3", label: "Last 3 months" },
{ value: "6", label: "Last 6 months" },
{ value: "12", label: "Last 12 months" },
]}
value={monthsBack}
onChange={(val) => setMonthsBack(val || "12")}
/>
</Grid.Col>
</Grid>
</Card>
{selectedVehicleId && statsData ? (
<>
{/* Stats Cards */}
<Grid mb="lg">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Purchases
</Text>
<Text fw={700} size="lg">
{statsData.totalPurchases}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Fuel
</Text>
<Text fw={700} size="lg">
{statsData.totalLiters.toFixed(2)} L
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Total Cost
</Text>
<Text fw={700} size="lg">
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Avg Price/L
</Text>
<Text fw={700} size="lg">
ETB {statsData.averagePricePerLiter.toFixed(2)}
</Text>
</Card>
</Grid.Col>
</Grid>
{/* Efficiency Metrics */}
<Grid mb="lg">
<Grid.Col span={{ base: 12, sm: 6 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Fuel Efficiency
</Text>
<Text fw={700} size="lg">
{efficiency} km/L
</Text>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Card withBorder padding="lg">
<Text size="sm" c="dimmed" fw={500}>
Cost per KM
</Text>
<Text fw={700} size="lg">
ETB {costPerKm}
</Text>
</Card>
</Grid.Col>
</Grid>
{/* Summary */}
<Card withBorder padding="lg">
<Stack gap="md">
<div>
<Text fw={600} mb="xs">
Summary
</Text>
<Text size="sm">
{selectedVehicle?.plateNumber} consumed{" "}
<Text fw={700} span>
{statsData.totalLiters.toFixed(2)} liters
</Text>{" "}
over the last {monthsBack} months, costing{" "}
<Text fw={700} span>
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
. Average fuel price was{" "}
<Text fw={700} span>
ETB {statsData.averagePricePerLiter.toFixed(2)} per liter
</Text>
.
</Text>
</div>
</Stack>
</Card>
</>
) : (
<Card withBorder padding="lg">
<Text c="dimmed" ta="center">
Select a vehicle to view fuel consumption statistics
</Text>
</Card>
)}
</Container>
);
}

View File

@@ -0,0 +1,206 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text, Container } from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { Plus } from 'lucide-react';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/services/api';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
interface MaintenanceSchedule {
id: string;
vehicleId: string;
maintenanceType: string;
description: string;
scheduledDate: string;
completedDate?: string;
status: string;
estimatedCost?: number;
actualCost?: number;
serviceProvider?: string;
}
export function MaintenancePage() {
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [openScheduleModal, setOpenScheduleModal] = useState(false);
const [formData, setFormData] = useState({
maintenanceType: 'PREVENTIVE',
description: '',
scheduledDate: new Date(),
estimatedCost: 0,
serviceProvider: '',
notes: '',
});
const queryClient = useQueryClient();
const { data: vehicles } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
const { data: upcoming, isLoading } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]),
enabled: !!selectedVehicle,
});
const scheduleMutation = useMutation({
mutationFn: async () => {
if (!selectedVehicle) return;
return api.post('/maintenance/schedules', {
vehicleId: selectedVehicle,
...formData,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') });
setOpenScheduleModal(false);
setFormData({
maintenanceType: 'PREVENTIVE',
description: '',
scheduledDate: new Date(),
estimatedCost: 0,
serviceProvider: '',
notes: '',
});
},
});
const vehicleOptions = useMemo(
() => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [],
[vehicles]
);
const statusColor = (status: string) => {
const colors: Record<string, string> = {
SCHEDULED: 'edr-blue',
IN_PROGRESS: 'edr-amber-soft',
COMPLETED: 'edr-green',
OVERDUE: 'edr-red',
};
return colors[status] || 'edr-slate';
};
return (
<Container size="xl" py="xl" px="lg">
<Stack gap="md">
<Card>
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Schedule Maintenance</Text>
<Button onClick={() => setOpenScheduleModal(true)} color="edr-green" leftSection={<Plus size={16} />}>
New Schedule
</Button>
</Group>
</Card.Section>
<Card.Section p="md">
<Select
label="Select Vehicle"
placeholder="Pick a vehicle"
data={vehicleOptions}
value={selectedVehicle}
onChange={setSelectedVehicle}
/>
</Card.Section>
</Card>
{selectedVehicle && (
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Upcoming Maintenance</Text>
</Card.Section>
<Card.Section p="md">
{isLoading ? (
<Text>Loading...</Text>
) : (upcoming || []).length > 0 ? (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Type</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Scheduled</Table.Th>
<Table.Th>Est. Cost</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(upcoming as MaintenanceSchedule[]).map(m => (
<Table.Tr key={m.id}>
<Table.Td>{m.maintenanceType}</Table.Td>
<Table.Td>{m.description}</Table.Td>
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
<Table.Td>${m.estimatedCost?.toFixed(2) || '—'}</Table.Td>
<Table.Td>
<Badge color={statusColor(m.status)}>{m.status}</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed">No upcoming maintenance</Text>
)}
</Card.Section>
</Card>
)}
<Modal
opened={openScheduleModal}
onClose={() => setOpenScheduleModal(false)}
title="Schedule Maintenance"
size="md"
>
<Stack gap="md">
<Select
label="Type"
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
value={formData.maintenanceType}
onChange={v => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
/>
<TextInput
label="Description"
placeholder="What needs to be done?"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.currentTarget.value })}
/>
<DateInput
label="Scheduled Date"
value={formData.scheduledDate}
onChange={d => setFormData({ ...formData, scheduledDate: d || new Date() })}
/>
<NumberInput
label="Estimated Cost"
value={formData.estimatedCost}
onChange={v => setFormData({ ...formData, estimatedCost: Number(v) })}
/>
<TextInput
label="Service Provider"
placeholder="e.g., John's Auto Repair"
value={formData.serviceProvider}
onChange={e => setFormData({ ...formData, serviceProvider: e.currentTarget.value })}
/>
<TextInput
label="Notes"
placeholder="Additional notes"
value={formData.notes}
onChange={e => setFormData({ ...formData, notes: e.currentTarget.value })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={() => setOpenScheduleModal(false)}>
Cancel
</Button>
<Button onClick={() => scheduleMutation.mutate()} loading={scheduleMutation.isPending}>
Schedule
</Button>
</Group>
</Stack>
</Modal>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,359 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, ThemeIcon, SimpleGrid } from '@mantine/core';
import { MapPin, Navigation, Radio, Activity } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
interface Vehicle {
id: string;
registrationNumber: string;
plateNumber: string;
manufacturer: string;
model: string;
status?: string;
}
interface GPSLocation {
lat: number;
lng: number;
speed?: number;
heading?: number;
lastUpdate?: string;
}
// Mock GPS data for demo
const generateMockGPS = (index: number): GPSLocation => ({
lat: 9.0 + Math.random() * 0.5,
lng: 38.7 + Math.random() * 0.5,
speed: Math.floor(Math.random() * 120),
heading: Math.floor(Math.random() * 360),
lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(),
});
export function TrackingPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string | null>(null);
const [mapCenter] = useState({ lat: 9.0, lng: 38.8 });
const mapZoom = 10;
const { data: vehicles = [] } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
},
});
// Generate mock GPS data for each vehicle
const vehiclesWithGPS = useMemo(() => {
return (vehicles as Vehicle[]).map((v, idx) => ({
...v,
gps: generateMockGPS(idx),
}));
}, [vehicles]);
// For demo: show all vehicles as trackable (or filter by ACTIVE if status data available)
const trackableVehicles = useMemo(
() => vehiclesWithGPS.slice(0, 10), // Limit to first 10 for demo
[vehiclesWithGPS]
);
const selectedVehicle = trackableVehicles.find(v => v.id === selectedVehicleId);
const vehicleOptions = useMemo(
() => trackableVehicles.map(v => ({ label: v.registrationNumber, value: v.id })),
[trackableVehicles]
);
// Map dimensions
const mapWidth = 800;
const mapHeight = 500;
const pixelsPerLat = mapHeight / 0.6;
const pixelsPerLng = mapWidth / 0.6;
const getMapCoords = (lat: number, lng: number) => ({
x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng),
y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat),
});
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Vehicle Tracking' }]} />
<Stack gap="xl">
<Group justify="space-between">
<div>
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Text c="dimmed" size="sm">
Monitor vehicle locations, speed, and status
</Text>
</div>
</Group>
<Grid>
{/* Map Section */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder p="lg">
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Map View</Text>
<Group gap="xs">
<Badge color="edr-green" leftSection={<Radio size={12} />}>
{trackableVehicles.length} Tracked
</Badge>
</Group>
</Group>
</Card.Section>
<Card.Section p="md">
<Box
pos="relative"
style={{
width: mapWidth,
height: mapHeight,
backgroundColor: '#f0f8f7',
border: `2px solid ${freightBrand.primary}`,
borderRadius: '8px',
overflow: 'hidden',
}}
>
{/* Grid background */}
<svg
width={mapWidth}
height={mapHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
>
{/* Latitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lat-${i}`}
x1={0}
y1={(i / 6) * mapHeight}
x2={mapWidth}
y2={(i / 6) * mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
{/* Longitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lng-${i}`}
x1={(i / 6) * mapWidth}
y1={0}
x2={(i / 6) * mapWidth}
y2={mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
</svg>
{/* Vehicle markers */}
{trackableVehicles.map((vehicle) => {
const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng);
const isSelected = vehicle.id === selectedVehicleId;
return (
<Box
key={vehicle.id}
pos="absolute"
style={{
left: coords.x - 15,
top: coords.y - 15,
width: 30,
height: 30,
cursor: 'pointer',
zIndex: isSelected ? 100 : 10,
}}
onClick={() => setSelectedVehicleId(vehicle.id)}
title={vehicle.registrationNumber}
>
<Box
pos="absolute"
inset={0}
style={{
backgroundColor: isSelected ? freightBrand.primary : '#3498db',
borderRadius: '50%',
border: isSelected ? `3px solid ${freightBrand.primaryDark}` : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '16px',
boxShadow: isSelected ? `0 0 0 8px ${freightBrand.ring}` : 'none',
}}
>
<Navigation size={16} />
</Box>
</Box>
);
})}
{/* Map labels */}
<Box pos="absolute" bottom={8} left={8} style={{ zIndex: 50 }}>
<Text size="xs" c="dimmed">
📍 Addis Ababa, Ethiopia
</Text>
</Box>
</Box>
</Card.Section>
</Card>
</Grid.Col>
{/* Sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{/* Vehicle Selector */}
<Card withBorder p="lg">
<Stack gap="md">
<Select
label="Track Vehicle"
placeholder="Select a vehicle to track"
data={vehicleOptions}
value={selectedVehicleId}
onChange={setSelectedVehicleId}
searchable
/>
{selectedVehicle && (
<Box p="md" style={{ backgroundColor: freightBrand.mutedBg, borderRadius: '8px' }}>
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed">
Registration
</Text>
<Text fw={600}>{selectedVehicle.registrationNumber}</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Vehicle
</Text>
<Text fw={600}>
{selectedVehicle.manufacturer} {selectedVehicle.model}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Status
</Text>
<Badge color={selectedVehicle.status === 'ACTIVE' ? 'edr-green' : 'gray'}>
{selectedVehicle.status || 'Unknown'}
</Badge>
</div>
</Stack>
</Box>
)}
</Stack>
</Card>
{/* GPS Details */}
{selectedVehicle && (
<Card withBorder p="lg">
<Stack gap="md">
<Group justify="space-between">
<Text fw={500}>GPS Location</Text>
<Badge color="edr-green" leftSection={<Activity size={12} />}>
Live
</Badge>
</Group>
<SimpleGrid cols={2} spacing="sm">
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Latitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lat.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Longitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lng.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Speed
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.speed} km/h
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Heading
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.heading}°
</Text>
</Box>
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">
Last Update
</Text>
<Text fw={500}>{selectedVehicle.gps.lastUpdate}</Text>
</div>
<Button color="edr-green" fullWidth leftSection={<MapPin size={16} />}>
View Full History
</Button>
</Stack>
</Card>
)}
{/* Tracked Vehicles List */}
<Card withBorder p="lg">
<Stack gap="md">
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
<Table size="sm">
<Table.Tbody>
{trackableVehicles.map(v => (
<Table.Tr
key={v.id}
style={{
cursor: 'pointer',
backgroundColor: v.id === selectedVehicleId ? freightBrand.mutedBg : 'transparent',
}}
onClick={() => setSelectedVehicleId(v.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>
{v.registrationNumber}
</Text>
<Text size="xs" c="dimmed">
{v.gps.speed} km/h
</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Badge
color={v.status === 'ACTIVE' ? 'edr-green' : 'gray'}
size="sm"
>
{v.status || 'N/A'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,16 @@
/** Shared formatting helpers for the fleet-management pages. */
/** Format a number as Ethiopian Birr, e.g. 12345.6 → "ETB 12,346". */
export function formatETB(amount: number, fractionDigits = 0): string {
const value = Number.isFinite(amount) ? amount : 0;
return `ETB ${value.toLocaleString("en-US", {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
})}`;
}
/** Safe percentage of `part` over `total`, rounded, 0 when total is 0. */
export function pct(part: number, total: number): number {
if (!total || !Number.isFinite(total) || !Number.isFinite(part)) return 0;
return Math.round((part / total) * 100);
}

View File

@@ -30,9 +30,11 @@ import {
Text,
TextInput,
UnstyledButton,
Alert,
} from "@mantine/core";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import {
@@ -44,6 +46,7 @@ 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) =>
@@ -336,6 +339,9 @@ const FirstMilePage = () => {
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = 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(),
queryFn: async () => {
@@ -434,6 +440,19 @@ 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 ?? "") });
setContainerAllocationOpen(false);
setContainerAllocationFirstMileId(null);
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
@@ -508,6 +527,16 @@ const FirstMilePage = () => {
setInvoiceRecord(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) {
@@ -530,7 +559,6 @@ const FirstMilePage = () => {
};
const matchesFilter = (r: FirstMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -743,9 +771,25 @@ const FirstMilePage = () => {
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
const isPaid = (row.original as any).paid;
if (!hasDistance) {
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>
);
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
@@ -1272,6 +1316,56 @@ const FirstMilePage = () => {
</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>
);
};

View File

@@ -45,6 +45,8 @@ import {
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
import { api } from "@/auth/http";
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
@@ -321,6 +323,9 @@ const LastMilePage = () => {
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
const [allocationOpen, setAllocationOpen] = useState(false);
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE.list(),
queryFn: async () => {
@@ -385,6 +390,19 @@ const LastMilePage = () => {
},
});
const allocateMutation = useMutation({
mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) =>
api.post(`/last-mile/${activeId}/allocate-containers`, data),
onSuccess: () => {
toast({ title: "Containers allocated", variant: "default" });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.byId(activeId ?? "") });
closeAllocation();
},
onError: () => {
toast({ title: "Allocation failed", variant: "destructive" });
},
});
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
queryKey: ["warehouse-inventory", "arrival-queue"],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
@@ -477,6 +495,18 @@ const LastMilePage = () => {
setInvoiceRecord(null);
};
const openAllocation = (id: string, containers?: LastMileContainerRow[]) => {
setActiveId(id);
setAllocationContainers(containers ?? []);
setAllocationOpen(true);
};
const closeAllocation = () => {
setAllocationOpen(false);
setActiveId(null);
setAllocationContainers([]);
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
@@ -509,7 +539,6 @@ const LastMilePage = () => {
);
const matchesFilter = (r: LastMileRecord) => {
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
switch (statusFilter) {
case "ALL": return true;
case "ASSIGNED": return isAssigned(r);
@@ -722,9 +751,25 @@ const LastMilePage = () => {
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
const isPaid = (row.original as any).paid;
if (!hasDistance) {
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>
);
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
@@ -1243,6 +1288,76 @@ const LastMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Container Allocation modal */}
<Modal
opened={allocationOpen}
onClose={closeAllocation}
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
size="xl"
radius="lg"
centered
>
<Stack gap="md">
{activeRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Stack gap={0}>
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Text size="xs" c="dimmed">{customerName(activeRecord)}</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" tt="uppercase">Cargo Type</Text>
<Text size="sm" fw={600}>{activeRecord.booking?.cargoType?.label ?? activeRecord.booking?.cargoType?.name ?? "—"}</Text>
</Stack>
</Group>
</Stack>
</Card>
{/* Capacity logic based on cargo type */}
{activeRecord.booking?.cargoType?.name === "BULK" ? (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-blue-0)" style={{ borderColor: "var(--mantine-color-blue-3)" }}>
<Stack gap="sm">
<Group gap="xs">
<Text fw={600} size="sm">Smart Capacity Allocation</Text>
</Group>
<Stack gap={2}>
<Text size="sm">Capacity: TBD</Text>
<Text size="xs" c="dimmed">
TODO: add vehicle capacity_tons to vehicle API if missing
</Text>
<Text size="xs" c="dimmed">
TODO: add container weight to booking if missing
</Text>
</Stack>
<Text size="sm" fw={500} mt="xs">
Select multiple containers per vehicle based on capacity
</Text>
</Stack>
</Card>
) : (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Text size="sm" fw={500}>One vehicle per container</Text>
</Card>
)}
</>
)}
<LastMileContainerAllocationTable
lastMileId={activeId ?? ""}
containers={allocationContainers}
onSave={async (mappings) => {
await allocateMutation.mutateAsync(mappings);
}}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAllocation}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -1,5 +1,6 @@
import { Button, Card } from '@mantine/core';
import { PackageSearch } from 'lucide-react';
import { useState } from 'react';
import { Button, Card, Group, Modal, Stack } from '@mantine/core';
import { PackageSearch, Truck } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { PageContainer, PageHeader } from '@/components/page';
@@ -7,6 +8,7 @@ import { WarehouseFlowWorkbench } from '@/components/warehouses';
export default function ExportWarehouseFlowPage() {
const navigate = useNavigate();
const [receiveOpen, setReceiveOpen] = useState(false);
return (
<PageContainer>
@@ -14,15 +16,41 @@ export default function ExportWarehouseFlowPage() {
title="Export Operations"
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
action={
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
Import Operations
</Button>
<Group gap="xs">
<Button
fw={700}
leftSection={<Truck size={16} />}
onClick={() => setReceiveOpen(true)}
>
Receive for Loading
</Button>
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
Import Operations
</Button>
</Group>
}
/>
<Card>
<WarehouseFlowWorkbench direction="EXPORT" />
</Card>
<Modal
opened={receiveOpen}
onClose={() => setReceiveOpen(false)}
title="Receive for loading"
centered
size="80rem"
>
<Stack gap="md">
<WarehouseFlowWorkbench enabled={receiveOpen} direction="EXPORT" />
<Group justify="flex-end">
<Button variant="default" onClick={() => setReceiveOpen(false)}>
Close
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}

View File

@@ -15,7 +15,8 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react';
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
@@ -31,7 +32,7 @@ import {
type WarehouseInvoiceStatus,
} from '@/types/warehouse';
import { openPdfBlob } from '@/components/warehouses/pdf';
import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf';
import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf';
import { extractErrorMessage } from '@/components/warehouses/options';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
@@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() {
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { toast } = useToast();
const navigate = useNavigate();
const { data: inv, isLoading } = useQuery(
api.warehouses.invoice.queryOptions({
input: { id: id ?? '' },
@@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
const pdfWindow = window.open('', '_blank');
try {
const { data } = await warehouseService.downloadInvoiceDocument(invoice.id);
openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Download failed',
description: extractErrorMessage(error),
});
}
};
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
const pdfWindow = window.open('', '_blank');
try {
const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id);
openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({
variant: 'destructive',
title: 'Download failed',
description: extractErrorMessage(error),
});
}
};
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
@@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
)}
<Group justify="flex-end" mt="sm">
{inv.bookingId && (
<Button
variant="subtle"
color="gray"
leftSection={<ExternalLink size={16} />}
onClick={() =>
navigate(
`/dashboard/booking-requests/${inv.bookingId}#warehouse-payments`,
)
}
>
View booking
</Button>
)}
<Button
variant="light"
color="gray"

View File

@@ -656,11 +656,11 @@ export const api = {
({ filter }) => ["warehouse-inventory", "inquiry", filter],
),
eligibleBookings: endpoint<void, EligibleBooking[]>(
eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
"warehouse-inventory",
"eligible-bookings",
() => warehouseService.eligibleBookings().then((r) => r.data),
() => ["warehouse-inventory", "eligible-bookings"],
(input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
(input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
),
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(

View File

@@ -137,6 +137,10 @@ export const warehouseService = {
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
responseType: 'blob',
}),
downloadGrnDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GRN_DOCUMENT(id), {
responseType: 'blob',
}),
downloadHandoverDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
responseType: 'blob',

View File

@@ -190,6 +190,7 @@ export interface WarehouseInventoryItem {
quantity: number;
weight: number;
volume: number | null;
grnNumber: string | null;
status: InventoryStatus;
inspectionStatus: string | null;
arrivedAt: string | null;
@@ -203,6 +204,8 @@ export interface WarehouseInventoryItem {
readyForPickupAt: string | null;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference?: string | null;
handoverDocumentDate?: string | null;
deliveredAt: string | null;
notes: string | null;
warehouse?: Warehouse | null;
@@ -472,6 +475,7 @@ export interface ReadyToLoadRow {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
origin: string | null;
destination: string | null;
inspectionStatus: string | null;
@@ -564,6 +568,7 @@ export interface ImportUnloadedItem {
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
@@ -571,6 +576,8 @@ export interface ImportUnloadedItem {
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
}