341 lines
17 KiB
TypeScript
341 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|