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:
@@ -640,3 +640,47 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
|
||||
the server demo-ready.
|
||||
- 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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
||||
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 { StatusBadge } from "./Badge";
|
||||
|
||||
@@ -14,7 +17,17 @@ function newIdempotencyKey(): string {
|
||||
|
||||
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
|
||||
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 (
|
||||
<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>
|
||||
@@ -58,9 +71,16 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
|
||||
opened for review.
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -68,11 +88,14 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
|
||||
export function ReturnForm({
|
||||
bookingRef,
|
||||
onRegistered,
|
||||
suggestedOdometerKm,
|
||||
}: {
|
||||
bookingRef: string;
|
||||
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 [cleanlinessOk, setCleanlinessOk] = useState(true);
|
||||
const [damageReported, setDamageReported] = useState(false);
|
||||
@@ -85,6 +108,16 @@ export function ReturnForm({
|
||||
const [step, setStep] = useState<"capture" | "review">("capture");
|
||||
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 {
|
||||
return {
|
||||
end_odometer_km: Number(odometer),
|
||||
@@ -151,7 +184,10 @@ export function ReturnForm({
|
||||
required
|
||||
min={0}
|
||||
value={odometer}
|
||||
onChange={(e) => setOdometer(e.target.value)}
|
||||
onChange={(e) => {
|
||||
odometerEditedByUser.current = true;
|
||||
setOdometer(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -66,7 +66,10 @@ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
|
||||
title: "6. Stel een vraag aan de procedureassistent",
|
||||
whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.",
|
||||
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.",
|
||||
route: () => "/knowledge",
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<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" && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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); }
|
||||
}
|
||||
|
||||
.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 > 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; }
|
||||
|
||||
Reference in New Issue
Block a user