GUI: dashboard Attention Queue presents a curated severity mix instead of pure severity-sort (grouped Now/Today/Later headers); Today's Movements seed data curated so a fresh reset shows a credible day (2+ departures, 2+ returns), with a new seed-integrity test; About Demo restructured into a compact grid with progressive disclosure for technical sections; Duplicate Merge shows match/conflict counts, hides matching fields by default, and previews the final merged record before confirmation. Repo hygiene: removed a stray empty `backend;C` directory and an untracked 31MB zip export; `.gitignore` now excludes future archive exports. n8n: fixed invalid JSON (a missing `},` between two node objects) in the committed `fleet-ops-vehicle-return.json` -- the file could not be parsed. Live-validated workflow 3 (RAGcore Procedure Sync): found and fixed a real defect (three body parameters had a stray trailing `}}`) and a missing Error Workflow wiring, both via the safe `n8n import:workflow` CLI path; exported the corrected, still- inactive workflow as the new source of truth and updated MANIFEST.md/check_drift.py. Publishing it (starts real daily unattended runs) remains a separate decision. RAGcore: root-caused and fixed (live, approved) the "zero retrieval candidates" bug -- a filesystem permission bug (`embedding_profiles.json` unreadable by the app's own runtime user) that broke every retrieval call before it reached Qdrant. Every other suspect (grants, scope resolution, Qdrant filters, embeddings) was verified healthy first. Found a second, deeper gap: the reranker adapter calls an Ollama HTTP route that does not exist on the deployed Ollama version, so `/v1/answers` still returns `not_answerable`. `KNOWLEDGE_PROVIDER` stays `demo` until that is resolved on the RAGcore side. Evidence-based MCP Hub integration status (real tool-call audit history, not just a boolean flag) replaces the old `configured`/`not_configured` guess. Full findings in `docs/final-integrations/current-state-audit.md`. Backend: 172 tests passing, ruff clean, mypy clean (50 files). Frontend: tsc clean, production build clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
267 lines
15 KiB
TypeScript
267 lines
15 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { Link, useSearchParams } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api } from "../api/client";
|
|
import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
|
import { useLocaleFormat } from "../i18n/format";
|
|
import { useGreetingPeriod } from "../i18n/useGreetingPeriod";
|
|
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
|
import { Icon } from "../components/Icons";
|
|
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
|
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
|
|
|
|
const FLEET_METRIC_KEYS: Array<{ key: keyof DashboardData["metrics"]; labelKey: string; tone: string }> = [
|
|
{ key: "available", labelKey: "readiness.available", tone: "ready" },
|
|
{ key: "rented", labelKey: "readiness.rented", tone: "neutral" },
|
|
{ key: "cleaning", labelKey: "readiness.cleaning", tone: "neutral" },
|
|
{ key: "maintenance", labelKey: "readiness.maintenance", tone: "warning" },
|
|
{ key: "blocked", labelKey: "readiness.blocked", tone: "critical" },
|
|
];
|
|
|
|
function attentionItemTitle(
|
|
t: (key: string, options?: Record<string, unknown>) => string,
|
|
item: { rule_type: string; link_ref: string },
|
|
): string {
|
|
const ruleLabel = t(`quality:ruleTypes.${item.rule_type}`, { defaultValue: item.rule_type.replace(/_/g, " ") });
|
|
return `${ruleLabel} — ${item.link_ref}`;
|
|
}
|
|
|
|
export function Dashboard() {
|
|
const { t } = useTranslation(["dashboard", "common", "integrations"]);
|
|
const { formatTime, formatShortDate } = useLocaleFormat();
|
|
const greetingPeriod = useGreetingPeriod();
|
|
const { user } = useAuth();
|
|
const { manifest } = useDemoManifest();
|
|
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const canSeeQuality = user?.role === "operations_manager";
|
|
const canSeeAutomation = user?.role === "operations_manager";
|
|
|
|
useEffect(() => {
|
|
if (searchParams.get("guide") === "start") {
|
|
restart();
|
|
openGuide();
|
|
setSearchParams({}, { replace: true });
|
|
}
|
|
// Only ever react to the initial `?guide=start` marker set by the login screen's
|
|
// "Start begeleide demo" CTA, so this intentionally runs once on mount.
|
|
}, []);
|
|
const [data, setData] = useState<DashboardData | null>(null);
|
|
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
|
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | 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(t("common:status.error")));
|
|
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!canSeeAutomation) return;
|
|
api
|
|
.get<IntegrationStatus>("/api/v1/integrations/status")
|
|
.then(setIntegrationStatus)
|
|
.catch(() => setIntegrationStatus(null));
|
|
}, [canSeeAutomation]);
|
|
|
|
const attention = useMemo(() => data?.attention_items.filter((item) => {
|
|
const matchesSeverity = severity === "all" || item.severity === severity;
|
|
const haystack = `${attentionItemTitle(t, item)} ${item.detail} ${item.link_ref}`.toLowerCase();
|
|
return matchesSeverity && haystack.includes(query.toLowerCase());
|
|
}) ?? [], [data, query, severity, t]);
|
|
|
|
const isUnfiltered = severity === "all" && query === "";
|
|
const attentionTiers = useMemo(() => {
|
|
if (!isUnfiltered) return [{ key: "all", labelKey: "", items: attention.slice(0, 6) }];
|
|
const bySeverity = (level: string) => attention.filter((item) => item.severity === level);
|
|
return [
|
|
{ key: "high", labelKey: "attention.tierNow", items: bySeverity("high") },
|
|
{ key: "medium", labelKey: "attention.tierToday", items: bySeverity("medium") },
|
|
{ key: "low", labelKey: "attention.tierLater", items: bySeverity("low") },
|
|
].filter((tier) => tier.items.length > 0);
|
|
}, [attention, isUnfiltered]);
|
|
|
|
if (error) return <ErrorState message={error} />;
|
|
if (!data) return <LoadingState label={t("common:status.loading")} />;
|
|
|
|
const latestRun = data.recent_automation[0];
|
|
const readyCount = manifest?.scenarios.filter((s) => s.ready).length ?? 0;
|
|
const greetingTitle = `${t(`greeting.${greetingPeriod}`)}. ${t(`greetingBody.${greetingPeriod}`)}`;
|
|
|
|
return (
|
|
<div className="page dashboard-page">
|
|
<PageHeader eyebrow={t("eyebrow")} title={greetingTitle} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
|
|
|
|
<section className="demo-start-panel" aria-label={t("demoStart.title")}>
|
|
<div>
|
|
<Icon name="spark" />
|
|
<div>
|
|
<strong>{t("demoStart.title")}</strong>
|
|
<span>
|
|
{manifest ? t("demoStart.readyCount", { ready: readyCount, total: manifest.scenarios.length }) : t("demoStart.readyCountFallback")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="demo-start-actions">
|
|
{user?.role === "operations_manager" && (
|
|
<button type="button" className="button button-secondary" onClick={openGuide}>
|
|
<Icon name="spark" /> {completed.size > 0 ? t("demoStart.resumeGuide", { current: currentIndex + 1, total: totalSteps }) : t("demoStart.startGuide")}
|
|
</button>
|
|
)}
|
|
<Link className="button button-primary" to="/scenarios">{t("demoStart.viewScenarios")} <Icon name="chevron" /></Link>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="readiness-band" aria-labelledby="readiness-heading">
|
|
<div className="readiness-label">
|
|
<span className="live-indicator" />
|
|
<div><h2 id="readiness-heading">{t("readiness.title")}</h2><p>{t("readiness.description")}</p></div>
|
|
</div>
|
|
<dl className="readiness-metrics">
|
|
{FLEET_METRIC_KEYS.map((metric) => (
|
|
<div key={metric.key} className={`metric-cell metric-${metric.tone}`}>
|
|
<dt>{t(metric.labelKey)}</dt><dd>{data.metrics[metric.key]}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
<Link className="inline-action" to="/vehicles">{t("readiness.openFleet")} <Icon name="chevron" /></Link>
|
|
</section>
|
|
|
|
<div className="operations-grid">
|
|
<section className="work-panel attention-panel" aria-labelledby="attention-heading">
|
|
<SectionHeading headingId="attention-heading" title={t("attention.title")} description={t("attention.description", { openIssues: data.metrics.open_quality_issues, workflowExceptions: data.metrics.pending_or_failed_workflows })} action={canSeeQuality ? <Link to="/data-quality">{t("attention.reviewQueue")} <Icon name="chevron" /></Link> : undefined} />
|
|
<div className="queue-controls">
|
|
<label className="compact-search"><Icon name="search" /><span className="visually-hidden">{t("attention.filterAriaLabel")}</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("attention.filterPlaceholder")} /></label>
|
|
<label><span className="visually-hidden">{t("attention.severityAriaLabel")}</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">{t("attention.severityAll")}</option><option value="high">{t("attention.severityHigh")}</option><option value="medium">{t("attention.severityMedium")}</option><option value="low">{t("attention.severityLow")}</option></select></label>
|
|
</div>
|
|
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> {t("attention.empty")}</p> : (
|
|
<>
|
|
{attentionTiers.map((tier) => (
|
|
<div key={tier.key} className="attention-tier">
|
|
{attentionTiers.length > 1 && <p className="attention-tier-label">{t(tier.labelKey)}</p>}
|
|
<ul className="attention-list">
|
|
{tier.items.map((item, index) => {
|
|
const href = item.issue_ref && canSeeQuality
|
|
? `/data-quality/${item.issue_ref}`
|
|
: item.link_type === "vehicle"
|
|
? `/vehicles/${item.link_ref}`
|
|
: null;
|
|
const title = attentionItemTitle(t, item);
|
|
return (
|
|
<li key={`${item.link_ref}-${index}`} className={href ? "row-clickable" : ""}>
|
|
<SeverityBadge severity={item.severity} />
|
|
<div className="queue-copy">
|
|
<p className="attention-title">{title}</p>
|
|
<p className="attention-detail">{item.detail}</p>
|
|
</div>
|
|
<span className="queue-ref">{item.link_ref}</span>
|
|
<Icon name="chevron" className="row-chevron" />
|
|
{href && (
|
|
<Link className="row-link" to={href} aria-label={t("attention.openRecord", { title })}>
|
|
<span className="visually-hidden">{title}</span>
|
|
</Link>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<section className="work-panel timeline-panel" aria-labelledby="today-heading">
|
|
<SectionHeading headingId="today-heading" title={t("movements.title")} description={t("movements.description")} action={<Link to="/bookings">{t("movements.allBookings")} <Icon name="chevron" /></Link>} />
|
|
{data.today.length === 0 ? <p className="quiet-empty"><Icon name="clock" /> {t("movements.empty")}</p> : (
|
|
<ol className="movement-timeline">
|
|
{data.today.map((item) => (
|
|
<li key={`${item.kind}-${item.booking_ref}`} className="row-clickable">
|
|
<time dateTime={item.scheduled_at}>{formatTime(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 === "return" ? t("movements.return") : t("movements.departure")}</span><span className="movement-ref">{item.booking_ref}</span><small>{item.vehicle_ref}</small></div>
|
|
<Link className="row-link" to={`/bookings/${item.booking_ref}`} aria-label={t("movements.openBooking", { ref: item.booking_ref })}>
|
|
<span className="visually-hidden">{item.booking_ref}</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
<div className="secondary-grid">
|
|
<section className="work-panel integration-panel" aria-labelledby="integration-heading">
|
|
<SectionHeading headingId="integration-heading" title={t("integrationPulse.title")} description={t("integrationPulse.description")} action={canSeeAutomation ? <Link to="/automation">{t("integrationPulse.systemDetail")} <Icon name="chevron" /></Link> : undefined} />
|
|
<ul className="integration-list">
|
|
<li>
|
|
<IntegrationMark kind="n8n" />
|
|
<div>
|
|
<strong>{t("integrationPulse.n8nTitle")}</strong>
|
|
<span>
|
|
{integrationStatus
|
|
? t("integrationPulse.n8nSummary", { succeeded: integrationStatus.n8n.succeeded, failed: integrationStatus.n8n.failed })
|
|
: latestRun
|
|
? t("integrationPulse.n8nLatest", { ref: latestRun.aggregate_ref })
|
|
: t("integrationPulse.n8nNoEvidence")}
|
|
</span>
|
|
</div>
|
|
{(() => {
|
|
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
|
|
return (
|
|
<StatusBadge
|
|
status={meta?.statusClass ?? "no_events"}
|
|
label={meta ? t(`integrations:statusLabels.${meta.labelKey}`) : undefined}
|
|
/>
|
|
);
|
|
})()}
|
|
</li>
|
|
<li>
|
|
<IntegrationMark kind="rag" />
|
|
<div>
|
|
<strong>{t("integrationPulse.knowledgeTitle")}</strong>
|
|
<span>{knowledge ? t("integrationPulse.knowledgeSummary", { count: knowledge.document_count }) : t("integrationPulse.knowledgeUnavailable")}</span>
|
|
</div>
|
|
<StatusBadge
|
|
status={knowledge?.available ? "available" : "unavailable"}
|
|
label={
|
|
knowledge?.available
|
|
? t(`integrations:statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
|
|
: t("integrations:statusLabels.unavailable")
|
|
}
|
|
/>
|
|
</li>
|
|
<li>
|
|
<IntegrationMark kind="mcp" />
|
|
<div>
|
|
<strong>{t("integrationPulse.mcpTitle")}</strong>
|
|
<span>{integrationStatus?.mcp_hub.registration_enabled ? t("integrationPulse.mcpEnabled") : t("integrationPulse.mcpNotConnected")}</span>
|
|
</div>
|
|
{(() => {
|
|
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
|
|
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`integrations:statusLabels.${meta.labelKey}`) : undefined} />;
|
|
})()}
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section className="work-panel recent-panel" aria-labelledby="recent-heading">
|
|
<SectionHeading headingId="recent-heading" title={t("recent.title")} description={t("recent.description")} />
|
|
{data.recent_automation.length === 0 ? <p className="quiet-empty">{t("recent.empty")}</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>{t(`integrations:ledger.eventTypes.${run.event_type}`, { defaultValue: run.event_type.replace(/_/g, " ") })}</strong><span>{run.aggregate_ref}</span></div><StatusBadge status={run.status} label={t(`integrations:ledger.status${run.status.charAt(0).toUpperCase()}${run.status.slice(1)}`, { defaultValue: run.status })} /><time dateTime={run.occurred_at}>{formatShortDate(run.occurred_at)}</time></li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|