M1: implement operational core
Demo auth, seed import/reset, dashboard, vehicle/booking list+detail, audit trail. Backend: 19 tests passing, ruff clean. Frontend: React Router shell, typed API client, responsive pages. Verified end-to-end via curl and browser.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { AuditEvent } from "../api/types";
|
||||
|
||||
export function Audit() {
|
||||
const [events, setEvents] = useState<AuditEvent[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [action, setAction] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (action) params.set("action", action);
|
||||
api
|
||||
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
|
||||
.then(setEvents)
|
||||
.catch(() => setError("Audit trail is unavailable right now."));
|
||||
}, [action]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Audit</h1>
|
||||
|
||||
<form className="filters" aria-label="Filter audit events">
|
||||
<label>
|
||||
Action
|
||||
<input
|
||||
type="text"
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
placeholder="e.g. demo_login"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!error && !events && <p>Loading audit trail…</p>}
|
||||
{events && events.length === 0 && <p>No audit events match this filter.</p>}
|
||||
|
||||
{events && events.length > 0 && (
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">Audit events</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">When</th>
|
||||
<th scope="col">Actor</th>
|
||||
<th scope="col">Action</th>
|
||||
<th scope="col">Entity</th>
|
||||
<th scope="col">Correlation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td>
|
||||
<time dateTime={e.occurred_at}>
|
||||
{new Date(e.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
||||
</time>
|
||||
</td>
|
||||
<td>{e.actor_label} ({e.actor_type})</td>
|
||||
<td>{e.action}</td>
|
||||
<td>{e.entity_type}</td>
|
||||
<td className="mono">{e.correlation_id.slice(0, 8)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Booking } from "../api/types";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
|
||||
export function BookingDetail() {
|
||||
const { publicRef } = useParams<{ publicRef: string }>();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicRef) return;
|
||||
setBooking(null);
|
||||
setError(null);
|
||||
api
|
||||
.get<Booking>(`/api/v1/bookings/${publicRef}`)
|
||||
.then(setBooking)
|
||||
.catch(() => setError("This booking could not be found."));
|
||||
}, [publicRef]);
|
||||
|
||||
if (error) return <p className="error" role="alert">{error}</p>;
|
||||
if (!booking) return <p>Loading booking…</p>;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<p><Link to="/bookings">← Back to bookings</Link></p>
|
||||
<h1>{booking.public_ref}</h1>
|
||||
<p><StatusBadge status={booking.status} /></p>
|
||||
<dl className="detail-grid">
|
||||
<div><dt>Customer</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
|
||||
<div><dt>Vehicle</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
|
||||
<div><dt>Starts</dt><dd>{new Date(booking.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
|
||||
<div><dt>Ends</dt><dd>{new Date(booking.ends_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
|
||||
<div><dt>Start odometer</dt><dd>{booking.start_odometer_km ?? "—"} km</dd></div>
|
||||
<div><dt>End odometer</dt><dd>{booking.end_odometer_km ?? "—"} km</dd></div>
|
||||
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Booking } from "../api/types";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
|
||||
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
|
||||
|
||||
export function Bookings() {
|
||||
const [bookings, setBookings] = useState<Booking[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
api
|
||||
.get<Booking[]>(`/api/v1/bookings?${params.toString()}`)
|
||||
.then(setBookings)
|
||||
.catch(() => setError("Booking list is unavailable right now."));
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Bookings</h1>
|
||||
|
||||
<form className="filters" aria-label="Filter bookings">
|
||||
<label>
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!error && !bookings && <p>Loading bookings…</p>}
|
||||
{bookings && bookings.length === 0 && <p>No bookings match these filters.</p>}
|
||||
|
||||
{bookings && bookings.length > 0 && (
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">Bookings</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Reference</th>
|
||||
<th scope="col">Customer</th>
|
||||
<th scope="col">Vehicle</th>
|
||||
<th scope="col">Window</th>
|
||||
<th scope="col">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bookings.map((b) => (
|
||||
<tr key={b.public_ref}>
|
||||
<th scope="row">
|
||||
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
|
||||
</th>
|
||||
<td>{b.customer_name}</td>
|
||||
<td>
|
||||
<Link to={`/vehicles/${b.vehicle_ref}`}>{b.vehicle_ref}</Link>
|
||||
</td>
|
||||
<td>
|
||||
{new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")}
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={b.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Dashboard as DashboardData } from "../api/types";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
|
||||
const METRIC_LABELS: Record<keyof DashboardData["metrics"], string> = {
|
||||
available: "Available",
|
||||
rented: "Rented",
|
||||
cleaning: "Cleaning",
|
||||
maintenance: "Maintenance",
|
||||
blocked: "Blocked",
|
||||
open_quality_issues: "Open quality issues",
|
||||
pending_or_failed_workflows: "Pending/failed workflows",
|
||||
};
|
||||
|
||||
export function Dashboard() {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<DashboardData>("/api/v1/dashboard")
|
||||
.then(setData)
|
||||
.catch(() => setError("Dashboard data is unavailable right now."));
|
||||
}, []);
|
||||
|
||||
if (error) return <p className="error" role="alert">{error}</p>;
|
||||
if (!data) return <p>Loading dashboard…</p>;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<section aria-labelledby="metrics-heading">
|
||||
<h2 id="metrics-heading">Operational metrics</h2>
|
||||
<ul className="metric-grid">
|
||||
{(Object.keys(METRIC_LABELS) as (keyof DashboardData["metrics"])[]).map((key) => (
|
||||
<li key={key} className="metric-tile">
|
||||
<span className="metric-value">{data.metrics[key]}</span>
|
||||
<span className="metric-label">{METRIC_LABELS[key]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="attention-heading" className="panel">
|
||||
<h2 id="attention-heading">Attention required</h2>
|
||||
{data.attention_items.length === 0 && <p>Nothing needs attention right now.</p>}
|
||||
<ul className="attention-list">
|
||||
{data.attention_items.map((item, index) => (
|
||||
<li key={`${item.link_ref}-${index}`}>
|
||||
<SeverityBadge severity={item.severity} />
|
||||
<div>
|
||||
<p className="attention-title">
|
||||
{item.link_type === "vehicle" ? (
|
||||
<Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</p>
|
||||
<p className="attention-detail">{item.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="today-heading" className="panel">
|
||||
<h2 id="today-heading">Today</h2>
|
||||
{data.today.length === 0 && <p>No departures or returns scheduled today.</p>}
|
||||
<ul className="today-list">
|
||||
{data.today.map((item) => (
|
||||
<li key={`${item.kind}-${item.booking_ref}`}>
|
||||
<span className="today-kind">{item.kind === "departure" ? "Departure" : "Return"}</span>
|
||||
<Link to={`/bookings/${item.booking_ref}`}>{item.booking_ref}</Link>
|
||||
<span>{item.vehicle_ref}</span>
|
||||
<time dateTime={item.scheduled_at}>
|
||||
{new Date(item.scheduled_at).toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: "Europe/Brussels",
|
||||
})}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="automation-heading" className="panel">
|
||||
<h2 id="automation-heading">Recent automation</h2>
|
||||
{data.recent_automation.length === 0 && <p>No automation runs recorded yet.</p>}
|
||||
<ul className="automation-list">
|
||||
{data.recent_automation.map((run) => (
|
||||
<li key={run.event_id}>
|
||||
<StatusBadge status={run.status} />
|
||||
<span>{run.event_type}</span>
|
||||
<span>{run.aggregate_ref}</span>
|
||||
<time dateTime={run.occurred_at}>
|
||||
{new Date(run.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { Role } from "../api/types";
|
||||
|
||||
export function Login() {
|
||||
const { loginAs, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleLogin(role: Role) {
|
||||
setError(null);
|
||||
try {
|
||||
await loginAs(role);
|
||||
navigate("/dashboard");
|
||||
} catch {
|
||||
setError("Could not start a demo session. The API may be unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-shell">
|
||||
<p className="demo-banner">
|
||||
Synthetic demo environment — no real customer or vehicle data.
|
||||
</p>
|
||||
<section className="login-hero">
|
||||
<p className="eyebrow">Synthetic proof of concept</p>
|
||||
<h1>MobilityOps</h1>
|
||||
<p>Connected operations for vehicle rental and service teams.</p>
|
||||
</section>
|
||||
<section className="login-panel panel" aria-labelledby="login-heading">
|
||||
<h2 id="login-heading">Choose a demo role</h2>
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="login-options">
|
||||
<button type="button" disabled={loading} onClick={() => handleLogin("operations_manager")}>
|
||||
Open as Operations Manager
|
||||
</button>
|
||||
<p>See the dashboard, resolve data-quality issues, retry automation and reset the demo.</p>
|
||||
|
||||
<button type="button" disabled={loading} onClick={() => handleLogin("rental_employee")}>
|
||||
Open as Rental Employee
|
||||
</button>
|
||||
<p>Register vehicle returns and look up bookings, vehicles and procedures.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { VehicleDetail as VehicleDetailData } from "../api/types";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
|
||||
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
export function VehicleDetail() {
|
||||
const { publicRef } = useParams<{ publicRef: string }>();
|
||||
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
|
||||
useEffect(() => {
|
||||
if (!publicRef) return;
|
||||
setVehicle(null);
|
||||
setError(null);
|
||||
api
|
||||
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
|
||||
.then(setVehicle)
|
||||
.catch(() => setError("This vehicle could not be found."));
|
||||
}, [publicRef]);
|
||||
|
||||
if (error) return <p className="error" role="alert">{error}</p>;
|
||||
if (!vehicle) return <p>Loading vehicle…</p>;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<p><Link to="/vehicles">← Back to vehicles</Link></p>
|
||||
<h1>
|
||||
{vehicle.public_ref} — {vehicle.make} {vehicle.model}
|
||||
</h1>
|
||||
<p>
|
||||
<StatusBadge status={vehicle.operational_status} />
|
||||
{vehicle.attention && <span className="badge severity-high">Needs attention</span>}
|
||||
</p>
|
||||
|
||||
<div role="tablist" aria-label="Vehicle sections" className="tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={tab === t}
|
||||
className={tab === t ? "active" : ""}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "overview" && (
|
||||
<dl className="detail-grid">
|
||||
<div><dt>Registration</dt><dd>{vehicle.registration_number}</dd></div>
|
||||
<div><dt>Model year</dt><dd>{vehicle.model_year}</dd></div>
|
||||
<div><dt>Location</dt><dd>{vehicle.location}</dd></div>
|
||||
<div><dt>Odometer</dt><dd>{vehicle.odometer_km.toLocaleString("en-GB")} km</dd></div>
|
||||
<div><dt>Next service</dt><dd>{vehicle.next_service_km.toLocaleString("en-GB")} km</dd></div>
|
||||
<div><dt>Active</dt><dd>{vehicle.active ? "Yes" : "No"}</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{tab === "bookings" && (
|
||||
<ul className="record-list">
|
||||
{vehicle.bookings.length === 0 && <li>No bookings recorded.</li>}
|
||||
{vehicle.bookings.map((b) => (
|
||||
<li key={b.public_ref}>
|
||||
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
|
||||
<StatusBadge status={b.status} />
|
||||
<span>
|
||||
{new Date(b.starts_at).toLocaleDateString("en-GB")} → {new Date(b.ends_at).toLocaleDateString("en-GB")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tab === "inspections" && (
|
||||
<ul className="record-list">
|
||||
{vehicle.inspections.length === 0 && <li>No inspections recorded.</li>}
|
||||
{vehicle.inspections.map((i) => (
|
||||
<li key={i.public_ref}>
|
||||
<span>{i.type}</span>
|
||||
<span>{i.odometer_km.toLocaleString("en-GB")} km</span>
|
||||
<span>Fuel {i.fuel_level_percent}%</span>
|
||||
{i.damage_reported && <span className="badge severity-high">Damage</span>}
|
||||
{i.technical_warning && <span className="badge severity-high">Technical warning</span>}
|
||||
<time dateTime={i.completed_at}>{new Date(i.completed_at).toLocaleDateString("en-GB")}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tab === "maintenance" && (
|
||||
<ul className="record-list">
|
||||
{vehicle.maintenance.length === 0 && <li>No maintenance records.</li>}
|
||||
{vehicle.maintenance.map((m) => (
|
||||
<li key={m.public_ref}>
|
||||
<span>{m.category}</span>
|
||||
<span>{m.summary}</span>
|
||||
<time dateTime={m.occurred_at}>{new Date(m.occurred_at).toLocaleDateString("en-GB")}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tab === "quality" && (
|
||||
<ul className="record-list">
|
||||
{vehicle.quality_issues.length === 0 && <li>No quality issues recorded.</li>}
|
||||
{vehicle.quality_issues.map((q) => (
|
||||
<li key={q.public_ref}>
|
||||
<SeverityBadge severity={q.severity} />
|
||||
<span>{q.rule_type.replace(/_/g, " ")}</span>
|
||||
<StatusBadge status={q.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Vehicle } from "../api/types";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
|
||||
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
|
||||
|
||||
export function Vehicles() {
|
||||
const [vehicles, setVehicles] = useState<Vehicle[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [attentionOnly, setAttentionOnly] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
if (attentionOnly) params.set("attention_only", "true");
|
||||
api
|
||||
.get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`)
|
||||
.then(setVehicles)
|
||||
.catch(() => setError("Vehicle list is unavailable right now."));
|
||||
}, [status, attentionOnly]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Vehicles</h1>
|
||||
|
||||
<form className="filters" aria-label="Filter vehicles">
|
||||
<label>
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attentionOnly}
|
||||
onChange={(e) => setAttentionOnly(e.target.checked)}
|
||||
/>
|
||||
Attention only
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!error && !vehicles && <p>Loading vehicles…</p>}
|
||||
{vehicles && vehicles.length === 0 && <p>No vehicles match these filters.</p>}
|
||||
|
||||
{vehicles && vehicles.length > 0 && (
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">Vehicle fleet</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Reference</th>
|
||||
<th scope="col">Make / model</th>
|
||||
<th scope="col">Location</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Odometer (km)</th>
|
||||
<th scope="col">Attention</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vehicles.map((v) => (
|
||||
<tr key={v.public_ref}>
|
||||
<th scope="row">
|
||||
<Link to={`/vehicles/${v.public_ref}`}>{v.public_ref}</Link>
|
||||
</th>
|
||||
<td>
|
||||
{v.make} {v.model} ({v.model_year})
|
||||
</td>
|
||||
<td>{v.location}</td>
|
||||
<td>
|
||||
<StatusBadge status={v.operational_status} />
|
||||
</td>
|
||||
<td>{v.odometer_km.toLocaleString("en-GB")}</td>
|
||||
<td>{v.attention ? "Needs attention" : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user