import { useEffect, useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { api } from "../api/client"; import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; import type { DataQualityIssue, Page, ScanResult, UserRecord } from "../api/types"; import { useAuth } from "../context/AuthContext"; import { SeverityBadge, StatusBadge } from "../components/Badge"; import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { Pagination } from "../components/Pagination"; import { useLocaleFormat } from "../i18n/format"; import { tryBrusselsLocalToIso } from "../i18n/brusselsDateTime"; const RULE_TYPES = [ "possible_duplicate_customer", "missing_required_field", "odometer_regression", "booking_overlap", "vehicle_status_conflict", ]; export function DataQuality() { const { t } = useTranslation("quality"); const { formatDateTime } = useLocaleFormat(); const { user } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const [issues, setIssues] = useState | null>(null); const [error, setError] = useState(null); const status = searchParams.get("status") ?? "open"; const ruleType = searchParams.get("rule_type") ?? ""; const severity = searchParams.get("severity") ?? ""; const assignee = searchParams.get("assignee") ?? ""; const overdueOnly = searchParams.get("overdue") === "true"; const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); const [scanResult, setScanResult] = useState(null); const [confirmingScan, setConfirmingScan] = useState(false); const demoScenariosOnly = searchParams.get("demo") === "true"; const [users, setUsers] = useState([]); const [selected, setSelected] = useState>(new Set()); const [bulkAssignee, setBulkAssignee] = useState(""); const [bulkDueAt, setBulkDueAt] = useState(""); const [bulkSaving, setBulkSaving] = useState(false); const [bulkError, setBulkError] = useState(null); const [filtersOpen, setFiltersOpen] = useState(false); const [refreshVersion, setRefreshVersion] = useState(0); const bulkDueAtIso = useMemo( () => bulkDueAt ? tryBrusselsLocalToIso(bulkDueAt) : null, [bulkDueAt], ); const bulkDueAtInvalid = Boolean(bulkDueAt && !bulkDueAtIso); function updateFilters(updates: Record) { const next = new URLSearchParams(searchParams); Object.entries(updates).forEach(([key, value]) => { if (value === null || value === "" || value === false) next.delete(key); else next.set(key, String(value)); }); setSearchParams(next, { replace: true }); } useEffect(() => { if (user?.role !== "operations_manager") return; // Keep the current rows visible while a filter change refetches (stale-while-revalidate); // the initial load still shows the loading state because `issues` starts as null. setError(null); const params = new URLSearchParams(); if (status) params.set("status", status); if (ruleType) params.set("rule_type", ruleType); if (severity) params.set("severity", severity); if (assignee) params.set("assigned_to_ref", assignee); if (overdueOnly) params.set("overdue", "true"); if (demoScenariosOnly) params.set("demo_only", "true"); params.set("page", String(page)); params.set("page_size", "25"); const controller = new AbortController(); api .get>(`/api/v1/data-quality/issues?${params.toString()}`, { signal: controller.signal }) .then(setIssues) .catch(() => { if (!controller.signal.aborted) setError(t("list.unavailable")); }); return () => controller.abort(); }, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user?.role, t, refreshVersion]); useEffect(() => { if (user?.role !== "operations_manager") return; const controller = new AbortController(); api.get("/api/v1/users", { signal: controller.signal }) .then((records) => setUsers(records.filter((record) => record.active))) .catch(() => { if (!controller.signal.aborted) setUsers([]); }); return () => controller.abort(); }, [user?.role]); useEffect(() => { // Bulk actions must never retain invisible rows after URL-driven filtering/history. setSelected(new Set()); }, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page]); async function applyBulkWork() { if (selected.size === 0 || (!bulkAssignee && !bulkDueAt) || bulkDueAtInvalid) return; setBulkSaving(true); setBulkError(null); try { await api.post("/api/v1/data-quality/issues/bulk-work", { issue_refs: [...selected], assigned_to_ref: bulkAssignee || undefined, due_at: bulkDueAtIso ?? undefined, }); setSelected(new Set()); setBulkAssignee(""); setBulkDueAt(""); setRefreshVersion((version) => version + 1); } catch (err) { setBulkError(describeApiError(t, err, "list.bulkFailed")); } finally { setBulkSaving(false); } } function toggleSelected(ref: string) { setSelected((current) => { const next = new Set(current); if (next.has(ref)) next.delete(ref); else next.add(ref); return next; }); } function toggleVisibleSelection() { setSelected((current) => { const visibleRefs = visibleIssues.map((issue) => issue.public_ref); const allSelected = visibleRefs.every((ref) => current.has(ref)); const next = new Set(current); visibleRefs.forEach((ref) => allSelected ? next.delete(ref) : next.add(ref)); return next; }); } async function handleScan() { setScanError(null); setScanning(true); try { const result = await api.post("/api/v1/data-quality/scan"); setScanResult(result); setConfirmingScan(false); setRefreshVersion((version) => version + 1); } catch (err) { setScanError(describeApiError(t, err, "list.scanFailed")); } finally { setScanning(false); } } if (user?.role !== "operations_manager") { return (

{t("detail.managerOnlyDetail")}

); } const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0; // The demo-scenario filter is applied server-side (so it spans all pages); the client-side // guard only bridges the moment between toggling the box and the filtered page arriving. const visibleIssues = issues ? demoScenariosOnly ? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-")) : issues.items : []; const activeFilterCount = [status !== "open", Boolean(ruleType), Boolean(severity), Boolean(assignee), overdueOnly, demoScenariosOnly].filter(Boolean).length; const allVisibleSelected = visibleIssues.length > 0 && visibleIssues.every((issue) => selected.has(issue.public_ref)); return (
setConfirmingScan(true)} disabled={scanning}> {t("list.runScan")} ) : (

{t("list.confirmScanBody")}

) } /> {scanResult && (

{t("list.scanComplete", { summary: scanTotal === 0 ? t("list.scanNoNew") : Object.entries(scanResult.created) .map(([rule, count]) => `${count} ${t(`ruleTypes.${rule}`, { defaultValue: rule.replace(/_/g, " ") })}`) .join(", "), })}

)}
{activeFilterCount > 0 && (
{status !== "open" && } {ruleType && } {severity && } {assignee && } {overdueOnly && } {demoScenariosOnly && }
)} {error && } {!error && !issues && } {issues && issues.items.length === 0 && } {issues && issues.items.length > 0 && visibleIssues.length === 0 && ( )} {visibleIssues.length > 0 && (
{selected.size > 0 && (
{t("list.selected", { count: selected.size })} {bulkDueAtInvalid && {t("list.invalidDueAt")}}
)}
{t("list.count", { count: issues?.total ?? visibleIssues.length })}{t("list.evidenceBacked")}
{visibleIssues.map((i) => ( ))}
{t("list.title")}
{t("list.columns.reference")} {t("list.columns.rule")} {t("list.columns.entity")} {t("list.columns.severity")} {t("list.columns.status")} {t("list.columns.assignee")} {t("list.columns.due")}
toggleSelected(i.public_ref)} aria-label={t("list.selectIssue", { ref: i.public_ref })} /> {i.public_ref} {t(`ruleTypes.${i.rule_type}`, { defaultValue: i.rule_type.replace(/_/g, " ") })} {i.entity_ref} {i.assigned_to_name ?? t("list.unassigned")} {i.due_at ? : "—"}
{issues && updateFilters({ page: nextPage })} />}
)}
); }