feat(ui): redesign operations dashboard
This commit is contained in:
@@ -1,110 +1,134 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { api } from "../api/client";
|
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 { 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> = {
|
const FLEET_METRICS: Array<{ key: keyof DashboardData["metrics"]; label: string; tone: string }> = [
|
||||||
available: "Available",
|
{ key: "available", label: "Available", tone: "ready" },
|
||||||
rented: "Rented",
|
{ key: "rented", label: "Rented", tone: "neutral" },
|
||||||
cleaning: "Cleaning",
|
{ key: "cleaning", label: "Cleaning", tone: "neutral" },
|
||||||
maintenance: "Maintenance",
|
{ key: "maintenance", label: "Maintenance", tone: "warning" },
|
||||||
blocked: "Blocked",
|
{ key: "blocked", label: "Blocked", tone: "critical" },
|
||||||
open_quality_issues: "Open quality issues",
|
];
|
||||||
pending_or_failed_workflows: "Pending/failed workflows",
|
|
||||||
};
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [data, setData] = useState<DashboardData | null>(null);
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [severity, setSeverity] = useState("all");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError("Dashboard data is unavailable right now."));
|
||||||
.get<DashboardData>("/api/v1/dashboard")
|
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
|
||||||
.then(setData)
|
|
||||||
.catch(() => setError("Dashboard data is unavailable right now."));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (error) return <p className="error" role="alert">{error}</p>;
|
const attention = useMemo(() => data?.attention_items.filter((item) => {
|
||||||
if (!data) return <p>Loading dashboard…</p>;
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page dashboard-page">
|
||||||
<h1>Dashboard</h1>
|
<PageHeader eyebrow="Operations / Live overview" title="Good morning. Here’s the fleet." description="Readiness, exceptions and hand-offs across today’s operation." actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> View fleet</Link>} />
|
||||||
|
|
||||||
<section aria-labelledby="metrics-heading">
|
<section className="readiness-band" aria-labelledby="readiness-heading">
|
||||||
<h2 id="metrics-heading">Operational metrics</h2>
|
<div className="readiness-label">
|
||||||
<ul className="metric-grid">
|
<span className="live-indicator" />
|
||||||
{(Object.keys(METRIC_LABELS) as (keyof DashboardData["metrics"])[]).map((key) => (
|
<div><h2 id="readiness-heading">Fleet readiness</h2><p>Live from persisted vehicle state</p></div>
|
||||||
<li key={key} className="metric-tile">
|
</div>
|
||||||
<span className="metric-value">{data.metrics[key]}</span>
|
<dl className="readiness-metrics">
|
||||||
<span className="metric-label">{METRIC_LABELS[key]}</span>
|
{FLEET_METRICS.map((metric) => (
|
||||||
</li>
|
<div key={metric.key} className={`metric-cell metric-${metric.tone}`}>
|
||||||
|
<dt>{metric.label}</dt><dd>{data.metrics[metric.key]}</dd>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</dl>
|
||||||
|
<Link className="inline-action" to="/vehicles">Open fleet <Icon name="chevron" /></Link>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="attention-heading" className="panel">
|
<div className="operations-grid">
|
||||||
<h2 id="attention-heading">Attention required</h2>
|
<section className="work-panel attention-panel" aria-labelledby="attention-heading">
|
||||||
{data.attention_items.length === 0 && <p>Nothing needs attention right now.</p>}
|
<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>} />
|
||||||
<ul className="attention-list">
|
<div className="queue-controls">
|
||||||
{data.attention_items.map((item, index) => (
|
<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>
|
||||||
<li key={`${item.link_ref}-${index}`}>
|
<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>
|
||||||
<SeverityBadge severity={item.severity} />
|
</div>
|
||||||
<div>
|
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> No issues match this filter.</p> : (
|
||||||
<p className="attention-title">
|
<ul className="attention-list">
|
||||||
{item.issue_ref ? (
|
{attention.slice(0, 6).map((item, index) => (
|
||||||
<Link to={`/data-quality/${item.issue_ref}`}>{item.title}</Link>
|
<li key={`${item.link_ref}-${index}`}>
|
||||||
) : item.link_type === "vehicle" ? (
|
<SeverityBadge severity={item.severity} />
|
||||||
<Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link>
|
<div className="queue-copy">
|
||||||
) : (
|
<p className="attention-title">
|
||||||
item.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>
|
<p className="attention-detail">{item.detail}</p>
|
||||||
<p className="attention-detail">{item.detail}</p>
|
</div>
|
||||||
</div>
|
<span className="queue-ref">{item.link_ref}</span>
|
||||||
</li>
|
<Icon name="chevron" className="row-chevron" />
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
</section>
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="today-heading" className="panel">
|
<section className="work-panel timeline-panel" aria-labelledby="today-heading">
|
||||||
<h2 id="today-heading">Today</h2>
|
<SectionHeading title="Today’s movements" description="Departures and returns in Europe/Brussels" action={<Link to="/bookings">All bookings <Icon name="chevron" /></Link>} />
|
||||||
{data.today.length === 0 && <p>No departures or returns scheduled today.</p>}
|
{data.today.length === 0 ? <p className="quiet-empty"><Icon name="clock" /> No movements scheduled today.</p> : (
|
||||||
<ul className="today-list">
|
<ol className="movement-timeline">
|
||||||
{data.today.map((item) => (
|
{data.today.slice(0, 6).map((item) => (
|
||||||
<li key={`${item.kind}-${item.booking_ref}`}>
|
<li key={`${item.kind}-${item.booking_ref}`}>
|
||||||
<span className="today-kind">{item.kind === "departure" ? "Departure" : "Return"}</span>
|
<time dateTime={item.scheduled_at}>{localTime(item.scheduled_at)}</time>
|
||||||
<Link to={`/bookings/${item.booking_ref}`}>{item.booking_ref}</Link>
|
<span className={`timeline-node timeline-${item.kind}`}><Icon name={item.kind === "return" ? "arrow-left" : "chevron"} /></span>
|
||||||
<span>{item.vehicle_ref}</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>
|
||||||
<time dateTime={item.scheduled_at}>
|
</li>
|
||||||
{new Date(item.scheduled_at).toLocaleTimeString("en-GB", {
|
))}
|
||||||
hour: "2-digit",
|
</ol>
|
||||||
minute: "2-digit",
|
)}
|
||||||
timeZone: "Europe/Brussels",
|
</section>
|
||||||
})}
|
</div>
|
||||||
</time>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section aria-labelledby="automation-heading" className="panel">
|
<div className="secondary-grid">
|
||||||
<h2 id="automation-heading">Recent automation</h2>
|
<section className="work-panel integration-panel" aria-labelledby="integration-heading">
|
||||||
{data.recent_automation.length === 0 && <p>No automation runs recorded yet.</p>}
|
<SectionHeading title="Integration pulse" description="Current evidence from connected services" action={<Link to="/automation">System detail <Icon name="chevron" /></Link>} />
|
||||||
<ul className="automation-list">
|
<ul className="integration-list">
|
||||||
{data.recent_automation.map((run) => (
|
<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 key={run.event_id}>
|
<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>
|
||||||
<StatusBadge status={run.status} />
|
<li><IntegrationMark kind="mcp" /><div><strong>MCP Hub</strong><span>No active adapter in this PoC</span></div><StatusBadge status="not_configured" /></li>
|
||||||
<span>{run.event_type}</span>
|
</ul>
|
||||||
<span>{run.aggregate_ref}</span>
|
</section>
|
||||||
<time dateTime={run.occurred_at}>
|
|
||||||
{new Date(run.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
<section className="work-panel recent-panel" aria-labelledby="recent-heading">
|
||||||
</time>
|
<SectionHeading title="Recent activity" description="Latest audited workflow changes" />
|
||||||
</li>
|
{data.recent_automation.length === 0 ? <p className="quiet-empty">No automation activity recorded.</p> : (
|
||||||
))}
|
<ul className="recent-list">
|
||||||
</ul>
|
{data.recent_automation.slice(0, 4).map((run) => (
|
||||||
</section>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user