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:
NuklearRabbit
2026-08-01 21:20:53 +02:00
parent 04d26f1f2e
commit 03c5b60235
47 changed files with 2518 additions and 70 deletions
+32 -44
View File
@@ -1,49 +1,37 @@
import { useEffect, useState } from "react";
const API = import.meta.env.VITE_API_BASE_URL ?? "";
type Status = {
service: string;
environment: string;
demo_mode: boolean;
knowledge_provider: string;
scaffold: boolean;
};
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import { Layout } from "./components/Layout";
import { RequireAuth } from "./components/RequireAuth";
import { Login } from "./pages/Login";
import { Dashboard } from "./pages/Dashboard";
import { Vehicles } from "./pages/Vehicles";
import { VehicleDetail } from "./pages/VehicleDetail";
import { Bookings } from "./pages/Bookings";
import { BookingDetail } from "./pages/BookingDetail";
import { Audit } from "./pages/Audit";
export function App() {
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`${API}/api/v1/system/status`)
.then((response) => {
if (!response.ok) throw new Error(`API returned ${response.status}`);
return response.json();
})
.then(setStatus)
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Unknown error"));
}, []);
return (
<main className="shell">
<section className="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="panel" aria-live="polite">
<h2>Scaffold status</h2>
{error && <p className="error">API unavailable: {error}</p>}
{!error && !status && <p>Connecting to the MobilityOps API</p>}
{status && (
<dl>
<div><dt>Service</dt><dd>{status.service}</dd></div>
<div><dt>Environment</dt><dd>{status.environment}</dd></div>
<div><dt>Knowledge provider</dt><dd>{status.knowledge_provider}</dd></div>
</dl>
)}
<p className="note">This is the bootable project scaffold. Claude must replace it with the complete scoped application described in the build pack.</p>
</section>
</main>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/vehicles" element={<Vehicles />} />
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
<Route path="/audit" element={<Audit />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AuthProvider>
);
}
+52
View File
@@ -0,0 +1,52 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!response.ok) {
let body: { error?: { code: string; message: string; correlation_id: string } } | undefined;
try {
body = await response.json();
} catch {
body = undefined;
}
const error = body?.error;
throw new ApiError(
response.status,
error?.code ?? String(response.status),
error?.message ?? response.statusText,
error?.correlation_id ?? "",
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
request<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }),
};
+131
View File
@@ -0,0 +1,131 @@
export type Role = "operations_manager" | "rental_employee";
export interface CurrentUser {
public_ref: string;
display_name: string;
role: Role;
}
export interface Vehicle {
public_ref: string;
make: string;
model: string;
model_year: number;
registration_number: string;
location: string;
operational_status: string;
odometer_km: number;
next_service_km: number;
active: boolean;
attention: boolean;
}
export interface BookingSummary {
public_ref: string;
customer_ref: string;
vehicle_ref: string;
starts_at: string;
ends_at: string;
status: string;
}
export interface Booking extends BookingSummary {
start_odometer_km: number | null;
end_odometer_km: number | null;
requirements_complete: boolean;
customer_name: string;
}
export interface Inspection {
public_ref: string;
booking_ref: string;
type: string;
fuel_level_percent: number;
cleanliness_ok: boolean;
damage_reported: boolean;
technical_warning: boolean;
odometer_km: number;
completed_at: string;
}
export interface MaintenanceRecord {
public_ref: string;
occurred_at: string;
odometer_km: number;
category: string;
summary: string;
}
export interface DataQualityIssue {
public_ref: string;
rule_type: string;
entity_type: string;
entity_ref: string;
severity: "low" | "medium" | "high";
status: "open" | "deferred" | "resolved" | "rejected";
evidence: Record<string, unknown>;
detected_at: string;
resolved_at: string | null;
}
export interface VehicleDetail extends Vehicle {
bookings: BookingSummary[];
inspections: Inspection[];
maintenance: MaintenanceRecord[];
quality_issues: DataQualityIssue[];
}
export interface DashboardMetrics {
available: number;
rented: number;
cleaning: number;
maintenance: number;
blocked: number;
open_quality_issues: number;
pending_or_failed_workflows: number;
}
export interface AttentionItem {
kind: string;
severity: "low" | "medium" | "high";
title: string;
detail: string;
link_type: "vehicle" | "booking" | "customer";
link_ref: string;
}
export interface TodayItem {
kind: "departure" | "return";
booking_ref: string;
vehicle_ref: string;
scheduled_at: string;
}
export interface AutomationRun {
event_id: string;
event_type: string;
aggregate_ref: string;
status: string;
attempts: number;
last_error: string | null;
occurred_at: string;
}
export interface Dashboard {
metrics: DashboardMetrics;
attention_items: AttentionItem[];
today: TodayItem[];
recent_automation: AutomationRun[];
}
export interface AuditEvent {
id: string;
actor_type: string;
actor_label: string;
action: string;
entity_type: string;
entity_id: string | null;
correlation_id: string;
occurred_at: string;
metadata: Record<string, unknown> | null;
}
+8
View File
@@ -0,0 +1,8 @@
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low";
return <span className={`badge severity-${severity}`}>{label} severity</span>;
}
export function StatusBadge({ status }: { status: string }) {
return <span className={`badge status-${status}`}>{status.replace(/_/g, " ")}</span>;
}
+56
View File
@@ -0,0 +1,56 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
const NAV_ITEMS = [
{ to: "/dashboard", label: "Dashboard" },
{ to: "/vehicles", label: "Vehicles" },
{ to: "/bookings", label: "Bookings" },
{ to: "/audit", label: "Audit" },
];
export function Layout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
function handleLogout() {
logout();
navigate("/login");
}
return (
<div className="app-shell">
<p className="demo-banner">
Synthetic demo environment no real customer or vehicle data.
</p>
<header className="app-header">
<span className="brand">MobilityOps</span>
<nav aria-label="Primary">
<ul>
{NAV_ITEMS.map((item) => (
<li key={item.to}>
<NavLink to={item.to} className={({ isActive }) => (isActive ? "active" : "")}>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="user-badge">
{user && (
<>
<span>
{user.display_name} · {user.role === "operations_manager" ? "Operations Manager" : "Rental Employee"}
</span>
<button type="button" onClick={handleLogout}>
Switch role
</button>
</>
)}
</div>
</header>
<main id="main-content">
<Outlet />
</main>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
export function RequireAuth({ children }: { children: ReactNode }) {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
+54
View File
@@ -0,0 +1,54 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { api, ApiError } from "../api/client";
import type { CurrentUser, Role } from "../api/types";
interface AuthState {
user: CurrentUser | null;
loading: boolean;
loginAs: (role: Role) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
const STORAGE_KEY = "mobilityops.demo-user";
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(() => {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as CurrentUser) : null;
});
const [loading, setLoading] = useState(false);
const loginAs = useCallback(async (role: Role) => {
setLoading(true);
try {
const loggedIn = await api.post<CurrentUser>("/api/v1/demo/login", { role });
setUser(loggedIn);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(loggedIn));
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
setUser(null);
sessionStorage.removeItem(STORAGE_KEY);
}, []);
return (
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
export function isSessionExpired(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}
+4 -1
View File
@@ -1,10 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+70
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+124
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
+175 -10
View File
@@ -6,14 +6,179 @@
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
.shell { width: min(920px, calc(100% - 32px)); margin: 0 auto; padding: 64px 0; }
.hero { margin-bottom: 28px; }
a { color: #1f5c8f; }
:focus-visible { outline: 3px solid #1f5c8f; outline-offset: 2px; }
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.demo-banner {
margin: 0;
padding: 8px 16px;
background: #14324f;
color: #eaf2fb;
font-size: 0.85rem;
text-align: center;
}
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
.app-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding: 14px 24px;
background: white;
border-bottom: 1px solid #dce3eb;
}
.brand { font-weight: 800; font-size: 1.15rem; color: #172033; }
.app-header nav ul {
display: flex;
flex-wrap: wrap;
gap: 4px;
list-style: none;
margin: 0; padding: 0;
}
.app-header nav a {
display: inline-block;
padding: 8px 12px;
border-radius: 8px;
color: #375065;
text-decoration: none;
font-weight: 600;
}
.app-header nav a.active, .app-header nav a[aria-current="page"] {
background: #e5eef9;
color: #14324f;
}
.user-badge { margin-left: auto; display: flex; align-items: center; gap: 12px; font-size: 0.9rem; }
.user-badge button {
padding: 6px 12px;
border-radius: 8px;
border: 1px solid #cfd8e2;
background: white;
cursor: pointer;
}
#main-content { width: min(1080px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; flex: 1; }
.page h1 { margin-top: 0; }
.page section { margin-bottom: 28px; }
.panel {
background: white;
border: 1px solid #dce3eb;
border-radius: 16px;
padding: 20px;
box-shadow: 0 10px 30px rgba(24, 40, 64, .05);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
list-style: none;
margin: 0; padding: 0;
}
.metric-tile {
background: white;
border: 1px solid #dce3eb;
border-radius: 14px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-value { font-size: 1.8rem; font-weight: 800; }
.metric-label { color: #607084; font-size: 0.85rem; }
.attention-list, .today-list, .automation-list, .record-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.attention-list li { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-bottom: 1px solid #edf1f5; }
.attention-title { margin: 0; font-weight: 700; }
.attention-detail { margin: 2px 0 0; color: #607084; font-size: 0.9rem; }
.today-list li, .automation-list li, .record-list li {
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
padding: 8px 0; border-bottom: 1px solid #edf1f5;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid transparent;
white-space: nowrap;
}
.severity-high { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; }
.severity-medium { background: #fdf1de; color: #8a5a10; border-color: #f0d29e; }
.severity-low { background: #eaf1fb; color: #315d73; border-color: #c4d9ec; }
.status-open, .status-pending, .status-active, .status-failed { background: #fdf1de; color: #8a5a10; border-color: #f0d29e; }
.status-failed { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; }
.status-succeeded, .status-resolved, .status-available, .status-returned { background: #e6f5ec; color: #1f6d3d; border-color: #b9e0c9; }
.status-blocked, .status-rejected { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; }
.status-cancelled, .status-deferred, .status-cleaning, .status-maintenance, .status-rented, .status-reserved, .status-delivering {
background: #eef1f5; color: #47566b; border-color: #d7dfe8;
}
.filters { display: flex; flex-wrap: wrap; gap: 16px; align-items: end; margin-bottom: 16px; }
.filters label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; color: #375065; font-weight: 600; }
.filters select, .filters input[type="text"] { padding: 8px 10px; border: 1px solid #cfd8e2; border-radius: 8px; font-size: 0.95rem; }
.checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; }
.data-table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #dce3eb; border-radius: 12px; overflow: hidden; }
.data-table th, .data-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #edf1f5; font-size: 0.92rem; }
.data-table thead th { background: #f6f8fb; color: #47566b; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .04em; }
.tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid #dce3eb; margin: 16px 0; }
.tabs button {
padding: 8px 14px; border: none; background: none; cursor: pointer;
border-bottom: 3px solid transparent; font-weight: 600; color: #607084;
}
.tabs button.active { color: #14324f; border-bottom-color: #1f5c8f; }
.detail-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.detail-grid div { background: white; border: 1px solid #dce3eb; border-radius: 10px; padding: 10px 14px; }
.detail-grid dt { color: #607084; font-size: 0.8rem; margin: 0; }
.detail-grid dd { margin: 4px 0 0; font-weight: 700; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.error { color: #9a2530; font-weight: 600; }
.login-shell { width: min(720px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0; }
.login-hero { margin-bottom: 24px; }
.eyebrow { color: #315d73; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; font-size: .8rem; }
h1 { margin: 6px 0; font-size: clamp(2.4rem, 7vw, 4.8rem); line-height: 1; }
.hero > p:last-child { color: #526173; font-size: 1.2rem; }
.panel { background: white; border: 1px solid #dce3eb; border-radius: 18px; padding: 24px; box-shadow: 0 10px 30px rgba(24,40,64,.06); }
dl { display: grid; gap: 12px; }
dl div { display: flex; justify-content: space-between; gap: 24px; border-bottom: 1px solid #edf1f5; padding-bottom: 10px; }
dt { color: #607084; } dd { margin: 0; font-weight: 700; }
.note { margin-top: 24px; color: #607084; }
.error { color: #9a2530; }
.login-hero h1 { margin: 6px 0; font-size: clamp(2.2rem, 7vw, 3.4rem); line-height: 1; }
.login-options { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.login-options button {
padding: 14px 18px; border-radius: 12px; border: none;
background: #14324f; color: white; font-weight: 700; font-size: 1rem; cursor: pointer;
}
.login-options button:hover, .login-options button:focus-visible { background: #1f5c8f; }
.login-options p { margin: 0 0 8px; color: #607084; font-size: 0.9rem; }
@media (max-width: 700px) {
.app-header { flex-direction: column; align-items: flex-start; }
.user-badge { margin-left: 0; }
.data-table, .data-table thead, .data-table tbody, .data-table th, .data-table td, .data-table tr {
display: block;
}
.data-table thead { display: none; }
.data-table tr { border-bottom: 2px solid #dce3eb; padding: 8px 0; }
.data-table td, .data-table th { border: none; padding: 4px 12px; }
}