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

210 lines
9.0 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter
} from '@/components/ui/dialog';
import { Search, Users, FolderOpen, Check, Loader2 } from 'lucide-react';
import { T, translate } from '@/i18n';
import {
ContactGroup,
fetchContactGroups, fetchGroupMembers,
} from '@/modules/contacts/client-contacts';
// ─── Props ────────────────────────────────────────────────────────────────────
export interface ContactsPickerProps {
/** Currently selected group IDs (controlled). */
value?: string[];
/** Called whenever the selection changes. */
onChange?: (groupIds: string[]) => void;
/** If provided, wraps the picker in a Dialog triggered by `trigger`. */
trigger?: React.ReactNode;
/** Called when the dialog confirms selection. Only used in dialog mode. */
onConfirm?: (groupIds: string[]) => void;
/** Disable all interactions. */
disabled?: boolean;
/** Placeholder shown when nothing is selected. */
placeholder?: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export const ContactsPicker = ({
value,
onChange,
trigger,
onConfirm,
disabled,
placeholder,
}: ContactsPickerProps) => {
const [groups, setGroups] = useState<ContactGroup[]>([]);
const [memberCounts, setMemberCounts] = useState<Map<string, number>>(new Map());
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
// Dialog mode state
const [open, setOpen] = useState(false);
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set(value ?? []));
const selectedIds = new Set(value ?? []);
const isDialog = !!trigger;
const load = useCallback(async () => {
setLoading(true);
try {
const [g, members] = await Promise.all([
fetchContactGroups(),
fetchGroupMembers(),
]);
setGroups(g);
// Count contacts per group
const counts = new Map<string, number>();
for (const m of members) {
counts.set(m.group_id, (counts.get(m.group_id) || 0) + 1);
}
setMemberCounts(counts);
} catch (e: any) {
toast.error(e.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
// Sync pendingIds when value changes externally (dialog mode)
useEffect(() => { setPendingIds(new Set(value ?? [])); }, [value]);
const toggle = (id: string) => {
if (disabled) return;
if (isDialog) {
setPendingIds(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
} else {
const next = new Set(selectedIds);
next.has(id) ? next.delete(id) : next.add(id);
onChange?.(Array.from(next));
}
};
const handleConfirm = () => {
const ids = Array.from(pendingIds);
onChange?.(ids);
onConfirm?.(ids);
setOpen(false);
};
const filtered = groups.filter(g => {
if (!search) return true;
return g.name.toLowerCase().includes(search.toLowerCase());
});
const activeSet = isDialog ? pendingIds : selectedIds;
// ─── Render list ──────────────────────────────────────────────────────────
const listContent = (
<div className="space-y-2">
{groups.length > 5 && (
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-8 h-8 text-sm"
placeholder={translate('Search groups…')}
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-4">
<T>{groups.length === 0 ? 'No groups yet.' : 'No matching groups.'}</T>
</p>
) : (
<div className="divide-y rounded-lg border max-h-[300px] overflow-y-auto">
{filtered.map(g => {
const checked = activeSet.has(g.id);
const count = memberCounts.get(g.id) || 0;
return (
<div
key={g.id}
role="button"
tabIndex={0}
onClick={() => !disabled && toggle(g.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(g.id); } }}
className={`flex items-center w-full gap-3 px-3 py-2.5 text-sm text-left transition-colors
${checked ? 'bg-primary/5' : 'hover:bg-muted/50'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<Checkbox checked={checked} tabIndex={-1} className="pointer-events-none" />
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="flex-1 truncate">{g.name}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 tabular-nums">
{count}
</Badge>
</div>
);
})}
</div>
)}
{activeSet.size > 0 && (
<p className="text-xs text-muted-foreground text-right">
{activeSet.size} {translate(activeSet.size !== 1 ? 'groups selected' : 'group selected')}
</p>
)}
</div>
);
// ─── Dialog mode ──────────────────────────────────────────────────────────
if (isDialog) {
return (
<>
<span onClick={() => !disabled && setOpen(true)}>{trigger}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-4 w-4" />
<T>Select Groups</T>
</DialogTitle>
</DialogHeader>
{listContent}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
<T>Cancel</T>
</Button>
<Button onClick={handleConfirm}>
<Check className="h-3 w-3 mr-1" />
<T>Confirm</T>
{pendingIds.size > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">
{pendingIds.size}
</Badge>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// ─── Inline mode ──────────────────────────────────────────────────────────
return listContent;
};