From 477b5e7ce92e97f448f2e437d48e9e14a5486b12 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:16:14 +0200 Subject: [PATCH] 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
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. --- frontend/e2e/interactive-elements.spec.ts | 70 ++++ frontend/src/api/types.ts | 11 + frontend/src/pages/DataQuality.tsx | 67 +++- frontend/src/pages/DataQualityIssueDetail.tsx | 359 +++++++++++++++++- 4 files changed, 490 insertions(+), 17 deletions(-) diff --git a/frontend/e2e/interactive-elements.spec.ts b/frontend/e2e/interactive-elements.spec.ts index 9d9f7f3..4c24ab0 100644 --- a/frontend/e2e/interactive-elements.spec.ts +++ b/frontend/e2e/interactive-elements.spec.ts @@ -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"); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 0bf816f..efa5cf9 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; +} + export interface MergeCustomersRequest { survivor_ref: string; field_overrides?: Record; diff --git a/frontend/src/pages/DataQuality.tsx b/frontend/src/pages/DataQuality.tsx index ccb1fcc..d85c826 100644 --- a/frontend/src/pages/DataQuality.tsx +++ b/frontend/src/pages/DataQuality.tsx @@ -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(null); const [status, setStatus] = useState("open"); const [ruleType, setRuleType] = useState(""); + const [scanning, setScanning] = useState(false); + const [scanError, setScanError] = useState(null); + const [scanResult, setScanResult] = useState(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("/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 (
@@ -43,9 +66,43 @@ export function DataQuality() { ); } + const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0; + return (
- + setConfirmingScan(true)} disabled={scanning}> + Run quality scan + + ) : ( +
+

Run the deterministic scan across all five rule types now?

+ + +
+ ) + } + /> + + {scanError &&

{scanError}

} + {scanResult && ( +

+ 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(", ")} +

+ )}