feat(users): implement inspector tabs and server actions
This commit is contained in:
@@ -59,6 +59,11 @@ import {
|
||||
} from '../data/mockUserCrmData';
|
||||
import { formatCurrency } from '@/data/mockData';
|
||||
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 { cn } from '@/lib/utils';
|
||||
|
||||
@@ -78,7 +83,6 @@ export function UserInspectorSheet({
|
||||
onSendNotification,
|
||||
}: UserInspectorSheetProps) {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [noteContent, setNoteContent] = useState('');
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
@@ -215,138 +219,37 @@ export function UserInspectorSheet({
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 bg-secondary/5">
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="p-4">
|
||||
{/* 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 value="overview" className="m-0">
|
||||
<OverviewTab user={user} notes={notes.length > 0 ? notes[0].content : ''} />
|
||||
</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>
|
||||
)}
|
||||
<BookingsTab bookings={bookings} />
|
||||
</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>
|
||||
{/* Tab 3: Security */}
|
||||
<TabsContent value="security" className="m-0">
|
||||
<SecurityTab user={user} />
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
{/* Tab 4: Support */}
|
||||
<TabsContent value="support" className="m-0">
|
||||
<SupportTab user={user} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 5: Audit (Admin Notes / Activity) */}
|
||||
<TabsContent value="admin" className="m-0">
|
||||
<AuditTab userId={user.id} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</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