- {item.link_type === "vehicle" ? (
+ {item.issue_ref ? (
+ {item.title}
+ ) : item.link_type === "vehicle" ? (
{item.title}
) : (
item.title
diff --git a/frontend/src/pages/DataQuality.tsx b/frontend/src/pages/DataQuality.tsx
new file mode 100644
index 0000000..bb2d0f5
--- /dev/null
+++ b/frontend/src/pages/DataQuality.tsx
@@ -0,0 +1,96 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { api } from "../api/client";
+import type { DataQualityIssue } from "../api/types";
+import { SeverityBadge, StatusBadge } from "../components/Badge";
+
+const RULE_TYPES = [
+ "possible_duplicate_customer",
+ "missing_required_field",
+ "odometer_regression",
+ "booking_overlap",
+ "vehicle_status_conflict",
+];
+
+export function DataQuality() {
+ const [issues, setIssues] = useState(null);
+ const [error, setError] = useState(null);
+ const [status, setStatus] = useState("open");
+ const [ruleType, setRuleType] = useState("");
+
+ useEffect(() => {
+ const params = new URLSearchParams();
+ if (status) params.set("status", status);
+ if (ruleType) params.set("rule_type", ruleType);
+ api
+ .get(`/api/v1/data-quality/issues?${params.toString()}`)
+ .then(setIssues)
+ .catch(() => setError("Data-quality issues are unavailable right now."));
+ }, [status, ruleType]);
+
+ return (
+
+
Data Quality
+
+
+
+ {error &&
{error}
}
+ {!error && !issues &&
Loading issues…
}
+ {issues && issues.length === 0 &&
No issues match these filters.
}
+
+ {issues && issues.length > 0 && (
+
+ Data-quality issues
+
+
+ | Reference |
+ Rule |
+ Entity |
+ Severity |
+ Status |
+
+
+
+ {issues.map((i) => (
+
+ |
+ {i.public_ref}
+ |
+ {i.rule_type.replace(/_/g, " ")} |
+ {i.entity_ref} |
+
+
+ |
+
+
+ |
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/DataQualityIssueDetail.tsx b/frontend/src/pages/DataQualityIssueDetail.tsx
new file mode 100644
index 0000000..7b57ade
--- /dev/null
+++ b/frontend/src/pages/DataQualityIssueDetail.tsx
@@ -0,0 +1,242 @@
+import { useCallback, useEffect, useState } 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 { SeverityBadge, StatusBadge } from "../components/Badge";
+import { useAuth } from "../context/AuthContext";
+
+const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
+
+function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
+ const { user } = useAuth();
+ const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
+ const [fieldChoices, setFieldChoices] = useState>({});
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [confirming, setConfirming] = useState(false);
+
+ if (!issue.entity_snapshot || !issue.related_snapshots[0]) {
+ return Both customers in this comparison could not be loaded.
;
+ }
+ const a: EntitySnapshot = issue.entity_snapshot;
+ const b: EntitySnapshot = issue.related_snapshots[0];
+
+ const survivor = survivorRef === a.public_ref ? a : b;
+ const loser = survivorRef === a.public_ref ? b : a;
+
+ async function handleMerge() {
+ setError(null);
+ setSubmitting(true);
+ try {
+ const overrides: Record = {};
+ for (const field of MERGE_FIELDS) {
+ const choice = fieldChoices[field];
+ const chosenSide = choice === "a" ? a : choice === "b" ? b : survivor;
+ if (chosenSide !== survivor && chosenSide[field]) {
+ overrides[field] = String(chosenSide[field]);
+ }
+ }
+ await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/merge-customers`, {
+ survivor_ref: survivor.public_ref,
+ field_overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
+ });
+ onResolved();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "Could not merge these customers.");
+ setConfirming(false);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ if (user?.role !== "operations_manager") {
+ return (
+
+ Merging duplicate customers requires the Operations Manager role. Switch role to resolve
+ this issue.
+
+ );
+ }
+
+ return (
+
+ Compare and merge
+ {error && {error}
}
+
+
+
+
+
+
+ {loser.public_ref} will become a tombstone linked to{" "}
+ {survivor.public_ref}; its bookings will be rewired to the survivor.
+
+
+ {!confirming && (
+
+ )}
+ {confirming && (
+
+
+ Merge {loser.public_ref} into {survivor.public_ref}? This cannot be undone.
+
+
+
+
+ )}
+
+ );
+}
+
+export function DataQualityIssueDetail() {
+ const { publicRef } = useParams<{ publicRef: string }>();
+ const [issue, setIssue] = useState(null);
+ const [error, setError] = useState(null);
+ const [actionError, setActionError] = useState(null);
+
+ const load = useCallback(() => {
+ if (!publicRef) return;
+ api
+ .get(`/api/v1/data-quality/issues/${publicRef}`)
+ .then(setIssue)
+ .catch(() => setError("This issue could not be found."));
+ }, [publicRef]);
+
+ useEffect(() => {
+ setIssue(null);
+ setError(null);
+ load();
+ }, [load]);
+
+ async function handleAction(action: "defer" | "reject") {
+ if (!issue) return;
+ setActionError(null);
+ try {
+ await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
+ load();
+ } catch (err) {
+ setActionError(err instanceof ApiError ? err.message : `Could not ${action} this issue.`);
+ }
+ }
+
+ if (error) return {error}
;
+ if (!issue) return Loading issue…
;
+
+ return (
+
+
← Back to data quality
+
{issue.public_ref}
+
+
+
+
+ - Rule
- {issue.rule_type.replace(/_/g, " ")}
+ - Entity
- {issue.entity_ref}
+ - Evidence
- {String(issue.evidence.summary ?? "")}
+
+
+ {actionError &&
{actionError}
}
+
+ {issue.status === "open" && issue.rule_type === "possible_duplicate_customer" && (
+
+ )}
+
+ {issue.status === "open" && (
+
+ Resolution
+ {issue.rule_type !== "possible_duplicate_customer" && (
+ <>
+ Evidence for this issue:
+ {JSON.stringify(issue.evidence, null, 2)}
+ >
+ )}
+ Defer to review later, or reject if this is not a real issue.
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/VehicleDetail.tsx b/frontend/src/pages/VehicleDetail.tsx
index 6a743d3..aba10f5 100644
--- a/frontend/src/pages/VehicleDetail.tsx
+++ b/frontend/src/pages/VehicleDetail.tsx
@@ -112,6 +112,7 @@ export function VehicleDetail() {
{vehicle.quality_issues.length === 0 && No quality issues recorded.}
{vehicle.quality_issues.map((q) => (
+ {q.public_ref}
{q.rule_type.replace(/_/g, " ")}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index d15f42b..1b22dd8 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -188,6 +188,38 @@ a { color: #1f5c8f; }
.return-form button:disabled { opacity: 0.6; cursor: not-allowed; }
.return-result .error { margin-top: 12px; }
+.duplicate-compare fieldset {
+ border: 1px solid #dce3eb; border-radius: 10px; padding: 12px 16px; margin: 12px 0;
+}
+.duplicate-compare legend { font-weight: 700; color: #375065; padding: 0 6px; }
+.duplicate-compare fieldset label { margin-right: 20px; }
+.compare-table th, .compare-table td { vertical-align: top; }
+.compare-table label { display: inline-flex; align-items: center; gap: 6px; font-weight: 400; }
+.duplicate-compare > button {
+ padding: 12px 20px; border-radius: 10px; border: none;
+ background: #14324f; color: white; font-weight: 700; cursor: pointer; margin-top: 12px;
+}
+.confirm-bar {
+ margin-top: 12px; padding: 14px; border-radius: 10px;
+ background: #fdf1de; border: 1px solid #f0d29e;
+}
+.confirm-bar p { margin: 0 0 10px; font-weight: 600; color: #8a5a10; }
+.confirm-bar button {
+ padding: 10px 16px; border-radius: 8px; border: none; font-weight: 700; cursor: pointer; margin-right: 8px;
+}
+.confirm-bar button:first-of-type { background: #9a2530; color: white; }
+.confirm-bar button:last-of-type { background: white; border: 1px solid #cfd8e2; }
+
+.evidence-block {
+ background: #f6f8fb; border: 1px solid #dce3eb; border-radius: 10px;
+ padding: 12px; overflow-x: auto; font-size: 0.85rem;
+}
+.resolution-actions { display: flex; gap: 10px; margin-top: 12px; }
+.resolution-actions button {
+ padding: 10px 18px; border-radius: 8px; border: 1px solid #cfd8e2;
+ background: white; font-weight: 700; cursor: pointer;
+}
+
@media (max-width: 700px) {
.app-header { flex-direction: column; align-items: flex-start; }
.user-badge { margin-left: 0; }