Merge remote-tracking branch 'origin/dev' into Truckdetantion

This commit is contained in:
Hagernesh
2026-07-07 08:49:20 +00:00
252 changed files with 16873 additions and 1684 deletions

View File

@@ -94,6 +94,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";
@@ -291,6 +295,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",
@@ -590,6 +618,9 @@ const DashboardShell = () => {
return (
<FreightDashboardLayout
sidebarSections={sidebarSections}
// GL Ethiopia / GL Djibouti are locked to a single clearance page — no
// sidebar (or mobile burger) at all; the page renders full width.
hideSidebar={Boolean(glClearanceHome)}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
@@ -1100,6 +1131,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

@@ -19,6 +19,7 @@ import {
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api";
import type { StaffBookingWindow } from "@/types/trainScheduling";
@@ -224,6 +225,9 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
* Hidden when nothing is pending.
*/
export function GlUpcomingWindowsSection() {
// Live pushes flip cards the moment the window engine transitions a phase;
// the 60s poll below stays only as a fallback.
useBookingWindowSocket();
const { data, isLoading } = useQuery(
api.trainScheduling.allBookingWindows.queryOptions({
refetchInterval: 60_000,

View File

@@ -7,6 +7,7 @@ import {
Group,
Modal,
NumberInput,
Radio,
Select,
MultiSelect,
SimpleGrid,
@@ -293,6 +294,26 @@ const FleetFormDialog = ({
// only by verification and never hand-edited.
const isDisabled = Boolean(field.disabled || field.faydaLocked);
if (field.type === "radio") {
return (
<Radio.Group
key={field.name}
label={field.label}
value={value == null ? "" : String(value)}
onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next }))
}
error={error}
>
<Group gap="lg" mt={6}>
{(field.options ?? []).map((o) => (
<Radio key={o.value} value={o.value} label={o.label} disabled={isDisabled} />
))}
</Group>
</Radio.Group>
);
}
if (field.type === "select") {
return (
<Select

View File

@@ -5,14 +5,12 @@ import {
Burger,
Divider,
Group,
Indicator,
Menu,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
Bell,
ChevronDown,
FileSignature,
Languages,
@@ -25,6 +23,8 @@ import {
import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import type { PageMeta } from "./types";
export interface FreightDashboardHeaderProps {
@@ -39,6 +39,8 @@ export interface FreightDashboardHeaderProps {
onToggleTheme: () => void;
mobileOpened: boolean;
onToggleMobile: () => void;
/** Hide the mobile burger when the shell has no sidebar to open. */
hideSidebarBurger?: boolean;
}
// Every header control is a consistent 36px frosted chip — same language as the
@@ -57,6 +59,7 @@ const FreightDashboardHeader = ({
onToggleTheme,
mobileOpened,
onToggleMobile,
hideSidebarBurger = false,
}: FreightDashboardHeaderProps) => {
const navigate = useNavigate();
@@ -88,13 +91,15 @@ const FreightDashboardHeader = ({
{/* Left: burger (mobile) + search — the search now occupies the slot
the page title used to hold; each page owns its own title. */}
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
<Burger
opened={mobileOpened}
onClick={onToggleMobile}
hiddenFrom="sm"
size="sm"
aria-label="Toggle sidebar"
/>
{!hideSidebarBurger && (
<Burger
opened={mobileOpened}
onClick={onToggleMobile}
hiddenFrom="sm"
size="sm"
aria-label="Toggle sidebar"
/>
)}
<Group
gap={8}
align="center"
@@ -117,19 +122,7 @@ const FreightDashboardHeader = ({
</UnstyledButton>
</Tooltip>
<Tooltip label="Notifications" withArrow openDelay={300}>
<Indicator
color="edr-accent"
size={8}
offset={6}
withBorder
aria-label="Unread notifications"
>
<UnstyledButton className={ISLAND} aria-label="Notifications">
<Bell size={17} strokeWidth={1.8} />
</UnstyledButton>
</Indicator>
</Tooltip>
<NotificationBellContainer />
{enableThemeToggle && (
<Tooltip

View File

@@ -23,6 +23,8 @@ function getInitialTheme(): Theme {
export interface FreightDashboardLayoutProps {
sidebarSections: SidebarSection[];
/** Render the shell with no navbar at all (used by GL clearance-only users). */
hideSidebar?: boolean;
activeHref?: string;
onNavigate?: (href: string) => void;
headerRight?: ReactNode;
@@ -36,6 +38,7 @@ export interface FreightDashboardLayoutProps {
const FreightDashboardLayout = ({
sidebarSections,
hideSidebar = false,
activeHref = "",
onNavigate,
headerRight,
@@ -75,11 +78,17 @@ const FreightDashboardLayout = ({
padding={0}
className="bg-edr-bg"
header={{ height: HEADER_HEIGHT }}
navbar={{
width: NAVBAR_WIDTH,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}}
// When the sidebar is hidden the navbar slot is dropped entirely so Main
// spans the full viewport width (GL clearance-only users).
navbar={
hideSidebar
? undefined
: {
width: NAVBAR_WIDTH,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}
}
>
<FreightDashboardHeader
pageMeta={pageMeta}
@@ -93,14 +102,17 @@ const FreightDashboardLayout = ({
onToggleTheme={toggleTheme}
mobileOpened={mobileOpened}
onToggleMobile={toggleMobile}
hideSidebarBurger={hideSidebar}
/>
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={navigate}
onClose={closeMobile}
/>
{!hideSidebar && (
<FreightSidebar
sections={sidebarSections}
activeHref={activeHref}
onNavigate={navigate}
onClose={closeMobile}
/>
)}
<AppShell.Main>
{/* Internal scroll keeps the fixed-viewport model the dashboard pages

View File

@@ -32,6 +32,7 @@ const DEFAULTS = {
docReviewMinutes: 30,
paymentWindowMinutes: 60,
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
};
/** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
@@ -53,6 +54,7 @@ interface FormState {
docReviewMinutes: number | "";
paymentWindowMinutes: number | "";
importWindowLeadDays: number | "";
exportBookingLeadHours: number | "";
}
function parseError(error: unknown, fallback: string): string {
@@ -112,6 +114,8 @@ export default function BookingWindowSettingsModal({
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
importWindowLeadDays:
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
exportBookingLeadHours:
r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours,
});
}, [opened, schedule]);
@@ -142,15 +146,20 @@ export default function BookingWindowSettingsModal({
const doc = Number(form.docReviewMinutes);
const pay = Number(form.paymentWindowMinutes);
const lead = Number(form.importWindowLeadDays);
const exportLead = Number(form.exportBookingLeadHours);
const leadInvalid = isExport
? form.exportBookingLeadHours === "" ||
!Number.isFinite(exportLead) ||
exportLead < 1
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
if (
form.windowDurationHours === "" ||
form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" ||
form.importWindowLeadDays === "" ||
!Number.isFinite(duration) ||
!Number.isFinite(doc) ||
!Number.isFinite(pay) ||
!Number.isFinite(lead)
leadInvalid
) {
toast({
title: "Fill every field before saving",
@@ -164,7 +173,9 @@ export default function BookingWindowSettingsModal({
windowDurationHours: duration,
docReviewMinutes: doc,
paymentWindowMinutes: pay,
importWindowLeadDays: lead,
...(isExport
? { exportBookingLeadHours: exportLead }
: { importWindowLeadDays: lead }),
};
try {
@@ -223,8 +234,10 @@ export default function BookingWindowSettingsModal({
<Stack gap="lg">
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single FCFS lead window the daily desk
hours below don't apply, only the lead time does.
Export schedules use a single first-come-first-served window: it
opens the export lead time before departure shifted to the next
desk opening if that lands outside desk hours and stays open
until departure. Cycle timing below doesn't apply.
</Alert>
) : null}
@@ -263,7 +276,6 @@ export default function BookingWindowSettingsModal({
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
disabled={isExport}
/>
<Select
label="Closes"
@@ -275,7 +287,6 @@ export default function BookingWindowSettingsModal({
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
disabled={isExport}
/>
</Group>
{isOvernight && !is24h ? (
@@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({
color="grape"
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
disabled={isExport}
onChange={(e) => {
const checked = e.currentTarget.checked;
setForm((f) => {
@@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({
});
}}
/>
{!isExport ? (
<Text size="xs" c="dimmed" mt={6}>
A not-yet-full train pauses at the close hour and resumes the next
morning at the open hour, every day until it fills or departs.
</Text>
) : null}
<Text size="xs" c="dimmed" mt={6}>
{isExport
? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead."
: "A not-yet-full train pauses at the close hour and resumes the next morning at the open hour, every day until it fills or departs."}
</Text>
</Box>
<Divider />
@@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({
<Divider />
{/* ── Lead time ────────────────────────────────────────────────── */}
<NumberInput
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
description={
isExport
? "How many days before departure export booking opens"
: "How many days before departure the booking window starts"
}
value={form.importWindowLeadDays}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
importWindowLeadDays: v === "" ? "" : Number(v),
},
)
}
min={0}
clampBehavior="none"
allowDecimal={false}
/>
{isExport ? (
<NumberInput
label="Export booking lead (hours)"
description="How many hours before departure the export booking window opens"
value={form.exportBookingLeadHours}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
exportBookingLeadHours: v === "" ? "" : Number(v),
},
)
}
min={1}
clampBehavior="none"
allowDecimal={false}
/>
) : (
<NumberInput
label="Window lead (days)"
description="How many days before departure the booking window starts"
value={form.importWindowLeadDays}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
importWindowLeadDays: v === "" ? "" : Number(v),
},
)
}
min={0}
clampBehavior="none"
allowDecimal={false}
/>
)}
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}>

View File

@@ -0,0 +1,376 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
IntercityBookingRow,
IntercityCapacity,
} from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
function fmt(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
if (!capacity) {
return (
<Text size="sm" c="dimmed">
Capacity unknown schedule has no locomotive/train set yet.
</Text>
);
}
return (
<Group gap="xs">
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
{fmt(capacity.wagons)} wagons free
</Badge>
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
{fmt(capacity.weightTons)} t free
</Badge>
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
{fmt(capacity.lengthMeters)} m free
</Badge>
</Group>
);
}
function NeedCells({ need }: { need: IntercityCapacity | null }) {
if (!need) return <Table.Td colSpan={3}></Table.Td>;
return (
<>
<Table.Td>{fmt(need.wagons)}</Table.Td>
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
</>
);
}
function CorridorCell({ row }: { row: IntercityBookingRow }) {
return (
<Group gap={6} wrap="nowrap">
<Text size="sm">{row.origin}</Text>
<ArrowRight size={13} />
<Text size="sm">{row.destination}</Text>
</Group>
);
}
/**
* Intercity ride-along desk for one import/export schedule: waiting intercity
* bookings whose corridor lies on this train's route, checked against the
* remaining wagon/weight/length budget. Accepting opens the customer's pay
* window; after payment the booking is allocated. Loading/unloading is
* confirmed manually when the train is physically at the booking's origin /
* destination yard (the server validates against recorded checkpoints).
*/
export function IntercityRideAlongPanel({
scheduleId,
direction,
}: {
scheduleId: string;
direction: string | null | undefined;
}) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const candidatesQuery = useQuery(
api.trainScheduling.intercityCandidates.queryOptions({
input: { scheduleId },
refetchInterval: 60_000,
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
});
const accept = useMutation(
api.trainScheduling.acceptIntercityBookings.mutationOptions({
onSuccess: (result) => {
setSelected([]);
void invalidate();
if (result.accepted.length > 0) {
toast({
title: `${result.accepted.length} intercity booking(s) accepted`,
description: "Customers have been asked to pay.",
});
}
for (const r of result.rejected) {
toast({
title: "Booking skipped",
description: r.reason,
variant: "destructive",
});
}
},
onError: (err) =>
toast({
title: "Accept failed",
description: parseError(err, "Could not accept intercity bookings"),
variant: "destructive",
}),
}),
);
const load = useMutation(
api.trainScheduling.loadIntercityBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo loaded" });
},
onError: (err) =>
toast({
title: "Load failed",
description: parseError(err, "Could not confirm loading"),
variant: "destructive",
}),
}),
);
const unload = useMutation(
api.trainScheduling.unloadIntercityBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo unloaded — booking completed" });
},
onError: (err) =>
toast({
title: "Unload failed",
description: parseError(err, "Could not confirm unloading"),
variant: "destructive",
}),
}),
);
// Intercity bookings only ride import/export trains.
if (direction !== "IMPORT" && direction !== "EXPORT") return null;
const data = candidatesQuery.data;
const candidates = data?.candidates ?? [];
const accepted = data?.accepted ?? [];
if (candidatesQuery.isLoading) {
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading intercity ride-along bookings
</Text>
</Group>
</Paper>
);
}
if (candidates.length === 0 && accepted.length === 0) return null;
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
<TrainFront size={18} />
<Text fw={700}>Intercity ride-along</Text>
</Group>
<CapacityBadges capacity={data?.remaining ?? null} />
</Group>
{candidates.length > 0 && (
<>
<Text size="sm" c="dimmed">
Waiting intercity bookings whose corridor lies on this train's
route. Accepting opens the customer's payment window against the
free capacity above.
</Text>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={36} />
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Fits</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{candidates.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Checkbox
size="xs"
checked={selected.includes(row.id)}
onChange={(e) =>
setSelected((prev) =>
e.currentTarget.checked
? [...prev, row.id]
: prev.filter((id) => id !== row.id),
)
}
/>
</Table.Td>
<Table.Td>
<Group gap={6}>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment && (
<Badge size="xs" variant="light" color="grape">
GOV
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<NeedCells need={row.need} />
<Table.Td>
{row.fits ? (
<Badge size="sm" variant="light" color="teal">
Fits
</Badge>
) : (
<Tooltip label="Exceeds the remaining wagon/weight/length budget">
<Badge size="sm" variant="light" color="red">
No room
</Badge>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="flex-end">
<Button
size="xs"
color="edr-green"
loading={accept.isPending}
disabled={selected.length === 0}
onClick={() => accept.mutate({ scheduleId, bookingIds: selected })}
>
Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train
</Button>
</Group>
</>
)}
{accepted.length > 0 && (
<>
<Text size="sm" fw={600}>
On this train
</Text>
<Table.ScrollContainer minWidth={680}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accepted.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{row.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
{row.status === "PAID" && (
<Tooltip label="Train must be at the booking's origin yard">
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId, bookingId: row.id })
}
>
Load
</Button>
</Tooltip>
)}
{row.status === "IN_TRANSIT" && (
<Tooltip label="Train must be at the booking's destination yard">
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId, bookingId: row.id })
}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)}
{candidatesQuery.isError && (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
{parseError(candidatesQuery.error, "Could not load intercity candidates")}
</Alert>
)}
</Stack>
</Paper>
);
}

View File

@@ -324,6 +324,14 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
INTERCITY_CANDIDATES: (id: string) =>
`/train-scheduling/schedules/${id}/intercity-candidates`,
INTERCITY_ACCEPT: (id: string) =>
`/train-scheduling/schedules/${id}/intercity/accept`,
INTERCITY_LOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/intercity/${bookingId}/load`,
INTERCITY_UNLOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/intercity/${bookingId}/unload`,
IMPORT_LOADING_BOOKINGS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-bookings`,
IMPORT_LOADING_STATUS: (id: string) =>

View File

@@ -0,0 +1,67 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
// The socket namespace lives at the server root, not under the `/api` REST
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
/**
* Subscribes to live booking-window pushes for staff. Every phase transition
* the window engine applies invalidates the GL windows carousel and the batch
* board, so both flip the moment the backend does — polling stays only as a
* fallback.
*/
export function useBookingWindowSocket(enabled: boolean = true) {
const qc = useQueryClient();
useEffect(() => {
if (!enabled) return;
const token = getCookie(AUTH_TOKEN_COOKIE);
if (!token) return;
const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token },
transports: ["websocket"],
withCredentials: true,
});
// Deliberate console breadcrumbs: "live updates not arriving" is only
// diagnosable from the browser when connect/reject outcomes are visible.
socket.on("connect", () =>
console.debug("[booking-windows] socket connected", socket.id),
);
socket.on("connect_error", (err) =>
console.warn("[booking-windows] socket connect failed:", err.message),
);
socket.on("disconnect", (reason) =>
console.debug("[booking-windows] socket disconnected:", reason),
);
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(_event: BookingWindowPhaseEvent) => {
qc.invalidateQueries({
queryKey: ["train-scheduling", "all-booking-windows"],
});
qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
});
},
);
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -0,0 +1,129 @@
import type { NotificationDto, NotificationListResult } from "@edr/types";
import {
NotificationBell,
NotificationDrawer,
NotificationToast,
type NotificationItemData,
} from "@edr/ui-common";
import { useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import {
resolveNotificationHref,
resolveNotificationVisual,
} from "./notificationConfig";
import {
useInfiniteNotifications,
useMarkAllRead,
useMarkRead,
useUnreadCount,
} from "./useNotifications";
import { useNotificationSocket } from "./useNotificationSocket";
/** Map a server notification into the shared presentational item shape. */
function toItem(n: NotificationDto): NotificationItemData {
return {
id: n.id,
type: n.type,
title: n.title,
body: n.body,
createdAt: n.createdAt,
isRead: n.isRead,
priority: n.priority,
link: n.link,
data: n.data,
};
}
/** Flatten an infinite query's pages into the drawer's item shape. */
function toItems(
data: { pages: NotificationListResult[] } | undefined,
): NotificationItemData[] {
return (data?.pages ?? []).flatMap((p) => p.items).map(toItem);
}
/**
* Wires react-query (infinite unread/read lists) + the notification WebSocket
* into the shared bell + drawer. Lists are only fetched while the drawer is
* open; the badge is driven by the lightweight unread-count query + socket.
*/
export default function NotificationBellContainer({
enabled = true,
}: {
enabled?: boolean;
}) {
const navigate = useNavigate();
const [opened, setOpened] = useState(false);
const unreadQ = useInfiniteNotifications(false, enabled && opened);
const readQ = useInfiniteNotifications(true, enabled && opened);
const unread = useUnreadCount(enabled);
const markRead = useMarkRead();
const markAllRead = useMarkAllRead();
const unreadItems = toItems(unreadQ.data);
const readItems = toItems(readQ.data);
const unreadCount = unread.data ?? 0;
const handleItemClick = (item: NotificationItemData) => {
if (!item.isRead) markRead.mutate(item.id);
const href = resolveNotificationHref(item);
setOpened(false);
if (href) navigate(href);
};
// Live push → rich toast that reuses the same registry + click action.
useNotificationSocket(enabled, (n) => {
const item = toItem(n);
toast.custom(
(t) => (
<NotificationToast
item={item}
visual={resolveNotificationVisual(item)}
visible={t.visible}
onClick={() => {
toast.dismiss(t.id);
handleItemClick(item);
}}
onDismiss={() => toast.dismiss(t.id)}
/>
),
{ duration: 6000 },
);
});
return (
<>
<NotificationBell
unreadCount={unreadCount}
onClick={() => setOpened(true)}
/>
<NotificationDrawer
opened={opened}
onClose={() => setOpened(false)}
unread={unreadItems}
read={readItems}
unreadCount={unreadCount}
loading={opened && (unreadQ.isLoading || readQ.isLoading)}
hasMoreUnread={unreadQ.hasNextPage}
hasMoreRead={readQ.hasNextPage}
loadingMoreUnread={unreadQ.isFetchingNextPage}
loadingMoreRead={readQ.isFetchingNextPage}
onLoadMoreUnread={() => {
if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) {
void unreadQ.fetchNextPage();
}
}}
onLoadMoreRead={() => {
if (readQ.hasNextPage && !readQ.isFetchingNextPage) {
void readQ.fetchNextPage();
}
}}
onItemClick={handleItemClick}
onMarkAllRead={() => markAllRead.mutate()}
resolveVisual={resolveNotificationVisual}
/>
</>
);
}

View File

@@ -0,0 +1,73 @@
import { NotificationType } from "@edr/types";
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
import { Bell, ClipboardCheck, FileSignature, Inbox, Wallet } from "lucide-react";
const ICON_SIZE = 17;
/**
* Backoffice notification registry. Maps a notification `type` → icon + Mantine
* color, and `type`/`data` → an in-app deep link. This is the single place to
* customize how each staff-facing notification looks and where it goes.
*/
export function resolveNotificationVisual(
item: NotificationItemData,
): NotificationVisual {
switch (item.type) {
case NotificationType.REQUEST_SUBMITTED:
return { icon: <Inbox size={ICON_SIZE} />, color: "blue" };
case NotificationType.PAYMENT_RECEIVED:
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
case NotificationType.CLEARANCE_REVIEW:
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
case NotificationType.CONTRACT_STATUS:
return { icon: <FileSignature size={ICON_SIZE} />, color: "indigo" };
default:
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
}
}
function asId(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/**
* Resolve where clicking a notification navigates. Prefers an explicit
* server-provided `link`, else derives a `/dashboard/*` route from `type` +
* `data`. Returns `null` when there's nowhere sensible to go.
*/
export function resolveNotificationHref(
item: NotificationItemData,
): string | null {
if (item.link) return item.link;
const data = item.data ?? {};
switch (item.type) {
case NotificationType.REQUEST_SUBMITTED: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/dashboard/booking-requests/${bookingId}`;
const contractId = asId(data.contractId);
if (contractId) return `/dashboard/contract-requests/${contractId}`;
return "/dashboard/booking-requests";
}
case NotificationType.PAYMENT_RECEIVED: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
const id = asId(data.customerId);
return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
}
case NotificationType.CLEARANCE_REVIEW: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
const contractId = asId(data.contractId);
if (contractId) return `/dashboard/contracts/clearance/${contractId}`;
return "/dashboard/arrival-queue";
}
case NotificationType.CONTRACT_STATUS: {
const contractId = asId(data.contractId);
return contractId
? `/dashboard/contract-requests/${contractId}`
: "/dashboard/contract-requests";
}
default:
return null;
}
}

View File

@@ -0,0 +1,37 @@
import type { NotificationListResult } from "@edr/types";
import { api } from "@/auth/http";
export interface ListNotificationsParams {
page?: number;
limit?: number;
isRead?: boolean;
}
/**
* Backoffice notification REST calls. The backoffice axios `api` response
* interceptor already unwraps the `{ success, data }` envelope, so `.data` here
* is the payload itself.
*/
export const notificationsApi = {
list: async (
params: ListNotificationsParams = {},
): Promise<NotificationListResult> => {
const { data } = await api.get<NotificationListResult>("/notifications", {
params,
});
return data;
},
unreadCount: async (): Promise<number> => {
const { data } = await api.get<{ unreadCount: number }>(
"/notifications/unread-count",
);
return data.unreadCount;
},
markRead: async (id: string): Promise<void> => {
await api.patch(`/notifications/${id}/read`);
},
markAllRead: async (): Promise<void> => {
await api.post("/notifications/read-all");
},
};

View File

@@ -0,0 +1,58 @@
import {
NOTIFICATION_WS_EVENTS,
NOTIFICATION_WS_NAMESPACE,
type NotificationDto,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications";
// The socket namespace lives at the server root, not under the `/api` REST
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
/**
* Subscribes to live notification pushes for the signed-in staff user. New
* items invalidate the cached lists + fire `onNew` (the host shows a rich
* toast); unread-count pushes update the badge.
*/
export function useNotificationSocket(
enabled: boolean,
onNew?: (notification: NotificationDto) => void,
) {
const qc = useQueryClient();
const onNewRef = useRef(onNew);
onNewRef.current = onNew;
useEffect(() => {
if (!enabled) return;
const token = getCookie(AUTH_TOKEN_COOKIE);
if (!token) return;
const socket = io(`${SOCKET_ORIGIN}/${NOTIFICATION_WS_NAMESPACE}`, {
auth: { token },
transports: ["websocket"],
withCredentials: true,
});
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
qc.invalidateQueries({ queryKey: UNREAD_KEY });
onNewRef.current?.(n);
});
socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => {
if (typeof count === "number") qc.setQueryData(UNREAD_KEY, count);
});
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -0,0 +1,64 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { notificationsApi } from "./notificationsApi";
export const NOTIFICATIONS_KEY = ["notifications"] as const;
export const UNREAD_KEY = ["notifications", "unread"] as const;
const PAGE_SIZE = 20;
/**
* Paginated (infinite) notifications for one read-state. Drives a drawer
* section; call `fetchNextPage` as the user scrolls. Each page carries the
* server `count` so we know when to stop.
*/
export function useInfiniteNotifications(isRead: boolean, enabled = true) {
return useInfiniteQuery({
queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }],
queryFn: ({ pageParam }) =>
notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0);
return loaded < lastPage.count ? allPages.length + 1 : undefined;
},
enabled,
});
}
export function useUnreadCount(enabled = true) {
return useQuery({
queryKey: UNREAD_KEY,
queryFn: () => notificationsApi.unreadCount(),
enabled,
// WebSocket keeps this fresh; poll as a fallback if the socket drops.
refetchInterval: 60_000,
});
}
export function useMarkRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => notificationsApi.markRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
qc.invalidateQueries({ queryKey: UNREAD_KEY });
},
});
}
export function useMarkAllRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => notificationsApi.markAllRead(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
qc.invalidateQueries({ queryKey: UNREAD_KEY });
},
});
}

View File

@@ -4,6 +4,7 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const FREIGHT_PERMS = {
bookings: {
view: "edr_freight_app:bookings:view",
create: "edr_freight_app:bookings:create",
clearanceView: "edr_freight_app:bookings:clearance_view",
staffAccept: "edr_freight_app:bookings:staff_accept",
requestChanges: "edr_freight_app:bookings:request_changes",
@@ -41,6 +42,11 @@ export const FREIGHT_PERMS = {
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",
manage: "edr_freight_app:train_scheduling:manage",
create: "edr_freight_app:train_scheduling:create",
update: "edr_freight_app:train_scheduling:update",
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
},
fleet: {
view: "edr_freight_app:fleet:view",
@@ -50,6 +56,243 @@ export const FREIGHT_PERMS = {
allocation: {
manage: "edr_freight_app:allocation:manage",
},
customers: {
view: "edr_freight_app:customers:view",
create: "edr_freight_app:customers:create",
update: "edr_freight_app:customers:update",
deactivate: "edr_freight_app:customers:deactivate",
verify: "edr_freight_app:customers:verify",
},
payments: {
view: "edr_freight_app:payments:view",
verify: "edr_freight_app:payments:verify",
refund: "edr_freight_app:payments:refund",
},
invoices: {
view: "edr_freight_app:invoices:view",
create: "edr_freight_app:invoices:create",
cancel: "edr_freight_app:invoices:cancel",
export: "edr_freight_app:invoices:export",
},
firstMile: {
view: "edr_freight_app:first_mile:view",
accept: "edr_freight_app:first_mile:accept",
create: "edr_freight_app:first_mile:create",
update: "edr_freight_app:first_mile:update",
delete: "edr_freight_app:first_mile:delete",
assignVehicles: "edr_freight_app:first_mile:assign_vehicles",
setDistances: "edr_freight_app:first_mile:set_distances",
generateInvoice: "edr_freight_app:first_mile:generate_invoice",
},
lastMile: {
view: "edr_freight_app:last_mile:view",
accept: "edr_freight_app:last_mile:accept",
create: "edr_freight_app:last_mile:create",
update: "edr_freight_app:last_mile:update",
delete: "edr_freight_app:last_mile:delete",
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
setDistances: "edr_freight_app:last_mile:set_distances",
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
},
locomotives: {
view: "edr_freight_app:locomotives:view",
create: "edr_freight_app:locomotives:create",
update: "edr_freight_app:locomotives:update",
delete: "edr_freight_app:locomotives:delete",
},
wagons: {
view: "edr_freight_app:wagons:view",
create: "edr_freight_app:wagons:create",
update: "edr_freight_app:wagons:update",
delete: "edr_freight_app:wagons:delete",
},
trains: {
view: "edr_freight_app:trains:view",
create: "edr_freight_app:trains:create",
update: "edr_freight_app:trains:update",
delete: "edr_freight_app:trains:delete",
assignWagons: "edr_freight_app:trains:assign_wagons",
},
routes: {
view: "edr_freight_app:routes:view",
create: "edr_freight_app:routes:create",
update: "edr_freight_app:routes:update",
delete: "edr_freight_app:routes:delete",
},
containers: {
view: "edr_freight_app:containers:view",
create: "edr_freight_app:containers:create",
update: "edr_freight_app:containers:update",
delete: "edr_freight_app:containers:delete",
},
cargoes: {
view: "edr_freight_app:cargoes:view",
create: "edr_freight_app:cargoes:create",
update: "edr_freight_app:cargoes:update",
delete: "edr_freight_app:cargoes:delete",
},
vehicles: {
view: "edr_freight_app:vehicles:view",
create: "edr_freight_app:vehicles:create",
update: "edr_freight_app:vehicles:update",
delete: "edr_freight_app:vehicles:delete",
},
drivers: {
view: "edr_freight_app:drivers:view",
create: "edr_freight_app:drivers:create",
update: "edr_freight_app:drivers:update",
delete: "edr_freight_app:drivers:delete",
},
tracking: {
view: "edr_freight_app:tracking:view",
},
fuel: {
view: "edr_freight_app:fuel:view",
create: "edr_freight_app:fuel:create",
update: "edr_freight_app:fuel:update",
delete: "edr_freight_app:fuel:delete",
approve: "edr_freight_app:fuel:approve",
},
maintenance: {
view: "edr_freight_app:maintenance:view",
create: "edr_freight_app:maintenance:create",
update: "edr_freight_app:maintenance:update",
delete: "edr_freight_app:maintenance:delete",
complete: "edr_freight_app:maintenance:complete",
},
fleetReports: {
view: "edr_freight_app:fleet_reports:view",
export: "edr_freight_app:fleet_reports:export",
},
fleetDashboard: {
view: "edr_freight_app:fleet_dashboard:view",
},
warehouseDashboard: {
view: "edr_freight_app:warehouse_dashboard:view",
},
warehouses: {
view: "edr_freight_app:warehouses:view",
create: "edr_freight_app:warehouses:create",
update: "edr_freight_app:warehouses:update",
delete: "edr_freight_app:warehouses:delete",
},
warehouseYards: {
view: "edr_freight_app:warehouse_yards:view",
create: "edr_freight_app:warehouse_yards:create",
update: "edr_freight_app:warehouse_yards:update",
delete: "edr_freight_app:warehouse_yards:delete",
},
warehouseZones: {
view: "edr_freight_app:warehouse_zones:view",
create: "edr_freight_app:warehouse_zones:create",
update: "edr_freight_app:warehouse_zones:update",
},
warehouseAllocationRules: {
view: "edr_freight_app:warehouse_allocation_rules:view",
create: "edr_freight_app:warehouse_allocation_rules:create",
update: "edr_freight_app:warehouse_allocation_rules:update",
delete: "edr_freight_app:warehouse_allocation_rules:delete",
},
warehouseFeeRules: {
view: "edr_freight_app:warehouse_fee_rules:view",
create: "edr_freight_app:warehouse_fee_rules:create",
update: "edr_freight_app:warehouse_fee_rules:update",
delete: "edr_freight_app:warehouse_fee_rules:delete",
},
warehouseInspectionReports: {
view: "edr_freight_app:warehouse_inspection_reports:view",
create: "edr_freight_app:warehouse_inspection_reports:create",
update: "edr_freight_app:warehouse_inspection_reports:update",
},
warehouseInventory: {
view: "edr_freight_app:warehouse_inventory:view",
receive: "edr_freight_app:warehouse_inventory:receive",
move: "edr_freight_app:warehouse_inventory:move",
load: "edr_freight_app:warehouse_inventory:load",
unload: "edr_freight_app:warehouse_inventory:unload",
dispatch: "edr_freight_app:warehouse_inventory:dispatch",
gatePass: "edr_freight_app:warehouse_inventory:gate_pass",
release: "edr_freight_app:warehouse_inventory:release",
deliver: "edr_freight_app:warehouse_inventory:deliver",
inspect: "edr_freight_app:warehouse_inventory:inspect",
},
interchangeDocuments: {
view: "edr_freight_app:interchange_documents:view",
generate: "edr_freight_app:interchange_documents:generate",
acknowledge: "edr_freight_app:interchange_documents:acknowledge",
dispute: "edr_freight_app:interchange_documents:dispute",
cancel: "edr_freight_app:interchange_documents:cancel",
},
warehouseFeeInvoices: {
view: "edr_freight_app:warehouse_fee_invoices:view",
generate: "edr_freight_app:warehouse_fee_invoices:generate",
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
config: {
contractValidity: {
view: "edr_freight_app:config:contract_validity:view",
manage: "edr_freight_app:config:contract_validity:manage",
},
},
settings: {
fileUpload: {
view: "edr_freight_app:settings:file_upload:view",
manage: "edr_freight_app:settings:file_upload:manage",
},
dropdown: {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
},
staff: {
roles: {
view: "edr_freight_app:staff:roles:view",
create: "edr_freight_app:staff:roles:create",
update: "edr_freight_app:staff:roles:update",
delete: "edr_freight_app:staff:roles:delete",
},
permissions: {
view: "edr_freight_app:staff:permissions:view",
assign: "edr_freight_app:staff:permissions:assign",
},
employeeRegistration: {
view: "edr_freight_app:employee_registration:view",
create: "edr_freight_app:employee_registration:create",
update: "edr_freight_app:employee_registration:update",
activate: "edr_freight_app:employee_registration:activate",
deactivate: "edr_freight_app:employee_registration:deactivate",
},
roleAssignment: {
view: "edr_freight_app:role_assignment:view",
assign: "edr_freight_app:role_assignment:assign",
replace: "edr_freight_app:role_assignment:replace",
},
hierarchyUnits: {
view: "edr_freight_app:hierarchy_units:view",
create: "edr_freight_app:hierarchy_units:create",
update: "edr_freight_app:hierarchy_units:update",
delete: "edr_freight_app:hierarchy_units:delete",
},
hierarchyPositions: {
view: "edr_freight_app:hierarchy_positions:view",
create: "edr_freight_app:hierarchy_positions:create",
update: "edr_freight_app:hierarchy_positions:update",
delete: "edr_freight_app:hierarchy_positions:delete",
changeParent: "edr_freight_app:hierarchy_positions:change_parent",
},
hierarchyEmployeeAssignment: {
view: "edr_freight_app:hierarchy_employee_assignment:view",
invite: "edr_freight_app:hierarchy_employee_assignment:invite",
assign: "edr_freight_app:hierarchy_employee_assignment:assign",
},
positionTypes: {
view: "edr_freight_app:position_types:view",
create: "edr_freight_app:position_types:create",
update: "edr_freight_app:position_types:update",
delete: "edr_freight_app:position_types:delete",
},
},
} as const;
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>

View File

@@ -60,3 +60,5 @@ createRoot(rootElement).render(
</MantineProvider>
</QueryClientProvider>
);
// run

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

@@ -1,9 +1,10 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Container,
@@ -17,11 +18,18 @@ import {
Timeline,
Title,
} from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import type { IFileUploadSetting } from "@edr/types/freight";
import {
ArrowLeft,
Download,
Eye,
FileText,
History,
Route,
ShieldCheck,
Trash2,
Upload,
Truck,
User,
} from "lucide-react";
@@ -29,6 +37,8 @@ import {
import { driversService } from "@/services/drivers.service";
import { vehiclesService } from "@/services/vehicles.service";
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
import { useToast } from "@/hooks/use-toast";
const fmtDate = (iso?: string | null) => {
if (!iso) return "—";
@@ -55,6 +65,178 @@ const Loading = () => (
<Center py="xl"><Loader size="sm" /></Center>
);
const fmtSize = (bytes: number) => {
if (!bytes) return "—";
const kb = bytes / 1024;
return kb < 1024 ? `${kb.toFixed(0)} KB` : `${(kb / 1024).toFixed(1)} MB`;
};
/** Upload-area setting code configured on the File Settings page. */
const DRIVER_DOCS_CODE = "driver_docs";
// Field key the FALLBACK setting is keyed on (used only when the "driver_docs"
// upload area hasn't been configured in File Settings yet).
const DRIVER_DOCS_KEY = "driver_docs";
/** Fallback single-field setting so the dropzone still works before an admin
* configures the "driver_docs" area in File Settings. */
const DRIVER_DOCS_FALLBACK: IFileUploadSetting = {
id: "driver-docs-setting",
createdAt: "",
updatedAt: "",
deletedAt: null,
code: "driver_docs",
label: "Driver documents",
description: null,
entity: "other",
fields: [
{
id: "driver-docs-field",
createdAt: "",
updatedAt: "",
deletedAt: null,
settingId: "driver-docs-setting",
fileKey: DRIVER_DOCS_KEY,
fileLabel: "Upload driver document(s)",
helpText: "License, national ID, contracts, training certificates, etc.",
isRequired: false,
isMultiple: true,
maxFiles: 20,
allowedExtensions: ["pdf", "png", "jpg", "jpeg", "doc", "docx"],
maxSizeMb: 10,
order: 1,
},
],
};
/** Driver documents upload + view area (files stored under code "driver_docs"). */
const DriverDocuments = ({ driverId }: { driverId: string }) => {
const { toast } = useToast();
const qc = useQueryClient();
// Files selected per configured field key (SmartFileInput is multi-field).
const [selectedMap, setSelectedMap] = useState<Record<string, File | File[] | null>>({});
const selectedFiles = Object.values(selectedMap).flatMap((v) =>
Array.isArray(v) ? v : v ? [v] : [],
);
// Upload-area configuration from the File Settings page (code "driver_docs").
// Falls back to a default field until an admin configures it there.
const { data: setting } = useQuery({
queryKey: ["file-upload-setting", DRIVER_DOCS_CODE],
queryFn: () => fileUploadSettingsService.getByCode(DRIVER_DOCS_CODE),
retry: false,
});
const activeSetting = setting ?? DRIVER_DOCS_FALLBACK;
const { data: docs = [], isLoading } = useQuery({
queryKey: ["driver", driverId, "documents"],
queryFn: () => driversService.listDocuments(driverId).then((r) => r.data ?? []),
enabled: Boolean(driverId),
});
const uploadMutation = useMutation({
mutationFn: (files: File[]) => driversService.uploadDocuments(driverId, files),
onSuccess: () => {
toast({ title: "Documents uploaded" });
void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] });
},
onError: (err: unknown) => {
const description =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Upload failed";
toast({ title: "Upload failed", description, variant: "destructive" });
},
});
const removeMutation = useMutation({
mutationFn: (fileId: string) => driversService.removeDocument(driverId, fileId),
onSuccess: () => {
toast({ title: "Document deleted" });
void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] });
},
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
});
// /files/:id is a public inline-serving route; open directly for preview/download.
const fileUrl = (fileId: string, download = false) =>
`${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`;
return (
<Card withBorder padding="lg" radius="md">
<Stack gap="md" mb="lg">
<Text fw={600} size="sm">{activeSetting.label ?? "Upload documents"}</Text>
<SmartFileInput
file={activeSetting}
value={selectedMap}
onChange={setSelectedMap}
/>
<Group justify="flex-end">
<Button
size="xs"
leftSection={<Upload size={14} />}
loading={uploadMutation.isPending}
disabled={selectedFiles.length === 0}
onClick={() =>
uploadMutation.mutate(selectedFiles, { onSuccess: () => setSelectedMap({}) })
}
>
Upload {selectedFiles.length > 0 ? `(${selectedFiles.length})` : ""}
</Button>
</Group>
</Stack>
<Text fw={600} size="sm" mb="sm">Uploaded documents ({docs.length})</Text>
{isLoading ? (
<Loading />
) : docs.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" py="md">No documents uploaded yet.</Text>
) : (
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Uploaded</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{docs.map((doc) => (
<Table.Tr key={doc.id}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<FileText size={15} />
<Text size="sm" truncate>{doc.name}</Text>
</Group>
</Table.Td>
<Table.Td>{fmtSize(doc.size)}</Table.Td>
<Table.Td>{fmtDate(doc.createdAt)}</Table.Td>
<Table.Td>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" aria-label="View" onClick={() => window.open(fileUrl(doc.id), "_blank")}>
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" aria-label="Download" onClick={() => window.open(fileUrl(doc.id, true), "_blank")}>
<Download size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label="Delete"
loading={removeMutation.isPending && removeMutation.variables === doc.id}
onClick={() => removeMutation.mutate(doc.id)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
);
};
const DriverDetailPage = () => {
const { id = "" } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -106,6 +288,7 @@ const DriverDetailPage = () => {
<Tabs.Tab value="vehicles" leftSection={<Truck size={14} />}>Vehicles</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
<Tabs.Tab value="trips" leftSection={<Route size={14} />}>Trips</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={14} />}>Documents</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="lg">
@@ -149,6 +332,10 @@ const DriverDetailPage = () => {
<Tabs.Panel value="trips" pt="lg">
<TripsTab driverId={id} />
</Tabs.Panel>
<Tabs.Panel value="documents" pt="lg">
<DriverDocuments driverId={id} />
</Tabs.Panel>
</Tabs>
)}
</Container>

View File

@@ -393,7 +393,7 @@ const FleetResourcePage = () => {
<Group key={filter.key} gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">{filter.label}:</Text>
<Group gap={4} wrap="wrap">
{[{ value: "ALL", label: "All" }, ...filter.data].map((option) => (
{filter.data.map((option) => (
<Button
key={option.value}
size="xs"

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

@@ -1,368 +1,500 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, SimpleGrid } from '@mantine/core';
import { MapPin, Navigation, Radio, Activity } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { vehiclesService } from '@/services/vehicles.service';
import { freightBrand } from '@/theme/freight-brand';
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Grid,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
} from "@mantine/core";
import {
APIProvider,
InfoWindow,
Map as GoogleMap,
Marker,
useMap,
} from "@vis.gl/react-google-maps";
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
interface Vehicle {
id: string;
registrationNumber: string;
plateNumber: string;
manufacturer: string;
model: string;
status?: string;
// Same default key + env override the portal's LocationPicker uses.
const GOOGLE_MAPS_API_KEY =
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
const toNum = (v: number | string | null | undefined): number | null =>
v == null || v === "" ? null : Number(v);
const deviceLabel = (d: GpsDevice) =>
d.vehicle
? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ")
: d.name || d.imei;
const fmtTime = (iso?: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
};
const StatBox = ({ label, value }: { label: string; value: string }) => (
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
<Text size="xs" c="dimmed">{label}</Text>
<Text fw={600} size="sm">{value}</Text>
</Box>
);
type LatLng = { lat: number; lng: number };
/** Flip a flag once the map (and thus the Maps JS classes) is loaded. */
function ReadyProbe({ onReady }: { onReady: () => void }) {
const map = useMap();
useEffect(() => {
if (map) onReady();
}, [map, onReady]);
return null;
}
interface GPSLocation {
/** Fit the map to the current markers (or center on a single one). */
function FitBounds({ points }: { points: LatLng[] }) {
const map = useMap();
useEffect(() => {
if (!map || points.length === 0 || typeof google === "undefined") return;
if (points.length === 1) {
map.setCenter(points[0]);
map.setZoom(14);
return;
}
const b = new google.maps.LatLngBounds();
points.forEach((p) => b.extend(p));
map.fitBounds(b, 60);
}, [map, points]);
return null;
}
/** Lazily reverse-geocode a coordinate to a human address. */
function useAddress(lat: number, lng: number): string | null {
const [addr, setAddr] = useState<string | null>(null);
useEffect(() => {
if (typeof google === "undefined" || !google.maps?.Geocoder) return;
setAddr(null);
let cancelled = false;
new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => {
if (cancelled) return;
setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location");
});
return () => { cancelled = true; };
}, [lat, lng]);
return addr;
}
/** Hover popup: label, coordinates, speed/course, and the reverse-geocoded place. */
function HoverInfo({
device,
lat,
lng,
onClose,
}: {
device: GpsDevice;
lat: number;
lng: number;
speed?: number;
heading?: number;
lastUpdate?: string;
onClose: () => void;
}) {
const address = useAddress(lat, lng);
return (
<InfoWindow position={{ lat, lng }} pixelOffset={[0, -46]} onCloseClick={onClose}>
<div style={{ minWidth: 190, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{deviceLabel(device)}</div>
<div style={{ fontFamily: "monospace" }}>
{lat.toFixed(5)}, {lng.toFixed(5)}
</div>
<div style={{ color: "#555" }}>
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
</div>
<div style={{ color: "#777", marginTop: 4 }}>{address ?? "Locating…"}</div>
</div>
</InfoWindow>
);
}
// Mock GPS data for demo (no real GPS backend exists — these are simulated values)
const generateMockGPS = (): GPSLocation => ({
lat: 9.0 + Math.random() * 0.5,
lng: 38.7 + Math.random() * 0.5,
speed: Math.floor(Math.random() * 120),
heading: Math.floor(Math.random() * 360),
lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(),
});
/** Draw the selected vehicle's recent path as a polyline. */
function RouteTrail({ path }: { path: LatLng[] }) {
const map = useMap();
useEffect(() => {
if (!map || path.length < 2 || typeof google === "undefined") return;
const line = new google.maps.Polyline({
path,
strokeColor: freightBrand.primary,
strokeOpacity: 0.85,
strokeWeight: 4,
});
line.setMap(map);
return () => line.setMap(null);
}, [map, path]);
return null;
}
export function TrackingPage() {
const [selectedVehicleId, setSelectedVehicleId] = useState<string | null>(null);
const [mapCenter] = useState({ lat: 9.0, lng: 38.8 });
const { toast } = useToast();
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [hoverId, setHoverId] = useState<string | null>(null);
const [mapsReady, setMapsReady] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editDevice, setEditDevice] = useState<GpsDevice | null>(null);
const [form, setForm] = useState({ imei: "", name: "", vehicleId: "" });
const { data: vehicles = [] } = useQuery({
queryKey: QUERY_KEYS.VEHICLES.list(),
queryFn: async () => {
const res = await vehiclesService.getAll({ limit: 1000 });
return res.data || [];
const openRegister = () => {
setEditDevice(null);
setForm({ imei: "", name: "", vehicleId: "" });
setModalOpen(true);
};
const openEdit = (d: GpsDevice) => {
setEditDevice(d);
setForm({ imei: d.imei, name: d.name ?? "", vehicleId: d.vehicleId ?? "" });
setModalOpen(true);
};
// Poll every 10s so the map tracks live movement.
const { data: devices = [] } = useQuery({
queryKey: ["gps", "devices"],
queryFn: async () => (await gpsTrackingService.listDevices()).data ?? [],
refetchInterval: 10_000,
});
const { data: vehiclesData } = useQuery({
queryKey: ["vehicles", "all"],
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
});
const vehicleOptions = useMemo(
() =>
(vehiclesData ?? []).map((v) => ({
value: v.id,
label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`,
})),
[vehiclesData],
);
const positioned = useMemo(
() =>
devices
.map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) }))
.filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null),
[devices],
);
const selected = devices.find((d) => d.id === selectedId) ?? null;
const onlineCount = devices.filter((d) => d.online).length;
// Route history for the selected device's vehicle (chronological trail).
const { data: history = [] } = useQuery({
queryKey: ["gps", "history", selected?.vehicleId],
queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
enabled: Boolean(selected?.vehicleId),
});
const trail = useMemo(
() => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
[history],
);
// Teardrop pin colored by state with a white truck glyph inside.
const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => {
// Maps API loads async — Size/Point classes may not exist yet at first render.
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined;
const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48">
<path d="M20 2C10 2 2 10 2 20c0 12 18 26 18 26s18-14 18-26C38 10 30 2 20 2Z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
<g transform="translate(8,7)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 18V6a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h1"/>
<path d="M15 18H9"/>
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/>
<circle cx="7" cy="18" r="2"/>
<circle cx="17" cy="18" r="2"/>
</g>
</svg>`;
return {
url: `data:image/svg+xml,${encodeURIComponent(svg)}`,
scaledSize: new google.maps.Size(40, 48),
anchor: new google.maps.Point(20, 48),
};
};
const saveMutation = useMutation({
mutationFn: () =>
editDevice
? gpsTrackingService.update(editDevice.id, {
name: form.name.trim() || undefined,
vehicleId: form.vehicleId || null,
})
: gpsTrackingService.register({
imei: form.imei.trim(),
name: form.name.trim() || undefined,
vehicleId: form.vehicleId || null,
}),
onSuccess: () => {
toast({ title: editDevice ? "Tracker updated" : "Tracker registered" });
setModalOpen(false);
setEditDevice(null);
setForm({ imei: "", name: "", vehicleId: "" });
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: (err: unknown) => {
const description =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed";
toast({ title: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" });
},
});
// Generate mock GPS data for each vehicle
const vehiclesWithGPS = useMemo(() => {
return (vehicles as Vehicle[]).map((v) => ({
...v,
gps: generateMockGPS(),
}));
}, [vehicles]);
const assignMutation = useMutation({
mutationFn: ({ id, vehicleId }: { id: string; vehicleId: string | null }) =>
gpsTrackingService.update(id, { vehicleId }),
onSuccess: () => {
toast({ title: "Tracker updated" });
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: () => toast({ title: "Update failed", variant: "destructive" }),
});
// For demo: show all vehicles as trackable (or filter by ACTIVE if status data available)
const trackableVehicles = useMemo(
() => vehiclesWithGPS.slice(0, 10), // Limit to first 10 for demo
[vehiclesWithGPS]
);
const selectedVehicle = trackableVehicles.find(v => v.id === selectedVehicleId);
const vehicleOptions = useMemo(
() => trackableVehicles.map(v => ({ label: v.registrationNumber, value: v.id })),
[trackableVehicles]
);
// Map dimensions
const mapWidth = 800;
const mapHeight = 500;
const pixelsPerLat = mapHeight / 0.6;
const pixelsPerLng = mapWidth / 0.6;
const getMapCoords = (lat: number, lng: number) => ({
x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng),
y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat),
const deleteMutation = useMutation({
mutationFn: (id: string) => gpsTrackingService.remove(id),
onSuccess: () => {
toast({ title: "Tracker removed" });
setSelectedId(null);
void qc.invalidateQueries({ queryKey: ["gps", "devices"] });
},
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
});
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Vehicle Tracking' }]} />
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
<Stack gap="xl">
<Group justify="space-between">
<div>
<Group gap="xs" align="center">
<Text fw={700} size="xl">
Real-Time Vehicle Tracking
</Text>
<Badge color="yellow" variant="light">
Simulated GPS
</Badge>
</Group>
<Text c="dimmed" size="sm">
Monitor vehicle locations, speed, and status
</Text>
</div>
</Group>
<Group justify="space-between" mb="xl">
<div>
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
</div>
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
</Group>
<Grid>
{/* Map Section */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder p="lg">
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Map View</Text>
<Group gap="xs">
<Badge color="edr-green" leftSection={<Radio size={12} />}>
{trackableVehicles.length} Tracked
</Badge>
</Group>
</Group>
</Card.Section>
<Card.Section p="md">
<Box style={{ overflowX: 'auto', maxWidth: '100%' }}>
<Box
pos="relative"
style={{
width: mapWidth,
height: mapHeight,
backgroundColor: '#f0f8f7',
border: `2px solid ${freightBrand.primary}`,
borderRadius: '8px',
overflow: 'hidden',
}}
<Grid>
{/* Map */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Card withBorder p="lg">
<Card.Section p="md" withBorder>
<Group justify="space-between">
<Text fw={500}>Live Map</Text>
<Badge color="edr-green" leftSection={<Radio size={12} />}>
{onlineCount} online · {positioned.length} located
</Badge>
</Group>
</Card.Section>
<Card.Section p="md">
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<GoogleMap
defaultCenter={DEFAULT_CENTER}
defaultZoom={7}
gestureHandling="greedy"
disableDefaultUI={false}
style={{ width: "100%", height: "100%" }}
>
{/* Grid background */}
<svg
width={mapWidth}
height={mapHeight}
style={{ position: 'absolute', top: 0, left: 0 }}
>
{/* Latitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lat-${i}`}
x1={0}
y1={(i / 6) * mapHeight}
x2={mapWidth}
y2={(i / 6) * mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
<ReadyProbe onReady={() => setMapsReady(true)} />
{positioned.map(({ d, lat, lng }) => (
<Marker
key={d.id}
position={{ lat, lng }}
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
icon={markerIcon(d, d.id === selectedId)}
onClick={() => setSelectedId(d.id)}
onMouseOver={() => setHoverId(d.id)}
/>
))}
{/* Longitude lines */}
{[0, 1, 2, 3, 4, 5, 6].map(i => (
<line
key={`lng-${i}`}
x1={(i / 6) * mapWidth}
y1={0}
x2={(i / 6) * mapWidth}
y2={mapHeight}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
</svg>
{/* Vehicle markers */}
{trackableVehicles.map((vehicle) => {
const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng);
const isSelected = vehicle.id === selectedVehicleId;
return (
<Box
key={vehicle.id}
pos="absolute"
style={{
left: coords.x - 15,
top: coords.y - 15,
width: 30,
height: 30,
cursor: 'pointer',
zIndex: isSelected ? 100 : 10,
}}
onClick={() => setSelectedVehicleId(vehicle.id)}
title={vehicle.registrationNumber}
>
<Box
pos="absolute"
inset={0}
style={{
backgroundColor: isSelected ? freightBrand.primary : '#3498db',
borderRadius: '50%',
border: isSelected ? `3px solid ${freightBrand.primaryDark}` : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: '16px',
boxShadow: isSelected ? `0 0 0 8px ${freightBrand.ring}` : 'none',
}}
>
<Navigation size={16} />
</Box>
</Box>
);
})}
{/* Map labels */}
<Box pos="absolute" bottom={8} left={8} style={{ zIndex: 50 }}>
<Text size="xs" c="dimmed">
📍 Addis Ababa, Ethiopia
</Text>
</Box>
</Box>
</Box>
<Text size="xs" c="dimmed" mt="xs">
Simulated map coordinates, speed, and heading are demo values, not live GPS.
{(() => {
const h = positioned.find((p) => p.d.id === hoverId);
return h ? (
<HoverInfo device={h.d} lat={h.lat} lng={h.lng} onClose={() => setHoverId(null)} />
) : null;
})()}
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
</GoogleMap>
</APIProvider>
</Box>
{positioned.length === 0 && (
<Text size="sm" c="dimmed" ta="center" mt="sm">
No located trackers yet waiting for GPS fixes.
</Text>
</Card.Section>
</Card>
</Grid.Col>
{/* Sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{/* Vehicle Selector */}
<Card withBorder p="lg">
<Stack gap="md">
<Select
label="Track Vehicle"
placeholder="Select a vehicle to track"
data={vehicleOptions}
value={selectedVehicleId}
onChange={setSelectedVehicleId}
searchable
/>
{selectedVehicle && (
<Box p="md" style={{ backgroundColor: freightBrand.mutedBg, borderRadius: '8px' }}>
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed">
Registration
</Text>
<Text fw={600}>{selectedVehicle.registrationNumber}</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Vehicle
</Text>
<Text fw={600}>
{selectedVehicle.manufacturer} {selectedVehicle.model}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
Status
</Text>
<Badge color={selectedVehicle.status === 'ACTIVE' ? 'edr-green' : 'gray'}>
{selectedVehicle.status || 'Unknown'}
</Badge>
</div>
</Stack>
</Box>
)}
</Stack>
</Card>
{/* GPS Details */}
{selectedVehicle && (
<Card withBorder p="lg">
<Stack gap="md">
<Group justify="space-between">
<Text fw={500}>GPS Location</Text>
<Badge color="edr-green" leftSection={<Activity size={12} />}>
Live
</Badge>
</Group>
<SimpleGrid cols={2} spacing="sm">
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Latitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lat.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Longitude
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.lng.toFixed(4)}°
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Speed
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.speed} km/h
</Text>
</Box>
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<Text size="xs" c="dimmed">
Heading
</Text>
<Text fw={600} size="sm">
{selectedVehicle.gps.heading}°
</Text>
</Box>
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">
Last Update
</Text>
<Text fw={500}>{selectedVehicle.gps.lastUpdate}</Text>
</div>
<Button color="edr-green" fullWidth leftSection={<MapPin size={16} />}>
View Full History
</Button>
</Stack>
</Card>
)}
</Card.Section>
</Card>
</Grid.Col>
{/* Tracked Vehicles List */}
{/* Sidebar */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="md">
{selected && (
<Card withBorder p="lg">
<Stack gap="md">
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
<Table>
<Table.Tbody>
{trackableVehicles.map(v => (
<Table.Tr
key={v.id}
style={{
cursor: 'pointer',
backgroundColor: v.id === selectedVehicleId ? freightBrand.mutedBg : 'transparent',
}}
onClick={() => setSelectedVehicleId(v.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>
{v.registrationNumber}
</Text>
<Text size="xs" c="dimmed">
{v.gps.speed} km/h
</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Badge
color={v.status === 'ACTIVE' ? 'edr-green' : 'gray'}
size="sm"
>
{v.status || 'N/A'}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group justify="space-between">
<Text fw={500}>{deviceLabel(selected)}</Text>
<Group gap="xs">
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
{selected.online ? "Live" : "Offline"}
</Badge>
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Group>
<SimpleGrid cols={2} spacing="sm">
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
</SimpleGrid>
<div>
<Text size="xs" c="dimmed">IMEI</Text>
<Text fw={500} size="sm">{selected.imei}</Text>
</div>
<div>
<Text size="xs" c="dimmed">Last fix</Text>
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
</div>
{selected.vehicleId && (
<Text size="xs" c="dimmed">
Showing last {trail.length} fixes as a route trail.
</Text>
)}
<Select
label="Assigned vehicle"
placeholder="Unassigned"
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
searchable
clearable
/>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Stack>
)}
<Card withBorder p="lg">
<Stack gap="md">
<Text fw={500}>Trackers ({devices.length})</Text>
<div style={{ maxHeight: 340, overflowY: "auto" }}>
<Table>
<Table.Tbody>
{devices.map((d) => (
<Table.Tr
key={d.id}
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
onClick={() => setSelectedId(d.id)}
>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
</Stack>
</Table.Td>
<Table.Td align="right">
<Group gap={6} justify="flex-end" wrap="nowrap">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
<Pencil size={15} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{devices.length === 0 && (
<Table.Tr>
<Table.Td colSpan={2}>
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</div>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
{/* Register / edit modal */}
<Modal
opened={modalOpen}
onClose={() => setModalOpen(false)}
title={editDevice ? "Edit GPS tracker" : "Register GPS tracker"}
radius="lg"
centered
>
<Stack gap="md">
<TextInput
label="IMEI"
placeholder="15-digit device IMEI"
required
disabled={Boolean(editDevice)}
value={form.imei}
onChange={(e) => setForm({ ...form, imei: e.currentTarget.value })}
/>
<TextInput
label="Name"
placeholder="Optional label"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.currentTarget.value })}
/>
<Select
label="Vehicle"
placeholder="Assign to a vehicle (optional)"
data={vehicleOptions}
value={form.vehicleId || null}
onChange={(v) => setForm({ ...form, vehicleId: v ?? "" })}
searchable
clearable
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button
loading={saveMutation.isPending}
disabled={!form.imei.trim()}
onClick={() => saveMutation.mutate()}
>
{editDevice ? "Save" : "Register"}
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}
export default TrackingPage;

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

@@ -29,6 +29,11 @@ const VEHICLE_AVAILABILITY_OPTIONS = [
{ label: "Busy", value: "BUSY" },
];
const CURRENCY_OPTIONS = [
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
];
export const vehiclesConfig: FleetResourceConfig = {
slug: "vehicles",
label: "Vehicles",
@@ -84,12 +89,14 @@ export const vehiclesConfig: FleetResourceConfig = {
{ name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" },
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
{ name: "pricePerKm", label: "Price per KM", type: "number" },
{ name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
{ name: "description", label: "Description", type: "textarea" },
],
emptyValues: {
code: "",
code: "03-ET",
plateNumber: "",
powerPlateNo: "",
trailerPlateNo: "",
@@ -102,6 +109,8 @@ export const vehiclesConfig: FleetResourceConfig = {
locationId: null,
estimatedDistanceKm: "",
actualDistanceKm: "",
pricePerKm: "",
currency: "ETB",
status: "ACTIVE",
availability: "FREE",
description: "",

View File

@@ -20,7 +20,7 @@ import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
ActionIcon,
Autocomplete,
Alert,
Badge,
Box,
Button,
@@ -54,7 +54,6 @@ import {
} from "@/services/first-mile.service";
import { bookingsService } from "@/services/bookings.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import type { BookingDetail } from "@/types/booking";
const formatPrice = (amount: number | string | null | undefined, currency = "ETB") =>
@@ -191,7 +190,24 @@ const isPostPaymentPending = (r: FirstMileRecord) =>
// Map API record → display fields used in modals and trip slip
const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId;
const currencyOf = (r: FirstMileRecord) => r.booking?.paymentCurrency ?? "ETB";
const currencyOf = (r: FirstMileRecord) =>
r.vehicle?.currency ??
r.vehicleAssignments?.[0]?.vehicle?.currency ??
r.booking?.paymentCurrency ??
"ETB";
type FmAssignment = NonNullable<FirstMileRecord["vehicleAssignments"]>[number];
const truckShort = (a: FmAssignment) =>
a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */
const billingIssues = (r: FirstMileRecord) => {
const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0);
const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort);
const currencies = [
...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))),
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
const cargoDesc = (r: FirstMileRecord) => {
@@ -480,6 +496,8 @@ const FirstMilePage = () => {
const [distanceOpen, setDistanceOpen] = useState(false);
// Per-vehicle actual distance, keyed by vehicleId.
const [distanceRows, setDistanceRows] = useState<Record<string, string>>({});
// Record pending invoice-generation confirmation (shows a summary first).
const [invoiceConfirm, setInvoiceConfirm] = useState<FirstMileRecord | null>(null);
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
@@ -500,14 +518,6 @@ const FirstMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "FIRST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("FIRST_MILE");
return res.data;
},
});
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
@@ -572,10 +582,17 @@ const FirstMilePage = () => {
distances: Array<{ vehicleId: string; distanceKm: number }>;
remainingPayment?: number;
}) => firstMileService.setDistances(id, distances, remainingPayment),
onSuccess: () => {
onSuccess: (res) => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
const updated = res?.data as FirstMileRecord | undefined;
closeDistance();
// Every truck has a distance and it isn't billed yet → offer to invoice now.
const trucks = updated?.vehicleAssignments ?? [];
const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0);
if (updated && allFilled && !updated.invoice) {
setInvoiceConfirm(updated);
}
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
@@ -783,18 +800,9 @@ const FirstMilePage = () => {
return;
}
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
let remainingPayment: number | undefined;
if (ratesData?.data) {
const firstMileRate = ratesData.data.find(
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (firstMileRate) {
remainingPayment = total * parseFloat(firstMileRate.rateValue);
}
}
setDistancesMutation.mutate({ id: activeId, distances, remainingPayment });
// Amount is computed server-side per truck (distance × the vehicle's
// price/km, in the vehicle's currency) — no flat FIRST_MILE rate.
setDistancesMutation.mutate({ id: activeId, distances });
};
const matchesFilter = (r: FirstMileRecord) => {
@@ -849,6 +857,33 @@ const FirstMilePage = () => {
return filteredRecords.slice(start, start + pagination.pageSize);
}, [filteredRecords, pagination]);
// Billing problems on the leg pending invoice confirmation.
const confirmIssues = invoiceConfirm
? billingIssues(invoiceConfirm)
: { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] };
// Guard invoice generation: block mixed currency, warn (but proceed) on trucks
// priced at 0/km.
const handleGenerateInvoice = (r: FirstMileRecord) => {
const { zeroPrice, mixedCurrency, currencies } = billingIssues(r);
if (mixedCurrency) {
toast({
title: "Mixed truck currencies",
description: `Trucks use ${currencies.join(", ")}. Assign trucks that share one currency.`,
variant: "destructive",
});
return;
}
if (zeroPrice.length) {
toast({
title: "Truck has no price/km",
description: `${zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle.`,
variant: "destructive",
});
}
generateInvoiceMutation.mutate(r.id);
};
const openAssign = (id: string | null) => {
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
const rec = records.find((r) => r.id === resolved);
@@ -1173,7 +1208,7 @@ const FirstMilePage = () => {
!(row.original.exactKm != null && row.original.exactKm > 0) ||
Boolean(row.original.invoice)
}
onClick={() => generateInvoiceMutation.mutate(row.original.id)}
onClick={() => handleGenerateInvoice(row.original)}
>
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
</Menu.Item>
@@ -1337,21 +1372,29 @@ const FirstMilePage = () => {
clearable
disabled={assignVehicleOptions.length === 0}
/>
<Autocomplete
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder="Container number"
data={containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
)}
value={row.containerNumber}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
]}
value={row.containerNumber || null}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)),
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
)
}
searchable
clearable
/>
{vehicleRows.length > 1 && (
<ActionIcon
@@ -1740,6 +1783,77 @@ const FirstMilePage = () => {
</Group>
</Stack>
</Modal>
{/* Generate Invoice — confirmation summary */}
<Modal
opened={Boolean(invoiceConfirm)}
onClose={() => setInvoiceConfirm(null)}
title={<Text fw={600}>Generate Invoice</Text>}
size="md"
radius="lg"
centered
>
{invoiceConfirm && (
<Stack gap="md">
<Group justify="space-between">
<Text fw={600} size="sm">{bookingRef(invoiceConfirm)}</Text>
<Text size="sm" c="dimmed">{customerName(invoiceConfirm)}</Text>
</Group>
<Card withBorder padding="sm" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap={6}>
{(invoiceConfirm.vehicleAssignments ?? []).map((a) => {
const v = a.vehicle;
const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
return (
<Group key={a.id} justify="space-between" wrap="nowrap">
<Text size="sm">
{label}
{a.containerNumber ? ` · ${a.containerNumber}` : ""}
</Text>
<Text size="sm">{a.distanceKm != null ? `${a.distanceKm} km` : "—"}</Text>
</Group>
);
})}
</Stack>
</Card>
<Group justify="space-between">
<Text size="sm" c="dimmed">Total distance</Text>
<Text size="sm" fw={500}>{invoiceConfirm.exactKm ?? 0} km</Text>
</Group>
<Group justify="space-between">
<Text fw={600}>Invoice amount</Text>
<Text fw={700}>{formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))}</Text>
</Group>
{confirmIssues.mixedCurrency && (
<Alert color="red" variant="light" title="Mixed truck currencies">
Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing.
</Alert>
)}
{confirmIssues.zeroPrice.length > 0 && (
<Alert color="yellow" variant="light" title="Truck has no price/km">
{confirmIssues.zeroPrice.join(", ")} will bill 0 set Price per KM on the vehicle.
</Alert>
)}
<Text size="xs" c="dimmed">
Generate the delivery-fee invoice now, or close and generate later from the row actions.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setInvoiceConfirm(null)}>Later</Button>
<Button
loading={generateInvoiceMutation.isPending}
disabled={confirmIssues.mixedCurrency}
onClick={() =>
generateInvoiceMutation.mutate(invoiceConfirm.id, {
onSuccess: () => setInvoiceConfirm(null),
})
}
>
Generate Invoice
</Button>
</Group>
</Stack>
)}
</Modal>
</Stack>
);
};

View File

@@ -19,7 +19,6 @@ import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
ActionIcon,
Alert,
Autocomplete,
Badge,
Box,
Button,
@@ -56,7 +55,6 @@ import {
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { driversService, type Driver } from "@/services/drivers.service";
import { ratesService } from "@/services/rates.service";
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
@@ -234,7 +232,24 @@ const computeLastMileSteps = (
};
const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
const currencyOf = (r: LastMileRecord) => r.booking?.paymentCurrency ?? "ETB";
const currencyOf = (r: LastMileRecord) =>
r.vehicle?.currency ??
r.vehicleAssignments?.[0]?.vehicle?.currency ??
r.booking?.paymentCurrency ??
"ETB";
type LmAssignment = NonNullable<LastMileRecord["vehicleAssignments"]>[number];
const truckShort = (a: LmAssignment) =>
a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */
const billingIssues = (r: LastMileRecord) => {
const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0);
const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort);
const currencies = [
...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))),
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
const cargoDesc = (r: LastMileRecord) => {
@@ -583,14 +598,6 @@ const LastMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "LAST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("LAST_MILE");
return res.data;
},
});
const records = listData?.data ?? [];
const existingLastMileBookingIds = useMemo(
() => new Set(records.map((record) => record.bookingId)),
@@ -669,10 +676,17 @@ const LastMilePage = () => {
distances: Array<{ vehicleId: string; distanceKm: number }>;
remainingPayment?: number;
}) => lastMileService.setDistances(id, distances, remainingPayment),
onSuccess: () => {
onSuccess: (res) => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
const updated = res?.data as LastMileRecord | undefined;
closeDistance();
// Every truck has a distance and it isn't billed yet → offer to invoice now.
const trucks = updated?.vehicleAssignments ?? [];
const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0);
if (updated && allFilled && !updated.invoice) {
setInvoiceConfirm(updated);
}
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
@@ -804,18 +818,9 @@ const LastMilePage = () => {
return;
}
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
let remainingPayment: number | undefined;
if (ratesData?.data) {
const lastMileRate = ratesData.data.find(
(r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (lastMileRate) {
remainingPayment = total * parseFloat(lastMileRate.rateValue);
}
}
distanceMutation.mutate({ id: activeId, distances, remainingPayment });
// Amount is computed server-side per truck (distance × the vehicle's
// price/km, in the vehicle's currency) — no flat LAST_MILE rate.
distanceMutation.mutate({ id: activeId, distances });
};
const activeRecord = useMemo(
@@ -931,6 +936,11 @@ const LastMilePage = () => {
[records],
);
// Billing problems on the leg pending invoice confirmation.
const confirmIssues = invoiceConfirm
? billingIssues(invoiceConfirm)
: { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] };
const filteredRecords = useMemo(() => {
const term = search.trim().toLowerCase();
return records.filter((r) => {
@@ -1709,21 +1719,29 @@ const LastMilePage = () => {
clearable
disabled={assignVehicleOptions.length === 0}
/>
<Autocomplete
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder="Container number"
data={containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
)}
value={row.containerNumber}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
]}
value={row.containerNumber || null}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)),
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
)
}
searchable
clearable
/>
{vehicleRows.length > 1 && (
<ActionIcon
@@ -2005,6 +2023,16 @@ const LastMilePage = () => {
<Text fw={600}>Invoice amount</Text>
<Text fw={700}>{formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))}</Text>
</Group>
{confirmIssues.mixedCurrency && (
<Alert color="red" variant="light" title="Mixed truck currencies">
Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing.
</Alert>
)}
{confirmIssues.zeroPrice.length > 0 && (
<Alert color="yellow" variant="light" title="Truck has no price/km">
{confirmIssues.zeroPrice.join(", ")} will bill 0 set Price per KM on the vehicle.
</Alert>
)}
<Text size="xs" c="dimmed">
This creates the delivery-fee invoice. Confirm the distances and amount are correct.
</Text>
@@ -2012,6 +2040,7 @@ const LastMilePage = () => {
<Button variant="default" onClick={() => setInvoiceConfirm(null)}>Cancel</Button>
<Button
loading={generateInvoiceMutation.isPending}
disabled={confirmIssues.mixedCurrency}
onClick={() =>
generateInvoiceMutation.mutate(invoiceConfirm.id, {
onSuccess: () => setInvoiceConfirm(null),

View File

@@ -15,7 +15,7 @@ export type ColumnFormat =
| "entityLabel"
| "rateLabel";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio";
export interface ResourceColumn {
id: string;

View File

@@ -48,6 +48,7 @@ import {
} from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
@@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() {
void detailQuery.refetch();
}}
/>
{scheduleId ? (
<IntercityRideAlongPanel
scheduleId={scheduleId}
direction={schedule.direction}
/>
) : null}
</Tabs.Panel>
</Tabs>

View File

@@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() {
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
// API rejects them, so keep them out of the picker entirely.
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.direction !== "DOMESTIC"),
[routesQuery.data],
);
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
@@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() {
}
try {
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveIds },
payload: {
routeId,
scheduleDate: new Date(scheduleDate).toISOString(),
locomotiveIds,
},
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
@@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() {
<TextInput
label="Departure date"
type="datetime-local"
value={scheduleDate ? scheduleDate.slice(0, 16) : ""}
onChange={(e) => {
const raw = e.currentTarget.value;
setScheduleDate(raw ? new Date(raw).toISOString() : "");
}}
value={scheduleDate}
onChange={(e) => setScheduleDate(e.currentTarget.value)}
/>
<MultiSelect
label="Locomotives"

View File

@@ -593,6 +593,52 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
intercityCandidates: endpoint<
{ scheduleId: string },
import("@/types/trainScheduling").IntercityCandidatesResult
>(
"train-scheduling",
"intercity-candidates",
({ scheduleId }) => trainSchedulingService.getIntercityCandidates(scheduleId),
({ scheduleId }) => ["train-scheduling", "intercity-candidates", scheduleId],
),
acceptIntercityBookings: endpoint<
{ scheduleId: string; bookingIds: string[] },
import("@/types/trainScheduling").IntercityAcceptResult
>(
"train-scheduling",
"intercity-accept",
({ scheduleId, bookingIds }) =>
trainSchedulingService.acceptIntercityBookings(scheduleId, bookingIds),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
loadIntercityBooking: endpoint<
{ scheduleId: string; bookingId: string },
void
>(
"train-scheduling",
"intercity-load",
({ scheduleId, bookingId }) =>
trainSchedulingService.loadIntercityBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
unloadIntercityBooking: endpoint<
{ scheduleId: string; bookingId: string },
void
>(
"train-scheduling",
"intercity-unload",
({ scheduleId, bookingId }) =>
trainSchedulingService.unloadIntercityBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
cancelSchedule: endpoint<
{ id: string; freightType?: FreightType },
TrainScheduleDetail

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

@@ -39,6 +39,17 @@ export type SaveDriverPayload = Omit<
'id' | 'createdAt' | 'updatedAt' | 'totalTrips' | 'rating'
>;
/** A stored driver document (code "driver_docs"). */
export interface DriverDocument {
id: string;
name: string;
url: string;
size: number;
mimeType: string;
code: string;
createdAt: string;
}
export const driversService = {
getAll: (filters: DriverListFilters = {}) => {
const params = new URLSearchParams();
@@ -59,4 +70,18 @@ export const driversService = {
update: (id: string, data: Partial<SaveDriverPayload>) =>
apiClient.patch(URL_CONSTANTS.DRIVERS.BY_ID(id), data),
delete: (id: string) => apiClient.delete(URL_CONSTANTS.DRIVERS.BY_ID(id)),
// ── Driver documents (upload area code "driver_docs") ──
listDocuments: (id: string) =>
apiClient.get<DriverDocument[]>(`${URL_CONSTANTS.DRIVERS.BY_ID(id)}/documents`),
uploadDocuments: (id: string, files: File[]) => {
const form = new FormData();
for (const f of files) form.append('files', f);
return apiClient.post<DriverDocument[]>(
`${URL_CONSTANTS.DRIVERS.BY_ID(id)}/documents`,
form,
);
},
removeDocument: (id: string, fileId: string) =>
apiClient.delete(`${URL_CONSTANTS.DRIVERS.BY_ID(id)}/documents/${fileId}`),
};

View File

@@ -47,6 +47,8 @@ export interface FirstMileVehicle {
trailerPlateNo?: string | null;
assignedDriverId?: string | null;
assignedDriverName?: string | null;
pricePerKm?: number | string | null;
currency?: string | null;
}
export interface FirstMileRecord {

View File

@@ -0,0 +1,48 @@
import { api } from "@/auth/http";
export interface GpsDevice {
id: string;
imei: string;
name?: string | null;
vehicleId?: string | null;
vehicle?: {
id: string;
plateNumber?: string;
code?: string | null;
manufacturer?: string;
model?: string;
} | null;
status: string;
online: boolean;
lastSeenAt?: string | null;
lastLat?: number | string | null;
lastLng?: number | string | null;
lastSpeed?: number | string | null;
lastCourse?: number | null;
lastFixAt?: string | null;
voltageLevel?: number | null;
gsmLevel?: number | null;
}
export interface GpsPosition {
id: string;
lat: number | string;
lng: number | string;
speed: number | string;
course: number;
satellites: number;
gpsTime: string;
alarm: number;
}
export const gpsTrackingService = {
latest: () => api.get<GpsDevice[]>("/gps/positions/latest"),
listDevices: () => api.get<GpsDevice[]>("/gps/devices"),
history: (vehicleId: string, limit = 200) =>
api.get<GpsPosition[]>(`/gps/positions/${vehicleId}/history?limit=${limit}`),
register: (data: { imei: string; name?: string; vehicleId?: string | null }) =>
api.post<GpsDevice>("/gps/devices", data),
update: (id: string, data: { name?: string; vehicleId?: string | null }) =>
api.patch<GpsDevice>(`/gps/devices/${id}`, data),
remove: (id: string) => api.delete<void>(`/gps/devices/${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

@@ -48,6 +48,8 @@ export interface LastMileVehicle {
trailerPlateNo?: string | null;
assignedDriverId?: string | null;
assignedDriverName?: string | null;
pricePerKm?: number | string | null;
currency?: string | null;
}
export interface LastMileRecord {

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

View File

@@ -4,6 +4,9 @@ import { URL_CONSTANTS } from '@/constants/URLS';
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
/** Frozen from yard countries: ET→DJ EXPORT, DJ→ET IMPORT, same country DOMESTIC (intercity, disabled). */
export type RouteDirection = 'IMPORT' | 'EXPORT' | 'DOMESTIC';
export interface YardRef {
id: string;
code: string;
@@ -23,6 +26,7 @@ export interface RouteMilestone {
export interface RouteRecord {
id: string;
status: RouteStatus;
direction?: RouteDirection;
originYardId: string;
destinationYardId: string;
originYard?: YardRef | null;

View File

@@ -17,6 +17,8 @@ import type {
ImportDjiboutiLoadList,
ImportDjiboutiOperation,
ImportLoadingBookingsResponse,
IntercityAcceptResult,
IntercityCandidatesResult,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
@@ -328,6 +330,46 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getIntercityCandidates: async (
scheduleId: string,
): Promise<IntercityCandidatesResult> => {
const response = await client.get<IntercityCandidatesResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_CANDIDATES(scheduleId),
);
return unwrap(response.data);
},
acceptIntercityBookings: async (
scheduleId: string,
bookingIds: string[],
): Promise<IntercityAcceptResult> => {
const response = await client.post<IntercityAcceptResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_ACCEPT(scheduleId),
{ bookingIds },
);
return unwrap(response.data);
},
loadIntercityBooking: async (
scheduleId: string,
bookingId: string,
): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_LOAD(scheduleId, bookingId),
{},
);
},
unloadIntercityBooking: async (
scheduleId: string,
bookingId: string,
): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_UNLOAD(scheduleId, bookingId),
{},
);
},
dispatchSchedule: async (
scheduleId: string,
): Promise<TrainScheduleDetail> => {

View File

@@ -38,6 +38,9 @@ export interface Vehicle {
/** Odometer-derived distances (API sends numeric strings; coerce with Number). */
estimatedDistanceKm?: number | null;
actualDistanceKm?: number | null;
/** Haulage rate per km + its currency (ETB | USD). */
pricePerKm?: number | null;
currency?: string | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -407,6 +407,7 @@ export interface ScheduleWindowRule {
windowDurationHours: number | null;
reopenDelayMinutes: number | null;
importWindowLeadDays: number | null;
exportBookingLeadHours: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
docReviewMinutes: number;
paymentWindowMinutes: number;
@@ -420,6 +421,7 @@ export interface UpdateScheduleWindowRulePayload {
docReviewMinutes?: number;
paymentWindowMinutes?: number;
importWindowLeadDays?: number;
exportBookingLeadHours?: number;
}
export interface TrainScheduleDetail {
@@ -746,3 +748,49 @@ export interface CompositionRemovalEntry {
removedAt: string;
notes: string | null;
}
// ── Intercity ride-along ─────────────────────────────────────────────────────
// Intercity (DOMESTIC) bookings have no train of their own — they ride a
// passing import/export schedule whose route milestones contain the booking's
// origin before its destination. Staff accept them at finalize time against
// the train's remaining wagon/weight/length capacity.
export interface IntercityCapacity {
wagons: number;
weightTons: number;
lengthMeters: number;
}
export interface IntercityBookingRow {
id: string;
reference: string | null;
status: string;
freightType: FreightType | null;
isGovernment: boolean;
customer: string;
originYardId: string;
destinationYardId: string;
origin: string;
destination: string;
weightTons: number;
paymentDeadline: string | null;
need: IntercityCapacity | null;
}
export interface IntercityCandidateRow extends IntercityBookingRow {
fits: boolean;
}
export interface IntercityCandidatesResult {
scheduleId: string;
routeId: string | null;
remaining: IntercityCapacity | null;
candidates: IntercityCandidateRow[];
accepted: IntercityBookingRow[];
}
export interface IntercityAcceptResult {
accepted: string[];
rejected: Array<{ bookingId: string; reason: string }>;
remaining: IntercityCapacity;
}