mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #255 from ArnasDon/feat/bulk-delete-contacts
feat(contacts): allow bulk-deleting selected contacts (#239)
This commit is contained in:
commit
b56e6b6e3a
@ -48,6 +48,7 @@ import { ImportModal } from '@/components/contacts/import-modal';
|
|||||||
import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
|
import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
|
||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
import { GatedButton } from '@/components/ui/gated-button';
|
import { GatedButton } from '@/components/ui/gated-button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@ -78,6 +79,10 @@ export default function ContactsPage() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Bulk selection (page-scoped — only the loaded rows are selectable)
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
|
|
||||||
// All tags for display
|
// All tags for display
|
||||||
const [tagsMap, setTagsMap] = useState<Record<string, Tag>>({});
|
const [tagsMap, setTagsMap] = useState<Record<string, Tag>>({});
|
||||||
|
|
||||||
@ -92,6 +97,10 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
const fetchContacts = useCallback(async () => {
|
const fetchContacts = useCallback(async () => {
|
||||||
setLoading(true);
|
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 from = page * PAGE_SIZE;
|
||||||
const to = from + PAGE_SIZE - 1;
|
const to = from + PAGE_SIZE - 1;
|
||||||
@ -208,6 +217,50 @@ export default function ContactsPage() {
|
|||||||
setDeleteTarget(null);
|
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 totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||||
const hasNext = page < totalPages - 1;
|
const hasNext = page < totalPages - 1;
|
||||||
const hasPrev = page > 0;
|
const hasPrev = page > 0;
|
||||||
@ -271,11 +324,50 @@ export default function ContactsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk action bar */}
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/40 px-4 py-2">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
<span className="font-medium">{selected.size}</span>{' '}
|
||||||
|
{selected.size === 1 ? 'contact' : 'contacts'} selected
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelected(new Set())}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<GatedButton
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
canAct={canEdit}
|
||||||
|
gateReason="delete contacts"
|
||||||
|
onClick={() => setBulkDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Delete selected
|
||||||
|
</GatedButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="rounded-lg border border-border overflow-hidden">
|
<div className="rounded-lg border border-border overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-border hover:bg-transparent">
|
<TableRow className="border-border hover:bg-transparent">
|
||||||
|
<TableHead className="w-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
indeterminate={!allOnPageSelected && someOnPageSelected}
|
||||||
|
onCheckedChange={toggleSelectAll}
|
||||||
|
disabled={contacts.length === 0}
|
||||||
|
aria-label="Select all contacts on this page"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Name</TableHead>
|
<TableHead className="text-muted-foreground">Name</TableHead>
|
||||||
<TableHead className="text-muted-foreground">Phone</TableHead>
|
<TableHead className="text-muted-foreground">Phone</TableHead>
|
||||||
<TableHead className="text-muted-foreground hidden md:table-cell">Email</TableHead>
|
<TableHead className="text-muted-foreground hidden md:table-cell">Email</TableHead>
|
||||||
@ -288,7 +380,7 @@ export default function ContactsPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow className="border-border">
|
<TableRow className="border-border">
|
||||||
<TableCell colSpan={7} className="text-center py-12">
|
<TableCell colSpan={8} className="text-center py-12">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Loader2 className="size-6 animate-spin text-primary" />
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
<p className="text-sm text-muted-foreground">Loading contacts...</p>
|
<p className="text-sm text-muted-foreground">Loading contacts...</p>
|
||||||
@ -297,7 +389,7 @@ export default function ContactsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : contacts.length === 0 ? (
|
) : contacts.length === 0 ? (
|
||||||
<TableRow className="border-border">
|
<TableRow className="border-border">
|
||||||
<TableCell colSpan={7} className="text-center py-12">
|
<TableCell colSpan={8} className="text-center py-12">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Users className="size-8 text-muted-foreground" />
|
<Users className="size-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@ -324,6 +416,13 @@ export default function ContactsPage() {
|
|||||||
className="border-border hover:bg-muted/50 cursor-pointer"
|
className="border-border hover:bg-muted/50 cursor-pointer"
|
||||||
onClick={() => openDetail(contact.id)}
|
onClick={() => openDetail(contact.id)}
|
||||||
>
|
>
|
||||||
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.has(contact.id)}
|
||||||
|
onCheckedChange={() => toggleSelect(contact.id)}
|
||||||
|
aria-label={`Select ${contact.name || contact.phone}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-foreground font-medium">
|
<TableCell className="text-foreground font-medium">
|
||||||
{contact.name || <span className="text-muted-foreground italic">Unnamed</span>}
|
{contact.name || <span className="text-muted-foreground italic">Unnamed</span>}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -521,6 +620,41 @@ export default function ContactsPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Bulk Delete Confirmation */}
|
||||||
|
<Dialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||||||
|
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-popover-foreground">
|
||||||
|
Delete {selected.size} {selected.size === 1 ? 'Contact' : 'Contacts'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
Are you sure you want to delete{' '}
|
||||||
|
<span className="text-popover-foreground font-medium">
|
||||||
|
{selected.size} {selected.size === 1 ? 'contact' : 'contacts'}
|
||||||
|
</span>
|
||||||
|
? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="bg-popover border-border">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBulkDeleteOpen(false)}
|
||||||
|
className="border-border text-muted-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/components/ui/checkbox.tsx
Normal file
42
src/components/ui/checkbox.tsx
Normal file
@ -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<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer size-4 shrink-0 cursor-pointer rounded-[4px] border border-input bg-card shadow-sm transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"data-[checked]:border-primary data-[checked]:bg-primary data-[checked]:text-primary-foreground",
|
||||||
|
"data-[indeterminate]:border-primary data-[indeterminate]:bg-primary data-[indeterminate]:text-primary-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current"
|
||||||
|
>
|
||||||
|
{props.indeterminate ? (
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
Loading…
Reference in New Issue
Block a user