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