Final commit
This commit is contained in:
387
frontend/src/components/dashboard/EventHistory.tsx
Normal file
387
frontend/src/components/dashboard/EventHistory.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import { API_BASE_URL } from '../../lib/config';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
History,
|
||||
Search,
|
||||
Calendar,
|
||||
MapPin,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Filter,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
User,
|
||||
Heart,
|
||||
FileText,
|
||||
X,
|
||||
Building2,
|
||||
Users,
|
||||
Wallet,
|
||||
Ticket
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { format } from 'date-fns';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
type: string;
|
||||
status: string;
|
||||
department: string;
|
||||
location: string;
|
||||
budget: number;
|
||||
conflictMessage?: string;
|
||||
rejectionReason?: string;
|
||||
institution: string;
|
||||
category: string;
|
||||
academicYears: string[];
|
||||
proposer?: {
|
||||
fullName: string;
|
||||
};
|
||||
description?: string;
|
||||
sponsors?: string[];
|
||||
hasRegistrationFee: boolean;
|
||||
registrationFee: number;
|
||||
}
|
||||
|
||||
export const EventHistory: React.FC = () => {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(API_BASE_URL + '/api/events')
|
||||
.then(res => res.json())
|
||||
.then(data => setEvents(data))
|
||||
.catch(err => console.error('History fetch failed:', err));
|
||||
}, []);
|
||||
|
||||
const getStatusStyle = (status: string, hasConflict: boolean) => {
|
||||
if (hasConflict && status !== 'APPROVED') return {
|
||||
bg: 'bg-red-50',
|
||||
text: 'text-red-600',
|
||||
border: 'border-red-100',
|
||||
icon: AlertTriangle,
|
||||
label: 'CONFLICT'
|
||||
};
|
||||
|
||||
switch (status) {
|
||||
case 'APPROVED': return { bg: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-100', icon: CheckCircle2, label: 'APPROVED' };
|
||||
case 'REQUESTED': return { bg: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-100', icon: Clock, label: 'REQUESTED' };
|
||||
case 'PENDING_PR': return { bg: 'bg-purple-50', text: 'text-purple-600', border: 'border-purple-100', icon: ShieldCheck, label: 'PENDING PRINCIPAL' };
|
||||
case 'HOD_REJECTED':
|
||||
case 'PRINCIPAL_REJECTED': return { bg: 'bg-red-50', text: 'text-red-600', border: 'border-red-100', icon: XCircle, label: 'REJECTED' };
|
||||
default: return { bg: 'bg-slate-50', text: 'text-slate-600', border: 'border-slate-100', icon: Clock, label: status };
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = events
|
||||
.filter(e => {
|
||||
const matchesSearch = e.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
e.department.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
if (statusFilter === 'ALL') return matchesSearch;
|
||||
if (statusFilter === 'PENDING') return matchesSearch && (e.status === 'REQUESTED' || e.status === 'PENDING_PR');
|
||||
if (statusFilter === 'REJECTED') return matchesSearch && e.status.includes('REJECTED');
|
||||
return matchesSearch && e.status === statusFilter;
|
||||
})
|
||||
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
|
||||
const currentEvents = filteredEvents.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-10">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="w-12 h-12 bg-brand-navy rounded-2xl flex items-center justify-center text-white premium-shadow">
|
||||
<History className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-text-dark tracking-tight">Institutional Event History</h2>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-text-muted">Audit-ready comprehensive event ledger</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-100 rounded-xl text-[10px] font-black uppercase tracking-widest text-text-muted hover:bg-slate-50 transition-all">
|
||||
<Download className="w-4 h-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Bar */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-6 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-300" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by event title or department..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full h-16 pl-16 pr-8 bg-white border border-slate-100 rounded-3xl text-sm font-bold text-text-dark focus:outline-none focus:ring-4 focus:ring-brand-indigo/5 focus:border-brand-indigo transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-white p-2 rounded-3xl border border-slate-100">
|
||||
{['ALL', 'APPROVED', 'PENDING', 'REJECTED'].map(status => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={cn(
|
||||
"px-6 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all",
|
||||
statusFilter === status
|
||||
? "bg-brand-indigo text-white premium-shadow-sm"
|
||||
: "text-text-muted hover:bg-slate-50"
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events Ledger */}
|
||||
<div className="bg-white rounded-[2.5rem] border border-slate-100 premium-shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-50">
|
||||
<th className="px-8 py-6 text-left text-[10px] font-black uppercase tracking-widest text-text-muted">Event Details</th>
|
||||
<th className="px-8 py-6 text-left text-[10px] font-black uppercase tracking-widest text-text-muted">Logistics</th>
|
||||
<th className="px-8 py-6 text-left text-[10px] font-black uppercase tracking-widest text-text-muted">Status</th>
|
||||
<th className="px-8 py-6 text-right text-[10px] font-black uppercase tracking-widest text-text-muted">Budget</th>
|
||||
<th className="px-8 py-6 text-right text-[10px] font-black uppercase tracking-widest text-text-muted"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{currentEvents.map((event, idx) => {
|
||||
const statusStyle = getStatusStyle(event.status, !!event.conflictMessage);
|
||||
const StatusIcon = statusStyle.icon;
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
key={event.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.03 }}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
className="group hover:bg-slate-50/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="px-8 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center font-black text-[10px]",
|
||||
event.conflictMessage ? "bg-red-50 text-red-500" : "bg-brand-indigo/5 text-brand-indigo"
|
||||
)}>
|
||||
{event.department?.slice(0, 3).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-black text-text-dark group-hover:text-brand-indigo transition-colors">{event.title}</p>
|
||||
<p className="text-[10px] font-bold text-text-muted uppercase tracking-widest mt-0.5">{event.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-bold">{format(new Date(event.startDate), 'MMM d, yyyy')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-bold">{event.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6">
|
||||
<div className={cn(
|
||||
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-[9px] font-black uppercase tracking-widest",
|
||||
statusStyle.bg, statusStyle.text, statusStyle.border
|
||||
)}>
|
||||
<StatusIcon className="w-3.5 h-3.5" />
|
||||
{statusStyle.label}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
<span className="text-sm font-black text-text-dark">₹{event.budget?.toLocaleString() || '0'}</span>
|
||||
</td>
|
||||
<td className="px-8 py-6 text-right">
|
||||
<button className="p-2 hover:bg-white hover:premium-shadow-sm rounded-lg transition-all text-slate-300 group-hover:text-brand-indigo">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalItems={filteredEvents.length}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={(val) => {
|
||||
setItemsPerPage(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
itemsPerPageOptions={[5, 10, 20, 50]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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="p-8 bg-brand-navy text-white flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">Event History</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>
|
||||
</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"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Description */}
|
||||
{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" />
|
||||
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" />
|
||||
Date & Time
|
||||
</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" />
|
||||
Location
|
||||
</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">
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
Department
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">{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 Batches
|
||||
</div>
|
||||
<p className="text-sm font-bold text-text-dark">{selectedEvent.academicYears?.join(', ') || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sponsors */}
|
||||
{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" />
|
||||
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" />
|
||||
Status
|
||||
</div>
|
||||
<p className="text-xs font-black text-brand-indigo uppercase">{selectedEvent.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEvent.rejectionReason && (
|
||||
<div className="p-4 bg-red-50 border border-red-100 rounded-2xl">
|
||||
<p className="text-[10px] font-black uppercase text-red-500 mb-1">Rejection Reason</p>
|
||||
<p className="text-xs font-bold text-red-700">{selectedEvent.rejectionReason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ShieldCheck = (props: any) => (
|
||||
<svg {...props} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
Reference in New Issue
Block a user