feat(users): implement advanced user actions (notifications, moderation, support, audit)
This commit is contained in:
@@ -35,8 +35,9 @@ import {
|
|||||||
interface ActionButtonsProps {
|
interface ActionButtonsProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
onSuspend?: () => void; // Provided by parent to open modal
|
onSuspend?: () => void;
|
||||||
onSendNotification?: () => void; // Provided by parent
|
onSendNotification?: () => void;
|
||||||
|
onCreateTicket?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActionButtons({
|
export function ActionButtons({
|
||||||
@@ -44,6 +45,7 @@ export function ActionButtons({
|
|||||||
userName,
|
userName,
|
||||||
onSuspend,
|
onSuspend,
|
||||||
onSendNotification,
|
onSendNotification,
|
||||||
|
onCreateTicket,
|
||||||
}: ActionButtonsProps) {
|
}: ActionButtonsProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -97,20 +99,9 @@ export function ActionButtons({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateTicket = () => {
|
const handleCreateTicket = () => {
|
||||||
// Quick action: create a generic ticket for now
|
if (onCreateTicket) {
|
||||||
// In reality, this might open a dialog
|
onCreateTicket();
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ import { BookingsTab } from './tabs/BookingsTab';
|
|||||||
import { SecurityTab } from './tabs/SecurityTab';
|
import { SecurityTab } from './tabs/SecurityTab';
|
||||||
import { SupportTab } from './tabs/SupportTab';
|
import { SupportTab } from './tabs/SupportTab';
|
||||||
import { AuditTab } from './tabs/AuditTab';
|
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 { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -84,6 +87,10 @@ export function UserInspectorSheet({
|
|||||||
}: UserInspectorSheetProps) {
|
}: UserInspectorSheetProps) {
|
||||||
const [activeTab, setActiveTab] = useState('overview');
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
|
||||||
|
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||||
|
const [moderationOpen, setModerationOpen] = useState(false);
|
||||||
|
const [escalationOpen, setEscalationOpen] = useState(false);
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
// Fetch related data
|
// Fetch related data
|
||||||
@@ -208,8 +215,9 @@ export function UserInspectorSheet({
|
|||||||
<ActionButtons
|
<ActionButtons
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
userName={user.name}
|
userName={user.name}
|
||||||
onSuspend={() => handleAction('Suspend')}
|
onSuspend={() => setModerationOpen(true)}
|
||||||
onSendNotification={() => onSendNotification(user)}
|
onSendNotification={() => setNotificationOpen(true)}
|
||||||
|
onCreateTicket={() => setEscalationOpen(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -254,6 +262,27 @@ export function UserInspectorSheet({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Tabs>
|
</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>
|
</SheetContent>
|
||||||
</Sheet>
|
</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;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuditTab({ userId }: AuditTabProps) {
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
// Mock Data for now
|
import { getUserAuditLogs } from '../../data/mockUserCrmData';
|
||||||
const logs = [
|
import type { AuditLog, AuditAction } from '@/lib/types/user';
|
||||||
{ 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) => {
|
interface AuditTabProps {
|
||||||
switch (type) {
|
userId: string;
|
||||||
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';
|
export function AuditTab({ userId }: AuditTabProps) {
|
||||||
case 'blue': return 'bg-blue-100 text-blue-600';
|
const logs = getUserAuditLogs(userId).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
default: return 'bg-slate-100 text-slate-600';
|
|
||||||
|
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">
|
<ScrollArea className="h-[400px] pr-4">
|
||||||
<div className="space-y-6 pl-2">
|
<div className="space-y-6 pl-2">
|
||||||
{logs.map((log, index) => (
|
{logs.map((log) => {
|
||||||
<div key={log.id} className="relative pl-6 pb-2 border-l border-border/40 last:border-0">
|
const { icon: Icon, color, label } = getActionDetails(log.action);
|
||||||
{/* Connector Line */}
|
return (
|
||||||
<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)}`}>
|
<div key={log.id} className="relative pl-6 pb-2 border-l border-border/40 last:border-0">
|
||||||
<log.icon className="h-3.5 w-3.5" />
|
{/* Connector Line */}
|
||||||
</div>
|
<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 flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">{log.action}</span>
|
<span className="text-sm font-medium capitalize">{log.action.replace('_', ' ')}</span>
|
||||||
<span className="text-xs text-muted-foreground">{log.timestamp}</span>
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
</div>
|
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
|
||||||
<div className="flex items-center gap-2 text-xs">
|
</span>
|
||||||
<Badge variant="outline" className="text-[10px] h-4 px-1 font-normal text-muted-foreground">
|
</div>
|
||||||
{log.actor}
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
</Badge>
|
<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>
|
||||||
</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>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTransition, useState } from 'react';
|
import { useTransition, useState } from 'react';
|
||||||
|
import { ReceiptViewer } from '../dialogs/ReceiptViewer';
|
||||||
|
import { OrdersExportPopover } from '../dialogs/OrdersExportPopover';
|
||||||
import type { UserBooking } from '@/lib/types/user';
|
import type { UserBooking } from '@/lib/types/user';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -32,11 +34,7 @@ import { formatCurrency } from '@/data/mockData';
|
|||||||
import { processRefund } from '@/lib/actions/user-tabs';
|
import { processRefund } from '@/lib/actions/user-tabs';
|
||||||
import {
|
import {
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Download,
|
|
||||||
Ticket,
|
Ticket,
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
Loader2
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -49,6 +47,7 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [refundDialogOpen, setRefundDialogOpen] = useState(false);
|
const [refundDialogOpen, setRefundDialogOpen] = useState(false);
|
||||||
const [selectedBooking, setSelectedBooking] = useState<UserBooking | null>(null);
|
const [selectedBooking, setSelectedBooking] = useState<UserBooking | null>(null);
|
||||||
|
const [selectedBookingForReceipt, setSelectedBookingForReceipt] = useState<UserBooking | null>(null);
|
||||||
const [refundReason, setRefundReason] = useState('');
|
const [refundReason, setRefundReason] = useState('');
|
||||||
|
|
||||||
const handleRefundClick = (booking: UserBooking) => {
|
const handleRefundClick = (booking: UserBooking) => {
|
||||||
@@ -57,6 +56,33 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
|||||||
setRefundDialogOpen(true);
|
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 = () => {
|
const confirmRefund = () => {
|
||||||
if (!selectedBooking) return;
|
if (!selectedBooking) return;
|
||||||
|
|
||||||
@@ -86,9 +112,7 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold">Transaction History</h3>
|
<h3 className="text-sm font-semibold">Transaction History</h3>
|
||||||
<Button variant="outline" size="sm" className="h-8 gap-2">
|
<OrdersExportPopover onExport={handleExport} />
|
||||||
<Download className="h-3.5 w-3.5" /> Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
@@ -133,7 +157,7 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem>View Receipt</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setSelectedBookingForReceipt(booking)}>View Receipt</DropdownMenuItem>
|
||||||
{booking.status === 'Confirmed' && (
|
{booking.status === 'Confirmed' && (
|
||||||
<DropdownMenuItem onClick={() => handleRefundClick(booking)} className="text-red-600 focus:text-red-600">
|
<DropdownMenuItem onClick={() => handleRefundClick(booking)} className="text-red-600 focus:text-red-600">
|
||||||
Process Refund
|
Process Refund
|
||||||
@@ -155,6 +179,13 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Receipt Dialog */}
|
||||||
|
<ReceiptViewer
|
||||||
|
open={!!selectedBookingForReceipt}
|
||||||
|
onOpenChange={(open) => !open && setSelectedBookingForReceipt(null)}
|
||||||
|
booking={selectedBookingForReceipt}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Refund Dialog */}
|
{/* Refund Dialog */}
|
||||||
<Dialog open={refundDialogOpen} onOpenChange={setRefundDialogOpen}>
|
<Dialog open={refundDialogOpen} onOpenChange={setRefundDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -5,22 +5,7 @@ import type { User } from '@/lib/types/user';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { EscalationForm } from '../dialogs/EscalationForm';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -29,7 +14,6 @@ import {
|
|||||||
Headphones,
|
Headphones,
|
||||||
Loader2
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { createSupportTicket } from '@/lib/actions/user-tabs';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface SupportTabProps {
|
interface SupportTabProps {
|
||||||
@@ -37,87 +21,22 @@ interface SupportTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SupportTab({ user }: SupportTabProps) {
|
export function SupportTab({ user }: SupportTabProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
// Form State
|
// Filter state could go here in future
|
||||||
const [subject, setSubject] = useState('');
|
// const [filter, setFilter] = useState('all');
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold">Communication Timeline</h3>
|
<h3 className="text-sm font-semibold">Communication Timeline</h3>
|
||||||
|
|
||||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
<EscalationForm
|
||||||
<DialogTrigger asChild>
|
userId={user.id}
|
||||||
<Button size="sm" className="h-8 gap-2">
|
userName={user.name}
|
||||||
<Plus className="h-3.5 w-3.5" /> Create Ticket
|
open={createDialogOpen}
|
||||||
</Button>
|
onOpenChange={setCreateDialogOpen}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Stream */}
|
{/* 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-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-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-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 { z } from 'zod';
|
||||||
import { logAdminAction } from '@/lib/audit-logger';
|
import { logAdminAction } from '@/lib/audit-logger';
|
||||||
import type { User } from '@/lib/types/user';
|
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_assigned'
|
||||||
| 'ticket_resolved'
|
| 'ticket_resolved'
|
||||||
| 'data_exported'
|
| 'data_exported'
|
||||||
| 'data_anonymized';
|
| 'data_anonymized'
|
||||||
|
| 'receipt_viewed'
|
||||||
|
| 'escalation_created';
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user