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

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>
);
}