feat(users): implement inspector tabs and server actions

This commit is contained in:
CycroftX
2026-02-09 21:46:55 +05:30
parent df4d881437
commit 389ebdeb5d
8 changed files with 1049 additions and 121 deletions

View File

@@ -59,6 +59,11 @@ import {
} from '../data/mockUserCrmData'; } from '../data/mockUserCrmData';
import { formatCurrency } from '@/data/mockData'; import { formatCurrency } from '@/data/mockData';
import { ActionButtons } from './ActionButtons'; import { ActionButtons } from './ActionButtons';
import { OverviewTab } from './tabs/OverviewTab';
import { BookingsTab } from './tabs/BookingsTab';
import { SecurityTab } from './tabs/SecurityTab';
import { SupportTab } from './tabs/SupportTab';
import { AuditTab } from './tabs/AuditTab';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -78,7 +83,6 @@ export function UserInspectorSheet({
onSendNotification, onSendNotification,
}: UserInspectorSheetProps) { }: UserInspectorSheetProps) {
const [activeTab, setActiveTab] = useState('overview'); const [activeTab, setActiveTab] = useState('overview');
const [noteContent, setNoteContent] = useState('');
if (!user) return null; if (!user) return null;
@@ -215,138 +219,37 @@ export function UserInspectorSheet({
<TabsList className="w-full justify-start h-9 bg-transparent p-0"> <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="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="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> <TabsTrigger value="security" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 text-xs">Security</TabsTrigger>
<TabsTrigger value="support" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 text-xs">Support</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">Audit</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<ScrollArea className="flex-1 bg-secondary/5"> <ScrollArea className="flex-1 bg-secondary/5">
<div className="p-4 space-y-6"> <div className="p-4">
{/* Tab 1: Overview */} {/* Tab 1: Overview */}
<TabsContent value="overview" className="m-0 space-y-6"> <TabsContent value="overview" className="m-0">
{/* Contact Info */} <OverviewTab user={user} notes={notes.length > 0 ? notes[0].content : ''} />
<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> </TabsContent>
{/* Tab 2: Orders */} {/* Tab 2: Orders */}
<TabsContent value="orders" className="m-0"> <TabsContent value="orders" className="m-0">
<Table> <BookingsTab bookings={bookings} />
<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> </TabsContent>
{/* Tab 3: Admin Notes */} {/* Tab 3: Security */}
<TabsContent value="admin" className="m-0 space-y-4"> <TabsContent value="security" className="m-0">
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg space-y-2"> <SecurityTab user={user} />
<div className="flex items-center gap-2 mb-2"> </TabsContent>
<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"> {/* Tab 4: Support */}
{notes.map((note) => ( <TabsContent value="support" className="m-0">
<div key={note.id} className="relative pl-4 border-l-2 border-border/50 py-1"> <SupportTab user={user} />
<p className="text-sm text-foreground">{note.content}</p> </TabsContent>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-medium text-muted-foreground">{note.authorName}</span> {/* Tab 5: Audit (Admin Notes / Activity) */}
<span className="text-[10px] text-muted-foreground/60"> {new Date(note.createdAt).toLocaleDateString()}</span> <TabsContent value="admin" className="m-0">
</div> <AuditTab userId={user.id} />
</div>
))}
</div>
</TabsContent> </TabsContent>
</div> </div>
</ScrollArea> </ScrollArea>

View File

@@ -0,0 +1,76 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Loader2, Check } from 'lucide-react';
import { saveUserNote } from '@/lib/actions/user-tabs';
import { toast } from 'sonner';
interface AdminNoteProps {
userId: string;
initialNote?: string;
}
export function AdminNote({ userId, initialNote = '' }: AdminNoteProps) {
const [content, setContent] = useState(initialNote);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const timeoutRef = useRef<NodeJS.Timeout>(null);
// Sync remote changes if any (unlikely in single user flow but good practice)
useEffect(() => {
setContent(initialNote || '');
}, [initialNote]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setContent(newValue);
setStatus('idle');
// Debounce save
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(async () => {
if (newValue.trim() === initialNote.trim()) return;
setStatus('saving');
const result = await saveUserNote(userId, newValue);
if (result.success) {
setStatus('saved');
// Reset saved status after 2 seconds
setTimeout(() => setStatus('idle'), 2000);
} else {
setStatus('error');
toast.error("Failed to save note");
}
}, 1000); // 1s delay
};
return (
<div className="relative">
<Textarea
value={content}
onChange={handleChange}
placeholder="Add private notes about this user..."
className="min-h-[120px] bg-amber-50/50 border-amber-200/50 resize-y text-sm focus-visible:ring-amber-500/20"
/>
<div className="absolute bottom-2 right-2 flex items-center gap-1.5 pointer-events-none">
{status === 'saving' && (
<span className="text-[10px] text-amber-600 flex items-center gap-1 bg-white/50 px-1.5 rounded-full">
<Loader2 className="h-3 w-3 animate-spin" /> Saving...
</span>
)}
{status === 'saved' && (
<span className="text-[10px] text-emerald-600 flex items-center gap-1 bg-white/50 px-1.5 rounded-full font-medium">
<Check className="h-3 w-3" /> Saved
</span>
)}
{status === 'error' && (
<span className="text-[10px] text-red-600 bg-white/50 px-1.5 rounded-full">
Not Saved
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
'use client';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import {
Activity,
Shield,
UserCog,
AlertTriangle,
Flag,
Settings,
LogIn
} from 'lucide-react';
interface AuditTabProps {
userId: string;
}
export function AuditTab({ userId }: AuditTabProps) {
// Mock Data for now
const logs = [
{ id: '1', action: 'User Suspended', actor: 'Admin (Sarah)', timestamp: '2 mins ago', icon: Shield, type: 'danger' },
{ id: '2', action: 'Note Added', actor: 'Admin (Sarah)', timestamp: '5 mins ago', icon: UserCog, type: 'blue' },
{ id: '3', action: 'Profile Updated', actor: 'User', timestamp: 'Yesterday', icon: Settings, type: 'neutral' },
{ id: '4', action: 'Failed Login Attempt', actor: 'System (IP 192.168...)', timestamp: '2 days ago', icon: AlertTriangle, type: 'warning' },
{ id: '5', action: 'Account Created', actor: 'User', timestamp: '1 month ago', icon: LogIn, type: 'success' },
];
const getIconColor = (type: string) => {
switch (type) {
case 'danger': return 'bg-red-100 text-red-600';
case 'warning': return 'bg-amber-100 text-amber-600';
case 'success': return 'bg-emerald-100 text-emerald-600';
case 'blue': return 'bg-blue-100 text-blue-600';
default: return 'bg-slate-100 text-slate-600';
}
};
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Activity className="h-4 w-4" /> Activity Log
</h3>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-6 pl-2">
{logs.map((log, index) => (
<div key={log.id} className="relative pl-6 pb-2 border-l border-border/40 last:border-0">
{/* Connector Line */}
<div className={`absolute -left-[14px] top-0 h-7 w-7 rounded-full border-4 border-background flex items-center justify-center ${getIconColor(log.type)}`}>
<log.icon className="h-3.5 w-3.5" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{log.action}</span>
<span className="text-xs text-muted-foreground">{log.timestamp}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="outline" className="text-[10px] h-4 px-1 font-normal text-muted-foreground">
{log.actor}
</Badge>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,193 @@
'use client';
import { useTransition, useState } from 'react';
import type { UserBooking } from '@/lib/types/user';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { formatCurrency } from '@/data/mockData';
import { processRefund } from '@/lib/actions/user-tabs';
import {
MoreHorizontal,
Download,
Ticket,
AlertCircle,
CheckCircle2,
XCircle,
Loader2
} from 'lucide-react';
import { toast } from 'sonner';
interface BookingsTabProps {
bookings: UserBooking[];
}
export function BookingsTab({ bookings }: BookingsTabProps) {
const [isPending, startTransition] = useTransition();
const [refundDialogOpen, setRefundDialogOpen] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<UserBooking | null>(null);
const [refundReason, setRefundReason] = useState('');
const handleRefundClick = (booking: UserBooking) => {
setSelectedBooking(booking);
setRefundReason('');
setRefundDialogOpen(true);
};
const confirmRefund = () => {
if (!selectedBooking) return;
startTransition(async () => {
const result = await processRefund(selectedBooking.id, refundReason);
if (result.success) {
toast.success(result.message);
setRefundDialogOpen(false);
setSelectedBooking(null);
} else {
toast.error(result.message);
}
});
};
const getStatusBadge = (status: UserBooking['status']) => {
switch (status) {
case 'Confirmed': return <Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200">Confirmed</Badge>;
case 'Attended': return <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">Attended</Badge>;
case 'Cancelled': return <Badge variant="outline" className="bg-slate-100 text-slate-600 border-slate-200">Cancelled</Badge>;
case 'Refunded': return <Badge variant="outline" className="bg-orange-50 text-orange-700 border-orange-200">Refunded</Badge>;
default: return <Badge variant="outline">{status}</Badge>;
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Transaction History</h3>
<Button variant="outline" size="sm" className="h-8 gap-2">
<Download className="h-3.5 w-3.5" /> Export CSV
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-secondary/5 hover:bg-secondary/5">
<TableHead className="w-[40px]"></TableHead>
<TableHead className="text-xs font-semibold">Event / Date</TableHead>
<TableHead className="text-xs font-semibold">Ticket</TableHead>
<TableHead className="text-xs font-semibold text-right">Amount</TableHead>
<TableHead className="text-xs font-semibold text-center">Status</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bookings.map((booking) => (
<TableRow key={booking.id} className="group">
<TableCell>
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center text-primary">
<Ticket className="h-4 w-4" />
</div>
</TableCell>
<TableCell className="min-w-[150px]">
<div className="font-medium text-sm">{booking.eventName}</div>
<div className="text-xs text-muted-foreground">{new Date(booking.eventDate).toLocaleDateString()}</div>
</TableCell>
<TableCell>
<div className="text-sm">{booking.ticketType}</div>
<div className="text-xs text-muted-foreground">x{booking.quantity}</div>
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(booking.amount)}
</TableCell>
<TableCell className="text-center">
{getStatusBadge(booking.status)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>View Receipt</DropdownMenuItem>
{booking.status === 'Confirmed' && (
<DropdownMenuItem onClick={() => handleRefundClick(booking)} className="text-red-600 focus:text-red-600">
Process Refund
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{bookings.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-sm">
No booking history found for this user.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Refund Dialog */}
<Dialog open={refundDialogOpen} onOpenChange={setRefundDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Process Refund</DialogTitle>
<DialogDescription>
Refunding <span className="font-semibold text-foreground">{formatCurrency(selectedBooking?.amount || 0)}</span> for {selectedBooking?.eventName}.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-4">
<Label>Refund Reason</Label>
<Input
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
placeholder="e.g. Customer requested, Event cancelled..."
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRefundDialogOpen(false)} disabled={isPending}>Cancel</Button>
<Button
variant="destructive"
onClick={confirmRefund}
disabled={!refundReason || isPending}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm Refund
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div >
);
}

View File

@@ -0,0 +1,224 @@
'use client';
import { useTransition, useState } from 'react';
import type { User } from '@/lib/types/user';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { AdminNote } from '../shared/AdminNote';
import { updateUserProfile } from '@/lib/actions/user-tabs';
import { formatCurrency } from '@/data/mockData';
import {
MapPin,
Calendar,
DollarSign,
ShieldCheck,
Globe,
Clock,
User as UserIcon,
Mail,
Phone,
Loader2
} from 'lucide-react';
import { toast } from 'sonner';
interface OverviewTabProps {
user: User;
notes?: string;
}
export function OverviewTab({ user, notes }: OverviewTabProps) {
const [isPending, startTransition] = useTransition();
// Editable Form State
const [formData, setFormData] = useState({
name: user.name,
email: user.email,
phone: user.phone,
language: user.language || 'English (US)'
});
const isDirty =
formData.name !== user.name ||
formData.email !== user.email ||
formData.phone !== user.phone ||
formData.language !== (user.language || 'English (US)');
const handleSaveProfile = () => {
startTransition(async () => {
const data = new FormData();
Object.entries(formData).forEach(([k, v]) => data.append(k, v));
const result = await updateUserProfile(user.id, data);
if (result.success) {
toast.success(result.message);
// In a real app with React Query, invalidation would re-fetch user prop
} else {
toast.error(result.message);
}
});
};
return (
<div className="space-y-6">
{/* 1. Stats Row */}
<div className="grid grid-cols-3 gap-4">
<Card className="shadow-none border border-border/60 bg-secondary/5">
<CardContent className="p-4 flex items-center gap-4">
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600">
<DollarSign className="h-5 w-5" />
</div>
<div>
<p className="text-xs text-muted-foreground uppercase font-semibold tracking-wider">Total Spent</p>
<p className="text-lg font-bold">{formatCurrency(user.totalSpent)}</p>
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-border/60 bg-secondary/5">
<CardContent className="p-4 flex items-center gap-4">
<div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600">
<Clock className="h-5 w-5" />
</div>
<div>
<p className="text-xs text-muted-foreground uppercase font-semibold tracking-wider">Last Active</p>
<p className="text-lg font-bold">2h ago</p>
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-border/60 bg-secondary/5">
<CardContent className="p-4 flex items-center gap-4">
<div className="h-10 w-10 rounded-full bg-purple-100 flex items-center justify-center text-purple-600">
<ShieldCheck className="h-5 w-5" />
</div>
<div>
<p className="text-xs text-muted-foreground uppercase font-semibold tracking-wider">Trust Score</p>
<p className="text-lg font-bold">98/100</p>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 2. Left Column: Identity Form */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-2">
<UserIcon className="h-4 w-4 text-primary" /> Identity & Contact
</h3>
{isDirty && (
<Button
size="sm"
className="h-7 text-xs"
onClick={handleSaveProfile}
disabled={isPending}
>
{isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Save Changes
</Button>
)}
</div>
<div className="grid gap-4 p-4 border rounded-lg bg-card shadow-sm">
<div className="grid gap-2">
<Label htmlFor="name" className="text-xs">Full Name</Label>
<Input
id="name"
value={formData.name}
onChange={e => setFormData(p => ({ ...p, name: e.target.value }))}
className="h-9"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email" className="text-xs">Email Address</Label>
<div className="relative">
<Mail className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="email"
value={formData.email}
onChange={e => setFormData(p => ({ ...p, email: e.target.value }))}
className="h-9 pl-9"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="phone" className="text-xs">Phone Number</Label>
<div className="relative">
<Phone className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="phone"
value={formData.phone}
onChange={e => setFormData(p => ({ ...p, phone: e.target.value }))}
className="h-9 pl-9"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="language" className="text-xs">Language Preference</Label>
<div className="relative">
<Globe className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="language"
value={formData.language}
onChange={e => setFormData(p => ({ ...p, language: e.target.value }))}
className="h-9 pl-9"
/>
</div>
</div>
</div>
{user.lastDevice && (
<div className="flex items-center gap-2 text-xs text-muted-foreground p-3 bg-secondary/10 rounded-md">
<MapPin className="h-3.5 w-3.5" />
<span>Last known location: <span className="font-medium text-foreground">{user.lastDevice.location}</span> (IP: {user.lastDevice.ip})</span>
</div>
)}
</div>
{/* 3. Right Column: Notes & Metadata */}
<div className="space-y-4">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Calendar className="h-4 w-4 text-amber-600" /> Admin Context
</h3>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Internal Tags</Label>
<div className="flex flex-wrap gap-2 p-3 border rounded-lg bg-background min-h-[60px]">
{user.tags.map(tag => (
<Badge key={tag.id} variant="secondary" className={tag.color + " bg-opacity-20 hover:bg-opacity-30 border"}>
{tag.name}
</Badge>
))}
<Button variant="ghost" size="sm" className="h-5 text-xs px-2 border border-dashed">
+ Add Tag
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Admin Notes</Label>
<div className="p-1 border rounded-lg bg-amber-50/30">
<AdminNote userId={user.id} initialNote={notes} />
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<span className="text-muted-foreground block">Registration Date</span>
<span className="font-medium">{new Date(user.createdAt).toLocaleDateString()}</span>
</div>
<div>
<span className="text-muted-foreground block">Last Login</span>
<span className="font-medium">{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Never'}</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import { useTransition } from 'react';
import type { User } from '@/lib/types/user';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
ShieldAlert,
Smartphone,
Laptop,
LogOut,
KeyRound,
Lock,
Globe,
Loader2
} from 'lucide-react';
import { toast } from 'sonner';
import { revokeUserSession, toggle2FA } from '@/lib/actions/user-tabs';
import { resetPassword } from '@/lib/actions/user-management'; // reusing existing action
interface SecurityTabProps {
user: User;
}
export function SecurityTab({ user }: SecurityTabProps) {
const [isPending, startTransition] = useTransition();
const handleRevokeSession = (sessionId: string) => {
startTransition(async () => {
const result = await revokeUserSession(sessionId);
if (result.success) toast.success(result.message);
else toast.error(result.message);
});
};
const handleToggle2FA = (checked: boolean) => {
startTransition(async () => {
const result = await toggle2FA(user.id, checked);
if (result.success) toast.success(result.message);
else toast.error(result.message);
});
};
const handlePasswordReset = () => {
startTransition(async () => {
const result = await resetPassword(user.id);
if (result.success) toast.success(result.message);
else toast.error(result.message);
});
};
return (
<div className="space-y-6">
{/* 1. Credentials Management */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Lock className="h-4 w-4 text-primary" /> Credentials & Access
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<h4 className="text-sm font-medium">Two-Factor Authentication</h4>
<p className="text-xs text-muted-foreground">Require 2FA for this user login.</p>
</div>
<Switch
checked={user.is2FAEnabled}
onCheckedChange={handleToggle2FA}
disabled={isPending}
/>
</div>
<div className="flex items-center justify-between border-t pt-4">
<div className="space-y-0.5">
<h4 className="text-sm font-medium">Password Reset</h4>
<p className="text-xs text-muted-foreground">Send a recovery link to {user.email}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handlePasswordReset}
disabled={isPending}
className="text-xs h-8"
>
<KeyRound className="mr-2 h-3.5 w-3.5" /> Send Reset Link
</Button>
</div>
</CardContent>
</Card>
{/* 2. Active Sessions */}
<Card className="border-red-100 bg-red-50/10">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-red-900">
<ShieldAlert className="h-4 w-4 text-red-600" /> Active Sessions
</CardTitle>
<CardDescription className="text-xs">
Monitor and revoke active device sessions.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Mock Sessions List */}
{[
{ id: 'sess_1', device: 'Chrome on macOS', location: 'Mumbai, IN', ip: '192.168.1.1', type: 'desktop', current: true, time: 'Now' },
{ id: 'sess_2', device: 'Safari on iPhone 14', location: 'New Delhi, IN', ip: '10.0.0.5', type: 'mobile', current: false, time: '2h ago' },
].map((session) => (
<div key={session.id} className="flex items-center justify-between p-3 bg-background border rounded-lg shadow-sm">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-secondary/20 flex items-center justify-center text-muted-foreground">
{session.type === 'mobile' ? <Smartphone className="h-4 w-4" /> : <Laptop className="h-4 w-4" />}
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{session.device}</p>
{session.current && <Badge variant="secondary" className="text-[10px] h-4 px-1 bg-green-100 text-green-700 hover:bg-green-100">Current</Badge>}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Globe className="h-3 w-3" /> {session.location} {session.ip} <span className={session.current ? "text-green-600 font-medium" : ""}>{session.time}</span>
</div>
</div>
</div>
{!session.current && (
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50 h-8 px-2"
onClick={() => handleRevokeSession(session.id)}
disabled={isPending}
>
<LogOut className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import { useTransition, useState } from 'react';
import type { User } from '@/lib/types/user';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
MessageSquare,
Mail,
Plus,
UserCircle2,
Headphones,
Loader2
} from 'lucide-react';
import { createSupportTicket } from '@/lib/actions/user-tabs';
import { toast } from 'sonner';
interface SupportTabProps {
user: User;
}
export function SupportTab({ user }: SupportTabProps) {
const [isPending, startTransition] = useTransition();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Form State
const [subject, setSubject] = useState('');
const [priority, setPriority] = useState('Normal');
const [type, setType] = useState('Inquiry');
const handleCreateTicket = () => {
startTransition(async () => {
const result = await createSupportTicket(user.id, { subject, priority, type });
if (result.success) {
toast.success(result.message);
setCreateDialogOpen(false);
setSubject('');
} else {
toast.error(result.message);
}
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Communication Timeline</h3>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="h-8 gap-2">
<Plus className="h-3.5 w-3.5" /> Create Ticket
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Support Ticket</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Subject</Label>
<Input
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder="Brief summary of the issue"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Inquiry">Inquiry</SelectItem>
<SelectItem value="Refund">Refund Request</SelectItem>
<SelectItem value="Technical">Technical Issue</SelectItem>
<SelectItem value="Complaint">Complaint</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Priority</Label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Low">Low</SelectItem>
<SelectItem value="Normal">Normal</SelectItem>
<SelectItem value="High">High</SelectItem>
<SelectItem value="Urgent">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreateTicket} disabled={!subject || isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Ticket
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Timeline Stream */}
<div className="relative pl-4 border-l-2 border-border/50 space-y-6">
{[
{ id: 1, type: 'ticket', title: 'Refund requested for "Tech Summit"', status: 'Open', date: '2 hours ago', icon: Headphones, color: 'text-blue-600 bg-blue-50' },
{ id: 2, type: 'email', title: 'Sent: "Your tickets are ready!"', status: 'Delivered', date: 'Yesterday', icon: Mail, color: 'text-slate-600 bg-slate-50' },
{ id: 3, type: 'ticket', title: 'Login issue reported', status: 'Resolved', date: '3 days ago', icon: MessageSquare, color: 'text-emerald-600 bg-emerald-50' },
].map((item) => (
<div key={item.id} className="relative">
<div className={`absolute -left-[25px] top-0 h-8 w-8 rounded-full border-2 border-background flex items-center justify-center ${item.color}`}>
<item.icon className="h-4 w-4" />
</div>
<div className="bg-card border rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<h4 className="text-sm font-medium">{item.title}</h4>
<span className="text-xs text-muted-foreground whitespace-nowrap">{item.date}</span>
</div>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-secondary/50 text-secondary-foreground">
{item.type === 'ticket' ? 'Ticket #1234' : 'Email System'}
</span>
<span className="text-xs text-muted-foreground"> {item.status}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
'use server';
import { z } from 'zod';
import { logAdminAction } from '@/lib/audit-logger';
import type { User } from '@/lib/types/user';
// --- Validation Schemas ---
const userProfileSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
phone: z.string().optional(),
language: z.string().optional(),
});
const refundSchema = z.object({
orderId: z.string(),
reason: z.string().min(5, "Reason is required"),
amount: z.number().positive(),
});
const ticketSchema = z.object({
subject: z.string().min(5),
priority: z.enum(['Low', 'Normal', 'High', 'Urgent']),
type: z.enum(['Refund', 'Technical', 'Inquiry', 'Complaint', 'Feedback']),
});
// --- Authorization Helper ---
// (Reusing the mock verified admin logic)
async function verifyAdmin() {
// Helper to simulate auth check
return { id: 'admin-1', role: 'admin' };
}
// --- Server Actions ---
export async function updateUserProfile(userId: string, formData: FormData) {
try {
const admin = await verifyAdmin();
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
phone: formData.get('phone'),
language: formData.get('language'),
};
const validated = userProfileSchema.safeParse(rawData);
if (!validated.success) return { success: false, message: "Invalid data" };
// Simulate DB update
await new Promise(resolve => setTimeout(resolve, 800));
await logAdminAction({
actorId: admin.id,
action: 'UPDATE_PROFILE',
targetId: userId,
details: validated.data
});
return { success: true, message: "Profile updated successfully" };
} catch (error) {
return { success: false, message: "Failed to update profile" };
}
}
export async function saveUserNote(userId: string, content: string) {
try {
const admin = await verifyAdmin();
// Simulate DB save
await new Promise(resolve => setTimeout(resolve, 500));
// Only log significant note changes or first creation to avoid spamming audit log
// For this mock, we'll just log it.
/* await logAdminAction({
actorId: admin.id,
action: 'UPDATE_NOTE',
targetId: userId,
}); */
return { success: true, message: "Note saved" };
} catch (error) {
return { success: false, message: "Failed to save note" };
}
}
export async function processRefund(orderId: string, reason: string) {
try {
const admin = await verifyAdmin();
// Simulate Stripe/Gateway call
await new Promise(resolve => setTimeout(resolve, 1500));
await logAdminAction({
actorId: admin.id,
action: 'REFUND_ORDER',
targetId: orderId, // Note: logging order ID here, might want user ID context too if available
details: { reason }
});
return { success: true, message: `Refund processed for Order #${orderId}` };
} catch (error) {
return { success: false, message: "Refund failed via Gateway" };
}
}
export async function revokeUserSession(sessionId: string) {
try {
const admin = await verifyAdmin();
await new Promise(resolve => setTimeout(resolve, 600));
await logAdminAction({
actorId: admin.id,
action: 'REVOKE_SESSION',
targetId: sessionId,
});
return { success: true, message: "Session revoked" };
} catch (error) {
return { success: false, message: "Failed to revoke session" };
}
}
export async function toggle2FA(userId: string, enabled: boolean) {
try {
const admin = await verifyAdmin();
await new Promise(resolve => setTimeout(resolve, 800));
await logAdminAction({
actorId: admin.id,
action: enabled ? 'ENABLE_2FA' : 'DISABLE_2FA',
targetId: userId,
});
return { success: true, message: `2FA ${enabled ? 'Enabled' : 'Disabled'}` };
} catch (error) {
return { success: false, message: "Failed to update security settings" };
}
}
export async function createSupportTicket(userId: string, data: { subject: string; priority: string; type: string }) {
try {
const admin = await verifyAdmin();
const validated = ticketSchema.safeParse(data);
if (!validated.success) return { success: false, message: "Invalid ticket data" };
await new Promise(resolve => setTimeout(resolve, 1000));
await logAdminAction({
actorId: admin.id,
action: 'CREATE_TICKET',
targetId: userId,
details: validated.data
});
return { success: true, message: "Support ticket created" };
} catch (error) {
return { success: false, message: "Failed to create ticket" };
}
}