feat: Bulk Actions floating bar, suspend/tag dialogs, dedicated server actions

This commit is contained in:
CycroftX
2026-02-10 12:37:48 +05:30
parent f180b3d7d2
commit 2cfefc17dc
5 changed files with 741 additions and 21 deletions

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Tooltip, TooltipContent, TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Shield, Tag, Mail, Download, Trash2, X,
CheckCircle2, Ban, Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { bulkExportUsers, bulkBanUsers, bulkDeleteUsers, bulkVerifyUsers } from '@/lib/actions/bulk-users';
import type { User } from '@/lib/types/user';
interface BulkActionBarProps {
selectedUsers: User[];
onClearSelection: () => void;
onOpenSuspendDialog: () => void;
onOpenTagDialog: () => void;
onOpenEmailComposer: () => void;
onComplete: () => void;
}
export function BulkActionBar({
selectedUsers,
onClearSelection,
onOpenSuspendDialog,
onOpenTagDialog,
onOpenEmailComposer,
onComplete,
}: BulkActionBarProps) {
const [loadingAction, setLoadingAction] = useState<string | null>(null);
const count = selectedUsers.length;
if (count === 0) return null;
const userIds = selectedUsers.map(u => u.id);
const runAction = async (key: string, action: () => Promise<{ success: boolean; message: string }>) => {
setLoadingAction(key);
try {
const res = await action();
if (res.success) {
toast.success(res.message);
onClearSelection();
onComplete();
} else {
toast.error(res.message);
}
} catch {
toast.error('Action failed');
} finally {
setLoadingAction(null);
}
};
const handleExport = () => {
runAction('export', async () => {
const res = await bulkExportUsers(userIds);
if (res.success && res.csvData) {
// Trigger download
const blob = new Blob([res.csvData], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `users_export_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
return res;
});
};
const handleBan = () => {
if (!confirm(`Permanently ban ${count} user(s)? This is a severe action.`)) return;
runAction('ban', () => bulkBanUsers(userIds, { reason: 'Bulk ban from action bar' }));
};
const handleDelete = () => {
if (!confirm(`Permanently delete ${count} user(s)? This CANNOT be undone.`)) return;
runAction('delete', () => bulkDeleteUsers(userIds));
};
const handleVerify = () => {
runAction('verify', () => bulkVerifyUsers(userIds));
};
const actions = [
{ key: 'suspend', icon: Shield, label: 'Suspend', color: 'text-orange-500 hover:bg-orange-500/10', onClick: onOpenSuspendDialog },
{ key: 'ban', icon: Ban, label: 'Ban', color: 'text-red-500 hover:bg-red-500/10', onClick: handleBan },
{ key: 'tag', icon: Tag, label: 'Tag', color: 'text-blue-500 hover:bg-blue-500/10', onClick: onOpenTagDialog },
{ key: 'email', icon: Mail, label: 'Email', color: 'text-violet-500 hover:bg-violet-500/10', onClick: onOpenEmailComposer },
{ key: 'verify', icon: CheckCircle2, label: 'Verify', color: 'text-emerald-500 hover:bg-emerald-500/10', onClick: handleVerify },
{ key: 'export', icon: Download, label: 'Export CSV', color: 'text-sky-500 hover:bg-sky-500/10', onClick: handleExport },
{ key: 'delete', icon: Trash2, label: 'Delete', color: 'text-red-600 hover:bg-red-600/10', onClick: handleDelete, destructive: true },
];
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 fade-in-0 duration-300">
<div className="flex items-center gap-1 bg-background/95 backdrop-blur-xl border border-border/60 shadow-2xl rounded-2xl px-2 py-2">
{/* Selection Count */}
<div className="flex items-center gap-2 px-3 pr-4 border-r border-border/40">
<Badge variant="default" className="h-7 min-w-7 p-0 flex items-center justify-center rounded-full text-xs font-bold">
{count}
</Badge>
<span className="text-sm font-medium text-foreground whitespace-nowrap">
User{count > 1 ? 's' : ''} Selected
</span>
<Button variant="ghost" size="icon" className="h-6 w-6 rounded-full hover:bg-destructive/10" onClick={onClearSelection}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-0.5 px-1">
{actions.map((action, i) => (
<span key={action.key}>
{action.destructive && <div className="w-px h-6 bg-border/40 mx-1" />}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-9 w-9 rounded-xl transition-all', action.color)}
onClick={action.onClick}
disabled={loadingAction !== null}
>
{loadingAction === action.key ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<action.icon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{action.label}
</TooltipContent>
</Tooltip>
</span>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { useState } from 'react';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { bulkSuspendUsers } from '@/lib/actions/bulk-users';
import { Loader2, Shield, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import type { User } from '@/lib/types/user';
const REASONS = [
{ value: 'spam', label: 'Spam / Abuse' },
{ value: 'fraud', label: 'Suspected Fraud' },
{ value: 'tos', label: 'Terms of Service Violation' },
{ value: 'chargeback', label: 'Chargeback Abuse' },
{ value: 'other', label: 'Other (specify below)' },
];
const DURATIONS = [
{ value: '24h', label: '24 Hours' },
{ value: '7d', label: '7 Days' },
{ value: '30d', label: '30 Days' },
{ value: 'permanent', label: 'Permanent' },
];
interface BulkSuspendDialogProps {
users: User[];
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: () => void;
}
export function BulkSuspendDialog({ users, open, onOpenChange, onComplete }: BulkSuspendDialogProps) {
const [reason, setReason] = useState('');
const [duration, setDuration] = useState('7d');
const [customNote, setCustomNote] = useState('');
const [notifyUsers, setNotifyUsers] = useState(true);
const [loading, setLoading] = useState(false);
// Filter out already suspended/banned users
const actionable = users.filter(u => u.status !== 'Suspended' && u.status !== 'Banned');
const skipped = users.length - actionable.length;
const handleSubmit = async () => {
if (!reason) { toast.error('Please select a reason'); return; }
setLoading(true);
try {
const finalReason = reason === 'other' ? customNote || 'Unspecified' : REASONS.find(r => r.value === reason)?.label || reason;
const res = await bulkSuspendUsers(
actionable.map(u => u.id),
{ reason: finalReason, duration, notifyUsers }
);
if (res.success) {
toast.success('Bulk Suspension Applied', {
description: res.message + (skipped > 0 ? ` (${skipped} already suspended/banned, skipped)` : ''),
});
onOpenChange(false);
onComplete();
} else {
toast.error(res.message);
}
} catch { toast.error('An unexpected error occurred'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-orange-500" />
Bulk Suspend Users
</DialogTitle>
<DialogDescription>
Suspend {actionable.length} user{actionable.length !== 1 ? 's' : ''} from the platform.
</DialogDescription>
</DialogHeader>
{skipped > 0 && (
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
{skipped} user{skipped > 1 ? 's are' : ' is'} already suspended/banned and will be skipped.
</div>
)}
{/* Affected Users Preview */}
<div className="max-h-28 overflow-y-auto p-3 rounded-lg bg-muted/30 border">
<div className="flex flex-wrap gap-1.5">
{users.slice(0, 20).map(u => (
<Badge key={u.id} variant="outline" className="text-xs gap-1">
{u.name}
{(u.status === 'Suspended' || u.status === 'Banned') && (
<span className="text-[9px] text-muted-foreground">(skip)</span>
)}
</Badge>
))}
{users.length > 20 && <Badge variant="secondary" className="text-xs">+{users.length - 20} more</Badge>}
</div>
</div>
<div className="space-y-4 py-2">
{/* Reason */}
<div className="space-y-2">
<Label>Reason for Suspension</Label>
<Select value={reason} onValueChange={setReason}>
<SelectTrigger><SelectValue placeholder="Select reason..." /></SelectTrigger>
<SelectContent>
{REASONS.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{reason === 'other' && (
<div className="space-y-2">
<Label>Custom Reason</Label>
<Textarea value={customNote} onChange={e => setCustomNote(e.target.value)} placeholder="Describe the reason..." rows={2} />
</div>
)}
{/* Duration */}
<div className="space-y-2">
<Label>Duration</Label>
<div className="grid grid-cols-4 gap-2">
{DURATIONS.map(d => (
<Button
key={d.value}
type="button"
variant={duration === d.value ? 'default' : 'outline'}
size="sm"
onClick={() => setDuration(d.value)}
className={duration === d.value && d.value === 'permanent' ? 'bg-red-600 hover:bg-red-700' : ''}
>
{d.label}
</Button>
))}
</div>
</div>
{/* Notify */}
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={notifyUsers} onCheckedChange={(v) => setNotifyUsers(!!v)} />
<span className="text-sm">Send suspension notification email to users</span>
</label>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button
onClick={handleSubmit}
disabled={loading || actionable.length === 0}
className="bg-orange-600 hover:bg-orange-700"
>
{loading ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Suspending...</> : `Suspend ${actionable.length} User${actionable.length !== 1 ? 's' : ''}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { bulkTagUsers } from '@/lib/actions/bulk-users';
import { mockTags } from '../../data/mockUserCrmData';
import { Loader2, Tag, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { User } from '@/lib/types/user';
interface BulkTagDialogProps {
users: User[];
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: () => void;
}
export function BulkTagDialog({ users, open, onOpenChange, onComplete }: BulkTagDialogProps) {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [mode, setMode] = useState<'add' | 'remove'>('add');
const [loading, setLoading] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [customTags, setCustomTags] = useState<{ id: string; name: string; color: string }[]>([]);
const allTags = [...mockTags, ...customTags];
const toggleTag = (tagId: string) => {
setSelectedTags(prev => prev.includes(tagId) ? prev.filter(t => t !== tagId) : [...prev, tagId]);
};
const handleCreateTag = () => {
if (!newTagName.trim()) return;
const id = 'tag-custom-' + Date.now();
setCustomTags(prev => [...prev, { id, name: newTagName.trim(), color: 'bg-indigo-500/20 text-indigo-600 border-indigo-300' }]);
setSelectedTags(prev => [...prev, id]);
setNewTagName('');
};
const handleSubmit = async () => {
if (selectedTags.length === 0) { toast.error('Select at least one tag'); return; }
setLoading(true);
try {
const res = await bulkTagUsers(users.map(u => u.id), { tagIds: selectedTags, mode });
if (res.success) {
toast.success('Tags Updated', { description: res.message });
onOpenChange(false);
setSelectedTags([]);
onComplete();
} else {
toast.error(res.message);
}
} catch { toast.error('An unexpected error occurred'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Tag className="h-5 w-5 text-primary" />
Bulk Tag Users
</DialogTitle>
<DialogDescription>
{mode === 'add' ? 'Add' : 'Remove'} tags {mode === 'add' ? 'to' : 'from'} {users.length} selected user{users.length !== 1 ? 's' : ''}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Mode Toggle */}
<div className="flex rounded-lg border overflow-hidden">
<Button
type="button"
variant={mode === 'add' ? 'default' : 'ghost'}
size="sm"
className="flex-1 rounded-none"
onClick={() => setMode('add')}
>
Add to All
</Button>
<Button
type="button"
variant={mode === 'remove' ? 'destructive' : 'ghost'}
size="sm"
className="flex-1 rounded-none"
onClick={() => setMode('remove')}
>
Remove from All
</Button>
</div>
{/* Tag Selection */}
<div className="space-y-2">
<Label>Select Tags</Label>
<div className="flex flex-wrap gap-2 p-3 rounded-lg border bg-muted/20 min-h-[80px]">
{allTags.map(tag => (
<Badge
key={tag.id}
variant="outline"
className={cn(
'cursor-pointer transition-all text-xs py-1 px-2.5',
tag.color,
selectedTags.includes(tag.id) && 'ring-2 ring-primary ring-offset-1 scale-105',
)}
onClick={() => toggleTag(tag.id)}
>
{selectedTags.includes(tag.id) && '✓ '}
{tag.name}
</Badge>
))}
</div>
</div>
{/* Create New Tag */}
<div className="space-y-2">
<Label>Or Create New Tag</Label>
<div className="flex gap-2">
<Input
value={newTagName}
onChange={e => setNewTagName(e.target.value)}
placeholder="e.g. Season Pass Holder"
className="flex-1"
onKeyDown={e => e.key === 'Enter' && handleCreateTag()}
/>
<Button type="button" variant="outline" size="icon" onClick={handleCreateTag} disabled={!newTagName.trim()}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Affected Users */}
<div className="max-h-24 overflow-y-auto p-2.5 rounded-lg bg-muted/20 border">
<p className="text-[11px] text-muted-foreground uppercase tracking-wider font-medium mb-1.5">Affected Users</p>
<div className="flex flex-wrap gap-1">
{users.slice(0, 15).map(u => (
<Badge key={u.id} variant="secondary" className="text-[10px]">{u.name}</Badge>
))}
{users.length > 15 && <Badge variant="secondary" className="text-[10px]">+{users.length - 15} more</Badge>}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button
onClick={handleSubmit}
disabled={loading || selectedTags.length === 0}
variant={mode === 'remove' ? 'destructive' : 'default'}
>
{loading ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Processing...</> : `${mode === 'add' ? 'Add' : 'Remove'} ${selectedTags.length} Tag${selectedTags.length !== 1 ? 's' : ''}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,240 @@
'use server';
import { z } from 'zod';
import { logAdminAction } from '@/lib/audit-logger';
// --- Validation ---
const bulkActionSchema = z.object({
userIds: z.array(z.string()).min(1, 'At least one user must be selected'),
selectAll: z.boolean().optional(), // For "Select All across pages" future support
});
// --- Authorization ---
async function verifyAdmin() {
return { id: 'admin-1', role: 'admin' };
}
// ===== BULK SUSPEND =====
export async function bulkSuspendUsers(
userIds: string[],
payload: { reason: string; duration: string; notifyUsers: boolean }
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
// Simulate batched DB update: UPDATE users SET status='Suspended' WHERE id IN (...)
await new Promise(r => setTimeout(r, 1200));
// In production: Prisma updateMany in a transaction
// const result = await prisma.user.updateMany({
// where: { id: { in: userIds }, status: { not: 'Suspended' } },
// data: { status: 'Suspended', suspendedAt: new Date(), suspendReason: payload.reason, suspendDuration: payload.duration }
// });
await logAdminAction({
actorId: admin.id,
action: 'BULK_SUSPEND',
targetId: 'multiple',
details: {
count: userIds.length,
reason: payload.reason,
duration: payload.duration,
notifyUsers: payload.notifyUsers,
userIds,
},
});
// If notifyUsers, queue notification emails (don't await)
if (payload.notifyUsers) {
// queueBulkEmail(userIds, 'suspension_notice', { reason: payload.reason, duration: payload.duration });
console.log(`[QUEUE] Suspension notice queued for ${userIds.length} users`);
}
return {
success: true,
message: `${userIds.length} user(s) suspended (${payload.duration}). Reason: ${payload.reason}`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk suspend failed.', affectedCount: 0 };
}
}
// ===== BULK BAN =====
export async function bulkBanUsers(
userIds: string[],
payload: { reason: string }
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
await new Promise(r => setTimeout(r, 1500));
await logAdminAction({
actorId: admin.id,
action: 'BULK_BAN',
targetId: 'multiple',
details: { count: userIds.length, reason: payload.reason, userIds },
});
return {
success: true,
message: `${userIds.length} user(s) permanently banned.`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk ban failed.', affectedCount: 0 };
}
}
// ===== BULK TAG =====
export async function bulkTagUsers(
userIds: string[],
payload: { tagIds: string[]; mode: 'add' | 'remove' }
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
await new Promise(r => setTimeout(r, 800));
await logAdminAction({
actorId: admin.id,
action: payload.mode === 'add' ? 'BULK_TAG_ADD' : 'BULK_TAG_REMOVE',
targetId: 'multiple',
details: { count: userIds.length, tagIds: payload.tagIds, mode: payload.mode, userIds },
});
return {
success: true,
message: `${payload.mode === 'add' ? 'Added' : 'Removed'} ${payload.tagIds.length} tag(s) ${payload.mode === 'add' ? 'to' : 'from'} ${userIds.length} user(s).`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk tag failed.', affectedCount: 0 };
}
}
// ===== BULK EMAIL =====
export async function bulkSendEmail(
userIds: string[],
payload: { subject: string; body: string; template?: string }
): Promise<{ success: boolean; message: string; queuedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
// Don't await delivery — push to queue
await new Promise(r => setTimeout(r, 600));
await logAdminAction({
actorId: admin.id,
action: 'BULK_EMAIL',
targetId: 'multiple',
details: { count: userIds.length, subject: payload.subject, userIds },
});
return {
success: true,
message: `${userIds.length} email(s) queued for delivery.`,
queuedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk email failed.', queuedCount: 0 };
}
}
// ===== BULK EXPORT =====
export async function bulkExportUsers(
userIds: string[],
selectAll: boolean = false
): Promise<{ success: boolean; message: string; csvData: string }> {
try {
const admin = await verifyAdmin();
await new Promise(r => setTimeout(r, 1000));
// In production: query DB with userIds or all if selectAll
const header = 'id,name,email,phone,status,role,totalSpent,bookingsCount,joinedAt\n';
const rows = userIds.map(id => `${id},Mock User,user@email.com,+91XXXXXXXX,Active,User,0,0,2025-01-01`).join('\n');
await logAdminAction({
actorId: admin.id,
action: 'BULK_EXPORT',
targetId: 'multiple',
details: { count: selectAll ? 'ALL' : userIds.length, selectAll },
});
return {
success: true,
message: `Exported ${selectAll ? 'all' : userIds.length} user(s) to CSV.`,
csvData: header + rows,
};
} catch (error: any) {
return { success: false, message: error.message || 'Export failed.', csvData: '' };
}
}
// ===== BULK DELETE =====
export async function bulkDeleteUsers(
userIds: string[]
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
await new Promise(r => setTimeout(r, 2000));
await logAdminAction({
actorId: admin.id,
action: 'BULK_DELETE',
targetId: 'multiple',
details: { count: userIds.length, userIds },
});
return {
success: true,
message: `${userIds.length} user(s) permanently deleted.`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk delete failed.', affectedCount: 0 };
}
}
// ===== BULK VERIFY =====
export async function bulkVerifyUsers(
userIds: string[]
): Promise<{ success: boolean; message: string; affectedCount: number }> {
try {
const admin = await verifyAdmin();
bulkActionSchema.parse({ userIds });
await new Promise(r => setTimeout(r, 800));
await logAdminAction({
actorId: admin.id,
action: 'BULK_VERIFY',
targetId: 'multiple',
details: { count: userIds.length, userIds },
});
return {
success: true,
message: `${userIds.length} user(s) marked as verified.`,
affectedCount: userIds.length,
};
} catch (error: any) {
return { success: false, message: error.message || 'Bulk verify failed.', affectedCount: 0 };
}
}

View File

@@ -26,7 +26,9 @@ import { SuspensionModal } from '@/features/users/components/SuspensionModal';
import { BanModal } from '@/features/users/components/BanModal';
import { DeleteConfirmDialog } from '@/features/users/components/DeleteConfirmDialog';
import { NotificationComposer } from '@/features/users/components/NotificationComposer';
import { BulkActionsDropdown } from '@/features/users/components/BulkActionsDropdown';
import { BulkActionBar } from '@/features/users/components/BulkActionBar';
import { BulkSuspendDialog } from '@/features/users/components/dialogs/BulkSuspendDialog';
import { BulkTagDialog } from '@/features/users/components/dialogs/BulkTagDialog';
// Data & Types
import { mockCrmUsers, mockUserMetrics } from '@/features/users/data/mockUserCrmData';
@@ -57,6 +59,8 @@ export default function Users() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [notificationComposerOpen, setNotificationComposerOpen] = useState(false);
const [notificationTargetUsers, setNotificationTargetUsers] = useState<User[]>([]);
const [bulkSuspendOpen, setBulkSuspendOpen] = useState(false);
const [bulkTagOpen, setBulkTagOpen] = useState(false);
// Sync URL params to filters
const mergedFilters: SegmentFilters = useMemo(() => ({
@@ -232,26 +236,6 @@ export default function Users() {
{/* Right: Actions */}
<div className="flex items-center gap-2">
{/* Bulk Actions (only when selected) */}
<BulkActionsDropdown
selectedUsers={selectedUsers}
onClearSelection={() => setSelectedUserIds([])}
onSendNotification={handleBulkNotification}
onSuspendUsers={(users) => {
if (users.length === 1) {
handleSuspendUser(users[0]);
} else {
toast.info(`Suspend ${users.length} users`);
}
}}
onBanUsers={(users) => {
if (users.length === 1) {
handleBanUser(users[0]);
} else {
toast.info(`Ban ${users.length} users`);
}
}}
/>
{/* View Toggle */}
<div className="flex border rounded-lg overflow-hidden bg-white/50">
@@ -430,6 +414,30 @@ export default function Users() {
onOpenChange={setNotificationComposerOpen}
onSent={() => setNotificationTargetUsers([])}
/>
<BulkSuspendDialog
users={selectedUsers}
open={bulkSuspendOpen}
onOpenChange={setBulkSuspendOpen}
onComplete={() => { setSelectedUserIds([]); handleRefresh(); }}
/>
<BulkTagDialog
users={selectedUsers}
open={bulkTagOpen}
onOpenChange={setBulkTagOpen}
onComplete={() => { setSelectedUserIds([]); handleRefresh(); }}
/>
{/* Floating Bulk Action Bar */}
<BulkActionBar
selectedUsers={selectedUsers}
onClearSelection={() => setSelectedUserIds([])}
onOpenSuspendDialog={() => setBulkSuspendOpen(true)}
onOpenTagDialog={() => setBulkTagOpen(true)}
onOpenEmailComposer={() => handleBulkNotification(selectedUsers)}
onComplete={handleRefresh}
/>
</AppLayout>
</TooltipProvider>
);