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

Freight/feature/first mile invoice
This commit is contained in:
yaschalew10
2026-07-04 06:50:11 +03:00
committed by GitHub
19 changed files with 304 additions and 220 deletions

View File

@@ -33,7 +33,6 @@ export interface ContainerAllocationTableProps {
* Displays containers with type/qty, vehicle dropdown per row, and save action.
*/
export function ContainerAllocationTable({
bookingId,
containers,
onSave,
}: ContainerAllocationTableProps) {
@@ -43,7 +42,10 @@ export function ContainerAllocationTable({
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
queryKey: ["vehicles", "free"],
queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }),
queryFn: async () => {
const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" });
return res.data ?? [];
},
});
const vehicleOptions = useMemo(
@@ -85,13 +87,12 @@ export function ContainerAllocationTable({
});
const allocatedCount = Object.values(allocations).filter(Boolean).length;
const allAllocated = allocatedCount === containers.length;
if (vehiclesLoading) {
return (
<Box display="flex" justifyContent="center" p="xl">
<Group justify="center" p="xl">
<Loader size="sm" />
</Box>
</Group>
);
}

View File

@@ -32,7 +32,7 @@ export function TransportDocumentCard({ bookingId }: { bookingId: string }) {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadTransportDocument(bookingId, file);
await contractsService.uploadTransportDocument(bookingId, { transportDocument: file });
toast.success("Transport document uploaded");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");

View File

@@ -282,7 +282,6 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/run-allocation`,
DOC_REVIEW_COMPLETE: (id: string) =>
`/train-scheduling/schedules/${id}/doc-review-complete`,
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) =>

View File

@@ -18,8 +18,8 @@ import {
type BookingDetailView,
} from "@/components/bookings/detail";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ContainerAllocationTable from "@/components/ContainerAllocationTable";
import { api } from "@/services/api";
import { ContainerAllocationTable } from "@/components/ContainerAllocationTable";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
const BookingDetailPage = () => {
@@ -160,9 +160,9 @@ const BookingDetailPage = () => {
type: c.containerType?.label ?? "Unknown",
qty: c.quantity,
}))}
onSave={(allocations) =>
allocateMutation.mutateAsync({ allocations })
}
onSave={async (allocations) => {
await allocateMutation.mutateAsync({ allocations });
}}
/>
<BookingApprovalCard
steps={approvalSteps}

View File

@@ -185,7 +185,7 @@ export default function GlClearanceDetailPage() {
</Tabs.List>
<Tabs.Panel value="workflow">
<Grid gutter="lg">
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
{data.kind === "booking" ? (
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />

View File

@@ -70,7 +70,7 @@ const DriverDetailPage = () => {
driver?.licenseExpiryDate && new Date(driver.licenseExpiryDate) < new Date();
return (
<Container size="lg" py="md">
<Container size="xl" py="xl" px="lg">
<Group gap="sm" mb="md" wrap="nowrap">
<ActionIcon variant="subtle" onClick={() => navigate("/dashboard/drivers")} aria-label="Back">
<ArrowLeft size={18} />

View File

@@ -1,26 +1,10 @@
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 { Card, Stack, Group, Grid, Select, Text, RingProgress, Container, Title } from '@mantine/core';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/services/api';
import { api } from '@/auth/http';
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;
@@ -31,6 +15,9 @@ interface CombinedReport {
maintenancePercentage: number;
}
const etb = (n: number) =>
'ETB ' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
export function FinancialReportsPage() {
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [months, setMonths] = useState('12');
@@ -45,13 +32,21 @@ export function FinancialReportsPage() {
const { data: fuelStats } = useQuery({
queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null),
queryFn: async () => {
if (!selectedVehicle) return Promise.resolve(null);
const res = await api.get(`/fuel/stats/${selectedVehicle}?months=${months}`);
return res.data;
},
enabled: !!selectedVehicle,
});
const { data: maintenanceStats } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null),
queryFn: async () => {
if (!selectedVehicle) return Promise.resolve(null);
const res = await api.get(`/maintenance/stats/${selectedVehicle}`);
return res.data;
},
enabled: !!selectedVehicle,
});
@@ -60,11 +55,11 @@ export function FinancialReportsPage() {
[vehicles]
);
const report = useMemo(() => {
if (!fuelStats || !maintenanceStats) return null;
const report = useMemo<CombinedReport | null>(() => {
if (!fuelStats && !maintenanceStats) return null;
const fuelCost = Number(fuelStats.totalCost) || 0;
const maintenanceCost = Number(maintenanceStats.totalCost) || 0;
const fuelCost = Number(fuelStats?.totalCost ?? 0) || 0;
const maintenanceCost = Number(maintenanceStats?.totalCost ?? 0) || 0;
const total = fuelCost + maintenanceCost;
return {
@@ -93,6 +88,9 @@ export function FinancialReportsPage() {
return (
<Container size="xl" py="xl" px="lg">
<Stack gap="md">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Financial Reports' }]} />
<Title order={1}>Financial Reports</Title>
<Card>
<Card.Section p="md" withBorder>
<Text fw={500}>Fleet Financial Analysis</Text>
@@ -126,13 +124,13 @@ export function FinancialReportsPage() {
<>
<Grid>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Total Operating Cost" value={`$${report.totalOperatingCost.toFixed(2)}`} />
<StatCard label="Total Operating Cost" value={etb(report.totalOperatingCost)} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Fuel Cost" value={`$${report.fuelCost.toFixed(2)}`} />
<StatCard label="Fuel Cost" value={etb(report.fuelCost)} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard label="Maintenance Cost" value={`$${report.maintenanceCost.toFixed(2)}`} />
<StatCard label="Maintenance Cost" value={etb(report.maintenanceCost)} />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<Card withBorder>
@@ -141,7 +139,7 @@ export function FinancialReportsPage() {
Monthly Avg
</Text>
<Text fw={700} size="lg">
${(report.totalOperatingCost / parseInt(months)).toFixed(2)}
{etb(report.totalOperatingCost / parseInt(months))}
</Text>
</Card.Section>
</Card>
@@ -166,7 +164,7 @@ export function FinancialReportsPage() {
<RingProgress
sections={[{ value: report.fuelPercentage, color: 'edr-accent' }]}
label={
<Text size="xs" align="center">
<Text size="xs" ta="center">
{report.fuelPercentage}%
</Text>
}
@@ -184,7 +182,7 @@ export function FinancialReportsPage() {
<RingProgress
sections={[{ value: report.maintenancePercentage, color: 'edr-red' }]}
label={
<Text size="xs" align="center">
<Text size="xs" ta="center">
{report.maintenancePercentage}%
</Text>
}
@@ -228,7 +226,7 @@ export function FinancialReportsPage() {
<Text size="sm" c="dimmed">
Avg Maintenance Cost
</Text>
<Text fw={500}>${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'}</Text>
<Text fw={500}>{etb(maintenanceStats?.averageCostPerMaintenance ?? 0)}</Text>
</div>
</Stack>
</Card.Section>

View File

@@ -1,7 +1,8 @@
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 { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core';
import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/auth/http';
@@ -39,7 +40,20 @@ interface FleetMetrics {
assignedDrivers: number;
}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: any) => (
/** ETB money, whole-birr for dashboard headlines. */
const etb = (n: number) => `ETB ${(Number(n) || 0).toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
/** Safe percentage — 0 when the denominator is 0 (empty fleet). */
const pct = (n: number, d: number) => (d > 0 ? (n / d) * 100 : 0);
interface StatCardProps {
icon: LucideIcon;
label: string;
value: string | number;
color?: string;
change?: number;
}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => (
<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">
@@ -111,8 +125,8 @@ export function FleetDashboard() {
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;
const fuelTotal = Number(fuelStats?.totalCost) || 0;
const maintenanceTotal = Number(maintenanceStats?.totalCost) || 0;
return {
totalVehicles,
@@ -152,10 +166,10 @@ export function FleetDashboard() {
<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" />
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} 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" />
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" />
</Grid.Col>
</Grid>
@@ -173,7 +187,7 @@ export function FleetDashboard() {
<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" />
<Progress value={pct(metrics.activeVehicles, metrics.totalVehicles)} color="edr-green" />
</div>
<div>
@@ -189,7 +203,7 @@ export function FleetDashboard() {
<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" />
<Progress value={pct(metrics.totalVehicles - metrics.activeVehicles, metrics.totalVehicles)} color="edr-amber-soft" />
</div>
</Stack>
</Card.Section>
@@ -212,7 +226,7 @@ export function FleetDashboard() {
label={
<div style={{ textAlign: 'center' }}>
<Text fw={700} size="sm">
${operatingCost.toFixed(0)}
{etb(operatingCost)}
</Text>
<Text size="xs" c="dimmed">
Total Cost
@@ -325,13 +339,12 @@ export function FleetDashboard() {
</Table.Td>
<Table.Td>{d.licenseNumber || 'N/A'}</Table.Td>
<Table.Td>
<Stack gap={0} size="xs">
<Stack gap={0}>
{d.phone && (
<Text size="xs">
<Group gap={4} inline>
<MapPin size={12} /> {d.phone}
</Group>
</Text>
<Group gap={4}>
<MapPin size={12} />
<Text size="xs">{d.phone}</Text>
</Group>
)}
{d.email && <Text size="xs">{d.email}</Text>}
</Stack>

View File

@@ -357,7 +357,7 @@ const FleetResourcePage = () => {
const itemLabel = config.label.toLowerCase();
return (
<Container size="xxl" py="lg">
<Container size="xxl" py="lg" px="lg">
<Breadcrumbs items={[{ label: config.label }]} />
<Stack gap="lg" mt="sm">

View File

@@ -1,11 +1,11 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Box,
Button,
Card,
Container,
Group,
Loader,
Modal,
NumberInput,
Select,
@@ -17,13 +17,12 @@ import {
Badge,
Grid,
} from "@mantine/core";
import { Plus, Trash2 } from "lucide-react";
import { Plus } 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;
@@ -67,7 +66,7 @@ export default function FuelPurchasePage() {
});
// Fetch fuel purchases
const { data: purchasesData = [] } = useQuery({
const { data: purchasesData = [], isLoading: isLoadingPurchases } = useQuery({
queryKey: ["fuel-purchases"],
queryFn: async () => {
const res = await api.get("/fuel/purchases");
@@ -104,8 +103,8 @@ export default function FuelPurchasePage() {
onError: (error: any) => {
toast({
title: "Error recording purchase",
message: error?.response?.data?.message || "Failed to record fuel purchase",
color: "red",
description: error?.response?.data?.message || "Failed to record fuel purchase",
variant: "destructive",
});
},
});
@@ -118,6 +117,17 @@ export default function FuelPurchasePage() {
const totalCost = formData.liters * formData.costPerLiter;
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.liters),
0
);
const totalPurchaseCost = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.totalCost),
0
);
const avgPricePerLiter = totalLiters > 0 ? totalPurchaseCost / totalLiters : 0;
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Fuel Management" }, { label: "Record Purchase" }]} />
@@ -147,10 +157,7 @@ export default function FuelPurchasePage() {
Total Liters
</Text>
<Text fw={700} size="lg">
{purchasesData
.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0)
.toFixed(2)}{" "}
L
{totalLiters.toFixed(2)} L
</Text>
</Card>
</Grid.Col>
@@ -160,9 +167,7 @@ export default function FuelPurchasePage() {
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 })}
ETB {totalPurchaseCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
</Card>
</Grid.Col>
@@ -172,11 +177,7 @@ export default function FuelPurchasePage() {
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)}
ETB {avgPricePerLiter.toFixed(2)}
</Text>
</Card>
</Grid.Col>
@@ -197,6 +198,23 @@ export default function FuelPurchasePage() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoadingPurchases ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : purchasesData.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="md">
No fuel purchases recorded yet.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{(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>

View File

@@ -1,20 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, Badge } from "@mantine/core";
import { Card, Container, Grid, Group, Loader, Select, Stack, Text, Title } 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");
@@ -29,7 +20,7 @@ export default function FuelStatsPage() {
});
// Fetch fuel stats
const { data: statsData } = useQuery({
const { data: statsData, isFetching: isStatsFetching } = useQuery({
queryKey: ["fuel-stats", selectedVehicleId, monthsBack],
queryFn: async () => {
if (!selectedVehicleId) return null;
@@ -102,7 +93,7 @@ export default function FuelStatsPage() {
Total Purchases
</Text>
<Text fw={700} size="lg">
{statsData.totalPurchases}
{Number(statsData.totalPurchases) || 0}
</Text>
</Card>
</Grid.Col>
@@ -112,7 +103,7 @@ export default function FuelStatsPage() {
Total Fuel
</Text>
<Text fw={700} size="lg">
{statsData.totalLiters.toFixed(2)} L
{(Number(statsData.totalLiters) || 0).toFixed(2)} L
</Text>
</Card>
</Grid.Col>
@@ -122,7 +113,7 @@ export default function FuelStatsPage() {
Total Cost
</Text>
<Text fw={700} size="lg">
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
</Card>
</Grid.Col>
@@ -132,7 +123,7 @@ export default function FuelStatsPage() {
Avg Price/L
</Text>
<Text fw={700} size="lg">
ETB {statsData.averagePricePerLiter.toFixed(2)}
ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)}
</Text>
</Card>
</Grid.Col>
@@ -172,15 +163,15 @@ export default function FuelStatsPage() {
<Text size="sm">
{selectedVehicle?.plateNumber} consumed{" "}
<Text fw={700} span>
{statsData.totalLiters.toFixed(2)} liters
{(Number(statsData.totalLiters) || 0).toFixed(2)} liters
</Text>{" "}
over the last {monthsBack} months, costing{" "}
<Text fw={700} span>
ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })}
ETB {(Number(statsData.totalCost) || 0).toLocaleString("en-US", { maximumFractionDigits: 2 })}
</Text>
. Average fuel price was{" "}
<Text fw={700} span>
ETB {statsData.averagePricePerLiter.toFixed(2)} per liter
ETB {(Number(statsData.averagePricePerLiter) || 0).toFixed(2)} per liter
</Text>
.
</Text>
@@ -188,6 +179,12 @@ export default function FuelStatsPage() {
</Stack>
</Card>
</>
) : selectedVehicleId && isStatsFetching ? (
<Card withBorder padding="lg">
<Group justify="center">
<Loader />
</Group>
</Card>
) : (
<Card withBorder padding="lg">
<Text c="dimmed" ta="center">

View File

@@ -1,12 +1,26 @@
import { useState, useMemo } from 'react';
import { useState } 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 {
Card,
Button,
Modal,
Stack,
Group,
Select,
TextInput,
NumberInput,
Table,
Badge,
Text,
Title,
Container,
} from '@mantine/core';
import { Plus } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { useToast } from '@/hooks/use-toast';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/services/api';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
import { api } from '@/auth/http';
import { vehiclesService, type Vehicle as VehicleType } from '@/services/vehicles.service';
interface MaintenanceSchedule {
id: string;
@@ -21,21 +35,23 @@ interface MaintenanceSchedule {
serviceProvider?: string;
}
const emptyForm = {
maintenanceType: 'PREVENTIVE',
description: '',
scheduledDate: new Date().toISOString().split('T')[0],
estimatedCost: 0,
serviceProvider: '',
notes: '',
};
export function MaintenancePage() {
const { toast } = useToast();
const queryClient = useQueryClient();
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 [formData, setFormData] = useState(emptyForm);
const queryClient = useQueryClient();
const { data: vehicles } = useQuery({
const { data: vehiclesData } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
@@ -45,36 +61,49 @@ export function MaintenancePage() {
const { data: upcoming, isLoading } = useQuery({
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]),
queryFn: async () => {
if (!selectedVehicle) return [];
const res = await api.get(`/maintenance/upcoming/${selectedVehicle}`);
return res.data || [];
},
enabled: !!selectedVehicle,
});
const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : [];
const scheduleMutation = useMutation({
mutationFn: async () => {
if (!selectedVehicle) return;
return api.post('/maintenance/schedules', {
const res = await api.post('/maintenance/schedules', {
vehicleId: selectedVehicle,
...formData,
});
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') });
toast({ title: 'Maintenance scheduled' });
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
});
setOpenScheduleModal(false);
setFormData({
maintenanceType: 'PREVENTIVE',
description: '',
scheduledDate: new Date(),
estimatedCost: 0,
serviceProvider: '',
notes: '',
setFormData(emptyForm);
},
onError: (err: any) => {
toast({
title: 'Error',
description: err?.response?.data?.message ?? 'Failed',
variant: 'destructive',
});
},
});
const vehicleOptions = useMemo(
() => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [],
[vehicles]
);
const vehicleOptions =
vehiclesData?.map((v: VehicleType) => ({
value: v.id,
label: v.plateNumber
? `${v.plateNumber} - ${v.manufacturer} ${v.model}`
: v.registrationNumber || v.id,
})) || [];
const statusColor = (status: string) => {
const colors: Record<string, string> = {
@@ -88,66 +117,85 @@ export function MaintenancePage() {
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Maintenance' }]} />
<Group justify="space-between" mb="lg">
<Title order={1}>Maintenance</Title>
<Button
onClick={() => setOpenScheduleModal(true)}
color="edr-green"
leftSection={<Plus size={16} />}
disabled={!selectedVehicle}
>
New Schedule
</Button>
</Group>
<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">
<Card withBorder padding="md">
<Select
label="Select Vehicle"
placeholder="Pick a vehicle"
data={vehicleOptions}
value={selectedVehicle}
onChange={setSelectedVehicle}
searchable
/>
</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>
)}
{!selectedVehicle ? (
<Card withBorder padding="lg">
<Text c="dimmed" ta="center">
Select a vehicle to view its maintenance schedule
</Text>
</Card>
) : (
<Card withBorder>
<Card.Section p="md" withBorder>
<Text fw={500}>Upcoming Maintenance</Text>
</Card.Section>
<Card.Section p="md">
{isLoading ? (
<Text>Loading...</Text>
) : upcomingList.length > 0 ? (
<Table striped highlightOnHover>
<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>
{upcomingList.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 != null
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 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>
)}
</Stack>
<Modal
opened={openScheduleModal}
@@ -160,47 +208,52 @@ export function MaintenancePage() {
label="Type"
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
value={formData.maintenanceType}
onChange={v => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
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 })}
onChange={(e) => setFormData({ ...formData, description: e.currentTarget.value })}
/>
<DateInput
<TextInput
label="Scheduled Date"
type="date"
value={formData.scheduledDate}
onChange={d => setFormData({ ...formData, scheduledDate: d || new Date() })}
onChange={(e) => setFormData({ ...formData, scheduledDate: e.currentTarget.value })}
/>
<NumberInput
label="Estimated Cost"
min={0}
value={formData.estimatedCost}
onChange={v => setFormData({ ...formData, estimatedCost: Number(v) })}
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 })}
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 })}
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}>
<Button
onClick={() => scheduleMutation.mutate()}
loading={scheduleMutation.isPending}
disabled={!selectedVehicle}
>
Schedule
</Button>
</Group>
</Stack>
</Modal>
</Stack>
</Container>
);
}

View File

@@ -24,7 +24,6 @@ import {
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";

View File

@@ -1,6 +1,6 @@
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 { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, 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';
@@ -24,8 +24,8 @@ interface GPSLocation {
lastUpdate?: string;
}
// Mock GPS data for demo
const generateMockGPS = (index: number): GPSLocation => ({
// Mock GPS data for demo (no real GPS backend exists — these are simulated values)
const generateMockGPS = (): GPSLocation => ({
lat: 9.0 + Math.random() * 0.5,
lng: 38.7 + Math.random() * 0.5,
speed: Math.floor(Math.random() * 120),
@@ -36,7 +36,6 @@ const generateMockGPS = (index: number): GPSLocation => ({
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(),
@@ -48,9 +47,9 @@ export function TrackingPage() {
// Generate mock GPS data for each vehicle
const vehiclesWithGPS = useMemo(() => {
return (vehicles as Vehicle[]).map((v, idx) => ({
return (vehicles as Vehicle[]).map((v) => ({
...v,
gps: generateMockGPS(idx),
gps: generateMockGPS(),
}));
}, [vehicles]);
@@ -84,9 +83,14 @@ export function TrackingPage() {
<Stack gap="xl">
<Group justify="space-between">
<div>
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Group gap="xs" align="center">
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Badge color="yellow" variant="light">
Simulated GPS
</Badge>
</Group>
<Text c="dimmed" size="sm">
Monitor vehicle locations, speed, and status
</Text>
@@ -109,17 +113,18 @@ export function TrackingPage() {
</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',
}}
>
<Box style={{ overflowX: 'auto', maxWidth: '100%' }}>
<Box
pos="relative"
style={{
width: mapWidth,
height: mapHeight,
backgroundColor: '#f0f8f7',
border: `2px solid ${freightBrand.primary}`,
borderRadius: '8px',
overflow: 'hidden',
}}
>
{/* Grid background */}
<svg
width={mapWidth}
@@ -199,7 +204,11 @@ export function TrackingPage() {
📍 Addis Ababa, Ethiopia
</Text>
</Box>
</Box>
</Box>
<Text size="xs" c="dimmed" mt="xs">
Simulated map coordinates, speed, and heading are demo values, not live GPS.
</Text>
</Card.Section>
</Card>
</Grid.Col>
@@ -314,7 +323,7 @@ export function TrackingPage() {
<Stack gap="md">
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
<Table size="sm">
<Table>
<Table.Tbody>
{trackableVehicles.map(v => (
<Table.Tr

View File

@@ -100,7 +100,7 @@ const VehicleDetailPage = () => {
: "";
return (
<Container size="lg" py="md">
<Container size="xl" py="xl" px="lg">
<Group gap="sm" mb="md" wrap="nowrap">
<ActionIcon variant="subtle" onClick={() => navigate("/dashboard/vehicles")} aria-label="Back">
<ArrowLeft size={18} />

View File

@@ -53,7 +53,7 @@ export interface FleetFormFieldDef extends FormFieldDef {
}
export interface FleetListFilterDef {
key: "status" | "currentYardId" | "wagonTypeId" | "trainId";
key: "status" | "availability" | "currentYardId" | "wagonTypeId" | "trainId";
label: string;
options?: Array<{ value: string; label: string }>;
allLabel?: string;

View File

@@ -19,7 +19,6 @@ import {
import {
Boxes,
ChevronRight,
FileText,
Home,
Layers,
Package,

View File

@@ -175,11 +175,6 @@ export const bookingsService = {
},
// ── Document clearance (GL workflow) ──
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
const response = await client.get(`/bookings/${id}/clearance`);
return unwrap(response.data) as Freight.ClearanceView;
},
reviewClearanceDocument: (
id: string,
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },

View File

@@ -35,6 +35,9 @@ export interface Vehicle {
powerPlateNo?: string | null;
trailerPlateNo?: string | null;
locationId?: string | null;
/** Odometer-derived distances (API sends numeric strings; coerce with Number). */
estimatedDistanceKm?: number | null;
actualDistanceKm?: number | null;
createdAt: string;
updatedAt: string;
}