Files
MobilityOps/frontend/src/pages/DataQuality.tsx
T
NuklearRabbit 81e3fd63bd
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped
M54: harden operations and demo resilience
2026-08-24 03:31:03 +02:00

341 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Page<DataQualityIssue> | null>(null);
const [error, setError] = useState<string | null>(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<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const demoScenariosOnly = searchParams.get("demo") === "true";
const [users, setUsers] = useState<UserRecord[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkAssignee, setBulkAssignee] = useState("");
const [bulkDueAt, setBulkDueAt] = useState("");
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkError, setBulkError] = useState<ApiErrorInfo | null>(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<string, string | boolean | number | null>) {
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<Page<DataQualityIssue>>(`/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<UserRecord[]>("/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<ScanResult>("/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 (
<div className="page">
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("detail.managerOnlyDetail")} />
<p>{t("detail.managerOnlyDetail")}</p>
</div>
);
}
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 (
<div className="page">
<PageHeader
eyebrow={t("list.eyebrow")}
title={t("list.title")}
description={t("list.description")}
actions={
!confirmingScan ? (
<button className="button button-secondary" type="button" onClick={() => setConfirmingScan(true)} disabled={scanning}>
{t("list.runScan")}
</button>
) : (
<div className="confirm-bar" role="alertdialog" aria-label={t("list.confirmScanTitle")}>
<p>{t("list.confirmScanBody")}</p>
<button type="button" onClick={handleScan} disabled={scanning}>
{scanning ? t("list.scanning") : t("list.confirmScanYes")}
</button>
<button type="button" onClick={() => setConfirmingScan(false)} disabled={scanning}>
{t("list.cancel")}
</button>
</div>
)
}
/>
<ApiErrorNotice error={scanError} />
<ApiErrorNotice error={bulkError} />
{scanResult && (
<p className="quiet-empty" role="status">
{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(", "),
})}
</p>
)}
<button className="button button-secondary filter-toggle" type="button" aria-expanded={filtersOpen} aria-controls="quality-filters" onClick={() => setFiltersOpen((open) => !open)}>
{t("list.filtersToggle", { count: activeFilterCount })}
</button>
<form id="quality-filters" className={`filters quality-filters${filtersOpen ? " is-open" : ""}`} aria-label={t("list.title")}>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option>
<option value="open">{t("list.statusOpen")}</option>
<option value="deferred">{t("list.statusDeferred")}</option>
<option value="resolved">{t("list.statusResolved")}</option>
<option value="rejected">{t("list.statusRejected")}</option>
</select>
</label>
<label>
{t("list.assigneeLabel")}
<select value={assignee} onChange={(e) => updateFilters({ assignee: e.target.value, page: 1 })}>
<option value="">{t("list.assigneeAll")}</option>
<option value="unassigned">{t("list.unassigned")}</option>
{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}
</select>
</label>
<label className="checkbox-label">
<input type="checkbox" checked={overdueOnly} onChange={(e) => updateFilters({ overdue: e.target.checked, page: 1 })} />
{t("list.overdueOnly")}
</label>
<label>
{t("list.ruleTypeLabel")}
<select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
<option value="">{t("list.ruleTypeAll")}</option>
{RULE_TYPES.map((r) => (
<option key={r} value={r}>
{t(`ruleTypes.${r}`)}
</option>
))}
</select>
</label>
<label>
{t("list.severityLabel")}
<select value={severity} onChange={(e) => updateFilters({ severity: e.target.value, page: 1 })}>
<option value="">{t("list.severityAll")}</option>
<option value="high">{t("severities.high")}</option>
<option value="medium">{t("severities.medium")}</option>
<option value="low">{t("severities.low")}</option>
</select>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => updateFilters({ demo: e.target.checked, page: 1 })}
/>
{t("list.demoScenariosOnly")}
</label>
</form>
{activeFilterCount > 0 && (
<div className="active-filter-chips" aria-label={t("list.activeFilters")}>
{status !== "open" && <button type="button" onClick={() => updateFilters({ status: "open", page: 1 })}>{t(`list.status${status.charAt(0).toUpperCase()}${status.slice(1)}`)} ×</button>}
{ruleType && <button type="button" onClick={() => updateFilters({ rule_type: null, page: 1 })}>{t(`ruleTypes.${ruleType}`)} ×</button>}
{severity && <button type="button" onClick={() => updateFilters({ severity: null, page: 1 })}>{t(`severities.${severity}`)} ×</button>}
{assignee && <button type="button" onClick={() => updateFilters({ assignee: null, page: 1 })}>{assignee === "unassigned" ? t("list.unassigned") : users.find((record) => record.public_ref === assignee)?.display_name ?? assignee} ×</button>}
{overdueOnly && <button type="button" onClick={() => updateFilters({ overdue: null, page: 1 })}>{t("list.overdueOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => updateFilters({ demo: null, page: 1 })}>{t("list.demoScenariosOnly")} ×</button>}
</div>
)}
{error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label={t("list.loading")} />}
{issues && issues.items.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
{issues && issues.items.length > 0 && visibleIssues.length === 0 && (
<EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
)}
{visibleIssues.length > 0 && (
<div className="table-shell">
{selected.size > 0 && (
<div className="bulk-toolbar" role="region" aria-label={t("list.bulkTitle")}>
<strong>{t("list.selected", { count: selected.size })}</strong>
<label>{t("list.assignTo")}<select value={bulkAssignee} onChange={(event) => setBulkAssignee(event.target.value)}><option value=""></option>{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}</select></label>
<label>{t("list.dueAt")}<input type="datetime-local" aria-invalid={bulkDueAtInvalid} value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
{bulkDueAtInvalid && <span className="error" role="alert">{t("list.invalidDueAt")}</span>}
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt) || bulkDueAtInvalid} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<button type="button" className="button button-secondary" onClick={() => setSelected(new Set())}>{t("list.clearSelection")}</button>
</div>
)}
<div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><button className="select-visible" type="button" onClick={toggleVisibleSelection}>{t(allVisibleSelected ? "list.deselectVisible" : "list.selectVisible")}</button><span>{t("list.evidenceBacked")}</span></div><table className="data-table quality-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
<th scope="col" className="selection-cell"><input type="checkbox" checked={allVisibleSelected} onChange={toggleVisibleSelection} aria-label={t("list.selectVisible")} /></th>
<th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.rule")}</th>
<th scope="col">{t("list.columns.entity")}</th>
<th scope="col">{t("list.columns.severity")}</th>
<th scope="col">{t("list.columns.status")}</th>
<th scope="col">{t("list.columns.assignee")}</th>
<th scope="col">{t("list.columns.due")}</th>
</tr>
</thead>
<tbody>
{visibleIssues.map((i) => (
<tr key={i.public_ref} className="row-clickable">
<td className="selection-cell"><input type="checkbox" checked={selected.has(i.public_ref)} onChange={() => toggleSelected(i.public_ref)} aria-label={t("list.selectIssue", { ref: i.public_ref })} /></td>
<th scope="row" data-label={t("list.columns.reference")}>
<span aria-hidden="true">{i.public_ref}</span>
<Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link>
</th>
<td data-label={t("list.columns.rule")}>{t(`ruleTypes.${i.rule_type}`, { defaultValue: i.rule_type.replace(/_/g, " ") })}</td>
<td data-label={t("list.columns.entity")}>{i.entity_ref}</td>
<td data-label={t("list.columns.severity")}>
<SeverityBadge severity={i.severity} />
</td>
<td data-label={t("list.columns.status")}>
<StatusBadge status={i.status} label={t(`list.status${i.status.charAt(0).toUpperCase()}${i.status.slice(1)}`, { defaultValue: i.status })} />
</td>
<td data-label={t("list.columns.assignee")}>{i.assigned_to_name ?? t("list.unassigned")}</td>
<td data-label={t("list.columns.due")} className={i.overdue ? "is-overdue" : ""}>{i.due_at ? <time dateTime={i.due_at}>{formatDateTime(i.due_at)}</time> : "—"}</td>
</tr>
))}
</tbody>
</table>{issues && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
)}
</div>
);
}