feat(users): implement inspector tabs and server actions
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
76
src/features/users/components/shared/AdminNote.tsx
Normal file
76
src/features/users/components/shared/AdminNote.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/features/users/components/tabs/AuditTab.tsx
Normal file
71
src/features/users/components/tabs/AuditTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
src/features/users/components/tabs/BookingsTab.tsx
Normal file
193
src/features/users/components/tabs/BookingsTab.tsx
Normal 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 >
|
||||||
|
);
|
||||||
|
}
|
||||||
224
src/features/users/components/tabs/OverviewTab.tsx
Normal file
224
src/features/users/components/tabs/OverviewTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/features/users/components/tabs/SecurityTab.tsx
Normal file
145
src/features/users/components/tabs/SecurityTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/features/users/components/tabs/SupportTab.tsx
Normal file
151
src/features/users/components/tabs/SupportTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/lib/actions/user-tabs.ts
Normal file
165
src/lib/actions/user-tabs.ts
Normal 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user