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