changemaker.lite/admin/src/components/people/MergeContactModal.tsx

307 lines
9.7 KiB
TypeScript

import { useState, useRef, useCallback } from 'react';
import {
Modal,
Select,
Typography,
Radio,
Tag,
Space,
Button,
Divider,
message,
} from 'antd';
import { SearchOutlined, SwapOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { Contact, UnifiedPerson, PeopleListResponse, MergeContactPayload } from '@/types/api';
import { SUPPORT_LEVEL_LABELS, CONTACT_SOURCE_LABELS, CONTACT_SOURCE_COLORS } from '@/types/api';
interface MergeContactModalProps {
open: boolean;
targetContact: Contact;
onClose: () => void;
onMerged: () => void;
}
type MergeField = 'displayName' | 'email' | 'phone' | 'supportLevel' | 'tags' | 'notes';
export default function MergeContactModal({ open, targetContact, onClose, onMerged }: MergeContactModalProps) {
const [searchResults, setSearchResults] = useState<UnifiedPerson[]>([]);
const [searching, setSearching] = useState(false);
const [sourcePerson, setSourcePerson] = useState<UnifiedPerson | null>(null);
const [fieldChoices, setFieldChoices] = useState<Record<MergeField, 'target' | 'source'>>({
displayName: 'target',
email: 'target',
phone: 'target',
supportLevel: 'target',
tags: 'target',
notes: 'target',
});
const [merging, setMerging] = useState(false);
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleSearch = useCallback((value: string) => {
clearTimeout(searchTimerRef.current);
if (!value.trim()) {
setSearchResults([]);
return;
}
searchTimerRef.current = setTimeout(async () => {
setSearching(true);
try {
const { data } = await api.get<PeopleListResponse>('/people', {
params: { search: value, limit: 8 },
});
// Filter out the target contact
setSearchResults(data.people.filter((p) => p.contactId !== targetContact.id));
} catch {
setSearchResults([]);
} finally {
setSearching(false);
}
}, 300);
}, [targetContact.id]);
const handleFieldChoice = (field: MergeField, value: 'target' | 'source') => {
setFieldChoices((prev) => ({ ...prev, [field]: value }));
};
const handleMerge = async () => {
if (!sourcePerson) {
message.warning('Please select a person to merge');
return;
}
const colonIdx = sourcePerson.id.indexOf(':');
const sourceType = colonIdx !== -1 ? sourcePerson.id.substring(0, colonIdx) : 'contact';
const sourceId = colonIdx !== -1 ? sourcePerson.id.substring(colonIdx + 1) : sourcePerson.id;
setMerging(true);
try {
const payload: MergeContactPayload = {
sourceType: sourceType as 'user' | 'addr' | 'contact',
sourceId,
keepFields: fieldChoices as Record<string, 'source' | 'target'>,
};
await api.post(`/people/contacts/${targetContact.id}/merge`, payload);
message.success('Contacts merged successfully');
onMerged();
} catch {
message.error('Failed to merge contacts');
} finally {
setMerging(false);
}
};
const handleClose = () => {
setSourcePerson(null);
setSearchResults([]);
setFieldChoices({
displayName: 'target',
email: 'target',
phone: 'target',
supportLevel: 'target',
tags: 'target',
notes: 'target',
});
onClose();
};
const getSourceValue = (field: MergeField): string => {
if (!sourcePerson) return '--';
switch (field) {
case 'displayName':
return sourcePerson.displayName || '--';
case 'email':
return sourcePerson.email || '--';
case 'phone':
return sourcePerson.phone || '--';
case 'supportLevel':
return sourcePerson.supportLevel ? SUPPORT_LEVEL_LABELS[sourcePerson.supportLevel] : '--';
case 'tags':
return sourcePerson.tags?.join(', ') || '--';
case 'notes':
return '(from source)';
default:
return '--';
}
};
const getTargetValue = (field: MergeField): string => {
switch (field) {
case 'displayName':
return targetContact.displayName || '--';
case 'email':
return targetContact.email || '--';
case 'phone':
return targetContact.phone || '--';
case 'supportLevel':
return targetContact.supportLevel ? SUPPORT_LEVEL_LABELS[targetContact.supportLevel] : '--';
case 'tags':
return targetContact.tags?.join(', ') || '--';
case 'notes':
return targetContact.notes || '--';
default:
return '--';
}
};
const mergeFields: { field: MergeField; label: string }[] = [
{ field: 'displayName', label: 'Display Name' },
{ field: 'email', label: 'Email' },
{ field: 'phone', label: 'Phone' },
{ field: 'supportLevel', label: 'Support Level' },
{ field: 'tags', label: 'Tags (union of both)' },
{ field: 'notes', label: 'Notes (concatenated)' },
];
return (
<Modal
title={
<Space>
<SwapOutlined />
<span>Merge Contacts</span>
</Space>
}
open={open}
onCancel={handleClose}
width={700}
footer={[
<Button key="cancel" onClick={handleClose}>
Cancel
</Button>,
<Button
key="merge"
type="primary"
danger
onClick={handleMerge}
loading={merging}
disabled={!sourcePerson}
>
Confirm Merge
</Button>,
]}
>
{/* Search for source person */}
<div style={{ marginBottom: 20 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Search for the person to merge INTO this contact:
</Typography.Text>
<Select
showSearch
placeholder="Search by name, email, or phone..."
filterOption={false}
onSearch={handleSearch}
loading={searching}
notFoundContent={searching ? 'Searching...' : 'No results'}
style={{ width: '100%' }}
value={sourcePerson?.id}
onChange={(value) => {
const person = searchResults.find((p) => p.id === value);
setSourcePerson(person || null);
}}
options={searchResults.map((p) => ({
value: p.id,
label: (
<div>
<span style={{ fontWeight: 500 }}>{p.displayName}</span>
{p.email && (
<span style={{ color: '#888', marginLeft: 8, fontSize: 12 }}>
{p.email}
</span>
)}
<Tag
color={CONTACT_SOURCE_COLORS[p.source]}
style={{ marginLeft: 8, fontSize: 10 }}
>
{CONTACT_SOURCE_LABELS[p.source]}
</Tag>
</div>
),
}))}
suffixIcon={<SearchOutlined />}
/>
</div>
{sourcePerson && (
<>
{/* Side-by-side header */}
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<div
style={{
flex: 1,
padding: 12,
background: 'rgba(82, 196, 26, 0.08)',
borderRadius: 8,
border: '1px solid rgba(82, 196, 26, 0.2)',
textAlign: 'center',
}}
>
<Typography.Text strong style={{ fontSize: 12, color: '#52c41a' }}>
TARGET (Keep)
</Typography.Text>
<div style={{ marginTop: 4 }}>
<Typography.Text strong>{targetContact.displayName}</Typography.Text>
</div>
</div>
<div
style={{
flex: 1,
padding: 12,
background: 'rgba(24, 144, 255, 0.08)',
borderRadius: 8,
border: '1px solid rgba(24, 144, 255, 0.2)',
textAlign: 'center',
}}
>
<Typography.Text strong style={{ fontSize: 12, color: '#1890ff' }}>
SOURCE (Merge In)
</Typography.Text>
<div style={{ marginTop: 4 }}>
<Typography.Text strong>{sourcePerson.displayName}</Typography.Text>
</div>
</div>
</div>
<Divider style={{ margin: '12px 0' }}>Field Selection</Divider>
{/* Field-by-field selection */}
{mergeFields.map(({ field, label }) => (
<div
key={field}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 12,
padding: '8px 0',
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}
>
<Typography.Text style={{ width: 130, fontSize: 13, flexShrink: 0 }}>
{label}
</Typography.Text>
<Radio.Group
value={fieldChoices[field]}
onChange={(e) => handleFieldChoice(field, e.target.value)}
size="small"
>
<Radio.Button value="target">
<span style={{ fontSize: 11 }}>{getTargetValue(field)}</span>
</Radio.Button>
<Radio.Button value="source">
<span style={{ fontSize: 11 }}>{getSourceValue(field)}</span>
</Radio.Button>
</Radio.Group>
</div>
))}
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 8 }}>
The source person will be marked as merged. Tags will be combined from both contacts.
Notes will be concatenated. All activity history will be preserved.
</Typography.Text>
</>
)}
</Modal>
);
}