diff --git a/src/app/(dashboard)/contacts/page.tsx b/src/app/(dashboard)/contacts/page.tsx index 2d3dde0..1b7a68b 100644 --- a/src/app/(dashboard)/contacts/page.tsx +++ b/src/app/(dashboard)/contacts/page.tsx @@ -48,6 +48,7 @@ import { ImportModal } from '@/components/contacts/import-modal'; import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager'; import { useCan } from '@/hooks/use-can'; import { GatedButton } from '@/components/ui/gated-button'; +import { Checkbox } from '@/components/ui/checkbox'; const PAGE_SIZE = 25; @@ -78,6 +79,10 @@ export default function ContactsPage() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); + // Bulk selection (page-scoped — only the loaded rows are selectable) + const [selected, setSelected] = useState>(new Set()); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + // All tags for display const [tagsMap, setTagsMap] = useState>({}); @@ -92,6 +97,10 @@ export default function ContactsPage() { const fetchContacts = useCallback(async () => { setLoading(true); + // The visible rows are about to change — drop any selection that + // referred to the old page/search results so the bulk bar can't + // act on rows the user can no longer see. + setSelected(new Set()); const from = page * PAGE_SIZE; const to = from + PAGE_SIZE - 1; @@ -208,6 +217,50 @@ export default function ContactsPage() { setDeleteTarget(null); } + const allOnPageSelected = + contacts.length > 0 && contacts.every((c) => selected.has(c.id)); + const someOnPageSelected = contacts.some((c) => selected.has(c.id)); + + function toggleSelectAll() { + setSelected((prev) => { + const next = new Set(prev); + if (allOnPageSelected) { + contacts.forEach((c) => next.delete(c.id)); + } else { + contacts.forEach((c) => next.add(c.id)); + } + return next; + }); + } + + function toggleSelect(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function handleBulkDelete() { + const ids = [...selected]; + if (ids.length === 0) return; + setDeleting(true); + + const { error } = await supabase.from('contacts').delete().in('id', ids); + + if (error) { + toast.error('Failed to delete contacts'); + } else { + toast.success(`${ids.length} contact${ids.length === 1 ? '' : 's'} deleted`); + setSelected(new Set()); + fetchContacts(); + } + + setDeleting(false); + setBulkDeleteOpen(false); + } + const totalPages = Math.ceil(totalCount / PAGE_SIZE); const hasNext = page < totalPages - 1; const hasPrev = page > 0; @@ -271,11 +324,50 @@ export default function ContactsPage() { /> + {/* Bulk action bar */} + {selected.size > 0 && ( +
+

+ {selected.size}{' '} + {selected.size === 1 ? 'contact' : 'contacts'} selected +

+
+ + setBulkDeleteOpen(true)} + > + + Delete selected + +
+
+ )} + {/* Table */}
+ + + Name Phone Email @@ -288,7 +380,7 @@ export default function ContactsPage() { {loading ? ( - +

Loading contacts...

@@ -297,7 +389,7 @@ export default function ContactsPage() { ) : contacts.length === 0 ? ( - +

@@ -324,6 +416,13 @@ export default function ContactsPage() { className="border-border hover:bg-muted/50 cursor-pointer" onClick={() => openDetail(contact.id)} > + e.stopPropagation()}> + toggleSelect(contact.id)} + aria-label={`Select ${contact.name || contact.phone}`} + /> + {contact.name || Unnamed} @@ -521,6 +620,41 @@ export default function ContactsPage() { + + {/* Bulk Delete Confirmation */} +

+ + + + Delete {selected.size} {selected.size === 1 ? 'Contact' : 'Contacts'} + + + Are you sure you want to delete{' '} + + {selected.size} {selected.size === 1 ? 'contact' : 'contacts'} + + ? This action cannot be undone. + + + + + + + +
); } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..c369fb8 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,42 @@ +"use client" + +import * as React from "react" +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" +import { Check, Minus } from "lucide-react" + +import { cn } from "@/lib/utils" + +// Root: primary token when checked or indeterminate (responds to the active +// color theme), input border when unchecked. Mirrors switch.tsx conventions. +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + {props.indeterminate ? ( + + ) : ( + + )} + + + ) +} + +export { Checkbox }