feat(ui): redesign operations dashboard

This commit is contained in:
NuklearRabbit
2026-08-02 03:27:05 +02:00
parent d0f34e6933
commit 6f0054c878
+115 -91
View File
@@ -1,110 +1,134 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import type { Dashboard as DashboardData } from "../api/types";
import type { Dashboard as DashboardData, KnowledgeHealth } from "../api/types";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
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",
};
const FLEET_METRICS: Array<{ key: keyof DashboardData["metrics"]; label: string; tone: string }> = [
{ key: "available", label: "Available", tone: "ready" },
{ key: "rented", label: "Rented", tone: "neutral" },
{ key: "cleaning", label: "Cleaning", tone: "neutral" },
{ key: "maintenance", label: "Maintenance", tone: "warning" },
{ key: "blocked", label: "Blocked", tone: "critical" },
];
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.issue_ref ? (
<Link to={`/data-quality/${item.issue_ref}`}>{item.title}</Link>
) : 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", {
function localTime(value: string, withDate = false) {
return new Date(value).toLocaleString("en-GB", {
...(withDate ? { day: "2-digit", month: "short" } : {}),
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Brussels",
})}
</time>
});
}
export function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [error, setError] = useState<string | null>(null);
const [severity, setSeverity] = useState("all");
const [query, setQuery] = useState("");
useEffect(() => {
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError("Dashboard data is unavailable right now."));
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, []);
const attention = useMemo(() => data?.attention_items.filter((item) => {
const matchesSeverity = severity === "all" || item.severity === severity;
const haystack = `${item.title} ${item.detail} ${item.link_ref}`.toLowerCase();
return matchesSeverity && haystack.includes(query.toLowerCase());
}) ?? [], [data, query, severity]);
if (error) return <ErrorState message={error} />;
if (!data) return <LoadingState label="Loading operations overview…" />;
const latestRun = data.recent_automation[0];
const n8nState = !latestRun ? "No delivery yet" : latestRun.status === "failed" ? "Needs attention" : latestRun.status;
return (
<div className="page dashboard-page">
<PageHeader eyebrow="Operations / Live overview" title="Good morning. Heres the fleet." description="Readiness, exceptions and hand-offs across todays operation." actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> View fleet</Link>} />
<section className="readiness-band" aria-labelledby="readiness-heading">
<div className="readiness-label">
<span className="live-indicator" />
<div><h2 id="readiness-heading">Fleet readiness</h2><p>Live from persisted vehicle state</p></div>
</div>
<dl className="readiness-metrics">
{FLEET_METRICS.map((metric) => (
<div key={metric.key} className={`metric-cell metric-${metric.tone}`}>
<dt>{metric.label}</dt><dd>{data.metrics[metric.key]}</dd>
</div>
))}
</dl>
<Link className="inline-action" to="/vehicles">Open fleet <Icon name="chevron" /></Link>
</section>
<div className="operations-grid">
<section className="work-panel attention-panel" aria-labelledby="attention-heading">
<SectionHeading title="Attention queue" description={`${data.metrics.open_quality_issues} open quality issues · ${data.metrics.pending_or_failed_workflows} workflow exceptions`} action={<Link to="/data-quality">Review queue <Icon name="chevron" /></Link>} />
<div className="queue-controls">
<label className="compact-search"><Icon name="search" /><span className="visually-hidden">Search attention queue</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter issues…" /></label>
<label><span className="visually-hidden">Severity</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">All severity</option><option value="high">Critical</option><option value="medium">Warning</option><option value="low">Info</option></select></label>
</div>
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> No issues match this filter.</p> : (
<ul className="attention-list">
{attention.slice(0, 6).map((item, index) => (
<li key={`${item.link_ref}-${index}`}>
<SeverityBadge severity={item.severity} />
<div className="queue-copy">
<p className="attention-title">
{item.issue_ref ? <Link to={`/data-quality/${item.issue_ref}`}>{item.title}</Link> : item.link_type === "vehicle" ? <Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link> : item.title}
</p>
<p className="attention-detail">{item.detail}</p>
</div>
<span className="queue-ref">{item.link_ref}</span>
<Icon name="chevron" className="row-chevron" />
</li>
))}
</ul>
)}
</section>
<section className="work-panel timeline-panel" aria-labelledby="today-heading">
<SectionHeading title="Todays movements" description="Departures and returns in Europe/Brussels" action={<Link to="/bookings">All bookings <Icon name="chevron" /></Link>} />
{data.today.length === 0 ? <p className="quiet-empty"><Icon name="clock" /> No movements scheduled today.</p> : (
<ol className="movement-timeline">
{data.today.slice(0, 6).map((item) => (
<li key={`${item.kind}-${item.booking_ref}`}>
<time dateTime={item.scheduled_at}>{localTime(item.scheduled_at)}</time>
<span className={`timeline-node timeline-${item.kind}`}><Icon name={item.kind === "return" ? "arrow-left" : "chevron"} /></span>
<div><span className="movement-kind">{item.kind}</span><Link to={`/bookings/${item.booking_ref}`}>{item.booking_ref}</Link><small>{item.vehicle_ref}</small></div>
</li>
))}
</ol>
)}
</section>
</div>
<div className="secondary-grid">
<section className="work-panel integration-panel" aria-labelledby="integration-heading">
<SectionHeading title="Integration pulse" description="Current evidence from connected services" action={<Link to="/automation">System detail <Icon name="chevron" /></Link>} />
<ul className="integration-list">
<li><IntegrationMark kind="n8n" /><div><strong>n8n delivery</strong><span>{latestRun ? `Latest event ${latestRun.aggregate_ref}` : "No workflow evidence recorded"}</span></div><StatusBadge status={n8nState.toLowerCase().replace(/ /g, "_")} /></li>
<li><IntegrationMark kind="rag" /><div><strong>RAGcore knowledge</strong><span>{knowledge ? `${knowledge.document_count} procedures indexed` : "Health check unavailable"}</span></div><StatusBadge status={knowledge?.available ? "available" : "unavailable"} /></li>
<li><IntegrationMark kind="mcp" /><div><strong>MCP Hub</strong><span>No active adapter in this PoC</span></div><StatusBadge status="not_configured" /></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>
<section className="work-panel recent-panel" aria-labelledby="recent-heading">
<SectionHeading title="Recent activity" description="Latest audited workflow changes" />
{data.recent_automation.length === 0 ? <p className="quiet-empty">No automation activity recorded.</p> : (
<ul className="recent-list">
{data.recent_automation.slice(0, 4).map((run) => (
<li key={run.event_id}><span className="activity-icon"><Icon name="activity" /></span><div><strong>{run.event_type.replace(/_/g, " ")}</strong><span>{run.aggregate_ref}</span></div><StatusBadge status={run.status} /><time dateTime={run.occurred_at}>{localTime(run.occurred_at, true)}</time></li>
))}
</ul>
)}
</section>
</div>
</div>
);
}