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

+ )}