polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul

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>
This commit is contained in:
NuklearRabbit
2026-08-03 18:33:22 +02:00
co-authored by Claude Sonnet 5
parent 257a4cf6c0
commit 337f8716bb
127 changed files with 5529 additions and 1287 deletions
+144 -181
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import type {
ApplyRecommendedStatusResult,
@@ -10,70 +11,42 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
const RULE_EXPLAINERS: Record<string, { whatIsWrong: string; whyItMatters: string }> = {
possible_duplicate_customer: {
whatIsWrong:
"Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.",
whyItMatters:
"Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations.",
},
missing_required_field: {
whatIsWrong:
"This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).",
whyItMatters:
"Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks.",
},
odometer_regression: {
whatIsWrong: "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.",
whyItMatters:
"A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history.",
},
booking_overlap: {
whatIsWrong: "The same vehicle is committed to two bookings whose date ranges overlap.",
whyItMatters:
"Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else.",
},
vehicle_status_conflict: {
whatIsWrong:
"This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.",
whyItMatters:
"An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet.",
},
};
function RuleExplainer({ ruleType }: { ruleType: string }) {
const explainer = RULE_EXPLAINERS[ruleType];
if (!explainer) return null;
const { t } = useTranslation("quality");
if (!t(`detail.explainer.${ruleType}.whatIsWrong`, { defaultValue: "" })) return null;
return (
<section className="rule-explainer" aria-label="Why this matters">
<section className="rule-explainer" aria-label={t("detail.explainer.whyItMatters")}>
<div>
<strong>What's wrong</strong>
<p>{explainer.whatIsWrong}</p>
<strong>{t("detail.explainer.whatIsWrong")}</strong>
<p>{t(`detail.explainer.${ruleType}.whatIsWrong`)}</p>
</div>
<div>
<strong>Why it matters</strong>
<p>{explainer.whyItMatters}</p>
<strong>{t("detail.explainer.whyItMatters")}</strong>
<p>{t(`detail.explainer.${ruleType}.whyItMatters`)}</p>
</div>
</section>
);
}
function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("common");
return (
<details className="evidence-disclosure">
<summary>Technical evidence</summary>
<summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{JSON.stringify(issue.evidence, null, 2)}</pre>
</details>
);
}
function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { user } = useAuth();
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
@@ -82,7 +55,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const [confirming, setConfirming] = useState(false);
if (!issue.entity_snapshot || !issue.related_snapshots[0]) {
return <p className="error">Both customers in this comparison could not be loaded.</p>;
return <p className="error">{t("detail.duplicateCustomer.bothMissing")}</p>;
}
const a: EntitySnapshot = issue.entity_snapshot;
const b: EntitySnapshot = issue.related_snapshots[0];
@@ -108,7 +81,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not merge these customers.");
setError(err instanceof ApiError ? err.message : t("detail.duplicateCustomer.mergeFailed"));
setConfirming(false);
} finally {
setSubmitting(false);
@@ -117,44 +90,41 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
if (user?.role !== "operations_manager") {
return (
<p className="panel">
Merging duplicate customers requires the Operations Manager role. Switch role to resolve
this issue.
</p>
<p className="panel">{t("detail.managerOnlyDetail")}</p>
);
}
return (
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
<SectionHeading title="Compare and merge" description="Choose the canonical customer and review each conflicting field." />
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
{error && <p className="error" role="alert">{error}</p>}
<fieldset>
<legend>Keep as survivor</legend>
<label className="checkbox-label">
<fieldset className="choice-fieldset">
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
<label className={`choice-card ${survivorRef === a.public_ref ? "is-selected" : ""}`}>
<input
type="radio"
name="survivor"
checked={survivorRef === a.public_ref}
onChange={() => setSurvivorRef(a.public_ref)}
/>
{a.public_ref}
<span className="choice-card-title">{a.public_ref}</span>
</label>
<label className="checkbox-label">
<label className={`choice-card ${survivorRef === b.public_ref ? "is-selected" : ""}`}>
<input
type="radio"
name="survivor"
checked={survivorRef === b.public_ref}
onChange={() => setSurvivorRef(b.public_ref)}
/>
{b.public_ref}
<span className="choice-card-title">{b.public_ref}</span>
</label>
</fieldset>
<table className="data-table compare-table">
<thead>
<tr>
<th scope="col">Field</th>
<th scope="col">{t("detail.duplicateCustomer.fieldColumn")}</th>
<th scope="col">{a.public_ref}</th>
<th scope="col">{b.public_ref}</th>
</tr>
@@ -166,7 +136,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const differ = valueA !== valueB;
return (
<tr key={field}>
<th scope="row" data-label="Field">{field.replace(/_/g, " ")}{differ ? <span className="difference-mark">Differs</span> : <span className="match-mark">Match</span>}</th>
<th scope="row" data-label="Field">{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
<td data-label={a.public_ref}>
{differ ? (
<label className="checkbox-label">
@@ -204,25 +174,22 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
</table>
<p className="merge-preview">
<strong>{loser.public_ref}</strong> will become a tombstone linked to{" "}
<strong>{survivor.public_ref}</strong>; its bookings will be rewired to the survivor.
{t("detail.duplicateCustomer.mergePreview", { loser: loser.public_ref, survivor: survivor.public_ref })}
</p>
{!confirming && (
<button type="button" onClick={() => setConfirming(true)}>
Merge into {survivor.public_ref}
<button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
{t("detail.duplicateCustomer.mergeInto", { ref: survivor.public_ref })}
</button>
)}
{confirming && (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm merge">
<p>
Merge {loser.public_ref} into {survivor.public_ref}? This cannot be undone.
</p>
<button type="button" onClick={handleMerge} disabled={submitting}>
{submitting ? "Merging…" : "Yes, merge"}
<div className="confirm-bar" role="alertdialog" aria-label={t("detail.duplicateCustomer.confirmMergeTitle")}>
<p>{t("detail.duplicateCustomer.confirmMergeBody", { loser: loser.public_ref, survivor: survivor.public_ref })}</p>
<button type="button" className="button button-primary" onClick={handleMerge} disabled={submitting}>
{submitting ? t("detail.duplicateCustomer.merging") : t("detail.duplicateCustomer.confirmMergeYes")}
</button>
<button type="button" onClick={() => setConfirming(false)} disabled={submitting}>
Cancel
<button type="button" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
{t("list.cancel")}
</button>
</div>
)}
@@ -230,26 +197,16 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
);
}
const CUSTOMER_FIELD_LABELS: Record<string, string> = {
first_name: "First name",
last_name: "Last name",
email: "Email",
phone: "Phone",
};
const VEHICLE_FIELD_LABELS: Record<string, string> = {
registration_number: "Registration number",
make: "Make",
model: "Model",
location: "Location",
};
function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const isCustomer = issue.entity_type === "customer";
const labels = isCustomer ? CUSTOMER_FIELD_LABELS : VEHICLE_FIELD_LABELS;
const fieldKeys = isCustomer
? ["first_name", "last_name", "email", "phone"]
: ["registration_number", "make", "model", "location"];
const snapshot = issue.entity_snapshot;
const [values, setValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const key of Object.keys(labels)) {
for (const key of fieldKeys) {
initial[key] = snapshot && snapshot[key] ? String(snapshot[key]) : "";
}
return initial;
@@ -268,7 +225,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not save these fields.");
setError(err instanceof ApiError ? err.message : t("detail.missingField.saveFailed"));
} finally {
setSubmitting(false);
}
@@ -277,14 +234,15 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="missing-field-heading">
<SectionHeading
title="Provide the missing fields"
description={`Complete the record for ${snapshot?.public_ref ?? issue.entity_ref}. The issue resolves automatically once nothing required is missing.`}
headingId="missing-field-heading"
title={t("detail.missingField.heading")}
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
/>
{error && <p className="error" role="alert">{error}</p>}
<div className="form-grid">
{Object.entries(labels).map(([field, label]) => (
{fieldKeys.map((field) => (
<label key={field}>
{label}
{t(`detail.missingField.fields.${field}`)}
<input
type="text"
value={values[field] ?? ""}
@@ -294,11 +252,11 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
))}
</div>
{isCustomer && (
<p className="table-subtext">At least one of email or phone is required.</p>
<p className="table-subtext">{t("detail.missingField.atLeastOne")}</p>
)}
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Save and re-check"}
{submitting ? t("detail.missingField.saving") : t("detail.missingField.saveAndRecheck")}
</button>
</div>
</form>
@@ -306,6 +264,8 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
}
function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const bookingSnapshots = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [decision, setDecision] = useState<"retain_canonical" | "correct_reading">("retain_canonical");
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
@@ -328,7 +288,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not resolve this issue.");
setError(err instanceof ApiError ? err.message : t("detail.odometerRegression.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -337,25 +297,29 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="odometer-heading">
<SectionHeading
title="Resolve the odometer regression"
description="The canonical odometer is never lowered automatically -- choose how to reconcile it."
headingId="odometer-heading"
title={t("detail.odometerRegression.heading")}
description={t("detail.odometerRegression.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<dl className="detail-grid">
<div><dt>Canonical odometer</dt><dd>{Number(issue.entity_snapshot?.odometer_km ?? 0).toLocaleString("en-GB")} km</dd></div>
<div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
</dl>
<fieldset>
<legend>Decision</legend>
<label className="checkbox-label check-card">
<fieldset className="choice-fieldset">
<legend>{t("detail.odometerRegression.decisionLegend")}</legend>
<label className={`choice-card ${decision === "retain_canonical" ? "is-selected" : ""}`}>
<input
type="radio"
name="decision"
checked={decision === "retain_canonical"}
onChange={() => setDecision("retain_canonical")}
/>
Retain canonical -- treat the submitted reading as erroneous
<span className="choice-card-body">
<span className="choice-card-title">{t("detail.odometerRegression.retainCanonical")}</span>
<span className="choice-card-detail">{t("detail.odometerRegression.retainCanonicalDetail")}</span>
</span>
</label>
<label className="checkbox-label check-card">
<label className={`choice-card ${decision === "correct_reading" ? "is-selected" : ""} ${bookingSnapshots.length === 0 ? "is-disabled" : ""}`}>
<input
type="radio"
name="decision"
@@ -363,26 +327,29 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
onChange={() => setDecision("correct_reading")}
disabled={bookingSnapshots.length === 0}
/>
Correct the reading -- update the booking and canonical odometer
<span className="choice-card-body">
<span className="choice-card-title">{t("detail.odometerRegression.correctReading")}</span>
<span className="choice-card-detail">{t("detail.odometerRegression.correctReadingDetail")}</span>
</span>
</label>
{bookingSnapshots.length === 0 && (
<p className="table-subtext">No related booking is attached to this issue, so only "retain canonical" is available.</p>
<p className="table-subtext">{t("detail.odometerRegression.noBookingAttached")}</p>
)}
</fieldset>
{decision === "correct_reading" && (
<div className="form-grid">
<label>
Booking
{t("detail.odometerRegression.booking")}
<select value={bookingRef} onChange={(e) => setBookingRef(e.target.value)}>
{bookingSnapshots.map((snap) => (
<option key={snap.public_ref} value={snap.public_ref}>
{snap.public_ref} ({typeof snap.end_odometer_km === "number" ? snap.end_odometer_km.toLocaleString("en-GB") : "—"} km)
{snap.public_ref} ({typeof snap.end_odometer_km === "number" ? formatNumber(snap.end_odometer_km) : "—"} km)
</option>
))}
</select>
</label>
<label>
Corrected odometer (km)
{t("detail.odometerRegression.correctedOdometer")}
<input
type="number"
min={0}
@@ -394,12 +361,12 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
</div>
)}
<label>
Note
{t("detail.odometerRegression.note")}
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label>
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? "Resolving…" : "Resolve issue"}
{submitting ? t("detail.odometerRegression.resolving") : t("detail.odometerRegression.resolveIssue")}
</button>
</div>
</form>
@@ -407,6 +374,8 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
}
function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatShortDate } = useLocaleFormat();
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
const [note, setNote] = useState("");
@@ -424,7 +393,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not resolve this overlap.");
setError(err instanceof ApiError ? err.message : t("detail.bookingOverlap.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -433,50 +402,41 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="overlap-heading">
<SectionHeading
title="Resolve the booking overlap"
description="Block one of the two overlapping commitments. The other keeps its current status."
headingId="overlap-heading"
title={t("detail.bookingOverlap.heading")}
description={t("detail.bookingOverlap.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<table className="data-table compare-table">
<thead>
<tr>
<th scope="col">Booking</th>
<th scope="col">Window</th>
<th scope="col">Status</th>
<th scope="col">Block this one</th>
</tr>
</thead>
<tbody>
{bookings.map((b) => (
<tr key={b.public_ref}>
<th scope="row">{b.public_ref}</th>
<td>
{b.starts_at ? new Date(String(b.starts_at)).toLocaleDateString("en-GB") : "—"} →{" "}
{b.ends_at ? new Date(String(b.ends_at)).toLocaleDateString("en-GB") : "—"}
</td>
<td><StatusBadge status={String(b.status)} /></td>
<td>
<label className="checkbox-label">
<input
type="radio"
name="overlap-booking"
checked={bookingRef === b.public_ref}
onChange={() => setBookingRef(b.public_ref)}
/>
<span className="visually-hidden">Block {b.public_ref}</span>
</label>
</td>
</tr>
))}
</tbody>
</table>
<fieldset className="choice-fieldset choice-fieldset-grid">
<legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
{bookings.map((b) => (
<label key={b.public_ref} className={`choice-card ${bookingRef === b.public_ref ? "is-selected" : ""}`}>
<input
type="radio"
name="overlap-booking"
checked={bookingRef === b.public_ref}
onChange={() => setBookingRef(b.public_ref)}
aria-label={`${t("detail.bookingOverlap.blockLabel", { ref: b.public_ref })}, ${
b.starts_at ? formatShortDate(String(b.starts_at)) : "—"
}${b.ends_at ? formatShortDate(String(b.ends_at)) : "—"}, ${b.status}`}
/>
<span className="choice-card-body">
<span className="choice-card-title">{b.public_ref}</span>
<span className="choice-card-detail">
{b.starts_at ? formatShortDate(String(b.starts_at)) : "—"} {b.ends_at ? formatShortDate(String(b.ends_at)) : "—"}
</span>
<StatusBadge status={String(b.status)} label={t(`bookings:statuses.${b.status}`, { defaultValue: String(b.status) })} />
</span>
</label>
))}
</fieldset>
<label>
Note
{t("detail.bookingOverlap.note")}
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label>
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting || !bookingRef}>
{submitting ? "Resolving…" : `Block ${bookingRef || "booking"}`}
{submitting ? t("detail.bookingOverlap.resolving") : t("detail.bookingOverlap.blockButton", { ref: bookingRef || t("detail.bookingOverlap.blockButtonFallback") })}
</button>
</div>
</form>
@@ -484,6 +444,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
}
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
@@ -514,7 +475,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
setResult(applied);
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not apply a recommended status.");
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
setConfirming(false);
} finally {
setSubmitting(false);
@@ -524,40 +485,41 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
return (
<section className="panel" aria-labelledby="status-conflict-heading">
<SectionHeading
title="Resolve the status conflict"
description="One authoritative rule recommends a corrected operational status for this vehicle."
headingId="status-conflict-heading"
title={t("detail.vehicleStatusConflict.heading")}
description={t("detail.vehicleStatusConflict.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<dl className="detail-grid">
<div><dt>Current status</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} /></dd></div>
<div><dt>{t("detail.vehicleStatusConflict.currentStatus")}</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} label={t(`fleet:statuses.${issue.entity_snapshot?.operational_status}`, { defaultValue: String(issue.entity_snapshot?.operational_status ?? "") })} /></dd></div>
</dl>
{result ? (
<>
<p className="quiet-empty">
<Icon name="check" /> Applied <StatusBadge status={result.applied_status} /> {result.reason}
<Icon name="check" /> {t("detail.vehicleStatusConflict.applied", { status: result.applied_status, reason: result.reason })}
</p>
<div className="result-links">
<Link className="button button-secondary" to="/audit">View audit trail<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>View vehicle<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>{t("detail.resolved.viewVehicle")}<Icon name="chevron" /></Link>
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" />
{t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
</>
) : !confirming ? (
<button type="button" onClick={() => setConfirming(true)}>
Calculate and apply recommended status
<button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
{t("detail.vehicleStatusConflict.calculateAndApply")}
</button>
) : (
<div className="confirm-bar" role="alertdialog" aria-label="Confirm status change">
<p>Apply the authoritative recommended status for this vehicle?</p>
<button type="button" onClick={handleApply} disabled={submitting}>
{submitting ? "Applying" : "Yes, apply"}
<div className="confirm-bar" role="alertdialog" aria-label={t("detail.vehicleStatusConflict.confirmTitle")}>
<p>{t("detail.vehicleStatusConflict.confirmBody")}</p>
<button type="button" className="button button-primary" onClick={handleApply} disabled={submitting}>
{submitting ? t("detail.vehicleStatusConflict.applying") : t("detail.vehicleStatusConflict.confirmYes")}
</button>
<button type="button" onClick={() => setConfirming(false)} disabled={submitting}>
Cancel
<button type="button" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
{t("list.cancel")}
</button>
</div>
)}
@@ -566,6 +528,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
}
export function DataQualityIssueDetail() {
const { t } = useTranslation("quality");
const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
@@ -581,7 +544,7 @@ export function DataQualityIssueDetail() {
api
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
.then(setIssue)
.catch(() => setError("This issue could not be found."));
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
useEffect(() => {
@@ -610,30 +573,30 @@ export function DataQualityIssueDetail() {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
load();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : `Could not ${action} this issue.`);
setActionError(err instanceof ApiError ? err.message : t(`detail.deferOrReject.${action}Failed`));
}
}
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow="Assurance / Workbench" title="Data quality issue" description="The quality workbench is visible to Operations Managers only." />
<p>Data-quality evidence and resolutions are visible to Operations Managers only.</p>
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("detail.managerOnly")} />
<p>{t("detail.managerOnlyDetail")}</p>
</div>
);
}
if (error) return <ErrorState message={error} />;
if (!issue) return <LoadingState label="Loading issue evidence…" />;
if (!issue) return <LoadingState label={t("detail.loading")} />;
return (
<div className="page">
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> Quality workbench</Link>
<PageHeader eyebrow={`Quality / ${issue.rule_type.replace(/_/g, " ")}`} title={issue.public_ref} description="Review persisted evidence and record an audited resolution." actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} /></div>} />
<section className="record-surface" aria-label="Issue summary"><dl className="detail-grid">
<div><dt>Rule</dt><dd>{issue.rule_type.replace(/_/g, " ")}</dd></div>
<div><dt>Entity</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
<div><dt>Evidence summary</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow={t("detail.eyebrow", { rule: t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") }) })} title={issue.public_ref} description={t("detail.title")} actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} label={t(`list.status${issue.status.charAt(0).toUpperCase()}${issue.status.slice(1)}`, { defaultValue: issue.status })} /></div>} />
<section className="record-surface" aria-label={t("detail.summary.rule")}><dl className="detail-grid">
<div><dt>{t("detail.summary.rule")}</dt><dd>{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}</dd></div>
<div><dt>{t("detail.summary.entity")}</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
<div><dt>{t("detail.summary.evidenceSummary")}</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
</dl>
<EvidenceDisclosure issue={issue} /></section>
@@ -643,16 +606,16 @@ export function DataQualityIssueDetail() {
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
<section className="panel success-panel" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Resolved</p><h2>Issue {issue.public_ref} resolved</h2></div></div>
<p>The change has been applied and is recorded in the audit trail.</p>
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("list.statusResolved")}</p><h2>{t("detail.resolved.title", { ref: issue.public_ref })}</h2></div></div>
<p>{t("detail.resolved.body")}</p>
<div className="result-links">
<Link className="button button-secondary" to="/audit">View audit trail<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
{issue.entity_type === "vehicle" && (
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>View vehicle<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>{t("detail.resolved.viewVehicle")}<Icon name="chevron" /></Link>
)}
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
Ga verder met de demo <Icon name="chevron" />
{t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
@@ -676,15 +639,15 @@ export function DataQualityIssueDetail() {
)}
{issue.status === "open" && (
<section className="panel" aria-labelledby="resolution-heading">
<h2 id="resolution-heading">Defer or reject</h2>
<p>Defer to review later, or reject if this is not a real issue.</p>
<section className="panel defer-reject-panel" aria-labelledby="resolution-heading">
<h2 id="resolution-heading">{t("detail.deferOrReject.heading")}</h2>
<p>{t("detail.deferOrReject.description")}</p>
<div className="resolution-actions">
<button type="button" onClick={() => handleAction("defer")}>
Defer
<button type="button" className="button-tertiary" onClick={() => handleAction("defer")}>
{t("detail.deferOrReject.defer")}
</button>
<button type="button" onClick={() => handleAction("reject")}>
Reject
<button type="button" className="button-tertiary button-tertiary-destructive" onClick={() => handleAction("reject")}>
{t("detail.deferOrReject.reject")}
</button>
</div>
</section>