- 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
280 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|