M48: harden demo operations and offsite recovery
MobilityOps acceptance / backend (push) Failing after 20s
MobilityOps acceptance / frontend (push) Successful in 28s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-21 22:17:49 +02:00
parent a24098c583
commit 00191e9b54
28 changed files with 1136 additions and 332 deletions
+1 -186
View File
@@ -6,7 +6,6 @@ import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type {
ApplyRecommendedStatusResult,
DataQualityIssueDetail as IssueDetail,
EntitySnapshot,
StatusRecommendation,
} from "../api/types";
import { SeverityBadge, StatusBadge } from "../components/Badge";
@@ -19,8 +18,7 @@ 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"];
import { DuplicateCustomerPanel } from "./data-quality/DuplicateCustomerPanel";
function RuleExplainer({ ruleType }: { ruleType: string }) {
const { t } = useTranslation("quality");
@@ -69,189 +67,6 @@ function EvidenceSignalList({ issue }: { issue: IssueDetail }) {
);
}
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";
@@ -0,0 +1,215 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../api/client";
import { describeApiError, type ApiErrorInfo } from "../../api/errorMessages";
import type { DataQualityIssueDetail as IssueDetail, EntitySnapshot } from "../../api/types";
import { ApiErrorNotice, SectionHeading } from "../../components/PageChrome";
import { useAuth } from "../../context/AuthContext";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
export 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>
{[a, b].map((candidate) => (
<label
key={candidate.public_ref}
className={`choice-card ${survivorRef === candidate.public_ref ? "is-selected" : ""}`}
>
<input
type="radio"
name="survivor"
checked={survivorRef === candidate.public_ref}
onChange={() => setSurvivorRef(candidate.public_ref)}
/>
<span className="choice-card-title">{candidate.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>
{([a, b] as const).map((candidate, index) => {
const value = index === 0 ? valueA : valueB;
const choice = index === 0 ? "a" : "b";
return (
<td key={candidate.public_ref} data-label={candidate.public_ref}>
{differ ? (
<label className="checkbox-label">
<input
type="radio"
name={`field-${field}`}
checked={(fieldChoices[field] ?? "a") === choice}
onChange={() =>
setFieldChoices((current) => ({ ...current, [field]: choice }))
}
/>
{value}
</label>
) : (
value
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{matchingFields.length > 0 && (
<button
type="button"
className="button button-tertiary toggle-matching-fields"
onClick={() => setShowMatching((value) => !value)}
>
{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] || survivor[field] || "—";
return (
<div key={field}>
<dt>{t(`detail.duplicateCustomer.fields.${field}`)}</dt>
<dd>{String(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>
) : (
<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>
);
}