feat(demo): make return, data-quality and knowledge flows demo-legible

Fixes a real honesty bug in Knowledge.tsx (body copy named "RAGcore" while
the active provider is the demo one) and a second real bug discovered
while fixing it: the brief's suggested Dutch questions would silently
return "insufficient evidence" against the English-only demo knowledge
base -- verified empirically and fixed by keeping suggested questions in
English. The return flow now pre-fills the odometer-regression scenario's
suspicious reading instead of asking a visitor to invent one, and links
to automation/audit after committing. Data-quality issues get a shared
plain-language "what's wrong / why it matters" explainer per rule type,
a post-resolution confirmation with audit/vehicle links, and a "demo
scenario's only" list filter. Also fixes a real async race where the
odometer pre-fill could clobber text a visitor had already started typing.
This commit is contained in:
NuklearRabbit
2026-08-03 14:41:02 +02:00
parent 6b864596e0
commit ddc3a98e4b
9 changed files with 394 additions and 29 deletions
+39 -2
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { Booking, RegisterReturnResult } from "../api/types";
import type { Booking, RegisterReturnResult, VehicleDetail } from "../api/types";
import { useDemoManifest } from "../context/DemoManifestContext";
import { StatusBadge } from "../components/Badge";
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
import { Icon } from "../components/Icons";
@@ -9,9 +10,11 @@ import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function BookingDetail() {
const { publicRef } = useParams<{ publicRef: string }>();
const { manifest } = useDemoManifest();
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
const [canonicalOdometerKm, setCanonicalOdometerKm] = useState<number | null>(null);
const load = useCallback(() => {
if (!publicRef) return;
@@ -28,6 +31,21 @@ export function BookingDetail() {
load();
}, [load]);
// The odometer-regression demo scenario supplies its own suspicious reading (per the
// brief: never ask a demo visitor to invent one) -- only fetched for that one known
// scenario booking, not for every return.
const isReturnAnomalyScenario =
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path === `/bookings/${publicRef}`;
useEffect(() => {
setCanonicalOdometerKm(null);
if (!isReturnAnomalyScenario || !booking) return;
api
.get<VehicleDetail>(`/api/v1/vehicles/${booking.vehicle_ref}`)
.then((vehicle) => setCanonicalOdometerKm(vehicle.odometer_km))
.catch(() => setCanonicalOdometerKm(null));
}, [isReturnAnomalyScenario, booking]);
function handleRegistered(result: RegisterReturnResult) {
setReturnResult(result);
load();
@@ -50,9 +68,28 @@ export function BookingDetail() {
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div>
</dl></section>
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
<section className="record-surface scenario-callout" aria-label="Demo scenario">
<Icon name="spark" />
<div>
<strong>Demonstratiescenario: afwijkende kilometerstand</strong>
<p>
Dit voertuig staat momenteel op <strong>{canonicalOdometerKm.toLocaleString("en-GB")} km</strong>.
Het onderstaande formulier is vooraf ingevuld met een retourstand die daaronder
ligt — een teken van een foutieve invoer of een verwisseld voertuig. Bevestig de
retour om te zien hoe MobilityOps dit detecteert en afhandelt.
</p>
</div>
</section>
)}
{returnResult && <ReturnResultPanel result={returnResult} />}
{!returnResult && booking.status === "active" && (
<ReturnForm bookingRef={booking.public_ref} onRegistered={handleRegistered} />
<ReturnForm
bookingRef={booking.public_ref}
onRegistered={handleRegistered}
suggestedOdometerKm={isReturnAnomalyScenario && canonicalOdometerKm !== null ? Math.max(0, canonicalOdometerKm - 50) : undefined}
/>
)}
</div>
);
+20 -3
View File
@@ -24,6 +24,7 @@ export function DataQuality() {
const [scanError, setScanError] = useState<string | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
const load = useCallback(() => {
if (user?.role !== "operations_manager") return;
@@ -67,6 +68,11 @@ export function DataQuality() {
}
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
const visibleIssues = issues
? demoScenariosOnly
? issues.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
: issues
: [];
return (
<div className="page">
@@ -126,14 +132,25 @@ export function DataQuality() {
))}
</select>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => setDemoScenariosOnly(e.target.checked)}
/>
Demo scenario's only
</label>
</form>
{error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label="Loading quality workbench…" />}
{issues && issues.length === 0 && <EmptyState icon="check" title="Queue is clear" detail="No issues match the current filters." />}
{issues && issues.length > 0 && visibleIssues.length === 0 && (
<EmptyState icon="check" title="No demo-scenario issues match" detail="Uncheck 'Demo scenario's only' to see the full queue." />
)}
{issues && issues.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{issues.length} issues</span><span>Evidence-backed detection</span></div><table className="data-table">
{visibleIssues.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{visibleIssues.length} issues</span><span>Evidence-backed detection</span></div><table className="data-table">
<caption className="visually-hidden">Data-quality issues</caption>
<thead>
<tr>
@@ -145,7 +162,7 @@ export function DataQuality() {
</tr>
</thead>
<tbody>
{issues.map((i) => (
{visibleIssues.map((i) => (
<tr key={i.public_ref}>
<th scope="row" data-label="Reference">
<Link to={`/data-quality/${i.public_ref}`}>{i.public_ref}</Link>
+116 -9
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api, ApiError } from "../api/client";
import type {
ApplyRecommendedStatusResult,
@@ -8,11 +8,62 @@ import type {
} from "../api/types";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
const RULE_EXPLAINERS: Record<string, { whatIsWrong: string; whyItMatters: string }> = {
possible_duplicate_customer: {
whatIsWrong:
"Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.",
whyItMatters:
"Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations.",
},
missing_required_field: {
whatIsWrong:
"This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).",
whyItMatters:
"Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks.",
},
odometer_regression: {
whatIsWrong: "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.",
whyItMatters:
"A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history.",
},
booking_overlap: {
whatIsWrong: "The same vehicle is committed to two bookings whose date ranges overlap.",
whyItMatters:
"Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else.",
},
vehicle_status_conflict: {
whatIsWrong:
"This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.",
whyItMatters:
"An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet.",
},
};
function RuleExplainer({ ruleType }: { ruleType: string }) {
const explainer = RULE_EXPLAINERS[ruleType];
if (!explainer) return null;
return (
<section className="rule-explainer" aria-label="Why this matters">
<div>
<strong>What's wrong</strong>
<p>{explainer.whatIsWrong}</p>
</div>
<div>
<strong>Why it matters</strong>
<p>{explainer.whyItMatters}</p>
</div>
</section>
);
}
function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
return (
<details className="evidence-disclosure">
@@ -433,11 +484,20 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
}
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = useState(false);
function continueDemo() {
completeAndAdvance();
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
}
// Once resolved through this panel, keep showing the "Applied" confirmation even
// after the parent's issue.status flips away from "open" -- reloading on success
// updates the page's own status badge immediately, but this panel controls its own
@@ -472,9 +532,20 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
<div><dt>Current status</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} /></dd></div>
</dl>
{result ? (
<p className="quiet-empty">
<Icon name="check" /> Applied <StatusBadge status={result.applied_status} /> — {result.reason}
</p>
<>
<p className="quiet-empty">
<Icon name="check" /> Applied <StatusBadge status={result.applied_status} /> — {result.reason}
</p>
<div className="result-links">
<Link className="button button-secondary" to="/audit">View audit trail<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>View vehicle<Icon name="chevron" /></Link>
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" />
</button>
)}
</div>
</>
) : !confirming ? (
<button type="button" onClick={() => setConfirming(true)}>
Calculate and apply recommended status
@@ -496,10 +567,14 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
export function DataQualityIssueDetail() {
const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const { publicRef } = useParams<{ publicRef: string }>();
const [issue, setIssue] = useState<IssueDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [justResolved, setJustResolved] = useState(false);
const load = useCallback(() => {
if (!publicRef) return;
@@ -513,9 +588,21 @@ export function DataQualityIssueDetail() {
if (user?.role !== "operations_manager") return;
setIssue(null);
setError(null);
setJustResolved(false);
load();
}, [load, user]);
function handleResolved() {
setJustResolved(true);
load();
}
function continueDemo() {
completeAndAdvance();
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
}
async function handleAction(action: "defer" | "reject") {
if (!issue) return;
setActionError(null);
@@ -550,22 +637,42 @@ export function DataQualityIssueDetail() {
</dl>
<EvidenceDisclosure issue={issue} /></section>
<RuleExplainer ruleType={issue.rule_type} />
{actionError && <p className="error" role="alert">{actionError}</p>}
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
<section className="panel success-panel" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Resolved</p><h2>Issue {issue.public_ref} resolved</h2></div></div>
<p>The change has been applied and is recorded in the audit trail.</p>
<div className="result-links">
<Link className="button button-secondary" to="/audit">View audit trail<Icon name="chevron" /></Link>
{issue.entity_type === "vehicle" && (
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>View vehicle<Icon name="chevron" /></Link>
)}
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" />
</button>
)}
</div>
</section>
)}
{issue.status === "open" && issue.rule_type === "possible_duplicate_customer" && (
<DuplicateCustomerPanel issue={issue} onResolved={load} />
<DuplicateCustomerPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && issue.rule_type === "missing_required_field" && (
<MissingFieldPanel issue={issue} onResolved={load} />
<MissingFieldPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && issue.rule_type === "odometer_regression" && (
<OdometerRegressionPanel issue={issue} onResolved={load} />
<OdometerRegressionPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && issue.rule_type === "booking_overlap" && (
<BookingOverlapPanel issue={issue} onResolved={load} />
<BookingOverlapPanel issue={issue} onResolved={handleResolved} />
)}
{issue.rule_type === "vehicle_status_conflict" && (
<VehicleStatusConflictPanel issue={issue} onResolved={load} />
<VehicleStatusConflictPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && (
+36 -8
View File
@@ -15,6 +15,13 @@ const EVIDENCE_LABEL: Record<GroundedAnswer["evidence_state"], string> = {
unavailable: "Knowledge service unavailable",
};
const SUGGESTED_QUESTIONS = [
"What must I do when a vehicle returns with damage?",
"When may a vehicle be made available again?",
"Who reviews an unusual odometer reading?",
"Which checks are required before departure?",
];
export function Knowledge() {
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [question, setQuestion] = useState("");
@@ -29,14 +36,12 @@ export function Knowledge() {
.catch(() => setStatus(null));
}, []);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!question.trim()) return;
async function ask(questionText: string) {
setError(null);
setSubmitting(true);
try {
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", { question });
setExchanges((prev) => [{ question, answer }, ...prev]);
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", { question: questionText });
setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
setQuestion("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not reach the knowledge service.");
@@ -45,11 +50,26 @@ export function Knowledge() {
}
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!question.trim()) return;
await ask(question);
}
const providerLabel = status?.provider === "ragcore" ? "RAGcore" : "Demo knowledge base";
return (
<div className="page">
<PageHeader eyebrow="Assurance / Grounded knowledge" title="Procedure knowledge" description="Ask operational questions. Answers are shown only when RAGcore returns sufficient cited evidence." />
<PageHeader eyebrow="Assurance / Grounded knowledge" title="Procedure knowledge" description={`Ask operational questions. Answers are shown only when the ${providerLabel.toLowerCase()} returns sufficient cited evidence.`} />
{status && (
<div className="knowledge-status"><span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} /><div><strong>{status.provider}</strong><span>{status.available ? "Available" : "Unavailable"} · {status.document_count} procedures indexed</span></div><small>{status.collection}</small></div>
<div className="knowledge-status"><span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} /><div><strong>{providerLabel}</strong><span>{status.available ? "Available" : "Unavailable"} · {status.document_count} procedures indexed</span></div><small>{status.collection}</small></div>
)}
{status?.provider !== "ragcore" && (
<p className="knowledge-provider-note">
<Icon name="shield" /> This demo answers from a small, fixed set of indexed
procedures — not a live RAGcore connection. A live RAGcore backend will later
take over the same interface without changing how this page works.
</p>
)}
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
@@ -74,10 +94,18 @@ export function Knowledge() {
</button>
</div>
{error && <p className="error" role="alert">{error}</p>}
<div className="knowledge-suggestions">
<span>Try one:</span>
{SUGGESTED_QUESTIONS.map((q) => (
<button key={q} type="button" className="suggestion-chip" onClick={() => ask(q)} disabled={submitting}>
{q}
</button>
))}
</div>
</form>
{exchanges.length === 0 && !error && (
<div className="knowledge-empty"><span><Icon name="knowledge" /></span><h2>Evidence before answers</h2><p>Ask about returns, damage, inspections or another indexed procedure. MobilityOps will not invent an answer when evidence is missing.</p><div className="retrieval-flow" aria-hidden="true"><span>Question</span><i /><span>RAGcore</span><i /><span>Sources</span><i /><span>Answer</span></div></div>
<div className="knowledge-empty"><span><Icon name="knowledge" /></span><h2>Evidence before answers</h2><p>Ask about returns, damage, inspections or another indexed procedure. MobilityOps will not invent an answer when evidence is missing.</p><div className="retrieval-flow" aria-hidden="true"><span>Question</span><i /><span>{providerLabel}</span><i /><span>Sources</span><i /><span>Answer</span></div></div>
)}
<ul className="exchange-list">