mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
The export-only assertAllocatedCargoLoaded guard threw a BadRequestException whenever a booking on the schedule had warehouse inventory in RECEIVED, STORED or READY_FOR_LOADING, making it impossible to dispatch a train whose cargo had not been inspected and loaded onto a wagon. Leaving cargo behind is an operational decision, not an error state. The dispatch confirm dialog already lists unassigned and unloaded bookings and offers Dispatch anyway, so the readiness signal is preserved — only the hard block is gone. Wagon/locomotive conflicts and the Djibouti gatepass check still block dispatch: those are physical and legal conflicts, not cargo readiness.
681 lines
23 KiB
TypeScript
681 lines
23 KiB
TypeScript
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 { useAuth } from "@/auth/useAuth";
|
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|
import { vehiclesService } from "@/services/vehicles.service";
|
|
import {
|
|
gpsTrackingService,
|
|
type GpsDevice,
|
|
} from "@/services/gps-tracking.service";
|
|
import { freightBrand } from "@/theme/freight-brand";
|
|
|
|
// Maps JavaScript API keys are public client-side keys — lock them down by
|
|
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
|
|
// used to live here and expired, turning a missing env var into a blank map
|
|
// that read as broken GPS rather than absent configuration.
|
|
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
|
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;
|
|
}
|
|
|
|
/** 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;
|
|
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>
|
|
);
|
|
}
|
|
|
|
/** 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 { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
const { user } = useAuth();
|
|
const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage);
|
|
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 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",
|
|
});
|
|
},
|
|
});
|
|
|
|
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" }),
|
|
});
|
|
|
|
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" }]}
|
|
/>
|
|
|
|
<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>
|
|
{canManage && (
|
|
<Button
|
|
leftSection={<Plus size={16} />}
|
|
color="edr-green"
|
|
onClick={openRegister}
|
|
>
|
|
Register tracker
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
|
|
<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",
|
|
}}
|
|
>
|
|
{!GOOGLE_MAPS_API_KEY ? (
|
|
// Name the missing variable rather than showing an empty map: the
|
|
// device list beside this still works, so a blank panel reads as
|
|
// "no GPS fixes" instead of "no map key".
|
|
<Box
|
|
style={{
|
|
height: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: 24,
|
|
textAlign: "center",
|
|
border: "1px solid #F0D2A8",
|
|
borderRadius: 8,
|
|
background: "#FFF9F0",
|
|
}}
|
|
>
|
|
<Text fz="sm" c="#8A5A16">
|
|
Map unavailable — <code>VITE_GOOGLE_MAPS_API_KEY</code> is
|
|
not set. Add a Google Maps key with the Maps JavaScript
|
|
API and Places API enabled to this app's{" "}
|
|
<code>.env</code>, then restart the dev server. Device
|
|
positions below are unaffected.
|
|
</Text>
|
|
</Box>
|
|
) : (
|
|
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
|
<GoogleMap
|
|
defaultCenter={DEFAULT_CENTER}
|
|
defaultZoom={7}
|
|
gestureHandling="greedy"
|
|
disableDefaultUI={false}
|
|
style={{ width: "100%", height: "100%" }}
|
|
>
|
|
<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)}
|
|
/>
|
|
))}
|
|
{(() => {
|
|
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">
|
|
{selected && (
|
|
<Card withBorder p="lg">
|
|
<Stack gap="md">
|
|
<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>
|
|
{canManage && (
|
|
<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 })
|
|
}
|
|
disabled={!canManage}
|
|
searchable
|
|
clearable
|
|
/>
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
|
|
<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>
|
|
{canManage && (
|
|
<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;
|