This commit is contained in:
natib21
2026-07-06 12:05:52 +00:00
parent e13720a32d
commit 998a6801ab
46 changed files with 5026 additions and 5 deletions

View File

@@ -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={

View File

@@ -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>
);
}

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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}`),
};

View File

@@ -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}`),
};

View File

@@ -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}`),
};

View File

@@ -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}`),
};