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>
843 lines
36 KiB
TypeScript
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>
|
|
);
|
|
}
|