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
+44
View File
@@ -640,3 +640,47 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
the server demo-ready. the server demo-ready.
- Exact next action: layer plain-language Dutch explanation onto the return flow, the 5 - Exact next action: layer plain-language Dutch explanation onto the return flow, the 5
data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33. data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33.
### Batch 4 — return/data-quality/knowledge demo legibility (complete)
- **Fixed a real honesty bug**: `Knowledge.tsx` named "RAGcore" in the body copy and the
retrieval-flow diagram even though the active provider is the demo TF-IDF one (the
small badge below was already honest, contradicting the prose one line above). Now
derives a `providerLabel` ("Demo knowledge base" vs "RAGcore") from the real health
check and uses it everywhere; added an explicit disclosure note when not RAGcore.
Added 4 suggested-question chips. **Discovered and fixed a second real bug in the
process**: the brief's suggested Dutch questions (and my own Demo Guide step 6 wording)
would have returned "insufficient evidence" against the demo provider, because the
indexed procedures are English-only — verified empirically (Dutch question →
`insufficient`, its English equivalent → `grounded`). Fixed by keeping suggested
questions in English (matching the indexed content) and rewording the Guide step to
explain the knowledge base is English, rather than mistranslating the demo's
centerpiece feature into silently returning wrong answers.
- Return flow: `BookingDetail.tsx` now detects the one named return-anomaly scenario
booking (via the manifest, not a hardcoded ref) and fetches that vehicle's real
canonical odometer to pre-fill `ReturnForm`'s "End odometer" field with a suspicious
value below it, plus a callout explaining why — the brief explicitly requires the demo
not ask a visitor to invent a suspicious number themselves. Scoped narrowly to that one
scenario booking; ordinary returns are unaffected. `ReturnResultPanel` now links to
Automation and Audit trail (previously only the vehicle), and shows a "Ga verder met de
demo" button when the Demo Guide is open (advances the guide and navigates to the next
step). **Fixed a real regression caught by the existing return-review e2e test**: the
async pre-fill could silently overwrite odometer text a visitor had already started
typing, if the vehicle-detail fetch resolved after they began typing — fixed with an
`odometerEditedByUser` ref guard.
- Data quality: added a shared `RuleExplainer` (what's wrong / why it matters, in plain
language) for all 5 rule types on `DataQualityIssueDetail.tsx`; added a generic
post-resolution confirmation (audit-trail link, vehicle link, "Ga verder met de demo")
for the 4 rule types that previously just silently flipped their status badge with no
explicit confirmation, and extended `VehicleStatusConflictPanel`'s existing confirmation
with the same links rather than duplicating it. Added a "Demo scenario's only" checkbox
filter on `DataQuality.tsx` (client-side `public_ref.startsWith("DQ-DEMO-")`, no new
business logic) so the curated issues are easy to find among the full queue.
- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite
**51 passed** (47 existing + 4 new `demo-legibility.spec.ts`: return pre-fill + why-
suspicious explanation + result links, rule explainer visible, demo-scenario filter
narrows correctly, knowledge suggested question returns grounded evidence with the
correct provider label). Backend untouched this batch.
- Exact next action: plain-language integration-status labels, richer audit narration,
the full "Over deze demo" page content (currently a first pass from Batch 2), and
wiring reset into the guide/About/OM menu narrative — task #34.
+77
View File
@@ -0,0 +1,77 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) {
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
expect(login.ok()).toBeTruthy();
const reset = await request.post("/api/v1/demo/reset");
expect(reset.ok()).toBeTruthy();
}
test.describe.configure({ mode: "serial" });
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
const odometerInput = page.getByLabel("End odometer (km)");
await expect(odometerInput).not.toHaveValue("");
const prefilled = Number(await odometerInput.inputValue());
expect(prefilled).toBeGreaterThan(0);
await page.getByRole("button", { name: "Review return" }).click();
await expect(page.getByText(/below.*canonical reading/)).toBeVisible();
await page.getByRole("button", { name: "Confirm return" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
await expect(page.getByRole("link", { name: "View automation status" })).toBeVisible();
await expect(page.getByRole("link", { name: "View audit trail" })).toBeVisible();
});
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
await expect(page.getByText("What's wrong")).toBeVisible();
await expect(page.getByText("Why it matters")).toBeVisible();
await expect(page.getByText(/likely the same person/)).toBeVisible();
});
test("data quality list can filter to demo scenarios only", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const allRows = await page.locator(".data-table tbody tr").count();
await page.getByRole("checkbox", { name: "Demo scenario's only" }).check();
const filteredRows = await page.locator(".data-table tbody tr").count();
expect(filteredRows).toBeGreaterThan(0);
expect(filteredRows).toBeLessThanOrEqual(allRows);
const refs = await page.locator(".data-table tbody tr th a").allTextContents();
for (const ref of refs) {
expect(ref.startsWith("DQ-DEMO-")).toBeTruthy();
}
});
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/knowledge");
// The status badge and retrieval-flow diagram must name the actual active provider
// honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is
// allowed to mention RAGcore by name when explaining it isn't live yet.
await expect(page.locator(".knowledge-status strong")).toHaveText("Demo knowledge base");
await expect(page.locator(".retrieval-flow")).toContainText("Demo knowledge base");
await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore");
await page.getByRole("button", { name: "Who reviews an unusual odometer reading?" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
});
+42 -6
View File
@@ -1,8 +1,11 @@
import { useState, type FormEvent } from "react"; import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { api, ApiError } from "../api/client"; import { api, ApiError } from "../api/client";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types"; import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
import { useAuth } from "../context/AuthContext"; 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 "./Icons"; import { Icon } from "./Icons";
import { StatusBadge } from "./Badge"; import { StatusBadge } from "./Badge";
@@ -14,7 +17,17 @@ function newIdempotencyKey(): string {
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) { export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const canSeeQualityIssue = user?.role === "operations_manager"; const canSeeQualityIssue = user?.role === "operations_manager";
function continueDemo() {
completeAndAdvance();
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
}
return ( return (
<section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite"> <section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Committed locally</p><h2 id="return-result-heading">Return registered</h2></div></div> <div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Committed locally</p><h2 id="return-result-heading">Return registered</h2></div></div>
@@ -58,9 +71,16 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
opened for review. opened for review.
</p> </p>
)} )}
<p> <div className="result-links">
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>View vehicle {result.vehicle_ref}<Icon name="chevron" /></Link> <Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>View vehicle {result.vehicle_ref}<Icon name="chevron" /></Link>
</p> <Link className="button button-secondary" to="/automation">View automation status<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/audit">View audit trail<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> </section>
); );
} }
@@ -68,11 +88,14 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
export function ReturnForm({ export function ReturnForm({
bookingRef, bookingRef,
onRegistered, onRegistered,
suggestedOdometerKm,
}: { }: {
bookingRef: string; bookingRef: string;
onRegistered: (result: RegisterReturnResult) => void; onRegistered: (result: RegisterReturnResult) => void;
suggestedOdometerKm?: number;
}) { }) {
const [odometer, setOdometer] = useState(""); const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : "");
const odometerEditedByUser = useRef(false);
const [fuel, setFuel] = useState("50"); const [fuel, setFuel] = useState("50");
const [cleanlinessOk, setCleanlinessOk] = useState(true); const [cleanlinessOk, setCleanlinessOk] = useState(true);
const [damageReported, setDamageReported] = useState(false); const [damageReported, setDamageReported] = useState(false);
@@ -85,6 +108,16 @@ export function ReturnForm({
const [step, setStep] = useState<"capture" | "review">("capture"); const [step, setStep] = useState<"capture" | "review">("capture");
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null); const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
useEffect(() => {
// suggestedOdometerKm arrives asynchronously (BookingDetail fetches the vehicle's
// canonical odometer after this form has already mounted), so the initial useState
// value alone won't catch it -- sync it once it resolves. Never overwrite a value the
// visitor has already started typing.
if (suggestedOdometerKm !== undefined && !odometerEditedByUser.current) {
setOdometer(String(suggestedOdometerKm));
}
}, [suggestedOdometerKm]);
function currentBody(): RegisterReturnRequest { function currentBody(): RegisterReturnRequest {
return { return {
end_odometer_km: Number(odometer), end_odometer_km: Number(odometer),
@@ -151,7 +184,10 @@ export function ReturnForm({
required required
min={0} min={0}
value={odometer} value={odometer}
onChange={(e) => setOdometer(e.target.value)} onChange={(e) => {
odometerEditedByUser.current = true;
setOdometer(e.target.value);
}}
/> />
</label> </label>
+4 -1
View File
@@ -66,7 +66,10 @@ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
title: "6. Stel een vraag aan de procedureassistent", title: "6. Stel een vraag aan de procedureassistent",
whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.", whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.",
whyItMatters: "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.", whyItMatters: "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.",
startAction: 'Stel de voorbeeldvraag "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?".', startAction:
'Klik op één van de voorbeeldvragen (bv. "What must I do when a vehicle returns ' +
'with damage?"). De geïndexeerde procedures zijn Engelstalig, dus gebruik de ' +
"voorgestelde vragen of stel je eigen vraag in het Engels.",
expectedOutcome: "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is.", expectedOutcome: "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is.",
route: () => "/knowledge", route: () => "/knowledge",
}, },
+39 -2
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { api } from "../api/client"; 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 { StatusBadge } from "../components/Badge";
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm"; import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
@@ -9,9 +10,11 @@ import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function BookingDetail() { export function BookingDetail() {
const { publicRef } = useParams<{ publicRef: string }>(); const { publicRef } = useParams<{ publicRef: string }>();
const { manifest } = useDemoManifest();
const [booking, setBooking] = useState<Booking | null>(null); const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null); const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
const [canonicalOdometerKm, setCanonicalOdometerKm] = useState<number | null>(null);
const load = useCallback(() => { const load = useCallback(() => {
if (!publicRef) return; if (!publicRef) return;
@@ -28,6 +31,21 @@ export function BookingDetail() {
load(); load();
}, [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) { function handleRegistered(result: RegisterReturnResult) {
setReturnResult(result); setReturnResult(result);
load(); load();
@@ -50,9 +68,28 @@ export function BookingDetail() {
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div> <div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div>
</dl></section> </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 && <ReturnResultPanel result={returnResult} />}
{!returnResult && booking.status === "active" && ( {!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> </div>
); );
+20 -3
View File
@@ -24,6 +24,7 @@ export function DataQuality() {
const [scanError, setScanError] = useState<string | null>(null); const [scanError, setScanError] = useState<string | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null); const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false); const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
const load = useCallback(() => { const load = useCallback(() => {
if (user?.role !== "operations_manager") return; 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 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 ( return (
<div className="page"> <div className="page">
@@ -126,14 +132,25 @@ export function DataQuality() {
))} ))}
</select> </select>
</label> </label>
<label className="checkbox-label">
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => setDemoScenariosOnly(e.target.checked)}
/>
Demo scenario's only
</label>
</form> </form>
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label="Loading quality workbench…" />} {!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 && <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 && ( {visibleIssues.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"> <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> <caption className="visually-hidden">Data-quality issues</caption>
<thead> <thead>
<tr> <tr>
@@ -145,7 +162,7 @@ export function DataQuality() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{issues.map((i) => ( {visibleIssues.map((i) => (
<tr key={i.public_ref}> <tr key={i.public_ref}>
<th scope="row" data-label="Reference"> <th scope="row" data-label="Reference">
<Link to={`/data-quality/${i.public_ref}`}>{i.public_ref}</Link> <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 { 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 { api, ApiError } from "../api/client";
import type { import type {
ApplyRecommendedStatusResult, ApplyRecommendedStatusResult,
@@ -8,11 +8,62 @@ import type {
} from "../api/types"; } from "../api/types";
import { SeverityBadge, StatusBadge } from "../components/Badge"; import { SeverityBadge, StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext"; 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 { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"]; 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 }) { function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
return ( return (
<details className="evidence-disclosure"> <details className="evidence-disclosure">
@@ -433,11 +484,20 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
} }
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { 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 [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null); const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = 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 // Once resolved through this panel, keep showing the "Applied" confirmation even
// after the parent's issue.status flips away from "open" -- reloading on success // 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 // 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> <div><dt>Current status</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} /></dd></div>
</dl> </dl>
{result ? ( {result ? (
<p className="quiet-empty"> <>
<Icon name="check" /> Applied <StatusBadge status={result.applied_status} /> {result.reason} <p className="quiet-empty">
</p> <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 ? ( ) : !confirming ? (
<button type="button" onClick={() => setConfirming(true)}> <button type="button" onClick={() => setConfirming(true)}>
Calculate and apply recommended status Calculate and apply recommended status
@@ -496,10 +567,14 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
export function DataQualityIssueDetail() { export function DataQualityIssueDetail() {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const { publicRef } = useParams<{ publicRef: string }>(); const { publicRef } = useParams<{ publicRef: string }>();
const [issue, setIssue] = useState<IssueDetail | null>(null); const [issue, setIssue] = useState<IssueDetail | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [justResolved, setJustResolved] = useState(false);
const load = useCallback(() => { const load = useCallback(() => {
if (!publicRef) return; if (!publicRef) return;
@@ -513,9 +588,21 @@ export function DataQualityIssueDetail() {
if (user?.role !== "operations_manager") return; if (user?.role !== "operations_manager") return;
setIssue(null); setIssue(null);
setError(null); setError(null);
setJustResolved(false);
load(); load();
}, [load, user]); }, [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") { async function handleAction(action: "defer" | "reject") {
if (!issue) return; if (!issue) return;
setActionError(null); setActionError(null);
@@ -550,22 +637,42 @@ export function DataQualityIssueDetail() {
</dl> </dl>
<EvidenceDisclosure issue={issue} /></section> <EvidenceDisclosure issue={issue} /></section>
<RuleExplainer ruleType={issue.rule_type} />
{actionError && <p className="error" role="alert">{actionError}</p>} {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" && ( {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" && ( {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" && ( {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" && ( {issue.status === "open" && issue.rule_type === "booking_overlap" && (
<BookingOverlapPanel issue={issue} onResolved={load} /> <BookingOverlapPanel issue={issue} onResolved={handleResolved} />
)} )}
{issue.rule_type === "vehicle_status_conflict" && ( {issue.rule_type === "vehicle_status_conflict" && (
<VehicleStatusConflictPanel issue={issue} onResolved={load} /> <VehicleStatusConflictPanel issue={issue} onResolved={handleResolved} />
)} )}
{issue.status === "open" && ( {issue.status === "open" && (
+36 -8
View File
@@ -15,6 +15,13 @@ const EVIDENCE_LABEL: Record<GroundedAnswer["evidence_state"], string> = {
unavailable: "Knowledge service unavailable", 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() { export function Knowledge() {
const [status, setStatus] = useState<KnowledgeHealth | null>(null); const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
@@ -29,14 +36,12 @@ export function Knowledge() {
.catch(() => setStatus(null)); .catch(() => setStatus(null));
}, []); }, []);
async function handleSubmit(e: FormEvent) { async function ask(questionText: string) {
e.preventDefault();
if (!question.trim()) return;
setError(null); setError(null);
setSubmitting(true); setSubmitting(true);
try { try {
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", { question }); const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", { question: questionText });
setExchanges((prev) => [{ question, answer }, ...prev]); setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
setQuestion(""); setQuestion("");
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "Could not reach the knowledge service."); 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 ( return (
<div className="page"> <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 && ( {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"> <form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
@@ -74,10 +94,18 @@ export function Knowledge() {
</button> </button>
</div> </div>
{error && <p className="error" role="alert">{error}</p>} {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> </form>
{exchanges.length === 0 && !error && ( {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"> <ul className="exchange-list">
+16
View File
@@ -248,6 +248,14 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; } .impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; }
.commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); } .commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); }
.success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; } .success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; }
.result-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.rule-explainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 18px; padding: 16px 18px; background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); }
.rule-explainer strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.rule-explainer p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
.scenario-callout { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 18px; padding: 16px 18px; background: var(--teal-pale); border: 1px solid #bfe6df; }
.scenario-callout svg { width: 18px; height: 18px; color: var(--teal-dark); flex-shrink: 0; margin-top: 2px; }
.scenario-callout strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .82rem; }
.scenario-callout p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; } .duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); } .compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
@@ -293,6 +301,14 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); } .app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); }
} }
.knowledge-provider-note { display: flex; align-items: flex-start; gap: 8px; margin: -6px 0 18px; padding: 10px 14px; color: var(--ink-soft); background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); font-size: .72rem; line-height: 1.5; }
.knowledge-provider-note svg { width: 15px; flex-shrink: 0; margin-top: 1px; color: var(--info); }
.knowledge-suggestions { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 12px; }
.knowledge-suggestions > span { color: var(--muted); font-size: .68rem; font-weight: 700; }
.suggestion-chip { padding: 6px 11px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 600; cursor: pointer; }
.suggestion-chip:hover { background: #d9f0ea; }
.suggestion-chip:disabled { opacity: .6; cursor: not-allowed; }
.demo-start-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 22px; padding: 16px 20px; background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: var(--radius); } .demo-start-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 22px; padding: 16px 20px; background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: var(--radius); }
.demo-start-panel > div:first-child { display: flex; align-items: center; gap: 12px; } .demo-start-panel > div:first-child { display: flex; align-items: center; gap: 12px; }
.demo-start-panel > div:first-child > svg { width: 22px; height: 22px; color: var(--teal-dark); flex-shrink: 0; } .demo-start-panel > div:first-child > svg { width: 22px; height: 22px; color: var(--teal-dark); flex-shrink: 0; }