feat: Bulk Actions floating bar, suspend/tag dialogs, dedicated server actions
This commit is contained in:
146
src/features/users/components/BulkActionBar.tsx
Normal file
146
src/features/users/components/BulkActionBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/features/users/components/dialogs/BulkSuspendDialog.tsx
Normal file
164
src/features/users/components/dialogs/BulkSuspendDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/features/users/components/dialogs/BulkTagDialog.tsx
Normal file
162
src/features/users/components/dialogs/BulkTagDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
src/lib/actions/bulk-users.ts
Normal file
240
src/lib/actions/bulk-users.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,9 @@ import { SuspensionModal } from '@/features/users/components/SuspensionModal';
|
|||||||
import { BanModal } from '@/features/users/components/BanModal';
|
import { BanModal } from '@/features/users/components/BanModal';
|
||||||
import { DeleteConfirmDialog } from '@/features/users/components/DeleteConfirmDialog';
|
import { DeleteConfirmDialog } from '@/features/users/components/DeleteConfirmDialog';
|
||||||
import { NotificationComposer } from '@/features/users/components/NotificationComposer';
|
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
|
// Data & Types
|
||||||
import { mockCrmUsers, mockUserMetrics } from '@/features/users/data/mockUserCrmData';
|
import { mockCrmUsers, mockUserMetrics } from '@/features/users/data/mockUserCrmData';
|
||||||
@@ -57,6 +59,8 @@ export default function Users() {
|
|||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [notificationComposerOpen, setNotificationComposerOpen] = useState(false);
|
const [notificationComposerOpen, setNotificationComposerOpen] = useState(false);
|
||||||
const [notificationTargetUsers, setNotificationTargetUsers] = useState<User[]>([]);
|
const [notificationTargetUsers, setNotificationTargetUsers] = useState<User[]>([]);
|
||||||
|
const [bulkSuspendOpen, setBulkSuspendOpen] = useState(false);
|
||||||
|
const [bulkTagOpen, setBulkTagOpen] = useState(false);
|
||||||
|
|
||||||
// Sync URL params to filters
|
// Sync URL params to filters
|
||||||
const mergedFilters: SegmentFilters = useMemo(() => ({
|
const mergedFilters: SegmentFilters = useMemo(() => ({
|
||||||
@@ -232,26 +236,6 @@ export default function Users() {
|
|||||||
|
|
||||||
{/* Right: Actions */}
|
{/* Right: Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<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 */}
|
{/* View Toggle */}
|
||||||
<div className="flex border rounded-lg overflow-hidden bg-white/50">
|
<div className="flex border rounded-lg overflow-hidden bg-white/50">
|
||||||
@@ -430,6 +414,30 @@ export default function Users() {
|
|||||||
onOpenChange={setNotificationComposerOpen}
|
onOpenChange={setNotificationComposerOpen}
|
||||||
onSent={() => setNotificationTargetUsers([])}
|
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>
|
</AppLayout>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user