307 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|