M54: harden operations and demo resilience
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user