feat(quality): add resolution UI for all five rule types and manual scan

DataQualityIssueDetail showed raw JSON as the primary interface for four of
five rule types, with no resolution surface beyond generic defer/reject.
Add a bounded panel per rule type (provide missing fields, retain/correct
an odometer reading, block one of two overlapping bookings, apply the
recommended vehicle status) wired to the new backend endpoints, and move
raw evidence behind a <details> disclosure. Add a "Run quality scan" action
to the workbench (confirmation, progress, per-rule result counts, auto
refresh) -- the endpoint already existed but had no UI trigger.
This commit is contained in:
NuklearRabbit
2026-08-02 06:16:14 +02:00
parent 6e227a214a
commit 477b5e7ce9
4 changed files with 490 additions and 17 deletions
+347 -12
View File
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router-dom";
import { api, ApiError } from "../api/client";
import type { DataQualityIssueDetail as IssueDetail, EntitySnapshot } from "../api/types";
import type {
ApplyRecommendedStatusResult,
DataQualityIssueDetail as IssueDetail,
EntitySnapshot,
} from "../api/types";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext";
import { Icon } from "../components/Icons";
@@ -9,6 +13,15 @@ import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../compone
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
return (
<details className="evidence-disclosure">
<summary>Technical evidence</summary>
<pre className="evidence-block">{JSON.stringify(issue.evidence, null, 2)}</pre>
</details>
);
}
function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { user } = useAuth();
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
@@ -166,6 +179,321 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
);
}
const CUSTOMER_FIELD_LABELS: Record<string, string> = {
first_name: "First name",
last_name: "Last name",
email: "Email",
phone: "Phone",
};
const VEHICLE_FIELD_LABELS: Record<string, string> = {
registration_number: "Registration number",
make: "Make",
model: "Model",
location: "Location",
};
function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const isCustomer = issue.entity_type === "customer";
const labels = isCustomer ? CUSTOMER_FIELD_LABELS : VEHICLE_FIELD_LABELS;
const snapshot = issue.entity_snapshot;
const [values, setValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const key of Object.keys(labels)) {
initial[key] = snapshot && snapshot[key] ? String(snapshot[key]) : "";
}
return initial;
});
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const fields = Object.fromEntries(
Object.entries(values).filter(([, value]) => value.trim().length > 0),
);
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not save these fields.");
} finally {
setSubmitting(false);
}
}
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="missing-field-heading">
<SectionHeading
title="Provide the missing fields"
description={`Complete the record for ${snapshot?.public_ref ?? issue.entity_ref}. The issue resolves automatically once nothing required is missing.`}
/>
{error && <p className="error" role="alert">{error}</p>}
<div className="form-grid">
{Object.entries(labels).map(([field, label]) => (
<label key={field}>
{label}
<input
type="text"
value={values[field] ?? ""}
onChange={(e) => setValues((v) => ({ ...v, [field]: e.target.value }))}
/>
</label>
))}
</div>
{isCustomer && (
<p className="table-subtext">At least one of email or phone is required.</p>
)}
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Save and re-check"}
</button>
</div>
</form>
);
}
function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const bookingSnapshots = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [decision, setDecision] = useState<"retain_canonical" | "correct_reading">("retain_canonical");
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
const [correctedValue, setCorrectedValue] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/resolve-odometer-regression`, {
decision,
booking_ref: decision === "correct_reading" ? bookingRef : undefined,
corrected_odometer_km:
decision === "correct_reading" && correctedValue ? Number(correctedValue) : undefined,
note: note || undefined,
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not resolve this issue.");
} finally {
setSubmitting(false);
}
}
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="odometer-heading">
<SectionHeading
title="Resolve the odometer regression"
description="The canonical odometer is never lowered automatically -- choose how to reconcile it."
/>
{error && <p className="error" role="alert">{error}</p>}
<dl className="detail-grid">
<div><dt>Canonical odometer</dt><dd>{Number(issue.entity_snapshot?.odometer_km ?? 0).toLocaleString("en-GB")} km</dd></div>
</dl>
<fieldset>
<legend>Decision</legend>
<label className="checkbox-label check-card">
<input
type="radio"
name="decision"
checked={decision === "retain_canonical"}
onChange={() => setDecision("retain_canonical")}
/>
Retain canonical -- treat the submitted reading as erroneous
</label>
<label className="checkbox-label check-card">
<input
type="radio"
name="decision"
checked={decision === "correct_reading"}
onChange={() => setDecision("correct_reading")}
disabled={bookingSnapshots.length === 0}
/>
Correct the reading -- update the booking and canonical odometer
</label>
{bookingSnapshots.length === 0 && (
<p className="table-subtext">No related booking is attached to this issue, so only "retain canonical" is available.</p>
)}
</fieldset>
{decision === "correct_reading" && (
<div className="form-grid">
<label>
Booking
<select value={bookingRef} onChange={(e) => setBookingRef(e.target.value)}>
{bookingSnapshots.map((snap) => (
<option key={snap.public_ref} value={snap.public_ref}>
{snap.public_ref} ({typeof snap.end_odometer_km === "number" ? snap.end_odometer_km.toLocaleString("en-GB") : "—"} km)
</option>
))}
</select>
</label>
<label>
Corrected odometer (km)
<input
type="number"
min={0}
required
value={correctedValue}
onChange={(e) => setCorrectedValue(e.target.value)}
/>
</label>
</div>
)}
<label>
Note
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label>
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Resolving…" : "Resolve issue"}
</button>
</div>
</form>
);
}
function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/resolve-overlap`, {
booking_ref: bookingRef,
note: note || undefined,
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not resolve this overlap.");
} finally {
setSubmitting(false);
}
}
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="overlap-heading">
<SectionHeading
title="Resolve the booking overlap"
description="Block one of the two overlapping commitments. The other keeps its current status."
/>
{error && <p className="error" role="alert">{error}</p>}
<table className="data-table compare-table">
<thead>
<tr>
<th scope="col">Booking</th>
<th scope="col">Window</th>
<th scope="col">Status</th>
<th scope="col">Block this one</th>
</tr>
</thead>
<tbody>
{bookings.map((b) => (
<tr key={b.public_ref}>
<th scope="row">{b.public_ref}</th>
<td>
{b.starts_at ? new Date(String(b.starts_at)).toLocaleDateString("en-GB") : "—"} {" "}
{b.ends_at ? new Date(String(b.ends_at)).toLocaleDateString("en-GB") : "—"}
</td>
<td><StatusBadge status={String(b.status)} /></td>
<td>
<label className="checkbox-label">
<input
type="radio"
name="overlap-booking"
checked={bookingRef === b.public_ref}
onChange={() => setBookingRef(b.public_ref)}
/>
<span className="visually-hidden">Block {b.public_ref}</span>
</label>
</td>
</tr>
))}
</tbody>
</table>
<label>
Note
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label>
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting || !bookingRef}>
{submitting ? "Resolving…" : `Block ${bookingRef || "booking"}`}
</button>
</div>
</form>
);
}
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = useState(false);
// 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
// visibility via `result` rather than disappearing the instant the reload lands.
if (issue.status !== "open" && !result) return null;
async function handleApply() {
setError(null);
setSubmitting(true);
try {
const applied = await api.post<ApplyRecommendedStatusResult>(
`/api/v1/data-quality/issues/${issue.public_ref}/apply-recommended-status`,
);
setResult(applied);
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not apply a recommended status.");
setConfirming(false);
} finally {
setSubmitting(false);
}
}
return (
<section className="panel" aria-labelledby="status-conflict-heading">
<SectionHeading
title="Resolve the status conflict"
description="One authoritative rule recommends a corrected operational status for this vehicle."
/>
{error && <p className="error" role="alert">{error}</p>}
<dl className="detail-grid">
<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>
) : !confirming ? (
<button type="button" onClick={() => setConfirming(true)}>
Calculate and apply recommended status
</button>
) : (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm status change">
<p>Apply the authoritative recommended status for this vehicle?</p>
<button type="button" onClick={handleApply} disabled={submitting}>
{submitting ? "Applying…" : "Yes, apply"}
</button>
<button type="button" onClick={() => setConfirming(false)} disabled={submitting}>
Cancel
</button>
</div>
)}
</section>
);
}
export function DataQualityIssueDetail() {
const { user } = useAuth();
const { publicRef } = useParams<{ publicRef: string }>();
@@ -217,25 +545,32 @@ export function DataQualityIssueDetail() {
<PageHeader eyebrow={`Quality / ${issue.rule_type.replace(/_/g, " ")}`} title={issue.public_ref} description="Review persisted evidence and record an audited resolution." actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} /></div>} />
<section className="record-surface" aria-label="Issue summary"><dl className="detail-grid">
<div><dt>Rule</dt><dd>{issue.rule_type.replace(/_/g, " ")}</dd></div>
<div><dt>Entity</dt><dd>{issue.entity_ref}</dd></div>
<div><dt>Evidence</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
</dl></section>
<div><dt>Entity</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
<div><dt>Evidence summary</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
</dl>
<EvidenceDisclosure issue={issue} /></section>
{actionError && <p className="error" role="alert">{actionError}</p>}
{issue.status === "open" && issue.rule_type === "possible_duplicate_customer" && (
<DuplicateCustomerPanel issue={issue} onResolved={load} />
)}
{issue.status === "open" && issue.rule_type === "missing_required_field" && (
<MissingFieldPanel issue={issue} onResolved={load} />
)}
{issue.status === "open" && issue.rule_type === "odometer_regression" && (
<OdometerRegressionPanel issue={issue} onResolved={load} />
)}
{issue.status === "open" && issue.rule_type === "booking_overlap" && (
<BookingOverlapPanel issue={issue} onResolved={load} />
)}
{issue.rule_type === "vehicle_status_conflict" && (
<VehicleStatusConflictPanel issue={issue} onResolved={load} />
)}
{issue.status === "open" && (
<section className="panel" aria-labelledby="resolution-heading">
<h2 id="resolution-heading">Resolution</h2>
{issue.rule_type !== "possible_duplicate_customer" && (
<>
<p>Evidence for this issue:</p>
<pre className="evidence-block">{JSON.stringify(issue.evidence, null, 2)}</pre>
</>
)}
<h2 id="resolution-heading">Defer or reject</h2>
<p>Defer to review later, or reject if this is not a real issue.</p>
<div className="resolution-actions">
<button type="button" onClick={() => handleAction("defer")}>