feat(users): implement server actions and audit logging

This commit is contained in:
CycroftX
2026-02-09 21:25:42 +05:30
parent 7ff360c2b5
commit 7a8c441b34
19 changed files with 5188 additions and 71 deletions

35
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -2820,6 +2821,39 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -6844,6 +6878,7 @@
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@remix-run/router": "1.23.0"
},

View File

@@ -42,6 +42,7 @@
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View File

@@ -0,0 +1,213 @@
'use client';
import { useTransition } from 'react';
import {
KeyRound,
Eye,
Ticket,
Ban,
Mail,
ShieldAlert,
LogOut,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from 'sonner';
import {
resetPassword,
impersonateUser,
terminateSessions,
createSupportTicket,
} from '@/lib/actions/user-management';
interface ActionButtonsProps {
userId: string;
userName: string;
onSuspend?: () => void; // Provided by parent to open modal
onSendNotification?: () => void; // Provided by parent
}
export function ActionButtons({
userId,
userName,
onSuspend,
onSendNotification,
}: ActionButtonsProps) {
const [isPending, startTransition] = useTransition();
const handleResetPassword = () => {
startTransition(async () => {
const result = await resetPassword(userId);
if (result.success) {
toast.success('Password Reset Initiated', {
description: result.message,
});
} else {
toast.error('Action Failed', {
description: result.message,
});
}
});
};
const handleImpersonate = () => {
startTransition(async () => {
const result = await impersonateUser(userId);
if (result.success) {
toast.success(`Impersonating ${userName}`, {
description: 'Redirecting to user dashboard...',
});
// Simulate redirect delay
setTimeout(() => {
if (result.redirectUrl) window.location.href = result.redirectUrl;
}, 1000);
} else {
toast.error('Impersonation Failed', {
description: result.message,
});
}
});
};
const handleTerminateSessions = () => {
startTransition(async () => {
const result = await terminateSessions(userId);
if (result.success) {
toast.success('Sessions Terminated', {
description: 'User has been logged out from all devices.',
});
} else {
toast.error('Action Failed', {
description: result.message,
});
}
});
};
const handleCreateTicket = () => {
// Quick action: create a generic ticket for now
// In reality, this might open a dialog
startTransition(async () => {
const result = await createSupportTicket(userId, "User requires manual review");
if (result.success) {
toast.success('Support Ticket Created', {
description: result.message,
});
} else {
toast.error('Failed to create ticket', {
description: result.message,
});
}
});
};
return (
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleResetPassword}
disabled={isPending}
>
<KeyRound className="h-4 w-4 text-muted-foreground hover:text-primary transition-colors" />
</Button>
</TooltipTrigger>
<TooltipContent>Reset Password</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleImpersonate}
disabled={isPending}
>
<Eye className="h-4 w-4 text-muted-foreground hover:text-primary transition-colors" />
</Button>
</TooltipTrigger>
<TooltipContent>Impersonate User</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={onSendNotification}
disabled={isPending}
>
<Mail className="h-4 w-4 text-muted-foreground hover:text-primary transition-colors" />
</Button>
</TooltipTrigger>
<TooltipContent>Email / Notify User</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleCreateTicket}
disabled={isPending}
>
<Ticket className="h-4 w-4 text-muted-foreground hover:text-primary transition-colors" />
</Button>
</TooltipTrigger>
<TooltipContent>Create Support Ticket</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="w-px h-4 bg-border/50 mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
disabled={isPending}
>
<ShieldAlert className="h-4 w-4 text-muted-foreground hover:text-orange-600 transition-colors" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleTerminateSessions} className="text-orange-600 focus:text-orange-600 focus:bg-orange-50">
<LogOut className="mr-2 h-4 w-4" />
Terminate Sessions
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSuspend} className="text-red-600 focus:text-red-600 focus:bg-red-50">
<Ban className="mr-2 h-4 w-4" />
Suspend / Ban User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,218 @@
// BanModal - Ban workflow with ban types and evidence
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Ban, Loader2, AlertOctagon, Shield, AtSign, Smartphone } from 'lucide-react';
import { banUserSchema, type BanUserInput } from '@/lib/validations/user';
import type { User, BanType } from '@/lib/types/user';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface BanModalProps {
user: User | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onBanned?: () => void;
}
const banTypeConfig: Record<BanType, { label: string; description: string; icon: React.ElementType }> = {
'ip_ban': { label: 'IP Ban', description: 'Block logins from current IP address', icon: Shield },
'device_ban': { label: 'Device Ban', description: 'Block logins from current device', icon: Smartphone },
'email_blacklist': { label: 'Email Blacklist', description: 'Block signups from this email domain', icon: AtSign },
};
export function BanModal({ user, open, onOpenChange, onBanned }: BanModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<BanUserInput>({
resolver: zodResolver(banUserSchema),
defaultValues: {
banTypes: ['email_blacklist'],
publicReason: '',
internalEvidence: '',
},
});
const watchBanTypes = form.watch('banTypes');
const handleBanTypeToggle = (type: BanType) => {
const current = form.getValues('banTypes');
const updated = current.includes(type)
? current.filter((t) => t !== type)
: [...current, type];
form.setValue('banTypes', updated as BanType[], { shouldValidate: true });
};
const onSubmit = async (data: BanUserInput) => {
if (!user) return;
setIsSubmitting(true);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success(`User "${user.name}" has been permanently banned`, {
description: `Ban types: ${data.banTypes.join(', ')}`,
});
onOpenChange(false);
onBanned?.();
form.reset();
} catch (error) {
toast.error('Failed to ban user');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
onOpenChange(false);
form.reset();
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<Ban className="h-5 w-5" />
Ban User
</DialogTitle>
<DialogDescription>
Permanently ban <span className="font-semibold">{user.name}</span> from the platform. This action is severe and should be used for fraud or serious violations.
</DialogDescription>
</DialogHeader>
<div className="p-4 rounded-lg bg-red-50 border border-red-200 flex items-start gap-3">
<AlertOctagon className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
<div className="text-sm text-red-800">
<p className="font-semibold">This action cannot be easily undone.</p>
<p className="text-red-700">The user will be permanently blocked from the platform.</p>
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="banTypes"
render={() => (
<FormItem>
<FormLabel>Ban Type(s) *</FormLabel>
<FormDescription>Select one or more ban types to apply.</FormDescription>
<div className="space-y-3 mt-2">
{(Object.keys(banTypeConfig) as BanType[]).map((type) => {
const config = banTypeConfig[type];
const isSelected = watchBanTypes.includes(type);
return (
<div
key={type}
onClick={() => handleBanTypeToggle(type)}
className={cn(
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-all',
isSelected
? 'border-red-300 bg-red-50'
: 'border-border hover:border-red-200 hover:bg-red-50/50'
)}
>
<Checkbox checked={isSelected} className="mt-0.5" />
<div className="flex-1">
<div className="flex items-center gap-2">
<config.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{config.label}</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">{config.description}</p>
</div>
</div>
);
})}
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="publicReason"
render={({ field }) => (
<FormItem>
<FormLabel>Public Reason *</FormLabel>
<FormControl>
<Input
placeholder="e.g., Violation of Terms of Service"
{...field}
/>
</FormControl>
<FormDescription>This will be shown to the user when they try to login.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="internalEvidence"
render={({ field }) => (
<FormItem>
<FormLabel>Internal Evidence</FormLabel>
<FormControl>
<Textarea
placeholder="Add screenshots links, logs, or other evidence (internal only)..."
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>Document evidence for internal records.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Banning...
</>
) : (
<>
<Ban className="h-4 w-4 mr-2" /> Ban User
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,271 @@
// BulkActionsDropdown - Bulk operations menu for selected users
import { useState } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
ChevronDown,
Bell,
Tag,
Shield,
Ban,
Trash2,
Download,
CheckCircle2,
XCircle,
Users,
Loader2,
} from 'lucide-react';
import { mockTags } from '../data/mockUserCrmData';
import type { User } from '@/lib/types/user';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface BulkActionsDropdownProps {
selectedUsers: User[];
onClearSelection: () => void;
onSendNotification: (users: User[]) => void;
onSuspendUsers: (users: User[]) => void;
onBanUsers: (users: User[]) => void;
}
export function BulkActionsDropdown({
selectedUsers,
onClearSelection,
onSendNotification,
onSuspendUsers,
onBanUsers,
}: BulkActionsDropdownProps) {
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [actionType, setActionType] = useState<'suspend' | 'ban' | 'delete' | 'export' | 'verify'>('suspend');
const [isProcessing, setIsProcessing] = useState(false);
const count = selectedUsers.length;
const handleAction = (action: 'suspend' | 'ban' | 'delete' | 'export' | 'verify') => {
setActionType(action);
setConfirmDialogOpen(true);
};
const handleConfirm = async () => {
setIsProcessing(true);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
switch (actionType) {
case 'suspend':
toast.success(`Suspended ${count} user${count > 1 ? 's' : ''}`);
break;
case 'ban':
toast.success(`Banned ${count} user${count > 1 ? 's' : ''}`);
break;
case 'delete':
toast.success(`Deleted ${count} user${count > 1 ? 's' : ''}`);
break;
case 'export':
toast.success(`Exported ${count} user${count > 1 ? 's' : ''} to CSV`);
break;
case 'verify':
toast.success(`Verified ${count} user${count > 1 ? 's' : ''}`);
break;
}
setConfirmDialogOpen(false);
onClearSelection();
} catch (error) {
toast.error('Operation failed');
} finally {
setIsProcessing(false);
}
};
const handleTagUsers = async (tagId: string) => {
const tag = mockTags.find((t) => t.id === tagId);
if (!tag) return;
toast.success(`Added tag "${tag.name}" to ${count} user${count > 1 ? 's' : ''}`);
onClearSelection();
};
const actionConfig = {
suspend: {
title: 'Suspend Users',
description: `Are you sure you want to suspend ${count} user${count > 1 ? 's' : ''}? They will be unable to login.`,
icon: Shield,
buttonText: 'Suspend All',
buttonClass: 'bg-orange-600 hover:bg-orange-700',
},
ban: {
title: 'Ban Users',
description: `Are you sure you want to permanently ban ${count} user${count > 1 ? 's' : ''}? This action is severe.`,
icon: Ban,
buttonText: 'Ban All',
buttonClass: 'bg-red-600 hover:bg-red-700',
},
delete: {
title: 'Delete Users',
description: `Are you sure you want to permanently delete ${count} user${count > 1 ? 's' : ''}? This cannot be undone.`,
icon: Trash2,
buttonText: 'Delete All',
buttonClass: 'bg-red-600 hover:bg-red-700',
},
export: {
title: 'Export Users',
description: `Export ${count} user${count > 1 ? 's' : ''} to CSV file?`,
icon: Download,
buttonText: 'Export',
buttonClass: 'bg-primary hover:bg-primary/90',
},
verify: {
title: 'Verify Users',
description: `Mark ${count} user${count > 1 ? 's' : ''} as verified?`,
icon: CheckCircle2,
buttonText: 'Verify All',
buttonClass: 'bg-emerald-600 hover:bg-emerald-700',
},
};
const config = actionConfig[actionType];
if (count === 0) {
return (
<Button variant="outline" disabled className="gap-2">
<Users className="h-4 w-4" />
Bulk Actions
</Button>
);
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="gap-2">
<Users className="h-4 w-4" />
Bulk Actions
<Badge variant="secondary" className="ml-1 h-5 min-w-5 p-0 flex items-center justify-center bg-white/20 text-white rounded-full text-xs">
{count}
</Badge>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => onSendNotification(selectedUsers)}>
<Bell className="mr-2 h-4 w-4" /> Send Notification
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Tag className="mr-2 h-4 w-4" /> Add Tag
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48">
{mockTags.map((tag) => (
<DropdownMenuItem key={tag.id} onClick={() => handleTagUsers(tag.id)}>
<Badge variant="outline" className={cn('mr-2', tag.color)}>
{tag.name}
</Badge>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onClick={() => handleAction('verify')}>
<CheckCircle2 className="mr-2 h-4 w-4 text-emerald-600" /> Verify Users
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('export')}>
<Download className="mr-2 h-4 w-4" /> Export to CSV
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleAction('suspend')} className="text-orange-600">
<Shield className="mr-2 h-4 w-4" /> Suspend Users
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('ban')} className="text-red-600">
<Ban className="mr-2 h-4 w-4" /> Ban Users
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleAction('delete')} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" /> Delete Users
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onClearSelection}>
<XCircle className="mr-2 h-4 w-4" /> Clear Selection
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Confirmation Dialog */}
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<config.icon className="h-5 w-5" />
{config.title}
</DialogTitle>
<DialogDescription>{config.description}</DialogDescription>
</DialogHeader>
<div className="max-h-40 overflow-y-auto p-3 rounded-lg bg-secondary/20">
<div className="flex flex-wrap gap-2">
{selectedUsers.slice(0, 20).map((user) => (
<Badge key={user.id} variant="outline" className="text-xs">
{user.name}
</Badge>
))}
{selectedUsers.length > 20 && (
<Badge variant="secondary" className="text-xs">
+{selectedUsers.length - 20} more
</Badge>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDialogOpen(false)} disabled={isProcessing}>
Cancel
</Button>
<Button
variant="destructive"
className={config.buttonClass}
onClick={handleConfirm}
disabled={isProcessing}
>
{isProcessing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Processing...
</>
) : (
config.buttonText
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,477 @@
// CreateUserDialog - Multi-step form for creating new users
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '@/components/ui/progress';
import {
User,
Mail,
Phone,
KeyRound,
Globe,
Tag,
ChevronLeft,
ChevronRight,
Loader2,
} from 'lucide-react';
import { createUserSchema, type CreateUserInput } from '@/lib/validations/user';
import { mockTags } from '../data/mockUserCrmData';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface CreateUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUserCreated?: () => void;
}
const steps = [
{ id: 1, title: 'Basic Info', icon: User },
{ id: 2, title: 'Security', icon: KeyRound },
{ id: 3, title: 'Preferences', icon: Globe },
{ id: 4, title: 'Tags', icon: Tag },
];
const countryCodes = [
{ code: '+91', country: 'India' },
{ code: '+1', country: 'USA' },
{ code: '+44', country: 'UK' },
{ code: '+971', country: 'UAE' },
{ code: '+65', country: 'Singapore' },
];
const languages = [
{ code: 'en', name: 'English' },
{ code: 'hi', name: 'Hindi' },
{ code: 'ta', name: 'Tamil' },
{ code: 'te', name: 'Telugu' },
{ code: 'mr', name: 'Marathi' },
];
const timezones = [
{ code: 'Asia/Kolkata', name: 'India (IST)' },
{ code: 'America/New_York', name: 'Eastern Time (ET)' },
{ code: 'Europe/London', name: 'London (GMT)' },
{ code: 'Asia/Dubai', name: 'Dubai (GST)' },
{ code: 'Asia/Singapore', name: 'Singapore (SGT)' },
];
const currencies = [
{ code: 'INR', symbol: '₹', name: 'Indian Rupee' },
{ code: 'USD', symbol: '$', name: 'US Dollar' },
{ code: 'GBP', symbol: '£', name: 'British Pound' },
{ code: 'AED', symbol: 'د.إ', name: 'UAE Dirham' },
{ code: 'SGD', symbol: 'S$', name: 'Singapore Dollar' },
];
export function CreateUserDialog({ open, onOpenChange, onUserCreated }: CreateUserDialogProps) {
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const form = useForm<CreateUserInput>({
resolver: zodResolver(createUserSchema),
defaultValues: {
name: '',
email: '',
phone: '',
countryCode: '+91',
password: '',
role: 'User',
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
tagIds: [],
},
});
const handleNext = async () => {
let isValid = true;
if (currentStep === 1) {
isValid = await form.trigger(['name', 'email', 'phone', 'countryCode']);
} else if (currentStep === 2) {
isValid = await form.trigger(['password', 'role']);
} else if (currentStep === 3) {
isValid = await form.trigger(['language', 'timezone', 'currency']);
}
if (isValid && currentStep < 4) {
setCurrentStep(currentStep + 1);
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleTagToggle = (tagId: string) => {
const updated = selectedTags.includes(tagId)
? selectedTags.filter((t) => t !== tagId)
: [...selectedTags, tagId];
setSelectedTags(updated);
form.setValue('tagIds', updated);
};
const onSubmit = async (data: CreateUserInput) => {
setIsSubmitting(true);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
toast.success(`User "${data.name}" created successfully!`);
onOpenChange(false);
onUserCreated?.();
form.reset();
setCurrentStep(1);
setSelectedTags([]);
} catch (error) {
toast.error('Failed to create user');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
onOpenChange(false);
form.reset();
setCurrentStep(1);
setSelectedTags([]);
}
};
const progress = (currentStep / 4) * 100;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
Create New User
</DialogTitle>
<DialogDescription>
Add a new user to the platform. Step {currentStep} of 4.
</DialogDescription>
</DialogHeader>
{/* Progress Bar */}
<div className="space-y-2">
<Progress value={progress} className="h-2" />
<div className="flex justify-between">
{steps.map((step) => (
<div
key={step.id}
className={cn(
'flex items-center gap-1 text-xs',
currentStep >= step.id ? 'text-primary' : 'text-muted-foreground'
)}
>
<step.icon className="h-3 w-3" />
<span className="hidden sm:inline">{step.title}</span>
</div>
))}
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Step 1: Basic Info */}
{currentStep === 1 && (
<div className="space-y-4 animate-in fade-in-50 duration-300">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="john@example.com" className="pl-9" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-3 gap-3">
<FormField
control={form.control}
name="countryCode"
render={({ field }) => (
<FormItem>
<FormLabel>Code</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{countryCodes.map((cc) => (
<SelectItem key={cc.code} value={cc.code}>
{cc.code} {cc.country}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Phone Number</FormLabel>
<FormControl>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="9876543210" className="pl-9" {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
)}
{/* Step 2: Security */}
{currentStep === 2 && (
<div className="space-y-4 animate-in fade-in-50 duration-300">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<div className="relative">
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input type="password" placeholder="Leave blank for magic link" className="pl-9" {...field} />
</div>
</FormControl>
<FormDescription>
If left blank, user will receive a magic link to set password.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="User">User</SelectItem>
<SelectItem value="Partner">Partner</SelectItem>
<SelectItem value="Support Agent">Support Agent</SelectItem>
<SelectItem value="Admin">Admin</SelectItem>
<SelectItem value="Super Admin">Super Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* Step 3: Preferences */}
{currentStep === 3 && (
<div className="space-y-4 animate-in fade-in-50 duration-300">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Timezone</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{timezones.map((tz) => (
<SelectItem key={tz.code} value={tz.code}>
{tz.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Currency</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{currencies.map((cur) => (
<SelectItem key={cur.code} value={cur.code}>
{cur.symbol} {cur.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* Step 4: Tags */}
{currentStep === 4 && (
<div className="space-y-4 animate-in fade-in-50 duration-300">
<FormItem>
<FormLabel>Tags (Optional)</FormLabel>
<FormDescription>Add labels to help segment this user.</FormDescription>
<div className="flex flex-wrap gap-2 mt-3">
{mockTags.map((tag) => (
<Badge
key={tag.id}
variant="outline"
onClick={() => handleTagToggle(tag.id)}
className={cn(
'cursor-pointer transition-all',
selectedTags.includes(tag.id)
? tag.color
: 'bg-secondary/50 text-muted-foreground hover:bg-secondary'
)}
>
{tag.name}
</Badge>
))}
</div>
</FormItem>
</div>
)}
<DialogFooter className="flex justify-between sm:justify-between">
<Button
type="button"
variant="outline"
onClick={handleBack}
disabled={currentStep === 1 || isSubmitting}
>
<ChevronLeft className="h-4 w-4 mr-1" /> Back
</Button>
{currentStep < 4 ? (
<Button type="button" onClick={handleNext}>
Next <ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Creating...
</>
) : (
'Create User'
)}
</Button>
)}
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,151 @@
// DeleteConfirmDialog - Hard delete with type confirmation safeguard
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Trash2, Loader2, AlertTriangle } from 'lucide-react';
import { deleteConfirmSchema, type DeleteConfirmInput } from '@/lib/validations/user';
import type { User } from '@/lib/types/user';
import { toast } from 'sonner';
interface DeleteConfirmDialogProps {
user: User | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onDeleted?: () => void;
}
export function DeleteConfirmDialog({ user, open, onOpenChange, onDeleted }: DeleteConfirmDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<DeleteConfirmInput>({
resolver: zodResolver(deleteConfirmSchema),
defaultValues: {
confirmText: '' as 'DELETE',
},
});
const watchConfirmText = form.watch('confirmText');
const isConfirmValid = watchConfirmText === 'DELETE';
const onSubmit = async () => {
if (!user) return;
setIsSubmitting(true);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
toast.success(`User "${user.name}" has been permanently deleted`);
onOpenChange(false);
onDeleted?.();
form.reset();
} catch (error) {
toast.error('Failed to delete user');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
onOpenChange(false);
form.reset();
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<Trash2 className="h-5 w-5" />
Delete User Permanently
</DialogTitle>
<DialogDescription>
You are about to permanently delete <span className="font-semibold">{user.name}</span>'s account.
</DialogDescription>
</DialogHeader>
<div className="p-4 rounded-lg bg-red-50 border border-red-200 space-y-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
<div className="text-sm text-red-800">
<p className="font-semibold">This action is irreversible!</p>
<p className="text-red-700 mt-1">The following data will be permanently deleted:</p>
</div>
</div>
<ul className="text-sm text-red-700 list-disc list-inside ml-8 space-y-1">
<li>User profile and account information</li>
<li>All bookings and transaction history</li>
<li>Payment methods and billing data</li>
<li>Support tickets and communication history</li>
<li>Notes, tags, and preferences</li>
</ul>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
Type <span className="font-mono font-bold text-red-600">DELETE</span> to confirm
</FormLabel>
<FormControl>
<Input
placeholder="DELETE"
className="font-mono uppercase"
{...field}
onChange={(e) => field.onChange(e.target.value.toUpperCase())}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={!isConfirmValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4 mr-2" /> Delete Permanently
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,365 @@
// NotificationComposer - Custom push notification with templates and preview
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Bell,
Send,
Eye,
Clock,
Mail,
Smartphone,
Loader2,
Sparkles,
Zap,
CalendarDays,
} from 'lucide-react';
import { notificationSchema, type NotificationInput } from '@/lib/validations/user';
import type { User } from '@/lib/types/user';
// Local type for notification channels (not part of form schema but used for UI)
type NotificationChannel = 'push' | 'email' | 'sms';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface NotificationComposerProps {
users: User[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSent?: () => void;
}
const templates = [
{
id: 'welcome',
name: 'Welcome',
icon: Sparkles,
title: 'Welcome to Eventify! 🎉',
body: 'Thank you for joining us. Explore amazing events happening near you!',
},
{
id: 'promo',
name: 'Flash Sale',
icon: Zap,
title: 'Flash Sale! 50% Off 🔥',
body: 'Limited time offer on premium events. Book now before it\'s gone!',
},
{
id: 'event',
name: 'Event Reminder',
icon: CalendarDays,
title: 'Don\'t Miss Out! 📅',
body: 'Your event is coming up soon. Make sure you\'re ready!',
},
];
const channelConfig: Record<NotificationChannel, { icon: React.ElementType; label: string }> = {
push: { icon: Bell, label: 'Push' },
email: { icon: Mail, label: 'Email' },
sms: { icon: Smartphone, label: 'SMS' },
};
export function NotificationComposer({ users, open, onOpenChange, onSent }: NotificationComposerProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<'compose' | 'preview'>('compose');
const [selectedChannels, setSelectedChannels] = useState<NotificationChannel[]>(['push']);
const form = useForm<NotificationInput>({
resolver: zodResolver(notificationSchema),
defaultValues: {
title: '',
body: '',
actionUrl: '',
priority: 'normal',
},
});
const watchTitle = form.watch('title');
const watchBody = form.watch('body');
const handleTemplateSelect = (template: typeof templates[0]) => {
form.setValue('title', template.title);
form.setValue('body', template.body);
};
const handleChannelToggle = (channel: NotificationChannel) => {
const updated = selectedChannels.includes(channel)
? selectedChannels.filter((c) => c !== channel)
: [...selectedChannels, channel];
setSelectedChannels(updated);
// Note: channels are managed via local state, not form
};
const onSubmit = async (data: NotificationInput) => {
setIsSubmitting(true);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500));
toast.success(`Notification sent to ${users.length} user${users.length > 1 ? 's' : ''}!`, {
description: data.title,
});
onOpenChange(false);
onSent?.();
form.reset();
setSelectedChannels(['push']);
} catch (error) {
toast.error('Failed to send notification');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
onOpenChange(false);
form.reset();
setActiveTab('compose');
setSelectedChannels(['push']);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
Send Notification
</DialogTitle>
<DialogDescription>
Send a custom notification to {users.length} user{users.length > 1 ? 's' : ''}.
{users.length === 1 && (
<span className="font-medium"> ({users[0].name})</span>
)}
</DialogDescription>
</DialogHeader>
{/* Recipients Preview */}
{users.length > 1 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-secondary/20 max-h-20 overflow-y-auto">
{users.slice(0, 10).map((user) => (
<Badge key={user.id} variant="outline" className="text-xs">
{user.name}
</Badge>
))}
{users.length > 10 && (
<Badge variant="secondary" className="text-xs">
+{users.length - 10} more
</Badge>
)}
</div>
)}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'compose' | 'preview')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="compose">Compose</TabsTrigger>
<TabsTrigger value="preview" className="gap-2">
<Eye className="h-4 w-4" /> Preview
</TabsTrigger>
</TabsList>
<TabsContent value="compose" className="space-y-4 mt-4">
{/* Quick Templates */}
<div className="space-y-2">
<FormLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Quick Templates
</FormLabel>
<div className="flex gap-2">
{templates.map((template) => (
<Button
key={template.id}
type="button"
variant="outline"
size="sm"
onClick={() => handleTemplateSelect(template)}
className="gap-2"
>
<template.icon className="h-4 w-4" />
{template.name}
</Button>
))}
</div>
</div>
<Form {...form}>
<form className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input placeholder="Notification title..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="body"
render={({ field }) => (
<FormItem>
<FormLabel>Message *</FormLabel>
<FormControl>
<Textarea
placeholder="Write your message here..."
className="resize-none"
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{(field.value?.length || 0)} / 200 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Channels */}
<FormItem>
<FormLabel>Channels *</FormLabel>
<div className="flex gap-2 mt-2">
{(Object.keys(channelConfig) as NotificationChannel[]).map((channel) => {
const config = channelConfig[channel];
const isSelected = selectedChannels.includes(channel);
return (
<Button
key={channel}
type="button"
variant={isSelected ? 'default' : 'outline'}
size="sm"
onClick={() => handleChannelToggle(channel)}
className="gap-2"
>
<config.icon className="h-4 w-4" />
{config.label}
</Button>
);
})}
</div>
</FormItem>
<FormField
control={form.control}
name="actionUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Action URL (Optional)</FormLabel>
<FormControl>
<Input placeholder="https://eventify.com/event/123" {...field} />
</FormControl>
<FormDescription>
Where should users land when they tap the notification?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</TabsContent>
<TabsContent value="preview" className="mt-4">
{/* Mobile-style notification preview */}
<div className="flex justify-center p-6 bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl">
<div className="w-72 bg-white rounded-2xl shadow-2xl overflow-hidden">
<div className="p-4 bg-gradient-to-r from-primary to-violet-600">
<div className="flex items-center gap-2 text-white/80 text-xs">
<Bell className="h-3 w-3" />
<span>EVENTIFY</span>
<span className="ml-auto">now</span>
</div>
</div>
<div className="p-4">
<h4 className="font-semibold text-foreground line-clamp-2">
{watchTitle || 'Notification Title'}
</h4>
<p className="text-sm text-muted-foreground mt-1 line-clamp-3">
{watchBody || 'Notification message will appear here...'}
</p>
</div>
<div className="px-4 pb-4 flex justify-end gap-2">
<Button size="sm" variant="ghost" className="text-xs">
Dismiss
</Button>
<Button size="sm" className="text-xs">
View
</Button>
</div>
</div>
</div>
<div className="flex flex-wrap justify-center gap-2 mt-4 text-sm text-muted-foreground">
<span>Sending via:</span>
{selectedChannels.map((channel) => {
const config = channelConfig[channel];
return (
<Badge key={channel} variant="outline" className="gap-1">
<config.icon className="h-3 w-3" />
{config.label}
</Badge>
);
})}
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
onClick={form.handleSubmit(onSubmit)}
disabled={!watchTitle || !watchBody || selectedChannels.length === 0 || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Sending...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" /> Send Now
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,215 @@
// SuspensionModal - Suspension workflow with reason, duration, and notification
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { suspendUserSchema, type SuspendUserInput } from '@/lib/validations/user';
import type { User } from '@/lib/types/user';
import { toast } from 'sonner';
interface SuspensionModalProps {
user: User | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuspended?: () => void;
}
const durationLabels = {
'7_days': '7 Days',
'30_days': '30 Days',
'90_days': '90 Days',
'permanent': 'Permanent',
};
export function SuspensionModal({ user, open, onOpenChange, onSuspended }: SuspensionModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<SuspendUserInput>({
resolver: zodResolver(suspendUserSchema),
defaultValues: {
reason: 'Policy Violation',
customNote: '',
duration: '7_days',
sendEmail: true,
},
});
const onSubmit = async (data: SuspendUserInput) => {
if (!user) return;
setIsSubmitting(true);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success(`User "${user.name}" has been suspended`, {
description: `Duration: ${durationLabels[data.duration]}`,
});
onOpenChange(false);
onSuspended?.();
form.reset();
} catch (error) {
toast.error('Failed to suspend user');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
onOpenChange(false);
form.reset();
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-orange-600">
<AlertTriangle className="h-5 w-5" />
Suspend User
</DialogTitle>
<DialogDescription>
Suspend <span className="font-semibold">{user.name}</span>'s account. They will be unable to login until the suspension is lifted.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>Reason *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Payment Issue">Payment Issue</SelectItem>
<SelectItem value="Policy Violation">Policy Violation</SelectItem>
<SelectItem value="Spam">Spam</SelectItem>
<SelectItem value="Abuse">Abuse</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customNote"
render={({ field }) => (
<FormItem>
<FormLabel>Internal Note</FormLabel>
<FormControl>
<Textarea
placeholder="Add any additional details (internal only)..."
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>This note is only visible to admins.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="duration"
render={({ field }) => (
<FormItem>
<FormLabel>Duration *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="7_days">7 Days</SelectItem>
<SelectItem value="30_days">30 Days</SelectItem>
<SelectItem value="90_days">90 Days</SelectItem>
<SelectItem value="permanent">Permanent</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sendEmail"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4 bg-secondary/20">
<div className="space-y-0.5">
<FormLabel className="text-base">Send Email Notification</FormLabel>
<FormDescription>
Notify the user about their suspension via email.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={isSubmitting} className="bg-orange-600 hover:bg-orange-700">
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Suspending...
</>
) : (
'Suspend User'
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,382 @@
// UserFilters - Advanced filter sidebar with segments
import { useState } from 'react';
import {
Filter,
X,
ChevronDown,
ChevronUp,
Save,
RotateCcw,
Search,
Bookmark,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Slider } from '@/components/ui/slider';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { mockTags, mockSegments } from '../data/mockUserCrmData';
import type { UserStatus, UserRole, UserTier, SegmentFilters, UserSegment } from '@/lib/types/user';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface UserFiltersProps {
filters: SegmentFilters;
onFiltersChange: (filters: SegmentFilters) => void;
isOpen: boolean;
onToggle: () => void;
}
const statusOptions: UserStatus[] = ['Active', 'Pending Verification', 'Suspended', 'Banned', 'Archived'];
const roleOptions: UserRole[] = ['Super Admin', 'Admin', 'Support Agent', 'Partner', 'User'];
const tierOptions: UserTier[] = ['Platinum', 'Gold', 'Silver', 'Bronze'];
export function UserFilters({ filters, onFiltersChange, isOpen, onToggle }: UserFiltersProps) {
const [statusOpen, setStatusOpen] = useState(true);
const [roleOpen, setRoleOpen] = useState(false);
const [tierOpen, setTierOpen] = useState(false);
const [spentOpen, setSpentOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [segmentDialogOpen, setSegmentDialogOpen] = useState(false);
const [segmentName, setSegmentName] = useState('');
const activeFiltersCount = [
filters.search ? 1 : 0,
filters.status?.length ? 1 : 0,
filters.role?.length ? 1 : 0,
filters.tier?.length ? 1 : 0,
filters.tags?.length ? 1 : 0,
filters.minSpent ? 1 : 0,
filters.maxSpent ? 1 : 0,
].reduce((a, b) => a + b, 0);
const handleStatusToggle = (status: UserStatus) => {
const current = filters.status || [];
const updated = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status];
onFiltersChange({ ...filters, status: updated.length ? updated : undefined });
};
const handleRoleToggle = (role: UserRole) => {
const current = filters.role || [];
const updated = current.includes(role)
? current.filter((r) => r !== role)
: [...current, role];
onFiltersChange({ ...filters, role: updated.length ? updated : undefined });
};
const handleTierToggle = (tier: UserTier) => {
const current = filters.tier || [];
const updated = current.includes(tier)
? current.filter((t) => t !== tier)
: [...current, tier];
onFiltersChange({ ...filters, tier: updated.length ? updated : undefined });
};
const handleTagToggle = (tagId: string) => {
const current = filters.tags || [];
const updated = current.includes(tagId)
? current.filter((t) => t !== tagId)
: [...current, tagId];
onFiltersChange({ ...filters, tags: updated.length ? updated : undefined });
};
const handleSpentChange = (values: number[]) => {
onFiltersChange({
...filters,
minSpent: values[0] > 0 ? values[0] : undefined,
maxSpent: values[1] < 200000 ? values[1] : undefined,
});
};
const handleSegmentApply = (segment: UserSegment) => {
onFiltersChange(segment.filters);
toast.success(`Applied segment: ${segment.name}`);
};
const handleSaveSegment = () => {
if (!segmentName.trim()) {
toast.error('Please enter a segment name');
return;
}
// In real app, this would save to backend
toast.success(`Segment "${segmentName}" saved!`);
setSegmentDialogOpen(false);
setSegmentName('');
};
const handleClearFilters = () => {
onFiltersChange({});
toast.info('Filters cleared');
};
if (!isOpen) {
return (
<Button
variant="outline"
onClick={onToggle}
className="neu-button h-10 px-4 gap-2"
>
<Filter className="h-4 w-4" />
Filters
{activeFiltersCount > 0 && (
<Badge variant="secondary" className="ml-1 h-5 w-5 p-0 flex items-center justify-center bg-primary text-primary-foreground rounded-full text-[10px]">
{activeFiltersCount}
</Badge>
)}
</Button>
);
}
return (
<div className="w-72 shrink-0 rounded-xl border border-white/40 bg-white/30 shadow-neu-sm backdrop-blur-md overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/30 bg-white/20">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-foreground">Filters</h3>
{activeFiltersCount > 0 && (
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
{activeFiltersCount}
</Badge>
)}
</div>
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8">
<X className="h-4 w-4" />
</Button>
</div>
<ScrollArea className="h-[calc(100vh-280px)]">
<div className="p-4 space-y-4">
{/* Search */}
<div className="space-y-2">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Search</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Name, email, phone..."
value={filters.search || ''}
onChange={(e) => onFiltersChange({ ...filters, search: e.target.value || undefined })}
className="pl-9 bg-white/50 border-white/50"
/>
</div>
</div>
<Separator className="bg-white/30" />
{/* Saved Segments */}
<div className="space-y-2">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Saved Segments</Label>
<Select onValueChange={(id) => {
const segment = mockSegments.find(s => s.id === id);
if (segment) handleSegmentApply(segment);
}}>
<SelectTrigger className="bg-white/50 border-white/50">
<SelectValue placeholder="Apply a segment..." />
</SelectTrigger>
<SelectContent>
{mockSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
<div className="flex items-center gap-2">
<Bookmark className="h-3 w-3 text-primary" />
<span>{segment.name}</span>
<span className="text-xs text-muted-foreground">({segment.userCount})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator className="bg-white/30" />
{/* Status Filter */}
<Collapsible open={statusOpen} onOpenChange={setStatusOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full py-2 group">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer">Status</Label>
{statusOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{statusOptions.map((status) => (
<div key={status} className="flex items-center gap-2">
<Checkbox
id={`status-${status}`}
checked={filters.status?.includes(status) || false}
onCheckedChange={() => handleStatusToggle(status)}
/>
<Label htmlFor={`status-${status}`} className="text-sm cursor-pointer">
{status}
</Label>
</div>
))}
</CollapsibleContent>
</Collapsible>
<Separator className="bg-white/30" />
{/* Role Filter */}
<Collapsible open={roleOpen} onOpenChange={setRoleOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full py-2 group">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer">Role</Label>
{roleOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{roleOptions.map((role) => (
<div key={role} className="flex items-center gap-2">
<Checkbox
id={`role-${role}`}
checked={filters.role?.includes(role) || false}
onCheckedChange={() => handleRoleToggle(role)}
/>
<Label htmlFor={`role-${role}`} className="text-sm cursor-pointer">
{role}
</Label>
</div>
))}
</CollapsibleContent>
</Collapsible>
<Separator className="bg-white/30" />
{/* Tier Filter */}
<Collapsible open={tierOpen} onOpenChange={setTierOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full py-2 group">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer">Tier</Label>
{tierOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{tierOptions.map((tier) => (
<div key={tier} className="flex items-center gap-2">
<Checkbox
id={`tier-${tier}`}
checked={filters.tier?.includes(tier) || false}
onCheckedChange={() => handleTierToggle(tier)}
/>
<Label htmlFor={`tier-${tier}`} className="text-sm cursor-pointer">
{tier}
</Label>
</div>
))}
</CollapsibleContent>
</Collapsible>
<Separator className="bg-white/30" />
{/* Total Spent Filter */}
<Collapsible open={spentOpen} onOpenChange={setSpentOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full py-2 group">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer">Total Spent</Label>
{spentOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
<Slider
value={[filters.minSpent || 0, filters.maxSpent || 200000]}
min={0}
max={200000}
step={5000}
onValueChange={handleSpentChange}
className="w-full"
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{((filters.minSpent || 0) / 1000).toFixed(0)}K</span>
<span>{((filters.maxSpent || 200000) / 1000).toFixed(0)}K</span>
</div>
</CollapsibleContent>
</Collapsible>
<Separator className="bg-white/30" />
{/* Tags Filter */}
<Collapsible open={tagsOpen} onOpenChange={setTagsOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full py-2 group">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider cursor-pointer">Tags</Label>
{tagsOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="flex flex-wrap gap-2">
{mockTags.map((tag) => (
<Badge
key={tag.id}
variant="outline"
onClick={() => handleTagToggle(tag.id)}
className={cn(
'cursor-pointer transition-all',
filters.tags?.includes(tag.id)
? tag.color
: 'bg-white/50 text-muted-foreground hover:bg-white/80'
)}
>
{tag.name}
</Badge>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
</ScrollArea>
{/* Footer Actions */}
<div className="p-4 border-t border-white/30 bg-white/20 space-y-2">
<Dialog open={segmentDialogOpen} onOpenChange={setSegmentDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full gap-2" disabled={activeFiltersCount === 0}>
<Save className="h-4 w-4" /> Save as Segment
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Filter as Segment</DialogTitle>
<DialogDescription>
Give your segment a name to quickly apply these filters later.
</DialogDescription>
</DialogHeader>
<Input
placeholder="e.g., High Value Dormant Users"
value={segmentName}
onChange={(e) => setSegmentName(e.target.value)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setSegmentDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveSegment}>Save Segment</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="ghost"
size="sm"
className="w-full gap-2 text-muted-foreground"
onClick={handleClearFilters}
disabled={activeFiltersCount === 0}
>
<RotateCcw className="h-4 w-4" /> Clear All
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,357 @@
import { useState } from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
User,
Mail,
Phone,
MapPin,
Shield,
Ticket,
CheckCircle2,
KeyRound,
Eye,
MessageSquare,
Ban,
Copy,
MoreHorizontal,
MoreVertical,
Clock,
AlertTriangle,
Plus,
Tag,
} from 'lucide-react';
import type { User as UserType } from '@/lib/types/user';
import {
getUserBookings,
getUserNotes,
} from '../data/mockUserCrmData';
import { formatCurrency } from '@/data/mockData';
import { ActionButtons } from './ActionButtons';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface UserInspectorSheetProps {
user: UserType | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onEditUser: (user: UserType) => void;
onSendNotification: (user: UserType) => void;
}
export function UserInspectorSheet({
user,
open,
onOpenChange,
onEditUser,
onSendNotification,
}: UserInspectorSheetProps) {
const [activeTab, setActiveTab] = useState('overview');
const [noteContent, setNoteContent] = useState('');
if (!user) return null;
// Fetch related data
const bookings = getUserBookings(user.id);
const notes = getUserNotes(user.id);
// Derived Metrics
const successBookings = bookings.filter(b => b.status === 'Attended' || b.status === 'Confirmed').length;
const cancelledBookings = bookings.filter(b => b.status === 'Cancelled' || b.status === 'Refunded').length;
const isHighRisk = user.refundRate > 5;
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const handleAction = (label: string) => {
toast.success(`${label} action triggered`);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[500px] sm:w-[600px] p-0 border-l border-border/50 shadow-2xl overflow-hidden flex flex-col">
{/* Section A: The Header (Compact) */}
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4 border-b border-border/50">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-4">
<Avatar className="h-14 w-14 border-2 border-background shadow-sm">
<AvatarImage src={user.avatarUrl} alt={user.name} />
<AvatarFallback className="text-lg bg-primary/10 text-primary font-bold">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold leading-none">{user.name}</h2>
{user.isVerified && (
<CheckCircle2 className="h-4 w-4 text-blue-500 fill-blue-100" />
)}
</div>
<div className="flex items-center gap-2 mt-1.5 text-xs text-muted-foreground font-mono">
<span
className="flex items-center gap-1 cursor-pointer hover:text-foreground transition-colors"
onClick={() => copyToClipboard(user.id)}
title="Click to copy ID"
>
{user.id} <Copy className="h-3 w-3" />
</span>
<span></span>
<span>Joined {new Date(user.createdAt).toLocaleDateString('en-IN', { month: 'short', year: 'numeric' })}</span>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-normal ml-1">
{user.role}
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={user.status === 'Active' ? 'default' : 'destructive'} className="rounded-full">
{user.status}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEditUser(user)}>Edit Profile</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction('Archive')}>Archive User</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => handleAction('Delete')}>Delete User</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Section B: Key Metrics Grid */}
<div className="grid grid-cols-4 border-b border-border/50 divide-x divide-border/50 bg-secondary/10">
<div className="p-3 text-center">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-0.5">LTV</p>
<p className={cn("text-sm font-bold", user.totalSpent > 50000 && "text-amber-600")}>
{formatCurrency(user.totalSpent)}
</p>
</div>
<div className="p-3 text-center">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-0.5">Bookings</p>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<p className="text-sm font-bold cursor-help underline decoration-dotted underline-offset-4">
{user.bookingsCount}
</p>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
<span className="text-emerald-500 font-medium">{successBookings} Success</span> <span className="text-red-500 font-medium">{cancelledBookings} Cancelled</span>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="p-3 text-center">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-0.5">Avg. Ticket</p>
<p className="text-sm font-bold text-foreground">
{formatCurrency(user.averageOrderValue)}
</p>
</div>
<div className="p-3 text-center">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-0.5">Refund Risk</p>
<Badge
variant="outline"
className={cn("h-5 text-[10px] px-1.5", isHighRisk ? "bg-red-50 text-red-600 border-red-200" : "bg-emerald-50 text-emerald-600 border-emerald-200")}
>
{user.refundRate}% {isHighRisk ? 'High' : 'Low'}
</Badge>
</div>
</div>
{/* Section C: Action Toolbar */}
<div className="px-4 py-2 border-b border-border/50 bg-background flex items-center justify-between">
<ActionButtons
userId={user.id}
userName={user.name}
onSuspend={() => handleAction('Suspend')}
onSendNotification={() => onSendNotification(user)}
/>
</div>
{/* Section D: Tabbed Content */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
<div className="px-4 border-b border-border/50">
<TabsList className="w-full justify-start h-9 bg-transparent p-0">
<TabsTrigger value="overview" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 text-xs">Overview</TabsTrigger>
<TabsTrigger value="orders" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 text-xs">Orders</TabsTrigger>
<TabsTrigger value="admin" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 text-xs">Admin Notes</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="flex-1 bg-secondary/5">
<div className="p-4 space-y-6">
{/* Tab 1: Overview */}
<TabsContent value="overview" className="m-0 space-y-6">
{/* Contact Info */}
<div>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Contact Information</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-3">
<Mail className="h-3.5 w-3.5 text-primary/60" />
<span className="text-foreground">{user.email}</span>
</div>
<div className="flex items-center gap-3">
<Phone className="h-3.5 w-3.5 text-primary/60" />
<span className="text-foreground">{user.countryCode} {user.phone}</span>
</div>
{user.lastDevice && (
<div className="flex items-center gap-3">
<MapPin className="h-3.5 w-3.5 text-primary/60" />
<span className="text-foreground">{user.lastDevice.location}</span>
</div>
)}
</div>
</div>
<Separator className="border-dashed" />
{/* Last Activity */}
<div>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Last Activity</h3>
<div className="flex items-start gap-3 p-3 bg-white border border-border/50 rounded-lg shadow-sm">
<div className="mt-0.5">
<Clock className="h-4 w-4 text-blue-500" />
</div>
<div>
<p className="text-sm font-medium">Scanned at <span className="text-foreground font-semibold">Tech Summit 2026</span></p>
<p className="text-xs text-muted-foreground mt-0.5">2 hours ago Verified by Staff</p>
</div>
</div>
</div>
<Separator className="border-dashed" />
{/* Tags */}
<div>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Tags</h3>
<div className="flex flex-wrap gap-2">
{user.tags.map((tag) => (
<Badge key={tag.id} variant="outline" className={cn("rounded-md px-2 py-0.5 font-normal", tag.color)}>
{tag.name}
</Badge>
))}
<Button variant="outline" size="sm" className="h-6 rounded-md px-2 text-xs border-dashed gap-1 text-muted-foreground hover:text-foreground">
<Plus className="h-3 w-3" /> Add
</Button>
</div>
</div>
</TabsContent>
{/* Tab 2: Orders */}
<TabsContent value="orders" className="m-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs font-medium">Event</TableHead>
<TableHead className="h-8 text-xs font-medium text-right">Date</TableHead>
<TableHead className="h-8 text-xs font-medium text-right">Amount</TableHead>
<TableHead className="h-8 text-xs font-medium text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bookings.slice(0, 5).map((booking) => (
<TableRow key={booking.id} className="hover:bg-transparent border-0">
<TableCell className="py-2 text-sm font-medium">
{booking.eventName}
<div className="text-[10px] text-muted-foreground font-normal">{booking.ticketType} x{booking.quantity}</div>
</TableCell>
<TableCell className="py-2 text-xs text-right text-muted-foreground">
{new Date(booking.eventDate).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })}
</TableCell>
<TableCell className="py-2 text-xs text-right font-medium">
{formatCurrency(booking.amount)}
</TableCell>
<TableCell className="py-2 text-right">
<Badge variant="outline" className="text-[10px] h-5 px-1.5 font-normal">
{booking.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{bookings.length === 0 && (
<div className="text-center py-8 text-xs text-muted-foreground">No orders found.</div>
)}
</TabsContent>
{/* Tab 3: Admin Notes */}
<TabsContent value="admin" className="m-0 space-y-4">
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg space-y-2">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600" />
<span className="text-xs font-bold text-amber-800 uppercase tracking-wide">Internal Notes</span>
</div>
<Textarea
className="min-h-[100px] border- amber-200/50 bg-white/50 focus-visible:ring-amber-500/30 text-sm resize-none"
placeholder="Add private notes about this user..."
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
/>
<div className="flex justify-end">
<Button size="sm" className="h-7 text-xs bg-amber-600 hover:bg-amber-700 text-white border-none shadow-none">
Save Note
</Button>
</div>
</div>
<div className="space-y-3">
{notes.map((note) => (
<div key={note.id} className="relative pl-4 border-l-2 border-border/50 py-1">
<p className="text-sm text-foreground">{note.content}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-medium text-muted-foreground">{note.authorName}</span>
<span className="text-[10px] text-muted-foreground/60"> {new Date(note.createdAt).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
</TabsContent>
</div>
</ScrollArea>
</Tabs>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,101 @@
// UserMetricsBar - Analytics dashboard strip above table
import {
Users,
UserCheck,
UserPlus,
AlertTriangle,
TrendingUp,
TrendingDown,
DollarSign,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { UserMetrics } from '@/lib/types/user';
import { formatCurrency } from '@/data/mockData';
interface UserMetricsBarProps {
metrics: UserMetrics;
}
interface MetricCardProps {
title: string;
value: string | number;
trend?: number;
icon: React.ElementType;
iconColor: string;
iconBg: string;
}
function MetricCard({ title, value, trend, icon: Icon, iconColor, iconBg }: MetricCardProps) {
return (
<div className="flex items-center gap-4 p-4 rounded-xl border border-white/40 bg-white/30 shadow-neu-sm backdrop-blur-md transition-all duration-300 hover:shadow-neu">
<div className={cn('p-3 rounded-xl', iconBg)}>
<Icon className={cn('h-5 w-5', iconColor)} />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">{title}</span>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-foreground">{value}</span>
{trend !== undefined && (
<div className={cn(
'flex items-center gap-0.5 text-xs font-semibold px-1.5 py-0.5 rounded-full',
trend >= 0 ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'
)}>
{trend >= 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
<span>{Math.abs(trend).toFixed(1)}%</span>
</div>
)}
</div>
</div>
</div>
);
}
export function UserMetricsBar({ metrics }: UserMetricsBarProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 animate-in fade-in-50 duration-500">
<MetricCard
title="Total Users"
value={metrics.totalUsers.toLocaleString('en-IN')}
trend={metrics.totalUsersTrend}
icon={Users}
iconColor="text-blue-600"
iconBg="bg-blue-100"
/>
<MetricCard
title="Active (30d)"
value={metrics.activeUsers.toLocaleString('en-IN')}
trend={metrics.activeUsersTrend}
icon={UserCheck}
iconColor="text-emerald-600"
iconBg="bg-emerald-100"
/>
<MetricCard
title="New Today"
value={metrics.newToday}
trend={metrics.newTodayTrend}
icon={UserPlus}
iconColor="text-violet-600"
iconBg="bg-violet-100"
/>
<MetricCard
title="Suspended / Banned"
value={`${metrics.suspendedCount} / ${metrics.bannedCount}`}
icon={AlertTriangle}
iconColor="text-orange-600"
iconBg="bg-orange-100"
/>
<MetricCard
title="Avg. LTV"
value={formatCurrency(metrics.averageLifetimeValue)}
trend={metrics.averageLifetimeValueTrend}
icon={DollarSign}
iconColor="text-green-600"
iconBg="bg-green-100"
/>
</div>
);
}

View File

@@ -0,0 +1,459 @@
// UsersTable - TanStack Table implementation with sorting, selection, and actions
import { useState, useMemo } from 'react';
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
RowSelectionState,
} from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
MoreHorizontal,
Copy,
Mail,
Shield,
Ban,
Trash2,
Edit,
Bell,
Eye,
CheckCircle2,
Clock,
XCircle,
AlertCircle,
Archive,
ArrowUpDown,
ChevronUp,
ChevronDown,
} from 'lucide-react';
import { toast } from 'sonner';
import type { User, UserStatus, UserRole } from '@/lib/types/user';
import { formatCurrency } from '@/data/mockData';
import { cn } from '@/lib/utils';
interface UsersTableProps {
users: User[];
onSelectUser: (user: User) => void;
onEditUser: (user: User) => void;
onSuspendUser: (user: User) => void;
onBanUser: (user: User) => void;
onDeleteUser: (user: User) => void;
onSendNotification: (user: User) => void;
selectedUserIds: string[];
onSelectionChange: (userIds: string[]) => void;
}
// Status configuration
const statusConfig: Record<UserStatus, { icon: React.ElementType; color: string; dotColor: string }> = {
'Active': { icon: CheckCircle2, color: 'text-emerald-600', dotColor: 'bg-emerald-500' },
'Pending Verification': { icon: Clock, color: 'text-amber-600', dotColor: 'bg-amber-500' },
'Suspended': { icon: AlertCircle, color: 'text-orange-600', dotColor: 'bg-orange-500' },
'Banned': { icon: XCircle, color: 'text-red-600', dotColor: 'bg-red-500' },
'Archived': { icon: Archive, color: 'text-slate-500', dotColor: 'bg-slate-400' },
};
// Role badge colors
const roleColors: Record<UserRole, string> = {
'Super Admin': 'bg-purple-500/20 text-purple-700 border-purple-300',
'Admin': 'bg-purple-400/20 text-purple-600 border-purple-200',
'Support Agent': 'bg-blue-500/20 text-blue-700 border-blue-300',
'Partner': 'bg-cyan-500/20 text-cyan-700 border-cyan-300',
'User': 'bg-green-500/20 text-green-700 border-green-300',
};
// Health score badges
const healthBadges = {
hot: { emoji: '🔥', label: 'Hot', color: 'bg-orange-100 text-orange-700' },
warm: { emoji: '😐', label: 'Warm', color: 'bg-yellow-100 text-yellow-700' },
cold: { emoji: '🧊', label: 'Cold', color: 'bg-blue-100 text-blue-700' },
};
// Responsive column visibility
const columnVisibilityClasses: Record<string, string> = {
email: 'hidden md:table-cell',
role: 'hidden lg:table-cell',
status: 'hidden sm:table-cell',
bookingsCount: 'hidden xl:table-cell',
totalSpent: 'hidden lg:table-cell',
lastActivityAt: 'hidden xl:table-cell',
};
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' });
}
export function UsersTable({
users,
onSelectUser,
onEditUser,
onSuspendUser,
onBanUser,
onDeleteUser,
onSendNotification,
selectedUserIds,
onSelectionChange,
}: UsersTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const rowSelection = useMemo(() => {
const selection: RowSelectionState = {};
selectedUserIds.forEach((id) => {
const index = users.findIndex((u) => u.id === id);
if (index !== -1) selection[index] = true;
});
return selection;
}, [selectedUserIds, users]);
const handleCopyEmail = (email: string, e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(email);
toast.success('Email copied to clipboard');
};
const columns: ColumnDef<User>[] = useMemo(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => e.stopPropagation()}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
size: 40,
},
{
accessorKey: 'name',
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="p-0 hover:bg-transparent font-semibold"
>
User
{column.getIsSorted() === 'asc' ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : column.getIsSorted() === 'desc' ? (
<ChevronDown className="ml-1 h-4 w-4" />
) : (
<ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />
)}
</Button>
),
cell: ({ row }) => {
const user = row.original;
const health = healthBadges[user.healthScore];
return (
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 border-2 border-white shadow-sm">
<AvatarImage src={user.avatarUrl} alt={user.name} />
<AvatarFallback className="bg-primary/10 text-primary font-bold">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground">{user.name}</span>
{user.isVerified && (
<Tooltip>
<TooltipTrigger>
<CheckCircle2 className="h-4 w-4 text-blue-500 fill-blue-100" />
</TooltipTrigger>
<TooltipContent>Verified User</TooltipContent>
</Tooltip>
)}
<span className="text-xs" title={health.label}>{health.emoji}</span>
</div>
<span className="text-xs text-muted-foreground font-mono">{user.id}</span>
</div>
</div>
);
},
size: 250,
},
{
accessorKey: 'email',
header: 'Email',
cell: ({ row }) => {
const email = row.original.email;
return (
<div className="flex items-center gap-2 group">
<span className="font-mono text-sm text-muted-foreground">{email}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => handleCopyEmail(email, e)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
);
},
size: 220,
},
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => {
const role = row.original.role;
return (
<Badge variant="outline" className={cn('font-medium', roleColors[role])}>
{role}
</Badge>
);
},
size: 130,
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status;
const config = statusConfig[status];
return (
<div className="flex items-center gap-2">
<span className={cn('h-2 w-2 rounded-full animate-pulse', config.dotColor)} />
<span className={cn('text-sm font-medium', config.color)}>{status}</span>
</div>
);
},
size: 150,
},
{
accessorKey: 'bookingsCount',
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="p-0 hover:bg-transparent font-semibold"
>
Bookings
{column.getIsSorted() === 'asc' ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : column.getIsSorted() === 'desc' ? (
<ChevronDown className="ml-1 h-4 w-4" />
) : (
<ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />
)}
</Button>
),
cell: ({ row }) => (
<span className="font-medium">{row.original.bookingsCount}</span>
),
size: 100,
},
{
accessorKey: 'totalSpent',
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
className="p-0 hover:bg-transparent font-semibold"
>
Spent
{column.getIsSorted() === 'asc' ? (
<ChevronUp className="ml-1 h-4 w-4" />
) : column.getIsSorted() === 'desc' ? (
<ChevronDown className="ml-1 h-4 w-4" />
) : (
<ArrowUpDown className="ml-1 h-4 w-4 opacity-50" />
)}
</Button>
),
cell: ({ row }) => (
<span className="font-semibold text-primary">{formatCurrency(row.original.totalSpent)}</span>
),
size: 100,
},
{
accessorKey: 'lastActivityAt',
header: 'Last Active',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatRelativeTime(row.original.lastActivityAt)}
</span>
),
size: 110,
},
{
id: 'actions',
cell: ({ row }) => {
const user = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onSelectUser(user)}>
<Eye className="mr-2 h-4 w-4" /> View Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditUser(user)}>
<Edit className="mr-2 h-4 w-4" /> Edit User
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSendNotification(user)}>
<Bell className="mr-2 h-4 w-4" /> Send Notification
</DropdownMenuItem>
<DropdownMenuSeparator />
{user.status === 'Active' && (
<DropdownMenuItem onClick={() => onSuspendUser(user)} className="text-orange-600">
<Shield className="mr-2 h-4 w-4" /> Suspend User
</DropdownMenuItem>
)}
{user.status !== 'Banned' && (
<DropdownMenuItem onClick={() => onBanUser(user)} className="text-red-600">
<Ban className="mr-2 h-4 w-4" /> Ban User
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDeleteUser(user)} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" /> Delete User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
size: 50,
},
],
[onSelectUser, onEditUser, onSuspendUser, onBanUser, onDeleteUser, onSendNotification]
);
const table = useReactTable({
data: users,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: (updater) => {
const newSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
const newIds = Object.keys(newSelection)
.filter((key) => newSelection[key])
.map((key) => users[parseInt(key)]?.id)
.filter(Boolean) as string[];
onSelectionChange(newIds);
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<div className="rounded-xl border border-white/40 bg-white/30 shadow-neu-sm backdrop-blur-md overflow-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b border-white/30 bg-white/20 hover:bg-white/20">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
className={cn(
"text-foreground/70 font-semibold text-xs uppercase tracking-wider py-4",
columnVisibilityClasses[header.column.id]
)}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn(
'border-b border-white/20 cursor-pointer transition-all duration-200',
'hover:bg-white/40 hover:shadow-sm',
row.getIsSelected() && 'bg-primary/5'
)}
onClick={() => onSelectUser(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(
"py-4",
columnVisibilityClasses[cell.column.id]
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-32 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Mail className="h-8 w-8 opacity-50" />
<p>No users found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,711 @@
// User Command Center - Extended Mock Data
// Comprehensive dataset for CRM, support, and moderation features
import type {
User,
UserTag,
UserSegment,
UserBooking,
UserSession,
SuspiciousActivity,
SupportTicket,
UserNote,
EmailLog,
Notification,
AuditLog,
UserMetrics,
TimelineEvent,
NotificationTemplate,
} from '@/lib/types/user';
// ============================================
// TAGS
// ============================================
export const mockTags: UserTag[] = [
{ id: 'tag-vip', name: 'VIP', color: 'bg-purple-500/20 text-purple-600 border-purple-300' },
{ id: 'tag-influencer', name: 'Influencer', color: 'bg-pink-500/20 text-pink-600 border-pink-300' },
{ id: 'tag-press', name: 'Press', color: 'bg-blue-500/20 text-blue-600 border-blue-300' },
{ id: 'tag-early-adopter', name: 'Early Adopter', color: 'bg-green-500/20 text-green-600 border-green-300' },
{ id: 'tag-big-spender', name: 'Big Spender', color: 'bg-yellow-500/20 text-yellow-700 border-yellow-400' },
{ id: 'tag-problem', name: 'Problem User', color: 'bg-red-500/20 text-red-600 border-red-300' },
{ id: 'tag-new', name: 'New User', color: 'bg-cyan-500/20 text-cyan-600 border-cyan-300' },
{ id: 'tag-corporate', name: 'Corporate', color: 'bg-slate-500/20 text-slate-600 border-slate-300' },
];
// ============================================
// USERS - 20+ comprehensive entries
// ============================================
export const mockCrmUsers: User[] = [
{
id: 'usr-001',
name: 'Priya Sharma',
email: 'priya.sharma@gmail.com',
phone: '9876543210',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=priya',
role: 'User',
status: 'Active',
tier: 'Platinum',
healthScore: 'hot',
isVerified: true,
is2FAEnabled: true,
totalSpent: 125000,
bookingsCount: 45,
refundRate: 2.2,
averageOrderValue: 2778,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: true,
smsNotifications: false,
tags: [mockTags[0], mockTags[4]], // VIP, Big Spender
pinnedNote: 'Top customer - Priority support always',
createdAt: '2023-01-15T10:30:00Z',
updatedAt: '2026-02-08T14:20:00Z',
lastLoginAt: '2026-02-09T08:45:00Z',
lastActivityAt: '2026-02-09T09:30:00Z',
lastDevice: { os: 'iOS 17', browser: 'Safari', ip: '103.42.156.78', location: 'Mumbai, IN' },
},
{
id: 'usr-002',
name: 'Rahul Verma',
email: 'rahul.verma@outlook.com',
phone: '8765432109',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=rahul',
role: 'User',
status: 'Active',
tier: 'Gold',
healthScore: 'warm',
isVerified: true,
is2FAEnabled: false,
totalSpent: 45000,
bookingsCount: 18,
refundRate: 5.5,
averageOrderValue: 2500,
language: 'hi',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: true,
smsNotifications: true,
tags: [mockTags[3]], // Early Adopter
createdAt: '2023-06-20T15:45:00Z',
updatedAt: '2026-02-05T11:00:00Z',
lastLoginAt: '2026-02-07T16:30:00Z',
lastActivityAt: '2026-02-07T17:15:00Z',
lastDevice: { os: 'Android 14', browser: 'Chrome', ip: '49.207.183.42', location: 'Delhi, IN' },
},
{
id: 'usr-003',
name: 'Ananya Patel',
email: 'ananya.p@yahoo.com',
phone: '7654321098',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=ananya',
role: 'User',
status: 'Pending Verification',
tier: 'Bronze',
healthScore: 'cold',
isVerified: false,
is2FAEnabled: false,
totalSpent: 0,
bookingsCount: 0,
refundRate: 0,
averageOrderValue: 0,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: false,
smsNotifications: false,
tags: [mockTags[6]], // New User
createdAt: '2026-02-08T09:00:00Z',
updatedAt: '2026-02-08T09:00:00Z',
lastLoginAt: '2026-02-08T09:00:00Z',
lastActivityAt: '2026-02-08T09:05:00Z',
lastDevice: { os: 'Windows 11', browser: 'Edge', ip: '122.161.52.19', location: 'Ahmedabad, IN' },
},
{
id: 'usr-004',
name: 'Vikram Singh',
email: 'vikram.s@proton.me',
phone: '6543210987',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=vikram',
role: 'User',
status: 'Suspended',
tier: 'Silver',
healthScore: 'cold',
isVerified: true,
is2FAEnabled: true,
totalSpent: 15000,
bookingsCount: 8,
refundRate: 25,
averageOrderValue: 1875,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: false,
pushNotifications: false,
smsNotifications: false,
tags: [mockTags[5]], // Problem User
pinnedNote: 'Suspended for multiple chargebacks. Review before reinstatement.',
createdAt: '2024-03-10T12:00:00Z',
updatedAt: '2026-02-01T09:30:00Z',
lastLoginAt: '2026-01-28T11:20:00Z',
lastActivityAt: '2026-01-28T11:45:00Z',
lastDevice: { os: 'macOS 14', browser: 'Chrome', ip: '157.33.48.92', location: 'Bangalore, IN' },
},
{
id: 'usr-005',
name: 'Meera Reddy',
email: 'meera.reddy@gmail.com',
phone: '9988776655',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=meera',
role: 'User',
status: 'Banned',
tier: 'Bronze',
healthScore: 'cold',
isVerified: true,
is2FAEnabled: false,
totalSpent: 2500,
bookingsCount: 2,
refundRate: 100,
averageOrderValue: 1250,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: false,
pushNotifications: false,
smsNotifications: false,
tags: [mockTags[5]], // Problem User
pinnedNote: 'BANNED: Fraud - Multiple stolen credit cards used',
createdAt: '2025-08-15T08:00:00Z',
updatedAt: '2025-10-20T14:00:00Z',
lastLoginAt: '2025-10-18T10:30:00Z',
lastActivityAt: '2025-10-18T10:35:00Z',
lastDevice: { os: 'Android 13', browser: 'Chrome', ip: '103.87.141.52', location: 'Hyderabad, IN' },
},
{
id: 'usr-006',
name: 'Arjun Nair',
email: 'arjun.nair@techcorp.com',
phone: '8899776655',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=arjun',
role: 'Partner',
status: 'Active',
tier: 'Gold',
healthScore: 'hot',
isVerified: true,
is2FAEnabled: true,
totalSpent: 250000,
bookingsCount: 60,
refundRate: 1.5,
averageOrderValue: 4167,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: true,
smsNotifications: true,
tags: [mockTags[0], mockTags[7]], // VIP, Corporate
pinnedNote: 'Corporate account - TechCorp bulk bookings',
createdAt: '2023-02-28T10:00:00Z',
updatedAt: '2026-02-09T08:00:00Z',
lastLoginAt: '2026-02-09T10:15:00Z',
lastActivityAt: '2026-02-09T10:45:00Z',
lastDevice: { os: 'Windows 11', browser: 'Chrome', ip: '14.139.42.15', location: 'Chennai, IN' },
},
{
id: 'usr-007',
name: 'Sneha Kapoor',
email: 'sneha.kapoor@instagram.fam',
phone: '7788996655',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=sneha',
role: 'User',
status: 'Active',
tier: 'Gold',
healthScore: 'hot',
isVerified: true,
is2FAEnabled: false,
totalSpent: 78000,
bookingsCount: 32,
refundRate: 3.1,
averageOrderValue: 2438,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: true,
smsNotifications: false,
tags: [mockTags[1], mockTags[0]], // Influencer, VIP
pinnedNote: 'Instagram influencer 500K followers. Comp tickets for fashion events.',
createdAt: '2023-09-05T14:30:00Z',
updatedAt: '2026-02-08T16:00:00Z',
lastLoginAt: '2026-02-09T07:30:00Z',
lastActivityAt: '2026-02-09T08:00:00Z',
lastDevice: { os: 'iOS 17', browser: 'Safari', ip: '103.219.75.33', location: 'Mumbai, IN' },
},
{
id: 'usr-008',
name: 'Karan Malhotra',
email: 'karan.m@gmail.com',
phone: '6677889944',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=karan',
role: 'User',
status: 'Active',
tier: 'Silver',
healthScore: 'warm',
isVerified: true,
is2FAEnabled: false,
totalSpent: 22000,
bookingsCount: 12,
refundRate: 8.3,
averageOrderValue: 1833,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: false,
smsNotifications: false,
tags: [],
createdAt: '2024-05-12T11:00:00Z',
updatedAt: '2026-02-04T15:30:00Z',
lastLoginAt: '2026-02-06T19:45:00Z',
lastActivityAt: '2026-02-06T20:10:00Z',
lastDevice: { os: 'Android 14', browser: 'Chrome', ip: '223.226.187.44', location: 'Pune, IN' },
},
{
id: 'usr-009',
name: 'Divya Iyer',
email: 'divya.iyer@journalism.in',
phone: '5566778899',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=divya',
role: 'User',
status: 'Active',
tier: 'Silver',
healthScore: 'warm',
isVerified: true,
is2FAEnabled: true,
totalSpent: 18500,
bookingsCount: 15,
refundRate: 0,
averageOrderValue: 1233,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: true,
smsNotifications: true,
tags: [mockTags[2]], // Press
pinnedNote: 'JOURNALIST - Times of India. Handle with care, provide press passes.',
createdAt: '2024-01-20T09:00:00Z',
updatedAt: '2026-02-07T12:00:00Z',
lastLoginAt: '2026-02-08T14:30:00Z',
lastActivityAt: '2026-02-08T15:00:00Z',
lastDevice: { os: 'macOS 14', browser: 'Safari', ip: '182.73.156.89', location: 'Mumbai, IN' },
},
{
id: 'usr-010',
name: 'Rohan Chatterjee',
email: 'rohan.c@yahoo.co.in',
phone: '4455667788',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=rohan',
role: 'User',
status: 'Archived',
tier: 'Bronze',
healthScore: 'cold',
isVerified: true,
is2FAEnabled: false,
totalSpent: 5000,
bookingsCount: 4,
refundRate: 25,
averageOrderValue: 1250,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: false,
pushNotifications: false,
smsNotifications: false,
tags: [],
createdAt: '2024-07-08T16:00:00Z',
updatedAt: '2025-12-15T10:00:00Z',
lastLoginAt: '2025-11-20T09:00:00Z',
lastActivityAt: '2025-11-20T09:30:00Z',
lastDevice: { os: 'Windows 10', browser: 'Firefox', ip: '117.197.45.88', location: 'Kolkata, IN' },
},
{
id: 'usr-011',
name: 'Aisha Khan',
email: 'aisha.khan@corporate.io',
phone: '9123456780',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=aisha',
role: 'Admin',
status: 'Active',
tier: 'Platinum',
healthScore: 'hot',
isVerified: true,
is2FAEnabled: true,
totalSpent: 0,
bookingsCount: 0,
refundRate: 0,
averageOrderValue: 0,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: true,
smsNotifications: true,
tags: [],
createdAt: '2022-06-01T08:00:00Z',
updatedAt: '2026-02-09T09:00:00Z',
lastLoginAt: '2026-02-09T09:00:00Z',
lastActivityAt: '2026-02-09T10:30:00Z',
lastDevice: { os: 'macOS 14', browser: 'Chrome', ip: '14.98.172.55', location: 'Delhi, IN' },
},
{
id: 'usr-012',
name: 'Nikhil Joshi',
email: 'nikhil.joshi@hotmail.com',
phone: '8234567891',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=nikhil',
role: 'User',
status: 'Active',
tier: 'Bronze',
healthScore: 'cold',
isVerified: true,
is2FAEnabled: false,
totalSpent: 3500,
bookingsCount: 3,
refundRate: 0,
averageOrderValue: 1167,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: false,
smsNotifications: false,
tags: [],
createdAt: '2025-03-15T14:00:00Z',
updatedAt: '2026-01-20T11:00:00Z',
lastLoginAt: '2026-01-15T18:00:00Z',
lastActivityAt: '2026-01-15T18:30:00Z',
lastDevice: { os: 'Android 13', browser: 'Chrome', ip: '203.122.45.67', location: 'Jaipur, IN' },
},
{
id: 'usr-013',
name: 'Pooja Menon',
email: 'pooja.menon@gmail.com',
phone: '7345678902',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=pooja',
role: 'User',
status: 'Active',
tier: 'Gold',
healthScore: 'hot',
isVerified: true,
is2FAEnabled: true,
totalSpent: 68000,
bookingsCount: 28,
refundRate: 3.6,
averageOrderValue: 2429,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: true,
smsNotifications: true,
tags: [mockTags[3], mockTags[4]], // Early Adopter, Big Spender
createdAt: '2023-04-10T10:00:00Z',
updatedAt: '2026-02-08T20:00:00Z',
lastLoginAt: '2026-02-09T06:30:00Z',
lastActivityAt: '2026-02-09T07:00:00Z',
lastDevice: { os: 'iOS 17', browser: 'Safari', ip: '59.94.178.23', location: 'Bangalore, IN' },
},
{
id: 'usr-014',
name: 'Amit Saxena',
email: 'amit.saxena@business.org',
phone: '6456789013',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=amit',
role: 'Support Agent',
status: 'Active',
tier: 'Silver',
healthScore: 'warm',
isVerified: true,
is2FAEnabled: true,
totalSpent: 0,
bookingsCount: 0,
refundRate: 0,
averageOrderValue: 0,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: true,
smsNotifications: false,
tags: [],
createdAt: '2024-02-01T09:00:00Z',
updatedAt: '2026-02-09T08:30:00Z',
lastLoginAt: '2026-02-09T08:30:00Z',
lastActivityAt: '2026-02-09T10:00:00Z',
lastDevice: { os: 'Windows 11', browser: 'Chrome', ip: '14.139.82.44', location: 'Gurgaon, IN' },
},
{
id: 'usr-015',
name: 'Kavitha Rajan',
email: 'kavitha.r@startup.co',
phone: '5567890124',
countryCode: '+91',
avatarUrl: 'https://i.pravatar.cc/150?u=kavitha',
role: 'User',
status: 'Active',
tier: 'Silver',
healthScore: 'warm',
isVerified: true,
is2FAEnabled: false,
totalSpent: 28000,
bookingsCount: 14,
refundRate: 7.1,
averageOrderValue: 2000,
language: 'en',
timezone: 'Asia/Kolkata',
currency: 'INR',
emailNotifications: true,
pushNotifications: true,
smsNotifications: false,
tags: [mockTags[7]], // Corporate
createdAt: '2024-08-20T12:00:00Z',
updatedAt: '2026-02-03T16:00:00Z',
lastLoginAt: '2026-02-05T11:00:00Z',
lastActivityAt: '2026-02-05T11:45:00Z',
lastDevice: { os: 'macOS 14', browser: 'Chrome', ip: '182.68.45.91', location: 'Chennai, IN' },
},
];
// ============================================
// BOOKINGS
// ============================================
export const mockBookings: UserBooking[] = [
{ id: 'book-001', userId: 'usr-001', eventId: 'evt-001', eventName: 'Tech Summit 2026', eventDate: '2026-03-15', ticketType: 'VIP Pass', quantity: 2, amount: 15000, status: 'Confirmed', createdAt: '2026-02-08T10:00:00Z' },
{ id: 'book-002', userId: 'usr-001', eventId: 'evt-002', eventName: 'Mumbai Music Festival', eventDate: '2026-02-20', ticketType: 'General Admission', quantity: 4, amount: 8000, status: 'Attended', createdAt: '2026-01-15T14:00:00Z' },
{ id: 'book-003', userId: 'usr-001', eventId: 'evt-003', eventName: 'Comedy Night Live', eventDate: '2026-01-28', ticketType: 'Premium', quantity: 2, amount: 5000, status: 'Attended', createdAt: '2026-01-10T09:00:00Z' },
{ id: 'book-004', userId: 'usr-002', eventId: 'evt-001', eventName: 'Tech Summit 2026', eventDate: '2026-03-15', ticketType: 'Standard', quantity: 1, amount: 5000, status: 'Confirmed', createdAt: '2026-02-05T11:30:00Z' },
{ id: 'book-005', userId: 'usr-002', eventId: 'evt-004', eventName: 'Startup Mixer', eventDate: '2026-01-20', ticketType: 'Entry', quantity: 1, amount: 1500, status: 'No-Show', createdAt: '2026-01-12T16:00:00Z' },
{ id: 'book-006', userId: 'usr-007', eventId: 'evt-005', eventName: 'Fashion Week Preview', eventDate: '2026-02-25', ticketType: 'Front Row', quantity: 1, amount: 25000, status: 'Confirmed', createdAt: '2026-02-01T10:00:00Z' },
];
// ============================================
// SESSIONS
// ============================================
export const mockSessions: UserSession[] = [
{ id: 'sess-001', userId: 'usr-001', device: { os: 'iOS 17', browser: 'Safari', ip: '103.42.156.78', location: 'Mumbai, IN' }, isCurrentSession: true, createdAt: '2026-02-09T08:45:00Z', lastActiveAt: '2026-02-09T09:30:00Z' },
{ id: 'sess-002', userId: 'usr-001', device: { os: 'macOS 14', browser: 'Chrome', ip: '103.42.156.80', location: 'Mumbai, IN' }, isCurrentSession: false, createdAt: '2026-02-07T10:00:00Z', lastActiveAt: '2026-02-07T18:00:00Z' },
{ id: 'sess-003', userId: 'usr-001', device: { os: 'Windows 11', browser: 'Edge', ip: '14.139.42.15', location: 'Office - Delhi, IN' }, isCurrentSession: false, createdAt: '2026-02-05T09:00:00Z', lastActiveAt: '2026-02-05T17:30:00Z' },
];
// ============================================
// SUSPICIOUS ACTIVITY
// ============================================
export const mockSuspiciousActivity: SuspiciousActivity[] = [
{ id: 'susp-001', userId: 'usr-004', type: 'failed_login', description: '5 failed login attempts in 10 minutes', severity: 'high', timestamp: '2026-01-28T11:15:00Z', resolved: false },
{ id: 'susp-002', userId: 'usr-005', type: 'geolocation_jump', description: 'Login from Mumbai then London within 1 hour', severity: 'high', timestamp: '2025-10-17T14:00:00Z', resolved: true },
{ id: 'susp-003', userId: 'usr-002', type: 'multiple_devices', description: '3 new devices logged in within 24 hours', severity: 'medium', timestamp: '2026-02-06T08:00:00Z', resolved: false },
];
// ============================================
// SUPPORT TICKETS
// ============================================
export const mockTickets: SupportTicket[] = [
{
id: 'tkt-001',
userId: 'usr-001',
subject: 'VIP Pass upgrade not reflecting',
type: 'Technical',
priority: 'High',
status: 'In Progress',
assignedTo: 'usr-014',
assignedToName: 'Amit Saxena',
messages: [
{ id: 'msg-001', ticketId: 'tkt-001', authorId: 'usr-001', authorName: 'Priya Sharma', authorType: 'user', content: 'I upgraded to VIP but my app still shows General Admission.', createdAt: '2026-02-08T15:00:00Z' },
{ id: 'msg-002', ticketId: 'tkt-001', authorId: 'usr-014', authorName: 'Amit Saxena', authorType: 'agent', content: 'Hi Priya, I can see the upgrade. Please try logging out and back in. It should reflect now.', createdAt: '2026-02-08T15:30:00Z' },
],
createdAt: '2026-02-08T15:00:00Z',
updatedAt: '2026-02-08T15:30:00Z',
},
{
id: 'tkt-002',
userId: 'usr-002',
subject: 'Refund request for Startup Mixer',
type: 'Refund',
priority: 'Normal',
status: 'Waiting on User',
assignedTo: 'usr-014',
assignedToName: 'Amit Saxena',
messages: [
{ id: 'msg-003', ticketId: 'tkt-002', authorId: 'usr-002', authorName: 'Rahul Verma', authorType: 'user', content: 'I couldn\'t attend due to work emergency. Can I get a refund?', createdAt: '2026-01-22T10:00:00Z' },
{ id: 'msg-004', ticketId: 'tkt-002', authorId: 'usr-014', authorName: 'Amit Saxena', authorType: 'agent', content: 'We can offer a 50% refund or full credit for a future event. Which would you prefer?', createdAt: '2026-01-22T14:00:00Z' },
],
createdAt: '2026-01-22T10:00:00Z',
updatedAt: '2026-01-22T14:00:00Z',
},
];
// ============================================
// USER NOTES
// ============================================
export const mockNotes: UserNote[] = [
{ id: 'note-001', userId: 'usr-001', authorId: 'usr-011', authorName: 'Aisha Khan', content: 'Top customer - Priority support always. Birthday: March 15.', isPinned: true, createdAt: '2023-06-10T10:00:00Z', updatedAt: '2024-01-15T14:00:00Z' },
{ id: 'note-002', userId: 'usr-001', authorId: 'usr-014', authorName: 'Amit Saxena', content: 'Prefers WhatsApp for communication. Respond within 2 hours.', isPinned: false, createdAt: '2025-08-20T11:00:00Z', updatedAt: '2025-08-20T11:00:00Z' },
{ id: 'note-003', userId: 'usr-007', authorId: 'usr-011', authorName: 'Aisha Khan', content: 'Instagram influencer 500K followers. Comp tickets for fashion events. @snehakapoor', isPinned: true, createdAt: '2024-02-14T09:00:00Z', updatedAt: '2024-02-14T09:00:00Z' },
{ id: 'note-004', userId: 'usr-009', authorId: 'usr-011', authorName: 'Aisha Khan', content: 'JOURNALIST - Times of India. Handle with care, provide press passes. Article contact: editor@toi.com', isPinned: true, createdAt: '2024-03-01T10:00:00Z', updatedAt: '2024-03-01T10:00:00Z' },
{ id: 'note-005', userId: 'usr-004', authorId: 'usr-011', authorName: 'Aisha Khan', content: 'Suspended for multiple chargebacks. 3 chargebacks in 2 months. Review before reinstatement. Evidence: #CHG-2026-001, #CHG-2026-002, #CHG-2026-003', isPinned: true, createdAt: '2026-02-01T09:30:00Z', updatedAt: '2026-02-01T09:30:00Z' },
];
// ============================================
// EMAIL LOGS
// ============================================
export const mockEmailLogs: EmailLog[] = [
{ id: 'email-001', userId: 'usr-001', templateName: 'Welcome Email', subject: 'Welcome to Eventify!', sentAt: '2023-01-15T10:35:00Z', status: 'Delivered' },
{ id: 'email-002', userId: 'usr-001', templateName: 'Booking Confirmation', subject: 'Your tickets for Tech Summit 2026', sentAt: '2026-02-08T10:05:00Z', status: 'Delivered' },
{ id: 'email-003', userId: 'usr-002', templateName: 'Booking Confirmation', subject: 'Your tickets for Tech Summit 2026', sentAt: '2026-02-05T11:35:00Z', status: 'Delivered' },
{ id: 'email-004', userId: 'usr-003', templateName: 'Email Verification', subject: 'Verify your email address', sentAt: '2026-02-08T09:05:00Z', status: 'Sent' },
{ id: 'email-005', userId: 'usr-004', templateName: 'Account Suspended', subject: 'Your account has been suspended', sentAt: '2026-02-01T09:35:00Z', status: 'Delivered' },
];
// ============================================
// NOTIFICATIONS
// ============================================
export const mockNotifications: Notification[] = [
{ id: 'notif-001', userId: 'usr-001', title: 'VIP Early Access', body: 'Get exclusive early bird tickets for Tech Summit 2026!', actionUrl: '/events/tech-summit-2026', priority: 'high', status: 'Clicked', sentAt: '2026-01-20T10:00:00Z', deliveredAt: '2026-01-20T10:00:30Z', clickedAt: '2026-01-20T10:05:00Z', createdAt: '2026-01-20T09:55:00Z', createdBy: 'usr-011' },
{ id: 'notif-002', userId: 'usr-001', title: 'Event Reminder', body: 'Mumbai Music Festival starts tomorrow!', actionUrl: '/events/mumbai-music-festival', priority: 'normal', status: 'Delivered', sentAt: '2026-02-19T10:00:00Z', deliveredAt: '2026-02-19T10:00:15Z', createdAt: '2026-02-19T09:55:00Z', createdBy: 'system' },
{ id: 'notif-003', userId: 'usr-007', title: 'Special Invite', body: 'You\'re invited to Fashion Week Preview - Front Row!', actionUrl: '/events/fashion-week-2026', priority: 'high', status: 'Clicked', sentAt: '2026-01-25T14:00:00Z', deliveredAt: '2026-01-25T14:00:20Z', clickedAt: '2026-01-25T14:10:00Z', createdAt: '2026-01-25T13:55:00Z', createdBy: 'usr-011' },
];
// ============================================
// AUDIT LOGS
// ============================================
export const mockAuditLogs: AuditLog[] = [
{ id: 'audit-001', actorId: 'usr-011', actorName: 'Aisha Khan', actorRole: 'Admin', action: 'user_created', targetUserId: 'usr-003', targetUserName: 'Ananya Patel', ipAddress: '14.98.172.55', timestamp: '2026-02-08T09:00:00Z' },
{ id: 'audit-002', actorId: 'usr-011', actorName: 'Aisha Khan', actorRole: 'Admin', action: 'suspended', targetUserId: 'usr-004', targetUserName: 'Vikram Singh', changes: { before: { status: 'Active' }, after: { status: 'Suspended' } }, metadata: { reason: 'Payment Issue', note: 'Multiple chargebacks' }, ipAddress: '14.98.172.55', timestamp: '2026-02-01T09:30:00Z' },
{ id: 'audit-003', actorId: 'usr-011', actorName: 'Aisha Khan', actorRole: 'Admin', action: 'banned', targetUserId: 'usr-005', targetUserName: 'Meera Reddy', changes: { before: { status: 'Suspended' }, after: { status: 'Banned' } }, metadata: { banTypes: ['ip_ban', 'email_blacklist'], reason: 'Fraud - stolen credit cards' }, ipAddress: '14.98.172.55', timestamp: '2025-10-20T14:00:00Z' },
{ id: 'audit-004', actorId: 'usr-014', actorName: 'Amit Saxena', actorRole: 'Support Agent', action: 'note_added', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { noteId: 'note-002' }, ipAddress: '14.139.82.44', timestamp: '2025-08-20T11:00:00Z' },
{ id: 'audit-005', actorId: 'usr-011', actorName: 'Aisha Khan', actorRole: 'Admin', action: 'tag_added', targetUserId: 'usr-007', targetUserName: 'Sneha Kapoor', changes: { before: { tags: ['VIP'] }, after: { tags: ['VIP', 'Influencer'] } }, ipAddress: '14.98.172.55', timestamp: '2024-02-14T09:00:00Z' },
{ id: 'audit-006', actorId: 'usr-011', actorName: 'Aisha Khan', actorRole: 'Admin', action: 'notification_sent', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { notificationId: 'notif-001', title: 'VIP Early Access' }, ipAddress: '14.98.172.55', timestamp: '2026-01-20T09:55:00Z' },
];
// ============================================
// TIMELINE EVENTS
// ============================================
export const mockTimeline: TimelineEvent[] = [
{ id: 'tl-001', userId: 'usr-001', type: 'booking', title: 'Booked Tech Summit 2026', description: '2x VIP Pass - ₹15,000', timestamp: '2026-02-08T10:00:00Z' },
{ id: 'tl-002', userId: 'usr-001', type: 'notification_sent', title: 'Push Notification Sent', description: 'VIP Early Access campaign', timestamp: '2026-01-20T10:00:00Z' },
{ id: 'tl-003', userId: 'usr-001', type: 'booking', title: 'Attended Mumbai Music Festival', description: '4x General Admission - ₹8,000', timestamp: '2026-02-20T18:00:00Z' },
{ id: 'tl-004', userId: 'usr-001', type: 'support_ticket', title: 'Support Ticket Created', description: 'VIP Pass upgrade not reflecting', timestamp: '2026-02-08T15:00:00Z' },
{ id: 'tl-005', userId: 'usr-001', type: 'login', title: 'Logged In', description: 'iOS 17, Safari - Mumbai, IN', timestamp: '2026-02-09T08:45:00Z' },
];
// ============================================
// SEGMENTS
// ============================================
export const mockSegments: UserSegment[] = [
{ id: 'seg-001', name: 'VIP Customers', description: 'Users who spent more than ₹50,000', filters: { minSpent: 50000 }, userCount: 4, createdAt: '2024-01-01T10:00:00Z', createdBy: 'usr-011' },
{ id: 'seg-002', name: 'At Risk', description: 'Haven\'t logged in for 90+ days', filters: { lastActiveBefore: '2025-11-09T00:00:00Z' }, userCount: 2, createdAt: '2024-06-15T14:00:00Z', createdBy: 'usr-011' },
{ id: 'seg-003', name: 'Influencers', description: 'Users tagged as Influencer or Press', filters: { tags: ['tag-influencer', 'tag-press'] }, userCount: 2, createdAt: '2024-03-01T10:00:00Z', createdBy: 'usr-011' },
{ id: 'seg-004', name: 'New This Week', description: 'Users who joined in the last 7 days', filters: { lastActiveAfter: '2026-02-02T00:00:00Z' }, userCount: 1, createdAt: '2026-02-01T10:00:00Z', createdBy: 'usr-011' },
];
// ============================================
// NOTIFICATION TEMPLATES
// ============================================
export const mockNotificationTemplates: NotificationTemplate[] = [
{ id: 'tpl-001', name: 'Birthday Greeting', title: '🎂 Happy Birthday!', body: 'Wishing you an amazing birthday! Here\'s a special 20% discount on your next booking.', actionUrl: '/profile/offers' },
{ id: 'tpl-002', name: 'Event Reminder', title: '🎫 Event Tomorrow!', body: '{{event_name}} starts tomorrow. Don\'t forget your tickets!', actionUrl: '/my-tickets' },
{ id: 'tpl-003', name: 'VIP Upgrade Offer', title: '⭐ Upgrade to VIP', body: 'Exclusive offer: Upgrade to VIP for just ₹2,000 more and get front-row access!', actionUrl: '/upgrade' },
{ id: 'tpl-004', name: 'Win-Back Campaign', title: '👋 We Miss You!', body: 'It\'s been a while! Check out the amazing events happening near you.', actionUrl: '/events' },
];
// ============================================
// METRICS
// ============================================
export const mockUserMetrics: UserMetrics = {
totalUsers: 15420,
totalUsersTrend: 5.2,
activeUsers: 8750,
activeUsersTrend: 3.8,
newToday: 47,
newTodayTrend: 12.5,
suspendedCount: 23,
bannedCount: 8,
averageLifetimeValue: 4250,
averageLifetimeValueTrend: 7.1,
};
// ============================================
// HELPER FUNCTIONS
// ============================================
export function getUserById(id: string): User | undefined {
return mockCrmUsers.find(u => u.id === id);
}
export function getUserBookings(userId: string): UserBooking[] {
return mockBookings.filter(b => b.userId === userId);
}
export function getUserSessions(userId: string): UserSession[] {
return mockSessions.filter(s => s.userId === userId);
}
export function getUserTickets(userId: string): SupportTicket[] {
return mockTickets.filter(t => t.userId === userId);
}
export function getUserNotes(userId: string): UserNote[] {
return mockNotes.filter(n => n.userId === userId);
}
export function getUserEmailLogs(userId: string): EmailLog[] {
return mockEmailLogs.filter(e => e.userId === userId);
}
export function getUserNotifications(userId: string): Notification[] {
return mockNotifications.filter(n => n.userId === userId);
}
export function getUserAuditLogs(userId: string): AuditLog[] {
return mockAuditLogs.filter(a => a.targetUserId === userId);
}
export function getUserTimeline(userId: string): TimelineEvent[] {
return mockTimeline.filter(t => t.userId === userId);
}
export function getUserSuspiciousActivity(userId: string): SuspiciousActivity[] {
return mockSuspiciousActivity.filter(a => a.userId === userId);
}

View File

@@ -0,0 +1,146 @@
'use server';
import { z } from 'zod';
import { logAdminAction } from '@/lib/audit-logger';
// --- Validation Schemas ---
const suspendSchema = z.object({
userId: z.string(),
reason: z.string().min(10, "Reason must be at least 10 characters"),
duration: z.enum(['24h', '7d', '30d', 'indefinite']),
});
const banSchema = z.object({
userId: z.string(),
reason: z.string().min(10),
});
const ticketSchema = z.object({
userId: z.string(),
summary: z.string().min(5),
});
// --- Authorization Helper ---
async function verifyAdmin() {
// TODO: Integrate with Auth.js session check
// const session = await auth();
// if (session?.user?.role !== 'admin') throw new Error("Unauthorized");
// Mock successful admin check
return { id: 'admin-1', role: 'admin' };
}
// --- Server Actions ---
export async function resetPassword(userId: string) {
try {
const admin = await verifyAdmin();
// Simulate DB call
await new Promise(resolve => setTimeout(resolve, 800));
await logAdminAction({
actorId: admin.id,
action: 'RESET_PASSWORD',
targetId: userId,
});
return { success: true, message: "Recovery email sent to user." };
} catch (error) {
return { success: false, message: "Failed to reset password." };
}
}
export async function impersonateUser(userId: string) {
try {
const admin = await verifyAdmin();
await logAdminAction({
actorId: admin.id,
action: 'IMPERSONATE_USER',
targetId: userId,
});
// In a real app, you'd set a cookie here
// cookies().set('impersonate_id', userId);
return { success: true, redirectUrl: '/dashboard' };
} catch (error) {
return { success: false, message: "Failed to start impersonation session." };
}
}
export async function terminateSessions(userId: string) {
try {
const admin = await verifyAdmin();
// Simulate DB call to delete sessions
await new Promise(resolve => setTimeout(resolve, 500));
await logAdminAction({
actorId: admin.id,
action: 'TERMINATE_SESSIONS',
targetId: userId,
});
return { success: true, message: "All active sessions terminated." };
} catch (error) {
return { success: false, message: "Failed to terminate sessions." };
}
}
export async function toggleSuspension(formData: FormData) {
try {
const admin = await verifyAdmin();
const rawData = {
userId: formData.get('userId'),
reason: formData.get('reason'),
duration: formData.get('duration'),
};
const validated = suspendSchema.safeParse(rawData);
if (!validated.success) {
return { success: false, message: validated.error.errors[0].message };
}
// Simulate DB update
await new Promise(resolve => setTimeout(resolve, 1000));
await logAdminAction({
actorId: admin.id,
action: 'SUSPEND_USER',
targetId: validated.data.userId,
details: { reason: validated.data.reason, duration: validated.data.duration },
});
return { success: true, message: `User suspended for ${validated.data.duration}.` };
} catch (error) {
return { success: false, message: "Failed to suspend user." };
}
}
export async function createSupportTicket(userId: string, summary: string) {
try {
const admin = await verifyAdmin();
const validated = ticketSchema.safeParse({ userId, summary });
if (!validated.success) return { success: false, message: "Invalid input" };
// Simulate DB insert
await new Promise(resolve => setTimeout(resolve, 600));
await logAdminAction({
actorId: admin.id,
action: 'CREATE_TICKET',
targetId: userId,
details: { summary },
});
return { success: true, message: "Support ticket #T-1234 created." };
} catch (error) {
return { success: false, message: "Failed to create ticket." };
}
}

26
src/lib/audit-logger.ts Normal file
View File

@@ -0,0 +1,26 @@
export interface AuditLogEntry {
actorId: string;
action: string;
targetId: string;
details?: Record<string, any>;
timestamp: Date;
}
/**
* Logs an administrative action to the audit/console.
* In a real app, this would write to a database table or external logging service.
*/
export async function logAdminAction(entry: Omit<AuditLogEntry, 'timestamp'>) {
const logEntry: AuditLogEntry = {
...entry,
timestamp: new Date(),
};
// Simulate database latency
// await new Promise(resolve => setTimeout(resolve, 100));
console.log(`[AUDIT] Admin ${entry.actorId} performed ${entry.action} on ${entry.targetId}`, entry.details || '');
// TODO: Connect to real audit log database
return { success: true };
}

365
src/lib/types/user.ts Normal file
View File

@@ -0,0 +1,365 @@
// User Command Center - Complete Type Definitions
// All types for CRM, moderation, notifications, and support
// ============================================
// CORE USER ENTITY
// ============================================
export type UserStatus = 'Active' | 'Pending Verification' | 'Suspended' | 'Banned' | 'Archived';
export type UserRole = 'Super Admin' | 'Admin' | 'Support Agent' | 'Partner' | 'User';
export type UserTier = 'Platinum' | 'Gold' | 'Silver' | 'Bronze';
export type UserHealthScore = 'hot' | 'warm' | 'cold';
export interface User {
id: string;
name: string;
email: string;
phone: string;
countryCode: string;
avatarUrl?: string;
// Role & Status
role: UserRole;
status: UserStatus;
tier: UserTier;
healthScore: UserHealthScore;
isVerified: boolean;
is2FAEnabled: boolean;
// Metrics
totalSpent: number;
bookingsCount: number;
refundRate: number;
averageOrderValue: number;
// Preferences
language: string;
timezone: string;
currency: string;
// Notification Preferences
emailNotifications: boolean;
pushNotifications: boolean;
smsNotifications: boolean;
// Tags & Notes
tags: UserTag[];
pinnedNote?: string;
// Timestamps
createdAt: string;
updatedAt: string;
lastLoginAt: string;
lastActivityAt: string;
// Device Info (from last login)
lastDevice?: DeviceInfo;
}
export interface DeviceInfo {
os: string;
browser: string;
ip: string;
location: string;
}
// ============================================
// TAGS & SEGMENTATION
// ============================================
export interface UserTag {
id: string;
name: string;
color: string; // Tailwind color class
}
export interface UserSegment {
id: string;
name: string;
description?: string;
filters: SegmentFilters;
userCount: number;
createdAt: string;
createdBy: string;
}
export interface SegmentFilters {
search?: string;
status?: UserStatus[];
role?: UserRole[];
tier?: UserTier[];
tags?: string[];
minSpent?: number;
maxSpent?: number;
minBookings?: number;
maxBookings?: number;
lastActiveAfter?: string;
lastActiveBefore?: string;
}
// ============================================
// BOOKINGS & TRANSACTIONS
// ============================================
export type BookingStatus = 'Confirmed' | 'Attended' | 'No-Show' | 'Cancelled' | 'Refunded';
export interface UserBooking {
id: string;
userId: string;
eventId: string;
eventName: string;
eventDate: string;
ticketType: string;
quantity: number;
amount: number;
status: BookingStatus;
createdAt: string;
}
// ============================================
// SECURITY & SESSIONS
// ============================================
export interface UserSession {
id: string;
userId: string;
device: DeviceInfo;
isCurrentSession: boolean;
createdAt: string;
lastActiveAt: string;
}
export interface SuspiciousActivity {
id: string;
userId: string;
type: 'failed_login' | 'geolocation_jump' | 'multiple_devices' | 'password_change';
description: string;
severity: 'low' | 'medium' | 'high';
timestamp: string;
resolved: boolean;
}
// ============================================
// SUPPORT & COMMUNICATION
// ============================================
export type TicketStatus = 'Open' | 'In Progress' | 'Waiting on User' | 'Resolved' | 'Closed';
export type TicketPriority = 'Low' | 'Normal' | 'High' | 'Urgent';
export type TicketType = 'Refund' | 'Technical' | 'Inquiry' | 'Complaint' | 'Feedback';
export interface SupportTicket {
id: string;
userId: string;
subject: string;
type: TicketType;
priority: TicketPriority;
status: TicketStatus;
assignedTo?: string;
assignedToName?: string;
messages: TicketMessage[];
createdAt: string;
updatedAt: string;
resolvedAt?: string;
}
export interface TicketMessage {
id: string;
ticketId: string;
authorId: string;
authorName: string;
authorType: 'user' | 'agent';
content: string;
createdAt: string;
}
export interface UserNote {
id: string;
userId: string;
authorId: string;
authorName: string;
content: string;
isPinned: boolean;
createdAt: string;
updatedAt: string;
}
export interface EmailLog {
id: string;
userId: string;
templateName: string;
subject: string;
sentAt: string;
status: 'Sent' | 'Delivered' | 'Bounced' | 'Failed';
}
// ============================================
// NOTIFICATIONS
// ============================================
export type NotificationPriority = 'low' | 'normal' | 'high';
export type NotificationStatus = 'Pending' | 'Sent' | 'Delivered' | 'Failed' | 'Clicked';
export interface Notification {
id: string;
userId: string;
title: string;
body: string;
actionUrl?: string;
priority: NotificationPriority;
status: NotificationStatus;
scheduledFor?: string;
sentAt?: string;
deliveredAt?: string;
clickedAt?: string;
createdAt: string;
createdBy: string;
}
export interface NotificationTemplate {
id: string;
name: string;
title: string;
body: string;
actionUrl?: string;
}
// ============================================
// AUDIT LOGGING
// ============================================
export type AuditAction =
| 'user_created'
| 'user_updated'
| 'user_deleted'
| 'user_archived'
| 'status_changed'
| 'role_changed'
| 'tag_added'
| 'tag_removed'
| 'note_added'
| 'note_updated'
| 'note_deleted'
| 'password_reset'
| '2fa_enabled'
| '2fa_disabled'
| '2fa_reset'
| 'session_terminated'
| 'suspended'
| 'suspension_lifted'
| 'banned'
| 'notification_sent'
| 'ticket_created'
| 'ticket_assigned'
| 'ticket_resolved'
| 'data_exported'
| 'data_anonymized';
export interface AuditLog {
id: string;
actorId: string;
actorName: string;
actorRole: UserRole;
action: AuditAction;
targetUserId: string;
targetUserName: string;
changes?: {
before: Record<string, unknown>;
after: Record<string, unknown>;
};
metadata?: Record<string, unknown>;
ipAddress: string;
timestamp: string;
}
// ============================================
// MODERATION
// ============================================
export type SuspensionReason = 'Payment Issue' | 'Policy Violation' | 'Spam' | 'Abuse' | 'Other';
export type SuspensionDuration = '7_days' | '30_days' | '90_days' | 'permanent';
export type BanType = 'ip_ban' | 'device_ban' | 'email_blacklist';
export interface Suspension {
userId: string;
reason: SuspensionReason;
customNote?: string;
duration: SuspensionDuration;
sendEmail: boolean;
suspendedAt: string;
suspendedBy: string;
expiresAt?: string;
liftedAt?: string;
liftedBy?: string;
liftNote?: string;
}
export interface Ban {
userId: string;
banTypes: BanType[];
publicReason: string;
internalEvidence?: string;
bannedAt: string;
bannedBy: string;
}
// ============================================
// METRICS & ANALYTICS
// ============================================
export interface UserMetrics {
totalUsers: number;
totalUsersTrend: number; // percentage change
activeUsers: number; // logged in last 30 days
activeUsersTrend: number;
newToday: number;
newTodayTrend: number;
suspendedCount: number;
bannedCount: number;
averageLifetimeValue: number;
averageLifetimeValueTrend: number;
}
// ============================================
// BULK OPERATIONS
// ============================================
export type BulkAction =
| 'change_status'
| 'assign_role'
| 'add_tag'
| 'remove_tag'
| 'send_notification'
| 'export_csv'
| 'delete';
export interface BulkActionResult {
action: BulkAction;
totalSelected: number;
successCount: number;
failureCount: number;
errors?: { userId: string; error: string }[];
}
// ============================================
// INTERACTION TIMELINE
// ============================================
export type TimelineEventType =
| 'booking'
| 'login'
| 'support_ticket'
| 'email_sent'
| 'note_added'
| 'status_changed'
| 'notification_sent'
| 'payment';
export interface TimelineEvent {
id: string;
userId: string;
type: TimelineEventType;
title: string;
description: string;
metadata?: Record<string, unknown>;
timestamp: string;
}

274
src/lib/validations/user.ts Normal file
View File

@@ -0,0 +1,274 @@
// User Command Center - Zod Validation Schemas
import { z } from 'zod';
// ============================================
// ENUMS AS ZOD SCHEMAS
// ============================================
export const userStatusSchema = z.enum([
'Active',
'Pending Verification',
'Suspended',
'Banned',
'Archived'
]);
export const userRoleSchema = z.enum([
'Super Admin',
'Admin',
'Support Agent',
'Partner',
'User'
]);
export const userTierSchema = z.enum(['Platinum', 'Gold', 'Silver', 'Bronze']);
export const ticketTypeSchema = z.enum([
'Refund',
'Technical',
'Inquiry',
'Complaint',
'Feedback'
]);
export const ticketPrioritySchema = z.enum(['Low', 'Normal', 'High', 'Urgent']);
export const notificationPrioritySchema = z.enum(['low', 'normal', 'high']);
export const suspensionReasonSchema = z.enum([
'Payment Issue',
'Policy Violation',
'Spam',
'Abuse',
'Other'
]);
export const suspensionDurationSchema = z.enum([
'7_days',
'30_days',
'90_days',
'permanent'
]);
export const banTypeSchema = z.enum(['ip_ban', 'device_ban', 'email_blacklist']);
// ============================================
// CREATE USER SCHEMA
// ============================================
export const createUserSchema = z.object({
// Basic Info
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters'),
email: z
.string()
.email('Invalid email address')
.min(1, 'Email is required'),
phone: z
.string()
.min(10, 'Phone number must be at least 10 digits')
.max(15, 'Phone number must be less than 15 digits')
.regex(/^[0-9+\-\s()]+$/, 'Invalid phone number format'),
countryCode: z.string().min(1, 'Country code is required').default('+91'),
// Security
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Password must contain uppercase, lowercase, and number'
)
.optional(),
role: userRoleSchema.default('User'),
// Preferences
language: z.string().default('en'),
timezone: z.string().default('Asia/Kolkata'),
currency: z.string().default('INR'),
// Tags (array of tag IDs)
tagIds: z.array(z.string()).optional().default([]),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
// ============================================
// UPDATE USER SCHEMA
// ============================================
export const updateUserSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters')
.optional(),
phone: z
.string()
.min(10, 'Phone number must be at least 10 digits')
.max(15, 'Phone number must be less than 15 digits')
.regex(/^[0-9+\-\s()]+$/, 'Invalid phone number format')
.optional(),
countryCode: z.string().optional(),
avatarUrl: z.string().url('Invalid URL').optional().nullable(),
// Role & Status (requires elevated permissions)
role: userRoleSchema.optional(),
status: userStatusSchema.optional(),
tier: userTierSchema.optional(),
// Security
is2FAEnabled: z.boolean().optional(),
// Preferences
language: z.string().optional(),
timezone: z.string().optional(),
currency: z.string().optional(),
// Notification Preferences
emailNotifications: z.boolean().optional(),
pushNotifications: z.boolean().optional(),
smsNotifications: z.boolean().optional(),
});
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
// ============================================
// SUSPEND USER SCHEMA
// ============================================
export const suspendUserSchema = z.object({
reason: suspensionReasonSchema,
customNote: z
.string()
.max(500, 'Note must be less than 500 characters')
.optional(),
duration: suspensionDurationSchema,
sendEmail: z.boolean().default(true),
});
export type SuspendUserInput = z.infer<typeof suspendUserSchema>;
// ============================================
// BAN USER SCHEMA
// ============================================
export const banUserSchema = z.object({
banTypes: z
.array(banTypeSchema)
.min(1, 'Select at least one ban type'),
publicReason: z
.string()
.min(10, 'Reason must be at least 10 characters')
.max(200, 'Reason must be less than 200 characters'),
internalEvidence: z.string().optional(),
});
export type BanUserInput = z.infer<typeof banUserSchema>;
// ============================================
// NOTIFICATION SCHEMA
// ============================================
export const notificationSchema = z.object({
title: z
.string()
.min(3, 'Title must be at least 3 characters')
.max(100, 'Title must be less than 100 characters'),
body: z
.string()
.min(10, 'Body must be at least 10 characters')
.max(500, 'Body must be less than 500 characters'),
actionUrl: z
.string()
.url('Invalid URL')
.optional()
.or(z.literal('')),
priority: notificationPrioritySchema.default('normal'),
scheduleFor: z.string().datetime().optional(),
});
export type NotificationInput = z.infer<typeof notificationSchema>;
// ============================================
// SUPPORT TICKET SCHEMA
// ============================================
export const createTicketSchema = z.object({
subject: z
.string()
.min(5, 'Subject must be at least 5 characters')
.max(200, 'Subject must be less than 200 characters'),
type: ticketTypeSchema,
priority: ticketPrioritySchema.default('Normal'),
initialMessage: z
.string()
.min(10, 'Message must be at least 10 characters')
.max(2000, 'Message must be less than 2000 characters')
.optional(),
});
export type CreateTicketInput = z.infer<typeof createTicketSchema>;
// ============================================
// USER NOTE SCHEMA
// ============================================
export const userNoteSchema = z.object({
content: z
.string()
.min(1, 'Note cannot be empty')
.max(2000, 'Note must be less than 2000 characters'),
isPinned: z.boolean().default(false),
});
export type UserNoteInput = z.infer<typeof userNoteSchema>;
// ============================================
// FILTER SCHEMA
// ============================================
export const userFiltersSchema = z.object({
search: z.string().optional(),
status: z.array(userStatusSchema).optional(),
role: z.array(userRoleSchema).optional(),
tier: z.array(userTierSchema).optional(),
tags: z.array(z.string()).optional(),
minSpent: z.number().min(0).optional(),
maxSpent: z.number().min(0).optional(),
minBookings: z.number().min(0).optional(),
maxBookings: z.number().min(0).optional(),
lastActiveAfter: z.string().optional(),
lastActiveBefore: z.string().optional(),
});
export type UserFiltersInput = z.infer<typeof userFiltersSchema>;
// ============================================
// SEGMENT SCHEMA
// ============================================
export const createSegmentSchema = z.object({
name: z
.string()
.min(3, 'Name must be at least 3 characters')
.max(50, 'Name must be less than 50 characters'),
description: z.string().max(200).optional(),
filters: userFiltersSchema,
});
export type CreateSegmentInput = z.infer<typeof createSegmentSchema>;
// ============================================
// DELETE CONFIRMATION SCHEMA
// ============================================
export const deleteConfirmSchema = z.object({
confirmText: z.literal('DELETE', {
errorMap: () => ({ message: 'Type DELETE to confirm' }),
}),
});
export type DeleteConfirmInput = z.infer<typeof deleteConfirmSchema>;

View File

@@ -1,86 +1,436 @@
// Users.tsx - User Command Center - Complete rewrite
import { useState, useMemo } from 'react';
import { useQueryState, parseAsString, parseAsInteger, parseAsArrayOf } from 'nuqs';
import { AppLayout } from '@/components/layout/AppLayout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { TooltipProvider } from '@/components/ui/tooltip';
import {
Search,
Plus,
LayoutGrid,
LayoutList,
RefreshCw,
Download,
Filter,
} from 'lucide-react';
import {
Shield,
Users,
BriefcaseBusiness,
Settings2,
ListFilter
} from "lucide-react";
import { AppLayout } from "@/components/layout/AppLayout";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { InternalTeamTab } from "@/features/users/components/InternalTeamTab";
import { PartnerAdminTab } from "@/features/users/components/PartnerAdminTab";
import { UserBaseTab } from "@/features/users/components/UserBaseTab";
import { RoleMatrix } from "@/features/users/components/RoleMatrix";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
// Components
import { UsersTable } from '@/features/users/components/UsersTable';
import { UserFilters } from '@/features/users/components/UserFilters';
import { UserMetricsBar } from '@/features/users/components/UserMetricsBar';
import { UserInspectorSheet } from '@/features/users/components/UserInspectorSheet';
import { CreateUserDialog } from '@/features/users/components/CreateUserDialog';
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';
// Data & Types
import { mockCrmUsers, mockUserMetrics } from '@/features/users/data/mockUserCrmData';
import type { User, SegmentFilters } from '@/lib/types/user';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type ViewMode = 'table' | 'grid';
export default function Users() {
// URL State with nuqs
const [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault(''));
const [minSpent, setMinSpent] = useQueryState('minSpent', parseAsInteger);
const [maxSpent, setMaxSpent] = useQueryState('maxSpent', parseAsInteger);
// Local State
const [viewMode, setViewMode] = useState<ViewMode>('table');
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<SegmentFilters>({});
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
// Modal States
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [suspensionModalOpen, setSuspensionModalOpen] = useState(false);
const [banModalOpen, setBanModalOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [notificationComposerOpen, setNotificationComposerOpen] = useState(false);
const [notificationTargetUsers, setNotificationTargetUsers] = useState<User[]>([]);
// Sync URL params to filters
const mergedFilters: SegmentFilters = useMemo(() => ({
...filters,
search: searchQuery || filters.search,
minSpent: minSpent ?? filters.minSpent,
maxSpent: maxSpent ?? filters.maxSpent,
}), [filters, searchQuery, minSpent, maxSpent]);
// Active filters count
const activeFiltersCount = useMemo(() => {
let count = 0;
if (mergedFilters.search) count++;
if (mergedFilters.status?.length) count++;
if (mergedFilters.role?.length) count++;
if (mergedFilters.tier?.length) count++;
if (mergedFilters.tags?.length) count++;
if (mergedFilters.minSpent) count++;
if (mergedFilters.maxSpent) count++;
return count;
}, [mergedFilters]);
// Filter users
const filteredUsers = useMemo(() => {
return mockCrmUsers.filter((user) => {
// Search filter
if (mergedFilters.search) {
const query = mergedFilters.search.toLowerCase();
if (
!user.name.toLowerCase().includes(query) &&
!user.email.toLowerCase().includes(query) &&
!user.phone.includes(query) &&
!user.id.toLowerCase().includes(query)
) {
return false;
}
}
// Status filter
if (mergedFilters.status?.length && !mergedFilters.status.includes(user.status)) {
return false;
}
// Role filter
if (mergedFilters.role?.length && !mergedFilters.role.includes(user.role)) {
return false;
}
// Tier filter
if (mergedFilters.tier?.length && !mergedFilters.tier.includes(user.tier)) {
return false;
}
// Spent range filter
if (mergedFilters.minSpent && user.totalSpent < mergedFilters.minSpent) {
return false;
}
if (mergedFilters.maxSpent && user.totalSpent > mergedFilters.maxSpent) {
return false;
}
// Tags filter
if (mergedFilters.tags?.length) {
const userTagIds = user.tags.map((t) => t.id);
if (!mergedFilters.tags.some((tagId) => userTagIds.includes(tagId))) {
return false;
}
}
return true;
});
}, [mergedFilters]);
// Selected users array
const selectedUsers = useMemo(
() => mockCrmUsers.filter((u) => selectedUserIds.includes(u.id)),
[selectedUserIds]
);
// Handlers
const handleSelectUser = (user: User) => {
setSelectedUser(user);
setInspectorOpen(true);
};
const handleEditUser = (user: User) => {
setSelectedUser(user);
toast.info(`Edit user: ${user.name}`);
// In real app, this would open an edit modal
};
const handleSuspendUser = (user: User) => {
setSelectedUser(user);
setSuspensionModalOpen(true);
};
const handleBanUser = (user: User) => {
setSelectedUser(user);
setBanModalOpen(true);
};
const handleDeleteUser = (user: User) => {
setSelectedUser(user);
setDeleteDialogOpen(true);
};
const handleSendNotification = (user: User) => {
setNotificationTargetUsers([user]);
setNotificationComposerOpen(true);
};
const handleBulkNotification = (users: User[]) => {
setNotificationTargetUsers(users);
setNotificationComposerOpen(true);
};
const handleRefresh = () => {
toast.success('Data refreshed');
};
const handleExport = () => {
toast.success(`Exported ${filteredUsers.length} users to CSV`);
};
const handleFiltersChange = (newFilters: SegmentFilters) => {
setFilters(newFilters);
// Sync to URL
if (newFilters.search !== mergedFilters.search) {
setSearchQuery(newFilters.search || null);
}
if (newFilters.minSpent !== mergedFilters.minSpent) {
setMinSpent(newFilters.minSpent || null);
}
if (newFilters.maxSpent !== mergedFilters.maxSpent) {
setMaxSpent(newFilters.maxSpent || null);
}
};
return (
<AppLayout title="Command Center" description="Unified team, partner, and user management.">
<div className="space-y-6 container mx-auto p-2 md:p-4">
<TooltipProvider>
<AppLayout title="User Command Center" description="Comprehensive user management & CRM">
<div className="space-y-6 container mx-auto p-2 md:p-4">
{/* Metrics Bar */}
<UserMetricsBar metrics={mockUserMetrics} />
<Tabs defaultValue="internal" className="w-full space-y-6">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Left: Search */}
<div className="flex items-center gap-3 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users by name, email, phone or ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value || null)}
className="pl-9 bg-white/50 border-white/50"
/>
</div>
<Button
variant={filtersOpen ? "secondary" : "outline"}
onClick={() => setFiltersOpen(!filtersOpen)}
className="gap-2"
>
<Filter className="h-4 w-4" />
Filters
{activeFiltersCount > 0 && (
<Badge variant="secondary" className="ml-1 h-5 w-5 p-0 flex items-center justify-center bg-primary text-primary-foreground rounded-full text-[10px]">
{activeFiltersCount}
</Badge>
)}
</Button>
</div>
{/* Main Navigation Tabs */}
<TabsList className="grid grid-cols-3 w-full md:w-[600px] h-11 bg-secondary/50 border border-border/50">
<TabsTrigger value="internal" className="data-[state=active]:bg-background data-[state=active]:shadow-sm">
<Shield className="h-4 w-4 mr-2" />
Internal Team
</TabsTrigger>
<TabsTrigger value="partners" className="data-[state=active]:bg-background data-[state=active]:shadow-sm">
<BriefcaseBusiness className="h-4 w-4 mr-2" />
Partners
</TabsTrigger>
<TabsTrigger value="users" className="data-[state=active]:bg-background data-[state=active]:shadow-sm">
<Users className="h-4 w-4 mr-2" />
User Base
</TabsTrigger>
</TabsList>
{/* 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`);
}
}}
/>
{/* Quick Actions (Role Manager) */}
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" className="gap-2 border-dashed border-primary/40 hover:border-primary text-primary hover:bg-primary/5">
<Settings2 className="h-4 w-4" />
Manage Roles & Permissions
{/* View Toggle */}
<div className="flex border rounded-lg overflow-hidden bg-white/50">
<Button
variant="ghost"
size="sm"
className={cn('rounded-none px-3', viewMode === 'table' && 'bg-primary/10')}
onClick={() => setViewMode('table')}
>
<LayoutList className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="min-w-[400px] md:min-w-[800px] sm:w-[80vw] overflow-y-auto">
<SheetHeader className="mb-6">
<SheetTitle>Role Permissions Matrix</SheetTitle>
<SheetDescription>Verify and modify capabilities for each internal system role.</SheetDescription>
</SheetHeader>
<RoleMatrix />
</SheetContent>
</Sheet>
</div>
<Button
variant="ghost"
size="sm"
className={cn('rounded-none px-3', viewMode === 'grid' && 'bg-primary/10')}
onClick={() => setViewMode('grid')}
>
<LayoutGrid className="h-4 w-4" />
</Button>
</div>
<div className="min-h-[600px] rounded-xl border border-border/40 bg-card/30 p-1">
<div className="h-full bg-background/50 rounded-lg p-4 md:p-6 shadow-sm">
<TabsContent value="internal" className="m-0 focus-visible:outline-none animate-in fade-in-50 duration-300">
<InternalTeamTab />
</TabsContent>
{/* Refresh */}
<Button variant="outline" size="icon" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4" />
</Button>
<TabsContent value="partners" className="m-0 focus-visible:outline-none animate-in fade-in-50 duration-300">
<PartnerAdminTab />
</TabsContent>
{/* Export */}
<Button variant="outline" size="icon" onClick={handleExport}>
<Download className="h-4 w-4" />
</Button>
<TabsContent value="users" className="m-0 focus-visible:outline-none animate-in fade-in-50 duration-300">
<UserBaseTab />
</TabsContent>
{/* Create User */}
<Button onClick={() => setCreateDialogOpen(true)} className="gap-2">
<Plus className="h-4 w-4" /> Add User
</Button>
</div>
</div>
</Tabs>
</div>
</AppLayout>
{/* Content Area */}
<div className="flex gap-6">
{/* Filter Sidebar (collapsible) */}
{filtersOpen && (
<UserFilters
filters={mergedFilters}
onFiltersChange={handleFiltersChange}
isOpen={true}
onToggle={() => setFiltersOpen(false)}
/>
)}
{/* Main Content */}
<div className="flex-1">
{/* Results Count */}
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-muted-foreground">
Showing <span className="font-semibold text-foreground">{filteredUsers.length}</span> of{' '}
<span className="font-semibold text-foreground">{mockCrmUsers.length}</span> users
{selectedUserIds.length > 0 && (
<span className="ml-2 text-primary">
({selectedUserIds.length} selected)
</span>
)}
</p>
</div>
{/* Table View */}
{viewMode === 'table' && (
<UsersTable
users={filteredUsers}
onSelectUser={handleSelectUser}
onEditUser={handleEditUser}
onSuspendUser={handleSuspendUser}
onBanUser={handleBanUser}
onDeleteUser={handleDeleteUser}
onSendNotification={handleSendNotification}
selectedUserIds={selectedUserIds}
onSelectionChange={setSelectedUserIds}
/>
)}
{/* Grid View */}
{viewMode === 'grid' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredUsers.map((user) => (
<div
key={user.id}
onClick={() => handleSelectUser(user)}
className="group relative flex flex-col gap-4 rounded-xl border border-white/40 bg-white/30 p-5 shadow-neu-sm hover:shadow-neu transition-all duration-300 cursor-pointer backdrop-blur-md overflow-hidden"
>
<div className="flex items-center gap-3">
<img
src={user.avatarUrl}
alt={user.name}
className="h-12 w-12 rounded-full border-2 border-white shadow-sm object-cover"
/>
<div>
<h4 className="font-bold text-foreground leading-tight group-hover:text-primary transition-colors">
{user.name}
</h4>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className={cn(
'px-2 py-0.5 rounded-full text-xs font-medium',
user.status === 'Active' ? 'bg-emerald-100 text-emerald-700' :
user.status === 'Suspended' ? 'bg-orange-100 text-orange-700' :
user.status === 'Banned' ? 'bg-red-100 text-red-700' :
'bg-slate-100 text-slate-600'
)}>
{user.status}
</span>
<span className="font-semibold text-primary">
{(user.totalSpent / 1000).toFixed(0)}K
</span>
</div>
<div className="flex flex-wrap gap-1">
{user.tags.slice(0, 2).map((tag) => (
<span key={tag.id} className={cn('text-[10px] px-1.5 py-0.5 rounded-full', tag.color)}>
{tag.name}
</span>
))}
{user.tags.length > 2 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-600">
+{user.tags.length - 2}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Modals & Sheets */}
<UserInspectorSheet
user={selectedUser}
open={inspectorOpen}
onOpenChange={setInspectorOpen}
onEditUser={handleEditUser}
onSendNotification={handleSendNotification}
/>
<CreateUserDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onUserCreated={handleRefresh}
/>
<SuspensionModal
user={selectedUser}
open={suspensionModalOpen}
onOpenChange={setSuspensionModalOpen}
onSuspended={handleRefresh}
/>
<BanModal
user={selectedUser}
open={banModalOpen}
onOpenChange={setBanModalOpen}
onBanned={handleRefresh}
/>
<DeleteConfirmDialog
user={selectedUser}
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDeleted={handleRefresh}
/>
<NotificationComposer
users={notificationTargetUsers}
open={notificationComposerOpen}
onOpenChange={setNotificationComposerOpen}
onSent={() => setNotificationTargetUsers([])}
/>
</AppLayout>
</TooltipProvider>
);
}