419 lines
18 KiB
TypeScript
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>
|
|
);
|
|
};
|