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 ( ); } 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 (

{t("detail.duplicateCustomer.summaryCounts", { matchCount: matchingFields.length, conflictCount: conflictingFields.length })}

{t("detail.duplicateCustomer.keepAsSurvivor")}
{visibleFields.map((field) => { const valueA = a[field] ? String(a[field]) : "—"; const valueB = b[field] ? String(b[field]) : "—"; const differ = valueA !== valueB; return ( ); })}
{t("detail.duplicateCustomer.fieldColumn")} {a.public_ref} {b.public_ref}
{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? {t("detail.duplicateCustomer.differs")} : {t("detail.duplicateCustomer.match")}} {differ ? ( ) : ( valueA )} {differ ? ( ) : ( valueB )}
{matchingFields.length > 0 && ( )}

{t("detail.duplicateCustomer.mergePreview", { loser: loser.public_ref, survivor: survivor.public_ref })}

{t("detail.duplicateCustomer.previewTitle", { ref: survivor.public_ref })}

{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 (
{t(`detail.duplicateCustomer.fields.${field}`)}
{value || "—"}
); })}
{!confirming && ( )} {confirming && (

{t("detail.duplicateCustomer.confirmMergeBody", { loser: loser.public_ref, survivor: survivor.public_ref })}

)}
); } 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 (
{fieldKeys.map((field) => ( ))}
{isCustomer && (

{t("detail.missingField.atLeastOne")}

)}
); } 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 (
{t("detail.odometerRegression.canonicalOdometer")}
{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km
{t("detail.odometerRegression.decisionLegend")} {bookingSnapshots.length === 0 && (

{t("detail.odometerRegression.noBookingAttached")}

)}
{decision === "correct_reading" && (
)}