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:
co-authored by
Claude Sonnet 5
parent
257a4cf6c0
commit
337f8716bb
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user