M54: harden operations and demo resilience
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-24 03:31:03 +02:00
parent b0706989db
commit 81e3fd63bd
101 changed files with 5641 additions and 828 deletions
+39 -21
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -9,7 +9,7 @@ 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 { brusselsLocalToIso } from "../i18n/brusselsDateTime";
import { tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -36,7 +36,7 @@ export function DataQuality() {
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
const demoScenariosOnly = searchParams.get("demo") === "true";
const [users, setUsers] = useState<UserRecord[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkAssignee, setBulkAssignee] = useState("");
@@ -44,6 +44,12 @@ export function DataQuality() {
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);
@@ -54,7 +60,7 @@ export function DataQuality() {
setSearchParams(next, { replace: true });
}
const load = useCallback(() => {
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.
@@ -68,35 +74,46 @@ export function DataQuality() {
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()}`)
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`, { signal: controller.signal })
.then(setIssues)
.catch(() => setError(t("list.unavailable")));
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user, t]);
useEffect(() => {
load();
}, [load]);
.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;
api.get<UserRecord[]>("/api/v1/users").then((records) => setUsers(records.filter((record) => record.active))).catch(() => setUsers([]));
}, [user]);
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)) return;
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: bulkDueAt ? brusselsLocalToIso(bulkDueAt) : undefined,
due_at: bulkDueAtIso ?? undefined,
});
setSelected(new Set());
setBulkAssignee("");
setBulkDueAt("");
load();
setRefreshVersion((version) => version + 1);
} catch (err) {
setBulkError(describeApiError(t, err, "list.bulkFailed"));
} finally {
@@ -129,7 +146,7 @@ export function DataQuality() {
const result = await api.post<ScanResult>("/api/v1/data-quality/scan");
setScanResult(result);
setConfirmingScan(false);
load();
setRefreshVersion((version) => version + 1);
} catch (err) {
setScanError(describeApiError(t, err, "list.scanFailed"));
} finally {
@@ -246,7 +263,7 @@ export function DataQuality() {
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => { setDemoScenariosOnly(e.target.checked); updateFilters({ demo: e.target.checked, page: 1 }); }}
onChange={(e) => updateFilters({ demo: e.target.checked, page: 1 })}
/>
{t("list.demoScenariosOnly")}
</label>
@@ -258,7 +275,7 @@ export function DataQuality() {
{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={() => { setDemoScenariosOnly(false); updateFilters({ demo: null, page: 1 }); }}>{t("list.demoScenariosOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => updateFilters({ demo: null, page: 1 })}>{t("list.demoScenariosOnly")} ×</button>}
</div>
)}
@@ -275,8 +292,9 @@ export function DataQuality() {
<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" value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt)} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<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>
)}