mono/packages/ui/src/components/ContactsManager.tsx
2026-03-21 20:18:25 +01:00

1340 lines
69 KiB
TypeScript

import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { toast } from 'sonner';
import { useSearchParams } from 'react-router-dom';
import {
DataGrid, GridColDef, GridRowSelectionModel, GridRowId,
type GridFilterModel, type GridSortModel, type GridColumnVisibilityModel, type GridPaginationModel
} from '@mui/x-data-grid';
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import { useMuiTheme } from '@/hooks/useMuiTheme';
import {
filterModelToParams, paramsToFilterModel,
sortModelToParams, paramsToSortModel,
visibilityModelToParams, paramsToVisibilityModel,
} from '@/components/grids/gridUtils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import {
Popover, PopoverContent, PopoverTrigger,
} from '@/components/ui/popover';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle
} from '@/components/ui/alert-dialog';
import {
Plus, Search, Download, Upload, Trash2, Pencil, Users, X, Check,
FolderOpen, ChevronDown, Mail, Phone, Building2, Globe, Tag, Layers, SlidersHorizontal, Code2
} from 'lucide-react';
import { T, translate } from '@/i18n';
import {
Contact, ContactGroup,
fetchContacts, createContact, updateContact, deleteContact, batchDeleteContacts,
importContacts, exportContacts,
fetchContactGroups, createContactGroup, deleteContactGroup,
addGroupMembers, fetchGroupMembers, removeGroupMember,
} from '@/modules/contacts/client-contacts';
// ─── Status badge ─────────────────────────────────────────────────────────────
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-500/15 text-green-600',
unsubscribed: 'bg-yellow-500/15 text-yellow-600',
bounced: 'bg-orange-500/15 text-orange-600',
blocked: 'bg-red-500/15 text-red-600',
};
const STATUSES = ['active', 'unsubscribed', 'bounced', 'blocked'] as const;
const StatusBadge = ({ status }: { status?: string }) => (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[status || 'active'] ?? STATUS_COLORS.active}`}>
<T>{status || 'active'}</T>
</span>
);
// ─── Contact Form ─────────────────────────────────────────────────────────────
const EMPTY: Partial<Contact> = {
name: '', first_name: '', last_name: '',
emails: [], phone: '', organization: '', title: '',
address: [], source: '', language: '', status: 'active',
notes: '', tags: [], meta: {},
};
const ContactForm = ({
initial, onSave, onCancel, saving,
groups, initialGroupIds,
}: {
initial: Partial<Contact>;
onSave: (c: Partial<Contact>, groupIds: string[]) => void;
onCancel: () => void;
saving: boolean;
groups: ContactGroup[];
initialGroupIds: string[];
}) => {
const [c, setC] = useState<Partial<Contact>>(initial);
const [emailInput, setEmailInput] = useState('');
const [tagInput, setTagInput] = useState('');
const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(new Set(initialGroupIds));
const set = (k: keyof Contact, v: any) => setC(prev => ({ ...prev, [k]: v }));
const toggleGroup = (id: string) =>
setSelectedGroupIds(prev => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
return s;
});
const addEmail = () => {
if (!emailInput.trim()) return;
set('emails', [...(c.emails || []), { email: emailInput.trim(), label: 'INTERNET', primary: !(c.emails?.length) }]);
setEmailInput('');
};
const removeEmail = (i: number) => set('emails', (c.emails || []).filter((_, idx) => idx !== i));
const addTag = () => {
if (!tagInput.trim()) return;
set('tags', [...(c.tags || []), tagInput.trim()]);
setTagInput('');
};
const removeTag = (t: string) => set('tags', (c.tags || []).filter(x => x !== t));
return (
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label><T>First name</T></Label>
<Input value={c.first_name || ''} onChange={e => set('first_name', e.target.value)} />
</div>
<div className="space-y-1">
<Label><T>Last name</T></Label>
<Input value={c.last_name || ''} onChange={e => set('last_name', e.target.value)} />
</div>
</div>
<div className="space-y-1">
<Label><T>Display name</T></Label>
<Input value={c.name || ''} onChange={e => set('name', e.target.value)} />
</div>
<div className="space-y-2">
<Label className="flex items-center gap-1"><Mail className="h-3 w-3" /><T>Emails</T></Label>
<div className="flex gap-2">
<Input value={emailInput} onChange={e => setEmailInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addEmail())}
placeholder={translate('email@example.com')} />
<Button type="button" size="sm" variant="outline" onClick={addEmail}><Plus className="h-3 w-3" /></Button>
</div>
<div className="flex flex-wrap gap-1">
{(c.emails || []).map((em, i) => (
<Badge key={i} variant="secondary" className="flex items-center gap-1">
{em.email}
{em.primary && <span className="text-[10px] ml-0.5 text-muted-foreground"></span>}
<button onClick={() => removeEmail(i)}><X className="h-2.5 w-2.5" /></button>
</Badge>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="flex items-center gap-1"><Phone className="h-3 w-3" /><T>Phone</T></Label>
<Input value={c.phone || ''} onChange={e => set('phone', e.target.value)} />
</div>
<div className="space-y-1">
<Label className="flex items-center gap-1"><Building2 className="h-3 w-3" /><T>Organization</T></Label>
<Input value={c.organization || ''} onChange={e => set('organization', e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label><T>Title</T></Label>
<Input value={c.title || ''} onChange={e => set('title', e.target.value)} />
</div>
<div className="space-y-1">
<Label className="flex items-center gap-1"><Globe className="h-3 w-3" /><T>Language</T></Label>
<Input value={c.language || ''} onChange={e => set('language', e.target.value)} placeholder={translate('en, de…')} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label><T>Source</T></Label>
<Input value={c.source || ''} onChange={e => set('source', e.target.value)} placeholder={translate('import, manual…')} />
</div>
<div className="space-y-1">
<Label><T>Status</T></Label>
<Select value={c.status || 'active'} onValueChange={v => set('status', v as any)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{STATUSES.map(s => <SelectItem key={s} value={s}>{translate(s)}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-1"><Tag className="h-3 w-3" /><T>Tags</T></Label>
<div className="flex gap-2">
<Input value={tagInput} onChange={e => setTagInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder={translate('Add tag…')} />
<Button type="button" size="sm" variant="outline" onClick={addTag}><Plus className="h-3 w-3" /></Button>
</div>
<div className="flex flex-wrap gap-1">
{(c.tags || []).map(t => (
<Badge key={t} variant="outline" className="flex items-center gap-1">
{t}<button onClick={() => removeTag(t)}><X className="h-2.5 w-2.5" /></button>
</Badge>
))}
</div>
</div>
<div className="space-y-2">
<Label>
<T>Notes</T>
</Label>
<Textarea rows={2} value={c.notes || ''} onChange={e => set('notes', e.target.value)} />
</div>
{groups.length > 0 && (
<div className="space-y-2">
<Label className="flex items-center gap-1"><Users className="h-3 w-3" /><T>Groups</T></Label>
<div className="flex flex-wrap gap-1.5">
{groups.map(g => {
const checked = selectedGroupIds.has(g.id);
return (
<button
key={g.id}
type="button"
onClick={() => toggleGroup(g.id)}
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${checked
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:border-primary/50'
}`}
>
{g.name}
</button>
);
})}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancel}><T>Cancel</T></Button>
<Button onClick={() => {
// Auto-add pending email if user forgot to press +
let toSave = c;
if (emailInput.trim()) {
const updatedEmails = [...(c.emails || []), { email: emailInput.trim(), label: 'INTERNET', primary: !(c.emails?.length) }];
toSave = { ...c, emails: updatedEmails };
}
onSave(toSave, Array.from(selectedGroupIds));
}} disabled={saving}>
<Check className="h-3 w-3 mr-1" /><T>{saving ? 'Saving…' : 'Save'}</T>
</Button>
</DialogFooter>
</div>
);
};
const ActionBar = ({
count,
total,
groups,
onSetGroup,
onRemoveGroups,
onSetStatus,
onDelete,
onClear,
onExport,
onNew,
busy,
resetKey,
}: {
count: number;
total: number;
groups: ContactGroup[];
onSetGroup: (groupId: string) => void;
onRemoveGroups: () => void;
onSetStatus: (status: string) => void;
onDelete: () => void;
onClear: () => void;
onExport: (format: 'json' | 'vcard') => void;
onNew: () => void;
busy: boolean;
resetKey: number;
}) => {
const hasSelection = count > 0;
return (
<div className="flex flex-wrap items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 border">
<span className="text-sm font-medium mr-2">
{count > 0 ? (
<span className="text-primary">{count} <T>selected</T></span>
) : (
<span className="text-muted-foreground"><T>Zero selected</T> ({total} <T>total</T>)</span>
)}
</span>
{/* Set group */}
<Select key={`g-${resetKey}`} onValueChange={v => v === '__none' ? onRemoveGroups() : onSetGroup(v)} disabled={busy || total === 0}>
<SelectTrigger className="h-7 w-36 text-xs">
<FolderOpen className="h-3 w-3 mr-1 text-muted-foreground" />
<SelectValue placeholder={translate('Set group…')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none"><T>None</T></SelectItem>
{groups.map(g => <SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>)}
</SelectContent>
</Select>
{/* Set status */}
<Select key={`s-${resetKey}`} onValueChange={onSetStatus} disabled={busy || total === 0}>
<SelectTrigger className="h-7 w-36 text-xs">
<Layers className="h-3 w-3 mr-1 text-muted-foreground" />
<SelectValue placeholder={translate('Set status…')} />
</SelectTrigger>
<SelectContent>
{STATUSES.map(s => <SelectItem key={s} value={s}>{translate(s)}</SelectItem>)}
</SelectContent>
</Select>
{/* Delete */}
{count > 0 ? (
<Button size="sm" variant="destructive" className="h-7 text-xs gap-1" onClick={onDelete} disabled={busy}>
<Trash2 className="h-3 w-3" /><T>Delete ({count})</T>
</Button>
) : (
<Button size="sm" variant="destructive" className="h-7 text-xs gap-1" onClick={onDelete} disabled={busy || total === 0}>
<Trash2 className="h-3 w-3" /><T>Delete All ({total})</T>
</Button>
)}
{hasSelection && (
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={onClear} disabled={busy}>
<X className="h-3 w-3 mr-1" /><T>Clear</T>
</Button>
)}
<div className="flex items-center gap-2 ml-auto">
<Select onValueChange={f => onExport(f as any)}>
<SelectTrigger className="h-7 w-28 text-xs">
<Download className="h-3 w-3 mr-1" /><T>Export</T>
</SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="vcard">vCard (.vcf)</SelectItem>
</SelectContent>
</Select>
<Button size="sm" className="h-7 text-xs gap-1" onClick={onNew}>
<Plus className="h-3 w-3" /><T>New</T>
</Button>
</div>
</div>
);
};
// ─── Main component ───────────────────────────────────────────────────────────
export const ContactsManager = () => {
const muiTheme = useMuiTheme();
const [searchParams, setSearchParams] = useSearchParams();
// Filters from URL search params
const q = searchParams.get('q') || '';
const filterGroup = searchParams.get('group') || '';
const filterStatus = searchParams.get('status') || '';
const setFilter = useCallback((key: string, value: string) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev);
if (value) next.set(key, value);
else next.delete(key);
return next;
}, { replace: true });
}, [setSearchParams]);
const setQ = useCallback((v: string) => setFilter('q', v), [setFilter]);
const setFilterGroup = useCallback((v: string) => setFilter('group', v), [setFilter]);
const setFilterStatus = useCallback((v: string) => setFilter('status', v), [setFilter]);
// Advanced fields
const [advancedFilters, setAdvancedFilters] = useState<Array<{ field: string, op: string, value: string }>>([]);
const advFields = [
{ id: 'name', label: 'Name' },
{ id: 'organization', label: 'Organization' },
{ id: 'email', label: 'Email' },
{ id: 'phone', label: 'Phone' },
{ id: 'website', label: 'Website' },
{ id: 'tags', label: 'Tag' },
{ id: 'city', label: 'City' },
{ id: 'country', label: 'Country' },
{ id: 'status', label: 'Status' },
{ id: 'group', label: 'Group' },
{ id: 'meta_all', label: 'Any Metadata' },
{ id: 'meta_key', label: 'Metadata (By Key)' }
];
const advOps = ['contains', 'does not contain', 'equals', 'does not equal', 'starts with', 'ends with', 'is empty', 'is not empty', 'is any of'];
const [contacts, setContacts] = useState<Contact[]>([]);
const [groups, setGroups] = useState<ContactGroup[]>([]);
// contact_id → array of ContactGroup
const [contactGroupMap, setContactGroupMap] = useState<Map<string, ContactGroup[]>>(new Map());
const [loading, setLoading] = useState(true);
// Selection
const EMPTY_SELECTION: GridRowSelectionModel = { type: 'include', ids: new Set<GridRowId>() };
const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>(EMPTY_SELECTION);
const [batchBusy, setBatchBusy] = useState(false);
const [batchResetKey, setBatchResetKey] = useState(0);
// Grid state from URL
const [filterModel, setFilterModel] = useState<GridFilterModel>(() => paramsToFilterModel(searchParams));
const [sortModel, setSortModel] = useState<GridSortModel>(() => paramsToSortModel(searchParams));
const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(() => paramsToVisibilityModel(searchParams));
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>(() => ({
page: parseInt(searchParams.get('page') || '0', 10),
pageSize: parseInt(searchParams.get('pageSize') || '50', 10),
}));
// Dialogs
const [editContact, setEditContact] = useState<Partial<Contact> | null>(null);
const [isNew, setIsNew] = useState(false);
const [saving, setSaving] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [batchDelete, setBatchDelete] = useState(false);
// Groups dialog
const [groupDialog, setGroupDialog] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [savingGroup, setSavingGroup] = useState(false);
// Import
const importRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const load = async () => {
setLoading(true);
try {
const [c, g, members] = await Promise.all([
fetchContacts({ q: q || undefined, group: filterGroup || undefined, status: filterStatus || undefined, limit: 10000 }),
fetchContactGroups(),
fetchGroupMembers(),
]);
// Build contact→groups map
const gMap = new Map<string, ContactGroup[]>();
const groupById = new Map(g.map(grp => [grp.id, grp]));
for (const m of members) {
const grp = groupById.get(m.group_id);
if (!grp) continue;
const list = gMap.get(m.contact_id) || [];
list.push(grp);
gMap.set(m.contact_id, list);
}
setContactGroupMap(gMap);
setGroups(g);
const enrichedContacts = c.map(contact => ({
...contact,
groups: gMap.get(contact.id) || []
}));
setContacts(enrichedContacts);
setRowSelectionModel({ type: 'include', ids: new Set<GridRowId>() });
} catch (e: any) { toast.error(e.message); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, [q, filterGroup, filterStatus]);
// ── Selection helpers ────────────────────────────────────────────────────
const filtered = contacts.filter(c => {
let match = true;
// Main generic search bar
if (q) {
const needle = q.toLowerCase();
match = !!(
c.name?.toLowerCase().includes(needle) ||
c.organization?.toLowerCase().includes(needle) ||
c.notes?.toLowerCase().includes(needle) ||
c.phone?.toLowerCase().includes(needle) ||
c.status?.toLowerCase().includes(needle) ||
c.source?.toLowerCase().includes(needle) ||
c.emails?.some(e => e.email.toLowerCase().includes(needle)) ||
c.tags?.some(t => t.toLowerCase().includes(needle)) ||
c.address?.some(a =>
a.city?.toLowerCase().includes(needle) ||
a.country?.toLowerCase().includes(needle) ||
a.street?.toLowerCase().includes(needle)
) ||
(c.meta && Object.values(c.meta).some(val =>
typeof val === 'string' && val.toLowerCase().includes(needle)
))
);
}
if (!match) return false;
// Advanced targeted fields (ALL must match)
if (advancedFilters.length > 0) {
for (const f of advancedFilters) {
const op = f.op || 'contains';
const v = (f.value || '').toLowerCase();
// Skip value check for empty/not empty operators
if (!v && !['is empty', 'is not empty'].includes(op)) continue;
let fieldVal = '';
if (f.field === 'name') fieldVal = c.name || '';
else if (f.field === 'organization') fieldVal = c.organization || '';
else if (f.field === 'phone') fieldVal = c.phone || '';
else if (f.field === 'email') fieldVal = c.emails?.map(e => e.email).join(', ') || '';
else if (f.field === 'tags') fieldVal = c.tags?.join(', ') || '';
else if (f.field === 'website') fieldVal = c.meta?.websites?.map((w: any) => w.url).join(', ') || '';
else if (f.field === 'status') fieldVal = c.status || '';
else if (f.field === 'group') fieldVal = (contactGroupMap.get(c.id) || []).map(g => g.name).join(', ');
else if (f.field === 'city' || f.field === 'country') {
fieldVal = c.address?.map(a => (a as any)[f.field]).filter(Boolean).join(', ') || '';
}
else if (f.field === 'meta_all') {
fieldVal = c.meta ? Object.values(c.meta).map(v => typeof v === 'object' ? '' : String(v)).join(', ') : '';
}
else if (f.field === 'meta_key') {
const mk = (f as any).metaKey?.trim();
if (mk && c.meta) {
const val = c.meta[mk] ?? c.meta[mk.toLowerCase()] ?? c.meta[mk.charAt(0).toUpperCase() + mk.slice(1)];
fieldVal = val != null && typeof val !== 'object' ? String(val) : '';
}
}
fieldVal = fieldVal.toLowerCase();
let ruleMatch = false;
switch (op) {
case 'contains': ruleMatch = fieldVal.includes(v); break;
case 'does not contain': ruleMatch = !fieldVal.includes(v); break;
case 'equals': ruleMatch = fieldVal === v; break;
case 'does not equal': ruleMatch = fieldVal !== v; break;
case 'starts with': ruleMatch = fieldVal.startsWith(v); break;
case 'ends with': ruleMatch = fieldVal.endsWith(v); break;
case 'is empty': ruleMatch = !fieldVal; break;
case 'is not empty': ruleMatch = !!fieldVal; break;
case 'is any of':
const parts = v.split(',').map(p => p.trim()).filter(Boolean);
ruleMatch = parts.some(p => fieldVal.includes(p));
break;
default: ruleMatch = fieldVal.includes(v);
}
if (!ruleMatch) {
match = false;
break;
}
}
}
return match;
});
const isExclude = rowSelectionModel.type === 'exclude';
const selectedCount = isExclude
? filtered.length - rowSelectionModel.ids.size
: rowSelectionModel.ids.size;
const selectedIds = isExclude
? filtered.filter(c => !rowSelectionModel.ids.has(c.id)).map(c => c.id)
: ([...rowSelectionModel.ids] as string[]);
// ── Columns ──
const columns = useMemo<GridColDef[]>(() => [
{
field: 'name', headerName: translate('Name'), flex: 1.5, minWidth: 180,
renderCell: (params: any) => {
const contact = params.row;
return (
<div className="flex items-center gap-2 h-full min-w-0">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary/40 to-primary flex items-center justify-center text-white font-semibold text-xs shrink-0">
{(contact.name || contact.first_name || contact.emails?.[0]?.email || '?')[0].toUpperCase()}
</div>
<div className="min-w-0 flex flex-col justify-center leading-tight mt-0.5">
<p className="font-medium text-sm truncate">
{contact.name || `${contact.first_name || ''} ${contact.last_name || ''}`.trim() || '—'}
</p>
{contact.organization && (
<p className="text-[11px] text-muted-foreground truncate">{contact.organization}</p>
)}
</div>
</div>
);
}
},
{
field: 'actions', headerName: translate('Actions'), width: 110, align: 'right', headerAlign: 'right', sortable: false,
renderCell: (params: any) => (
<div className="flex items-center h-full justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity w-full pr-2">
<Popover>
<PopoverTrigger asChild>
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={(e) => e.stopPropagation()} title={translate('View Dump')}>
<Code2 className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[450px] max-h-[500px] overflow-auto p-4 z-50" onClick={e => e.stopPropagation()}>
<pre className="text-[10px] sm:text-xs text-muted-foreground whitespace-pre-wrap break-all font-mono">
{JSON.stringify(params.row, null, 2)}
</pre>
</PopoverContent>
</Popover>
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={(e) => { e.stopPropagation(); setEditContact(params.row); setIsNew(false); }}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive"
onClick={(e) => { e.stopPropagation(); setDeleteId(params.row.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)
},
{
field: 'email', headerName: translate('Email'), flex: 1, minWidth: 160,
valueGetter: (value: any, row: any) => row.emails?.[0]?.email,
renderCell: (params: any) => (
<span className="text-xs text-muted-foreground truncate">
{params.value || '—'}
</span>
)
},
{
field: 'status', headerName: translate('Status'), width: 100,
renderCell: (params: any) => (
<div className="flex items-center h-full">
<StatusBadge status={params.row.status} />
</div>
)
},
{
field: 'groups', headerName: translate('Groups'), flex: 1, minWidth: 150,
valueGetter: (_value: any, row: any) => {
return (contactGroupMap.get(row.id) || []).map((g: any) => g.name).join(', ')
},
renderCell: (params: any) => {
const groups = contactGroupMap.get(params.row.id) || [];
return (
<div className="flex items-center gap-1 flex-wrap h-full py-1">
{groups.slice(0, 2).map((g: any) => (
<Badge key={g.id} variant="secondary" className="text-[10px] h-5 min-h-[20px] px-1.5 py-0 font-medium">
<span className="truncate max-w-[60px]">{g.name}</span>
</Badge>
))}
{groups.length > 2 && (
<Badge variant="secondary" className="text-[10px] h-5 min-h-[20px] px-1.5 py-0 font-medium">+{groups.length - 2}</Badge>
)}
</div>
);
}
},
{
field: 'tags', headerName: translate('Tags'), flex: 1, minWidth: 120,
renderCell: (params: any) => {
const tags = params.row.tags || [];
return (
<div className="flex items-center gap-1 flex-wrap h-full py-1">
{tags.slice(0, 2).map((t: string) => (
<Badge key={t} variant="outline" className="text-[10px] h-5 min-h-[20px] px-1.5 py-0 font-medium">
<span className="truncate max-w-[60px]">{t}</span>
</Badge>
))}
{tags.length > 2 && (
<Badge variant="outline" className="text-[10px] h-5 min-h-[20px] px-1.5 py-0 font-medium">+{tags.length - 2}</Badge>
)}
</div>
);
}
},
{
field: 'notes', headerName: translate('Notes / Source'), flex: 1.5, minWidth: 150,
renderCell: (params: any) => (
<div className="flex items-center h-full">
<span className="text-xs text-muted-foreground truncate max-w-[200px]" title={params.row.notes || ''}>
{params.row.notes?.substring(0, 80) || '—'}
</span>
</div>
)
},
{
field: 'email_date', headerName: translate('Email Date'), width: 120,
valueGetter: (_value: any, row: any) => row.meta?.email?.date,
renderCell: (params: any) => {
if (!params.value) return <span className="text-xs text-muted-foreground"></span>;
const d = new Date(params.value);
return (
<span className="text-xs text-muted-foreground" title={d.toLocaleString()}>
{d.toLocaleDateString()}
</span>
);
}
},
{
field: 'websites', headerName: translate('Websites'), flex: 1.2, minWidth: 160,
valueGetter: (_value: any, row: any) => row.meta?.websites,
renderCell: (params: any) => {
const sites: { url: string; source?: string }[] = params.value || [];
if (!sites.length) return <span className="text-xs text-muted-foreground"></span>;
return (
<div className="flex items-center gap-1 flex-wrap h-full py-1">
{sites.slice(0, 2).map((s, i) => {
let hostname = s.url;
try { hostname = new URL(s.url).hostname; } catch { }
return (
<a
key={i}
href={s.url}
target="_blank"
rel="noreferrer"
onClick={e => e.stopPropagation()}
className="flex items-center gap-0.5 text-[10px] text-primary hover:underline bg-primary/8 px-1.5 py-0.5 rounded"
title={s.url}
>
<Globe className="h-2.5 w-2.5 shrink-0" />
<span className="truncate max-w-[90px]">{hostname}</span>
</a>
);
})}
{sites.length > 2 && (
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 py-0">+{sites.length - 2}</Badge>
)}
</div>
);
}
}
], [contactGroupMap]);
// ── CRUD ──────────────────────────────────────────────────────────────────
const handleSave = async (data: Partial<Contact>, newGroupIds: string[]) => {
setSaving(true);
try {
let savedId: string | undefined;
if (isNew) {
const created = await createContact(data);
setContacts(prev => [created, ...prev]);
savedId = created.id;
toast.success(translate('Contact created'));
} else if (editContact?.id) {
const updated = await updateContact(editContact.id, data);
setContacts(prev => prev.map(x => x.id === updated.id ? updated : x));
savedId = updated.id;
toast.success(translate('Contact saved'));
}
// Sync group membership
if (savedId) {
const oldGroupIds = new Set((contactGroupMap.get(savedId) || []).map(g => g.id));
const newSet = new Set(newGroupIds);
const toAdd = newGroupIds.filter(id => !oldGroupIds.has(id));
const toRemove = [...oldGroupIds].filter(id => !newSet.has(id));
await Promise.all([
...toAdd.map(gid => addGroupMembers(gid, [savedId!])),
...toRemove.map(gid => removeGroupMember(gid, savedId!)),
]);
if (toAdd.length || toRemove.length) load(); // refresh map
}
setEditContact(null);
} catch (e: any) { toast.error(e.message); }
finally { setSaving(false); }
};
const handleDelete = async () => {
if (!deleteId) return;
try {
await deleteContact(deleteId);
setContacts(prev => prev.filter(c => c.id !== deleteId));
setRowSelectionModel((prev) => { const ids = new Set(prev.ids); ids.delete(deleteId); return { ...prev, ids }; });
toast.success(translate('Contact deleted'));
} catch (e: any) { toast.error(e.message); }
finally { setDeleteId(null); }
};
// ── Batch actions ─────────────────────────────────────────────────────────
const getTargetIds = () => selectedIds.length > 0 ? selectedIds : filtered.map(c => c.id);
const handleBatchSetGroup = async (groupId: string) => {
const ids = getTargetIds();
if (!ids.length) return;
setBatchBusy(true);
try {
await addGroupMembers(groupId, ids);
const g = groups.find(x => x.id === groupId);
toast.success(`${ids.length} ${translate('contacts added to')} "${g?.name ?? groupId}"`);
load();
} catch (e: any) { toast.error(e.message); }
finally { setBatchBusy(false); setBatchResetKey(k => k + 1); }
};
const handleBatchRemoveGroups = async () => {
const ids = getTargetIds();
if (!ids.length) return;
setBatchBusy(true);
try {
// Remove every targeted contact from every group it belongs to
const removals: Promise<any>[] = [];
for (const cid of ids) {
const cGroups = contactGroupMap.get(cid) || [];
for (const g of cGroups) {
removals.push(removeGroupMember(g.id, cid));
}
}
await Promise.all(removals);
toast.success(`${ids.length} ${translate('contacts removed from all groups')}`);
load();
} catch (e: any) { toast.error(e.message); }
finally { setBatchBusy(false); setBatchResetKey(k => k + 1); }
};
const handleBatchSetStatus = async (status: string) => {
const ids = getTargetIds();
if (!ids.length) return;
setBatchBusy(true);
try {
await Promise.all(ids.map(id => updateContact(id, { status: status as any })));
const idsSet = new Set(ids);
setContacts(prev => prev.map(c => idsSet.has(c.id) ? { ...c, status: status as any } : c));
toast.success(`${ids.length} ${translate('contacts updated')}`);
} catch (e: any) { toast.error(e.message); }
finally { setBatchBusy(false); setBatchResetKey(k => k + 1); }
};
const handleBatchDelete = async () => {
const isAll = selectedIds.length === 0;
const ids = getTargetIds();
if (!ids.length) return;
if (isAll) {
if (!window.confirm(translate(`Are you sure you want to delete ALL ${ids.length} contacts matching the current filter?`))) return;
} else {
if (!window.confirm(translate(`Delete ${ids.length} selected contacts?`))) return;
}
setBatchBusy(true);
try {
await batchDeleteContacts(ids);
const idsSet = new Set(ids);
setContacts(prev => prev.filter(c => !idsSet.has(c.id)));
setRowSelectionModel({ type: 'include', ids: new Set<GridRowId>() });
toast.success(`${ids.length} ${translate('contacts deleted')}`);
} catch (e: any) { toast.error(e.message); }
finally { setBatchBusy(false); setBatchDelete(false); }
};
// ── Import / Export ───────────────────────────────────────────────────────
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importGroupId, setImportGroupId] = useState<string>('__none');
// Default group name = e.g. "group-24-12"
const getDefaultImportGroupName = () => {
const d = new Date();
return `group-${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}`;
};
const [importNewGroupName, setImportNewGroupName] = useState<string>('');
const handleOpenImportDialog = () => {
setImportGroupId('__none');
setImportNewGroupName(getDefaultImportGroupName());
setImportDialogOpen(true);
};
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImporting(true);
setImportDialogOpen(false); // Optimistically close
try {
const text = await file.text();
const isVcard = file.name.endsWith('.vcf') || file.type === 'text/vcard';
const body = isVcard ? text : JSON.parse(text);
let targetGroupId: string | undefined = undefined;
if (importGroupId === '__new') {
const groupName = importNewGroupName.trim() || getDefaultImportGroupName();
const newGroup = await createContactGroup({ name: groupName });
setGroups(prev => [...prev, newGroup]); // Append eagerly
targetGroupId = newGroup.id;
} else if (importGroupId !== '__none') {
targetGroupId = importGroupId;
}
const result = await importContacts(body, isVcard ? 'vcard' : 'json', targetGroupId);
toast.success(translate(`Imported ${result.imported} contact(s), skipped ${result.skipped}`));
if (targetGroupId) setFilterGroup(targetGroupId);
load();
} catch (err: any) {
toast.error(`${translate('Import failed')}: ${err.message}`);
} finally {
setImporting(false);
if (importRef.current) importRef.current.value = '';
}
};
const handleExport = async (format: 'json' | 'vcard') => {
try {
const result = await exportContacts({
format,
group: filterGroup || undefined,
ids: selectedIds.length > 0 ? selectedIds : undefined
});
const content = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
const mime = format === 'vcard' ? 'text/vcard' : 'application/json';
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `contacts.${format === 'vcard' ? 'vcf' : 'json'}`; a.click();
URL.revokeObjectURL(url);
} catch (e: any) { toast.error(e.message); }
};
// ── Groups ────────────────────────────────────────────────────────────────
const handleCreateGroup = async () => {
if (!newGroupName.trim()) return;
setSavingGroup(true);
try {
const g = await createContactGroup({ name: newGroupName.trim() });
setGroups(prev => [...prev, g]);
setNewGroupName('');
setGroupDialog(false);
toast.success(translate('Group created'));
} catch (e: any) { toast.error(e.message); }
finally { setSavingGroup(false); }
};
const handleDeleteGroup = async (id: string) => {
try {
await deleteContactGroup(id);
setGroups(prev => prev.filter(g => g.id !== id));
if (filterGroup === id) setFilterGroup('');
toast.success(translate('Group deleted'));
} catch (e: any) { toast.error(e.message); }
};
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="space-y-3">
{/* ── Toolbar ── */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[180px] flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10" />
<Input className="pl-8 h-8 text-sm" placeholder={translate('Search…')} value={q} onChange={e => setQ(e.target.value)} />
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className={`absolute right-1 h-6 w-6 rounded-sm ${advancedFilters.length ? 'bg-primary/10 text-primary' : 'text-muted-foreground'}`}>
<SlidersHorizontal className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-max min-w-[320px] max-w-[95vw] p-3 shadow-xl">
<div className="space-y-3">
<div>
<h4 className="text-sm font-semibold mb-1"><T>Advanced Search</T></h4>
<p className="text-xs text-muted-foreground"><T>Requires all specific rules to match.</T></p>
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-hidden px-1 py-1 -mx-1 -my-1">
{advancedFilters.map((f, i) => (
<div key={i} className="flex gap-1 items-center animate-in fade-in slide-in-from-top-1">
<Select value={f.field} onValueChange={v => {
const neu = [...advancedFilters];
neu[i].field = v;
setAdvancedFilters(neu);
}}>
<SelectTrigger className="h-7 w-[90px] text-xs px-2 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{advFields.map(af => <SelectItem key={advFields.indexOf(af) + af.id} value={af.id}>{translate(af.label)}</SelectItem>)}
</SelectContent>
</Select>
{f.field === 'meta_key' && (
<Input className="h-7 text-xs w-[100px] shrink-0" value={(f as any).metaKey || ''} placeholder={translate('Key')}
onChange={e => {
const neu = [...advancedFilters] as any[];
neu[i].metaKey = e.target.value;
setAdvancedFilters(neu);
}} />
)}
<Select value={f.op} onValueChange={v => {
const neu = [...advancedFilters];
neu[i].op = v;
setAdvancedFilters(neu);
}}>
<SelectTrigger className="h-7 w-[110px] text-xs px-2 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{advOps.map(op => <SelectItem key={op} value={op}><T>{op}</T></SelectItem>)}
</SelectContent>
</Select>
{!['is empty', 'is not empty'].includes(f.op) && (
<Input className="h-7 text-xs flex-1 min-w-[60px]" value={f.value} placeholder={translate('value')}
onChange={e => {
const neu = [...advancedFilters];
neu[i].value = e.target.value;
setAdvancedFilters(neu);
}} />
)}
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive shrink-0"
onClick={() => setAdvancedFilters(advancedFilters.filter((_, idx) => idx !== i))}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
{advancedFilters.length === 0 && (
<div className="py-4 text-center text-xs text-muted-foreground border rounded bg-muted/30">
<T>No specific field rules yet.</T>
</div>
)}
</div>
<div className="flex items-center gap-2 pt-1 border-t">
<Button size="sm" variant="outline" className="h-7 text-xs flex-1"
onClick={() => setAdvancedFilters([...advancedFilters, { field: 'name', op: 'contains', value: '' }])}>
<Plus className="h-3 w-3 mr-1" /><T>Add Rule</T>
</Button>
{advancedFilters.length > 0 && (
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={() => setAdvancedFilters([])}>
<T>Clear</T>
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
<Select value={filterGroup || '__all'} onValueChange={v => setFilterGroup(v === '__all' ? '' : v)}>
<SelectTrigger className="h-8 w-36 text-sm">
<FolderOpen className="h-3 w-3 mr-1 text-muted-foreground" />
<SelectValue placeholder={translate('All groups')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all"><T>All groups</T></SelectItem>
{groups.map(g => <SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={filterStatus || '__all'} onValueChange={v => setFilterStatus(v === '__all' ? '' : v)}>
<SelectTrigger className="h-8 w-36 text-sm">
<ChevronDown className="h-3 w-3 mr-1 text-muted-foreground" />
<SelectValue placeholder={translate('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all"><T>All statuses</T></SelectItem>
{STATUSES.map(s => <SelectItem key={s} value={s}>{translate(s)}</SelectItem>)}
</SelectContent>
</Select>
<div className="flex items-center gap-1 ml-auto">
<Button size="sm" variant="outline" className="h-8 text-xs gap-1" onClick={handleOpenImportDialog} disabled={importing}>
<Upload className="h-3 w-3" /><T>{importing ? 'Importing…' : 'Import'}</T>
</Button>
<Button size="sm" variant="outline" className="h-8 text-xs gap-1" onClick={() => setGroupDialog(true)}>
<Users className="h-3 w-3" /><T>Groups</T>
</Button>
</div>
</div>
<ActionBar
count={selectedCount}
total={filtered.length}
groups={groups}
onSetGroup={handleBatchSetGroup}
onRemoveGroups={handleBatchRemoveGroups}
onSetStatus={handleBatchSetStatus}
onDelete={() => setBatchDelete(true)}
onClear={() => setRowSelectionModel({ type: 'include', ids: new Set<GridRowId>() })}
onExport={handleExport}
onNew={() => { setEditContact({ ...EMPTY }); setIsNew(true); }}
busy={batchBusy}
resetKey={batchResetKey}
/>
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle><T>Import Contacts</T></DialogTitle>
<DialogDescription className="text-sm text-muted-foreground pt-1.5">
<T>Select a JSON or vCard file. Optionally, drop them directly into a group.</T>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label><T>Destination Group</T></Label>
<Select value={importGroupId} onValueChange={setImportGroupId}>
<SelectTrigger>
<SelectValue placeholder={translate('None')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none"><T>{"None (Don\'t Group)"}</T></SelectItem>
<SelectItem value="__new"><T>+ Create New Group</T></SelectItem>
{groups.map(g => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{importGroupId === '__new' && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
<Label><T>New Group Name</T></Label>
<Input
value={importNewGroupName}
onChange={e => setImportNewGroupName(e.target.value)}
placeholder={translate('group-dd-mm')}
/>
</div>
)}
</div>
<DialogFooter>
<input ref={importRef} type="file" accept=".json,.vcf" className="hidden" onChange={handleImportFile} />
<Button variant="outline" onClick={() => setImportDialogOpen(false)}><T>Cancel</T></Button>
<Button onClick={() => importRef.current?.click()}>
<T>Select File & Import</T>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Contact list ── */}
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground"><T>Loading</T></div>
) : filtered.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
<T>No contacts found.</T>{' '}
<button className="underline" onClick={() => { setEditContact({ ...EMPTY }); setIsNew(true); }}>
<T>Add one</T>
</button>
</div>
) : (
<div className="h-[600px] w-full bg-background rounded-md border flex flex-col">
<MuiThemeProvider theme={muiTheme}>
<DataGrid
rows={filtered}
columns={columns}
checkboxSelection
showToolbar
disableRowSelectionOnClick
disableColumnMenu
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={(newSelection) => {
setRowSelectionModel(newSelection);
}}
filterModel={filterModel}
onFilterModelChange={(newFilterModel) => {
setFilterModel(newFilterModel);
setSearchParams(prev => {
const p = new URLSearchParams(prev);
Array.from(p.keys()).forEach(k => { if (k.startsWith('filter_')) p.delete(k); });
Object.entries(filterModelToParams(newFilterModel)).forEach(([k, v]) => p.set(k, v));
return p;
}, { replace: true });
}}
sortModel={sortModel}
onSortModelChange={(newSortModel) => {
setSortModel(newSortModel);
setSearchParams(prev => {
const p = new URLSearchParams(prev);
p.delete('sort');
const sp = sortModelToParams(newSortModel);
if (sp.sort) p.set('sort', sp.sort);
return p;
}, { replace: true });
}}
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={(newModel) => {
setColumnVisibilityModel(newModel);
setSearchParams(prev => {
const p = new URLSearchParams(prev);
p.delete('hidden');
const vp = visibilityModelToParams(newModel);
if (vp.hidden !== undefined) p.set('hidden', vp.hidden);
return p;
}, { replace: true });
}}
paginationModel={paginationModel}
onPaginationModelChange={(newPaginationModel) => {
setPaginationModel(newPaginationModel);
setSearchParams(prev => {
const p = new URLSearchParams(prev);
if (newPaginationModel.page !== 0) p.set('page', String(newPaginationModel.page));
else p.delete('page');
if (newPaginationModel.pageSize !== 50) p.set('pageSize', String(newPaginationModel.pageSize));
else p.delete('pageSize');
return p;
}, { replace: true });
}}
pageSizeOptions={[20, 50, 100]}
getRowClassName={() => 'group cursor-pointer hover:bg-muted/30 transition-colors'}
sx={{
border: 0,
bgcolor: 'transparent',
color: 'hsl(var(--foreground))',
'& .MuiDataGrid-columnHeaders': {
borderColor: 'hsl(var(--border))',
},
'& .MuiDataGrid-cell': {
borderColor: 'hsl(var(--border))',
'&:focus, &:focus-within': { outline: 'none' },
},
'& .MuiDataGrid-columnHeader': {
'&:focus, &:focus-within': { outline: 'none' },
},
'& .MuiDataGrid-row.Mui-selected': {
backgroundColor: 'hsl(var(--primary) / 0.05)',
'&:hover': {
backgroundColor: 'hsl(var(--primary) / 0.1)',
},
},
'& .MuiDataGrid-footerContainer': {
borderColor: 'hsl(var(--border))',
},
'& .MuiTablePagination-root': {
color: 'hsl(var(--muted-foreground))',
},
'& .MuiDataGrid-overlay': {
bgcolor: 'transparent',
},
'& .MuiCheckbox-root': {
color: 'hsl(var(--muted-foreground))',
'&.Mui-checked': {
color: 'hsl(var(--primary))',
},
},
'& .MuiDataGrid-columnSeparator': {
color: 'hsl(var(--border))',
},
'& .MuiDataGrid-menuIcon button, & .MuiDataGrid-iconButtonContainer button': {
color: 'hsl(var(--muted-foreground))',
},
'& .MuiDataGrid-virtualScroller': {
overflowX: 'auto',
},
}}
/>
</MuiThemeProvider>
</div>
)}
<p className="text-xs text-muted-foreground text-right">
{filtered.length} {translate(filtered.length !== 1 ? 'contacts' : 'contact')}
</p>
{/* ── Edit / Create dialog ── */}
<Dialog open={!!editContact} onOpenChange={o => !o && setEditContact(null)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle><T>{isNew ? 'New Contact' : 'Edit Contact'}</T></DialogTitle>
</DialogHeader>
{editContact && (
<ContactForm
initial={editContact}
onSave={handleSave}
onCancel={() => setEditContact(null)}
saving={saving}
groups={groups}
initialGroupIds={(contactGroupMap.get(editContact.id ?? '') || []).map(g => g.id)}
/>
)}
</DialogContent>
</Dialog>
{/* ── Single delete confirm ── */}
<AlertDialog open={!!deleteId} onOpenChange={o => !o && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle><T>Delete contact?</T></AlertDialogTitle>
<AlertDialogDescription><T>This cannot be undone.</T></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
<T>Delete</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Batch delete confirm ── */}
<AlertDialog open={batchDelete} onOpenChange={o => !o && setBatchDelete(false)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<T>Delete</T> {selectedIds.length} <T>contacts?</T>
</AlertDialogTitle>
<AlertDialogDescription><T>This cannot be undone.</T></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={batchBusy}><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction onClick={handleBatchDelete} disabled={batchBusy}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
<T>{batchBusy ? 'Deleting…' : 'Delete all'}</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Groups manager dialog ── */}
<Dialog open={groupDialog} onOpenChange={setGroupDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle><T>Contact Groups</T></DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-2">
<Input placeholder={translate('New group name…')} value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateGroup()} />
<Button size="sm" onClick={handleCreateGroup} disabled={savingGroup || !newGroupName.trim()}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="divide-y rounded-lg border">
{groups.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4"><T>No groups yet.</T></p>
)}
{groups.map(g => (
<div key={g.id} className="flex items-center justify-between px-3 py-2 text-sm group">
<span>{g.name}</span>
<Button size="icon" variant="ghost" className="h-6 w-6 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100"
onClick={() => handleDeleteGroup(g.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};