Files
eventify_command_center/src/features/partners/components/EventApprovalQueue.tsx
CycroftX 49770dfe73 feat: Partner Command Center Module
- Extend types/partner.ts: riskScore, KYCDocument, PartnerEvent, RiskLevel
- Extend mockPartnerData.ts: risk scores, 15 KYC docs, 9 partner events, 6th partner
- Create lib/actions/partner-governance.ts: KYC verification, event approval, impersonation, 2FA/password reset, suspend/unsuspend
- Rewrite PartnerDirectory.tsx: card grid → data table with stats, risk gauge, filter tabs
- Rewrite PartnerProfile.tsx: tabs → 3-column layout (Identity | KYC Vault | Event Governance)
- Create KYCVaultPanel.tsx: per-doc approve/reject with progress bar and auto-verification
- Create EventApprovalQueue.tsx: pending events list with review dialog
- Create ImpersonationDialog.tsx: audit-aware confirmation with token generation
- Extend prisma/schema.prisma: PartnerProfile, PartnerDoc models, KYC/Event enums
- Add partner governance permission scopes to staff.ts
2026-02-11 10:06:30 +05:30

280 lines
12 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { PartnerEvent } from '@/types/partner';
import { approvePartnerEvent, rejectPartnerEvent } from '@/lib/actions/partner-governance';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Calendar,
MapPin,
Ticket,
CheckCircle2,
XCircle,
Clock,
Eye,
DollarSign,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface EventApprovalQueueProps {
partnerId: string;
events: PartnerEvent[];
onEventUpdated?: () => void;
}
const EVENT_STATUS_STYLES: Record<string, string> = {
PENDING_REVIEW: 'bg-warning/10 text-warning border-warning/20',
LIVE: 'bg-success/10 text-success border-success/20',
DRAFT: 'bg-muted text-muted-foreground border-border',
COMPLETED: 'bg-primary/10 text-primary border-primary/20',
CANCELLED: 'bg-destructive/10 text-destructive border-destructive/20',
REJECTED: 'bg-destructive/10 text-destructive border-destructive/20',
};
export function EventApprovalQueue({ partnerId, events, onEventUpdated }: EventApprovalQueueProps) {
const [eventList, setEventList] = useState(events);
const [reviewingEvent, setReviewingEvent] = useState<PartnerEvent | null>(null);
const [rejectionReason, setRejectionReason] = useState('');
const [processing, setProcessing] = useState(false);
const pendingEvents = eventList.filter(e => e.status === 'PENDING_REVIEW');
const otherEvents = eventList.filter(e => e.status !== 'PENDING_REVIEW');
const handleApprove = useCallback(async (eventId: string) => {
setProcessing(true);
try {
const result = await approvePartnerEvent(eventId);
if (result.success) {
toast.success(result.message);
setEventList(prev =>
prev.map(e => e.id === eventId ? { ...e, status: 'LIVE' as const } : e)
);
setReviewingEvent(null);
onEventUpdated?.();
} else {
toast.error(result.message);
}
} catch {
toast.error('Failed to approve event.');
} finally {
setProcessing(false);
}
}, [onEventUpdated]);
const handleReject = useCallback(async (eventId: string) => {
if (!rejectionReason.trim()) {
toast.error('Please provide a reason for rejection.');
return;
}
setProcessing(true);
try {
const result = await rejectPartnerEvent(eventId, rejectionReason);
if (result.success) {
toast.success(result.message);
setEventList(prev =>
prev.map(e => e.id === eventId ? { ...e, status: 'REJECTED' as const, rejectionReason } : e)
);
setReviewingEvent(null);
setRejectionReason('');
onEventUpdated?.();
} else {
toast.error(result.message);
}
} catch {
toast.error('Failed to reject event.');
} finally {
setProcessing(false);
}
}, [rejectionReason, onEventUpdated]);
const EventCard = ({ event, showActions }: { event: PartnerEvent; showActions: boolean }) => (
<div className="p-3 rounded-lg border border-border/50 bg-card/50 hover:border-border transition-colors space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{event.title}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(event.date).toLocaleDateString()}
</span>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{event.venue}
</span>
</div>
</div>
<Badge variant="outline" className={cn('text-[10px] shrink-0', EVENT_STATUS_STYLES[event.status])}>
{event.status.replace('_', ' ')}
</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Ticket className="h-3 w-3" />
{event.ticketsSold}/{event.totalTickets} sold
</span>
{event.ticketPrice > 0 && (
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
{event.ticketPrice}
</span>
)}
{event.category && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">{event.category}</Badge>
)}
</div>
{showActions && event.status === 'PENDING_REVIEW' && (
<div className="flex gap-2 pt-1">
<Button
size="sm"
variant="outline"
className="flex-1 text-xs gap-1.5 h-7"
onClick={() => setReviewingEvent(event)}
>
<Eye className="h-3 w-3" /> Review
</Button>
<Button
size="sm"
className="flex-1 text-xs gap-1.5 h-7 bg-success hover:bg-success/90 text-white"
onClick={() => handleApprove(event.id)}
>
<CheckCircle2 className="h-3 w-3" /> Approve
</Button>
</div>
)}
{event.status === 'REJECTED' && event.rejectionReason && (
<p className="text-xs text-destructive/80 italic pt-1">
Rejected: {event.rejectionReason}
</p>
)}
</div>
);
return (
<div className="space-y-4">
{/* Pending Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
Pending Approval
</h4>
{pendingEvents.length > 0 && (
<Badge variant="outline" className="bg-warning/10 text-warning border-warning/20 text-[10px]">
{pendingEvents.length} pending
</Badge>
)}
</div>
{pendingEvents.length > 0 ? (
<div className="space-y-2">
{pendingEvents.map(event => (
<EventCard key={event.id} event={event} showActions={true} />
))}
</div>
) : (
<div className="text-center py-6 text-muted-foreground text-sm border border-dashed border-border/50 rounded-lg">
<Clock className="h-8 w-8 mx-auto mb-2 opacity-30" />
No events pending review
</div>
)}
</div>
{/* Other Events */}
{otherEvents.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
All Events
</h4>
<div className="space-y-2">
{otherEvents.map(event => (
<EventCard key={event.id} event={event} showActions={false} />
))}
</div>
</div>
)}
{/* Review Dialog */}
<Dialog open={!!reviewingEvent} onOpenChange={(open) => { if (!open) { setReviewingEvent(null); setRejectionReason(''); } }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Review Event</DialogTitle>
<DialogDescription>
Review and approve or decline this event submission.
</DialogDescription>
</DialogHeader>
{reviewingEvent && (
<div className="space-y-4">
<div className="p-4 bg-secondary/30 rounded-lg space-y-3">
<h3 className="font-semibold">{reviewingEvent.title}</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
{new Date(reviewingEvent.date).toLocaleDateString()}
{reviewingEvent.time && ` at ${reviewingEvent.time}`}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4" />
{reviewingEvent.venue}
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Ticket className="h-4 w-4" />
{reviewingEvent.totalTickets} tickets
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<DollarSign className="h-4 w-4" />
{reviewingEvent.ticketPrice} each
</div>
</div>
{reviewingEvent.category && (
<Badge variant="secondary">{reviewingEvent.category}</Badge>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Rejection Reason (if declining)</label>
<Textarea
placeholder="Explain what needs to be fixed..."
value={rejectionReason}
onChange={e => setRejectionReason(e.target.value)}
className="min-h-[80px] resize-none"
/>
</div>
</div>
)}
<DialogFooter className="gap-2">
<Button
variant="destructive"
className="gap-1.5"
onClick={() => reviewingEvent && handleReject(reviewingEvent.id)}
disabled={processing}
>
<XCircle className="h-4 w-4" /> Decline
</Button>
<Button
className="bg-success hover:bg-success/90 text-white gap-1.5"
onClick={() => reviewingEvent && handleApprove(reviewingEvent.id)}
disabled={processing}
>
<CheckCircle2 className="h-4 w-4" /> Approve & Go Live
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}