Implement address quality and reversible contact merges
This commit is contained in:
@@ -39,6 +39,11 @@ export type ContactEmail = {
|
||||
id?: string;
|
||||
label?: string | null;
|
||||
email: string;
|
||||
original_email?: string;
|
||||
normalized_email?: string;
|
||||
provenance?: Record<string, unknown>;
|
||||
quality_state?: ContactPointQualityState;
|
||||
quality_reason_code?: string | null;
|
||||
is_primary: boolean;
|
||||
};
|
||||
|
||||
@@ -46,6 +51,11 @@ export type ContactPhone = {
|
||||
id?: string;
|
||||
label?: string | null;
|
||||
phone: string;
|
||||
original_phone?: string;
|
||||
normalized_phone?: string;
|
||||
provenance?: Record<string, unknown>;
|
||||
quality_state?: ContactPointQualityState;
|
||||
quality_reason_code?: string | null;
|
||||
is_primary: boolean;
|
||||
};
|
||||
|
||||
@@ -57,9 +67,35 @@ export type ContactPostalAddress = {
|
||||
locality?: string | null;
|
||||
region?: string | null;
|
||||
country?: string | null;
|
||||
original_value?: Record<string, unknown>;
|
||||
normalized_value?: Record<string, unknown>;
|
||||
provenance?: Record<string, unknown>;
|
||||
quality_state?: ContactPointQualityState;
|
||||
quality_reason_code?: string | null;
|
||||
is_primary: boolean;
|
||||
};
|
||||
|
||||
export type ContactPointQualityState = "valid" | "invalid" | "returned" | "stale" | "undeliverable";
|
||||
|
||||
export type ContactFieldProvenance = {
|
||||
id: string;
|
||||
contact_id: string;
|
||||
field_path: string;
|
||||
value?: unknown;
|
||||
source_kind: string;
|
||||
source_ref?: string | null;
|
||||
source_revision?: string | null;
|
||||
precedence: number;
|
||||
selected: boolean;
|
||||
reason_code: string;
|
||||
explanation?: string | null;
|
||||
visibility: "inherit" | "private" | "restricted" | "public";
|
||||
merge_record_id?: string | null;
|
||||
created_by_account_id?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Contact = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
@@ -79,11 +115,95 @@ export type Contact = {
|
||||
emails: ContactEmail[];
|
||||
phones: ContactPhone[];
|
||||
postal_addresses: ContactPostalAddress[];
|
||||
field_provenance?: ContactFieldProvenance[];
|
||||
deleted_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ContactPointQualityDecision = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
contact_id: string;
|
||||
channel: "email" | "phone" | "postal" | "internal_mail" | "portal";
|
||||
contact_point_id?: string | null;
|
||||
state: ContactPointQualityState;
|
||||
reason_code: string;
|
||||
reason?: string | null;
|
||||
evidence_ref?: string | null;
|
||||
effective_from: string;
|
||||
effective_until?: string | null;
|
||||
created_by_account_id?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ContactDuplicateFeature = {
|
||||
code: string;
|
||||
label: string;
|
||||
weight: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ContactDuplicateSuggestion = {
|
||||
left: Contact;
|
||||
right: Contact;
|
||||
score: number;
|
||||
confidence: "possible" | "likely" | "strong";
|
||||
features: ContactDuplicateFeature[];
|
||||
};
|
||||
|
||||
export type ContactDuplicateSuggestionList = {
|
||||
suggestions: ContactDuplicateSuggestion[];
|
||||
scanned_contacts: number;
|
||||
candidate_pairs: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type ContactMergeRecord = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
address_book_id: string;
|
||||
winner_contact_id: string;
|
||||
loser_contact_ids: string[];
|
||||
status: string;
|
||||
reason: string;
|
||||
survivorship: Record<string, unknown>;
|
||||
decisions: Array<Record<string, unknown>>;
|
||||
before_hash: string;
|
||||
after_hash: string;
|
||||
created_by_account_id?: string | null;
|
||||
recovered_at?: string | null;
|
||||
recovered_by_account_id?: string | null;
|
||||
recovery_action?: string | null;
|
||||
recovery_reason?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AddressQualityCorrection = {
|
||||
contact_id: string;
|
||||
display_name: string;
|
||||
channel: "email" | "phone" | "postal" | "internal_mail" | "portal";
|
||||
contact_point_id?: string | null;
|
||||
state: ContactPointQualityState;
|
||||
reason_code: string;
|
||||
reason?: string | null;
|
||||
effective_from: string;
|
||||
};
|
||||
|
||||
export type AddressQualitySummary = {
|
||||
contact_count: number;
|
||||
contact_point_count: number;
|
||||
quality_counts: Record<string, number>;
|
||||
duplicate_suggestion_count: number;
|
||||
correction_count: number;
|
||||
corrections: AddressQualityCorrection[];
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type AddressDistributionChannel = "email" | "postal" | "internal_mail" | "portal";
|
||||
export type AddressChannelDecision =
|
||||
| "allowed"
|
||||
@@ -392,6 +512,14 @@ type ContactChannelRuleListResponse = {
|
||||
rules: ContactChannelRule[];
|
||||
};
|
||||
|
||||
type ContactPointQualityDecisionListResponse = {
|
||||
decisions: ContactPointQualityDecision[];
|
||||
};
|
||||
|
||||
type ContactMergeRecordListResponse = {
|
||||
merges: ContactMergeRecord[];
|
||||
};
|
||||
|
||||
function queryString(params: Record<string, string | number | null | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
@@ -677,6 +805,110 @@ export function restoreContact(settings: ApiSettings, contactId: string): Promis
|
||||
return apiFetch<Contact>(settings, `/api/v1/addresses/contacts/${contactId}/restore`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function getAddressQualitySummary(settings: ApiSettings, addressBookId: string): Promise<AddressQualitySummary> {
|
||||
return apiFetch<AddressQualitySummary>(settings, `/api/v1/addresses/address-books/${addressBookId}/quality-summary`);
|
||||
}
|
||||
|
||||
export function listContactDuplicateSuggestions(
|
||||
settings: ApiSettings,
|
||||
addressBookId: string,
|
||||
options: { contactId?: string | null; minimumScore?: number; limit?: number; scanLimit?: number } = {}
|
||||
): Promise<ContactDuplicateSuggestionList> {
|
||||
return apiFetch<ContactDuplicateSuggestionList>(
|
||||
settings,
|
||||
`/api/v1/addresses/address-books/${addressBookId}/duplicate-suggestions${queryString({
|
||||
contact_id: options.contactId,
|
||||
minimum_score: options.minimumScore,
|
||||
limit: options.limit,
|
||||
scan_limit: options.scanLimit
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function listContactQualityDecisions(settings: ApiSettings, contactId: string): Promise<ContactPointQualityDecision[]> {
|
||||
const response = await apiFetch<ContactPointQualityDecisionListResponse>(
|
||||
settings,
|
||||
`/api/v1/addresses/contacts/${contactId}/quality-decisions`
|
||||
);
|
||||
return response.decisions;
|
||||
}
|
||||
|
||||
export function createContactQualityDecision(
|
||||
settings: ApiSettings,
|
||||
contactId: string,
|
||||
payload: {
|
||||
channel: ContactPointQualityDecision["channel"];
|
||||
contact_point_id?: string | null;
|
||||
state: ContactPointQualityState;
|
||||
reason_code?: string | null;
|
||||
reason?: string | null;
|
||||
evidence_ref?: string | null;
|
||||
}
|
||||
): Promise<ContactPointQualityDecision> {
|
||||
return apiFetch<ContactPointQualityDecision>(settings, `/api/v1/addresses/contacts/${contactId}/quality-decisions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function listContactProvenance(
|
||||
settings: ApiSettings,
|
||||
contactId: string,
|
||||
options: { currentOnly?: boolean; limit?: number } = {}
|
||||
): Promise<ContactFieldProvenance[]> {
|
||||
return apiFetch<ContactFieldProvenance[]>(
|
||||
settings,
|
||||
`/api/v1/addresses/contacts/${contactId}/provenance${queryString({
|
||||
current_only: options.currentOnly ? "true" : null,
|
||||
limit: options.limit
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function listContactMerges(
|
||||
settings: ApiSettings,
|
||||
options: { addressBookId?: string | null; contactId?: string | null; limit?: number } = {}
|
||||
): Promise<ContactMergeRecord[]> {
|
||||
const response = await apiFetch<ContactMergeRecordListResponse>(
|
||||
settings,
|
||||
`/api/v1/addresses/contact-merges${queryString({
|
||||
address_book_id: options.addressBookId,
|
||||
contact_id: options.contactId,
|
||||
limit: options.limit
|
||||
})}`
|
||||
);
|
||||
return response.merges;
|
||||
}
|
||||
|
||||
export function mergeContacts(
|
||||
settings: ApiSettings,
|
||||
payload: {
|
||||
winner_contact_id: string;
|
||||
duplicate_contact_ids: string[];
|
||||
reason: string;
|
||||
field_sources?: Record<string, string>;
|
||||
contact_point_strategy?: "union" | "winner_only";
|
||||
source_precedence?: string[];
|
||||
}
|
||||
): Promise<ContactMergeRecord> {
|
||||
return apiFetch<ContactMergeRecord>(settings, "/api/v1/addresses/contact-merges", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function recoverContactMerge(
|
||||
settings: ApiSettings,
|
||||
merge: ContactMergeRecord,
|
||||
action: "undo" | "split",
|
||||
reason: string
|
||||
): Promise<ContactMergeRecord> {
|
||||
return apiFetch<ContactMergeRecord>(settings, `/api/v1/addresses/contact-merges/${merge.id}/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason, expected_after_hash: merge.after_hash })
|
||||
});
|
||||
}
|
||||
|
||||
export async function listContactChannelRules(settings: ApiSettings, contactId: string): Promise<ContactChannelRule[]> {
|
||||
const response = await apiFetch<ContactChannelRuleListResponse>(
|
||||
settings,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, Edit3, Link2, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
|
||||
import { Download, Edit3, GitMerge, History, Link2, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
|
||||
import {
|
||||
ApiError,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatDateTime,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
MetricCard,
|
||||
PasswordField,
|
||||
SegmentedControl,
|
||||
SelectionList,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
createCardDavSyncSource,
|
||||
createContact,
|
||||
createContactChannelRule,
|
||||
createContactQualityDecision,
|
||||
deleteAddressBook,
|
||||
deleteAddressList,
|
||||
deleteAddressListEntry,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
exportAddressBookVcards,
|
||||
exportContactVcard,
|
||||
importAddressBookVcards,
|
||||
getAddressQualitySummary,
|
||||
listAddressBooks,
|
||||
listAddressCredentials,
|
||||
listAddressListEntries,
|
||||
@@ -52,7 +55,12 @@ import {
|
||||
listContacts,
|
||||
listContactsPage,
|
||||
listContactChannelRules,
|
||||
listContactDuplicateSuggestions,
|
||||
listContactMerges,
|
||||
listContactProvenance,
|
||||
previewAddressSyncSource,
|
||||
mergeContacts,
|
||||
recoverContactMerge,
|
||||
restoreAddressBook,
|
||||
restoreAddressList,
|
||||
restoreContact,
|
||||
@@ -75,9 +83,15 @@ import {
|
||||
type AddressSyncPlan,
|
||||
type AddressSyncSource,
|
||||
type AddressSyncTombstone,
|
||||
type AddressQualitySummary,
|
||||
type Contact,
|
||||
type ContactChannelRule,
|
||||
type ContactChannelRulePayload
|
||||
type ContactChannelRulePayload,
|
||||
type ContactDuplicateSuggestion,
|
||||
type ContactFieldProvenance,
|
||||
type ContactMergeRecord,
|
||||
type ContactPointQualityDecision,
|
||||
type ContactPointQualityState
|
||||
} from "../../api/addresses";
|
||||
|
||||
type Props = {
|
||||
@@ -210,6 +224,65 @@ type AddressTreeNode = {
|
||||
|
||||
type ConflictMergeChoice = "local" | "remote";
|
||||
|
||||
type QualityPointTarget = {
|
||||
contact: Contact;
|
||||
channel: ContactPointQualityDecision["channel"];
|
||||
contactPointId: string;
|
||||
label: string;
|
||||
currentState: ContactPointQualityState;
|
||||
};
|
||||
|
||||
type QualityFormState = {
|
||||
state: ContactPointQualityState;
|
||||
reason_code: string;
|
||||
reason: string;
|
||||
evidence_ref: string;
|
||||
};
|
||||
|
||||
type MergeDialogState = {
|
||||
suggestion: ContactDuplicateSuggestion;
|
||||
winnerId: string;
|
||||
contactPointStrategy: "union" | "winner_only";
|
||||
fieldSources: Record<string, string>;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type MergeRecoveryDialogState = {
|
||||
merge: ContactMergeRecord;
|
||||
action: "undo" | "split";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const MERGE_SCALAR_FIELDS = [
|
||||
{ id: "display_name", label: "Display name" },
|
||||
{ id: "given_name", label: "Given name" },
|
||||
{ id: "family_name", label: "Family name" },
|
||||
{ id: "organization", label: "Organization" },
|
||||
{ id: "role_title", label: "Role title" },
|
||||
{ id: "note", label: "Note" }
|
||||
] as const;
|
||||
|
||||
type MergeScalarField = typeof MERGE_SCALAR_FIELDS[number]["id"];
|
||||
|
||||
function contactScalarValue(contact: Contact, field: MergeScalarField): string {
|
||||
const value = contact[field];
|
||||
return typeof value === "string" && value.trim() ? value : "Not set";
|
||||
}
|
||||
|
||||
function defaultMergeFieldSources(
|
||||
suggestion: ContactDuplicateSuggestion,
|
||||
winnerId: string
|
||||
): Record<string, string> {
|
||||
const winner = suggestion.left.id === winnerId ? suggestion.left : suggestion.right;
|
||||
const loser = suggestion.left.id === winnerId ? suggestion.right : suggestion.left;
|
||||
return Object.fromEntries(
|
||||
MERGE_SCALAR_FIELDS.map(({ id }) => [
|
||||
id,
|
||||
contactScalarValue(winner, id) !== "Not set" ? winner.id : loser.id
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_BOOK_FORM: BookFormState = {
|
||||
scope_type: "user",
|
||||
group_id: "",
|
||||
@@ -247,6 +320,13 @@ const EMPTY_CHANNEL_RULE_FORM: ChannelRuleFormState = {
|
||||
effective_until: ""
|
||||
};
|
||||
|
||||
const EMPTY_QUALITY_FORM: QualityFormState = {
|
||||
state: "valid",
|
||||
reason_code: "",
|
||||
reason: "",
|
||||
evidence_ref: ""
|
||||
};
|
||||
|
||||
const ADDRESS_CONTACT_DRAG_TYPE = "application/x-govoplan-address-contact-id";
|
||||
const CONFLICT_PAYLOAD_FIELDS = ["display_name", "given_name", "family_name", "organization", "role_title", "emails", "phones", "postal_addresses", "tags", "note"] as const;
|
||||
|
||||
@@ -322,6 +402,34 @@ function primaryPhone(contact: Contact): string {
|
||||
return contact.phones.find((phone) => phone.is_primary)?.phone ?? contact.phones[0]?.phone ?? "";
|
||||
}
|
||||
|
||||
function contactPointSource(provenance?: Record<string, unknown>): string {
|
||||
if (!provenance) return "";
|
||||
const sourceKind = typeof provenance.source_kind === "string" ? provenance.source_kind : "";
|
||||
const sourceRef = typeof provenance.source_ref === "string" ? provenance.source_ref : "";
|
||||
return [sourceKind, sourceRef].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function originalPostalSummary(address: Contact["postal_addresses"][number]): string {
|
||||
const value = address.original_value;
|
||||
if (!value) return "";
|
||||
return [
|
||||
value.street,
|
||||
[value.postal_code, value.locality].filter(Boolean).join(" "),
|
||||
value.region,
|
||||
value.country
|
||||
].filter((item): item is string => typeof item === "string" && Boolean(item.trim())).join(", ");
|
||||
}
|
||||
|
||||
function provenanceValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "Not set";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceGroupForBook(book: AddressBook): "local" | "linked" {
|
||||
return book.source_kind === "local" ? "local" : "linked";
|
||||
}
|
||||
@@ -687,6 +795,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
const [governanceContact, setGovernanceContact] = useState<Contact | null>(null);
|
||||
const [channelRules, setChannelRules] = useState<ContactChannelRule[]>([]);
|
||||
const [channelRuleForm, setChannelRuleForm] = useState<ChannelRuleFormState>(EMPTY_CHANNEL_RULE_FORM);
|
||||
const [qualityOpen, setQualityOpen] = useState(false);
|
||||
const [qualityLoading, setQualityLoading] = useState(false);
|
||||
const [qualitySummary, setQualitySummary] = useState<AddressQualitySummary | null>(null);
|
||||
const [duplicateSuggestions, setDuplicateSuggestions] = useState<ContactDuplicateSuggestion[]>([]);
|
||||
const [contactMerges, setContactMerges] = useState<ContactMergeRecord[]>([]);
|
||||
const [provenanceContact, setProvenanceContact] = useState<Contact | null>(null);
|
||||
const [contactProvenance, setContactProvenance] = useState<ContactFieldProvenance[]>([]);
|
||||
const [provenanceLoading, setProvenanceLoading] = useState(false);
|
||||
const [showProvenanceHistory, setShowProvenanceHistory] = useState(false);
|
||||
const [qualityPointTarget, setQualityPointTarget] = useState<QualityPointTarget | null>(null);
|
||||
const [qualityForm, setQualityForm] = useState<QualityFormState>(EMPTY_QUALITY_FORM);
|
||||
const [mergeDialog, setMergeDialog] = useState<MergeDialogState | null>(null);
|
||||
const [mergeRecoveryDialog, setMergeRecoveryDialog] = useState<MergeRecoveryDialogState | null>(null);
|
||||
const [memberDialogOpen, setMemberDialogOpen] = useState(false);
|
||||
const [memberCandidates, setMemberCandidates] = useState<Contact[]>([]);
|
||||
const [memberQuery, setMemberQuery] = useState("");
|
||||
@@ -853,6 +974,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
[!canWriteSync, "You need permission to run address sync."],
|
||||
[saving, savingReason]
|
||||
);
|
||||
const qualityDashboardReason = disabledReason(
|
||||
[!selectedBook, "Select an address book before reviewing address quality."],
|
||||
[!canReadGovernance, "You need permission to view address quality."],
|
||||
[saving, savingReason]
|
||||
);
|
||||
const createContactReason = disabledReason(
|
||||
[!selectedBook, "Select an address book before adding a contact."],
|
||||
[!canWriteContacts, "You need permission to manage contacts."],
|
||||
@@ -1260,6 +1386,166 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshQualityReview(bookId: string) {
|
||||
setQualityLoading(true);
|
||||
try {
|
||||
const [summary, duplicates, merges] = await Promise.all([
|
||||
getAddressQualitySummary(settings, bookId),
|
||||
listContactDuplicateSuggestions(settings, bookId),
|
||||
listContactMerges(settings, { addressBookId: bookId, limit: 100 })
|
||||
]);
|
||||
setQualitySummary(summary);
|
||||
setDuplicateSuggestions(duplicates.suggestions);
|
||||
setContactMerges(merges);
|
||||
} finally {
|
||||
setQualityLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openQualityReview() {
|
||||
if (!selectedBook) return;
|
||||
setQualityOpen(true);
|
||||
setError("");
|
||||
try {
|
||||
await refreshQualityReview(selectedBook.id);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setQualityOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openProvenanceDialog(contact: Contact) {
|
||||
setProvenanceContact(contact);
|
||||
setContactProvenance([]);
|
||||
setShowProvenanceHistory(false);
|
||||
setProvenanceLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setContactProvenance(await listContactProvenance(settings, contact.id, { limit: 2000 }));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setProvenanceContact(null);
|
||||
} finally {
|
||||
setProvenanceLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openQualityPointEditor(target: QualityPointTarget) {
|
||||
setQualityPointTarget(target);
|
||||
setQualityForm({
|
||||
...EMPTY_QUALITY_FORM,
|
||||
state: target.currentState
|
||||
});
|
||||
}
|
||||
|
||||
async function submitQualityDecision(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!qualityPointTarget || !canWriteGovernance) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await createContactQualityDecision(settings, qualityPointTarget.contact.id, {
|
||||
channel: qualityPointTarget.channel,
|
||||
contact_point_id: qualityPointTarget.contactPointId,
|
||||
state: qualityForm.state,
|
||||
reason_code: qualityForm.reason_code.trim() || null,
|
||||
reason: qualityForm.reason.trim() || null,
|
||||
evidence_ref: qualityForm.evidence_ref.trim() || null
|
||||
});
|
||||
setQualityPointTarget(null);
|
||||
setNotice(`Quality state recorded for ${qualityPointTarget.label}.`);
|
||||
await refreshContacts(selectedBookId, query);
|
||||
if (qualityOpen && selectedBookId) await refreshQualityReview(selectedBookId);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openMergeDialog(suggestion: ContactDuplicateSuggestion, winnerId: string) {
|
||||
setMergeDialog({
|
||||
suggestion,
|
||||
winnerId,
|
||||
contactPointStrategy: "union",
|
||||
fieldSources: defaultMergeFieldSources(suggestion, winnerId),
|
||||
reason: "Confirmed duplicate during address quality review."
|
||||
});
|
||||
}
|
||||
|
||||
function changeMergeWinner(winnerId: string) {
|
||||
setMergeDialog((current) => current ? {
|
||||
...current,
|
||||
winnerId,
|
||||
fieldSources: defaultMergeFieldSources(current.suggestion, winnerId)
|
||||
} : null);
|
||||
}
|
||||
|
||||
async function submitContactMerge(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!mergeDialog || mergeDialog.reason.trim().length < 3) return;
|
||||
const loser = mergeDialog.suggestion.left.id === mergeDialog.winnerId
|
||||
? mergeDialog.suggestion.right
|
||||
: mergeDialog.suggestion.left;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await mergeContacts(settings, {
|
||||
winner_contact_id: mergeDialog.winnerId,
|
||||
duplicate_contact_ids: [loser.id],
|
||||
reason: mergeDialog.reason.trim(),
|
||||
field_sources: mergeDialog.fieldSources,
|
||||
contact_point_strategy: mergeDialog.contactPointStrategy
|
||||
});
|
||||
const winner = mergeDialog.suggestion.left.id === mergeDialog.winnerId
|
||||
? mergeDialog.suggestion.left
|
||||
: mergeDialog.suggestion.right;
|
||||
setMergeDialog(null);
|
||||
setSelectedContactId(winner.id);
|
||||
setNotice(`Merged duplicate contact into "${winner.display_name}".`);
|
||||
await refreshBooks();
|
||||
await refreshContacts(selectedBookId, query);
|
||||
if (selectedListId) await refreshListEntries(selectedListId);
|
||||
if (selectedBookId) await refreshQualityReview(selectedBookId);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMergeRecovery(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!mergeRecoveryDialog || mergeRecoveryDialog.reason.trim().length < 3) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await recoverContactMerge(
|
||||
settings,
|
||||
mergeRecoveryDialog.merge,
|
||||
mergeRecoveryDialog.action,
|
||||
mergeRecoveryDialog.reason.trim()
|
||||
);
|
||||
setNotice(
|
||||
mergeRecoveryDialog.action === "undo"
|
||||
? "Contact merge undone."
|
||||
: "Merged contacts split back into their recorded pre-merge state."
|
||||
);
|
||||
setMergeRecoveryDialog(null);
|
||||
await refreshBooks();
|
||||
await refreshContacts(selectedBookId, query);
|
||||
if (selectedListId) await refreshListEntries(selectedListId);
|
||||
if (selectedBookId) await refreshQualityReview(selectedBookId);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateEmailRow(rowId: string, patch: Partial<Omit<ContactEmailRow, "rowId">>) {
|
||||
setContactForm((current) => ({
|
||||
...current,
|
||||
@@ -1439,8 +1725,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
tags: tagsFromForm(contactForm.tags),
|
||||
emails,
|
||||
phones,
|
||||
postal_addresses,
|
||||
provenance: {}
|
||||
postal_addresses
|
||||
};
|
||||
try {
|
||||
let savedContact: Contact;
|
||||
@@ -1973,6 +2258,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
return (
|
||||
<>
|
||||
<Button type="button" title="Refresh address books" aria-label="Refresh address books" onClick={() => void refreshAll()} disabledReason={refreshReason}><RefreshCw size={15} /></Button>
|
||||
<Button type="button" title="Review address quality" aria-label="Review address quality" onClick={() => void openQualityReview()} disabledReason={qualityDashboardReason}><ShieldCheck size={15} /></Button>
|
||||
<Button type="button" title="Add address book" aria-label="Add address book" variant="primary" onClick={openCreateBookDialog} disabledReason={createBookReason}><Plus size={15} /></Button>
|
||||
<Button type="button" title="Add address list" aria-label="Add address list" onClick={openCreateListDialog} disabledReason={createListReason}><Plus size={15} /></Button>
|
||||
<Button type="button" title="Import vCard into selected address book" aria-label="Import vCard into selected address book" onClick={() => setImportOpen(true)} disabledReason={importBookReason}><Upload size={15} /></Button>
|
||||
@@ -2051,6 +2337,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
onClick={() => void openGovernanceDialog(selectedContact)}>
|
||||
<ShieldCheck size={15} /> Governance
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
title="Field provenance"
|
||||
aria-label="Field provenance"
|
||||
disabledReason={disabledReason([saving, savingReason])}
|
||||
onClick={() => void openProvenanceDialog(selectedContact)}>
|
||||
<History size={15} /> Provenance
|
||||
</Button>
|
||||
<Button type="button" title="Export contact vCard" aria-label="Export contact vCard" disabledReason={exportContactReason()} onClick={() => void exportOneContact(selectedContact)}><Download size={15} /> vCard</Button>
|
||||
<Button type="button" title="Edit contact" aria-label="Edit contact" disabledReason={editContactReason()} onClick={() => openEditContactDialog(selectedContact)}><Edit3 size={15} /> Edit</Button>
|
||||
<Button type="button" variant="danger" title="Delete contact" aria-label="Delete contact" disabledReason={deleteContactReason()} onClick={() => setConfirmState({ kind: "contact", contact: selectedContact })}><Trash2 size={15} /> Delete</Button>
|
||||
@@ -2089,7 +2383,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
{selectedContact.emails.map((email, index) => (
|
||||
<div key={email.id ?? `${email.email}-${index}`}>
|
||||
<dt>{email.label || "email"}{email.is_primary ? " · primary" : ""}</dt>
|
||||
<dd>{email.email}</dd>
|
||||
<dd className="address-contact-point-value">
|
||||
<span>{email.email}</span>
|
||||
<StatusBadge status={email.quality_state ?? "valid"} />
|
||||
{email.id && <Button
|
||||
type="button"
|
||||
title="Record email quality"
|
||||
aria-label={`Record quality for ${email.email}`}
|
||||
disabledReason={disabledReason(
|
||||
[!canWriteGovernance, "You need permission to manage address quality."],
|
||||
[saving, savingReason]
|
||||
)}
|
||||
onClick={() => openQualityPointEditor({
|
||||
contact: selectedContact,
|
||||
channel: "email",
|
||||
contactPointId: email.id as string,
|
||||
label: email.email,
|
||||
currentState: email.quality_state ?? "valid"
|
||||
})}>
|
||||
Quality
|
||||
</Button>}
|
||||
{email.original_email && email.original_email !== email.email && <small>Original: {email.original_email}</small>}
|
||||
{contactPointSource(email.provenance) && <small>Source: {contactPointSource(email.provenance)}</small>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -2102,7 +2418,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
{selectedContact.phones.map((phone, index) => (
|
||||
<div key={phone.id ?? `${phone.phone}-${index}`}>
|
||||
<dt>{phone.label || "phone"}{phone.is_primary ? " · primary" : ""}</dt>
|
||||
<dd>{phone.phone}</dd>
|
||||
<dd className="address-contact-point-value">
|
||||
<span>{phone.phone}</span>
|
||||
<StatusBadge status={phone.quality_state ?? "valid"} />
|
||||
{phone.id && <Button
|
||||
type="button"
|
||||
title="Record phone quality"
|
||||
aria-label={`Record quality for ${phone.phone}`}
|
||||
disabledReason={disabledReason(
|
||||
[!canWriteGovernance, "You need permission to manage address quality."],
|
||||
[saving, savingReason]
|
||||
)}
|
||||
onClick={() => openQualityPointEditor({
|
||||
contact: selectedContact,
|
||||
channel: "phone",
|
||||
contactPointId: phone.id as string,
|
||||
label: phone.phone,
|
||||
currentState: phone.quality_state ?? "valid"
|
||||
})}>
|
||||
Quality
|
||||
</Button>}
|
||||
{phone.original_phone && phone.original_phone !== phone.phone && <small>Original: {phone.original_phone}</small>}
|
||||
{contactPointSource(phone.provenance) && <small>Source: {contactPointSource(phone.provenance)}</small>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -2115,7 +2453,29 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
{selectedContact.postal_addresses.map((address, index) => (
|
||||
<div key={address.id ?? `${address.label}-${index}`}>
|
||||
<dt>{address.label || "address"}{address.is_primary ? " · primary" : ""}</dt>
|
||||
<dd>{formatPostalAddress(address) || "No formatted address."}</dd>
|
||||
<dd className="address-contact-point-value">
|
||||
<span>{formatPostalAddress(address) || "No formatted address."}</span>
|
||||
<StatusBadge status={address.quality_state ?? "valid"} />
|
||||
{address.id && <Button
|
||||
type="button"
|
||||
title="Record postal-address quality"
|
||||
aria-label={`Record quality for ${formatPostalAddress(address) || "postal address"}`}
|
||||
disabledReason={disabledReason(
|
||||
[!canWriteGovernance, "You need permission to manage address quality."],
|
||||
[saving, savingReason]
|
||||
)}
|
||||
onClick={() => openQualityPointEditor({
|
||||
contact: selectedContact,
|
||||
channel: "postal",
|
||||
contactPointId: address.id as string,
|
||||
label: formatPostalAddress(address) || "postal address",
|
||||
currentState: address.quality_state ?? "valid"
|
||||
})}>
|
||||
Quality
|
||||
</Button>}
|
||||
{originalPostalSummary(address) && originalPostalSummary(address) !== formatPostalAddress(address) && <small>Original: {originalPostalSummary(address)}</small>}
|
||||
{contactPointSource(address.provenance) && <small>Source: {contactPointSource(address.provenance)}</small>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -2507,6 +2867,258 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(provenanceContact)}
|
||||
title={provenanceContact ? `Field provenance · ${provenanceContact.display_name}` : "Field provenance"}
|
||||
onClose={() => setProvenanceContact(null)}
|
||||
closeDisabled={provenanceLoading}
|
||||
className="address-quality-dialog"
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={<Button type="button" onClick={() => setProvenanceContact(null)} disabledReason={provenanceLoading ? "Field provenance is loading." : ""}>Close</Button>}>
|
||||
<LoadingFrame loading={provenanceLoading} label="Loading field provenance...">
|
||||
<div className="address-provenance-layout">
|
||||
<ToggleSwitch
|
||||
label="Show history"
|
||||
checked={showProvenanceHistory}
|
||||
onChange={() => setShowProvenanceHistory((current) => !current)}
|
||||
help="Include superseded source decisions as well as the currently retained values."
|
||||
/>
|
||||
<div className="address-quality-list address-provenance-list">
|
||||
{contactProvenance.filter((item) => showProvenanceHistory || item.selected).length === 0 ?
|
||||
<p className="muted address-quality-empty">No field provenance was recorded.</p> :
|
||||
contactProvenance
|
||||
.filter((item) => showProvenanceHistory || item.selected)
|
||||
.map((item) => (
|
||||
<article className="address-quality-row" key={item.id}>
|
||||
<div className="address-quality-row-main">
|
||||
<span className="address-quality-row-heading">
|
||||
<strong>{item.field_path}</strong>
|
||||
<StatusBadge status={item.selected ? "retained" : "superseded"} />
|
||||
{item.visibility !== "inherit" && <StatusBadge status={item.visibility} />}
|
||||
</span>
|
||||
<span className="address-provenance-value">{provenanceValue(item.value)}</span>
|
||||
<small>
|
||||
Source: {item.source_kind}{item.source_ref ? ` · ${item.source_ref}` : ""}
|
||||
{` · ${item.reason_code}`}
|
||||
{` · ${formatDateTime(item.created_at, ADDRESS_DATE_TIME_OPTIONS)}`}
|
||||
</small>
|
||||
{item.explanation && <small>{item.explanation}</small>}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={qualityOpen}
|
||||
title={selectedBook ? `Address quality · ${selectedBook.name}` : "Address quality"}
|
||||
onClose={() => setQualityOpen(false)}
|
||||
closeDisabled={saving}
|
||||
className="address-quality-dialog"
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" onClick={() => selectedBook && void refreshQualityReview(selectedBook.id)} disabledReason={disabledReason([qualityLoading, "Address quality is loading."], [saving, savingReason])}><RefreshCw size={15} /> Refresh</Button>
|
||||
<Button type="button" onClick={() => setQualityOpen(false)} disabledReason={dialogCancelReason}>Close</Button>
|
||||
</>
|
||||
}>
|
||||
<LoadingFrame loading={qualityLoading} label="Loading address quality...">
|
||||
<div className="address-quality-layout">
|
||||
{qualitySummary && <div className="metric-grid inside address-quality-metrics">
|
||||
<MetricCard label="Contacts" value={qualitySummary.contact_count} tone="info" detail={`${qualitySummary.contact_point_count} contact points`} />
|
||||
<MetricCard label="Corrections" value={qualitySummary.correction_count} tone={qualitySummary.correction_count > 0 ? "warning" : "good"} detail="Current non-valid states" />
|
||||
<MetricCard label="Duplicates" value={qualitySummary.duplicate_suggestion_count} tone={qualitySummary.duplicate_suggestion_count > 0 ? "warning" : "good"} detail="Explainable suggestions" />
|
||||
<MetricCard label="Undeliverable" value={(qualitySummary.quality_counts.undeliverable ?? 0) + (qualitySummary.quality_counts.returned ?? 0)} tone={(qualitySummary.quality_counts.undeliverable ?? 0) + (qualitySummary.quality_counts.returned ?? 0) > 0 ? "danger" : "good"} detail="Returned or undeliverable" />
|
||||
</div>}
|
||||
|
||||
<section className="address-quality-section">
|
||||
<div className="address-form-section-heading">
|
||||
<div>
|
||||
<strong>Duplicate suggestions</strong>
|
||||
<p className="muted small-text">Scores are bounded and show the exact matching features. Choose which contact survives.</p>
|
||||
</div>
|
||||
</div>
|
||||
{duplicateSuggestions.length === 0 ? <p className="muted">No duplicate suggestions above the current threshold.</p> :
|
||||
<div className="address-quality-list">
|
||||
{duplicateSuggestions.map((suggestion) => (
|
||||
<article className="address-quality-row" key={`${suggestion.left.id}:${suggestion.right.id}`}>
|
||||
<div className="address-quality-row-main">
|
||||
<span className="address-quality-row-heading">
|
||||
<strong>{suggestion.left.display_name}</strong>
|
||||
<span>and</span>
|
||||
<strong>{suggestion.right.display_name}</strong>
|
||||
<StatusBadge status={suggestion.confidence} label={`${suggestion.score}% ${suggestion.confidence}`} />
|
||||
</span>
|
||||
<small>{suggestion.features.map((feature) => `${feature.label}: ${feature.value}`).join(" · ")}</small>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button type="button" onClick={() => openMergeDialog(suggestion, suggestion.left.id)} disabledReason={disabledReason([!canWriteContacts || !canDeleteContacts, "You need permission to edit and delete contacts."], [saving, savingReason])}><GitMerge size={15} /> Keep left</Button>
|
||||
<Button type="button" onClick={() => openMergeDialog(suggestion, suggestion.right.id)} disabledReason={disabledReason([!canWriteContacts || !canDeleteContacts, "You need permission to edit and delete contacts."], [saving, savingReason])}><GitMerge size={15} /> Keep right</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section className="address-quality-section">
|
||||
<div className="address-form-section-heading">
|
||||
<div>
|
||||
<strong>Correction queue</strong>
|
||||
<p className="muted small-text">Current quality states are also applied to campaign and distribution-list recipient resolution.</p>
|
||||
</div>
|
||||
</div>
|
||||
{!qualitySummary || qualitySummary.corrections.length === 0 ? <p className="muted">No contact points need correction.</p> :
|
||||
<div className="address-quality-list">
|
||||
{qualitySummary.corrections.map((correction) => (
|
||||
<button
|
||||
type="button"
|
||||
className="address-quality-row address-quality-row-button"
|
||||
key={`${correction.contact_id}:${correction.channel}:${correction.contact_point_id ?? "all"}`}
|
||||
onClick={() => {
|
||||
setSelectedContactId(correction.contact_id);
|
||||
setQualityOpen(false);
|
||||
}}>
|
||||
<span className="address-quality-row-main">
|
||||
<span className="address-quality-row-heading"><strong>{correction.display_name}</strong><StatusBadge status={correction.state} /></span>
|
||||
<small>{correction.channel.replace("_", " ")} · {correction.reason || correction.reason_code}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section className="address-quality-section">
|
||||
<div className="address-form-section-heading">
|
||||
<div>
|
||||
<strong>Merge history</strong>
|
||||
<p className="muted small-text">Recovery is available only while the recorded post-merge state still matches.</p>
|
||||
</div>
|
||||
</div>
|
||||
{contactMerges.length === 0 ? <p className="muted">No contact merges recorded.</p> :
|
||||
<div className="address-quality-list">
|
||||
{contactMerges.map((merge) => (
|
||||
<article className="address-quality-row" key={merge.id}>
|
||||
<div className="address-quality-row-main">
|
||||
<span className="address-quality-row-heading"><History size={15} /><strong>{merge.reason}</strong><StatusBadge status={merge.status} /></span>
|
||||
<small>{merge.loser_contact_ids.length} merged contact{merge.loser_contact_ids.length === 1 ? "" : "s"} · {formatDateTime(merge.created_at, ADDRESS_DATE_TIME_OPTIONS)}</small>
|
||||
</div>
|
||||
{merge.status === "active" && <div className="button-row compact-actions">
|
||||
<Button type="button" onClick={() => setMergeRecoveryDialog({ merge, action: "undo", reason: "Undo merge after quality review." })} disabledReason={disabledReason([!canWriteContacts, "You need permission to edit contacts."], [saving, savingReason])}><RotateCcw size={15} /> Undo</Button>
|
||||
<Button type="button" onClick={() => setMergeRecoveryDialog({ merge, action: "split", reason: "Split merged contacts after quality review." })} disabledReason={disabledReason([!canWriteContacts, "You need permission to edit contacts."], [saving, savingReason])}>Split</Button>
|
||||
</div>}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
{qualitySummary?.truncated && <DismissibleAlert tone="warning" dismissible={false}>This bounded review is truncated. Narrow the address book or use the API for a complete staged review.</DismissibleAlert>}
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(qualityPointTarget)}
|
||||
title={qualityPointTarget ? `Contact-point quality · ${qualityPointTarget.contact.display_name}` : "Contact-point quality"}
|
||||
onClose={() => setQualityPointTarget(null)}
|
||||
closeDisabled={saving}
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" onClick={() => setQualityPointTarget(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||
<Button type="submit" form="address-quality-point-form" variant="primary" disabledReason={disabledReason([!qualityPointTarget, "Select a contact point."], [!canWriteGovernance, "You need permission to manage address quality."], [saving, savingReason])}><Save size={15} /> Record</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="address-quality-point-form" className="address-dialog-form" onSubmit={(event) => void submitQualityDecision(event)}>
|
||||
<FormField label="Contact point"><input value={qualityPointTarget?.label ?? ""} readOnly disabled /></FormField>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Quality state">
|
||||
<select value={qualityForm.state} onChange={(event) => setQualityForm((current) => ({ ...current, state: event.target.value as ContactPointQualityState }))}>
|
||||
<option value="valid">Valid</option>
|
||||
<option value="invalid">Invalid</option>
|
||||
<option value="returned">Returned</option>
|
||||
<option value="stale">Stale</option>
|
||||
<option value="undeliverable">Undeliverable</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Reason code"><input value={qualityForm.reason_code} placeholder={`addresses.quality.${qualityForm.state}`} onChange={(event) => setQualityForm((current) => ({ ...current, reason_code: event.target.value }))} /></FormField>
|
||||
</div>
|
||||
<FormField label="Reason"><textarea rows={3} value={qualityForm.reason} onChange={(event) => setQualityForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
|
||||
<FormField label="Evidence reference"><input value={qualityForm.evidence_ref} placeholder="mail:delivery:..." onChange={(event) => setQualityForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(mergeDialog)}
|
||||
title="Merge duplicate contacts"
|
||||
onClose={() => setMergeDialog(null)}
|
||||
closeDisabled={saving}
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" onClick={() => setMergeDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||
<Button type="submit" form="address-merge-form" variant="primary" disabledReason={disabledReason([!mergeDialog || mergeDialog.reason.trim().length < 3, "Record why these contacts are being merged."], [saving, savingReason])}><GitMerge size={15} /> Merge</Button>
|
||||
</>
|
||||
}>
|
||||
{mergeDialog && <form id="address-merge-form" className="address-dialog-form" onSubmit={(event) => void submitContactMerge(event)}>
|
||||
<DismissibleAlert tone="warning" dismissible={false}>The other contact is archived and redirected to the survivor. Address-list memberships and matching contact points are repaired transactionally.</DismissibleAlert>
|
||||
<FormField label="Surviving contact">
|
||||
<select value={mergeDialog.winnerId} onChange={(event) => changeMergeWinner(event.target.value)}>
|
||||
<option value={mergeDialog.suggestion.left.id}>{mergeDialog.suggestion.left.display_name} · {primaryEmail(mergeDialog.suggestion.left) || "no email"}</option>
|
||||
<option value={mergeDialog.suggestion.right.id}>{mergeDialog.suggestion.right.display_name} · {primaryEmail(mergeDialog.suggestion.right) || "no email"}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<div className="form-grid two address-merge-field-sources">
|
||||
{MERGE_SCALAR_FIELDS.map((field) => (
|
||||
<FormField label={`${field.label} source`} key={field.id}>
|
||||
<select
|
||||
value={mergeDialog.fieldSources[field.id] ?? mergeDialog.winnerId}
|
||||
onChange={(event) => setMergeDialog((current) => current ? {
|
||||
...current,
|
||||
fieldSources: { ...current.fieldSources, [field.id]: event.target.value }
|
||||
} : null)}>
|
||||
<option value={mergeDialog.suggestion.left.id}>Left · {contactScalarValue(mergeDialog.suggestion.left, field.id)}</option>
|
||||
<option value={mergeDialog.suggestion.right.id}>Right · {contactScalarValue(mergeDialog.suggestion.right, field.id)}</option>
|
||||
</select>
|
||||
</FormField>
|
||||
))}
|
||||
</div>
|
||||
<FormField label="Contact points">
|
||||
<SegmentedControl<"union" | "winner_only">
|
||||
role="group"
|
||||
size="equal"
|
||||
ariaLabel="Contact-point merge strategy"
|
||||
options={[{ id: "union", label: "Combine unique" }, { id: "winner_only", label: "Keep survivor only" }]}
|
||||
value={mergeDialog.contactPointStrategy}
|
||||
onChange={(contactPointStrategy) => setMergeDialog((current) => current ? { ...current, contactPointStrategy } : null)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Reason"><textarea rows={3} value={mergeDialog.reason} onChange={(event) => setMergeDialog((current) => current ? { ...current, reason: event.target.value } : null)} /></FormField>
|
||||
</form>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(mergeRecoveryDialog)}
|
||||
title={mergeRecoveryDialog?.action === "split" ? "Split merged contacts" : "Undo contact merge"}
|
||||
onClose={() => setMergeRecoveryDialog(null)}
|
||||
closeDisabled={saving}
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" onClick={() => setMergeRecoveryDialog(null)} disabledReason={dialogCancelReason}>Cancel</Button>
|
||||
<Button type="submit" form="address-merge-recovery-form" variant="primary" disabledReason={disabledReason([!mergeRecoveryDialog || mergeRecoveryDialog.reason.trim().length < 3, "Record why the merge is being recovered."], [saving, savingReason])}><RotateCcw size={15} /> {mergeRecoveryDialog?.action === "split" ? "Split" : "Undo"}</Button>
|
||||
</>
|
||||
}>
|
||||
{mergeRecoveryDialog && <form id="address-merge-recovery-form" className="address-dialog-form" onSubmit={(event) => void submitMergeRecovery(event)}>
|
||||
<p className="muted">Recovery restores the recorded pre-merge values and list memberships. It is rejected if either contact changed after the merge.</p>
|
||||
<FormField label="Reason"><textarea rows={3} value={mergeRecoveryDialog.reason} onChange={(event) => setMergeRecoveryDialog((current) => current ? { ...current, reason: event.target.value } : null)} autoFocus /></FormField>
|
||||
</form>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={memberDialogOpen}
|
||||
title={selectedList ? `Add contacts to ${selectedList.name}` : "Add contacts to list"}
|
||||
|
||||
@@ -420,6 +420,23 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.address-contact-point-value {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 4px 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.address-contact-point-value > small {
|
||||
color: var(--muted);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.address-contact-point-value .btn {
|
||||
min-height: 28px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.address-membership-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -543,6 +560,104 @@
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.dialog-panel.address-quality-dialog,
|
||||
.address-quality-dialog .dialog-panel {
|
||||
width: min(1120px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.address-quality-layout {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
max-height: min(720px, calc(100vh - 210px));
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.address-provenance-layout {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.address-provenance-list {
|
||||
max-height: min(620px, calc(100vh - 300px));
|
||||
}
|
||||
|
||||
.address-provenance-value {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.address-quality-empty {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.address-merge-field-sources select {
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.address-quality-metrics {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.address-quality-section,
|
||||
.address-quality-list,
|
||||
.address-quality-row-main {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.address-quality-section {
|
||||
border-top: var(--border-line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.address-quality-list {
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.address-quality-row {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: var(--border-line);
|
||||
color: inherit;
|
||||
display: grid;
|
||||
font: inherit;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.address-quality-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.address-quality-row-button {
|
||||
cursor: pointer;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.address-quality-row-button:hover {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.address-quality-row-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.address-quality-row-main small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.address-governance-layout,
|
||||
.address-governance-list {
|
||||
display: grid;
|
||||
@@ -637,4 +752,13 @@
|
||||
.address-form-row-postal {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.address-quality-row,
|
||||
.address-contact-point-value {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.address-contact-point-value > small {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user