429 lines
21 KiB
TypeScript
429 lines
21 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Event, Announcement } from '../types';
|
|
|
|
interface AdminEventStatusViewProps {
|
|
events: Event[];
|
|
announcements: Announcement[];
|
|
onAddAnnouncement: (ann: Announcement) => Promise<void>;
|
|
onUpdateAnnouncement?: (ann: Announcement) => Promise<void>;
|
|
onDeleteAnnouncement: (id: string) => Promise<void>;
|
|
onUpdateEvent: (event: Event) => void;
|
|
onBack: () => void;
|
|
currentUserId?: string;
|
|
localRegistrations?: any[];
|
|
}
|
|
|
|
type ExpiryOption = '1H' | '6H' | '24H' | 'NEVER' | 'CUSTOM';
|
|
|
|
const AdminEventStatusView: React.FC<AdminEventStatusViewProps> = ({
|
|
events,
|
|
announcements,
|
|
onAddAnnouncement,
|
|
onUpdateAnnouncement,
|
|
onDeleteAnnouncement,
|
|
onUpdateEvent,
|
|
onBack,
|
|
currentUserId,
|
|
localRegistrations = []
|
|
}) => {
|
|
const [selectedEventId, setSelectedEventId] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
const [type, setType] = useState<Announcement['type']>('INFO');
|
|
const [expiryOption, setExpiryOption] = useState<ExpiryOption>('NEVER');
|
|
const [customExpiry, setCustomExpiry] = useState('');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState<Announcement | null>(null);
|
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
|
|
const calculateExpiry = (): string | null => {
|
|
const now = new Date();
|
|
switch (expiryOption) {
|
|
case '1H': return new Date(now.getTime() + 60 * 60 * 1000).toISOString();
|
|
case '6H': return new Date(now.getTime() + 6 * 60 * 60 * 1000).toISOString();
|
|
case '24H': return new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
|
|
case 'CUSTOM': return customExpiry ? new Date(customExpiry).toISOString() : null;
|
|
default: return null;
|
|
}
|
|
};
|
|
|
|
const handleEdit = (ann: Announcement) => {
|
|
setEditingId(ann.id);
|
|
setSelectedEventId(ann.eventId || '');
|
|
const cleanMessage = ann.message.includes(']') ? ann.message.split(']').slice(1).join(']').trim() : ann.message;
|
|
setMessage(cleanMessage);
|
|
setType(ann.type);
|
|
setExpiryOption(ann.expiresAt ? 'CUSTOM' : 'NEVER');
|
|
if (ann.expiresAt) setCustomExpiry(new Date(ann.expiresAt).toISOString().slice(0, 16));
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setEditingId(null);
|
|
setMessage('');
|
|
setSelectedEventId('');
|
|
setExpiryOption('NEVER');
|
|
setCustomExpiry('');
|
|
};
|
|
|
|
const processDelete = async () => {
|
|
if (!confirmDeleteId) return;
|
|
const id = confirmDeleteId;
|
|
setConfirmDeleteId(null);
|
|
setDeletingId(id);
|
|
try {
|
|
await onDeleteAnnouncement(id);
|
|
} catch (err: any) {
|
|
console.error("UI Delete Trigger Error:", err);
|
|
alert("Database failed to delete. Check console for error.");
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
const executeSubmit = async (overrideId?: string) => {
|
|
setIsSubmitting(true);
|
|
try {
|
|
const selectedEvent = events.find(ev => ev.id === selectedEventId);
|
|
const expiresAt = calculateExpiry();
|
|
const targetId = overrideId || editingId;
|
|
|
|
const announcementPayload: Announcement = {
|
|
id: targetId || '',
|
|
title: type === 'DELAY' ? 'DELAY ALERT' : type === 'URGENT' ? 'URGENT' : type === 'ENDED' ? 'CONCLUDED' : type === 'ONGOING' ? 'LIVE NOW' : 'NOTICE',
|
|
message: selectedEvent ? `[${selectedEvent.title}] ${message}` : message,
|
|
type,
|
|
timestamp: new Date().toISOString(),
|
|
expiresAt: expiresAt,
|
|
eventId: selectedEventId || undefined
|
|
};
|
|
|
|
if (targetId && onUpdateAnnouncement) {
|
|
await onUpdateAnnouncement(announcementPayload);
|
|
} else {
|
|
await onAddAnnouncement(announcementPayload);
|
|
}
|
|
|
|
if (type === 'ENDED' && selectedEvent) {
|
|
onUpdateEvent({ ...selectedEvent, status: 'Completed' });
|
|
} else if (type === 'ONGOING' && selectedEvent) {
|
|
onUpdateEvent({ ...selectedEvent, status: 'Event Ongoing' });
|
|
}
|
|
|
|
cancelEdit();
|
|
setShowOverwriteConfirm(null);
|
|
} catch (err) {
|
|
console.error("Broadcast operation failed:", err);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDownloadExcel = () => {
|
|
if (!selectedEventId || selectedEventId === 'GENERAL') return;
|
|
const event = events.find(e => e.id === selectedEventId);
|
|
if (!event) return;
|
|
|
|
const filteredRegs = localRegistrations.filter(reg => String(reg.event_id) === String(selectedEventId));
|
|
if (filteredRegs.length === 0) {
|
|
alert("No participants found for this event.");
|
|
return;
|
|
}
|
|
|
|
const headers = ['Name', 'Registration No', 'Department', 'Year', 'Email', 'Phone', 'College'];
|
|
const rows = filteredRegs.map(r => [
|
|
r.user_name || 'N/A',
|
|
`\t${r.reg_no || 'N/A'}`,
|
|
r.dept || 'N/A',
|
|
r.year || 'N/A',
|
|
r.email || 'N/A',
|
|
r.phone || 'N/A',
|
|
r.college || 'RAJALAKSHMI INSTITUTE OF TECHNOLOGY'
|
|
]);
|
|
|
|
const csvContent = [
|
|
[`EVENT: ${event.title}`].join(','),
|
|
[`EXPORTED AT: ${new Date().toLocaleString()}`].join(','),
|
|
[''],
|
|
headers.join(','),
|
|
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
|
].join('\n');
|
|
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', `${event.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_participants.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
const handlePublish = async () => {
|
|
if (!message.trim() || !selectedEventId) return;
|
|
|
|
if (selectedEventId !== 'GENERAL') {
|
|
const event = events.find(e => e.id === selectedEventId);
|
|
if (!event || event.created_by !== currentUserId) {
|
|
alert("Unauthorized: Only the creator can broadcast for this event.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
executeSubmit();
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!message) return;
|
|
|
|
if ((type === 'ENDED' || type === 'ONGOING') && selectedEventId && !editingId) {
|
|
const existingStatusNotice = announcements.find(a => a.eventId === selectedEventId && a.type === type);
|
|
if (existingStatusNotice) {
|
|
setShowOverwriteConfirm(existingStatusNotice);
|
|
return;
|
|
}
|
|
}
|
|
|
|
handlePublish();
|
|
};
|
|
|
|
const getStatusColor = (t: Announcement['type']) => {
|
|
switch (t) {
|
|
case 'URGENT': return 'border-rose-500/30 text-rose-500 bg-rose-500/5';
|
|
case 'DELAY': return 'border-amber-500/30 text-amber-500 bg-amber-500/5';
|
|
case 'ENDED': return 'border-emerald-500/30 text-emerald-500 bg-emerald-500/5';
|
|
case 'ONGOING': return 'border-purple-500/30 text-purple-500 bg-purple-500/5';
|
|
default: return 'border-blue-500/30 text-blue-500 bg-blue-500/5';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto py-10 px-6 animate-in fade-in slide-in-from-bottom-10 duration-500">
|
|
{/* Overwrite Confirmation Modal */}
|
|
{showOverwriteConfirm && createPortal(
|
|
<div className="fixed inset-0 z-[10001] flex items-center justify-center p-6 bg-slate-900/40 backdrop-blur-sm animate-in fade-in duration-300">
|
|
<div className="bg-white border border-slate-200 rounded-[3.5rem] w-full max-w-md p-14 shadow-2xl flex flex-col items-center text-center animate-in zoom-in-95 duration-500">
|
|
<div className="w-24 h-24 bg-amber-50 text-amber-500 rounded-full flex items-center justify-center text-4xl mb-8 border border-amber-100">
|
|
<i className="fas fa-exclamation-triangle"></i>
|
|
</div>
|
|
<h3 className="text-3xl font-black text-slate-900 mb-3 uppercase tracking-tighter">Replace Previous Log?</h3>
|
|
<p className="text-slate-500 font-bold text-[10px] uppercase tracking-[0.2em] mb-12 leading-relaxed">
|
|
A conclusion log already exists for this scope.<br />
|
|
Replacing it will overwrite the history entry.
|
|
</p>
|
|
<div className="flex gap-4 w-full">
|
|
<button onClick={() => setShowOverwriteConfirm(null)} className="flex-1 py-5 bg-slate-50 text-slate-500 rounded-2xl font-black uppercase text-[10px] tracking-widest hover:bg-slate-100 hover:text-slate-900 transition-all border border-slate-200">Dismiss</button>
|
|
<button onClick={() => executeSubmit(showOverwriteConfirm.id)} className="flex-1 py-5 bg-amber-500 text-white rounded-2xl font-black uppercase text-[10px] tracking-widest hover:bg-amber-600 transition-all shadow-xl shadow-amber-500/20">Overwrite</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{confirmDeleteId && createPortal(
|
|
<div className="fixed inset-0 z-[10001] flex items-center justify-center p-6 bg-slate-900/40 backdrop-blur-sm animate-in fade-in duration-300">
|
|
<div className="bg-white border border-slate-200 rounded-[3.5rem] w-full max-w-md p-14 shadow-2xl flex flex-col items-center text-center animate-in zoom-in-95 duration-500">
|
|
<div className="w-24 h-24 bg-rose-50 text-rose-500 rounded-full flex items-center justify-center text-4xl mb-8 border border-rose-100">
|
|
<i className="fas fa-trash-can"></i>
|
|
</div>
|
|
<h3 className="text-3xl font-black text-slate-900 mb-3 uppercase tracking-tighter">Remove Broadcast?</h3>
|
|
<p className="text-slate-500 font-bold text-[10px] uppercase tracking-[0.2em] mb-12 leading-relaxed">
|
|
This action is permanent and cannot be reversed.
|
|
</p>
|
|
<div className="flex gap-4 w-full">
|
|
<button onClick={() => setConfirmDeleteId(null)} className="flex-1 py-5 bg-slate-50 text-slate-500 rounded-2xl font-black uppercase text-[10px] tracking-widest hover:bg-slate-100 hover:text-slate-900 transition-all border border-slate-200">Cancel</button>
|
|
<button onClick={processDelete} className="flex-1 py-5 bg-rose-600 text-white rounded-2xl font-black uppercase text-[10px] tracking-widest hover:bg-rose-700 transition-all shadow-xl">Delete Now</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
<div className="flex items-center justify-between mb-16 border-b border-slate-200 pb-8">
|
|
<div>
|
|
<h2 className="text-5xl font-black text-slate-900 tracking-tighter uppercase">ADMIN <span className="text-[#004a99]">PULSE</span></h2>
|
|
<p className="text-[10px] font-black text-slate-500 uppercase tracking-[0.4em] mt-3 ml-1">Live Management of System Notifications</p>
|
|
</div>
|
|
<button onClick={onBack} className="group flex items-center gap-3 text-slate-400 hover:text-[#004a99] transition-all text-xs font-black uppercase tracking-widest bg-white border border-slate-200 px-6 py-3 rounded-full shadow-sm">
|
|
<i className="fas fa-arrow-left transition-transform group-hover:-translate-x-1"></i> BACK
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
|
|
{/* Left Side: Creation Form */}
|
|
<div className="lg:col-span-4 lg:sticky lg:top-24">
|
|
<form onSubmit={handleSubmit} className="bg-white border border-slate-200 rounded-[3rem] p-10 space-y-8 shadow-sm">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em]">{editingId ? 'Refining Broadcast' : 'New Broadcast'}</h3>
|
|
{editingId && (
|
|
<button type="button" onClick={cancelEdit} className="text-[9px] text-rose-500 font-black uppercase hover:underline">Cancel Edit</button>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[9px] font-black text-slate-500 uppercase tracking-[0.2em] mb-3 ml-1">Target Scope</label>
|
|
<div className="flex gap-2">
|
|
<select
|
|
className="flex-1 bg-white border border-slate-200 rounded-xl px-5 py-4 text-slate-900 focus:ring-2 focus:ring-[#004a99] outline-none font-bold text-xs appearance-none shadow-sm cursor-pointer"
|
|
value={selectedEventId}
|
|
onChange={(e) => setSelectedEventId(e.target.value)}
|
|
>
|
|
<option value="">General Notice</option>
|
|
{events.map(ev => (
|
|
<option key={ev.id} value={ev.id} style={{ color: ev.created_by !== currentUserId ? '#94a3b8' : 'inherit' }}>
|
|
{ev.title} {ev.created_by !== currentUserId ? "(READ ONLY)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{selectedEventId && selectedEventId !== 'GENERAL' && (
|
|
<button
|
|
type="button"
|
|
onClick={handleDownloadExcel}
|
|
className="w-14 h-14 bg-emerald-50 border border-emerald-100 text-emerald-600 rounded-xl flex items-center justify-center hover:bg-emerald-600 hover:text-white transition-all shadow-sm active:scale-95"
|
|
title="Download Roster"
|
|
>
|
|
<i className="fas fa-file-csv"></i>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[9px] font-black text-slate-500 uppercase tracking-[0.2em] mb-3 ml-1">Broadcast Type</label>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{(['DELAY', 'INFO', 'URGENT', 'ENDED', 'ONGOING'] as Announcement['type'][]).map(t => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
onClick={() => setType(t)}
|
|
className={`py-4 rounded-xl text-[9px] font-black uppercase tracking-widest transition-all border ${type === t ? 'bg-[#004a99] border-[#004a99] text-white shadow-md shadow-[#004a99]/20' : 'bg-slate-50 border-slate-200 text-slate-500 hover:text-slate-900 hover:border-slate-300'}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-2">
|
|
<label className="block text-[9px] font-black text-slate-500 uppercase tracking-[0.2em] mb-4 ml-1">Auto-Expiry</label>
|
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
|
{(['1H', 'NEVER', 'CUSTOM'] as ExpiryOption[]).map(opt => (
|
|
<button
|
|
key={opt}
|
|
type="button"
|
|
onClick={() => setExpiryOption(opt)}
|
|
className={`py-3 rounded-xl text-[7px] font-black uppercase tracking-widest transition-all border ${expiryOption === opt ? 'bg-blue-50 border-blue-200 text-[#004a99]' : 'bg-transparent border-slate-200 text-slate-500 hover:bg-slate-50'}`}
|
|
>
|
|
{opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{expiryOption === 'CUSTOM' && (
|
|
<input
|
|
type="datetime-local"
|
|
className="w-full bg-white border border-slate-200 rounded-xl px-5 py-4 text-slate-900 focus:ring-2 focus:ring-[#004a99] outline-none text-[10px] font-bold shadow-sm"
|
|
value={customExpiry}
|
|
onChange={(e) => setCustomExpiry(e.target.value)}
|
|
required
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<textarea
|
|
required
|
|
placeholder="Broadcast message..."
|
|
className="w-full bg-white border border-slate-200 rounded-xl px-6 py-5 text-slate-900 focus:ring-2 focus:ring-[#004a99] outline-none text-xs h-40 resize-none font-medium placeholder:text-slate-400 shadow-sm"
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
/>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className={`w-full py-5 rounded-xl font-black uppercase text-[10px] tracking-[0.3em] transition-all disabled:opacity-50 shadow-md hover:shadow-lg active:scale-95 ${editingId ? 'bg-blue-100 text-blue-800 hover:bg-blue-200 border border-blue-200' : 'bg-[#004a99] text-white hover:bg-blue-800'} `}
|
|
>
|
|
{isSubmitting ? <><i className="fas fa-spinner fa-spin mr-2"></i> SYNCING...</> : editingId ? 'UPDATE HISTORY' : 'PUBLISH NOTICE'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Right Side: Live Stream History */}
|
|
<div className="lg:col-span-8 bg-white border border-slate-200 rounded-[3.5rem] p-10 min-h-[700px] flex flex-col shadow-sm">
|
|
<div className="flex items-center justify-between border-b border-slate-100 pb-10 mb-8">
|
|
<div>
|
|
<h3 className="text-2xl font-black uppercase tracking-tighter text-slate-900">Live Stream History</h3>
|
|
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest mt-2">Real-time Authenticated Activity Feed</p>
|
|
</div>
|
|
<div className="flex items-center gap-6">
|
|
<div className="flex flex-col items-end">
|
|
<span className="text-[10px] font-black text-[#004a99] uppercase tracking-widest">{announcements.length} ACTIVE BROADCASTS</span>
|
|
<div className="w-12 h-0.5 bg-blue-200 mt-1"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-6 overflow-y-auto no-scrollbar pr-2 pb-10">
|
|
{announcements.length > 0 ? announcements.map((ann) => (
|
|
<div
|
|
key={ann.id}
|
|
className={`bg-white border rounded-[2.5rem] p-8 flex items-start justify-between group transition-all duration-500 hover:shadow-md ${editingId === ann.id ? 'border-blue-500 ring-1 ring-blue-500/20 bg-blue-50/50' : 'border-slate-200 hover:border-slate-300'
|
|
}`}
|
|
>
|
|
<div className="flex-1 pr-10 text-left">
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<span className={`px-4 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border ${getStatusColor(ann.type)}`}>
|
|
{ann.type}
|
|
</span>
|
|
<h4 className="text-base font-black text-slate-900 uppercase tracking-tight">{ann.title}</h4>
|
|
</div>
|
|
<p className="text-sm text-slate-600 font-medium leading-relaxed mb-8 max-w-2xl">{ann.message}</p>
|
|
<div className="flex flex-wrap items-center gap-8">
|
|
<div className="flex items-center gap-2 text-slate-500">
|
|
<i className="far fa-clock text-[10px]"></i>
|
|
<span className="text-[10px] font-black uppercase tracking-widest">
|
|
Logged: {new Date(ann.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 self-center relative z-20">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleEdit(ann); }}
|
|
disabled={deletingId === ann.id}
|
|
className="w-14 h-14 rounded-2xl bg-slate-50 flex items-center justify-center text-slate-400 hover:text-[#004a99] hover:bg-blue-50 transition-all border border-slate-200 hover:border-blue-200 disabled:opacity-30 cursor-pointer pointer-events-auto"
|
|
>
|
|
<i className="fas fa-pen-nib text-sm"></i>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setConfirmDeleteId(ann.id); }}
|
|
disabled={deletingId === ann.id}
|
|
className="w-14 h-14 rounded-2xl bg-slate-50 flex items-center justify-center text-slate-400 hover:text-rose-500 hover:bg-rose-50 transition-all border border-slate-200 hover:border-rose-200 disabled:opacity-30 cursor-pointer pointer-events-auto"
|
|
>
|
|
{deletingId === ann.id ? <i className="fas fa-spinner fa-spin text-sm"></i> : <i className="fas fa-trash-can text-sm"></i>}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)) : (
|
|
<div className="flex flex-col items-center justify-center py-40 text-center animate-in fade-in duration-500 bg-slate-50 border border-dashed border-slate-200 rounded-[3rem]">
|
|
<div className="w-24 h-24 rounded-full bg-white border border-slate-200 flex items-center justify-center mb-8 shadow-sm text-slate-300">
|
|
<i className="fas fa-stream text-4xl"></i>
|
|
</div>
|
|
<h4 className="text-2xl font-black uppercase tracking-tighter text-slate-400">History Empty</h4>
|
|
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest mt-3">Ready for local broadcast input</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminEventStatusView; |