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:
@@ -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" && (
|
||||
|
||||
Reference in New Issue
Block a user