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"; import { describeEvidenceSignal } from "../data/evidenceSignals"; import type { AttentionItem } from "../api/types"; 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, 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}`; } // The backend never emits prose here either -- only stable signal codes + params, exactly // like the data-quality issue detail page. Multiple signals on one item (e.g. the // duplicate-customer match) are joined into one readable line for this compact row. function attentionItemDetail( t: (key: string, options?: Record) => string, formatNumber: (value: number) => string, item: Pick, ): string { return item.evidence_signals.map((signal) => describeEvidenceSignal(t, formatNumber, signal)).join(" · "); } export function Dashboard() { const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]); const { formatTime, formatShortDate, formatNumber } = 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(null); const [knowledge, setKnowledge] = useState(null); const [integrationStatus, setIntegrationStatus] = useState(null); const [error, setError] = useState(null); const [severity, setSeverity] = useState("all"); const [query, setQuery] = useState(""); useEffect(() => { api.get("/api/v1/dashboard").then(setData).catch(() => setError(t("common:status.error"))); api.get("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null)); }, []); useEffect(() => { if (!canSeeAutomation) return; api .get("/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)} ${attentionItemDetail(t, formatNumber, item)} ${item.link_ref}`.toLowerCase(); return matchesSeverity && haystack.includes(query.toLowerCase()); }) ?? [], [data, query, severity, t, formatNumber]); const isUnfiltered = severity === "all" && query === ""; const attentionTiers = useMemo(() => { if (!isUnfiltered) return [{ key: "all", labelKey: "", items: attention.slice(0, 6) }]; // Keep the initial dashboard queue deliberately bounded. The full evidence-backed // queue remains one click away in Data Quality; this view is for deciding what to // do next, not for rendering the whole backlog. const visible = ["high", "medium", "low"].flatMap((level) => attention.filter((item) => item.severity === level)).slice(0, 6); const bySeverity = (level: string) => visible.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 ; if (!data) return ; 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 (
{t("viewFleet")}} />
{t("demoStart.title")} {manifest ? t("demoStart.readyCount", { ready: readyCount, total: manifest.scenarios.length }) : t("demoStart.readyCountFallback")}
{user?.role === "operations_manager" && ( )} {t("demoStart.viewScenarios")}

{t("readiness.title")}

{t("readiness.description")}

{FLEET_METRIC_KEYS.map((metric) => (
{t(metric.labelKey)}
{data.metrics[metric.key]}
))}
{t("readiness.openFleet")}
{t("attention.reviewQueue")} : undefined} />
{attention.length === 0 ?

{t("attention.empty")}

: ( <> {attentionTiers.map((tier) => (
{attentionTiers.length > 1 &&

{t(tier.labelKey)}

}
    {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 (
  • {title}

    {attentionItemDetail(t, formatNumber, item)}

    {item.link_ref} {href && ( {title} )}
  • ); })}
))} {isUnfiltered && attention.length > 6 && canSeeQuality && (

{t("attention.remaining", { count: attention.length - 6 })}

)} )}
{t("movements.allBookings")} } /> {data.today.length === 0 ?

{t("movements.empty")}

: (
    {data.today.map((item) => (
  1. {item.kind === "return" ? t("movements.return") : t("movements.departure")}{item.booking_ref}{item.vehicle_ref}
    {item.booking_ref}
  2. ))}
)}
{t("integrationPulse.systemDetail")} : undefined} />
  • {t("integrationPulse.n8nTitle")} {integrationStatus ? t("integrationPulse.n8nSummary", { succeeded: integrationStatus.n8n.succeeded, failed: integrationStatus.n8n.failed }) : latestRun ? t("integrationPulse.n8nLatest", { ref: latestRun.aggregate_ref }) : t("integrationPulse.n8nNoEvidence")}
    {(() => { const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null; return ( ); })()}
  • {t("integrationPulse.knowledgeTitle")} {knowledge ? (knowledge.document_count === null ? t("integrationPulse.knowledgeIndexUnknown") : t("integrationPulse.knowledgeSummary", { count: knowledge.document_count })) : t("integrationPulse.knowledgeUnavailable")}
  • {t("integrationPulse.mcpTitle")} {integrationStatus?.mcp_hub.registration_enabled ? t("integrationPulse.mcpEnabled") : t("integrationPulse.mcpNotConnected")}
    {(() => { const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; return ; })()}
{data.recent_automation.length === 0 ?

{t("recent.empty")}

: (
    {data.recent_automation.slice(0, 4).map((run) => (
  • {t(`integrations:ledger.eventTypes.${run.event_type}`, { defaultValue: run.event_type.replace(/_/g, " ") })}{run.aggregate_ref}
  • ))}
)}
); }