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:
@@ -162,6 +162,76 @@ test("data quality issue detail: defer and reject buttons work", async ({ page,
|
||||
await expect(page.getByText("deferred", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-ATTENTION");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-ATTENTION" })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Registration number").fill("TST-777");
|
||||
await page.getByLabel("Make").fill("TestMake");
|
||||
await page.getByLabel("Model").fill("TestModel");
|
||||
await page.getByLabel("Location").fill("Depot");
|
||||
await page.getByRole("button", { name: "Save and re-check" }).click();
|
||||
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-OVERLAP" })).toBeVisible();
|
||||
|
||||
await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
|
||||
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
||||
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
|
||||
expect((await booking.json()).status).toBe("blocked");
|
||||
});
|
||||
|
||||
test("data quality: applying the recommended status resolves a vehicle conflict", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Calculate and apply recommended status" }).click();
|
||||
await page.getByRole("button", { name: "Yes, apply" }).click();
|
||||
|
||||
await expect(page.getByText("Applied", { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: retaining canonical resolves an odometer regression issue", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
const issues = await (
|
||||
await page.request.get("/api/v1/data-quality/issues", {
|
||||
params: { rule_type: "odometer_regression", status: "open" },
|
||||
})
|
||||
).json();
|
||||
const target = issues[0];
|
||||
|
||||
await page.goto(`/data-quality/${target.public_ref}`);
|
||||
await expect(page.getByRole("heading", { name: target.public_ref })).toBeVisible();
|
||||
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality");
|
||||
await page.getByRole("button", { name: "Run quality scan" }).click();
|
||||
await page.getByRole("button", { name: "Yes, run scan" }).click();
|
||||
|
||||
await expect(page.getByText(/Scan complete/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("automation page: status filter and retry button work", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/automation");
|
||||
|
||||
@@ -160,6 +160,7 @@ export interface ReturnPreviewResult {
|
||||
}
|
||||
|
||||
export interface EntitySnapshot {
|
||||
entity_type: "customer" | "vehicle" | "booking" | "inspection";
|
||||
public_ref: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -169,6 +170,16 @@ export interface DataQualityIssueDetail extends DataQualityIssue {
|
||||
related_snapshots: EntitySnapshot[];
|
||||
}
|
||||
|
||||
export interface ApplyRecommendedStatusResult {
|
||||
issue: DataQualityIssue;
|
||||
applied_status: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
created: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface MergeCustomersRequest {
|
||||
survivor_ref: string;
|
||||
field_overrides?: Record<string, string>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { DataQualityIssue } from "../api/types";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import type { DataQualityIssue, ScanResult } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
@@ -20,8 +20,12 @@ export function DataQuality() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("open");
|
||||
const [ruleType, setRuleType] = useState("");
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [confirmingScan, setConfirmingScan] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = useCallback(() => {
|
||||
if (user?.role !== "operations_manager") return;
|
||||
setIssues(null);
|
||||
setError(null);
|
||||
@@ -34,6 +38,25 @@ export function DataQuality() {
|
||||
.catch(() => setError("Data-quality issues are unavailable right now."));
|
||||
}, [status, ruleType, user]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleScan() {
|
||||
setScanError(null);
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await api.post<ScanResult>("/api/v1/data-quality/scan");
|
||||
setScanResult(result);
|
||||
setConfirmingScan(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
setScanError(err instanceof ApiError ? err.message : "Could not run the quality scan.");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.role !== "operations_manager") {
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -43,9 +66,43 @@ export function DataQuality() {
|
||||
);
|
||||
}
|
||||
|
||||
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader eyebrow="Assurance / Workbench" title="Data quality" description="Resolve evidence-backed exceptions before they disrupt operations." />
|
||||
<PageHeader
|
||||
eyebrow="Assurance / Workbench"
|
||||
title="Data quality"
|
||||
description="Resolve evidence-backed exceptions before they disrupt operations."
|
||||
actions={
|
||||
!confirmingScan ? (
|
||||
<button className="button button-secondary" type="button" onClick={() => setConfirmingScan(true)} disabled={scanning}>
|
||||
Run quality scan
|
||||
</button>
|
||||
) : (
|
||||
<div className="confirm-bar" role="alertdialog" aria-label="Confirm quality scan">
|
||||
<p>Run the deterministic scan across all five rule types now?</p>
|
||||
<button type="button" onClick={handleScan} disabled={scanning}>
|
||||
{scanning ? "Scanning…" : "Yes, run scan"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirmingScan(false)} disabled={scanning}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{scanError && <p className="error" role="alert">{scanError}</p>}
|
||||
{scanResult && (
|
||||
<p className="quiet-empty" role="status">
|
||||
Scan complete: {scanTotal === 0
|
||||
? "no new issues found (existing open issues are not recreated)."
|
||||
: Object.entries(scanResult.created)
|
||||
.map(([rule, count]) => `${count} new ${rule.replace(/_/g, " ")}`)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="filters" aria-label="Filter data-quality issues">
|
||||
<label>
|
||||
|
||||
@@ -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