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 (
{t("detail.explainer.whatIsWrong")}
{t(`detail.explainer.${ruleType}.whatIsWrong`)}
{t("detail.explainer.whyItMatters")}
{t(`detail.explainer.${ruleType}.whyItMatters`)}
);
}
function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("common");
return (
{t("actions.technicalDetails")}
{JSON.stringify(issue.evidence, null, 2)}
);
}
// 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 (
{signals.map((signal, index) => (
{describeEvidenceSignal(t, formatNumber, signal)}
))}
);
}
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>({});
const [error, setError] = useState(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 {t("detail.duplicateCustomer.bothMissing")}
;
}
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 = {};
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 (
{t("detail.managerOnlyDetail")}
);
}
return (
);
}
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>(() => {
const initial: Record = {};
for (const key of fieldKeys) {
initial[key] = snapshot && snapshot[key] ? String(snapshot[key]) : "";
}
return initial;
});
const [error, setError] = useState(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 (
);
}
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(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 (
);
}
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(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 (
{t("detail.bookingOverlap.columns.blockThis")}
{bookings.map((b) => (
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}`}
/>
{b.public_ref}
{b.starts_at ? formatShortDate(String(b.starts_at)) : "—"} → {b.ends_at ? formatShortDate(String(b.ends_at)) : "—"}
))}
{t("detail.bookingOverlap.note")}
setNote(e.target.value)} maxLength={500} rows={2} />
{submitting ? t("detail.bookingOverlap.resolving") : t("detail.bookingOverlap.blockButton", { ref: bookingRef || t("detail.bookingOverlap.blockButtonFallback") })}
);
}
function statusLabel(t: (key: string, opts?: Record) => 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 (
{lines.map((line) => (
{line}
))}
);
}
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(null);
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
const [stale, setStale] = useState(false);
const [error, setError] = useState(null);
const [result, setResult] = useState(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(
`/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(
`/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 (
{t("detail.vehicleStatusConflict.currentStatus")}
{result ? (
<>
{t("detail.vehicleStatusConflict.applied", {
status: statusLabel(t, result.applied_status),
reason: t(`detail.vehicleStatusConflict.reasonCodes.${result.reason_code}`, {
defaultValue: result.reason_code,
}),
})}
{t("detail.resolved.viewAudit")}
{t("detail.resolved.viewVehicle")}
{guideOpen && (
{t("detail.resolved.continueDemo")}
)}
>
) : !recommendation ? (
{loadingRecommendation
? t("detail.vehicleStatusConflict.loadingRecommendation")
: t(stale ? "detail.vehicleStatusConflict.reviewAgain" : "detail.vehicleStatusConflict.reviewRecommendation")}
) : needsManualReview ? (
{t("detail.vehicleStatusConflict.manualReview.heading")}
{t("detail.vehicleStatusConflict.manualReview.body")}
{t("detail.vehicleStatusConflict.manualReview.hint")}
) : noChangeNeeded ? (
{t("detail.vehicleStatusConflict.noConflict.heading")}
{t("detail.vehicleStatusConflict.noConflict.body")}
) : hasSafeRecommendation && recommendation.recommended_status ? (
{t("detail.vehicleStatusConflict.recommendedStatus")}
{t("detail.vehicleStatusConflict.whyHeading")}
{t(`detail.vehicleStatusConflict.reasonCodes.${recommendation.recommendation_code}`, {
defaultValue: recommendation.recommendation_code,
})}
{t("detail.vehicleStatusConflict.evidenceHeading")}
{t("detail.vehicleStatusConflict.consequenceHeading")}
{t("detail.vehicleStatusConflict.consequence.statusWillChange", {
status: statusLabel(t, recommendation.recommended_status),
})}
{t("detail.vehicleStatusConflict.consequence.issueWillBeRechecked")}
{t("detail.vehicleStatusConflict.consequence.changeWillBeAudited")}
{t("detail.vehicleStatusConflict.consequence.bookingsNotDeleted")}
{submitting
? t("detail.vehicleStatusConflict.applying")
: t("detail.vehicleStatusConflict.changeStatusTo", {
status: statusLabel(t, recommendation.recommended_status),
})}
) : null}
);
}
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(null);
const [error, setError] = useState(null);
const [actionError, setActionError] = useState(null);
const [justResolved, setJustResolved] = useState(false);
const load = useCallback(() => {
if (!publicRef) return;
api
.get(`/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 (
{t("detail.managerOnlyDetail")}
);
}
if (error) return ;
if (!issue) return ;
return (
} />
{t("detail.summary.rule")} {t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}
{t("detail.summary.entity")} {issue.entity_type === "vehicle" ? {issue.entity_ref} : issue.entity_ref}
{t("detail.summary.evidenceSummary")}
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
{t("list.statusResolved")}
{t("detail.resolved.title", { ref: issue.public_ref })}
{t("detail.resolved.body")}
{t("detail.resolved.viewAudit")}
{issue.entity_type === "vehicle" && (
{t("detail.resolved.viewVehicle")}
)}
{guideOpen && (
{t("detail.resolved.continueDemo")}
)}
)}
{issue.status === "open" && issue.rule_type === "possible_duplicate_customer" && (
)}
{issue.status === "open" && issue.rule_type === "missing_required_field" && (
)}
{issue.status === "open" && issue.rule_type === "odometer_regression" && (
)}
{issue.status === "open" && issue.rule_type === "booking_overlap" && (
)}
{issue.rule_type === "vehicle_status_conflict" && (
)}
{issue.status === "open" && (
{t("detail.deferOrReject.heading")}
{t("detail.deferOrReject.description")}
handleAction("defer")}>
{t("detail.deferOrReject.defer")}
handleAction("reject")}>
{t("detail.deferOrReject.reject")}
)}
);
}