mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
fleet
This commit is contained in:
@@ -93,6 +93,10 @@ 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 CompliancePage from "./pages/fleet/CompliancePage";
|
||||
import IncidentsPage from "./pages/fleet/IncidentsPage";
|
||||
import WorkOrdersPage from "./pages/fleet/WorkOrdersPage";
|
||||
import ProcurementPage from "./pages/fleet/ProcurementPage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
@@ -290,6 +294,30 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Work Orders",
|
||||
href: "/dashboard/work-orders",
|
||||
icon: <SlidersHorizontal />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Compliance & Alerts",
|
||||
href: "/dashboard/compliance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Incidents",
|
||||
href: "/dashboard/incidents",
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Procurement",
|
||||
href: "/dashboard/procurement",
|
||||
icon: <Package />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Financial Reports",
|
||||
href: "/dashboard/financial-reports",
|
||||
@@ -1058,6 +1086,38 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="compliance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<CompliancePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="incidents"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<IncidentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="work-orders"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<WorkOrdersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="procurement"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<ProcurementPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Plus, AlertTriangle } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
complianceService,
|
||||
type ComplianceAlert,
|
||||
type ComplianceRecord,
|
||||
type ComplianceType,
|
||||
} from "@/services/compliance.service";
|
||||
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
|
||||
|
||||
const COMPLIANCE_TYPES: ComplianceType[] = [
|
||||
"INSPECTION",
|
||||
"INSURANCE",
|
||||
"ROADWORTHINESS",
|
||||
"PERMIT",
|
||||
"TAX",
|
||||
];
|
||||
|
||||
const severityColor = (severity: ComplianceAlert["severity"]) =>
|
||||
severity === "OVERDUE" ? "red" : "yellow";
|
||||
|
||||
const statusColor = (status: ComplianceRecord["status"]) => {
|
||||
if (status === "EXPIRED") return "red";
|
||||
if (status === "EXPIRING") return "yellow";
|
||||
return "green";
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) =>
|
||||
value ? new Date(value).toLocaleDateString() : "—";
|
||||
|
||||
const emptyForm = {
|
||||
vehicleId: "",
|
||||
type: "INSPECTION" as ComplianceType,
|
||||
documentNumber: "",
|
||||
issuedDate: "",
|
||||
expiryDate: new Date().toISOString().split("T")[0],
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export default function CompliancePage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [formData, setFormData] = useState(emptyForm);
|
||||
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: ["vehicles", "compliance-select"],
|
||||
queryFn: async () => {
|
||||
const res = await vehiclesService.getAll({ limit: 1000 });
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: alerts = [], isLoading: isLoadingAlerts } = useQuery({
|
||||
queryKey: ["compliance", "alerts"],
|
||||
queryFn: async () => {
|
||||
const res = await complianceService.getAlerts();
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: records = [], isLoading: isLoadingRecords } = useQuery({
|
||||
queryKey: ["compliance"],
|
||||
queryFn: async () => {
|
||||
const res = await complianceService.list();
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const res = await complianceService.create({
|
||||
vehicleId: data.vehicleId,
|
||||
type: data.type,
|
||||
expiryDate: data.expiryDate,
|
||||
documentNumber: data.documentNumber || undefined,
|
||||
issuedDate: data.issuedDate || undefined,
|
||||
notes: data.notes || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Compliance record created" });
|
||||
setModalOpen(false);
|
||||
setFormData(emptyForm);
|
||||
qc.invalidateQueries({ queryKey: ["compliance"] });
|
||||
qc.invalidateQueries({ queryKey: ["compliance", "alerts"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error creating record",
|
||||
description:
|
||||
error?.response?.data?.message || "Failed to create compliance record",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const vehicleOptions =
|
||||
vehiclesData?.map((v: VehicleType) => ({
|
||||
value: v.id,
|
||||
label: `${v.plateNumber ?? v.code ?? v.id} - ${v.manufacturer ?? ""} ${v.model ?? ""}`.trim(),
|
||||
})) || [];
|
||||
|
||||
const vehicleLabel = (record: ComplianceRecord) =>
|
||||
record.vehicle?.plateNumber ||
|
||||
vehiclesData?.find((v) => v.id === record.vehicleId)?.plateNumber ||
|
||||
record.vehicleId;
|
||||
|
||||
const overdueCount = (alerts as ComplianceAlert[]).filter(
|
||||
(a) => a.severity === "OVERDUE",
|
||||
).length;
|
||||
const dueSoonCount = (alerts as ComplianceAlert[]).filter(
|
||||
(a) => a.severity === "DUE_SOON",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl" px="lg">
|
||||
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Compliance" }]} />
|
||||
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Title order={1}>Compliance & Alerts</Title>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
color="edr-green"
|
||||
>
|
||||
New Record
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Alerts */}
|
||||
<Group mb="sm" gap="xs">
|
||||
<AlertTriangle size={18} />
|
||||
<Title order={3}>Expiry Alerts</Title>
|
||||
{overdueCount > 0 && (
|
||||
<Badge color="red" variant="light">
|
||||
{overdueCount} overdue
|
||||
</Badge>
|
||||
)}
|
||||
{dueSoonCount > 0 && (
|
||||
<Badge color="yellow" variant="light">
|
||||
{dueSoonCount} due soon
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isLoadingAlerts ? (
|
||||
<Group justify="center" py="md" mb="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (alerts as ComplianceAlert[]).length === 0 ? (
|
||||
<Card withBorder padding="lg" mb="lg">
|
||||
<Text c="dimmed" ta="center">
|
||||
No compliance items are overdue or due soon. All clear.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Grid mb="lg">
|
||||
{(alerts as ComplianceAlert[]).map((alert, index) => (
|
||||
<Grid.Col
|
||||
key={`${alert.vehicleId}-${alert.kind}-${index}`}
|
||||
span={{ base: 12, sm: 6, md: 4 }}
|
||||
>
|
||||
<Card withBorder padding="md" h="100%">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Badge color={severityColor(alert.severity)}>
|
||||
{alert.severity === "OVERDUE" ? "Overdue" : "Due Soon"}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{alert.daysUntil < 0
|
||||
? `${Math.abs(alert.daysUntil)}d ago`
|
||||
: `in ${alert.daysUntil}d`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={600}>{alert.label}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{alert.vehiclePlate || alert.vehicleId}
|
||||
</Text>
|
||||
<Text size="sm" mt="xs">
|
||||
Expires {formatDate(alert.expiryDate)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Records */}
|
||||
<Title order={3} mb="sm">
|
||||
Compliance Records
|
||||
</Title>
|
||||
<Card withBorder>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Document #</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Expiry</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoadingRecords ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (records as ComplianceRecord[]).length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No compliance records yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{(records as ComplianceRecord[]).map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>{vehicleLabel(record)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" size="sm">
|
||||
{record.type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{record.documentNumber || "—"}</Table.Td>
|
||||
<Table.Td>{formatDate(record.issuedDate)}</Table.Td>
|
||||
<Table.Td>{formatDate(record.expiryDate)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(record.status)} size="sm">
|
||||
{record.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title="New Compliance Record"
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
data={vehicleOptions}
|
||||
value={formData.vehicleId}
|
||||
onChange={(val) => setFormData({ ...formData, vehicleId: val || "" })}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Type"
|
||||
data={COMPLIANCE_TYPES.map((t) => ({ value: t, label: t }))}
|
||||
value={formData.type}
|
||||
onChange={(val) =>
|
||||
setFormData({ ...formData, type: (val as ComplianceType) || "INSPECTION" })
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Document Number"
|
||||
placeholder="Optional"
|
||||
value={formData.documentNumber}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, documentNumber: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Issued Date"
|
||||
type="date"
|
||||
value={formData.issuedDate}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, issuedDate: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Expiry Date"
|
||||
type="date"
|
||||
value={formData.expiryDate}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, expiryDate: e.currentTarget.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<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={() => createMutation.mutate(formData)}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!formData.vehicleId || !formData.expiryDate}
|
||||
>
|
||||
Create Record
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
Badge,
|
||||
Grid,
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
incidentsService,
|
||||
type Incident,
|
||||
type IncidentSeverity,
|
||||
type IncidentStatus,
|
||||
type IncidentType,
|
||||
type SaveIncidentPayload,
|
||||
} from "@/services/incidents.service";
|
||||
import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver as DriverType } from "@/services/drivers.service";
|
||||
|
||||
const TYPE_OPTIONS: IncidentType[] = [
|
||||
"ACCIDENT",
|
||||
"BREAKDOWN",
|
||||
"TRAFFIC_VIOLATION",
|
||||
"THEFT",
|
||||
"OTHER",
|
||||
];
|
||||
const SEVERITY_OPTIONS: IncidentSeverity[] = ["MINOR", "MODERATE", "MAJOR", "CRITICAL"];
|
||||
|
||||
const TYPE_COLORS: Record<IncidentType, string> = {
|
||||
ACCIDENT: "red",
|
||||
BREAKDOWN: "orange",
|
||||
TRAFFIC_VIOLATION: "yellow",
|
||||
THEFT: "grape",
|
||||
OTHER: "gray",
|
||||
};
|
||||
|
||||
const SEVERITY_COLORS: Record<IncidentSeverity, string> = {
|
||||
MINOR: "gray",
|
||||
MODERATE: "yellow",
|
||||
MAJOR: "orange",
|
||||
CRITICAL: "red",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<IncidentStatus, string> = {
|
||||
REPORTED: "blue",
|
||||
UNDER_REVIEW: "yellow",
|
||||
CLAIM_FILED: "grape",
|
||||
RESOLVED: "teal",
|
||||
CLOSED: "gray",
|
||||
};
|
||||
|
||||
const OPEN_STATUSES: IncidentStatus[] = ["REPORTED", "UNDER_REVIEW", "CLAIM_FILED"];
|
||||
|
||||
const formatMoney = (value: unknown) =>
|
||||
`ETB ${(Number(value) || 0).toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
|
||||
const initialForm = {
|
||||
type: "ACCIDENT" as IncidentType,
|
||||
severity: "MINOR" as IncidentSeverity,
|
||||
occurredAt: new Date().toISOString().split("T")[0],
|
||||
vehicleId: "",
|
||||
driverId: "",
|
||||
location: "",
|
||||
description: "",
|
||||
damageEstimate: undefined as number | undefined,
|
||||
reportedBy: "",
|
||||
};
|
||||
|
||||
export default function IncidentsPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [formData, setFormData] = useState(initialForm);
|
||||
|
||||
// Fetch vehicles
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: ["vehicles", "incidents-select"],
|
||||
queryFn: async () => {
|
||||
const res = await vehiclesService.getAll({ limit: 1000 });
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch drivers
|
||||
const { data: driversData } = useQuery({
|
||||
queryKey: ["drivers", "incidents-select"],
|
||||
queryFn: async () => {
|
||||
const res = await driversService.getAll({ limit: 1000 });
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch incidents
|
||||
const { data: incidentsData = [], isLoading } = useQuery({
|
||||
queryKey: ["incidents"],
|
||||
queryFn: async () => {
|
||||
const res = await incidentsService.getAll();
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof formData) => {
|
||||
const payload: SaveIncidentPayload = {
|
||||
type: data.type,
|
||||
severity: data.severity,
|
||||
occurredAt: new Date(data.occurredAt).toISOString(),
|
||||
description: data.description,
|
||||
};
|
||||
if (data.vehicleId) payload.vehicleId = data.vehicleId;
|
||||
if (data.driverId) payload.driverId = data.driverId;
|
||||
if (data.location) payload.location = data.location;
|
||||
if (data.damageEstimate != null) payload.damageEstimate = Number(data.damageEstimate);
|
||||
if (data.reportedBy) payload.reportedBy = data.reportedBy;
|
||||
const res = await incidentsService.create(payload);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Incident reported" });
|
||||
setModalOpen(false);
|
||||
setFormData(initialForm);
|
||||
qc.invalidateQueries({ queryKey: ["incidents"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error reporting incident",
|
||||
description: error?.response?.data?.message || "Failed to report incident",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const vehicleOptions =
|
||||
vehiclesData?.map((v: VehicleType) => ({
|
||||
value: v.id,
|
||||
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
|
||||
})) || [];
|
||||
|
||||
const driverOptions =
|
||||
driversData?.map((d: DriverType) => ({
|
||||
value: d.id,
|
||||
label: `${d.firstName} ${d.lastName}${d.licenseNumber ? ` (${d.licenseNumber})` : ""}`,
|
||||
})) || [];
|
||||
|
||||
const incidents = incidentsData as Incident[];
|
||||
const totalCount = incidents.length;
|
||||
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
|
||||
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
|
||||
const resolvedCount = incidents.filter((i) => i.status === "RESOLVED").length;
|
||||
|
||||
const vehicleLabel = (incident: Incident) =>
|
||||
incident.vehicle?.plateNumber ||
|
||||
incident.vehicle?.registrationNumber ||
|
||||
incident.vehicleId ||
|
||||
"—";
|
||||
|
||||
const driverLabel = (incident: Incident) => {
|
||||
if (incident.driver) {
|
||||
const name = `${incident.driver.firstName ?? ""} ${incident.driver.lastName ?? ""}`.trim();
|
||||
if (name) return name;
|
||||
}
|
||||
return incident.driverId || "—";
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl" px="lg">
|
||||
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Incidents" }]} />
|
||||
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Title order={1}>Accidents & Incidents</Title>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setModalOpen(true)} color="edr-green">
|
||||
Report Incident
|
||||
</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 Incidents
|
||||
</Text>
|
||||
<Text fw={700} size="lg">
|
||||
{totalCount}
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<Card withBorder padding="lg">
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Open
|
||||
</Text>
|
||||
<Text fw={700} size="lg">
|
||||
{openCount}
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<Card withBorder padding="lg">
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Under Review
|
||||
</Text>
|
||||
<Text fw={700} size="lg">
|
||||
{underReviewCount}
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||||
<Card withBorder padding="lg">
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
Resolved
|
||||
</Text>
|
||||
<Text fw={700} size="lg">
|
||||
{resolvedCount}
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Incidents Table */}
|
||||
<Card withBorder>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Severity</Table.Th>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Driver</Table.Th>
|
||||
<Table.Th align="right">Damage</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : incidents.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No incidents recorded yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{incidents.map((incident) => (
|
||||
<Table.Tr key={incident.id}>
|
||||
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={TYPE_COLORS[incident.type]}>
|
||||
{incident.type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={SEVERITY_COLORS[incident.severity]}>
|
||||
{incident.severity}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{vehicleLabel(incident)}</Table.Td>
|
||||
<Table.Td>{driverLabel(incident)}</Table.Td>
|
||||
<Table.Td align="right">
|
||||
{incident.damageEstimate != null ? formatMoney(incident.damageEstimate) : "—"}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={STATUS_COLORS[incident.status]} variant="light">
|
||||
{incident.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
<Modal opened={modalOpen} onClose={() => setModalOpen(false)} title="Report Incident" size="lg">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Type"
|
||||
data={TYPE_OPTIONS.map((t) => ({ value: t, label: t.replace(/_/g, " ") }))}
|
||||
value={formData.type}
|
||||
onChange={(val) => setFormData({ ...formData, type: (val as IncidentType) || "ACCIDENT" })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Severity"
|
||||
data={SEVERITY_OPTIONS.map((s) => ({ value: s, label: s }))}
|
||||
value={formData.severity}
|
||||
onChange={(val) =>
|
||||
setFormData({ ...formData, severity: (val as IncidentSeverity) || "MINOR" })
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Occurred At"
|
||||
type="date"
|
||||
value={formData.occurredAt}
|
||||
onChange={(e) => setFormData({ ...formData, occurredAt: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
data={vehicleOptions}
|
||||
value={formData.vehicleId || null}
|
||||
onChange={(val) => setFormData({ ...formData, vehicleId: val || "" })}
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Driver"
|
||||
placeholder="Select driver"
|
||||
data={driverOptions}
|
||||
value={formData.driverId || null}
|
||||
onChange={(val) => setFormData({ ...formData, driverId: val || "" })}
|
||||
clearable
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="What happened?"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.currentTarget.value })}
|
||||
minRows={3}
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Damage Estimate (ETB)"
|
||||
placeholder="0.00"
|
||||
value={formData.damageEstimate}
|
||||
onChange={(val) =>
|
||||
setFormData({ ...formData, damageEstimate: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
thousandSeparator=","
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Where did it happen?"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.currentTarget.value })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Reported By"
|
||||
placeholder="Optional"
|
||||
value={formData.reportedBy}
|
||||
onChange={(e) => setFormData({ ...formData, reportedBy: e.currentTarget.value })}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate(formData)}
|
||||
loading={createMutation.isPending}
|
||||
disabled={!formData.description.trim()}
|
||||
>
|
||||
Report Incident
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { vehiclesService, type Vehicle } from "@/services/vehicles.service";
|
||||
import {
|
||||
procurementService,
|
||||
type AssetAcquisition,
|
||||
type AssetDisposal,
|
||||
type Vendor,
|
||||
type AcquisitionType,
|
||||
type AcquisitionStatus,
|
||||
type VendorType,
|
||||
type DisposalMethod,
|
||||
} from "@/services/procurement.service";
|
||||
|
||||
const money = (x: number | null | undefined) =>
|
||||
`ETB ${(Number(x) || 0).toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
|
||||
const ACQUISITION_TYPES: AcquisitionType[] = ["PURCHASE", "LEASE", "RENTAL"];
|
||||
const ACQUISITION_STATUSES: AcquisitionStatus[] = ["ACTIVE", "LEASE_EXPIRING", "DISPOSED"];
|
||||
const VENDOR_TYPES: VendorType[] = ["DEALER", "LEASING", "PARTS", "SERVICE", "OTHER"];
|
||||
const DISPOSAL_METHODS: DisposalMethod[] = ["SALE", "SCRAP", "RETURN_LEASE", "TRADE_IN"];
|
||||
|
||||
const typeBadgeColor = (t: AcquisitionType) =>
|
||||
t === "PURCHASE" ? "green" : t === "LEASE" ? "blue" : "grape";
|
||||
const statusBadgeColor = (s: AcquisitionStatus) =>
|
||||
s === "ACTIVE" ? "green" : s === "LEASE_EXPIRING" ? "yellow" : "gray";
|
||||
|
||||
const vehicleLabel = (
|
||||
v?: { plateNumber?: string | null; registrationNumber?: string | null } | null,
|
||||
fallback?: string | null,
|
||||
) => v?.plateNumber || v?.registrationNumber || fallback || "—";
|
||||
|
||||
// Strip empty strings / null / undefined before sending to the API (ValidationPipe rejects "" for UUID fields).
|
||||
const clean = <T extends Record<string, unknown>>(obj: T): Partial<T> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(obj).filter(([, v]) => v !== "" && v !== undefined && v !== null),
|
||||
) as Partial<T>;
|
||||
|
||||
const emptyAcquisition = {
|
||||
vehicleId: "",
|
||||
vendorId: "",
|
||||
acquisitionType: "PURCHASE" as AcquisitionType,
|
||||
acquisitionDate: new Date().toISOString().split("T")[0],
|
||||
cost: undefined as number | undefined,
|
||||
usefulLifeMonths: undefined as number | undefined,
|
||||
salvageValue: undefined as number | undefined,
|
||||
leaseStart: "",
|
||||
leaseEnd: "",
|
||||
monthlyPayment: undefined as number | undefined,
|
||||
status: "ACTIVE" as AcquisitionStatus,
|
||||
notes: "",
|
||||
};
|
||||
|
||||
const emptyVendor = {
|
||||
name: "",
|
||||
type: "" as VendorType | "",
|
||||
contactPerson: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
address: "",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const emptyDisposal = {
|
||||
vehicleId: "",
|
||||
disposalDate: new Date().toISOString().split("T")[0],
|
||||
method: "SALE" as DisposalMethod,
|
||||
salePrice: undefined as number | undefined,
|
||||
buyer: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export default function ProcurementPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [tab, setTab] = useState<string>("acquisitions");
|
||||
const [acqModalOpen, setAcqModalOpen] = useState(false);
|
||||
const [vendorModalOpen, setVendorModalOpen] = useState(false);
|
||||
const [disposalModalOpen, setDisposalModalOpen] = useState(false);
|
||||
|
||||
const [acqForm, setAcqForm] = useState({ ...emptyAcquisition });
|
||||
const [vendorForm, setVendorForm] = useState({ ...emptyVendor });
|
||||
const [disposalForm, setDisposalForm] = useState({ ...emptyDisposal });
|
||||
|
||||
// ---- Queries ----
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: ["vehicles", "list"],
|
||||
queryFn: async () => {
|
||||
const res = await vehiclesService.getAll({ limit: 1000 });
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: acquisitions = [], isLoading: loadingAcquisitions } = useQuery({
|
||||
queryKey: ["procurement", "acquisitions"],
|
||||
queryFn: async () => {
|
||||
const res = await procurementService.listAcquisitions();
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: vendors = [], isLoading: loadingVendors } = useQuery({
|
||||
queryKey: ["procurement", "vendors"],
|
||||
queryFn: async () => {
|
||||
const res = await procurementService.listVendors();
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: disposals = [], isLoading: loadingDisposals } = useQuery({
|
||||
queryKey: ["procurement", "disposals"],
|
||||
queryFn: async () => {
|
||||
const res = await procurementService.listDisposals();
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const vehicleOptions =
|
||||
vehiclesData?.map((v: Vehicle) => ({
|
||||
value: v.id,
|
||||
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
|
||||
})) || [];
|
||||
|
||||
const vendorOptions = vendors.map((v: Vendor) => ({ value: v.id, label: v.name }));
|
||||
|
||||
// ---- Mutations ----
|
||||
const createAcquisition = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await procurementService.createAcquisition(clean(acqForm) as never);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Acquisition recorded" });
|
||||
setAcqModalOpen(false);
|
||||
setAcqForm({ ...emptyAcquisition });
|
||||
qc.invalidateQueries({ queryKey: ["procurement", "acquisitions"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error recording acquisition",
|
||||
description: error?.response?.data?.message || "Failed to record acquisition",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const createVendor = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await procurementService.createVendor(clean(vendorForm) as never);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Vendor created" });
|
||||
setVendorModalOpen(false);
|
||||
setVendorForm({ ...emptyVendor });
|
||||
qc.invalidateQueries({ queryKey: ["procurement", "vendors"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error creating vendor",
|
||||
description: error?.response?.data?.message || "Failed to create vendor",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const createDisposal = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await procurementService.createDisposal(clean(disposalForm) as never);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Disposal recorded" });
|
||||
setDisposalModalOpen(false);
|
||||
setDisposalForm({ ...emptyDisposal });
|
||||
qc.invalidateQueries({ queryKey: ["procurement", "disposals"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error recording disposal",
|
||||
description: error?.response?.data?.message || "Failed to record disposal",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl" px="lg">
|
||||
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Procurement" }]} />
|
||||
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Title order={1}>Procurement & Assets</Title>
|
||||
</Group>
|
||||
|
||||
<Tabs value={tab} onChange={(val) => setTab(val || "acquisitions")}>
|
||||
<Tabs.List mb="lg">
|
||||
<Tabs.Tab value="acquisitions">Acquisitions</Tabs.Tab>
|
||||
<Tabs.Tab value="vendors">Vendors</Tabs.Tab>
|
||||
<Tabs.Tab value="disposals">Disposals</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ---- Acquisitions ---- */}
|
||||
<Tabs.Panel value="acquisitions">
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setAcqModalOpen(true)}
|
||||
color="edr-green"
|
||||
>
|
||||
New Acquisition
|
||||
</Button>
|
||||
</Group>
|
||||
<Card withBorder>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th align="right">Cost</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{loadingAcquisitions ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : acquisitions.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No acquisitions recorded yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{acquisitions.map((a: AssetAcquisition) => (
|
||||
<Table.Tr key={a.id}>
|
||||
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
|
||||
{a.acquisitionType}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{new Date(a.acquisitionDate).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td align="right">{money(a.cost)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={statusBadgeColor(a.status)}>
|
||||
{a.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Vendors ---- */}
|
||||
<Tabs.Panel value="vendors">
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setVendorModalOpen(true)}
|
||||
color="edr-green"
|
||||
>
|
||||
New Vendor
|
||||
</Button>
|
||||
</Group>
|
||||
<Card withBorder>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Contact</Table.Th>
|
||||
<Table.Th>Phone</Table.Th>
|
||||
<Table.Th>Email</Table.Th>
|
||||
<Table.Th>Active</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{loadingVendors ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : vendors.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No vendors added yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{vendors.map((v: Vendor) => (
|
||||
<Table.Tr key={v.id}>
|
||||
<Table.Td>{v.name}</Table.Td>
|
||||
<Table.Td>{v.type ? <Badge size="sm">{v.type}</Badge> : "—"}</Table.Td>
|
||||
<Table.Td>{v.contactPerson || "—"}</Table.Td>
|
||||
<Table.Td>{v.phone || "—"}</Table.Td>
|
||||
<Table.Td>{v.email || "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={v.isActive ? "green" : "gray"}>
|
||||
{v.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Disposals ---- */}
|
||||
<Tabs.Panel value="disposals">
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setDisposalModalOpen(true)}
|
||||
color="edr-green"
|
||||
>
|
||||
New Disposal
|
||||
</Button>
|
||||
</Group>
|
||||
<Card withBorder>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Method</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th align="right">Sale Price</Table.Th>
|
||||
<Table.Th>Buyer</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{loadingDisposals ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : disposals.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No disposals recorded yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
{disposals.map((d: AssetDisposal) => (
|
||||
<Table.Tr key={d.id}>
|
||||
<Table.Td>{d.vehicleId}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm">{d.method}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{new Date(d.disposalDate).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td align="right">{money(d.salePrice)}</Table.Td>
|
||||
<Table.Td>{d.buyer || "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* ---- Acquisition Modal ---- */}
|
||||
<Modal
|
||||
opened={acqModalOpen}
|
||||
onClose={() => setAcqModalOpen(false)}
|
||||
title="New Acquisition"
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
data={vehicleOptions}
|
||||
value={acqForm.vehicleId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
data={vendorOptions}
|
||||
value={acqForm.vendorId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Acquisition Type"
|
||||
data={ACQUISITION_TYPES}
|
||||
value={acqForm.acquisitionType}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" })
|
||||
}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Acquisition Date"
|
||||
type="date"
|
||||
value={acqForm.acquisitionDate}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, acquisitionDate: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="Cost"
|
||||
placeholder="0.00"
|
||||
value={acqForm.cost}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, cost: val as number | undefined })}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Useful Life (months)"
|
||||
placeholder="Optional"
|
||||
value={acqForm.usefulLifeMonths}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, usefulLifeMonths: val as number | undefined })
|
||||
}
|
||||
decimalScale={0}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Salvage Value"
|
||||
placeholder="0.00"
|
||||
value={acqForm.salvageValue}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, salvageValue: val as number | undefined })}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease Start"
|
||||
type="date"
|
||||
value={acqForm.leaseStart}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease End"
|
||||
type="date"
|
||||
value={acqForm.leaseEnd}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Monthly Payment"
|
||||
placeholder="0.00"
|
||||
value={acqForm.monthlyPayment}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
data={ACQUISITION_STATUSES}
|
||||
value={acqForm.status}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, status: (val as AcquisitionStatus) || "ACTIVE" })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Notes"
|
||||
placeholder="Optional notes"
|
||||
value={acqForm.notes}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, notes: e.currentTarget.value })}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={() => setAcqModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createAcquisition.mutate()}
|
||||
loading={createAcquisition.isPending}
|
||||
disabled={!acqForm.acquisitionDate}
|
||||
>
|
||||
Save Acquisition
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* ---- Vendor Modal ---- */}
|
||||
<Modal
|
||||
opened={vendorModalOpen}
|
||||
onClose={() => setVendorModalOpen(false)}
|
||||
title="New Vendor"
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Vendor name"
|
||||
value={vendorForm.name}
|
||||
onChange={(e) => setVendorForm({ ...vendorForm, name: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Type"
|
||||
placeholder="Select type"
|
||||
data={VENDOR_TYPES}
|
||||
value={vendorForm.type || null}
|
||||
onChange={(val) => setVendorForm({ ...vendorForm, type: (val as VendorType) || "" })}
|
||||
clearable
|
||||
/>
|
||||
<TextInput
|
||||
label="Contact Person"
|
||||
placeholder="Optional"
|
||||
value={vendorForm.contactPerson}
|
||||
onChange={(e) => setVendorForm({ ...vendorForm, contactPerson: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone"
|
||||
placeholder="Optional"
|
||||
value={vendorForm.phone}
|
||||
onChange={(e) => setVendorForm({ ...vendorForm, phone: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="Optional"
|
||||
value={vendorForm.email}
|
||||
onChange={(e) => setVendorForm({ ...vendorForm, email: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Address"
|
||||
placeholder="Optional"
|
||||
value={vendorForm.address}
|
||||
onChange={(e) => setVendorForm({ ...vendorForm, address: e.currentTarget.value })}
|
||||
/>
|
||||
<Switch
|
||||
label="Active"
|
||||
checked={vendorForm.isActive}
|
||||
onChange={(e) => setVendorForm({ ...vendorForm, isActive: e.currentTarget.checked })}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={() => setVendorModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createVendor.mutate()}
|
||||
loading={createVendor.isPending}
|
||||
disabled={!vendorForm.name}
|
||||
>
|
||||
Save Vendor
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* ---- Disposal Modal ---- */}
|
||||
<Modal
|
||||
opened={disposalModalOpen}
|
||||
onClose={() => setDisposalModalOpen(false)}
|
||||
title="New Disposal"
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
data={vehicleOptions}
|
||||
value={disposalForm.vehicleId}
|
||||
onChange={(val) => setDisposalForm({ ...disposalForm, vehicleId: val || "" })}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Disposal Date"
|
||||
type="date"
|
||||
value={disposalForm.disposalDate}
|
||||
onChange={(e) =>
|
||||
setDisposalForm({ ...disposalForm, disposalDate: e.currentTarget.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Method"
|
||||
data={DISPOSAL_METHODS}
|
||||
value={disposalForm.method}
|
||||
onChange={(val) =>
|
||||
setDisposalForm({ ...disposalForm, method: (val as DisposalMethod) || "SALE" })
|
||||
}
|
||||
required
|
||||
/>
|
||||
<NumberInput
|
||||
label="Sale Price"
|
||||
placeholder="0.00"
|
||||
value={disposalForm.salePrice}
|
||||
onChange={(val) =>
|
||||
setDisposalForm({ ...disposalForm, salePrice: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
<TextInput
|
||||
label="Buyer"
|
||||
placeholder="Optional"
|
||||
value={disposalForm.buyer}
|
||||
onChange={(e) => setDisposalForm({ ...disposalForm, buyer: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Notes"
|
||||
placeholder="Optional notes"
|
||||
value={disposalForm.notes}
|
||||
onChange={(e) => setDisposalForm({ ...disposalForm, notes: e.currentTarget.value })}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={() => setDisposalModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createDisposal.mutate()}
|
||||
loading={createDisposal.isPending}
|
||||
disabled={!disposalForm.vehicleId || !disposalForm.disposalDate}
|
||||
>
|
||||
Save Disposal
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Modal,
|
||||
Stack,
|
||||
Group,
|
||||
Select,
|
||||
TextInput,
|
||||
Textarea,
|
||||
NumberInput,
|
||||
Table,
|
||||
Badge,
|
||||
Text,
|
||||
Title,
|
||||
Container,
|
||||
Tabs,
|
||||
Loader,
|
||||
Switch,
|
||||
ActionIcon,
|
||||
} from '@mantine/core';
|
||||
import { Plus, Trash2, Pencil, Wrench, Package, ShieldCheck } from 'lucide-react';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
maintenanceDepthService,
|
||||
type WorkOrder,
|
||||
type WorkOrderStatus,
|
||||
type WorkOrderPriority,
|
||||
type Part,
|
||||
type Warranty,
|
||||
} from '@/services/maintenance-depth.service';
|
||||
import { vehiclesService, type Vehicle as VehicleType } from '@/services/vehicles.service';
|
||||
|
||||
const WORK_ORDER_STATUSES: WorkOrderStatus[] = ['OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'];
|
||||
const WORK_ORDER_PRIORITIES: WorkOrderPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'URGENT'];
|
||||
const PART_CATEGORIES = ['TIRE', 'ENGINE', 'BRAKE', 'ELECTRICAL', 'FILTER', 'FLUID', 'OTHER'];
|
||||
|
||||
const etb = (x: number | string | null | undefined) =>
|
||||
`ETB ${(Number(x) || 0).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
OPEN: 'edr-blue',
|
||||
IN_PROGRESS: 'edr-amber-soft',
|
||||
COMPLETED: 'edr-green',
|
||||
CANCELLED: 'edr-slate',
|
||||
};
|
||||
return colors[status] || 'edr-slate';
|
||||
};
|
||||
|
||||
const priorityColor = (priority: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
LOW: 'edr-slate',
|
||||
MEDIUM: 'edr-blue',
|
||||
HIGH: 'edr-amber-soft',
|
||||
URGENT: 'edr-red',
|
||||
};
|
||||
return colors[priority] || 'edr-slate';
|
||||
};
|
||||
|
||||
const emptyWorkOrder = {
|
||||
vehicleId: '',
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'OPEN' as WorkOrderStatus,
|
||||
priority: 'MEDIUM' as WorkOrderPriority,
|
||||
assignedTo: '',
|
||||
laborCost: 0,
|
||||
partsCost: 0,
|
||||
};
|
||||
|
||||
const emptyPart = {
|
||||
name: '',
|
||||
sku: '',
|
||||
category: 'TIRE',
|
||||
quantityInStock: 0,
|
||||
reorderLevel: 0,
|
||||
unitCost: 0,
|
||||
location: '',
|
||||
};
|
||||
|
||||
const emptyWarranty = {
|
||||
vehicleId: '',
|
||||
component: '',
|
||||
provider: '',
|
||||
startDate: '',
|
||||
expiryDate: new Date().toISOString().split('T')[0],
|
||||
coverageNotes: '',
|
||||
};
|
||||
|
||||
export default function WorkOrdersPage() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>('work-orders');
|
||||
|
||||
// Work orders
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [openWorkOrderModal, setOpenWorkOrderModal] = useState(false);
|
||||
const [workOrderForm, setWorkOrderForm] = useState(emptyWorkOrder);
|
||||
|
||||
// Parts
|
||||
const [lowStockOnly, setLowStockOnly] = useState(false);
|
||||
const [openPartModal, setOpenPartModal] = useState(false);
|
||||
const [editingPartId, setEditingPartId] = useState<string | null>(null);
|
||||
const [partForm, setPartForm] = useState(emptyPart);
|
||||
|
||||
// Warranties
|
||||
const [openWarrantyModal, setOpenWarrantyModal] = useState(false);
|
||||
const [warrantyForm, setWarrantyForm] = useState(emptyWarranty);
|
||||
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: ['vehicles', 'all-for-maintenance-depth'],
|
||||
queryFn: async () => {
|
||||
const res = await vehiclesService.getAll({ limit: 1000 });
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const vehicleOptions =
|
||||
vehiclesData?.map((v: VehicleType) => ({
|
||||
value: v.id,
|
||||
label: v.plateNumber
|
||||
? `${v.plateNumber} - ${v.manufacturer} ${v.model}`
|
||||
: v.registrationNumber || v.id,
|
||||
})) || [];
|
||||
|
||||
const vehicleLabel = (vehicleId: string) =>
|
||||
vehicleOptions.find((o) => o.value === vehicleId)?.label || vehicleId;
|
||||
|
||||
// ---- Work orders queries/mutations ----
|
||||
const { data: workOrders, isLoading: workOrdersLoading } = useQuery({
|
||||
queryKey: ['maintenance-work-orders', statusFilter],
|
||||
queryFn: async () => {
|
||||
const res = await maintenanceDepthService.getWorkOrders({
|
||||
status: (statusFilter as WorkOrderStatus) || undefined,
|
||||
});
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
const workOrderList: WorkOrder[] = Array.isArray(workOrders) ? workOrders : [];
|
||||
|
||||
const createWorkOrderMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await maintenanceDepthService.createWorkOrder({
|
||||
vehicleId: workOrderForm.vehicleId,
|
||||
title: workOrderForm.title,
|
||||
description: workOrderForm.description || undefined,
|
||||
status: workOrderForm.status,
|
||||
priority: workOrderForm.priority,
|
||||
assignedTo: workOrderForm.assignedTo || undefined,
|
||||
laborCost: Number(workOrderForm.laborCost) || undefined,
|
||||
partsCost: Number(workOrderForm.partsCost) || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Work order created' });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenance-work-orders'] });
|
||||
setOpenWorkOrderModal(false);
|
||||
setWorkOrderForm(emptyWorkOrder);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err?.response?.data?.message ?? 'Failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteWorkOrderMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await maintenanceDepthService.deleteWorkOrder(id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Work order deleted' });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenance-work-orders'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err?.response?.data?.message ?? 'Failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Parts queries/mutations ----
|
||||
const { data: parts, isLoading: partsLoading } = useQuery({
|
||||
queryKey: ['maintenance-parts', lowStockOnly],
|
||||
queryFn: async () => {
|
||||
const res = await maintenanceDepthService.getParts({ lowStock: lowStockOnly || undefined });
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
const partList: Part[] = Array.isArray(parts) ? parts : [];
|
||||
|
||||
const savePartMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
name: partForm.name,
|
||||
sku: partForm.sku || undefined,
|
||||
category: partForm.category || undefined,
|
||||
quantityInStock: Number(partForm.quantityInStock) || 0,
|
||||
reorderLevel: Number(partForm.reorderLevel) || 0,
|
||||
unitCost: Number(partForm.unitCost) || undefined,
|
||||
location: partForm.location || undefined,
|
||||
};
|
||||
if (editingPartId) {
|
||||
const res = await maintenanceDepthService.updatePart(editingPartId, payload);
|
||||
return res.data;
|
||||
}
|
||||
const res = await maintenanceDepthService.createPart(payload);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: editingPartId ? 'Part updated' : 'Part created' });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenance-parts'] });
|
||||
setOpenPartModal(false);
|
||||
setPartForm(emptyPart);
|
||||
setEditingPartId(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err?.response?.data?.message ?? 'Failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deletePartMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await maintenanceDepthService.deletePart(id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Part deleted' });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenance-parts'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err?.response?.data?.message ?? 'Failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ---- Warranties queries/mutations ----
|
||||
const { data: warranties, isLoading: warrantiesLoading } = useQuery({
|
||||
queryKey: ['maintenance-warranties'],
|
||||
queryFn: async () => {
|
||||
const res = await maintenanceDepthService.getWarranties();
|
||||
return res.data || [];
|
||||
},
|
||||
});
|
||||
const warrantyList: Warranty[] = Array.isArray(warranties) ? warranties : [];
|
||||
|
||||
const createWarrantyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await maintenanceDepthService.createWarranty({
|
||||
vehicleId: warrantyForm.vehicleId,
|
||||
component: warrantyForm.component,
|
||||
provider: warrantyForm.provider || undefined,
|
||||
startDate: warrantyForm.startDate || undefined,
|
||||
expiryDate: warrantyForm.expiryDate,
|
||||
coverageNotes: warrantyForm.coverageNotes || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Warranty created' });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenance-warranties'] });
|
||||
setOpenWarrantyModal(false);
|
||||
setWarrantyForm(emptyWarranty);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err?.response?.data?.message ?? 'Failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteWarrantyMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await maintenanceDepthService.deleteWarranty(id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Warranty deleted' });
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenance-warranties'] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err?.response?.data?.message ?? 'Failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const openPartForEdit = (part: Part) => {
|
||||
setEditingPartId(part.id);
|
||||
setPartForm({
|
||||
name: part.name,
|
||||
sku: part.sku || '',
|
||||
category: part.category || 'OTHER',
|
||||
quantityInStock: part.quantityInStock,
|
||||
reorderLevel: part.reorderLevel,
|
||||
unitCost: Number(part.unitCost) || 0,
|
||||
location: part.location || '',
|
||||
});
|
||||
setOpenPartModal(true);
|
||||
};
|
||||
|
||||
const openPartForCreate = () => {
|
||||
setEditingPartId(null);
|
||||
setPartForm(emptyPart);
|
||||
setOpenPartModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl" px="lg">
|
||||
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Maintenance' }, { label: 'Work Orders' }]} />
|
||||
|
||||
<Group justify="space-between" mb="lg">
|
||||
<Title order={1}>Work Orders & Parts</Title>
|
||||
</Group>
|
||||
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="work-orders" leftSection={<Wrench size={14} />}>
|
||||
Work Orders
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="parts" leftSection={<Package size={14} />}>
|
||||
Parts / Tires
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="warranties" leftSection={<ShieldCheck size={14} />}>
|
||||
Warranties
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* ---- Work Orders tab ---- */}
|
||||
<Tabs.Panel value="work-orders" pt="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={WORK_ORDER_STATUSES}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setOpenWorkOrderModal(true)}
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New Work Order
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card withBorder>
|
||||
{workOrdersLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : workOrderList.length > 0 ? (
|
||||
<Table.ScrollContainer minWidth={800}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Title</Table.Th>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Priority</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Assigned</Table.Th>
|
||||
<Table.Th>Labor</Table.Th>
|
||||
<Table.Th>Parts</Table.Th>
|
||||
<Table.Th>Opened</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{workOrderList.map((wo) => (
|
||||
<Table.Tr key={wo.id}>
|
||||
<Table.Td>{wo.title}</Table.Td>
|
||||
<Table.Td>{vehicleLabel(wo.vehicleId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={priorityColor(wo.priority)}>{wo.priority}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(wo.status)}>{wo.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{wo.assignedTo || '—'}</Table.Td>
|
||||
<Table.Td>{etb(wo.laborCost)}</Table.Td>
|
||||
<Table.Td>{etb(wo.partsCost)}</Table.Td>
|
||||
<Table.Td>{new Date(wo.openedAt).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="edr-red"
|
||||
onClick={() => deleteWorkOrderMutation.mutate(wo.id)}
|
||||
aria-label="Delete work order"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" p="xl">
|
||||
No work orders yet
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Parts / Tires tab ---- */}
|
||||
<Tabs.Panel value="parts" pt="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Switch
|
||||
label="Low stock only"
|
||||
checked={lowStockOnly}
|
||||
onChange={(e) => setLowStockOnly(e.currentTarget.checked)}
|
||||
/>
|
||||
<Button onClick={openPartForCreate} color="edr-green" leftSection={<Plus size={16} />}>
|
||||
New Part
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card withBorder>
|
||||
{partsLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : partList.length > 0 ? (
|
||||
<Table.ScrollContainer minWidth={800}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>SKU</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>In Stock</Table.Th>
|
||||
<Table.Th>Reorder Level</Table.Th>
|
||||
<Table.Th>Unit Cost</Table.Th>
|
||||
<Table.Th>Location</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{partList.map((p) => (
|
||||
<Table.Tr key={p.id}>
|
||||
<Table.Td>{p.name}</Table.Td>
|
||||
<Table.Td>{p.sku || '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{p.category ? <Badge variant="light">{p.category}</Badge> : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{p.quantityInStock}
|
||||
{p.quantityInStock <= p.reorderLevel && (
|
||||
<Badge color="edr-red" size="sm">
|
||||
Low stock
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{p.reorderLevel}</Table.Td>
|
||||
<Table.Td>{etb(p.unitCost)}</Table.Td>
|
||||
<Table.Td>{p.location || '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => openPartForEdit(p)}
|
||||
aria-label="Adjust part"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="edr-red"
|
||||
onClick={() => deletePartMutation.mutate(p.id)}
|
||||
aria-label="Delete part"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" p="xl">
|
||||
No parts in inventory
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ---- Warranties tab ---- */}
|
||||
<Tabs.Panel value="warranties" pt="lg">
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Button
|
||||
onClick={() => setOpenWarrantyModal(true)}
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New Warranty
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card withBorder>
|
||||
{warrantiesLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : warrantyList.length > 0 ? (
|
||||
<Table.ScrollContainer minWidth={700}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Component</Table.Th>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Provider</Table.Th>
|
||||
<Table.Th>Start</Table.Th>
|
||||
<Table.Th>Expiry</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{warrantyList.map((w) => {
|
||||
const expired = new Date(w.expiryDate) < new Date();
|
||||
return (
|
||||
<Table.Tr key={w.id}>
|
||||
<Table.Td>{w.component}</Table.Td>
|
||||
<Table.Td>{vehicleLabel(w.vehicleId)}</Table.Td>
|
||||
<Table.Td>{w.provider || '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{w.startDate ? new Date(w.startDate).toLocaleDateString() : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{new Date(w.expiryDate).toLocaleDateString()}
|
||||
<Badge color={expired ? 'edr-red' : 'edr-green'} size="sm">
|
||||
{expired ? 'Expired' : 'Active'}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="edr-red"
|
||||
onClick={() => deleteWarrantyMutation.mutate(w.id)}
|
||||
aria-label="Delete warranty"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" p="xl">
|
||||
No warranties recorded
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{/* ---- Work Order modal ---- */}
|
||||
<Modal
|
||||
opened={openWorkOrderModal}
|
||||
onClose={() => setOpenWorkOrderModal(false)}
|
||||
title="New Work Order"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Pick a vehicle"
|
||||
data={vehicleOptions}
|
||||
value={workOrderForm.vehicleId || null}
|
||||
onChange={(v) => setWorkOrderForm({ ...workOrderForm, vehicleId: v || '' })}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Title"
|
||||
placeholder="e.g., Replace front brake pads"
|
||||
value={workOrderForm.title}
|
||||
onChange={(e) => setWorkOrderForm({ ...workOrderForm, title: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="Details of the work needed"
|
||||
value={workOrderForm.description}
|
||||
onChange={(e) =>
|
||||
setWorkOrderForm({ ...workOrderForm, description: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Priority"
|
||||
data={WORK_ORDER_PRIORITIES}
|
||||
value={workOrderForm.priority}
|
||||
onChange={(v) =>
|
||||
setWorkOrderForm({ ...workOrderForm, priority: (v as WorkOrderPriority) || 'MEDIUM' })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
data={WORK_ORDER_STATUSES}
|
||||
value={workOrderForm.status}
|
||||
onChange={(v) =>
|
||||
setWorkOrderForm({ ...workOrderForm, status: (v as WorkOrderStatus) || 'OPEN' })
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Assigned To"
|
||||
placeholder="e.g., Mechanic name"
|
||||
value={workOrderForm.assignedTo}
|
||||
onChange={(e) =>
|
||||
setWorkOrderForm({ ...workOrderForm, assignedTo: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Labor Cost (ETB)"
|
||||
min={0}
|
||||
value={workOrderForm.laborCost}
|
||||
onChange={(v) => setWorkOrderForm({ ...workOrderForm, laborCost: Number(v) || 0 })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Parts Cost (ETB)"
|
||||
min={0}
|
||||
value={workOrderForm.partsCost}
|
||||
onChange={(v) => setWorkOrderForm({ ...workOrderForm, partsCost: Number(v) || 0 })}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={() => setOpenWorkOrderModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createWorkOrderMutation.mutate()}
|
||||
loading={createWorkOrderMutation.isPending}
|
||||
disabled={!workOrderForm.vehicleId || !workOrderForm.title}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* ---- Part modal ---- */}
|
||||
<Modal
|
||||
opened={openPartModal}
|
||||
onClose={() => {
|
||||
setOpenPartModal(false);
|
||||
setEditingPartId(null);
|
||||
}}
|
||||
title={editingPartId ? 'Adjust Part' : 'New Part'}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="e.g., 315/80R22.5 Tire"
|
||||
value={partForm.name}
|
||||
onChange={(e) => setPartForm({ ...partForm, name: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="SKU"
|
||||
placeholder="Optional"
|
||||
value={partForm.sku}
|
||||
onChange={(e) => setPartForm({ ...partForm, sku: e.currentTarget.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Category"
|
||||
data={PART_CATEGORIES}
|
||||
value={partForm.category}
|
||||
onChange={(v) => setPartForm({ ...partForm, category: v || 'OTHER' })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Quantity in Stock"
|
||||
min={0}
|
||||
value={partForm.quantityInStock}
|
||||
onChange={(v) => setPartForm({ ...partForm, quantityInStock: Number(v) || 0 })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Reorder Level"
|
||||
min={0}
|
||||
value={partForm.reorderLevel}
|
||||
onChange={(v) => setPartForm({ ...partForm, reorderLevel: Number(v) || 0 })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Unit Cost (ETB)"
|
||||
min={0}
|
||||
value={partForm.unitCost}
|
||||
onChange={(v) => setPartForm({ ...partForm, unitCost: Number(v) || 0 })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="e.g., Shelf A3"
|
||||
value={partForm.location}
|
||||
onChange={(e) => setPartForm({ ...partForm, location: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
setOpenPartModal(false);
|
||||
setEditingPartId(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => savePartMutation.mutate()}
|
||||
loading={savePartMutation.isPending}
|
||||
disabled={!partForm.name}
|
||||
>
|
||||
{editingPartId ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* ---- Warranty modal ---- */}
|
||||
<Modal
|
||||
opened={openWarrantyModal}
|
||||
onClose={() => setOpenWarrantyModal(false)}
|
||||
title="New Warranty"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Pick a vehicle"
|
||||
data={vehicleOptions}
|
||||
value={warrantyForm.vehicleId || null}
|
||||
onChange={(v) => setWarrantyForm({ ...warrantyForm, vehicleId: v || '' })}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Component"
|
||||
placeholder="e.g., Engine, Transmission"
|
||||
value={warrantyForm.component}
|
||||
onChange={(e) => setWarrantyForm({ ...warrantyForm, component: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Provider"
|
||||
placeholder="e.g., Manufacturer name"
|
||||
value={warrantyForm.provider}
|
||||
onChange={(e) => setWarrantyForm({ ...warrantyForm, provider: e.currentTarget.value })}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Start Date"
|
||||
type="date"
|
||||
value={warrantyForm.startDate}
|
||||
onChange={(e) =>
|
||||
setWarrantyForm({ ...warrantyForm, startDate: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Expiry Date"
|
||||
type="date"
|
||||
value={warrantyForm.expiryDate}
|
||||
onChange={(e) =>
|
||||
setWarrantyForm({ ...warrantyForm, expiryDate: e.currentTarget.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</Group>
|
||||
<Textarea
|
||||
label="Coverage Notes"
|
||||
placeholder="What the warranty covers"
|
||||
value={warrantyForm.coverageNotes}
|
||||
onChange={(e) =>
|
||||
setWarrantyForm({ ...warrantyForm, coverageNotes: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={() => setOpenWarrantyModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createWarrantyMutation.mutate()}
|
||||
loading={createWarrantyMutation.isPending}
|
||||
disabled={!warrantyForm.vehicleId || !warrantyForm.component || !warrantyForm.expiryDate}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
export type ComplianceType =
|
||||
| 'INSPECTION'
|
||||
| 'INSURANCE'
|
||||
| 'ROADWORTHINESS'
|
||||
| 'PERMIT'
|
||||
| 'TAX';
|
||||
|
||||
export type ComplianceStatus = 'VALID' | 'EXPIRING' | 'EXPIRED';
|
||||
|
||||
export type AlertSeverity = 'OVERDUE' | 'DUE_SOON';
|
||||
|
||||
export interface ComplianceRecord {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
vehicle?: {
|
||||
id: string;
|
||||
plateNumber?: string | null;
|
||||
manufacturer?: string | null;
|
||||
model?: string | null;
|
||||
};
|
||||
type: ComplianceType;
|
||||
documentNumber?: string | null;
|
||||
issuedDate?: string | null;
|
||||
expiryDate: string;
|
||||
status: ComplianceStatus;
|
||||
notes?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ComplianceAlert {
|
||||
vehicleId: string;
|
||||
vehiclePlate?: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
expiryDate: string;
|
||||
daysUntil: number;
|
||||
severity: AlertSeverity;
|
||||
}
|
||||
|
||||
export interface ComplianceListFilters {
|
||||
vehicleId?: string;
|
||||
type?: ComplianceType;
|
||||
}
|
||||
|
||||
export interface SaveCompliancePayload {
|
||||
vehicleId: string;
|
||||
type: ComplianceType;
|
||||
documentNumber?: string;
|
||||
issuedDate?: string;
|
||||
expiryDate: string;
|
||||
status?: ComplianceStatus;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const complianceService = {
|
||||
list: (filters: ComplianceListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.vehicleId) params.set('vehicleId', filters.vehicleId);
|
||||
if (filters.type) params.set('type', filters.type);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<ComplianceRecord[]>(`/compliance${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getAlerts: () => apiClient.get<ComplianceAlert[]>('/compliance/alerts'),
|
||||
getById: (id: string) => apiClient.get<ComplianceRecord>(`/compliance/${id}`),
|
||||
create: (data: SaveCompliancePayload) =>
|
||||
apiClient.post<ComplianceRecord>('/compliance', data),
|
||||
update: (id: string, data: Partial<SaveCompliancePayload>) =>
|
||||
apiClient.patch<ComplianceRecord>(`/compliance/${id}`, data),
|
||||
remove: (id: string) => apiClient.delete(`/compliance/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { api } from '@/auth/http';
|
||||
|
||||
export type IncidentType = 'ACCIDENT' | 'BREAKDOWN' | 'TRAFFIC_VIOLATION' | 'THEFT' | 'OTHER';
|
||||
export type IncidentSeverity = 'MINOR' | 'MODERATE' | 'MAJOR' | 'CRITICAL';
|
||||
export type IncidentStatus =
|
||||
| 'REPORTED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'CLAIM_FILED'
|
||||
| 'RESOLVED'
|
||||
| 'CLOSED';
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
vehicleId?: string | null;
|
||||
driverId?: string | null;
|
||||
bookingId?: string | null;
|
||||
type: IncidentType;
|
||||
severity: IncidentSeverity;
|
||||
occurredAt: string;
|
||||
location?: string | null;
|
||||
description: string;
|
||||
/** API sends numeric as string; coerce with Number. */
|
||||
damageEstimate?: number | string | null;
|
||||
status: IncidentStatus;
|
||||
insuranceClaimNumber?: string | null;
|
||||
reportedBy?: string | null;
|
||||
vehicle?: {
|
||||
id: string;
|
||||
plateNumber?: string | null;
|
||||
registrationNumber?: string | null;
|
||||
} | null;
|
||||
driver?: {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
} | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IncidentFilters {
|
||||
vehicleId?: string;
|
||||
driverId?: string;
|
||||
status?: IncidentStatus;
|
||||
type?: IncidentType;
|
||||
}
|
||||
|
||||
export interface DriverIncidentStats {
|
||||
total: number;
|
||||
byType: Record<string, number>;
|
||||
lastIncidentAt: string | null;
|
||||
}
|
||||
|
||||
export interface SaveIncidentPayload {
|
||||
vehicleId?: string;
|
||||
driverId?: string;
|
||||
bookingId?: string;
|
||||
type: IncidentType;
|
||||
severity: IncidentSeverity;
|
||||
occurredAt: string;
|
||||
location?: string;
|
||||
description: string;
|
||||
damageEstimate?: number;
|
||||
status?: IncidentStatus;
|
||||
insuranceClaimNumber?: string;
|
||||
reportedBy?: string;
|
||||
}
|
||||
|
||||
export const incidentsService = {
|
||||
getAll: (filters: IncidentFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.vehicleId) params.set('vehicleId', filters.vehicleId);
|
||||
if (filters.driverId) params.set('driverId', filters.driverId);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.type) params.set('type', filters.type);
|
||||
const qs = params.toString();
|
||||
return api.get<Incident[]>(`/incidents${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getByDriver: (driverId: string) => api.get<Incident[]>(`/incidents/driver/${driverId}`),
|
||||
getDriverStats: (driverId: string) =>
|
||||
api.get<DriverIncidentStats>(`/incidents/driver/${driverId}/stats`),
|
||||
getById: (id: string) => api.get<Incident>(`/incidents/${id}`),
|
||||
create: (data: SaveIncidentPayload) => api.post<Incident>('/incidents', data),
|
||||
update: (id: string, data: Partial<SaveIncidentPayload>) =>
|
||||
api.patch<Incident>(`/incidents/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/incidents/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { api } from '@/auth/http';
|
||||
|
||||
export type WorkOrderStatus = 'OPEN' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
|
||||
export type WorkOrderPriority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
|
||||
export interface WorkOrder {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status: WorkOrderStatus;
|
||||
priority: WorkOrderPriority;
|
||||
assignedTo?: string | null;
|
||||
openedAt: string;
|
||||
closedAt?: string | null;
|
||||
/** API sends numeric as string; coerce with Number. */
|
||||
laborCost?: number | string | null;
|
||||
partsCost?: number | string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SaveWorkOrderPayload {
|
||||
vehicleId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: WorkOrderStatus;
|
||||
priority?: WorkOrderPriority;
|
||||
assignedTo?: string;
|
||||
openedAt?: string;
|
||||
closedAt?: string;
|
||||
laborCost?: number;
|
||||
partsCost?: number;
|
||||
}
|
||||
|
||||
export interface WorkOrderFilters {
|
||||
vehicleId?: string;
|
||||
status?: WorkOrderStatus;
|
||||
}
|
||||
|
||||
export interface Part {
|
||||
id: string;
|
||||
name: string;
|
||||
sku?: string | null;
|
||||
category?: string | null;
|
||||
quantityInStock: number;
|
||||
reorderLevel: number;
|
||||
/** API sends numeric as string; coerce with Number. */
|
||||
unitCost?: number | string | null;
|
||||
location?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SavePartPayload {
|
||||
name: string;
|
||||
sku?: string;
|
||||
category?: string;
|
||||
quantityInStock?: number;
|
||||
reorderLevel?: number;
|
||||
unitCost?: number;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface PartFilters {
|
||||
category?: string;
|
||||
lowStock?: boolean;
|
||||
}
|
||||
|
||||
export interface Warranty {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
component: string;
|
||||
provider?: string | null;
|
||||
startDate?: string | null;
|
||||
expiryDate: string;
|
||||
coverageNotes?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SaveWarrantyPayload {
|
||||
vehicleId: string;
|
||||
component: string;
|
||||
provider?: string;
|
||||
startDate?: string;
|
||||
expiryDate: string;
|
||||
coverageNotes?: string;
|
||||
}
|
||||
|
||||
export const maintenanceDepthService = {
|
||||
// Work Orders
|
||||
getWorkOrders: (filters: WorkOrderFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.vehicleId) params.set('vehicleId', filters.vehicleId);
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
const qs = params.toString();
|
||||
return api.get<WorkOrder[]>(`/maintenance/work-orders${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getWorkOrder: (id: string) => api.get<WorkOrder>(`/maintenance/work-orders/${id}`),
|
||||
createWorkOrder: (data: SaveWorkOrderPayload) =>
|
||||
api.post<WorkOrder>('/maintenance/work-orders', data),
|
||||
updateWorkOrder: (id: string, data: Partial<SaveWorkOrderPayload>) =>
|
||||
api.patch<WorkOrder>(`/maintenance/work-orders/${id}`, data),
|
||||
deleteWorkOrder: (id: string) => api.delete(`/maintenance/work-orders/${id}`),
|
||||
|
||||
// Parts / Tires
|
||||
getParts: (filters: PartFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.category) params.set('category', filters.category);
|
||||
if (filters.lowStock) params.set('lowStock', 'true');
|
||||
const qs = params.toString();
|
||||
return api.get<Part[]>(`/maintenance/parts${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
createPart: (data: SavePartPayload) => api.post<Part>('/maintenance/parts', data),
|
||||
updatePart: (id: string, data: Partial<SavePartPayload>) =>
|
||||
api.patch<Part>(`/maintenance/parts/${id}`, data),
|
||||
deletePart: (id: string) => api.delete(`/maintenance/parts/${id}`),
|
||||
|
||||
// Warranties
|
||||
getWarranties: (vehicleId?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (vehicleId) params.set('vehicleId', vehicleId);
|
||||
const qs = params.toString();
|
||||
return api.get<Warranty[]>(`/maintenance/warranties${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
createWarranty: (data: SaveWarrantyPayload) =>
|
||||
api.post<Warranty>('/maintenance/warranties', data),
|
||||
deleteWarranty: (id: string) => api.delete(`/maintenance/warranties/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
export type VendorType = "DEALER" | "LEASING" | "PARTS" | "SERVICE" | "OTHER";
|
||||
export type AcquisitionType = "PURCHASE" | "LEASE" | "RENTAL";
|
||||
export type AcquisitionStatus = "ACTIVE" | "LEASE_EXPIRING" | "DISPOSED";
|
||||
export type DisposalMethod = "SALE" | "SCRAP" | "RETURN_LEASE" | "TRADE_IN";
|
||||
|
||||
export interface Vendor {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: VendorType | null;
|
||||
contactPerson?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
address?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AssetAcquisition {
|
||||
id: string;
|
||||
vehicleId?: string | null;
|
||||
vendorId?: string | null;
|
||||
acquisitionType: AcquisitionType;
|
||||
acquisitionDate: string;
|
||||
cost?: number | null;
|
||||
usefulLifeMonths?: number | null;
|
||||
salvageValue?: number | null;
|
||||
leaseStart?: string | null;
|
||||
leaseEnd?: string | null;
|
||||
monthlyPayment?: number | null;
|
||||
status: AcquisitionStatus;
|
||||
notes?: string | null;
|
||||
vehicle?: {
|
||||
id: string;
|
||||
plateNumber?: string | null;
|
||||
registrationNumber?: string | null;
|
||||
manufacturer?: string | null;
|
||||
model?: string | null;
|
||||
} | null;
|
||||
vendor?: Vendor | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AssetDisposal {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
disposalDate: string;
|
||||
method: DisposalMethod;
|
||||
salePrice?: number | null;
|
||||
buyer?: string | null;
|
||||
notes?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DepreciationResult {
|
||||
method: "STRAIGHT_LINE";
|
||||
cost: number;
|
||||
salvageValue: number;
|
||||
usefulLifeMonths: number;
|
||||
monthsElapsed: number;
|
||||
monthlyDepreciation: number;
|
||||
bookValue: number;
|
||||
}
|
||||
|
||||
export interface LifecycleResult {
|
||||
vehicleId: string;
|
||||
acquisition: AssetAcquisition | null;
|
||||
disposal: AssetDisposal | null;
|
||||
depreciation: DepreciationResult | null;
|
||||
}
|
||||
|
||||
export interface CreateVendorPayload {
|
||||
name: string;
|
||||
type?: VendorType;
|
||||
contactPerson?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateAcquisitionPayload {
|
||||
vehicleId?: string;
|
||||
vendorId?: string;
|
||||
acquisitionType: AcquisitionType;
|
||||
acquisitionDate: string;
|
||||
cost?: number;
|
||||
usefulLifeMonths?: number;
|
||||
salvageValue?: number;
|
||||
leaseStart?: string;
|
||||
leaseEnd?: string;
|
||||
monthlyPayment?: number;
|
||||
status?: AcquisitionStatus;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateDisposalPayload {
|
||||
vehicleId: string;
|
||||
disposalDate: string;
|
||||
method: DisposalMethod;
|
||||
salePrice?: number;
|
||||
buyer?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const procurementService = {
|
||||
// Vendors
|
||||
listVendors: () => api.get<Vendor[]>("/procurement/vendors"),
|
||||
createVendor: (data: CreateVendorPayload) => api.post("/procurement/vendors", data),
|
||||
updateVendor: (id: string, data: Partial<CreateVendorPayload>) =>
|
||||
api.patch(`/procurement/vendors/${id}`, data),
|
||||
deleteVendor: (id: string) => api.delete(`/procurement/vendors/${id}`),
|
||||
|
||||
// Acquisitions
|
||||
listAcquisitions: (vehicleId?: string) =>
|
||||
api.get<AssetAcquisition[]>(
|
||||
`/procurement/acquisitions${vehicleId ? `?vehicleId=${vehicleId}` : ""}`,
|
||||
),
|
||||
getAcquisition: (id: string) => api.get<AssetAcquisition>(`/procurement/acquisitions/${id}`),
|
||||
createAcquisition: (data: CreateAcquisitionPayload) =>
|
||||
api.post("/procurement/acquisitions", data),
|
||||
updateAcquisition: (id: string, data: Partial<CreateAcquisitionPayload>) =>
|
||||
api.patch(`/procurement/acquisitions/${id}`, data),
|
||||
deleteAcquisition: (id: string) => api.delete(`/procurement/acquisitions/${id}`),
|
||||
|
||||
// Disposals
|
||||
listDisposals: () => api.get<AssetDisposal[]>("/procurement/disposals"),
|
||||
createDisposal: (data: CreateDisposalPayload) => api.post("/procurement/disposals", data),
|
||||
deleteDisposal: (id: string) => api.delete(`/procurement/disposals/${id}`),
|
||||
|
||||
// Lifecycle
|
||||
lifecycle: (vehicleId: string) =>
|
||||
api.get<LifecycleResult>(`/procurement/lifecycle/${vehicleId}`),
|
||||
};
|
||||
Reference in New Issue
Block a user