Files
Event-Management-System/RIT-EMS-main/frontend/src/components/dashboard/ApprovalsView.tsx

419 lines
18 KiB
TypeScript

import { API_BASE_URL } from '../../lib/config';
import React, { useEffect, useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
CheckCircle,
XCircle,
Clock,
User,
Building2,
ChevronRight,
AlertCircle,
ShieldCheck,
MapPin,
Wallet,
Users,
Layers,
Ticket,
AlertTriangle,
FileText,
Heart,
Calendar
} from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { cn } from '../../lib/utils';
import { Pagination } from './Pagination';
interface Event {
id: number;
title: string;
startDate: string;
endDate: string;
type: string;
institution: string;
department: string;
academicYears: string[];
status: string;
location: string;
proposer?: {
fullName: string;
department: string;
};
budget: number;
hasRegistrationFee: boolean;
registrationFee: number;
category: string;
targetedSections: string[];
conflictMessage?: string;
description?: string;
sponsors?: string[];
rejectionReason?: string;
}
export const ApprovalsView: React.FC = () => {
const { user } = useAuth();
const [events, setEvents] = useState<Event[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [processingId, setProcessingId] = useState<number | null>(null);
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(5);
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
try {
const response = await fetch(API_BASE_URL + '/api/events');
if (response.ok) {
const data = await response.json();
// Filter based on role
if (user?.role === 'HOD') {
const userDepts = user.department ? user.department.split(',').map(d => d.trim().toLowerCase()) : [];
setEvents(data.filter((e: Event) => e.status === 'REQUESTED' && userDepts.includes(e.department?.trim().toLowerCase())));
} else if (user?.role === 'PRINCIPAL') {
setEvents(data.filter((e: Event) => e.status === 'PENDING_PR'));
}
}
} catch (error) {
console.error('Failed to fetch events:', error);
} finally {
setIsLoading(false);
}
};
const handleAction = async (id: number, action: 'approve' | 'reject') => {
let reason = '';
if (action === 'reject') {
reason = window.prompt('Please enter a rejection reason:') || '';
if (!reason) return;
}
setProcessingId(id);
try {
const response = await fetch(`${API_BASE_URL}/api/events/${id}/${action}?userId=${user?.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: action === 'reject' ? JSON.stringify({ reason }) : undefined
});
if (response.ok) {
setEvents(events.filter(e => e.id !== id));
setSelectedEvent(null);
} else {
const err = await response.json();
alert(err.message || 'Operation failed');
}
} catch (error) {
console.error(`Failed to ${action} event:`, error);
} finally {
setProcessingId(null);
}
};
const sortedEvents = useMemo(() => {
return [...events].sort((a: any, b: any) => {
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
return dateB - dateA;
});
}, [events]);
const currentEvents = sortedEvents.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
return (
<div className="space-y-8">
<div>
<h2 className="text-3xl font-black text-text-dark tracking-tight">Pending Approvals</h2>
<p className="text-text-muted font-medium">Review and take action on event proposals from your {user?.role === 'HOD' ? 'department' : 'institution'}.</p>
</div>
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center p-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-indigo"></div>
</div>
) : events.length === 0 ? (
<div className="bg-white rounded-[2.5rem] p-20 text-center border border-slate-100 premium-shadow">
<CheckCircle className="w-16 h-16 text-emerald-500 mx-auto mb-6 opacity-20" />
<h3 className="text-xl font-black text-text-dark mb-2">Queue is Empty</h3>
<p className="text-text-muted italic">All proposals have been processed. Great work!</p>
</div>
) : (
<AnimatePresence mode="popLayout">
{currentEvents.map((event) => (
<motion.div
key={event.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
onClick={() => setSelectedEvent(event)}
className={cn(
"bg-white rounded-[2rem] p-6 border transition-all flex flex-col md:flex-row items-center justify-between gap-6 cursor-pointer group",
event.conflictMessage ? "border-red-200 bg-red-50/30" : "border-slate-100 hover:premium-shadow"
)}
>
<div className="flex items-center gap-6 w-full md:w-auto">
<div className={cn(
"w-16 h-16 rounded-2xl flex items-center justify-center font-black text-xl shadow-lg",
event.conflictMessage ? "bg-red-500 text-white" : "bg-brand-glow text-brand-indigo"
)}>
{event.title[0]}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
{event.category === 'CLUB' && (
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-600 rounded-md text-[8px] font-black uppercase tracking-widest border border-emerald-100">
Institutional Club
</span>
)}
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">
{event.type}
</span>
</div>
<h3 className="text-xl font-black text-text-dark leading-tight mb-2 group-hover:text-brand-indigo transition-colors">{event.title}</h3>
{event.conflictMessage ? (
<div className="flex items-center gap-2 text-red-500 text-[10px] font-black uppercase tracking-widest mt-1">
<AlertTriangle className="w-3.5 h-3.5" />
{event.conflictMessage}
</div>
) : (
<div className="flex items-center justify-center md:justify-start gap-4 text-xs font-bold text-text-muted">
<div className="flex items-center gap-1.5">
<User className="w-3.5 h-3.5" />
{event.proposer?.fullName || 'Faculty Member'}
</div>
<div className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{new Date(event.startDate).toLocaleDateString()}
</div>
</div>
)}
</div>
</div>
<div className="flex items-center gap-3 w-full md:w-auto" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleAction(event.id, 'reject')}
disabled={processingId === event.id}
className="flex-1 md:flex-none flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-red-50 text-red-600 hover:bg-red-100 transition-all font-black text-[10px] uppercase tracking-widest border border-red-100/50"
>
<XCircle className="w-4 h-4" />
Reject
</button>
<button
onClick={() => handleAction(event.id, 'approve')}
disabled={processingId === event.id || !!event.conflictMessage}
className={cn(
"flex-1 md:flex-none flex items-center justify-center gap-2 px-8 py-3 rounded-xl transition-all font-black text-[10px] uppercase tracking-widest premium-shadow",
event.conflictMessage
? "bg-slate-200 text-slate-400 cursor-not-allowed"
: "bg-brand-navy text-white hover:scale-105"
)}
>
{processingId === event.id ? 'Processing...' : (
<>
<CheckCircle className="w-4 h-4" />
Approve
</>
)}
</button>
</div>
</motion.div>
))}
</AnimatePresence>
)}
</div>
{events.length > 0 && (
<Pagination
currentPage={currentPage}
totalItems={events.length}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
onItemsPerPageChange={(val) => {
setItemsPerPage(val);
setCurrentPage(1);
}}
itemsPerPageOptions={[5, 10, 15, 20]}
/>
)}
{/* Detail Modal */}
<AnimatePresence>
{selectedEvent && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedEvent(null)}
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-2xl bg-white rounded-[2.5rem] premium-shadow overflow-hidden max-h-[90vh] overflow-y-auto"
>
<div className={cn(
"p-8 text-white flex justify-between items-start",
selectedEvent.conflictMessage ? "bg-red-600" : "bg-brand-navy"
)}>
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">Proposal Details</span>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">{selectedEvent.institution}</span>
{selectedEvent.category === 'CLUB' && (
<>
<span className="w-1 h-1 bg-white/30 rounded-full" />
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-400">Institutional Club Event</span>
</>
)}
</div>
<h3 className="text-2xl font-black tracking-tight">{selectedEvent.title}</h3>
</div>
<button
onClick={() => setSelectedEvent(null)}
className="p-2 hover:bg-white/10 rounded-xl transition-all"
>
<XCircle className="w-6 h-6" />
</button>
</div>
<div className="p-8 space-y-8">
{/* Description Section */}
{selectedEvent.description && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
<FileText className="w-3.5 h-3.5" />
Event Description
</div>
<p className="text-sm font-medium text-text-dark leading-relaxed bg-slate-50 p-4 rounded-2xl border border-slate-100">
{selectedEvent.description}
</p>
</div>
)}
<div className="grid grid-cols-2 gap-8">
<div className="space-y-6">
<div className="space-y-1">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
<Calendar className="w-3.5 h-3.5" />
Schedule
</div>
<p className="text-sm font-bold text-text-dark">
{new Date(selectedEvent.startDate).toLocaleString()}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
<MapPin className="w-3.5 h-3.5" />
Venue
</div>
<p className="text-sm font-bold text-text-dark">{selectedEvent.location}</p>
</div>
</div>
<div className="space-y-6">
<div className="space-y-1">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
<User className="w-3.5 h-3.5" />
Proposed By
</div>
<p className="text-sm font-bold text-text-dark">{selectedEvent.proposer?.fullName || 'Faculty'}</p>
<p className="text-[10px] font-black text-brand-indigo uppercase">{selectedEvent.department}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
<Users className="w-3.5 h-3.5" />
Target Audience
</div>
<p className="text-sm font-bold text-text-dark">{selectedEvent.academicYears.join(', ')} Batches</p>
</div>
</div>
</div>
{/* Sponsors Section */}
{selectedEvent.sponsors && selectedEvent.sponsors.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
<Heart className="w-3.5 h-3.5 text-brand-indigo" />
Event Sponsors
</div>
<div className="flex flex-wrap gap-2">
{selectedEvent.sponsors.map((sponsor, idx) => (
<span key={idx} className="px-3 py-1 bg-brand-glow text-brand-indigo rounded-lg text-[10px] font-black uppercase tracking-widest border border-brand-indigo/10">
{sponsor}
</span>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-8 pt-4 border-t border-slate-50">
<div className="p-4 bg-slate-50 rounded-2xl">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted mb-1">
<Wallet className="w-3.5 h-3.5" />
Estimated Budget
</div>
<p className="text-lg font-black text-text-dark">{selectedEvent.budget?.toLocaleString() || '0'}</p>
</div>
<div className="p-4 bg-slate-50 rounded-2xl">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-text-muted mb-1">
<Ticket className="w-3.5 h-3.5" />
Registration
</div>
<p className="text-lg font-black text-text-dark">
{selectedEvent.hasRegistrationFee ? `${selectedEvent.registrationFee}` : 'FREE'}
</p>
</div>
</div>
{selectedEvent.conflictMessage && (
<div className="p-4 bg-red-50 border border-red-100 rounded-2xl flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-500" />
<div>
<p className="text-[10px] font-black uppercase text-red-500">Critical Conflict Detected</p>
<p className="text-xs font-bold text-red-700">{selectedEvent.conflictMessage}</p>
</div>
</div>
)}
<div className="flex items-center gap-4 pt-4">
<button
onClick={() => handleAction(selectedEvent.id, 'reject')}
disabled={processingId === selectedEvent.id}
className="flex-1 bg-red-50 text-red-600 py-4 rounded-2xl font-black text-[11px] uppercase tracking-widest hover:bg-red-100 transition-all"
>
Reject Proposal
</button>
<button
onClick={() => handleAction(selectedEvent.id, 'approve')}
disabled={processingId === selectedEvent.id || !!selectedEvent.conflictMessage}
className={cn(
"flex-1 py-4 rounded-2xl font-black text-[11px] uppercase tracking-widest transition-all premium-shadow",
selectedEvent.conflictMessage ? "bg-slate-200 text-slate-400" : "bg-brand-navy text-white hover:scale-[1.02]"
)}
>
{processingId === selectedEvent.id ? 'Processing...' : 'Approve Event'}
</button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};