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
This commit is contained in:
279
src/features/partners/components/EventApprovalQueue.tsx
Normal file
279
src/features/partners/components/EventApprovalQueue.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user