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
+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>
);
}