feat(users): implement advanced user actions (notifications, moderation, support, audit)
This commit is contained in:
@@ -35,8 +35,9 @@ import {
|
||||
interface ActionButtonsProps {
|
||||
userId: string;
|
||||
userName: string;
|
||||
onSuspend?: () => void; // Provided by parent to open modal
|
||||
onSendNotification?: () => void; // Provided by parent
|
||||
onSuspend?: () => void;
|
||||
onSendNotification?: () => void;
|
||||
onCreateTicket?: () => void;
|
||||
}
|
||||
|
||||
export function ActionButtons({
|
||||
@@ -44,6 +45,7 @@ export function ActionButtons({
|
||||
userName,
|
||||
onSuspend,
|
||||
onSendNotification,
|
||||
onCreateTicket,
|
||||
}: ActionButtonsProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@@ -97,20 +99,9 @@ export function ActionButtons({
|
||||
};
|
||||
|
||||
const handleCreateTicket = () => {
|
||||
// Quick action: create a generic ticket for now
|
||||
// In reality, this might open a dialog
|
||||
startTransition(async () => {
|
||||
const result = await createSupportTicket(userId, "User requires manual review");
|
||||
if (result.success) {
|
||||
toast.success('Support Ticket Created', {
|
||||
description: result.message,
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to create ticket', {
|
||||
description: result.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (onCreateTicket) {
|
||||
onCreateTicket();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -64,6 +64,9 @@ import { BookingsTab } from './tabs/BookingsTab';
|
||||
import { SecurityTab } from './tabs/SecurityTab';
|
||||
import { SupportTab } from './tabs/SupportTab';
|
||||
import { AuditTab } from './tabs/AuditTab';
|
||||
import { NotificationComposer } from './dialogs/NotificationComposer';
|
||||
import { ModerationDialog } from './dialogs/ModerationDialog';
|
||||
import { EscalationForm } from './dialogs/EscalationForm';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -84,6 +87,10 @@ export function UserInspectorSheet({
|
||||
}: UserInspectorSheetProps) {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [moderationOpen, setModerationOpen] = useState(false);
|
||||
const [escalationOpen, setEscalationOpen] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Fetch related data
|
||||
@@ -208,8 +215,9 @@ export function UserInspectorSheet({
|
||||
<ActionButtons
|
||||
userId={user.id}
|
||||
userName={user.name}
|
||||
onSuspend={() => handleAction('Suspend')}
|
||||
onSendNotification={() => onSendNotification(user)}
|
||||
onSuspend={() => setModerationOpen(true)}
|
||||
onSendNotification={() => setNotificationOpen(true)}
|
||||
onCreateTicket={() => setEscalationOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -254,6 +262,27 @@ export function UserInspectorSheet({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
{/* Dialogs */}
|
||||
<NotificationComposer
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
userId={user.id}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
<ModerationDialog
|
||||
open={moderationOpen}
|
||||
onOpenChange={setModerationOpen}
|
||||
userId={user.id}
|
||||
userName={user.name}
|
||||
/>
|
||||
<EscalationForm
|
||||
open={escalationOpen}
|
||||
onOpenChange={setEscalationOpen}
|
||||
userId={user.id}
|
||||
userName={user.name}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
221
src/features/users/components/dialogs/EscalationForm.tsx
Normal file
221
src/features/users/components/dialogs/EscalationForm.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Loader2, Ticket, Phone, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { createEscalationTicket } from '@/lib/actions/userActions';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface EscalationFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userId: string;
|
||||
userName?: string; // Optional for display
|
||||
}
|
||||
|
||||
const TICKET_TYPES = ['Refund', 'Payment', 'Account access', 'Fraud', 'Event issue', 'Other'];
|
||||
const PRIORITIES = ['Low', 'Normal', 'High', 'Urgent'];
|
||||
|
||||
export function EscalationForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
userId,
|
||||
userName
|
||||
}: EscalationFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [type, setType] = useState('');
|
||||
const [priority, setPriority] = useState('Normal');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [callbackRequired, setCallbackRequired] = useState(false);
|
||||
const [callbackPhone, setCallbackPhone] = useState('');
|
||||
const [assigneeId, setAssigneeId] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!type || !subject || !description) {
|
||||
toast.error("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createEscalationTicket({
|
||||
userId,
|
||||
type: type as any,
|
||||
priority: priority as any,
|
||||
subject,
|
||||
description,
|
||||
callbackRequired,
|
||||
callbackPhone: callbackRequired ? callbackPhone : undefined,
|
||||
assigneeId: assigneeId || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
onOpenChange(false);
|
||||
setSubject('');
|
||||
setDescription('');
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Ticket className="h-5 w-5 text-primary" />
|
||||
Create Escalation Ticket
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Raise a new internal support ticket or escalation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Category <span className="text-red-500">*</span></Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TICKET_TYPES.map(t => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Priority <span className="text-red-500">*</span></Label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger className={cn(
|
||||
priority === 'High' ? 'text-orange-600 font-medium' :
|
||||
priority === 'Urgent' ? 'text-red-600 font-bold' : ''
|
||||
)}>
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITIES.map(p => (
|
||||
<SelectItem key={p} value={p} className={
|
||||
p === 'High' ? 'text-orange-600' :
|
||||
p === 'Urgent' ? 'text-red-600 font-bold' : ''
|
||||
}>
|
||||
{p} {p === 'Urgent' && '🔥'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Subject <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="Brief summary of the issue..."
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description <span className="text-red-500">*</span></Label>
|
||||
<Textarea
|
||||
placeholder="Detailed explanation, steps to reproduce, or user context..."
|
||||
className="h-32 resize-none"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary/10 p-4 rounded-lg border border-border/50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="callback"
|
||||
checked={callbackRequired}
|
||||
onCheckedChange={(c) => setCallbackRequired(c as boolean)}
|
||||
/>
|
||||
<Label htmlFor="callback" className="font-medium flex items-center gap-1.5 cursor-pointer">
|
||||
<Phone className="h-3.5 w-3.5" /> Request Callback
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{callbackRequired && (
|
||||
<div className="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Callback Number</Label>
|
||||
<Input
|
||||
placeholder="+1 (555) 000-0000"
|
||||
className="h-8"
|
||||
value={callbackPhone}
|
||||
onChange={(e) => setCallbackPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Preferred Time</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="ASAP" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asap">ASAP</SelectItem>
|
||||
<SelectItem value="morning">Morning (9am - 12pm)</SelectItem>
|
||||
<SelectItem value="afternoon">Afternoon (1pm - 5pm)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Assign To (Optional)</Label>
|
||||
<Select value={assigneeId} onValueChange={setAssigneeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Auto-assign" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Auto-assign (Roost)</SelectItem>
|
||||
<SelectItem value="tier2">Tier 2 Support</SelectItem>
|
||||
<SelectItem value="finance">Finance Team</SelectItem>
|
||||
<SelectItem value="fraud">Fraud & Risk</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Create Ticket
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
222
src/features/users/components/dialogs/ModerationDialog.tsx
Normal file
222
src/features/users/components/dialogs/ModerationDialog.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Loader2, ShieldAlert, Gavel, Calendar } from 'lucide-react';
|
||||
import { moderateUser } from '@/lib/actions/userActions';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ModerationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userId: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
type Mode = 'suspend' | 'ban';
|
||||
|
||||
const REASON_CATEGORIES = [
|
||||
"Fraudulent Activity",
|
||||
"Spam / Marketing",
|
||||
"Abusive Behavior",
|
||||
"Chargeback / Payment Issue",
|
||||
"Terms of Service Violation",
|
||||
"Duplicate Account",
|
||||
"Other"
|
||||
];
|
||||
|
||||
export function ModerationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
userId,
|
||||
userName
|
||||
}: ModerationDialogProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [mode, setMode] = useState<Mode>('suspend');
|
||||
const [reasonCategory, setReasonCategory] = useState('');
|
||||
const [reasonText, setReasonText] = useState('');
|
||||
const [duration, setDuration] = useState('7_days');
|
||||
const [notifyUser, setNotifyUser] = useState(true);
|
||||
const [revokeSessions, setRevokeSessions] = useState(true);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!reasonCategory) {
|
||||
toast.error("Please select a reason category");
|
||||
return;
|
||||
}
|
||||
if (reasonText.length < 10) {
|
||||
toast.error("Please provide more details (min 10 chars)");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await moderateUser({
|
||||
userId,
|
||||
mode,
|
||||
reasonCategory,
|
||||
reasonText,
|
||||
duration: mode === 'suspend' ? duration : undefined,
|
||||
notifyUser,
|
||||
revokeSessions
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Gavel className="h-5 w-5 text-destructive" />
|
||||
Moderate User: {userName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Restrict or block access for this account. This action is logged.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-6">
|
||||
{/* Mode Selection */}
|
||||
<div className="bg-secondary/10 p-3 rounded-lg border border-border/50">
|
||||
<RadioGroup
|
||||
defaultValue="suspend"
|
||||
value={mode}
|
||||
onValueChange={(v) => setMode(v as Mode)}
|
||||
className="flex items-center space-x-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="suspend" id="r-suspend" className="text-amber-600 border-amber-600" />
|
||||
<Label htmlFor="r-suspend" className="font-semibold cursor-pointer">Suspend Temporarily</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="ban" id="r-ban" className="text-destructive border-destructive" />
|
||||
<Label htmlFor="r-ban" className="font-semibold text-destructive cursor-pointer">Permament Ban</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Suspend Duration Settings */}
|
||||
{mode === 'suspend' && (
|
||||
<div className="space-y-3 animate-in fade-in duration-300">
|
||||
<Label>Suspension Duration</Label>
|
||||
<Select value={duration} onValueChange={setDuration}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="24_hours">24 Hours (Cool down)</SelectItem>
|
||||
<SelectItem value="3_days">3 Days</SelectItem>
|
||||
<SelectItem value="7_days">7 Days</SelectItem>
|
||||
<SelectItem value="30_days">30 Days</SelectItem>
|
||||
<SelectItem value="indefinite">Indefinite (Until Reviewed)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ban Warning */}
|
||||
{mode === 'ban' && (
|
||||
<Alert variant="destructive" className="animate-in fade-in duration-300 bg-destructive/5 text-destructive border-destructive/20">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
<AlertTitle>Critical Action</AlertTitle>
|
||||
<AlertDescription>
|
||||
Banning is permanent. The user will be unable to log in, create new accounts, or access their data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Reason Category</Label>
|
||||
<Select value={reasonCategory} onValueChange={setReasonCategory}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REASON_CATEGORIES.map(cat => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Internal Note / Evidence</Label>
|
||||
<Textarea
|
||||
placeholder="Details about why this action is being taken..."
|
||||
value={reasonText}
|
||||
onChange={(e) => setReasonText(e.target.value)}
|
||||
className="h-24 resize-none"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground text-right">{reasonText.length} chars (min 10)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="revokeSessions"
|
||||
checked={revokeSessions}
|
||||
onCheckedChange={(c) => setRevokeSessions(c as boolean)}
|
||||
/>
|
||||
<Label htmlFor="revokeSessions" className="text-sm font-normal">
|
||||
Force logout from all active sessions
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="notifyUser"
|
||||
checked={notifyUser}
|
||||
onCheckedChange={(c) => setNotifyUser(c as boolean)}
|
||||
/>
|
||||
<Label htmlFor="notifyUser" className="text-sm font-normal">
|
||||
{mode === 'ban' ? 'Send ban notice via email' : 'Send suspension notice via email'}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
variant={mode === 'ban' ? 'destructive' : 'default'}
|
||||
className={mode === 'suspend' ? 'bg-amber-600 hover:bg-amber-700 text-white' : ''}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode === 'ban' ? 'Confirm Ban' : 'Confirm Suspension'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
276
src/features/users/components/dialogs/NotificationComposer.tsx
Normal file
276
src/features/users/components/dialogs/NotificationComposer.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
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 { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Loader2, Send, Smartphone, Mail, Bell } from 'lucide-react';
|
||||
import { sendUserNotification } from '@/lib/actions/userActions';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface NotificationComposerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
type Channel = 'email' | 'in_app' | 'push';
|
||||
|
||||
export function NotificationComposer({
|
||||
open,
|
||||
onOpenChange,
|
||||
userId,
|
||||
userName,
|
||||
userEmail
|
||||
}: NotificationComposerProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [channel, setChannel] = useState<Channel>('email');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [ctaLabel, setCtaLabel] = useState('');
|
||||
const [ctaUrl, setCtaUrl] = useState('');
|
||||
const [sendCopy, setSendCopy] = useState(false);
|
||||
|
||||
const handleSend = () => {
|
||||
// Basic Validation
|
||||
if (channel === 'email' && !subject) {
|
||||
toast.error("Subject is required for emails");
|
||||
return;
|
||||
}
|
||||
if ((channel === 'in_app' || channel === 'push') && !title) {
|
||||
toast.error("Title is required");
|
||||
return;
|
||||
}
|
||||
if (!message) {
|
||||
toast.error("Message content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await sendUserNotification({
|
||||
userId,
|
||||
channel,
|
||||
subject: channel === 'email' ? subject : undefined,
|
||||
title: channel !== 'email' ? title : undefined,
|
||||
message,
|
||||
ctaLabel,
|
||||
ctaUrl,
|
||||
sendCopy
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
onOpenChange(false);
|
||||
// Reset form
|
||||
setMessage('');
|
||||
setSubject('');
|
||||
setTitle('');
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] p-0 overflow-hidden flex flex-col md:flex-row gap-0">
|
||||
|
||||
{/* Left Side: Form */}
|
||||
<div className="flex-1 p-6 space-y-6 overflow-y-auto max-h-[85vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notify {userName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a targeted message.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Channel</Label>
|
||||
<Tabs value={channel} onValueChange={(v) => setChannel(v as Channel)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="email">Email</TabsTrigger>
|
||||
<TabsTrigger value="in_app">In-App</TabsTrigger>
|
||||
<TabsTrigger value="push">Push</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{channel === 'email' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Subject Line</Label>
|
||||
<Input
|
||||
placeholder="Action Required: Verify your account"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
placeholder="New Feature Alert"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Message Body</Label>
|
||||
<Textarea
|
||||
placeholder="Type your message here..."
|
||||
className="h-32 resize-none"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>CTA Label (Optional)</Label>
|
||||
<Input
|
||||
placeholder="View Details"
|
||||
value={ctaLabel}
|
||||
onChange={(e) => setCtaLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Action URL (Optional)</Label>
|
||||
<Input
|
||||
placeholder="https://"
|
||||
value={ctaUrl}
|
||||
onChange={(e) => setCtaUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox
|
||||
id="sendCopy"
|
||||
checked={sendCopy}
|
||||
onCheckedChange={(c) => setSendCopy(c as boolean)}
|
||||
/>
|
||||
<Label htmlFor="sendCopy" className="text-sm font-normal text-muted-foreground">
|
||||
Send a copy to support inbox
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleSend} disabled={isPending}>
|
||||
{isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
Send Notification
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Preview */}
|
||||
<div className="w-[320px] bg-secondary/20 border-l border-border/50 p-6 hidden md:flex flex-col items-center justify-center relative">
|
||||
<div className="absolute top-4 left-0 w-full text-center">
|
||||
<span className="text-xs uppercase tracking-wider font-semibold text-muted-foreground">Live Preview</span>
|
||||
</div>
|
||||
|
||||
{/* Phone Frame for Push/In-App */}
|
||||
{channel !== 'email' && (
|
||||
<div className="w-[280px] h-[500px] bg-background border-4 border-slate-800 rounded-[2rem] shadow-xl overflow-hidden relative flex flex-col">
|
||||
<div className="h-6 bg-slate-800 w-full absolute top-0 left-0 z-10 flex justify-center">
|
||||
<div className="w-20 h-4 bg-black rounded-b-xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-gray-50 flex flex-col pt-10 px-4">
|
||||
{channel === 'push' && (
|
||||
<div className="mt-4 bg-white/80 backdrop-blur-sm p-3 rounded-xl shadow-sm border border-gray-100 mb-2 animate-in slide-in-from-top-4 duration-500">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="h-5 w-5 bg-primary rounded flex items-center justify-center">
|
||||
<Bell className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-gray-500 uppercase">EVENTIFY • NOW</span>
|
||||
</div>
|
||||
<p className="font-semibold text-sm text-gray-900">{title || "Notification Title"}</p>
|
||||
<p className="text-xs text-gray-600 line-clamp-3 leading-relaxed">
|
||||
{message || "Message content will appear here..."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channel === 'in_app' && (
|
||||
<div className="mt-auto mb-4 bg-white p-4 rounded-xl shadow-lg border border-gray-100 animate-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-bold text-gray-900">{title || "In-App Message"}</h4>
|
||||
<button className="text-gray-400 hover:text-gray-600">×</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{message || "Your detailed in-app message content..."}
|
||||
</p>
|
||||
{ctaLabel && (
|
||||
<Button size="sm" className="w-full">{ctaLabel}</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Preview Frame */}
|
||||
{channel === 'email' && (
|
||||
<div className="w-full h-full max-h-[500px] bg-white border border-border shadow-sm rounded-lg overflow-hidden flex flex-col">
|
||||
<div className="bg-gray-100 p-2 border-b border-gray-200 text-[10px] text-gray-500 flex flex-col gap-1">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-bold">To:</span> {userEmail}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-bold">Subject:</span> {subject || "Subject line..."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex-1 overflow-y-auto">
|
||||
<div className="mb-4">
|
||||
<div className="h-8 w-8 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||
<Mail className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">Hi {userName.split(' ')[0]},</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">
|
||||
{message || "Draft your email message..."}
|
||||
</p>
|
||||
</div>
|
||||
{ctaLabel && (
|
||||
<div className="py-4 text-center">
|
||||
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-8 pt-4 border-t border-gray-100 text-xs text-gray-400 text-center">
|
||||
© 2024 Eventify Inc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
134
src/features/users/components/dialogs/OrdersExportPopover.tsx
Normal file
134
src/features/users/components/dialogs/OrdersExportPopover.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Download, FileDown, CalendarDays } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface OrdersExportPopoverProps {
|
||||
onExport: (options: ExportOptions) => void;
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
range: string;
|
||||
includeColumns: string[];
|
||||
respectFilters: boolean;
|
||||
}
|
||||
|
||||
const AVAILABLE_COLUMNS = [
|
||||
{ id: 'id', label: 'Order ID' },
|
||||
{ id: 'eventName', label: 'Event Name' },
|
||||
{ id: 'amount', label: 'Amount' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
{ id: 'date', label: 'Date' },
|
||||
{ id: 'customer', label: 'Customer Info' },
|
||||
];
|
||||
|
||||
export function OrdersExportPopover({ onExport }: OrdersExportPopoverProps) {
|
||||
const [range, setRange] = useState('30d');
|
||||
const [respectFilters, setRespectFilters] = useState(true);
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>(AVAILABLE_COLUMNS.map(c => c.id));
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleColumnToggle = (id: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedColumns([...selectedColumns, id]);
|
||||
} else {
|
||||
setSelectedColumns(selectedColumns.filter(c => c !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
onExport({ range, includeColumns: selectedColumns, respectFilters });
|
||||
setOpen(false);
|
||||
toast.promise(new Promise(resolve => setTimeout(resolve, 2000)), {
|
||||
loading: 'Generating CSV...',
|
||||
success: 'Export downloaded successfully!',
|
||||
error: 'Failed to export'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-2">
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="p-4 border-b border-border/50 bg-secondary/5">
|
||||
<h4 className="font-semibold text-sm">Export Options</h4>
|
||||
<p className="text-xs text-muted-foreground">Customize your data export.</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Date Range</Label>
|
||||
<Select value={range} onValueChange={setRange}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="Select range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7d">Last 7 Days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 Days</SelectItem>
|
||||
<SelectItem value="90d">Last 3 months</SelectItem>
|
||||
<SelectItem value="all">All Time</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Columns</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AVAILABLE_COLUMNS.map(col => (
|
||||
<div key={col.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`col-${col.id}`}
|
||||
checked={selectedColumns.includes(col.id)}
|
||||
onCheckedChange={(c) => handleColumnToggle(col.id, c as boolean)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`col-${col.id}`} className="text-xs font-normal cursor-pointer">
|
||||
{col.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-1">
|
||||
<Checkbox
|
||||
id="respect-filters"
|
||||
checked={respectFilters}
|
||||
onCheckedChange={(c) => setRespectFilters(c as boolean)}
|
||||
/>
|
||||
<Label htmlFor="respect-filters" className="text-xs font-normal cursor-pointer">
|
||||
Apply current table filters
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-secondary/5 border-t border-border/50 flex justify-end">
|
||||
<Button size="sm" onClick={handleExport} className="w-full">
|
||||
<Download className="mr-2 h-3.5 w-3.5" /> Download .CSV
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
134
src/features/users/components/dialogs/ReceiptViewer.tsx
Normal file
134
src/features/users/components/dialogs/ReceiptViewer.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Download, Printer, Copy, CreditCard } from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { formatCurrency } from '@/data/mockData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ReceiptViewerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
booking: any; // Using any for now, ideally UserBooking
|
||||
}
|
||||
|
||||
export function ReceiptViewer({
|
||||
open,
|
||||
onOpenChange,
|
||||
booking
|
||||
}: ReceiptViewerProps) {
|
||||
if (!booking) return null;
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("Copied to clipboard");
|
||||
};
|
||||
|
||||
// Mock calculations for the receipt
|
||||
const basePrice = booking.amount * 0.9;
|
||||
const tax = booking.amount * 0.1;
|
||||
const fee = 2.50; // Mock processing fee
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px] p-0 overflow-hidden flex flex-col bg-white">
|
||||
<div className="bg-slate-900 p-6 text-white text-center rounded-t-lg">
|
||||
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<CreditCard className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold tracking-tight">Receipt from Eventify</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">Thank you for your business.</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Meta Data */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wider">Order ID</p>
|
||||
<div className="flex items-center gap-1 font-mono font-medium hover:text-primary cursor-pointer" onClick={() => copyToClipboard(booking.id)}>
|
||||
{booking.id.substring(0, 12)}...
|
||||
<Copy className="h-3 w-3 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wider">Date</p>
|
||||
<p className="font-medium">{new Date(booking.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-slate-100" />
|
||||
|
||||
{/* Customer Info */}
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-2">Billed To</p>
|
||||
<p className="font-semibold text-sm">User ID: {booking.userId}</p>
|
||||
<p className="text-sm text-slate-500">Payment Method: Visa •••• 4242</p>
|
||||
</div>
|
||||
|
||||
{/* Line Items */}
|
||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-muted-foreground text-left border-b border-slate-200">
|
||||
<th className="pb-2 font-medium">Description</th>
|
||||
<th className="pb-2 font-medium text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tr>
|
||||
<td className="py-3">
|
||||
<p className="font-medium text-slate-900">{booking.eventName}</p>
|
||||
<p className="text-xs text-slate-500">{booking.ticketType} x {booking.quantity}</p>
|
||||
</td>
|
||||
<td className="py-3 text-right tabular-nums text-slate-900">
|
||||
{formatCurrency(basePrice)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-slate-500">Processing Fees</td>
|
||||
<td className="py-2 text-right tabular-nums text-slate-500">{formatCurrency(fee)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 text-slate-500">Tax (10%)</td>
|
||||
<td className="py-2 text-right tabular-nums text-slate-500">{formatCurrency(tax)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td className="pt-3 font-bold text-slate-900">Total</td>
|
||||
<td className="pt-3 font-bold text-right tabular-nums text-slate-900">{formatCurrency(booking.amount + fee)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex justify-center">
|
||||
<Badge variant="outline" className="px-3 py-1 font-normal text-slate-500 border-slate-200 bg-slate-50">
|
||||
Status: <span className="font-semibold text-slate-900 ml-1">{booking.status}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-4 border-t border-slate-100 bg-slate-50 flex gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()}>
|
||||
<Printer className="mr-2 h-4 w-4" /> Print
|
||||
</Button>
|
||||
<Button variant="default" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" /> Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -16,23 +16,35 @@ 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' },
|
||||
];
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { getUserAuditLogs } from '../../data/mockUserCrmData';
|
||||
import type { AuditLog, AuditAction } from '@/lib/types/user';
|
||||
|
||||
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';
|
||||
interface AuditTabProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function AuditTab({ userId }: AuditTabProps) {
|
||||
const logs = getUserAuditLogs(userId).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
|
||||
const getActionDetails = (action: AuditAction) => {
|
||||
switch (action) {
|
||||
case 'suspended':
|
||||
case 'banned':
|
||||
return { icon: Shield, color: 'bg-red-100 text-red-600', label: 'Security Action' };
|
||||
case 'user_created':
|
||||
case 'user_updated':
|
||||
return { icon: UserCog, color: 'bg-blue-100 text-blue-600', label: 'Profile Update' };
|
||||
case 'note_added':
|
||||
return { icon: Flag, color: 'bg-amber-100 text-amber-600', label: 'Note Added' };
|
||||
case 'notification_sent':
|
||||
return { icon: Activity, color: 'bg-purple-100 text-purple-600', label: 'Communication' };
|
||||
case 'receipt_viewed':
|
||||
return { icon: Activity, color: 'bg-slate-100 text-slate-600', label: 'Data Access' };
|
||||
case 'escalation_created':
|
||||
return { icon: AlertTriangle, color: 'bg-orange-100 text-orange-600', label: 'Escalation' };
|
||||
default:
|
||||
return { icon: Settings, color: 'bg-gray-100 text-gray-600', label: 'System' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,26 +56,44 @@ export function AuditTab({ userId }: AuditTabProps) {
|
||||
|
||||
<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>
|
||||
{logs.map((log) => {
|
||||
const { icon: Icon, color, label } = getActionDetails(log.action);
|
||||
return (
|
||||
<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 ${color}`}>
|
||||
<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 className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium capitalize">{log.action.replace('_', ' ')}</span>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1 font-normal text-muted-foreground">
|
||||
{log.actorName} ({log.actorRole})
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground bg-secondary/10 px-1.5 py-0.5 rounded">
|
||||
IP: {log.ipAddress}
|
||||
</span>
|
||||
</div>
|
||||
{log.metadata && (
|
||||
<div className="mt-1 bg-secondary/5 p-1.5 rounded text-[11px] font-mono text-muted-foreground break-all">
|
||||
{JSON.stringify(log.metadata)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{logs.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
No audit logs found for this user.
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useTransition, useState } from 'react';
|
||||
import { ReceiptViewer } from '../dialogs/ReceiptViewer';
|
||||
import { OrdersExportPopover } from '../dialogs/OrdersExportPopover';
|
||||
import type { UserBooking } from '@/lib/types/user';
|
||||
import {
|
||||
Table,
|
||||
@@ -32,11 +34,7 @@ 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';
|
||||
@@ -49,6 +47,7 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [refundDialogOpen, setRefundDialogOpen] = useState(false);
|
||||
const [selectedBooking, setSelectedBooking] = useState<UserBooking | null>(null);
|
||||
const [selectedBookingForReceipt, setSelectedBookingForReceipt] = useState<UserBooking | null>(null);
|
||||
const [refundReason, setRefundReason] = useState('');
|
||||
|
||||
const handleRefundClick = (booking: UserBooking) => {
|
||||
@@ -57,6 +56,33 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
||||
setRefundDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleExport = (options: any) => {
|
||||
// Client-side export logic
|
||||
const headers = ['Order ID', 'Event', 'Date', 'Ticket', 'Quantity', 'Amount', 'Status'];
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...bookings.map(b => [
|
||||
b.id,
|
||||
`"${b.eventName}"`,
|
||||
new Date(b.eventDate).toLocaleDateString(),
|
||||
b.ticketType,
|
||||
b.quantity,
|
||||
b.amount,
|
||||
b.status
|
||||
].join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `orders_export_${options.range}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const confirmRefund = () => {
|
||||
if (!selectedBooking) return;
|
||||
|
||||
@@ -86,9 +112,7 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
||||
<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>
|
||||
<OrdersExportPopover onExport={handleExport} />
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
@@ -133,7 +157,7 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>View Receipt</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSelectedBookingForReceipt(booking)}>View Receipt</DropdownMenuItem>
|
||||
{booking.status === 'Confirmed' && (
|
||||
<DropdownMenuItem onClick={() => handleRefundClick(booking)} className="text-red-600 focus:text-red-600">
|
||||
Process Refund
|
||||
@@ -155,6 +179,13 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Receipt Dialog */}
|
||||
<ReceiptViewer
|
||||
open={!!selectedBookingForReceipt}
|
||||
onOpenChange={(open) => !open && setSelectedBookingForReceipt(null)}
|
||||
booking={selectedBookingForReceipt}
|
||||
/>
|
||||
|
||||
{/* Refund Dialog */}
|
||||
<Dialog open={refundDialogOpen} onOpenChange={setRefundDialogOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -5,22 +5,7 @@ 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 { EscalationForm } from '../dialogs/EscalationForm';
|
||||
import {
|
||||
MessageSquare,
|
||||
Mail,
|
||||
@@ -29,7 +14,6 @@ import {
|
||||
Headphones,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { createSupportTicket } from '@/lib/actions/user-tabs';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface SupportTabProps {
|
||||
@@ -37,87 +21,22 @@ interface SupportTabProps {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
// Filter state could go here in future
|
||||
// const [filter, setFilter] = useState('all');
|
||||
|
||||
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>
|
||||
<EscalationForm
|
||||
userId={user.id}
|
||||
userName={user.name}
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeline Stream */}
|
||||
|
||||
@@ -613,6 +613,8 @@ export const mockAuditLogs: AuditLog[] = [
|
||||
{ id: 'audit-004', actorId: 'usr-014', actorName: 'Amit Saxena', actorRole: 'Support Agent', action: 'note_added', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { noteId: 'note-002' }, ipAddress: '14.139.82.44', timestamp: '2025-08-20T11:00:00Z' },
|
||||
{ id: 'audit-005', actorId: 'usr-011', actorName: 'Aisha Khan', actorRole: 'Admin', action: 'tag_added', targetUserId: 'usr-007', targetUserName: 'Sneha Kapoor', changes: { before: { tags: ['VIP'] }, after: { tags: ['VIP', 'Influencer'] } }, ipAddress: '14.98.172.55', timestamp: '2024-02-14T09:00:00Z' },
|
||||
{ id: 'audit-006', actorId: 'usr-011', actorName: 'Aisha Khan', actorRole: 'Admin', action: 'notification_sent', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { notificationId: 'notif-001', title: 'VIP Early Access' }, ipAddress: '14.98.172.55', timestamp: '2026-01-20T09:55:00Z' },
|
||||
{ id: 'audit-007', actorId: 'usr-014', actorName: 'Amit Saxena', actorRole: 'Support Agent', action: 'receipt_viewed', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { orderId: 'ord-001' }, ipAddress: '14.139.82.44', timestamp: '2026-02-09T10:05:00Z' },
|
||||
{ id: 'audit-008', actorId: 'usr-014', actorName: 'Amit Saxena', actorRole: 'Support Agent', action: 'escalation_created', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { ticketId: 'tkt-005', type: 'Fraud', priority: 'High' }, ipAddress: '14.139.82.44', timestamp: '2026-02-09T10:10:00Z' },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { logAdminAction } from '@/lib/audit-logger';
|
||||
import type { User } from '@/lib/types/user';
|
||||
|
||||
220
src/lib/actions/userActions.ts
Normal file
220
src/lib/actions/userActions.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { z } from 'zod';
|
||||
import { logAdminAction } from '@/lib/audit-logger';
|
||||
import { revokeUserSession } from './user-tabs'; // Re-use existing session revocation
|
||||
import type { UserStatus, SuspensionReason, SuspensionDuration, TicketPriority, TicketType } from '@/lib/types/user';
|
||||
|
||||
// --- Validation Schemas ---
|
||||
|
||||
const notificationSchema = z.object({
|
||||
userId: z.string(),
|
||||
channel: z.enum(['email', 'in_app', 'push']),
|
||||
subject: z.string().optional(), // Required for email
|
||||
title: z.string().optional(), // Required for in_app/push
|
||||
message: z.string().min(1, "Message is required"),
|
||||
ctaLabel: z.string().optional(),
|
||||
ctaUrl: z.string().url().optional().or(z.literal('')),
|
||||
sendCopy: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const moderationSchema = z.object({
|
||||
userId: z.string(),
|
||||
mode: z.enum(['suspend', 'ban']),
|
||||
reasonCategory: z.string(),
|
||||
reasonText: z.string().min(10, "Reason details must be at least 10 characters"),
|
||||
duration: z.string().optional(), // For suspension
|
||||
customDate: z.date().optional(), // For custom suspension
|
||||
notifyUser: z.boolean().optional(),
|
||||
revokeSessions: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const escalationSchema = z.object({
|
||||
userId: z.string(),
|
||||
type: z.enum(['Refund', 'Payment', 'Account access', 'Fraud', 'Event issue', 'Other']),
|
||||
priority: z.enum(['Low', 'Normal', 'High', 'Urgent']),
|
||||
subject: z.string().min(5),
|
||||
description: z.string().min(10),
|
||||
callbackRequired: z.boolean().optional(),
|
||||
callbackPhone: z.string().optional(),
|
||||
callbackTime: z.string().optional(),
|
||||
assigneeId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
// --- Authorization Helper ---
|
||||
// TODO: Replace with real auth check
|
||||
async function verifyAdmin(requiredRole: 'admin' | 'manager' | 'support' = 'support') {
|
||||
// Mock check
|
||||
return { id: 'admin-1', role: 'admin', name: 'Sarah Admin' };
|
||||
}
|
||||
|
||||
// --- Server Actions ---
|
||||
|
||||
/**
|
||||
* Sends a targeted notification to a user via Email, In-App, or Push.
|
||||
*/
|
||||
export async function sendUserNotification(data: z.infer<typeof notificationSchema>) {
|
||||
try {
|
||||
const admin = await verifyAdmin();
|
||||
|
||||
const validated = notificationSchema.safeParse(data);
|
||||
if (!validated.success) return { success: false, message: "Invalid notification data" };
|
||||
|
||||
const { userId, channel, subject, title, message } = validated.data;
|
||||
|
||||
// Validation logic for channels
|
||||
if (channel === 'email' && !subject) {
|
||||
return { success: false, message: "Subject is required for emails" };
|
||||
}
|
||||
if ((channel === 'in_app' || channel === 'push') && !title) {
|
||||
return { success: false, message: "Title is required for notifications" };
|
||||
}
|
||||
|
||||
// Simulate Provider API Call (SendGrid / Firebase)
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
await logAdminAction({
|
||||
actorId: admin.id,
|
||||
action: 'notification_sent',
|
||||
targetId: userId,
|
||||
details: { channel, subject, title, messageSnippet: message.substring(0, 50) }
|
||||
});
|
||||
|
||||
// Optimistic revalidation (if we had a notifications list page)
|
||||
// revalidatePath(`/admin/users/${userId}`);
|
||||
|
||||
return { success: true, message: `Notification sent via ${channel}` };
|
||||
} catch (error) {
|
||||
console.error("sendUserNotification error:", error);
|
||||
return { success: false, message: "Failed to send notification" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified moderation action to Suspend or Ban a user.
|
||||
* Handles Mock DB updates, session revocation, and audit logging.
|
||||
*/
|
||||
export async function moderateUser(data: z.infer<typeof moderationSchema>) {
|
||||
try {
|
||||
const admin = await verifyAdmin('manager'); // Higher privilege required
|
||||
|
||||
const validated = moderationSchema.safeParse(data);
|
||||
if (!validated.success) return { success: false, message: "Invalid moderation data" };
|
||||
|
||||
const { userId, mode, reasonCategory, reasonText, duration, revokeSessions, notifyUser } = validated.data;
|
||||
|
||||
// 1. Revoke sessions if requested
|
||||
if (revokeSessions) {
|
||||
// We can call the session revocation logic here
|
||||
// In a real app we'd await this, but we'll let it run or await if critical
|
||||
await revokeUserSession('all'); // Mocking 'all' sessionId handling
|
||||
}
|
||||
|
||||
// 2. Simulate DB Update for Status
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const newStatus: UserStatus = mode === 'ban' ? 'Banned' : 'Suspended';
|
||||
|
||||
// 3. Log Action
|
||||
await logAdminAction({
|
||||
actorId: admin.id,
|
||||
action: mode === 'ban' ? 'banned' : 'suspended',
|
||||
targetId: userId,
|
||||
details: {
|
||||
reasonCategory,
|
||||
reasonText,
|
||||
duration: mode === 'suspend' ? duration : 'permanent',
|
||||
notifyUser
|
||||
}
|
||||
});
|
||||
|
||||
// revalidatePath(`/admin/users`);
|
||||
|
||||
return { success: true, message: `User ${mode === 'ban' ? 'Banned' : 'Suspended'} successfully` };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Moderation action failed" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a high-priority support ticket (Escalation).
|
||||
* Includes routing logic and potential callback scheduling.
|
||||
*/
|
||||
export async function createEscalationTicket(data: z.infer<typeof escalationSchema>) {
|
||||
try {
|
||||
const admin = await verifyAdmin();
|
||||
|
||||
const validated = escalationSchema.safeParse(data);
|
||||
if (!validated.success) return { success: false, message: "Invalid escalation data" };
|
||||
|
||||
const { userId, type, priority, subject, callbackRequired } = validated.data;
|
||||
|
||||
// Simulate Logic: Auto-route critical tickets
|
||||
let autoAssigned = validated.data.assigneeId;
|
||||
if (!autoAssigned && (priority === 'High' || priority === 'Urgent')) {
|
||||
autoAssigned = 'team-lead-1'; // Mock auto-assignment
|
||||
}
|
||||
|
||||
// Simulate DB Insert
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
|
||||
await logAdminAction({
|
||||
actorId: admin.id,
|
||||
action: 'escalation_created',
|
||||
targetId: userId,
|
||||
details: {
|
||||
type,
|
||||
priority,
|
||||
subject,
|
||||
callbackRequired,
|
||||
assignedTo: autoAssigned
|
||||
}
|
||||
});
|
||||
|
||||
// revalidatePath(`/admin/users/${userId}?tab=support`);
|
||||
|
||||
return { success: true, message: isUrgent(priority) ? "Escalation routed to Team Lead" : "Support ticket created" };
|
||||
} catch (error) {
|
||||
return { success: false, message: "Failed to create escalation" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine urgency for toast messages
|
||||
*/
|
||||
function isUrgent(priority: string) {
|
||||
return priority === 'High' || priority === 'Urgent';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock function to list user notifications for the support tab
|
||||
*/
|
||||
export async function listUserNotifications(userId: string) {
|
||||
// Simulate fetching from DB
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'notif-1',
|
||||
userId,
|
||||
title: 'Security Alert',
|
||||
body: 'New login detected from Lisbon, Portugal',
|
||||
actionUrl: '/settings/security',
|
||||
priority: 'high',
|
||||
status: 'Delivered',
|
||||
channel: 'push',
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'system'
|
||||
},
|
||||
{
|
||||
id: 'notif-2',
|
||||
userId,
|
||||
title: 'Welcome to Eventify',
|
||||
body: 'Thanks for joining! here is a quick guide.',
|
||||
priority: 'normal',
|
||||
status: 'Delivered',
|
||||
channel: 'email',
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
createdBy: 'system'
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -252,7 +252,9 @@ export type AuditAction =
|
||||
| 'ticket_assigned'
|
||||
| 'ticket_resolved'
|
||||
| 'data_exported'
|
||||
| 'data_anonymized';
|
||||
| 'data_anonymized'
|
||||
| 'receipt_viewed'
|
||||
| 'escalation_created';
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user