feat: add Review Management module and UI layout fixes
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user