Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with eager-bundled per-namespace resources, a persisted accessible language switcher (topbar and mobile drawer), locale-aware date/number formatting, and a coverage test that fails the build on any missing or empty translation key. Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves from fixed English/Dutch prose to stable message codes + params so the frontend can localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus (11 documents each) with per-language retrieval and localized evidence-state messages. The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a floating panel that auto-collapses to a persistent, closable progress chip on standard desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+ highlight on "go to this step", Escape handling, and reduced-motion support. The Data Quality Workbench gets accessible choice-card decisions with a clear primary/ secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and uses meaningful short refs; the Audit trail groups events by correlation id with human action labels and readable before/after diffs. Attention Queue, Today's movements, Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern) with independent secondary links, keyboard support and mobile touch targets. Fixes a topbar overflow on mobile caused by the new language switcher (moved into the mobile drawer at <=960px) and two dangling aria-labelledby references introduced this session. Updates all affected Playwright specs for the new nl-BE default and the new Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
191 lines
7.5 KiB
TypeScript
191 lines
7.5 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api, ApiError } from "../api/client";
|
|
import type { DataQualityIssue, ScanResult } from "../api/types";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
|
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
|
|
|
const RULE_TYPES = [
|
|
"possible_duplicate_customer",
|
|
"missing_required_field",
|
|
"odometer_regression",
|
|
"booking_overlap",
|
|
"vehicle_status_conflict",
|
|
];
|
|
|
|
export function DataQuality() {
|
|
const { t } = useTranslation("quality");
|
|
const { user } = useAuth();
|
|
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [status, setStatus] = useState("open");
|
|
const [ruleType, setRuleType] = useState("");
|
|
const [scanning, setScanning] = useState(false);
|
|
const [scanError, setScanError] = useState<string | null>(null);
|
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
|
const [confirmingScan, setConfirmingScan] = useState(false);
|
|
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
|
|
|
|
const load = useCallback(() => {
|
|
if (user?.role !== "operations_manager") return;
|
|
setIssues(null);
|
|
setError(null);
|
|
const params = new URLSearchParams();
|
|
if (status) params.set("status", status);
|
|
if (ruleType) params.set("rule_type", ruleType);
|
|
api
|
|
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
|
|
.then(setIssues)
|
|
.catch(() => setError(t("list.unavailable")));
|
|
}, [status, ruleType, user]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
async function handleScan() {
|
|
setScanError(null);
|
|
setScanning(true);
|
|
try {
|
|
const result = await api.post<ScanResult>("/api/v1/data-quality/scan");
|
|
setScanResult(result);
|
|
setConfirmingScan(false);
|
|
load();
|
|
} catch (err) {
|
|
setScanError(err instanceof ApiError ? err.message : t("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;
|
|
const visibleIssues = issues
|
|
? demoScenariosOnly
|
|
? issues.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
|
|
: issues
|
|
: [];
|
|
|
|
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>
|
|
)
|
|
}
|
|
/>
|
|
|
|
{scanError && <p className="error" role="alert">{scanError}</p>}
|
|
{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>
|
|
)}
|
|
|
|
<form className="filters" aria-label={t("list.title")}>
|
|
<label>
|
|
{t("list.statusLabel")}
|
|
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
|
<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.ruleTypeLabel")}
|
|
<select value={ruleType} onChange={(e) => setRuleType(e.target.value)}>
|
|
<option value="">{t("list.ruleTypeAll")}</option>
|
|
{RULE_TYPES.map((r) => (
|
|
<option key={r} value={r}>
|
|
{t(`ruleTypes.${r}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="checkbox-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={demoScenariosOnly}
|
|
onChange={(e) => setDemoScenariosOnly(e.target.checked)}
|
|
/>
|
|
{t("list.demoScenariosOnly")}
|
|
</label>
|
|
</form>
|
|
|
|
{error && <ErrorState message={error} />}
|
|
{!error && !issues && <LoadingState label={t("list.loading")} />}
|
|
{issues && issues.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
|
|
{issues && issues.length > 0 && visibleIssues.length === 0 && (
|
|
<EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
|
|
)}
|
|
|
|
{visibleIssues.length > 0 && (
|
|
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
|
|
<caption className="visually-hidden">{t("list.title")}</caption>
|
|
<thead>
|
|
<tr>
|
|
<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>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleIssues.map((i) => (
|
|
<tr key={i.public_ref} className="row-clickable">
|
|
<th scope="row" data-label={t("list.columns.reference")}>
|
|
{i.public_ref}
|
|
<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>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table></div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|