M48: harden demo operations and offsite recovery
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.rule-explainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 18px; padding: 16px 18px; background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); }
|
||||
.rule-explainer strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.rule-explainer p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
|
||||
.scenario-callout { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 18px; padding: 16px 18px; background: var(--teal-pale); border: 1px solid #bfe6df; }
|
||||
.scenario-callout svg { width: 18px; height: 18px; color: var(--teal-dark); flex-shrink: 0; margin-top: 2px; }
|
||||
.scenario-callout strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .82rem; }
|
||||
.scenario-callout p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
|
||||
|
||||
.status-decision { margin-top: 16px; padding: 16px 18px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.status-decision h3 { margin: 16px 0 6px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.status-decision h3:first-child { margin-top: 0; }
|
||||
.status-decision > .detail-grid { margin-bottom: 4px; }
|
||||
.evidence-list { margin: 0; padding-left: 18px; color: var(--ink-soft); font-size: .78rem; line-height: 1.6; }
|
||||
.evidence-list li { margin-bottom: 2px; }
|
||||
.status-decision .button { margin-top: 16px; }
|
||||
|
||||
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }.duplicate-compare .merge-record-preview { position: sticky; bottom: 16px; z-index: 2; box-shadow: 0 10px 24px rgba(15,23,42,.08); }
|
||||
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
|
||||
.merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
|
||||
.merge-summary-counts { margin: 0 0 12px; color: var(--muted); font-size: .72rem; font-weight: 700; }
|
||||
.toggle-matching-fields { display: inline-block; margin: 10px 0; padding: 6px 10px; color: var(--ink-soft); background: transparent; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 700; cursor: pointer; }
|
||||
.toggle-matching-fields:hover { background: var(--surface-subtle); }
|
||||
.merge-record-preview { margin: 14px 0; padding: 13px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.merge-record-preview-title { margin: 0 0 8px; color: var(--ink); font-size: .72rem; font-weight: 700; }
|
||||
.merge-record-preview dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 16px; }
|
||||
.merge-record-preview dt { margin: 0; color: var(--muted); font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.merge-record-preview dd { margin: 1px 0 0; color: var(--ink); font-size: .78rem; }
|
||||
.confirm-bar { margin-top: 13px; padding: 15px; background: var(--warning-pale); border: 1px solid #eed4aa; }.confirm-bar p { margin: 0 0 11px; color: #75420c; font-size: .75rem; font-weight: 700; }.confirm-bar button:first-of-type { color: white; background: var(--critical); border-color: var(--critical); }
|
||||
.resolution-actions { display: flex; gap: 9px; }.evidence-block { max-width: 100%; padding: 13px; overflow: auto; color: #324054; background: var(--surface-subtle); border: 1px solid var(--line); font-size: .68rem; }
|
||||
.evidence-disclosure { margin-top: 14px; }.evidence-disclosure summary { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }.evidence-disclosure .evidence-block { margin-top: 8px; }
|
||||
|
||||
.choice-fieldset { display: grid; gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.choice-fieldset legend { margin-bottom: 2px; padding: 0; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.choice-fieldset-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.choice-card { display: flex !important; flex-direction: row; align-items: flex-start; gap: 11px; padding: 13px 15px; background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--radius); cursor: pointer; transition: border-color .15s ease, background .15s ease; }
|
||||
.choice-card:hover { border-color: var(--teal); }
|
||||
.choice-card:has(input:checked), .choice-card.is-selected { border-color: var(--teal-dark); background: var(--teal-pale); }
|
||||
.choice-card:has(input:disabled), .choice-card.is-disabled { cursor: not-allowed; opacity: .55; }
|
||||
.choice-card input[type="radio"] { flex: 0 0 auto; margin-top: 2px; }
|
||||
.choice-card-body { display: grid; gap: 3px; }
|
||||
.choice-card-title { color: var(--ink); font-size: .82rem; font-weight: 700; }
|
||||
.choice-card-detail { color: var(--muted); font-size: .72rem; line-height: 1.5; }
|
||||
|
||||
.defer-reject-panel { padding: 16px 18px; background: var(--surface-subtle); }
|
||||
.defer-reject-panel h2 { margin: 0 0 4px; color: var(--ink-soft); font-size: .78rem; font-weight: 700; }
|
||||
.defer-reject-panel > p { margin: 0 0 12px; color: var(--muted); font-size: .72rem; }
|
||||
|
||||
.resolution-actions .button-tertiary, .resolution-actions .button-tertiary-destructive { min-height: 36px; padding: 7px 12px; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; }
|
||||
.resolution-actions .button-tertiary:hover { color: var(--ink); background: var(--surface); border-color: var(--line); }
|
||||
.resolution-actions .button-tertiary-destructive:hover { color: var(--critical); background: var(--surface); border-color: var(--line); }
|
||||
|
||||
+2
-49
@@ -1,3 +1,5 @@
|
||||
@import "./styles-data-quality.css";
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #0f172a;
|
||||
@@ -329,51 +331,6 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.result-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.link-button { padding: 0; color: var(--teal-dark); background: transparent; border: 0; font-size: inherit; font-weight: 700; text-decoration: underline; cursor: pointer; }
|
||||
.link-button:hover { color: var(--teal); }
|
||||
.rule-explainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 18px; padding: 16px 18px; background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); }
|
||||
.rule-explainer strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.rule-explainer p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
|
||||
.scenario-callout { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 18px; padding: 16px 18px; background: var(--teal-pale); border: 1px solid #bfe6df; }
|
||||
.scenario-callout svg { width: 18px; height: 18px; color: var(--teal-dark); flex-shrink: 0; margin-top: 2px; }
|
||||
.scenario-callout strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .82rem; }
|
||||
.scenario-callout p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
|
||||
|
||||
.status-decision { margin-top: 16px; padding: 16px 18px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.status-decision h3 { margin: 16px 0 6px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.status-decision h3:first-child { margin-top: 0; }
|
||||
.status-decision > .detail-grid { margin-bottom: 4px; }
|
||||
.evidence-list { margin: 0; padding-left: 18px; color: var(--ink-soft); font-size: .78rem; line-height: 1.6; }
|
||||
.evidence-list li { margin-bottom: 2px; }
|
||||
.status-decision .button { margin-top: 16px; }
|
||||
|
||||
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }.duplicate-compare .merge-record-preview { position: sticky; bottom: 16px; z-index: 2; box-shadow: 0 10px 24px rgba(15,23,42,.08); }
|
||||
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
|
||||
.merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
|
||||
.merge-summary-counts { margin: 0 0 12px; color: var(--muted); font-size: .72rem; font-weight: 700; }
|
||||
.toggle-matching-fields { display: inline-block; margin: 10px 0; padding: 6px 10px; color: var(--ink-soft); background: transparent; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 700; cursor: pointer; }
|
||||
.toggle-matching-fields:hover { background: var(--surface-subtle); }
|
||||
.merge-record-preview { margin: 14px 0; padding: 13px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.merge-record-preview-title { margin: 0 0 8px; color: var(--ink); font-size: .72rem; font-weight: 700; }
|
||||
.merge-record-preview dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 16px; }
|
||||
.merge-record-preview dt { margin: 0; color: var(--muted); font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.merge-record-preview dd { margin: 1px 0 0; color: var(--ink); font-size: .78rem; }
|
||||
.confirm-bar { margin-top: 13px; padding: 15px; background: var(--warning-pale); border: 1px solid #eed4aa; }.confirm-bar p { margin: 0 0 11px; color: #75420c; font-size: .75rem; font-weight: 700; }.confirm-bar button:first-of-type { color: white; background: var(--critical); border-color: var(--critical); }
|
||||
.resolution-actions { display: flex; gap: 9px; }.evidence-block { max-width: 100%; padding: 13px; overflow: auto; color: #324054; background: var(--surface-subtle); border: 1px solid var(--line); font-size: .68rem; }
|
||||
.evidence-disclosure { margin-top: 14px; }.evidence-disclosure summary { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }.evidence-disclosure .evidence-block { margin-top: 8px; }
|
||||
|
||||
.choice-fieldset { display: grid; gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.choice-fieldset legend { margin-bottom: 2px; padding: 0; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.choice-fieldset-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.choice-card { display: flex !important; flex-direction: row; align-items: flex-start; gap: 11px; padding: 13px 15px; background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--radius); cursor: pointer; transition: border-color .15s ease, background .15s ease; }
|
||||
.choice-card:hover { border-color: var(--teal); }
|
||||
.choice-card:has(input:checked), .choice-card.is-selected { border-color: var(--teal-dark); background: var(--teal-pale); }
|
||||
.choice-card:has(input:disabled), .choice-card.is-disabled { cursor: not-allowed; opacity: .55; }
|
||||
.choice-card input[type="radio"] { flex: 0 0 auto; margin-top: 2px; }
|
||||
.choice-card-body { display: grid; gap: 3px; }
|
||||
.choice-card-title { color: var(--ink); font-size: .82rem; font-weight: 700; }
|
||||
.choice-card-detail { color: var(--muted); font-size: .72rem; line-height: 1.5; }
|
||||
|
||||
.defer-reject-panel { padding: 16px 18px; background: var(--surface-subtle); }
|
||||
.defer-reject-panel h2 { margin: 0 0 4px; color: var(--ink-soft); font-size: .78rem; font-weight: 700; }
|
||||
.defer-reject-panel > p { margin: 0 0 12px; color: var(--muted); font-size: .72rem; }
|
||||
|
||||
.audit-group-list { list-style: none; display: grid; gap: 12px; margin: 0; padding: 16px; }
|
||||
.audit-group { padding: 16px 18px; }
|
||||
.audit-group-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
|
||||
@@ -385,10 +342,6 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.audit-related-list { list-style: none; display: grid; gap: 10px; margin: 12px 0 0; padding: 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.audit-related-list > li { padding: 10px 12px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.audit-technical-grid { margin-top: 8px; }
|
||||
.resolution-actions .button-tertiary, .resolution-actions .button-tertiary-destructive { min-height: 36px; padding: 7px 12px; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; }
|
||||
.resolution-actions .button-tertiary:hover { color: var(--ink); background: var(--surface); border-color: var(--line); }
|
||||
.resolution-actions .button-tertiary-destructive:hover { color: var(--critical); background: var(--surface); border-color: var(--line); }
|
||||
|
||||
.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 0; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }
|
||||
.integration-cards .integration-badge-stack { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-self: end; }
|
||||
.integration-cards small { display: block; margin-top: 4px; color: var(--muted-light); font-size: var(--type-meta); }.integration-cards h2 { margin: 3px 0 7px; font-size: 1rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .09em; }
|
||||
|
||||
Reference in New Issue
Block a user