Files
MobilityOps/frontend/src/pages/DataQualityIssueDetail.tsx
T
NuklearRabbitandClaude Sonnet 5 4faac24b5a fix: localize dashboard evidence, explain blocked vehicles, clarify pending odometers
Three content defects found by a live reviewer:

- Dashboard attention subtext was raw, untranslated evidence.summary text, and for
  11 of 15 seeded issues that text was literally "Synthetic deterministic seed
  issue". AttentionItem now exposes evidence_signals (stable code + params, same
  shape as the issue detail page) instead of a detail string; the frontend renders
  them through a shared describeEvidenceSignal() used by both the dashboard and the
  issue detail page. Every previously-placeholder seed row now cites a real,
  per-rule-type fact (a genuinely crossed service threshold, a genuinely blank
  field, or a real pair of booking odometer readings) instead of invented prose.

- 5 of 7 blocked vehicles had no quality issue at all and one had only a resolved
  one, so "needs attention" led nowhere. Each now has a real open
  missing_required_field issue backed by a genuinely blank field (no schema change,
  no migration -- reuses the existing data-quality pipeline).

- Booking odometer fields showing a bare "-" for 25 reserved + 1 active booking now
  show a localized explanation ("trip hasn't started yet" / "not yet closed").
  MO-024's rented-but-service-overdue contradiction was already caught by the
  vehicle-status evaluator (DQ-SCAN, vehicle.manual_review_required) -- added a
  regression test rather than new logic.

Also fixed a related bug the above exposed: the vehicle entity_snapshot omitted
registration_number entirely, so the "provide missing fields" form always showed
it blank regardless of the real value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:44:32 +02:00

843 lines
36 KiB
TypeScript

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 { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type {
ApplyRecommendedStatusResult,
DataQualityIssueDetail as IssueDetail,
EntitySnapshot,
StatusRecommendation,
} from "../api/types";
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 { describeEvidenceSignal } from "../data/evidenceSignals";
import type { EvidenceSignal } from "../api/types";
import { Icon } from "../components/Icons";
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
function RuleExplainer({ ruleType }: { ruleType: string }) {
const { t } = useTranslation("quality");
if (!t(`detail.explainer.${ruleType}.whatIsWrong`, { defaultValue: "" })) return null;
return (
<section className="rule-explainer" aria-label={t("detail.explainer.whyItMatters")}>
<div>
<strong>{t("detail.explainer.whatIsWrong")}</strong>
<p>{t(`detail.explainer.${ruleType}.whatIsWrong`)}</p>
</div>
<div>
<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>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{JSON.stringify(issue.evidence, null, 2)}</pre>
</details>
);
}
// The backend never emits prose for evidence -- only stable signal codes + raw data
// params (see app/services/data_quality.py::_open_issue). describeEvidenceSignal is the
// one place that turns them into the operator's selected language; the legacy
// `evidence.summary` string is a technical fallback only, shown solely inside
// EvidenceDisclosure above.
function EvidenceSignalList({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const signals = (issue.evidence.signals as EvidenceSignal[] | undefined) ?? [];
if (signals.length === 0) return null;
return (
<ul className="evidence-signal-list">
{signals.map((signal, index) => (
<li key={`${signal.code}-${index}`}>{describeEvidenceSignal(t, formatNumber, signal)}</li>
))}
</ul>
);
}
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">>({});
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = useState(false);
const [showMatching, setShowMatching] = useState(false);
if (!issue.entity_snapshot || !issue.related_snapshots[0]) {
return <p className="error">{t("detail.duplicateCustomer.bothMissing")}</p>;
}
const a: EntitySnapshot = issue.entity_snapshot;
const b: EntitySnapshot = issue.related_snapshots[0];
const survivor = survivorRef === a.public_ref ? a : b;
const loser = survivorRef === a.public_ref ? b : a;
const conflictingFields = MERGE_FIELDS.filter((field) => String(a[field] ?? "") !== String(b[field] ?? ""));
const matchingFields = MERGE_FIELDS.filter((field) => !conflictingFields.includes(field));
const visibleFields = showMatching ? MERGE_FIELDS : conflictingFields;
async function handleMerge() {
setError(null);
setSubmitting(true);
try {
const overrides: Record<string, string> = {};
for (const field of MERGE_FIELDS) {
const choice = fieldChoices[field];
const chosenSide = choice === "a" ? a : choice === "b" ? b : survivor;
if (chosenSide !== survivor && chosenSide[field]) {
overrides[field] = String(chosenSide[field]);
}
}
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/merge-customers`, {
survivor_ref: survivor.public_ref,
field_overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
});
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.duplicateCustomer.mergeFailed"));
setConfirming(false);
} finally {
setSubmitting(false);
}
}
if (user?.role !== "operations_manager") {
return (
<p className="panel">{t("detail.managerOnlyDetail")}</p>
);
}
return (
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
<ApiErrorNotice error={error} />
<p className="merge-summary-counts">
{t("detail.duplicateCustomer.summaryCounts", { matchCount: matchingFields.length, conflictCount: conflictingFields.length })}
</p>
<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)}
/>
<span className="choice-card-title">{a.public_ref}</span>
</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)}
/>
<span className="choice-card-title">{b.public_ref}</span>
</label>
</fieldset>
<table className="data-table compare-table">
<thead>
<tr>
<th scope="col">{t("detail.duplicateCustomer.fieldColumn")}</th>
<th scope="col">{a.public_ref}</th>
<th scope="col">{b.public_ref}</th>
</tr>
</thead>
<tbody>
{visibleFields.map((field) => {
const valueA = a[field] ? String(a[field]) : "—";
const valueB = b[field] ? String(b[field]) : "—";
const differ = valueA !== valueB;
return (
<tr key={field}>
<th scope="row" data-label={t("detail.duplicateCustomer.fieldColumn")}>{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">
<input
type="radio"
name={`field-${field}`}
checked={(fieldChoices[field] ?? "a") === "a"}
onChange={() => setFieldChoices((c) => ({ ...c, [field]: "a" }))}
/>
{valueA}
</label>
) : (
valueA
)}
</td>
<td data-label={b.public_ref}>
{differ ? (
<label className="checkbox-label">
<input
type="radio"
name={`field-${field}`}
checked={fieldChoices[field] === "b"}
onChange={() => setFieldChoices((c) => ({ ...c, [field]: "b" }))}
/>
{valueB}
</label>
) : (
valueB
)}
</td>
</tr>
);
})}
</tbody>
</table>
{matchingFields.length > 0 && (
<button type="button" className="button button-tertiary toggle-matching-fields" onClick={() => setShowMatching((v) => !v)}>
{showMatching ? t("detail.duplicateCustomer.hideMatchingFields") : t("detail.duplicateCustomer.showMatchingFields")}
</button>
)}
<p className="merge-preview">
{t("detail.duplicateCustomer.mergePreview", { loser: loser.public_ref, survivor: survivor.public_ref })}
</p>
<div className="merge-record-preview">
<p className="merge-record-preview-title">{t("detail.duplicateCustomer.previewTitle", { ref: survivor.public_ref })}</p>
<dl>
{MERGE_FIELDS.map((field) => {
const choice = fieldChoices[field];
const chosenSide = choice === "a" ? a : choice === "b" ? b : survivor;
const value = (chosenSide[field] ? String(chosenSide[field]) : survivor[field] ? String(survivor[field]) : "—");
return (
<div key={field}>
<dt>{t(`detail.duplicateCustomer.fields.${field}`)}</dt>
<dd>{value || "—"}</dd>
</div>
);
})}
</dl>
</div>
{!confirming && (
<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={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" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
{t("list.cancel")}
</button>
</div>
)}
</section>
);
}
function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const isCustomer = issue.entity_type === "customer";
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 fieldKeys) {
initial[key] = snapshot && snapshot[key] ? String(snapshot[key]) : "";
}
return initial;
});
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const fields = Object.fromEntries(
Object.entries(values).filter(([, value]) => value.trim().length > 0),
);
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.missingField.saveFailed"));
} finally {
setSubmitting(false);
}
}
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="missing-field-heading">
<SectionHeading
headingId="missing-field-heading"
title={t("detail.missingField.heading")}
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
/>
<ApiErrorNotice error={error} />
<div className="form-grid">
{fieldKeys.map((field) => (
<label key={field}>
{t(`detail.missingField.fields.${field}`)}
<input
type="text"
value={values[field] ?? ""}
onChange={(e) => setValues((v) => ({ ...v, [field]: e.target.value }))}
/>
</label>
))}
</div>
{isCustomer && (
<p className="table-subtext">{t("detail.missingField.atLeastOne")}</p>
)}
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? t("detail.missingField.saving") : t("detail.missingField.saveAndRecheck")}
</button>
</div>
</form>
);
}
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 ?? "");
const [correctedValue, setCorrectedValue] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/resolve-odometer-regression`, {
decision,
booking_ref: decision === "correct_reading" ? bookingRef : undefined,
corrected_odometer_km:
decision === "correct_reading" && correctedValue ? Number(correctedValue) : undefined,
note: note || undefined,
});
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.odometerRegression.resolveFailed"));
} finally {
setSubmitting(false);
}
}
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="odometer-heading">
<SectionHeading
headingId="odometer-heading"
title={t("detail.odometerRegression.heading")}
description={t("detail.odometerRegression.description")}
/>
<ApiErrorNotice error={error} />
<dl className="detail-grid">
<div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
</dl>
<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")}
/>
<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={`choice-card ${decision === "correct_reading" ? "is-selected" : ""} ${bookingSnapshots.length === 0 ? "is-disabled" : ""}`}>
<input
type="radio"
name="decision"
checked={decision === "correct_reading"}
onChange={() => setDecision("correct_reading")}
disabled={bookingSnapshots.length === 0}
/>
<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">{t("detail.odometerRegression.noBookingAttached")}</p>
)}
</fieldset>
{decision === "correct_reading" && (
<div className="form-grid">
<label>
{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" ? formatNumber(snap.end_odometer_km) : "—"} km)
</option>
))}
</select>
</label>
<label>
{t("detail.odometerRegression.correctedOdometer")}
<input
type="number"
min={0}
required
value={correctedValue}
onChange={(e) => setCorrectedValue(e.target.value)}
/>
</label>
</div>
)}
<label>
{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 ? t("detail.odometerRegression.resolving") : t("detail.odometerRegression.resolveIssue")}
</button>
</div>
</form>
);
}
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("");
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/resolve-overlap`, {
booking_ref: bookingRef,
note: note || undefined,
});
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.bookingOverlap.resolveFailed"));
} finally {
setSubmitting(false);
}
}
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="overlap-heading">
<SectionHeading
headingId="overlap-heading"
title={t("detail.bookingOverlap.heading")}
description={t("detail.bookingOverlap.description")}
/>
<ApiErrorNotice error={error} />
<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>
{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 ? t("detail.bookingOverlap.resolving") : t("detail.bookingOverlap.blockButton", { ref: bookingRef || t("detail.bookingOverlap.blockButtonFallback") })}
</button>
</div>
</form>
);
}
function statusLabel(t: (key: string, opts?: Record<string, unknown>) => string, status: string): string {
const raw = t(`fleet:statuses.${status}`, { defaultValue: status });
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
function StatusRecommendationEvidence({ recommendation }: { recommendation: StatusRecommendation }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const facts = recommendation.facts;
const lines: string[] = [];
if (facts.active_booking_refs.length > 0) {
lines.push(
t("detail.vehicleStatusConflict.evidence.activeBookings", { refs: facts.active_booking_refs.join(", ") }),
);
}
if (facts.overlapping_booking_pairs.length > 0) {
lines.push(
t("detail.vehicleStatusConflict.evidence.overlappingBookings", {
pairs: facts.overlapping_booking_pairs.map((pair) => pair.join(" ↔ ")).join(", "),
}),
);
}
if (facts.service_threshold_reached) {
lines.push(
t("detail.vehicleStatusConflict.evidence.serviceThresholdReached", {
odometer: formatNumber(facts.odometer_km),
threshold: formatNumber(facts.next_service_km),
}),
);
}
if (facts.open_booking_overlap_issue_ref) {
lines.push(
t("detail.vehicleStatusConflict.evidence.openOverlapIssue", { ref: facts.open_booking_overlap_issue_ref }),
);
}
if (lines.length === 0) return null;
return (
<ul className="evidence-list">
{lines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
);
}
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
const [stale, setStale] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
const [submitting, setSubmitting] = useState(false);
function continueDemo() {
completeAndAdvance();
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
}
// Once resolved through this panel, keep showing the "Applied" confirmation even
// after the parent's issue.status flips away from "open" -- reloading on success
// updates the page's own status badge immediately, but this panel controls its own
// visibility via `result` rather than disappearing the instant the reload lands.
if (issue.status !== "open" && !result) return null;
async function loadRecommendation() {
setError(null);
setStale(false);
setLoadingRecommendation(true);
try {
const preview = await api.post<StatusRecommendation>(
`/api/v1/data-quality/issues/${issue.public_ref}/status-recommendation`,
);
setRecommendation(preview);
} catch (err) {
setError(describeApiError(t, err, "detail.vehicleStatusConflict.recommendationFailed"));
} finally {
setLoadingRecommendation(false);
}
}
async function handleApply() {
if (!recommendation) return;
setError(null);
setSubmitting(true);
try {
const applied = await api.post<ApplyRecommendedStatusResult>(
`/api/v1/data-quality/issues/${issue.public_ref}/apply-recommended-status`,
{ recommendation_token: recommendation.recommendation_token },
);
setResult(applied);
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.vehicleStatusConflict.applyFailed"));
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
setRecommendation(null);
setStale(true);
}
} finally {
setSubmitting(false);
}
}
// safe_to_apply is only ever true alongside a non-null recommended_status, so it must
// not be used to detect "manual review needed" -- a genuine no_conflict recommendation
// also carries safe_to_apply: false (there is nothing to apply), and conflating the two
// would show "manual review required" for a vehicle that needs no attention at all.
const needsManualReview = recommendation !== null && recommendation.manual_review_required;
const noChangeNeeded = recommendation !== null && !needsManualReview && recommendation.recommended_status === null;
const hasSafeRecommendation =
recommendation !== null &&
!needsManualReview &&
recommendation.recommended_status !== null &&
recommendation.safe_to_apply;
return (
<section className="panel" aria-labelledby="status-conflict-heading" aria-live="polite">
<SectionHeading
headingId="status-conflict-heading"
title={t("detail.vehicleStatusConflict.heading")}
description={t("detail.vehicleStatusConflict.description")}
/>
<ApiErrorNotice error={error} />
<dl className="detail-grid">
<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" /> {t("detail.vehicleStatusConflict.applied", {
status: statusLabel(t, result.applied_status),
reason: t(`detail.vehicleStatusConflict.reasonCodes.${result.reason_code}`, {
defaultValue: result.reason_code,
}),
})}
</p>
<div className="result-links">
<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}>
{t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
</>
) : !recommendation ? (
<button type="button" className="button button-primary" onClick={loadRecommendation} disabled={loadingRecommendation}>
{loadingRecommendation
? t("detail.vehicleStatusConflict.loadingRecommendation")
: t(stale ? "detail.vehicleStatusConflict.reviewAgain" : "detail.vehicleStatusConflict.reviewRecommendation")}
</button>
) : needsManualReview ? (
<div className="status-decision" role="status">
<h3>{t("detail.vehicleStatusConflict.manualReview.heading")}</h3>
<p>{t("detail.vehicleStatusConflict.manualReview.body")}</p>
<StatusRecommendationEvidence recommendation={recommendation} />
<p className="table-subtext">{t("detail.vehicleStatusConflict.manualReview.hint")}</p>
</div>
) : noChangeNeeded ? (
<div className="status-decision" role="status">
<h3>{t("detail.vehicleStatusConflict.noConflict.heading")}</h3>
<p>{t("detail.vehicleStatusConflict.noConflict.body")}</p>
</div>
) : hasSafeRecommendation && recommendation.recommended_status ? (
<div className="status-decision" role="group" aria-label={t("detail.vehicleStatusConflict.recommendedStatus")}>
<dl className="detail-grid">
<div>
<dt>{t("detail.vehicleStatusConflict.recommendedStatus")}</dt>
<dd>
<StatusBadge
status={recommendation.recommended_status}
label={t(`fleet:statuses.${recommendation.recommended_status}`, {
defaultValue: recommendation.recommended_status,
})}
/>
</dd>
</div>
</dl>
<h3>{t("detail.vehicleStatusConflict.whyHeading")}</h3>
<p>{t(`detail.vehicleStatusConflict.reasonCodes.${recommendation.recommendation_code}`, {
defaultValue: recommendation.recommendation_code,
})}</p>
<h3>{t("detail.vehicleStatusConflict.evidenceHeading")}</h3>
<StatusRecommendationEvidence recommendation={recommendation} />
<h3>{t("detail.vehicleStatusConflict.consequenceHeading")}</h3>
<ul className="evidence-list">
<li>{t("detail.vehicleStatusConflict.consequence.statusWillChange", {
status: statusLabel(t, recommendation.recommended_status),
})}</li>
<li>{t("detail.vehicleStatusConflict.consequence.issueWillBeRechecked")}</li>
<li>{t("detail.vehicleStatusConflict.consequence.changeWillBeAudited")}</li>
<li>{t("detail.vehicleStatusConflict.consequence.bookingsNotDeleted")}</li>
</ul>
<button type="button" className="button button-primary" onClick={handleApply} disabled={submitting}>
{submitting
? t("detail.vehicleStatusConflict.applying")
: t("detail.vehicleStatusConflict.changeStatusTo", {
status: statusLabel(t, recommendation.recommended_status),
})}
</button>
</div>
) : null}
</section>
);
}
export function DataQualityIssueDetail() {
const { t } = useTranslation("quality");
const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const { publicRef } = useParams<{ publicRef: string }>();
const [issue, setIssue] = useState<IssueDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [justResolved, setJustResolved] = useState(false);
const load = useCallback(() => {
if (!publicRef) return;
api
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
.then(setIssue)
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
useEffect(() => {
if (user?.role !== "operations_manager") return;
setIssue(null);
setError(null);
setJustResolved(false);
load();
}, [load, user]);
function handleResolved() {
setJustResolved(true);
load();
}
function continueDemo() {
completeAndAdvance();
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
}
async function handleAction(action: "defer" | "reject") {
if (!issue) return;
setActionError(null);
try {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
load();
} catch (err) {
setActionError(describeApiError(t, err, `detail.deferOrReject.${action}Failed`));
}
}
if (user?.role !== "operations_manager") {
return (
<div className="page">
<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={t("detail.loading")} />;
return (
<div className="page">
<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>
</dl>
<div className="record-surface-evidence">
<dt>{t("detail.summary.evidenceSummary")}</dt>
<EvidenceSignalList issue={issue} />
</div>
<EvidenceDisclosure issue={issue} /></section>
<RuleExplainer ruleType={issue.rule_type} />
<ApiErrorNotice error={actionError} />
{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">{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">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
{issue.entity_type === "vehicle" && (
<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}>
{t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
</section>
)}
{issue.status === "open" && issue.rule_type === "possible_duplicate_customer" && (
<DuplicateCustomerPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && issue.rule_type === "missing_required_field" && (
<MissingFieldPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && issue.rule_type === "odometer_regression" && (
<OdometerRegressionPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && issue.rule_type === "booking_overlap" && (
<BookingOverlapPanel issue={issue} onResolved={handleResolved} />
)}
{issue.rule_type === "vehicle_status_conflict" && (
<VehicleStatusConflictPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && (
<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" className="button-tertiary" onClick={() => handleAction("defer")}>
{t("detail.deferOrReject.defer")}
</button>
<button type="button" className="button-tertiary button-tertiary-destructive" onClick={() => handleAction("reject")}>
{t("detail.deferOrReject.reject")}
</button>
</div>
</section>
)}
</div>
);
}