updated UI and logics

This commit is contained in:
2026-06-22 12:16:30 +05:30
parent ef93f4bf48
commit 947c89167d
22 changed files with 2243 additions and 360 deletions

View File

@@ -9,6 +9,7 @@ import { UserManagement } from './components/dashboard/UserManagement';
import { ClassManagement } from './components/dashboard/ClassManagement';
import { LoginPage } from './pages/LoginPage';
import { AuthProvider, useAuth } from './context/AuthContext';
import { DialogProvider } from './context/DialogContext';
import { motion, AnimatePresence } from 'framer-motion';
import { StatusTimeline, type Event } from './components/dashboard/EventStatusTimeline';
import { useEffect } from 'react';
@@ -22,6 +23,8 @@ import { Overview } from './components/dashboard/Overview';
import { ExcelImport } from './components/dashboard/ExcelImport';
import { StudentDashboard } from './components/dashboard/StudentDashboard';
import { StudentRegistrationsView } from './components/dashboard/StudentRegistrationsView';
import { ManageNoticesView } from './components/dashboard/ManageNoticesView';
const AppContent: React.FC = () => {
const { isAuthenticated, user } = useAuth();
@@ -158,6 +161,8 @@ const AppContent: React.FC = () => {
return <ExcelImport />;
case 'student-registrations':
return (user?.role === 'FACULTY' || user?.role === 'HOD' || user?.role === 'ADMIN') ? <StudentRegistrationsView /> : isDashboard;
case 'manage-notices':
return user?.role === 'ADMIN' ? <ManageNoticesView /> : isDashboard;
case 'dashboard':
return isDashboard;
default:
@@ -187,9 +192,11 @@ const AppContent: React.FC = () => {
function App() {
return (
<AuthProvider>
<AppContent />
</AuthProvider>
<DialogProvider>
<AuthProvider>
<AppContent />
</AuthProvider>
</DialogProvider>
);
}

View File

@@ -13,7 +13,8 @@ import {
Calendar,
HelpCircle,
ExternalLink,
CheckCircle
CheckCircle,
Bell
} from 'lucide-react';
import { cn } from '../lib/utils';
import { motion } from 'framer-motion';
@@ -39,6 +40,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeItem, onItemClick }) =>
...((user?.role === 'ADMIN' || user?.role === 'PRINCIPAL' || user?.role === 'HOD') ? [{ icon: Zap, label: 'Automation', id: 'automation' }] : []),
...(user?.role === 'ADMIN' ? [{ icon: BookOpen, label: 'Classes', id: 'classes' }] : []),
...(user?.role === 'ADMIN' ? [{ icon: Users, label: 'Manage Users', id: 'user-management' }] : []),
...(user?.role === 'ADMIN' ? [{ icon: Bell, label: 'Manage Notices', id: 'manage-notices' }] : []),
...((user?.role === 'FACULTY' || user?.role === 'HOD' || user?.role === 'ADMIN') ? [{ icon: Users, label: 'Student Hub', id: 'student-registrations' }] : [])
];

View File

@@ -16,6 +16,7 @@ import {
} from 'lucide-react';
import { cn } from '../../lib/utils';
import { Pagination } from './Pagination';
import { useDialog } from '../../context/DialogContext';
interface ClassMapping {
id: number;
@@ -34,6 +35,7 @@ import rsbLogo from '../../assets/images/rsb_logo.png';
import ritLogo from '../../assets/images/college-logo.png';
export const ClassManagement: React.FC = () => {
const { showAlert, showConfirm } = useDialog();
const [classes, setClasses] = useState<ClassMapping[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
@@ -48,6 +50,16 @@ export const ClassManagement: React.FC = () => {
sections: [] as string[]
});
const [batches, setBatches] = useState<any[]>([]);
const [newBatchName, setNewBatchName] = useState('');
const [selectedBatchDept, setSelectedBatchDept] = useState('AI&DS');
const [tempBatches, setTempBatches] = useState<any[]>([]);
useEffect(() => {
const deptBatches = batches.filter(b => b.department === selectedBatchDept);
setTempBatches(JSON.parse(JSON.stringify(deptBatches)));
}, [batches, selectedBatchDept]);
const currentDepartments = formData.institution === 'RIT' ? ritDepartments : rsbDepartments;
useEffect(() => {
@@ -58,8 +70,21 @@ export const ClassManagement: React.FC = () => {
useEffect(() => {
fetchClasses();
fetchBatches();
}, []);
const fetchBatches = async () => {
try {
const response = await fetch(API_BASE_URL + '/api/batches');
if (response.ok) {
const data = await response.json();
setBatches(data);
}
} catch (error) {
console.error('Failed to fetch batches:', error);
}
};
const fetchClasses = async () => {
try {
const response = await fetch(API_BASE_URL + '/api/classes');
@@ -94,7 +119,7 @@ export const ClassManagement: React.FC = () => {
const handleSave = async () => {
if (formData.sections.length === 0) {
alert('Please add at least one section');
showAlert('Required', 'Please add at least one section', 'error');
return;
}
@@ -108,50 +133,120 @@ export const ClassManagement: React.FC = () => {
if (response.ok) {
fetchClasses();
setFormData({ ...formData, sections: [] });
showAlert('Success', 'Class mapping saved successfully.', 'success');
}
} catch (error) {
console.error('Failed to save class mapping:', error);
showAlert('Error', 'Failed to save class mapping.', 'error');
}
};
const handleDelete = async (id: number) => {
if (window.confirm('Are you sure you want to delete this mapping?')) {
try {
const response = await fetch(`${API_BASE_URL}/api/classes/${id}`, {
method: 'DELETE'
});
if (response.ok) {
setClasses(prev => prev.filter(c => c.id !== id));
showConfirm(
'Delete Mapping',
'Are you sure you want to delete this mapping?',
async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/classes/${id}`, {
method: 'DELETE'
});
if (response.ok) {
setClasses(prev => prev.filter(c => c.id !== id));
showAlert('Success', 'Mapping deleted successfully.', 'success');
}
} catch (error) {
console.error('Delete failed:', error);
showAlert('Error', 'Failed to delete mapping.', 'error');
}
} catch (error) {
console.error('Delete failed:', error);
}
}
);
};
const handlePromote = async () => {
const confirmed = window.confirm(
`⚠️ WARNING: ${formData.institution} ACADEMIC YEAR PROMOTION\n\n` +
`This action will shift all academic structures for ${formData.institution} forward:\n` +
'• 1st Year → 2nd Year\n' +
'• 2nd Year → 3rd Year\n' +
'• 3rd Year → 4th Year\n' +
'• 4th Year mappings will be PERMANENTLY DELETED.\n\n' +
'Do you wish to proceed with this irreversible operation?'
);
if (confirmed) {
try {
const response = await fetch(`${API_BASE_URL}/api/classes/promote?institution=${formData.institution}`, {
method: 'POST'
});
if (response.ok) {
fetchClasses();
alert(`${formData.institution} academic year promotion completed successfully!`);
showConfirm(
'Academic Promotion',
`WARNING: This action will shift all academic structures for ${formData.institution} forward:\n• 1st Year → 2nd Year\n• 2nd Year → 3rd Year\n• 3rd Year → 4th Year\n• 4th Year mappings will be PERMANENTLY DELETED.\n\nDo you wish to proceed?`,
async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/classes/promote?institution=${formData.institution}`, {
method: 'POST'
});
if (response.ok) {
fetchClasses();
showAlert('Success', `${formData.institution} academic year promotion completed successfully!`, 'success');
}
} catch (error) {
console.error('Promotion failed:', error);
showAlert('Error', 'Promotion failed.', 'error');
}
} catch (error) {
console.error('Promotion failed:', error);
}
);
};
const handleCreateTempBatch = () => {
if (!newBatchName.trim()) {
showAlert('Required', 'Please enter a batch name', 'error');
return;
}
const newBatch = {
id: Date.now() + Math.floor(Math.random() * 1000),
name: newBatchName.trim(),
department: selectedBatchDept,
classes: []
};
setTempBatches([...tempBatches, newBatch]);
setNewBatchName('');
};
const handleDeleteTempBatch = (batchId: number) => {
setTempBatches(tempBatches.filter(b => b.id !== batchId));
};
const handleToggleClassInTempBatch = (batchId: number, classStr: string) => {
setTempBatches(tempBatches.map(b => {
if (b.id !== batchId) return b;
const isAssigned = b.classes?.includes(classStr);
return {
...b,
classes: isAssigned
? b.classes.filter((c: string) => c !== classStr)
: [...(b.classes || []), classStr]
};
}));
};
const handleSaveChanges = async () => {
try {
const originalDeptBatches = batches.filter(b => b.department === selectedBatchDept);
// 1. Identify deleted batches (in original but not in temp)
const deletedBatches = originalDeptBatches.filter(ob => !tempBatches.some(tb => String(tb.id) === String(ob.id)));
// 2. Perform deletes
for (const db of deletedBatches) {
await fetch(`${API_BASE_URL}/api/batches/${db.id}`, { method: 'DELETE' });
}
// 3. Save / Update current batches in tempBatches
for (const tb of tempBatches) {
await fetch(API_BASE_URL + '/api/batches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: tb.id,
name: tb.name,
department: tb.department,
classes: tb.classes
})
});
}
// 4. Refresh state
await fetchBatches();
showAlert('Success', 'Batch configurations saved successfully.', 'success');
} catch (error) {
console.error('Failed to save batch changes:', error);
showAlert('Error', 'Failed to save changes.', 'error');
}
};
@@ -169,6 +264,14 @@ export const ClassManagement: React.FC = () => {
currentPage * itemsPerPage
);
const selectedDeptClasses = classes.filter(c => c.institution === formData.institution && c.department === formData.department);
const totalClassesCount = selectedDeptClasses.reduce((sum, c) => sum + c.sections.length, 0);
const hasMoreThanFourClasses = totalClassesCount > 4;
const deptClassesList = selectedDeptClasses.flatMap(c => c.sections.map(sec => `${c.academicYear} - ${sec}`));
const selectedBatchDeptClasses = classes.filter(c => c.department === selectedBatchDept);
const batchDeptClassesList = selectedBatchDeptClasses.flatMap(c => c.sections.map(sec => `${c.academicYear} - ${sec}`));
return (
<div className="space-y-10">
{/* Header */}
@@ -430,6 +533,111 @@ export const ClassManagement: React.FC = () => {
</motion.div>
</div>
</div>
{/* Batches Section - Always Visible */}
<div className="bg-white rounded-[2.5rem] border border-slate-100 premium-shadow p-8 space-y-6 mt-10">
<div className="flex flex-col lg:flex-row lg:items-center justify-between border-b border-slate-50 pb-6 gap-6">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-brand-indigo rounded-xl flex items-center justify-center text-white">
<Layers className="w-5 h-5" />
</div>
<div>
<h3 className="text-xl font-black text-text-dark tracking-tight">Department Batches</h3>
<p className="text-xs text-text-muted font-bold">Configure custom student batches for auditing & event planning.</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
{/* Select Department */}
<div className="flex items-center gap-2">
<span className="text-[9px] font-black text-text-muted uppercase tracking-wider">Department:</span>
<select
value={selectedBatchDept}
onChange={e => setSelectedBatchDept(e.target.value)}
className="bg-slate-50 border border-slate-200 rounded-xl py-2 px-3 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all text-slate-800 cursor-pointer"
>
{ritDepartments.filter(d => d !== 'Club' && d !== 'Centre' && d !== 'Placement Department').map(dept => (
<option key={dept} value={dept}>{dept}</option>
))}
</select>
</div>
{/* Create Batch Form */}
<div className="flex items-center gap-2">
<input
type="text"
placeholder="New Batch Name (e.g. Batch 1)"
value={newBatchName}
onChange={e => setNewBatchName(e.target.value)}
className="bg-slate-50 border border-slate-200 rounded-xl py-2 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all text-slate-800"
/>
<button
onClick={handleCreateTempBatch}
className="px-4 py-2 bg-brand-indigo text-white rounded-xl font-black text-[10px] uppercase tracking-widest hover:scale-[1.02] active:scale-[0.98] transition-all"
>
Create
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{tempBatches.map(batch => (
<div key={batch.id} className="bg-slate-50 p-6 rounded-3xl border border-slate-100 space-y-4">
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
<span className="text-sm font-black text-brand-navy uppercase">{batch.name}</span>
<button
onClick={() => handleDeleteTempBatch(batch.id)}
className="text-[10px] font-black text-rose-500 hover:text-rose-700 uppercase tracking-widest transition-all"
>
Delete
</button>
</div>
<div className="space-y-3">
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest">Assign Classes to this Batch:</span>
{batchDeptClassesList.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{batchDeptClassesList.map(cls => {
const isChecked = batch.classes?.includes(cls);
return (
<label key={cls} className="flex items-center gap-2 bg-white p-3 rounded-xl border border-slate-200 hover:border-brand-indigo/30 transition-all cursor-pointer">
<input
type="checkbox"
checked={isChecked}
onChange={() => handleToggleClassInTempBatch(batch.id, cls)}
className="w-4 h-4 rounded border-slate-300 text-brand-indigo focus:ring-brand-indigo cursor-pointer"
/>
<span className="text-[10px] font-black text-slate-600 uppercase tracking-tight">{cls}</span>
</label>
);
})}
</div>
) : (
<span className="block text-[10px] font-bold text-text-muted italic">No classes configured for {selectedBatchDept}.</span>
)}
</div>
</div>
))}
{tempBatches.length === 0 && (
<div className="col-span-2 py-12 text-center text-slate-400 font-bold uppercase tracking-wider text-xs border-2 border-dashed border-slate-200 rounded-[2rem]">
No batches created yet for {selectedBatchDept}. Use the form above to add your first batch.
</div>
)}
</div>
{tempBatches.length > 0 && (
<div className="flex items-center justify-end border-t border-slate-100 pt-6 mt-6">
<button
onClick={handleSaveChanges}
className="px-6 py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-black text-[10px] uppercase tracking-widest hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center gap-2 shadow-md shadow-emerald-500/10"
>
<CheckCircle2 className="w-4 h-4" />
Save Changes
</button>
</div>
)}
</div>
</div>
);
};

View File

@@ -131,11 +131,17 @@ export const EventDetailsModal: React.FC<EventDetailsModalProps> = ({ isOpen, on
Target Audience
</h3>
<div className="flex flex-wrap gap-2">
{event.academicYears.map((year: string) => (
<span key={year} className="px-3 py-1.5 bg-brand-glow text-brand-indigo text-xs font-bold rounded-xl border border-brand-indigo/10">
{year}
{(event as any).targetedBatch ? (
<span className="px-3 py-1.5 bg-brand-indigo text-white text-xs font-bold rounded-xl shadow-sm">
{(event as any).targetedBatch}
</span>
))}
) : (
event.academicYears.map((year: string) => (
<span key={year} className="px-3 py-1.5 bg-brand-glow text-brand-indigo text-xs font-bold rounded-xl border border-brand-indigo/10">
{year}
</span>
))
)}
</div>
</div>
)}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Calendar,
@@ -100,6 +100,7 @@ interface EventProposalFormProps {
budget?: string | number;
hasRegistrationFee?: boolean;
registrationFee?: string | number;
paymentLink?: string;
targetDepartments?: string[];
description?: string;
sponsors?: string[];
@@ -194,6 +195,7 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
customEventType: '',
hasRegistrationFee: initialData?.hasRegistrationFee || false,
registrationFee: initialData?.registrationFee || '' as string | number,
paymentLink: initialData?.paymentLink || '',
targetDepartments: initialData?.targetDepartments || (isFaculty ? (isPlacementCell ? [] : [user?.department || 'CSE']) : [] as string[]),
allDepts: false,
allBatches: false,
@@ -203,7 +205,8 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
centreName: initialData?.centreName || (eventScope === 'CENTRE' ? ((CENTRE_MAPPING[user?.department || ''] || CENTRE_MAPPING['Placement Department'] || [])[0] || '') : ''),
isPublicEvent: initialData?.isPublicEvent || false,
image: initialData?.image || '',
status: initialData?.status || 'REQUESTED'
status: initialData?.status || 'REQUESTED',
targetedBatch: (initialData as any)?.targetedBatch || ''
});
const [newSponsor, setNewSponsor] = useState('');
@@ -230,12 +233,32 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
}));
}, [eventScope, user?.department]);
const [batches, setBatches] = useState<any[]>([]);
useEffect(() => {
fetchClasses();
fetchExistingEvents();
fetchClassStrengths();
fetchBatches();
}, []);
const fetchBatches = async () => {
try {
const response = await fetch(API_BASE_URL + '/api/batches');
if (response.ok) {
const data = await response.json();
setBatches(data);
}
} catch (err) {
console.error('Failed to fetch batches:', err);
}
};
const deptBatches = useMemo(() => {
const dept = eventScope === 'DEPARTMENT' ? formData.department : (user?.department || 'CSE');
return batches.filter(b => b.department === dept);
}, [batches, formData.department, eventScope, user?.department]);
const fetchClassStrengths = async () => {
try {
const response = await fetch(API_BASE_URL + '/api/admin/users');
@@ -853,17 +876,31 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
exit={{ opacity: 0, height: 0 }}
className="md:col-span-2 overflow-hidden"
>
<div className="p-6 bg-brand-glow border border-brand-indigo/10 rounded-[2rem] space-y-4">
<label className="text-[10px] font-black uppercase tracking-widest text-brand-indigo">Fee per Delegate ()</label>
<div className="relative">
<span className="absolute left-6 top-1/2 -translate-y-1/2 text-brand-indigo font-bold"></span>
<input
type="number"
value={formData.registrationFee}
onChange={e => setFormData({...formData, registrationFee: e.target.value})}
className="w-full bg-white border border-brand-indigo/20 rounded-xl py-4 pl-12 pr-6 text-sm font-bold text-brand-indigo focus:ring-2 ring-brand-indigo/20 outline-none"
placeholder="Amount in INR"
/>
<div className="p-6 bg-brand-glow border border-brand-indigo/10 rounded-[2rem] space-y-4 flex flex-col md:flex-row md:items-center gap-6">
<div className="flex-1">
<label className="text-[10px] font-black uppercase tracking-widest text-brand-indigo block mb-3">Fee per Delegate ()</label>
<div className="relative">
<span className="absolute left-6 top-1/2 -translate-y-1/2 text-brand-indigo font-bold"></span>
<input
type="number"
value={formData.registrationFee}
onChange={e => setFormData({...formData, registrationFee: e.target.value})}
className="w-full bg-white border border-brand-indigo/20 rounded-xl py-4 pl-12 pr-6 text-sm font-bold text-brand-indigo focus:ring-2 ring-brand-indigo/20 outline-none"
placeholder="Amount in INR"
/>
</div>
</div>
<div className="flex-[2] w-full">
<label className="text-[10px] font-black uppercase tracking-widest text-brand-indigo block mb-3">Registration / Payment Link (e.g. Google Form, Sheets, GPay)</label>
<div className="relative">
<input
type="url"
value={formData.paymentLink}
onChange={e => setFormData({...formData, paymentLink: e.target.value})}
className="w-full bg-white border border-brand-indigo/20 rounded-xl py-4 px-6 text-sm font-bold text-brand-indigo focus:ring-2 ring-brand-indigo/20 outline-none"
placeholder="https://forms.gle/... or payment link"
/>
</div>
</div>
</div>
</motion.div>
@@ -1102,14 +1139,59 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
))}
</div>
) : (
<select
value={formData.academicYears[0] || ''}
onChange={e => setFormData({...formData, academicYears: [e.target.value], targetedSections: [], allSections: false})}
className="w-full bg-slate-50 border border-transparent rounded-2xl py-4 px-6 text-sm font-semibold appearance-none"
>
<option value="">Select Batch</option>
{availableYears.map(year => <option key={year} value={year}>{year}</option>)}
</select>
<div className="space-y-4">
{deptBatches.length > 0 && (
<div>
<select
value={formData.targetedBatch || ''}
onChange={e => {
const selectedBatchName = e.target.value;
const matchedBatch = deptBatches.find(b => b.name === selectedBatchName);
if (matchedBatch) {
const targetYearsSet = new Set<string>();
const targetSecsSet = new Set<string>();
matchedBatch.classes.forEach((cls: string) => {
const parts = cls.split(' - ');
if (parts.length === 2) {
targetYearsSet.add(parts[0]);
targetSecsSet.add(parts[1]);
}
});
setFormData({
...formData,
targetedBatch: selectedBatchName,
academicYears: Array.from(targetYearsSet),
targetedSections: Array.from(targetSecsSet),
allSections: false
});
} else {
setFormData({
...formData,
targetedBatch: '',
academicYears: [],
targetedSections: [],
allSections: false
});
}
}}
className="w-full bg-indigo-50 border border-transparent rounded-2xl py-4 px-6 text-sm font-semibold appearance-none text-brand-indigo"
>
<option value="">-- Target Custom Batch --</option>
{deptBatches.map(b => <option key={b.id} value={b.name}>{b.name}</option>)}
</select>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1.5 ml-2">Or select a standard academic year below:</p>
</div>
)}
<select
value={formData.academicYears[0] || ''}
onChange={e => setFormData({...formData, targetedBatch: '', academicYears: [e.target.value], targetedSections: [], allSections: false})}
className="w-full bg-slate-50 border border-transparent rounded-2xl py-4 px-6 text-sm font-semibold appearance-none"
>
<option value="">Select Academic Year</option>
{availableYears.map(year => <option key={year} value={year}>{year}</option>)}
</select>
</div>
)}
</div>
</div>

View File

@@ -33,6 +33,7 @@ interface Event {
category: string;
createdAt?: string;
updatedAt?: string;
targetedBatch?: string;
}
interface ClassMapping {
@@ -63,9 +64,19 @@ const DepartmentCard: React.FC<{
index: number;
isApplicable: boolean;
sections: string[];
batches: any[];
onIncompleteClick?: (data: any) => void;
isCompact?: boolean;
}> = ({ deptName, events, selectedBatch, index, isApplicable, sections, onIncompleteClick, isCompact }) => {
}> = ({ deptName, events, selectedBatch, index, isApplicable, sections, batches, onIncompleteClick, isCompact }) => {
const activeBatchesForYear = useMemo(() => {
return batches.filter(b =>
b.department === deptName &&
b.classes?.some((cls: string) => cls.startsWith(selectedBatch))
);
}, [batches, deptName, selectedBatch]);
const hasCustomBatches = activeBatchesForYear.length > 0;
const departmentEvents = events.filter(e => {
const status = e.status?.toUpperCase();
const isDeptMatch = e.department?.trim().toLowerCase() === deptName.trim().toLowerCase();
@@ -75,10 +86,9 @@ const DepartmentCard: React.FC<{
(status === 'APPROVED' || status === 'COMPLETED' || status === 'PENDING_PR');
});
// Base requirement is 1 event per category per batch, or 2 if multiple sections exist
// Base requirement is 1 event per category per batch, or 1 if no custom batches exist
const getRequiredCount = (catId: string) => {
if (catId === 'INSTITUTIONAL') return 1;
return sections.length > 1 ? 2 : 1;
return 1;
};
return (
@@ -97,6 +107,11 @@ const DepartmentCard: React.FC<{
<div className="flex items-center gap-2">
{!isApplicable ? (
<span className="px-3 py-1 bg-amber-50 text-amber-600 rounded-full border border-amber-100 text-[8px] font-black uppercase tracking-widest">Inert Batch</span>
) : hasCustomBatches ? (
<span className="px-3 py-1 bg-brand-glow text-brand-indigo rounded-full border border-brand-indigo/10 text-[8px] font-black uppercase tracking-widest flex items-center gap-1.5">
<Layers className="w-2.5 h-2.5" />
{activeBatchesForYear.length} Batches
</span>
) : (
<span className="px-3 py-1 bg-brand-glow text-brand-indigo rounded-full border border-brand-indigo/10 text-[8px] font-black uppercase tracking-widest flex items-center gap-1.5">
<Users className="w-2.5 h-2.5" />
@@ -132,9 +147,17 @@ const DepartmentCard: React.FC<{
}
});
const eventsRequired = getRequiredCount(cat.id);
const eventsRequired = hasCustomBatches ? activeBatchesForYear.length : getRequiredCount(cat.id);
const missingSections = sections.filter(s => !coveredSections.has(s));
const isFullyCovered = (missingSections.length === 0 && currentCount > 0) || (currentCount >= eventsRequired);
const coveredBatches = activeBatchesForYear.filter(b =>
matchingEvents.some(e => e.category === 'INSTITUTIONAL' || !e.targetedBatch || e.targetedBatch === b.name)
);
const missingBatches = activeBatchesForYear.filter(b => !coveredBatches.includes(b));
const isFullyCovered = hasCustomBatches
? (missingBatches.length === 0 && matchingEvents.length > 0)
: (missingSections.length === 0 && currentCount > 0) || (currentCount >= eventsRequired);
return (
<div
@@ -192,7 +215,17 @@ const DepartmentCard: React.FC<{
/>
</div>
{missingSections.length > 0 && currentCount > 0 && (
{hasCustomBatches && missingBatches.length > 0 && currentCount > 0 && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="flex items-start gap-2 p-3 bg-red-50 rounded-xl border border-red-100 mt-2">
<AlertTriangle className="w-3.5 h-3.5 text-red-500 shrink-0" />
<div>
<p className="text-[9px] font-black text-red-600 uppercase tracking-widest">Batches Left Out</p>
<p className="text-[10px] font-bold text-red-700">{missingBatches.map(b => b.name).join(', ')}</p>
</div>
</motion.div>
)}
{!hasCustomBatches && missingSections.length > 0 && currentCount > 0 && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} className="flex items-start gap-2 p-3 bg-red-50 rounded-xl border border-red-100 mt-2">
<AlertTriangle className="w-3.5 h-3.5 text-red-500 shrink-0" />
<div>
@@ -219,6 +252,7 @@ export const InstitutionalChecklist: React.FC<{
const [classes, setClasses] = useState<ClassMapping[]>([]);
const [loading, setLoading] = useState(true);
const [selectedBatch, setSelectedBatch] = useState<string>('');
const [batches, setBatches] = useState<any[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(6);
@@ -228,9 +262,10 @@ export const InstitutionalChecklist: React.FC<{
const fetchData = async () => {
try {
const [eventsRes, classesRes] = await Promise.all([
const [eventsRes, classesRes, batchesRes] = await Promise.all([
fetch(API_BASE_URL + '/api/events'),
fetch(API_BASE_URL + '/api/classes')
fetch(API_BASE_URL + '/api/classes'),
fetch(API_BASE_URL + '/api/batches')
]);
if (eventsRes.ok && classesRes.ok) {
@@ -238,12 +273,14 @@ export const InstitutionalChecklist: React.FC<{
eventsRes.json(),
classesRes.json()
]);
const batchesData = batchesRes.ok ? await batchesRes.json() : [];
setEvents(eventsData);
setClasses(classesData);
setBatches(batchesData);
const batches = Array.from(new Set(classesData.map((c: any) => c.academicYear.trim()))).sort().reverse();
if (batches.length > 0 && !selectedBatch) {
setSelectedBatch(batches[0] as string);
const yearBatches = Array.from(new Set(classesData.map((c: any) => c.academicYear.trim()))).sort().reverse();
if (yearBatches.length > 0 && !selectedBatch) {
setSelectedBatch(yearBatches[0] as string);
}
}
} catch (error) {
@@ -347,6 +384,7 @@ export const InstitutionalChecklist: React.FC<{
index={idx}
isApplicable={deptClasses.length > 0}
sections={sections}
batches={batches}
onIncompleteClick={onIncompleteClick}
isCompact={isCompact}
/>

View File

@@ -0,0 +1,798 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Bell,
Trash2,
Plus,
Layers,
Settings,
Upload,
Calendar,
X,
CheckCircle2,
AlertTriangle,
Globe,
Loader2,
FileImage
} from 'lucide-react';
import { cn } from '../../lib/utils';
import { API_BASE_URL } from '../../lib/config';
import { useDialog } from '../../context/DialogContext';
interface Notice {
id: string;
title: string;
message: string;
timestamp: string;
type: 'INFO' | 'URGENT' | 'DELAY';
image?: string;
expiresAt?: string;
}
interface SpecialEvent {
id: string;
title: string;
description: string;
link: string;
created_at: string;
}
export const ManageNoticesView: React.FC = () => {
const { showAlert, showConfirm } = useDialog();
// Navigation Tabs
const [activeTab, setActiveTab] = useState<'notices' | 'scroll' | 'special-events'>('notices');
// Data States
const [notices, setNotices] = useState<Notice[]>([]);
const [specialEvents, setSpecialEvents] = useState<SpecialEvent[]>([]);
const [scrollConfig, setScrollConfig] = useState({
isActive: false,
title: '',
message: ''
});
const [loading, setLoading] = useState(true);
// Modal States
const [isNoticeModalOpen, setIsNoticeModalOpen] = useState(false);
const [isEventModalOpen, setIsEventModalOpen] = useState(false);
// Form States (Notice)
const [noticeTitle, setNoticeTitle] = useState('');
const [noticeMessage, setNoticeMessage] = useState('');
const [noticeType, setNoticeType] = useState<'INFO' | 'URGENT' | 'DELAY'>('INFO');
const [noticeImage, setNoticeImage] = useState<string | null>(null);
// Form States (Special Event)
const [eventTitle, setEventTitle] = useState('');
const [eventDesc, setEventDesc] = useState('');
const [eventLink, setEventLink] = useState('');
const [eventImage, setEventImage] = useState<string | null>(null);
const handleEventImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 1.5 * 1024 * 1024) {
showAlert('File Too Large', 'Please upload an image smaller than 1.5MB.', 'error');
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setEventImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
useEffect(() => {
fetchInitialData();
}, []);
const fetchInitialData = async () => {
try {
setLoading(true);
const [noticesRes, eventsRes, settingsRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/announcements`),
fetch(`${API_BASE_URL}/api/special-events`),
fetch(`${API_BASE_URL}/api/settings`)
]);
if (noticesRes.ok) {
const noticesData = await noticesRes.json();
setNotices(noticesData);
}
if (eventsRes.ok) {
const eventsData = await eventsRes.json();
setSpecialEvents(eventsData);
}
if (settingsRes.ok) {
const settingsData = await settingsRes.json();
if (settingsData.scroll_notification) {
setScrollConfig(settingsData.scroll_notification);
}
}
} catch (err) {
console.error('Failed to load notice configuration data:', err);
} finally {
setLoading(false);
}
};
// Sticky notice limits check
const handleOpenNoticeModal = () => {
if (notices.length >= 3) {
showAlert(
'Limit Reached',
'A maximum of 3 notices can be active on the campus notice board at any time. Please delete an existing notice first.',
'info'
);
return;
}
setNoticeTitle('');
setNoticeMessage('');
setNoticeType('INFO');
setNoticeImage(null);
setIsNoticeModalOpen(true);
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 1.5 * 1024 * 1024) {
showAlert('File Too Large', 'Please upload an image smaller than 1.5MB.', 'error');
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setNoticeImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleAddNotice = async (e: React.FormEvent) => {
e.preventDefault();
if (!noticeTitle.trim() || !noticeMessage.trim()) {
showAlert('Required Fields', 'Please fill in both title and message.', 'error');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/announcements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: noticeTitle.trim(),
message: noticeMessage.trim(),
type: noticeType,
image: noticeImage
})
});
if (response.ok) {
setIsNoticeModalOpen(false);
fetchInitialData();
showAlert('Success', 'Notice posted successfully to Campus Notice Board.', 'success');
}
} catch (err) {
console.error('Failed to post notice:', err);
showAlert('Error', 'Failed to save notice. Please try again.', 'error');
}
};
const handleDeleteNotice = async (id: string) => {
showConfirm(
'Remove Notice',
'Are you sure you want to remove this notice from the board?',
async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/announcements/${id}`, {
method: 'DELETE'
});
if (response.ok) {
fetchInitialData();
showAlert('Success', 'Notice removed successfully.', 'success');
}
} catch (err) {
console.error('Delete failed:', err);
showAlert('Error', 'Failed to delete notice.', 'error');
}
}
);
};
// Scroll Notification Updates
const handleSaveScrollConfig = async (e: React.FormEvent) => {
e.preventDefault();
if (scrollConfig.isActive && (!scrollConfig.title.trim() || !scrollConfig.message.trim())) {
showAlert('Required fields', 'Please enter a title and message for the scroll notification.', 'error');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: 'scroll_notification',
value: scrollConfig
})
});
if (response.ok) {
fetchInitialData();
showAlert('Success', 'Ancient scroll notification settings saved successfully.', 'success');
}
} catch (err) {
console.error('Save failed:', err);
showAlert('Error', 'Failed to update scroll settings.', 'error');
}
};
// Special Events Updates
const handleAddSpecialEvent = async (e: React.FormEvent) => {
e.preventDefault();
if (!eventTitle.trim() || !eventDesc.trim() || !eventLink.trim()) {
showAlert('Required Fields', 'Please fill in all special event details.', 'error');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/api/special-events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: eventTitle.trim(),
description: eventDesc.trim(),
link: eventLink.trim(),
created_by: 'ADMIN',
image: eventImage
})
});
if (response.ok) {
setIsEventModalOpen(false);
setEventTitle('');
setEventDesc('');
setEventLink('');
setEventImage(null);
fetchInitialData();
showAlert('Success', 'Special event added to the registry successfully.', 'success');
}
} catch (err) {
console.error('Post failed:', err);
showAlert('Error', 'Failed to create special event.', 'error');
}
};
const handleDeleteSpecialEvent = async (id: string) => {
showConfirm(
'Delete Special Event',
'Are you sure you want to remove this event from the registry?',
async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/special-events/${id}`, {
method: 'DELETE'
});
if (response.ok) {
fetchInitialData();
showAlert('Success', 'Special event deleted successfully.', 'success');
}
} catch (err) {
console.error('Delete failed:', err);
showAlert('Error', 'Failed to delete event.', 'error');
}
}
);
};
if (loading) {
return (
<div className="flex items-center justify-center p-24">
<Loader2 className="w-8 h-8 text-brand-indigo animate-spin" />
</div>
);
}
return (
<div className="space-y-10 font-sans">
{/* Title */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div>
<h1 className="text-4xl font-black text-text-dark tracking-tight mb-1">Notices & Banner Settings</h1>
<p className="text-text-muted font-black uppercase tracking-widest text-[10px]">Configure notice boards, scroll announcements, & special events</p>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-100 gap-6">
<button
onClick={() => setActiveTab('notices')}
className={cn(
"pb-4 text-xs font-black uppercase tracking-widest border-b-2 transition-all flex items-center gap-2",
activeTab === 'notices'
? "border-brand-indigo text-brand-indigo"
: "border-transparent text-text-muted hover:text-brand-indigo/70"
)}
>
<Bell className="w-4 h-4" />
Notice Board ({notices.length}/3)
</button>
<button
onClick={() => setActiveTab('scroll')}
className={cn(
"pb-4 text-xs font-black uppercase tracking-widest border-b-2 transition-all flex items-center gap-2",
activeTab === 'scroll'
? "border-brand-indigo text-brand-indigo"
: "border-transparent text-text-muted hover:text-brand-indigo/70"
)}
>
<Settings className="w-4 h-4" />
Scroll Notification
</button>
<button
onClick={() => setActiveTab('special-events')}
className={cn(
"pb-4 text-xs font-black uppercase tracking-widest border-b-2 transition-all flex items-center gap-2",
activeTab === 'special-events'
? "border-brand-indigo text-brand-indigo"
: "border-transparent text-text-muted hover:text-brand-indigo/70"
)}
>
<Layers className="w-4 h-4" />
Special Events
</button>
</div>
{/* Notices Tab */}
{activeTab === 'notices' && (
<div className="space-y-6">
<div className="flex justify-between items-center bg-slate-50 p-6 rounded-3xl border border-slate-100">
<div>
<h2 className="text-lg font-black text-text-dark">Active Campus Notices</h2>
<p className="text-xs text-text-muted font-bold">These cards are pinned to the student Campus Notice Board (Maximum 3 notices).</p>
</div>
<button
onClick={handleOpenNoticeModal}
className="flex items-center gap-2 bg-brand-indigo text-white px-5 py-3 rounded-2xl font-black text-xs uppercase tracking-widest hover:scale-[1.02] transition-all shadow-md shadow-brand-indigo/15"
>
<Plus className="w-4 h-4" />
Post Notice
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{notices.map(notice => (
<div key={notice.id} className="bg-white border border-slate-100 p-6 rounded-[2rem] premium-shadow flex flex-col justify-between min-h-[250px] relative">
<div>
<div className="flex items-center justify-between mb-4">
<span className={cn(
"px-2.5 py-1 text-[8px] font-black uppercase tracking-wider rounded",
notice.type === 'URGENT' ? 'bg-red-550/10 text-rose-600' :
notice.type === 'DELAY' ? 'bg-amber-500/10 text-amber-600' :
'bg-slate-100 text-slate-600'
)}>
{notice.type}
</span>
<button
onClick={() => handleDeleteNotice(notice.id)}
className="p-2 hover:bg-rose-50 text-slate-400 hover:text-rose-500 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{notice.image && (
<img
src={notice.image}
alt="Notice Attachment"
className="w-full h-36 object-cover rounded-2xl mb-4 border border-slate-100"
/>
)}
<h3 className="text-base font-black text-text-dark tracking-tight mb-2 leading-tight">{notice.title}</h3>
<p className="text-xs text-text-muted font-medium leading-relaxed mb-4">{notice.message}</p>
</div>
<div className="text-[10px] font-bold text-slate-400 border-t border-slate-50 pt-3 mt-auto">
{new Date(notice.timestamp).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}
</div>
</div>
))}
{notices.length === 0 && (
<div className="col-span-full py-16 text-center text-slate-400 font-bold uppercase tracking-wider text-xs border-2 border-dashed border-slate-200 rounded-[2.5rem]">
No active announcements on the notice board.
</div>
)}
</div>
</div>
)}
{/* Scroll Notifications Tab */}
{activeTab === 'scroll' && (
<div className="max-w-xl bg-white border border-slate-100 premium-shadow rounded-[2rem] p-8 space-y-6">
<div>
<h2 className="text-lg font-black text-text-dark">Ancient Scroll Notifications</h2>
<p className="text-xs text-text-muted font-bold">Configure a custom styled scroll overlay alert visible on the student home page.</p>
</div>
<form onSubmit={handleSaveScrollConfig} className="space-y-5">
{/* Toggle Status */}
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-2xl border border-slate-100">
<div>
<p className="text-[11px] font-black text-text-dark leading-tight">Display Notification Scroll</p>
<p className="text-[9px] font-bold text-text-muted">Turns the scroll visibility on/off for students</p>
</div>
<button
type="button"
onClick={() => setScrollConfig({...scrollConfig, isActive: !scrollConfig.isActive})}
className={cn(
"w-10 h-5 rounded-full p-1 transition-all duration-300",
scrollConfig.isActive ? "bg-brand-indigo" : "bg-slate-300"
)}
>
<div className={cn(
"w-3 h-3 bg-white rounded-full transition-all duration-300 transform",
scrollConfig.isActive ? "translate-x-5" : "translate-x-0"
)} />
</button>
</div>
{/* Title */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Scroll Title</label>
<input
type="text"
placeholder="E.g., The Grand Decrees of RIT"
value={scrollConfig.title}
onChange={e => setScrollConfig({...scrollConfig, title: e.target.value})}
className="w-full bg-slate-50 border border-slate-200 rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all text-slate-800"
required={scrollConfig.isActive}
/>
</div>
{/* Message */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Scroll Decree Message</label>
<textarea
rows={4}
placeholder="Enter message to display on the parchment scroll..."
value={scrollConfig.message}
onChange={e => setScrollConfig({...scrollConfig, message: e.target.value})}
className="w-full bg-slate-50 border border-slate-200 rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all text-slate-800"
required={scrollConfig.isActive}
/>
</div>
<button
type="submit"
className="w-full py-4 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl font-black text-xs uppercase tracking-widest transition-all flex items-center justify-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
Save Configuration
</button>
</form>
</div>
)}
{/* Special Events Tab */}
{activeTab === 'special-events' && (
<div className="space-y-6">
<div className="flex justify-between items-center bg-slate-50 p-6 rounded-3xl border border-slate-100">
<div>
<h2 className="text-lg font-black text-text-dark">Special Events Registry</h2>
<p className="text-xs text-text-muted font-bold">These events are rendered in the modern slanted registry section of the student dashboard.</p>
</div>
<button
onClick={() => setIsEventModalOpen(true)}
className="flex items-center gap-2 bg-brand-indigo text-white px-5 py-3 rounded-2xl font-black text-xs uppercase tracking-widest hover:scale-[1.02] transition-all shadow-md shadow-brand-indigo/15"
>
<Plus className="w-4 h-4" />
Add Special Event
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{specialEvents.map(event => (
<div key={event.id} className="bg-white border border-slate-100 p-6 rounded-[2rem] premium-shadow flex flex-col justify-between min-h-[220px]">
<div>
<div className="flex items-center justify-between mb-4">
<span className="px-2.5 py-1 bg-cyan-50 text-cyan-600 border border-cyan-100 text-[8px] font-black uppercase tracking-wider rounded">
Special Event
</span>
<button
onClick={() => handleDeleteSpecialEvent(event.id)}
className="p-2 hover:bg-rose-50 text-slate-400 hover:text-rose-500 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<h3 className="text-base font-black text-text-dark tracking-tight mb-2 leading-tight">{event.title}</h3>
<p className="text-xs text-text-muted font-medium leading-relaxed mb-4 line-clamp-3">{event.description}</p>
</div>
<div className="border-t border-slate-50 pt-3 mt-auto flex items-center justify-between">
<a
href={event.link}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-black text-brand-indigo uppercase tracking-wider hover:underline flex items-center gap-1.5"
>
View Registry Link
<Globe className="w-3.5 h-3.5" />
</a>
</div>
</div>
))}
{specialEvents.length === 0 && (
<div className="col-span-full py-16 text-center text-slate-400 font-bold uppercase tracking-wider text-xs border-2 border-dashed border-slate-200 rounded-[2.5rem]">
No special events currently registered.
</div>
)}
</div>
</div>
)}
{/* Notice Dialog Modal */}
<AnimatePresence>
{isNoticeModalOpen && (
<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={() => setIsNoticeModalOpen(false)}
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-lg bg-white rounded-[2.5rem] premium-shadow overflow-hidden flex flex-col max-h-[90vh]"
>
<form onSubmit={handleAddNotice} className="flex flex-col overflow-hidden">
{/* Header */}
<div className="p-8 bg-brand-navy text-white flex justify-between items-start shrink-0">
<div>
<h3 className="text-2xl font-black tracking-tight">Post New Notice</h3>
<p className="text-white/60 text-xs font-medium mt-1">Configure campus notice board bulletin.</p>
</div>
<button
type="button"
onClick={() => setIsNoticeModalOpen(false)}
className="p-2 hover:bg-white/10 rounded-xl transition-all"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Body */}
<div className="p-8 space-y-5 overflow-y-auto custom-scrollbar flex-1 max-h-[60vh]">
{/* Title */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Notice Title</label>
<input
type="text"
required
value={noticeTitle}
onChange={e => setNoticeTitle(e.target.value)}
placeholder="Enter short, descriptive title"
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Message */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Notice Message</label>
<textarea
rows={3}
required
value={noticeMessage}
onChange={e => setNoticeMessage(e.target.value)}
placeholder="Write announcement body..."
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Alert Type */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Alert Casing</label>
<select
value={noticeType}
onChange={e => setNoticeType(e.target.value as any)}
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all appearance-none"
>
<option value="INFO">Information (INFO)</option>
<option value="URGENT">Urgent Announcement (URGENT)</option>
<option value="DELAY">Schedule Delay / Change (DELAY)</option>
</select>
</div>
{/* Image Dropzone */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Notice Banner Image</label>
<div className="border-2 border-dashed border-slate-200 hover:border-brand-indigo/40 rounded-2xl p-6 bg-slate-50/50 flex flex-col items-center justify-center text-center relative group transition-colors">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full z-10"
/>
{noticeImage ? (
<div className="space-y-3 z-20">
<img
src={noticeImage}
alt="Notice preview"
className="max-h-28 object-cover rounded-xl border border-slate-200"
/>
<button
type="button"
onClick={() => setNoticeImage(null)}
className="text-[10px] font-black text-rose-500 hover:text-rose-700 uppercase tracking-widest block mx-auto transition-all"
>
Remove Banner
</button>
</div>
) : (
<div className="space-y-2 pointer-events-none">
<FileImage className="w-8 h-8 text-slate-300 mx-auto group-hover:text-brand-indigo/60 transition-colors" />
<div>
<span className="text-[10px] font-black text-brand-indigo uppercase tracking-wider">Upload banner image</span>
<p className="text-[9px] text-slate-400 font-bold mt-0.5">JPEG, PNG up to 1.5MB</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="shrink-0 w-full">
<button
type="submit"
className="w-full bg-brand-navy hover:bg-[#0c365c] text-white py-5 font-black text-xs uppercase tracking-[0.2em] transition-all flex items-center justify-center"
>
Post Notice Bulletin
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Special Event Dialog Modal */}
<AnimatePresence>
{isEventModalOpen && (
<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={() => setIsEventModalOpen(false)}
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-lg bg-white rounded-[2.5rem] premium-shadow overflow-hidden flex flex-col max-h-[90vh]"
>
<form onSubmit={handleAddSpecialEvent} className="flex flex-col overflow-hidden">
{/* Header */}
<div className="p-8 bg-brand-navy text-white flex justify-between items-start shrink-0">
<div>
<h3 className="text-2xl font-black tracking-tight">Add Special Event</h3>
<p className="text-white/60 text-xs font-medium mt-1">Configure special event registry entry.</p>
</div>
<button
type="button"
onClick={() => setIsEventModalOpen(false)}
className="p-2 hover:bg-white/10 rounded-xl transition-all"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Body */}
<div className="p-8 space-y-5 overflow-y-auto custom-scrollbar flex-1 max-h-[60vh]">
{/* Title */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Event Title</label>
<input
type="text"
required
value={eventTitle}
onChange={e => setEventTitle(e.target.value)}
placeholder="Hackathon, techfest, etc."
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Description */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Event Description</label>
<textarea
rows={3}
required
value={eventDesc}
onChange={e => setEventDesc(e.target.value)}
placeholder="Write brief description..."
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Link */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Registry External Link</label>
<input
type="url"
required
value={eventLink}
onChange={e => setEventLink(e.target.value)}
placeholder="https://example.com/registration"
className="w-full bg-slate-50 border-transparent rounded-xl py-3 px-4 text-xs font-bold focus:bg-white focus:border-brand-indigo outline-none transition-all"
/>
</div>
{/* Image Dropzone */}
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Event Cover Image (Optional)</label>
<div className="border-2 border-dashed border-slate-200 hover:border-brand-indigo/40 rounded-2xl p-6 bg-slate-50/50 flex flex-col items-center justify-center text-center relative group transition-colors">
<input
type="file"
accept="image/*"
onChange={handleEventImageUpload}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full z-10"
/>
{eventImage ? (
<div className="space-y-3 z-20">
<img
src={eventImage}
alt="Event preview"
className="max-h-28 object-cover rounded-xl border border-slate-200"
/>
<button
type="button"
onClick={() => setEventImage(null)}
className="text-[10px] font-black text-rose-500 hover:text-rose-700 uppercase tracking-widest block mx-auto transition-all"
>
Remove Cover Image
</button>
</div>
) : (
<div className="space-y-2 pointer-events-none">
<FileImage className="w-8 h-8 text-slate-300 mx-auto group-hover:text-brand-indigo/60 transition-colors" />
<div>
<span className="text-[10px] font-black text-brand-indigo uppercase tracking-wider">Upload cover image</span>
<p className="text-[9px] text-slate-400 font-bold mt-0.5">JPEG, PNG up to 1.5MB</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="shrink-0 w-full">
<button
type="submit"
className="w-full bg-brand-navy hover:bg-[#0c365c] text-white py-5 font-black text-xs uppercase tracking-[0.2em] transition-all flex items-center justify-center"
>
Register Special Event
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import type { UserRole, DashboardView, Event, Announcement, SpecialEvent } from '../../types';
import { useAuth } from '../../context/AuthContext';
import { useDialog } from '../../context/DialogContext';
import { API_BASE_URL } from '../../lib/config';
// Import Site 2 components
@@ -25,6 +26,7 @@ const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const StudentDashboard: React.FC = () => {
const { user, logout } = useAuth();
const { showAlert } = useDialog();
// Tab Routing
const [activeView, setActiveView] = useState<DashboardView>('HOME');
@@ -36,6 +38,7 @@ export const StudentDashboard: React.FC = () => {
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
const [specialEvents, setSpecialEvents] = useState<SpecialEvent[]>([]);
const [settings, setSettings] = useState<Record<string, any>>({});
const [isLoading, setIsLoading] = useState(true);
// Team Modals
@@ -151,13 +154,23 @@ export const StudentDashboard: React.FC = () => {
// Fetch special events safely (defaulting to empty array if not supported)
try {
const seRes = await fetch(`${API_BASE_URL}/api/special-events`);
const [seRes, setRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/special-events`),
fetch(`${API_BASE_URL}/api/settings`)
]);
if (seRes.ok) {
const seData = await seRes.json();
setSpecialEvents(seData);
}
} catch {
if (setRes.ok) {
const setData = await setRes.json();
setSettings(setData);
}
} catch (err) {
console.error("Failed to fetch special events/settings:", err);
setSpecialEvents([]);
setSettings({});
}
} catch (err) {
console.error("Failed to fetch student dashboard data from Firestore:", err);
@@ -186,7 +199,7 @@ export const StudentDashboard: React.FC = () => {
}, [userRegistrations]);
// Actions
const handleToggleBooking = async (eventId: string) => {
const handleToggleBooking = async (eventId: string, customDetails?: any) => {
if (!user) return;
const isBooked = bookedEventIds.includes(eventId);
setIsProcessing(true);
@@ -201,7 +214,7 @@ export const StudentDashboard: React.FC = () => {
if (res.ok) {
await fetchData();
} else {
alert("Failed to cancel registration.");
showAlert("Error", "Failed to cancel registration.", "error");
}
} else {
// Block booking if there's an unfinalized completed event (missing proof or missing OD approval)
@@ -217,7 +230,7 @@ export const StudentDashboard: React.FC = () => {
if (unfinalized) {
const ev = events.find(e => String(e.id) === String(unfinalized.eventId || unfinalized.event_id));
alert(`Registration Blocked: You must upload your certificate and obtain OD approval for your completed event "${ev?.title || 'Past Event'}" before registering for another event.`);
showAlert("Registration Blocked", `You must upload your certificate and obtain OD approval for your completed event "${ev?.title || 'Past Event'}" before registering for another event.`, "error");
setIsProcessing(false);
return;
}
@@ -231,13 +244,13 @@ export const StudentDashboard: React.FC = () => {
user_id: user.id,
eventId: Number(eventId),
event_id: Number(eventId),
userEmail: user.email,
userName: user.fullName,
regNo: user.regNo,
phone: user.phone,
userEmail: customDetails?.email || user.email,
userName: customDetails?.userName || user.fullName,
regNo: customDetails?.regNo || user.regNo,
phone: customDetails?.phone || user.phone,
gender: user.gender,
dept: user.department,
section: user.section,
dept: customDetails?.dept || user.department,
section: customDetails?.section || user.section,
year: user.year,
college: user.collegeName || 'Rajalakshmi Institute of Technology'
};
@@ -253,7 +266,7 @@ export const StudentDashboard: React.FC = () => {
setActiveView('REGISTRATIONS');
} else {
const err = await res.json();
alert(err.message || "Registration failed.");
showAlert("Error", err.message || "Registration failed.", "error");
}
}
} catch (err) {
@@ -266,7 +279,10 @@ export const StudentDashboard: React.FC = () => {
const generateCode = () => Math.random().toString(36).substring(2, 8).toUpperCase();
const handleCreateTeam = async (eventId: string) => {
if (!teamName.trim()) return alert("Enter team name");
if (!teamName.trim()) {
showAlert("Required", "Enter team name", "error");
return;
}
setIsProcessing(true);
try {
const code = generateCode();
@@ -288,14 +304,17 @@ export const StudentDashboard: React.FC = () => {
await fetchData();
} catch (err) {
console.error(err);
alert("Failed to form team");
showAlert("Error", "Failed to form team", "error");
} finally {
setIsProcessing(false);
}
};
const handleJoinTeam = async (eventId: string) => {
if (!teamCode.trim()) return alert("Enter team code");
if (!teamCode.trim()) {
showAlert("Required", "Enter team code", "error");
return;
}
setIsProcessing(true);
try {
// Fetch team registrations to validate
@@ -304,7 +323,7 @@ export const StudentDashboard: React.FC = () => {
const teamRegs = await res.json();
if (!teamRegs || teamRegs.length === 0) {
alert("Invalid team code for this event.");
showAlert("Invalid Team", "Invalid team code for this event.", "error");
setIsProcessing(false);
return;
}
@@ -313,13 +332,13 @@ export const StudentDashboard: React.FC = () => {
const eventVal = events.find(e => String(e.id) === String(eventId));
if (eventVal?.teamSizeLimit && teamRegs.length >= eventVal.teamSizeLimit) {
alert("Team is already full.");
showAlert("Team Full", "Team is already full.", "error");
setIsProcessing(false);
return;
}
if (eventVal?.teamComposition === 'INTER_DEPT' && leader && user?.department !== (leader.dept || leader.department)) {
alert(`This event requires INTER-DEPARTMENT teams. You must join a team from department ${(leader.dept || leader.department)}`);
showAlert("Inter-Department Only", `This event requires INTER-DEPARTMENT teams. You must join a team from department ${(leader.dept || leader.department)}`, "error");
setIsProcessing(false);
return;
}
@@ -342,7 +361,7 @@ export const StudentDashboard: React.FC = () => {
await fetchData();
} catch (err) {
console.error(err);
alert("Failed to join team");
showAlert("Error", "Failed to join team", "error");
} finally {
setIsProcessing(false);
}
@@ -388,6 +407,7 @@ export const StudentDashboard: React.FC = () => {
announcements={announcements}
onNavigateToEvents={() => setActiveView('EVENTS')}
specialEvents={specialEvents}
settings={settings}
/>
</>
);
@@ -457,7 +477,7 @@ export const StudentDashboard: React.FC = () => {
}
return (
<div className="min-h-screen bg-[#F3F4F6]">
<div className="min-h-screen bg-white">
<Navbar
activeView={activeView}
onViewChange={(view) => {

View File

@@ -14,6 +14,7 @@ import {
Award
} from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { useDialog } from '../../context/DialogContext';
import { cn } from '../../lib/utils';
interface Event {
@@ -52,6 +53,7 @@ interface Registration {
export const StudentRegistrationsView: React.FC = () => {
const { user } = useAuth();
const { showAlert, showConfirm } = useDialog();
const [events, setEvents] = useState<Event[]>([]);
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [registrations, setRegistrations] = useState<Registration[]>([]);
@@ -74,10 +76,25 @@ export const StudentRegistrationsView: React.FC = () => {
const daysList = ["Day 1", "Day 2", "Day 3"]; // Standard slots
const [batches, setBatches] = useState<any[]>([]);
useEffect(() => {
fetchEvents();
fetchBatches();
}, []);
const fetchBatches = async () => {
try {
const res = await fetch(API_BASE_URL + '/api/batches');
if (res.ok) {
const data = await res.json();
setBatches(data);
}
} catch (err) {
console.error(err);
}
};
useEffect(() => {
if (selectedEvent) {
fetchRegistrations(selectedEvent.id);
@@ -161,6 +178,33 @@ export const StudentRegistrationsView: React.FC = () => {
}
};
// Dismiss Student from event
const handleDismissStudent = async (regId: string, studentName: string) => {
showConfirm(
"Dismiss Student",
`Are you sure you want to dismiss ${studentName} from this event? This will completely remove their registration.`,
async () => {
setIsProcessing(true);
try {
const res = await fetch(`${API_BASE_URL}/api/registrations/${regId}`, {
method: 'DELETE'
});
if (res.ok) {
showAlert("Success", `${studentName} has been successfully dismissed.`, "success");
await fetchRegistrations(selectedEvent!.id);
} else {
showAlert("Error", "Failed to dismiss student.", "error");
}
} catch (err) {
console.error(err);
showAlert("Error", "An error occurred while dismissing the student.", "error");
} finally {
setIsProcessing(false);
}
}
);
};
// Toggle Attendance Cell local state
const handleToggleAttendance = (regId: string, day: string) => {
setAttendanceGrid(prev => ({
@@ -197,10 +241,10 @@ export const StudentRegistrationsView: React.FC = () => {
});
if (res.ok) {
alert("Attendance records synchronized successfully!");
showAlert("Success", "Attendance records synchronized successfully!", "success");
await fetchRegistrations(selectedEvent!.id);
} else {
alert("Failed to save attendance.");
showAlert("Error", "Failed to save attendance.", "error");
}
} catch (err) {
console.error(err);
@@ -223,7 +267,10 @@ export const StudentRegistrationsView: React.FC = () => {
// Upload OD File to all registered students
const handleUploadODFile = async () => {
if (!odFileBase64) return alert("Please select an On-Duty file first.");
if (!odFileBase64) {
showAlert("Error", "Please select an On-Duty file first.", "error");
return;
}
setIsProcessing(true);
try {
// Loop over and update all registrations for this event
@@ -234,7 +281,7 @@ export const StudentRegistrationsView: React.FC = () => {
body: JSON.stringify({ odUrl: odFileBase64 })
});
}
alert("OD sheet broadcasted to all registered students successfully!");
showAlert("Success", "OD sheet broadcasted to all registered students successfully!", "success");
setOdFileBase64(null);
await fetchRegistrations(selectedEvent!.id);
} catch (err) {
@@ -256,7 +303,7 @@ export const StudentRegistrationsView: React.FC = () => {
if (res.ok) {
setInspectCertReg(null);
alert(status === 'APPROVED' ? "Certificate approved!" : "Certificate rejected.");
showAlert("Certificate Audited", status === 'APPROVED' ? "Certificate approved!" : "Certificate rejected.", status === 'APPROVED' ? "success" : "info");
await fetchRegistrations(selectedEvent!.id);
}
} catch (err) {
@@ -271,6 +318,12 @@ export const StudentRegistrationsView: React.FC = () => {
r.regNo.toLowerCase().includes(searchQuery.toLowerCase())
);
const getStudentBatchName = (reg: any) => {
const classStr = `${reg.year} - ${reg.section}`;
const match = batches.find(b => b.department === reg.dept && b.classes?.includes(classStr));
return match ? match.name : '';
};
return (
<div className="space-y-8 animate-in fade-in duration-300">
{/* Header Info */}
@@ -377,12 +430,13 @@ export const StudentRegistrationsView: React.FC = () => {
<th className="py-4">College</th>
<th className="py-4">Alliance/Team</th>
<th className="py-4 text-center">Payment Status</th>
<th className="py-4 text-center">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{filteredRoster.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-300 italic font-bold">No registered students matched the search.</td>
<td colSpan={6} className="py-8 text-center text-slate-300 italic font-bold">No registered students matched the search.</td>
</tr>
) : (
filteredRoster.map((reg) => (
@@ -391,8 +445,13 @@ export const StudentRegistrationsView: React.FC = () => {
<p className="text-sm font-black text-brand-navy">{reg.userName}</p>
<p className="text-[10px] text-slate-400 mt-0.5">{reg.regNo} | {reg.phone}</p>
</td>
<td className="py-4 text-slate-600">
<td className="py-4 text-slate-600">
{reg.year} Year / SEC {reg.section}
{getStudentBatchName(reg) && (
<span className="ml-1.5 px-1.5 py-0.5 bg-brand-indigo/10 text-brand-indigo text-[8px] font-black rounded">
{getStudentBatchName(reg)}
</span>
)}
<p className="text-[9px] text-brand-indigo mt-0.5">{reg.dept}</p>
</td>
<td className="py-4 text-slate-600 truncate max-w-[150px]">{reg.college}</td>
@@ -420,6 +479,15 @@ export const StudentRegistrationsView: React.FC = () => {
{reg.paymentStatus}
</button>
</td>
<td className="py-4 text-center">
<button
onClick={() => handleDismissStudent(reg.id, reg.userName)}
disabled={isProcessing}
className="px-3 py-1.5 bg-rose-50 border border-rose-100 text-rose-600 rounded-lg text-[9px] font-black tracking-widest uppercase hover:bg-rose-100 hover:text-rose-700 transition-all"
>
Dismiss
</button>
</td>
</tr>
))
)}
@@ -450,17 +518,53 @@ export const StudentRegistrationsView: React.FC = () => {
<th className="py-4">Student</th>
<th className="py-4">Register No</th>
{daysList.map(day => <th key={day} className="py-4 text-center">{day}</th>)}
<th className="py-4 text-center">
<div className="flex flex-col items-center gap-1">
<span className="text-[10px]">Mark All</span>
<input
type="checkbox"
checked={
registrations.length > 0 &&
registrations.every(reg =>
daysList.every(day => attendanceGrid[reg.id]?.[day] || false)
)
}
onChange={(e) => {
const checked = e.target.checked;
setAttendanceGrid(prev => {
const next = { ...prev };
registrations.forEach(reg => {
next[reg.id] = next[reg.id] || {};
daysList.forEach(day => {
next[reg.id][day] = checked;
});
});
return next;
});
}}
className="w-4 h-4 rounded border-slate-300 text-brand-indigo focus:ring-brand-indigo cursor-pointer"
title="Toggle all days for all students"
/>
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{registrations.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-300 italic font-bold">No students registered yet.</td>
<td colSpan={6} className="py-8 text-center text-slate-300 italic font-bold">No students registered yet.</td>
</tr>
) : (
registrations.map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50/50">
<td className="py-4 font-black text-brand-navy">{reg.userName}</td>
<tr key={reg.id} className="hover:bg-slate-50/50">
<td className="py-4 font-black text-brand-navy">
{reg.userName}
{getStudentBatchName(reg) && (
<span className="ml-1.5 px-1 py-0.5 bg-brand-indigo/10 text-brand-indigo text-[7px] font-black rounded block w-max mt-0.5">
{getStudentBatchName(reg)}
</span>
)}
</td>
<td className="py-4 text-slate-400">{reg.regNo}</td>
{daysList.map(day => (
<td key={day} className="py-4 text-center">
@@ -472,6 +576,24 @@ export const StudentRegistrationsView: React.FC = () => {
/>
</td>
))}
<td className="py-4 text-center">
<input
type="checkbox"
checked={daysList.every(day => attendanceGrid[reg.id]?.[day] || false)}
onChange={(e) => {
const checked = e.target.checked;
setAttendanceGrid(prev => ({
...prev,
[reg.id]: daysList.reduce((acc, day) => {
acc[day] = checked;
return acc;
}, {} as Record<string, boolean>)
}));
}}
className="w-4.5 h-4.5 rounded border-slate-300 text-brand-indigo focus:ring-brand-indigo cursor-pointer"
title="Toggle all days for this student"
/>
</td>
</tr>
))
)}

View File

@@ -314,14 +314,14 @@ export const UserManagement: React.FC = () => {
onClick={() => setIsModalOpen(false)}
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
/>
<motion.div
<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-lg bg-white rounded-[2.5rem] premium-shadow overflow-hidden"
className="relative w-full max-w-lg bg-white rounded-[2.5rem] premium-shadow overflow-hidden flex flex-col max-h-[90vh]"
>
<form onSubmit={handleSubmit}>
<div className="p-8 bg-brand-navy text-white flex justify-between items-start">
<form onSubmit={handleSubmit} className="flex flex-col overflow-hidden">
<div className="p-8 bg-brand-navy text-white flex justify-between items-start shrink-0">
<div>
<h3 className="text-2xl font-black tracking-tight">
{editingUser ? 'Edit User' : 'Add New User'}
@@ -337,7 +337,7 @@ export const UserManagement: React.FC = () => {
</button>
</div>
<div className="p-8 space-y-5">
<div className="p-8 space-y-5 overflow-y-auto custom-scrollbar flex-1 max-h-[65vh]">
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Full Name</label>
<div className="relative">
@@ -625,15 +625,15 @@ export const UserManagement: React.FC = () => {
</div>
</motion.div>
)}
</div>
<div className="pt-4">
<button
type="submit"
className="w-full bg-brand-navy text-white rounded-xl py-4 font-black text-[10px] uppercase tracking-widest premium-shadow hover:scale-[1.02] active:scale-[0.98] transition-all"
>
{editingUser ? 'Update User Credentials' : 'Create System User'}
</button>
</div>
<div className="shrink-0 w-full">
<button
type="submit"
className="w-full bg-brand-navy hover:bg-[#0c365c] text-white py-5 font-black text-xs uppercase tracking-[0.2em] transition-all flex items-center justify-center"
>
{editingUser ? 'Update User Credentials' : 'Create System User'}
</button>
</div>
</form>
</motion.div>

View File

@@ -9,7 +9,7 @@ import { CLUBS } from './constants';
interface EventCardProps {
event: Event;
isBooked: boolean;
onToggle: () => void;
onToggle: (customDetails?: any) => void;
onTrackStatus?: (event: Event) => void;
currentUserName?: string;
userRole?: string;
@@ -35,6 +35,55 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
const ticketRef = useRef<HTMLDivElement>(null);
const [persistedTicket, setPersistedTicket] = useState<{id: string, qr: string} | null>(null);
// Form states for registration details
const [formName, setFormName] = useState('');
const [formDept, setFormDept] = useState('');
const [formSection, setFormSection] = useState('');
const [formRegNo, setFormRegNo] = useState('');
const [formPhone, setFormPhone] = useState('');
const [formEmail, setFormEmail] = useState('');
const { user } = useAuth();
const [batches, setBatches] = useState<any[]>([]);
useEffect(() => {
const fetchBatches = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/batches`);
if (res.ok) {
const data = await res.json();
setBatches(data);
}
} catch (err) {
console.error(err);
}
};
fetchBatches();
}, []);
const studentBatchName = useMemo(() => {
const userDeptVal = registration?.dept || userDept || (user?.department || '');
const userYearVal = registration?.year || userYear || (user?.year || '');
const userSecVal = registration?.section || userSection || (user?.section || '');
if (!userDeptVal || !userYearVal || !userSecVal) return null;
const classStr = `${userYearVal} - ${userSecVal}`;
const match = batches.find(b => b.department === userDeptVal && b.classes?.includes(classStr));
return match ? match.name : null;
}, [batches, registration, userDept, userYear, userSection, user]);
useEffect(() => {
if (showConfirm && user) {
setFormName(user.fullName || '');
setFormDept(user.department || '');
setFormSection(user.section || 'A');
setFormRegNo(user.regNo || '');
setFormPhone(user.phone || '');
setFormEmail(user.email || '');
}
}, [showConfirm, user]);
useEffect(() => {
if (showTicket && registration?.id) {
const initializeTicket = async () => {
@@ -69,8 +118,6 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
}
}, [showTicket, registration?.id]);
const { user } = useAuth();
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
if (user) {
@@ -131,11 +178,20 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
if (!ticketRef.current) return;
setIsDownloading(true);
try {
const dataUrl = await toPng(ticketRef.current, { cacheBust: true, quality: 1, backgroundColor: '#1e293b' });
const link = document.createElement('a');
link.download = `Pass_${event.title.replace(/\s+/g, '_')}.png`;
link.href = dataUrl;
link.click();
const dataUrl = await toPng(ticketRef.current, { cacheBust: true, quality: 2, backgroundColor: '#1e293b' });
const { jsPDF } = await import('jspdf');
const width = ticketRef.current.offsetWidth || 380;
const height = ticketRef.current.offsetHeight || 580;
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'px',
format: [width, height]
});
pdf.addImage(dataUrl, 'PNG', 0, 0, width, height);
pdf.save(`Pass_${event.title.replace(/\s+/g, '_')}.pdf`);
} catch (err) {
console.error("Capture failed:", err);
} finally {
@@ -328,48 +384,133 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
{showConfirm && (
<Portal>
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-6 bg-slate-900/60 backdrop-blur-sm">
<div className="bg-white rounded-[3rem] w-full max-w-sm p-12 text-center shadow-2xl animate-in zoom-in-95">
<div className={`w-20 h-20 ${event.isTeamEvent ? 'bg-orange-50 text-orange-500' : 'bg-blue-50 text-blue-500'} rounded-full flex items-center justify-center text-3xl mx-auto mb-8`}>
<i className={`fas ${event.isTeamEvent ? 'fa-users' : 'fa-ticket'}`}></i>
</div>
<h3 className="text-2xl font-black text-[#1A202C] uppercase mb-4">
{event.isTeamEvent ? 'Team Registration' : 'Confirm Pass?'}
</h3>
{event.isTeamEvent ? (
<div className="space-y-6 mb-10">
<div className="bg-orange-50 border border-orange-100 rounded-2xl p-6 text-left">
<p className="text-[10px] font-black text-orange-600 uppercase tracking-widest mb-3 flex items-center gap-2">
<div className="bg-white rounded-[3rem] w-full max-w-lg p-8 shadow-2xl animate-in zoom-in-95 flex flex-col max-h-[90vh]">
<div className="overflow-y-auto flex-1 pr-2 custom-scrollbar space-y-6">
<div className={`w-16 h-16 ${event.isTeamEvent ? 'bg-orange-50 text-orange-500' : 'bg-blue-50 text-blue-500'} rounded-full flex items-center justify-center text-2xl mx-auto mb-2`}>
<i className={`fas ${event.isTeamEvent ? 'fa-users' : 'fa-ticket'}`}></i>
</div>
<h3 className="text-xl font-black text-[#1A202C] uppercase text-center mb-1">
{event.isTeamEvent ? 'Team Registration' : 'Confirm Pass?'}
</h3>
{event.isTeamEvent && (
<div className="bg-orange-50 border border-orange-100 rounded-2xl p-4 text-left">
<p className="text-[9px] font-black text-orange-600 uppercase tracking-widest mb-1 flex items-center gap-1.5">
<i className="fas fa-exclamation-triangle"></i> Important Notice
</p>
<p className="text-xs text-orange-800 font-bold leading-relaxed lowercase first-letter:uppercase">
This is a <span className="underline">team-based event</span>. You are about to register as an individual, after which you must <span className="underline">create or join a team</span> in the registrations section to participate.
<p className="text-xs text-orange-800 font-bold leading-normal">
This is a <span className="underline">team-based event</span>. You must create or join a team in the registrations section after registering.
</p>
</div>
)}
{/* Paid Event Links Box */}
{event.paymentLink && (
<div className="bg-indigo-50 border border-brand-indigo/10 rounded-2xl p-4 text-left">
<span className="block text-[8px] font-black text-brand-indigo uppercase tracking-widest mb-2 flex items-center gap-1">
<i className="fas fa-link"></i> Required Payment & Form Link
</span>
<a
href={event.paymentLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-black text-blue-600 hover:underline flex items-center gap-1.5 break-all"
>
{event.paymentLink} <i className="fas fa-external-link-alt text-[9px]"></i>
</a>
</div>
)}
{/* Registration Details Form */}
<div className="space-y-4 text-left">
<span className="block text-[9px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100 pb-1">Review Registration Details</span>
<div className="grid grid-cols-2 gap-4">
<div className="bg-slate-50 border border-slate-100 rounded-xl p-3">
<span className="block text-[8px] font-black text-gray-400 uppercase tracking-widest mb-1">Max Size</span>
<span className="text-xs font-black text-slate-900">{event.teamSizeLimit || '∞'} Members</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-[8px] font-black text-slate-400 uppercase tracking-wider mb-1">Name</label>
<input
type="text"
value={formName}
onChange={e => setFormName(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-xs font-bold outline-none focus:ring-2 focus:ring-brand-indigo/20 text-slate-800"
required
/>
</div>
<div className="bg-slate-50 border border-slate-100 rounded-xl p-3">
<span className="block text-[8px] font-black text-gray-400 uppercase tracking-widest mb-1">Type</span>
<span className="text-xs font-black text-slate-900">{event.teamComposition === 'INTER_DEPT' ? 'Inter-Dept' : 'Mixed'}</span>
<div>
<label className="block text-[8px] font-black text-slate-400 uppercase tracking-wider mb-1">College Email ID</label>
<input
type="email"
value={formEmail}
onChange={e => setFormEmail(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-xs font-bold outline-none focus:ring-2 focus:ring-brand-indigo/20 text-slate-800"
required
/>
</div>
</div>
<p className="text-gray-500 text-[10px] font-bold uppercase tracking-widest">
Proceed with registration for <span className="text-slate-900 underline">"{event.title}"</span>?
</p>
</div>
) : (
<p className="text-gray-500 text-sm mb-10 lowercase first-letter:uppercase">Register for <span className="font-bold">"{event.title}"</span>?</p>
)}
<div className="flex gap-4">
<button onClick={() => setShowConfirm(false)} className="flex-1 py-4 bg-gray-100 text-gray-500 rounded-2xl font-black uppercase tracking-widest text-[10px] hover:bg-gray-200 transition-all">Cancel</button>
<button onClick={() => { onToggle(); setShowConfirm(false); }} className={`flex-1 py-4 ${event.isTeamEvent ? 'bg-orange-500 shadow-orange-200' : 'bg-[#1A202C] shadow-gray-200'} text-white rounded-2xl font-black uppercase tracking-widest text-[10px] shadow-lg active:scale-95 transition-all`}>
{event.isTeamEvent ? 'I Understand' : 'Confirm'}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-[8px] font-black text-slate-400 uppercase tracking-wider mb-1">Dept</label>
<input
type="text"
value={formDept}
onChange={e => setFormDept(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-xs font-bold outline-none focus:ring-2 focus:ring-brand-indigo/20 text-slate-800"
required
/>
</div>
<div>
<label className="block text-[8px] font-black text-slate-400 uppercase tracking-wider mb-1">Section</label>
<input
type="text"
value={formSection}
onChange={e => setFormSection(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-xs font-bold outline-none focus:ring-2 focus:ring-brand-indigo/20 text-slate-800"
required
/>
</div>
<div>
<label className="block text-[8px] font-black text-slate-400 uppercase tracking-wider mb-1">Roll / Reg No</label>
<input
type="text"
value={formRegNo}
onChange={e => setFormRegNo(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-xs font-bold outline-none focus:ring-2 focus:ring-brand-indigo/20 text-slate-800"
required
/>
</div>
</div>
<div>
<label className="block text-[8px] font-black text-slate-400 uppercase tracking-wider mb-1">Phone Number</label>
<input
type="text"
value={formPhone}
onChange={e => setFormPhone(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-xs font-bold outline-none focus:ring-2 focus:ring-brand-indigo/20 text-slate-800"
required
/>
</div>
</div>
</div>
<div className="flex gap-4 border-t border-slate-100 pt-4 mt-6 shrink-0">
<button onClick={() => setShowConfirm(false)} className="flex-1 py-3.5 bg-gray-100 text-gray-500 rounded-2xl font-black uppercase tracking-widest text-[10px] hover:bg-gray-200 transition-all">Cancel</button>
<button
onClick={() => {
onToggle({
userName: formName,
email: formEmail,
dept: formDept,
section: formSection,
regNo: formRegNo,
phone: formPhone
});
setShowConfirm(false);
}}
className={`flex-1 py-3.5 ${event.isTeamEvent ? 'bg-orange-500 shadow-orange-200' : 'bg-[#1A202C] shadow-gray-200'} text-white rounded-2xl font-black uppercase tracking-widest text-[10px] shadow-lg active:scale-95 transition-all`}
>
Confirm
</button>
</div>
</div>
@@ -439,8 +580,9 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
</div>
<div>
<span className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1">Year / Section</span>
<p className="text-sm font-black text-[#1A202C] truncate uppercase">
<p className="text-sm font-black text-[#1A202C] truncate uppercase flex items-center gap-1.5">
{registration?.year || userYear || 'N/A'} - {registration?.section || userSection || 'N/A'}
{studentBatchName && <span className="px-1.5 py-0.5 bg-[#f97316]/10 text-[#f97316] text-[8px] font-black rounded">{studentBatchName}</span>}
</p>
</div>
{event.isTeamEvent && registration?.team_name && (

View File

@@ -7,7 +7,7 @@ interface HeroProps {
const Hero: React.FC<HeroProps> = () => {
return (
<section className="relative w-full h-screen flex items-center justify-center overflow-hidden bg-[#F3F4F6] font-sans select-none">
<section className="relative w-full h-screen flex items-center justify-center overflow-hidden bg-slate-950 font-sans select-none">
{/* Optimized Video Background Container */}
<div className="absolute inset-0 z-0 overflow-hidden">
<video

View File

@@ -3,15 +3,23 @@ import type { Event, Announcement, SpecialEvent } from '../../types';
import { motion, AnimatePresence } from 'framer-motion';
import SpecialEventsBanner from './SpecialEventsBanner';
import AccreditationsSection from './AccreditationsSection';
import ScrollNotification from './ScrollNotification';
interface HomeDashboardProps {
events: Event[];
announcements: Announcement[];
onNavigateToEvents: () => void;
specialEvents: SpecialEvent[];
settings?: Record<string, any>;
}
const HomeDashboard: React.FC<HomeDashboardProps> = ({ events = [], announcements = [], onNavigateToEvents, specialEvents = [] }) => {
const HomeDashboard: React.FC<HomeDashboardProps> = ({
events = [],
announcements = [],
onNavigateToEvents,
specialEvents = [],
settings = {}
}) => {
const [now, setNow] = useState(new Date());
useEffect(() => {
@@ -72,8 +80,11 @@ const HomeDashboard: React.FC<HomeDashboardProps> = ({ events = [], announcement
</div>
</div>
{/* Scroll Notification - displayed only when enabled by admin */}
<ScrollNotification settings={settings} />
{/* Notice Board Section */}
<div className="w-full bg-[#e8e4c9] p-8 md:p-12 rounded-[2rem] shadow-2xl border-[12px] border-[#8d6e63] relative overflow-hidden min-h-[600px]">
<div className="w-full bg-[#e8e4c9] p-8 md:p-12 rounded-[2rem] shadow-2xl border-[12px] border-[#8d6e63] relative overflow-hidden min-h-[600px] mt-12">
{/* Cork texture pattern */}
<div className="absolute inset-0 opacity-30 bg-[url('https://www.transparenttextures.com/patterns/cork-board.png')] pointer-events-none"></div>
@@ -89,7 +100,7 @@ const HomeDashboard: React.FC<HomeDashboardProps> = ({ events = [], announcement
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-12 p-6">
<AnimatePresence mode="popLayout">
{filteredAnnouncements.length > 0 ? (
filteredAnnouncements.slice(0, 6).map((ann) => {
filteredAnnouncements.slice(0, 3).map((ann) => {
const style = getNoteStyle(ann.id);
return (
<motion.div
@@ -118,6 +129,14 @@ const HomeDashboard: React.FC<HomeDashboardProps> = ({ events = [], announcement
<h3 className="font-serif font-bold text-2xl text-gray-900 mb-4 leading-tight">
{ann.title}
</h3>
{(ann as any).image && (
<img
src={(ann as any).image}
alt={ann.title}
className="w-full h-40 object-cover rounded-xl mb-4 shrink-0 shadow-sm border border-black/10"
/>
)}
<p className="font-sans text-gray-800 text-base flex-grow mb-6 leading-relaxed opacity-90">
{ann.message}

View File

@@ -0,0 +1,128 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
interface ScrollNotificationProps {
settings?: Record<string, any>;
}
export const ScrollNotification: React.FC<ScrollNotificationProps> = ({ settings = {} }) => {
const [isOpen, setIsOpen] = useState(false);
const scrollData = settings.scroll_notification;
if (!scrollData || !scrollData.isActive) return null;
const title = scrollData.title || "The Grand Decrees of RIT";
const message = scrollData.message || "No decrees active at this time.";
return (
<section className="py-8 relative overflow-hidden bg-transparent">
<div className="max-w-2xl mx-auto px-6 relative z-10">
<motion.div
initial="closed"
animate={isOpen ? "open" : "closed"}
className="relative flex flex-col items-center"
>
{/* Top Roller - Interactive */}
<motion.div
onClick={() => setIsOpen(!isOpen)}
className="relative z-30 w-full h-16 rounded-full shadow-[0_8px_32px_rgba(62,39,35,0.25)] border-[3px] border-[#3e2723]/60 overflow-hidden cursor-pointer active:scale-95 transition-transform"
style={{
backgroundImage: 'url(/wood_roller_texture.png)',
backgroundSize: '100% 100%',
backgroundPosition: 'center'
}}
>
<div className="absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-[#1a0f0d] to-transparent opacity-80"></div>
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-[#1a0f0d] to-transparent opacity-80"></div>
<div className="absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/20"></div>
<div className="absolute inset-x-4 inset-y-0 flex items-center justify-between pointer-events-none px-4">
<div className="w-4 h-4 rounded-full border border-white/5 bg-white/5 blur-[1px]"></div>
<div className="w-4 h-4 rounded-full border border-white/5 bg-white/5 blur-[1px]"></div>
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
<span className="text-[10px] font-medieval text-white/60 uppercase tracking-[0.3em] bg-black/20 px-4 py-1 rounded-full backdrop-blur-sm">
{isOpen ? 'Tap to Close Decree' : 'Tap to Open Decree'}
</span>
</div>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-[11px] font-medieval text-white uppercase tracking-[0.25em] drop-shadow-md">
{title}
</span>
</div>
</motion.div>
{/* Parchment Body */}
<motion.div
variants={{
closed: {
height: 0,
opacity: 0,
transition: { duration: 0.5, ease: "easeInOut" }
},
open: {
height: 'auto',
opacity: 1,
transition: { duration: 0.8, ease: "easeOut" }
}
}}
className="relative z-20 w-[94%] overflow-hidden origin-top"
style={{
backgroundImage: 'url(/parchment_texture.png)',
backgroundSize: 'cover',
backgroundPosition: 'center'
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-black/5 via-transparent to-black/5 pointer-events-none shadow-inner"></div>
<div className="p-8 md:p-12 text-center space-y-6">
<motion.div
variants={{
closed: { opacity: 0, y: -20 },
open: { opacity: 1, y: 0 }
}}
>
<h2 className="font-medieval text-2xl md:text-3xl text-[#3e2723] mb-4 drop-shadow-sm">
{title}
</h2>
<div className="w-24 h-[1px] bg-[#8d6e63]/20 mx-auto mb-6"></div>
<p className="font-parchment text-sm md:text-lg text-[#5d4037] leading-relaxed max-w-lg mx-auto italic opacity-95 whitespace-pre-wrap">
{message}
</p>
</motion.div>
</div>
</motion.div>
{/* Bottom Roller - Interactive */}
<motion.div
onClick={() => setIsOpen(!isOpen)}
variants={{
closed: { y: -64 },
open: { y: 0 }
}}
transition={{ duration: 0.8, ease: "easeOut" }}
className="relative z-30 w-full h-16 rounded-full shadow-[0_-8px_32px_rgba(62,39,35,0.25)] border-[3px] border-[#3e2723]/60 overflow-hidden cursor-pointer active:scale-95 transition-transform"
style={{
backgroundImage: 'url(/wood_roller_texture.png)',
backgroundSize: '100% 100%',
backgroundPosition: 'center'
}}
>
<div className="absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-[#1a0f0d] to-transparent opacity-80"></div>
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-[#1a0f0d] to-transparent opacity-80"></div>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 via-transparent to-black/20"></div>
</motion.div>
</motion.div>
</div>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Almendra:ital,wght@0,400;0,700;1,400&family=Pirata+One&display=swap');
.font-medieval { font-family: 'Pirata One', cursive; }
.font-parchment { font-family: 'Almendra', serif; }
`}</style>
</section>
);
};
export default ScrollNotification;

View File

@@ -1,183 +1,255 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import React from 'react';
import { motion } from 'framer-motion';
import type { SpecialEvent } from '../../types';
import { Sparkles, ArrowUpRight, Zap } from 'lucide-react';
interface SpecialEventsBannerProps {
specialEvents: SpecialEvent[];
}
const SpecialEventsBanner: React.FC<SpecialEventsBannerProps> = ({ specialEvents }) => {
const [isOpen, setIsOpen] = useState(false);
const hasEvents = specialEvents && specialEvents.length > 0;
const handleRedirect = (link: string) => {
window.location.href = link;
if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
};
const defaultTitle = "The Grand Chronicles of RIT";
const defaultDescription = "Hark! The tides have brought forth prestigious gatherings and legendary challenges. Seek thy destiny in the links below.";
if (!hasEvents) return null;
const marqueeText = Array(8).fill("⚡ SPECIAL EVENT ALERT ⚡ REGISTRATION LIVE ⚡").join(" /// ") + " /// ";
// Animation variants for container
const containerVariants: any = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.2
}
}
};
// Animation variants for cards
const cardVariants: any = {
hidden: { opacity: 0, y: 40, scale: 0.95 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
type: 'spring',
stiffness: 100,
damping: 15
}
}
};
// Header animation variants
const headerVariants: any = {
hidden: { opacity: 0, y: -20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: 'easeOut' }
}
};
return (
<section className="py-12 relative overflow-hidden bg-transparent">
{/* Background glow (Softer and centered) */}
<div className="absolute inset-0 z-0 opacity-5 pointer-events-none">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] bg-amber-500/20 blur-[80px] rounded-full"></div>
</div>
<div className="max-w-2xl mx-auto px-6 relative z-10">
{/* New Header: Descriptive text above the scroll */}
<div className="text-center mb-12">
<h2 className="font-medieval text-3xl md:text-5xl text-[#3e2723] mb-4 opacity-90">
Special Events Registry
</h2>
<p className="font-parchment text-sm md:text-base text-[#5d4037]/70 italic tracking-wide">
Tap the ancient scroll to reveal the current decrees of RIT
</p>
<div className="w-16 h-[1px] bg-[#8d6e63]/20 mx-auto mt-6"></div>
<section className="relative py-32 mt-36 mb-20 font-sans -mx-6 md:-mx-12 lg:-mx-24 bg-white">
{/* Full-width Skewed Light Multi-Gradient Background (Blue, Red/Rose, Pink) */}
<div
className="absolute inset-0 bg-gradient-to-tr from-[#e0f2fe] via-[#ffe4e6] to-[#fce7f3] transform -skew-y-3 origin-top-left z-0 shadow-[0_20px_40px_rgba(79,70,229,0.06)] border-y border-black/10 overflow-hidden"
>
{/* Caution Tape Top */}
<div className="absolute top-0 left-0 right-0 h-9 bg-[#facc15] text-black overflow-hidden flex flex-col justify-between border-b-2 border-black z-20 select-none">
<div className="h-[4px] w-full hazard-stripes" />
<div className="flex-1 flex items-center overflow-hidden">
<div className="animate-marquee whitespace-nowrap flex items-center py-0.5">
<span className="text-[10px] font-black uppercase tracking-[0.15em]">{marqueeText}</span>
<span className="text-[10px] font-black uppercase tracking-[0.15em]">{marqueeText}</span>
</div>
</div>
<div className="h-[4px] w-full hazard-stripes" />
</div>
{/* Caution Tape Bottom */}
<div className="absolute bottom-0 left-0 right-0 h-9 bg-[#facc15] text-black overflow-hidden flex flex-col justify-between border-t-2 border-black z-20 select-none">
<div className="h-[4px] w-full hazard-stripes" />
<div className="flex-1 flex items-center overflow-hidden">
<div className="animate-marquee whitespace-nowrap flex items-center py-0.5">
<span className="text-[10px] font-black uppercase tracking-[0.15em]">{marqueeText}</span>
<span className="text-[10px] font-black uppercase tracking-[0.15em]">{marqueeText}</span>
</div>
</div>
<div className="h-[4px] w-full hazard-stripes" />
</div>
</div>
{/* Floating Pastel Glow Spheres matching the theme */}
<motion.div
animate={{
x: [0, 30, -15, 0],
y: [0, -20, 15, 0],
}}
transition={{
duration: 15,
repeat: Infinity,
ease: 'easeInOut'
}}
className="absolute top-1/4 left-10 w-[350px] h-[350px] bg-blue-300/25 blur-[120px] rounded-full pointer-events-none z-0"
/>
<motion.div
animate={{
x: [0, -40, 20, 0],
y: [0, 30, -20, 0],
}}
transition={{
duration: 18,
repeat: Infinity,
ease: 'easeInOut'
}}
className="absolute bottom-1/4 right-10 w-[400px] h-[400px] bg-rose-300/25 blur-[130px] rounded-full pointer-events-none z-0"
/>
<motion.div
animate={{
scale: [0.9, 1.1, 0.9],
opacity: [0.3, 0.6, 0.3]
}}
transition={{
duration: 8,
repeat: Infinity,
ease: 'easeInOut'
}}
className="absolute top-1/2 left-1/3 w-[200px] h-[200px] bg-pink-300/20 blur-[90px] rounded-full pointer-events-none z-0"
/>
{/* Un-skewed Content Container */}
<div className="relative z-10 max-w-7xl mx-auto px-6 md:px-12 lg:px-16">
{/* Animated Header */}
<motion.div
initial="closed"
animate={isOpen ? "open" : "closed"}
className="relative flex flex-col items-center"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={headerVariants}
className="text-center mb-20 relative"
>
{/* Top Roller - Interactive */}
<motion.div
onClick={() => setIsOpen(!isOpen)}
className="relative z-30 w-full h-16 rounded-full shadow-[0_8px_32px_rgba(62,39,35,0.25)] border-[3px] border-[#3e2723]/60 overflow-hidden cursor-pointer active:scale-95 transition-transform"
style={{
backgroundImage: 'url(/wood_roller_texture.png)',
backgroundSize: '100% 100%',
backgroundPosition: 'center'
}}
whileHover={{ scale: 1.05 }}
className="inline-flex items-center gap-2 px-4 py-1.5 bg-gradient-to-r from-blue-500/10 via-rose-500/10 to-pink-500/10 text-indigo-700 border border-indigo-500/20 text-[10px] font-black uppercase tracking-widest rounded-full mb-6 cursor-default shadow-lg shadow-indigo-500/5 hover:border-indigo-400/40 transition-colors"
>
{/* End Caps: Fixes the 'broken corner' look */}
<div className="absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-[#1a0f0d] to-transparent opacity-80"></div>
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-[#1a0f0d] to-transparent opacity-80"></div>
<div className="absolute inset-0 bg-gradient-to-b from-black/10 via-transparent to-black/20"></div>
<div className="absolute inset-x-4 inset-y-0 flex items-center justify-between pointer-events-none px-4">
<div className="w-4 h-4 rounded-full border border-white/5 bg-white/5 blur-[1px]"></div>
<div className="w-4 h-4 rounded-full border border-white/5 bg-white/5 blur-[1px]"></div>
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
<span className="text-[10px] font-medieval text-white/60 uppercase tracking-[0.3em] bg-black/20 px-4 py-1 rounded-full backdrop-blur-sm">
{isOpen ? 'Tap to Close' : 'Tap to Open'}
</span>
</div>
<Sparkles className="w-3.5 h-3.5 text-rose-500 animate-spin" style={{ animationDuration: '4s' }} />
Registry Live
</motion.div>
<h2 className="text-4xl md:text-6xl font-extrabold text-slate-900 tracking-tight leading-none mb-5 font-fantasy-title">
Special Events Registry
</h2>
<div className="w-24 h-[3px] bg-gradient-to-r from-blue-500 via-rose-500 to-pink-500 mx-auto mb-6 rounded-full shadow-[0_0_12px_rgba(79,70,229,0.2)]" />
<p className="text-slate-600 text-sm md:text-base max-w-xl mx-auto font-medium leading-relaxed">
Explore premium workshops, hackathons, and institutional programs selected for Rajalakshmi Institute of Technology.
</p>
</motion.div>
{/* Parchment Body */}
<motion.div
variants={{
closed: {
height: 0,
opacity: 0,
transition: { duration: 0.5, ease: "easeInOut" }
},
open: {
height: 'auto',
opacity: 1,
transition: { duration: 0.8, ease: "easeOut" }
}
}}
className="relative z-20 w-[94%] overflow-hidden origin-top"
style={{
backgroundImage: 'url(/parchment_texture.png)',
backgroundSize: 'cover',
backgroundPosition: 'center'
}}
>
{/* Inner Shadow Shadow */}
<div className="absolute inset-0 bg-gradient-to-r from-black/5 via-transparent to-black/5 pointer-events-none shadow-inner"></div>
<div className="p-8 md:p-12 text-center space-y-8">
<motion.div
variants={{
closed: { opacity: 0, y: -20 },
open: { opacity: 1, y: 0 }
}}
>
<h2 className="font-medieval text-3xl md:text-5xl text-[#3e2723] mb-4 drop-shadow-sm">
{hasEvents ? defaultTitle : "The Void Chronicles"}
</h2>
<div className="w-24 h-[1px] bg-[#8d6e63]/20 mx-auto mb-6"></div>
<p className="font-parchment text-base md:text-xl text-[#5d4037] leading-relaxed max-w-lg mx-auto italic opacity-90">
{hasEvents ? defaultDescription : "The magic portal remains dormant. Check back when the moons align."}
{/* Animated Cards Grid */}
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-10"
>
{specialEvents.map((event) => (
<motion.div
key={event.id}
variants={cardVariants}
whileHover={{
y: -10,
scale: 1.02,
borderColor: 'rgba(79, 70, 229, 0.3)',
boxShadow: "0 25px 50px -12px rgba(79, 70, 229, 0.12), 0 0 15px rgba(79, 70, 229, 0.04)"
}}
whileTap={{ scale: 0.98 }}
onClick={() => handleRedirect(event.link)}
className="bg-white/85 backdrop-blur-md border border-slate-200 rounded-[2.2rem] p-8 transition-all duration-300 cursor-pointer group flex flex-col justify-between min-h-[290px] relative overflow-hidden shadow-xl shadow-slate-900/5"
>
{/* Animated inner glowing border gradient on hover */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 via-rose-500/5 to-pink-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<div className="absolute -top-12 -right-12 w-32 h-32 bg-blue-400/5 blur-2xl rounded-full group-hover:bg-blue-400/10 transition-all duration-500" />
<div>
<div className="flex items-center justify-between mb-6">
{/* Glowing Icon Container */}
<div className="w-12 h-12 rounded-2xl bg-indigo-50/80 border border-indigo-100/60 flex items-center justify-center text-indigo-600 group-hover:bg-gradient-to-tr group-hover:from-blue-500 group-hover:to-pink-500 group-hover:text-white group-hover:border-transparent transition-all duration-300 group-hover:shadow-[0_0_15px_rgba(79,70,229,0.25)]">
<Zap className="w-5 h-5 group-hover:animate-bounce" />
</div>
{/* Status chip */}
<span className="text-[9px] font-black text-indigo-600 group-hover:text-indigo-800 uppercase tracking-widest border border-indigo-50 px-2.5 py-1 rounded-full bg-indigo-50/40 transition-colors">
Featured
</span>
</div>
<h3 className="text-2xl font-bold text-slate-900 mb-3 group-hover:text-indigo-950 transition-all duration-300 tracking-tight font-fantasy-card">
{event.title}
</h3>
<p className="text-slate-700 leading-relaxed font-medium line-clamp-4 group-hover:text-slate-900 transition-colors font-fantasy-body">
{event.description}
</p>
</motion.div>
<div className="grid grid-cols-1 gap-6 pt-4">
{specialEvents.map((event, index) => (
<motion.div
key={event.id}
variants={{
closed: { opacity: 0, scale: 0.95 },
open: { opacity: 1, scale: 1 }
}}
transition={{ delay: isOpen ? 0.3 + (index * 0.1) : 0 }}
whileHover={{ scale: 1.02 }}
className="group cursor-pointer relative"
onClick={() => handleRedirect(event.link)}
>
<div className="border border-[#8d6e63]/15 bg-[#8d6e63]/5 p-5 rounded-lg backdrop-blur-[1px] transition-all duration-300 group-hover:bg-[#8d6e63]/10 group-hover:border-[#8d6e63]/30">
<h3 className="font-medieval text-xl md:text-2xl text-[#3e2723] mb-2 group-hover:text-[#795548] transition-colors">
{event.title}
</h3>
<p className="font-parchment text-xs md:text-sm text-[#5d4037] line-clamp-2 opacity-80">
{event.description}
</p>
<div className="mt-3 flex justify-end">
<span className="text-[9px] font-medieval uppercase tracking-[0.2em] text-[#8d6e63] group-hover:text-[#3e2723] flex items-center gap-2">
Behold <i className="fas fa-feather-pointed"></i>
</span>
</div>
</div>
</motion.div>
))}
</div>
{hasEvents && (
<div className="font-medieval text-[10px] text-[#8d6e63]/40 uppercase tracking-[0.4em] pt-8">
End of Scroll
</div>
)}
</div>
</motion.div>
{/* Bottom Roller - Interactive */}
<motion.div
onClick={() => setIsOpen(!isOpen)}
variants={{
closed: { y: -64 }, // Perfect overlap (h-16 = 64px)
open: { y: 0 }
}}
transition={{ duration: 0.8, ease: "easeOut" }}
className="relative z-30 w-full h-16 rounded-full shadow-[0_-8px_32px_rgba(62,39,35,0.25)] border-[3px] border-[#3e2723]/60 overflow-hidden cursor-pointer active:scale-95 transition-transform"
style={{
backgroundImage: 'url(/wood_roller_texture.png)',
backgroundSize: '100% 100%',
backgroundPosition: 'center'
}}
>
{/* End Caps: Fixes the 'broken corner' look */}
<div className="absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-[#1a0f0d] to-transparent opacity-80"></div>
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-[#1a0f0d] to-transparent opacity-80"></div>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 via-transparent to-black/20"></div>
</motion.div>
{/* Bottom bar with action */}
<div className="mt-8 pt-5 border-t border-slate-100 flex items-center justify-between group-hover:border-indigo-200 transition-colors">
<span className="text-[10px] font-extrabold text-indigo-600 uppercase tracking-widest flex items-center gap-2 group-hover:text-indigo-800 transition-colors font-fantasy-card">
Launch Registry
<ArrowUpRight className="w-4 h-4 transform group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform duration-300 text-indigo-600 group-hover:text-pink-500" />
</span>
{/* Visual accent dot */}
<div className="w-1.5 h-1.5 rounded-full bg-indigo-600 group-hover:bg-pink-500 group-hover:animate-ping transition-colors" />
</div>
</motion.div>
))}
</motion.div>
</div>
<style>{`
.font-medieval { font-family: 'Pirata One', cursive; }
.font-parchment { font-family: 'Almendra', serif; }
@import url('https://fonts.googleapis.com/css2?family=MedievalSharp&family=Cinzel:wght@700;900&family=Eagle+Lake&display=swap');
.font-fantasy-title {
font-family: 'Cinzel', serif;
font-weight: 900;
}
.font-fantasy-card {
font-family: 'MedievalSharp', cursive;
}
.font-fantasy-body {
font-family: 'Eagle Lake', cursive;
font-size: 13px;
}
@keyframes marquee-scroll {
0% { transform: translateX(0%); }
100% { transform: translateX(-50%); }
}
.animate-marquee {
display: flex;
width: max-content;
animation: marquee-scroll 25s linear infinite;
}
.hazard-stripes {
background: repeating-linear-gradient(
-45deg,
#000,
#000 6px,
#facc15 6px,
#facc15 12px
);
}
`}</style>
</section>
);
};
export default SpecialEventsBanner;

View File

@@ -221,7 +221,7 @@ const StatusTrackerView: React.FC<StatusTrackerViewProps> = ({ event, registrati
};
return (
<div className="pt-40 pb-16 px-6 md:px-12 lg:px-24 bg-[#F3F4F6] min-h-screen animate-in fade-in duration-500 font-inter">
<div className="pt-40 pb-16 px-6 md:px-12 lg:px-24 bg-white min-h-screen animate-in fade-in duration-500 font-inter">
{showSuccess && <SuccessPopup onClose={() => { setShowSuccess(false); window.location.reload(); }} />}
<div className="max-w-7xl mx-auto">

View File

@@ -0,0 +1,127 @@
import React, { createContext, useContext, useState } from 'react';
import { createPortal } from 'react-dom';
import { CheckSquare, XCircle, CheckCircle, Eye } from 'lucide-react';
interface DialogOptions {
type?: 'info' | 'confirm' | 'error' | 'success';
title: string;
message: string;
onConfirm?: () => void;
onCancel?: () => void;
}
interface DialogContextType {
showAlert: (title: string, message: string, type?: 'info' | 'error' | 'success') => void;
showConfirm: (title: string, message: string, onConfirm: () => void, onCancel?: () => void) => void;
}
const DialogContext = createContext<DialogContextType | undefined>(undefined);
export const useDialog = () => {
const context = useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a DialogProvider');
}
return context;
};
export const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<DialogOptions | null>(null);
const showAlert = (title: string, message: string, type: 'info' | 'error' | 'success' = 'info') => {
setOptions({
title,
message,
type,
});
setIsOpen(true);
};
const showConfirm = (title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
setOptions({
title,
message,
type: 'confirm',
onConfirm,
onCancel,
});
setIsOpen(true);
};
const handleClose = () => {
if (options?.onCancel) {
options.onCancel();
}
setIsOpen(false);
};
const handleConfirm = () => {
if (options?.onConfirm) {
options.onConfirm();
}
setIsOpen(false);
};
return (
<DialogContext.Provider value={{ showAlert, showConfirm }}>
{children}
{isOpen && options && createPortal(
<div className="fixed inset-0 z-[100000] flex items-center justify-center p-6 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white rounded-[2.5rem] w-full max-w-sm p-8 text-center shadow-2xl animate-in zoom-in-95 border border-slate-100 flex flex-col justify-between">
<div>
<div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl mx-auto mb-6 ${
options.type === 'confirm' ? "bg-amber-50 text-amber-500" :
options.type === 'error' ? "bg-rose-50 text-rose-500" :
options.type === 'success' ? "bg-emerald-50 text-emerald-500" :
"bg-blue-50 text-blue-500"
}`}>
{options.type === 'confirm' && <CheckSquare className="w-8 h-8" />}
{options.type === 'error' && <XCircle className="w-8 h-8" />}
{options.type === 'success' && <CheckCircle className="w-8 h-8" />}
{(options.type === 'info' || !options.type) && <Eye className="w-8 h-8" />}
</div>
<h3 className="text-xl font-black text-slate-800 uppercase mb-2 tracking-tight">
{options.title}
</h3>
<p className="text-slate-500 text-xs font-semibold leading-relaxed mb-8">
{options.message}
</p>
</div>
<div className="flex gap-4">
{options.type === 'confirm' ? (
<>
<button
onClick={handleClose}
className="flex-1 py-3 bg-slate-100 hover:bg-slate-200 text-slate-500 rounded-xl font-black uppercase text-[10px] tracking-widest transition-all"
>
No, Cancel
</button>
<button
onClick={handleConfirm}
className="flex-1 py-3 bg-[#1A202C] hover:bg-black text-white rounded-xl font-black uppercase text-[10px] tracking-widest transition-all shadow-md"
>
Yes, Proceed
</button>
</>
) : (
<button
onClick={handleClose}
className={`w-full py-3 text-white rounded-xl font-black uppercase text-[10px] tracking-widest transition-all shadow-md ${
options.type === 'error' ? "bg-rose-500 hover:bg-rose-600 shadow-rose-100" :
options.type === 'success' ? "bg-emerald-500 hover:bg-emerald-600 shadow-emerald-100" :
"bg-[#1A202C] hover:bg-black shadow-slate-100"
}`}
>
Okay
</button>
)}
</div>
</div>
</div>,
document.body
)}
</DialogContext.Provider>
);
};

View File

@@ -199,20 +199,32 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
}
}
// Auto-register student if matches roll number format
const studentMatch = emailLower.match(/^student\.(\d{6})@([a-zA-Z0-9&-_]+)\.ritchennai\.edu\.in$/);
if (studentMatch) {
const rollNo = studentMatch[1];
const rawDept = studentMatch[2].toLowerCase();
// Auto-register student if email contains @ritchennai.edu.in
const isRitEmail = emailLower.includes('@ritchennai.edu.in') || emailLower.includes('.ritchennai.edu.in');
if (isRitEmail) {
const studentMatch = emailLower.match(/^student\.(\d{6})@([a-zA-Z0-9&-_]+)\.ritchennai\.edu\.in$/);
let rollNo = studentMatch ? studentMatch[1] : '';
let rawDept = studentMatch ? studentMatch[2] : 'CSE';
// Extract department from the email subdomain if possible
const deptMatch = emailLower.match(/@([a-zA-Z0-9&-_]+)\.ritchennai\.edu\.in$/);
if (!studentMatch && deptMatch) {
rawDept = deptMatch[1];
}
if (!rollNo) {
const digitMatch = emailLower.match(/\d+/);
rollNo = digitMatch ? digitMatch[0] : String(Math.floor(100000 + Math.random() * 900000));
}
// Calculate joining year and academic year (current local time is 2026-06-19)
const joinYear = 2000 + parseInt(rollNo.substring(0, 2));
const joinYear = 2000 + parseInt(rollNo.substring(0, 2) || "23");
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth();
const academicYearOffset = currentMonth >= 5 ? 1 : 0;
const yearIndex = currentYear - joinYear + academicYearOffset;
const years = ["1st Year", "2nd Year", "3rd Year", "4th Year"];
const calculatedYear = years[yearIndex - 1] || "N/A";
const calculatedYear = years[yearIndex - 1] || "3rd Year";
// Map department
let dept = rawDept.toUpperCase();
@@ -271,19 +283,31 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
}
// Check if matches student RIT format
const studentMatch = emailLower.match(/^student\.(\d{6})@([a-zA-Z0-9&-_]+)\.ritchennai\.edu\.in$/);
if (studentMatch) {
const rollNo = studentMatch[1];
const rawDept = studentMatch[2].toLowerCase();
const isRitEmail = emailLower.includes('@ritchennai.edu.in') || emailLower.includes('.ritchennai.edu.in');
if (isRitEmail) {
const studentMatch = emailLower.match(/^student\.(\d{6})@([a-zA-Z0-9&-_]+)\.ritchennai\.edu\.in$/);
let rollNo = studentMatch ? studentMatch[1] : '';
let rawDept = studentMatch ? studentMatch[2] : 'CSE';
// Extract department from the email subdomain if possible
const deptMatch = emailLower.match(/@([a-zA-Z0-9&-_]+)\.ritchennai\.edu\.in$/);
if (!studentMatch && deptMatch) {
rawDept = deptMatch[1];
}
if (!rollNo) {
const digitMatch = emailLower.match(/\d+/);
rollNo = digitMatch ? digitMatch[0] : String(Math.floor(100000 + Math.random() * 900000));
}
// Calculate joining year and academic year (current local time is 2026-06-19)
const joinYear = 2000 + parseInt(rollNo.substring(0, 2));
const joinYear = 2000 + parseInt(rollNo.substring(0, 2) || "23");
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth();
const academicYearOffset = currentMonth >= 5 ? 1 : 0;
const yearIndex = currentYear - joinYear + academicYearOffset;
const years = ["1st Year", "2nd Year", "3rd Year", "4th Year"];
const calculatedYear = years[yearIndex - 1] || "N/A";
const calculatedYear = years[yearIndex - 1] || "3rd Year";
// Map department
let dept = rawDept.toUpperCase();
@@ -477,6 +501,33 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
return jsonResponse({ message: `Academic year promotion completed for ${inst}` });
}
// ----------------------------------------------------
// BATCHES CONTROLLER
// ----------------------------------------------------
if (path === '/api/batches' && method === 'GET') {
const snap = await getDocs(collection(db, 'ems_batches'));
return jsonResponse(snap.docs.map(d => d.data()));
}
if (path === '/api/batches' && method === 'POST') {
const payload = parseBody(init?.body);
const id = payload.id || generateNumericId();
const newBatch = {
...payload,
id,
classes: payload.classes || []
};
await setDoc(doc(db, 'ems_batches', String(id)), newBatch);
return jsonResponse(newBatch);
}
if (path.startsWith('/api/batches/') && method === 'DELETE') {
const parts = path.split('/');
const id = parts[parts.length - 1];
await deleteDoc(doc(db, 'ems_batches', String(id)));
return jsonResponse({ message: "Batch deleted successfully" });
}
// ----------------------------------------------------
// NOTES CONTROLLER
// ----------------------------------------------------
@@ -642,9 +693,11 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
budget: budgetStr === "" ? 0.0 : parseFloat(budgetStr),
hasRegistrationFee: !!payload.hasRegistrationFee,
registrationFee: feeStr === "" ? 0.0 : parseFloat(feeStr),
paymentLink: payload.paymentLink || null,
centreName: payload.centreName || null,
isPublicEvent: !!payload.isPublicEvent,
image: payload.image || null,
targetedBatch: payload.targetedBatch || null,
proposer: {
id: proposer.id,
fullName: proposer.fullName,
@@ -894,6 +947,7 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
if (payload.centreName !== undefined) updatedFields.centreName = payload.centreName;
if (payload.isPublicEvent !== undefined) updatedFields.isPublicEvent = !!payload.isPublicEvent;
if (payload.image !== undefined) updatedFields.image = payload.image;
if (payload.targetedBatch !== undefined) updatedFields.targetedBatch = payload.targetedBatch;
if (payload.status !== undefined && isAdmin) {
updatedFields.status = payload.status;
}
@@ -922,6 +976,7 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
}
const id = generateNumericId();
const isRitEmail = email.includes('@ritchennai.edu.in') || email.includes('.ritchennai.edu.in');
const newUser = {
...payload,
id,
@@ -933,7 +988,7 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
regNo: payload.regNo || '',
phone: payload.phone || '',
gender: payload.gender || 'Male',
collegeName: payload.collegeName || 'Rajalakshmi Institute of Technology'
collegeName: isRitEmail ? 'Rajalakshmi Institute of Technology' : (payload.collegeName || 'External Institution')
};
await setDoc(doc(db, 'ems_users', String(id)), newUser);
@@ -984,14 +1039,12 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
return jsonResponse({ message: "You are already registered for this event." }, 400);
}
// Fetch event details
const eventSnap = await getDoc(doc(db, 'ems_events', String(eventId)));
if (!eventSnap.exists()) {
return jsonResponse({ message: "Event not found" }, 404);
}
const event = eventSnap.data();
// Check deadline
if (event.endDate) {
const deadline = new Date(event.registrationFee ? (event.registrationDeadline || event.endDate) : event.endDate);
if (new Date() > deadline) {
@@ -999,17 +1052,14 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
}
}
// Count current registrations for this event
const allRegsSnap = await getDocs(collection(db, 'ems_registrations'));
const eventRegs = allRegsSnap.docs.map(d => d.data()).filter(r => String(r.eventId || r.event_id) === String(eventId));
const currentParticipants = eventRegs.length;
// Validate overall capacity
if (event.maxParticipants && currentParticipants >= event.maxParticipants) {
return jsonResponse({ message: "Registration Blocked: The event has reached its capacity." }, 409);
}
// Quota validation
const studentDept = payload.dept || payload.department || 'CSE';
const studentSec = payload.section || 'A';
@@ -1017,7 +1067,6 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
const deptLimit = Number(event.deptLimits[studentDept]);
const deptRegs = eventRegs.filter(r => (r.dept || r.department) === studentDept);
// Section limit check if present
if (event.deptSectionLimits && event.deptSectionLimits[studentDept]) {
const sectionLimits = event.deptSectionLimits[studentDept];
const sectionLimit = Number(sectionLimits[studentSec] || 0);
@@ -1031,17 +1080,16 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
return jsonResponse({ message: `Registration Blocked: Quota for Section ${studentSec} of ${studentDept} is full.` }, 409);
}
} else {
// Standard department limit check
if (deptRegs.length >= deptLimit) {
return jsonResponse({ message: `Registration Blocked: Quota for department ${studentDept} is full.` }, 409);
}
}
}
// Safe to create registration
const { image, ...registrationData } = payload;
const isFree = event.hasRegistrationFee === false || event.registrationFee === 0 || event.pricingType === 'FREE';
const newReg = {
...payload,
...registrationData,
id: regId,
userId,
user_id: userId,
@@ -1056,8 +1104,6 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
};
await setDoc(regDocRef, newReg);
// Update event's currentParticipants
const eventDocRef = doc(db, 'ems_events', String(eventId));
await updateDoc(eventDocRef, { currentParticipants: currentParticipants + 1 });
@@ -1184,6 +1230,58 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
return jsonResponse({ message: "Attendance updated successfully" });
}
// ----------------------------------------------------
// SETTINGS CONTROLLER
// ----------------------------------------------------
if (path === '/api/settings' && method === 'GET') {
const snap = await getDocs(collection(db, 'ems_settings'));
const settings: Record<string, any> = {};
snap.docs.forEach(d => {
settings[d.id] = d.data();
});
return jsonResponse(settings);
}
if (path === '/api/settings' && method === 'POST') {
const payload = parseBody(init?.body);
const { key, value } = payload;
if (key) {
await setDoc(doc(db, 'ems_settings', key), value);
}
return jsonResponse({ message: "Settings saved successfully" });
}
// ----------------------------------------------------
// SPECIAL EVENTS CONTROLLER
// ----------------------------------------------------
if (path === '/api/special-events' && method === 'GET') {
const snap = await getDocs(collection(db, 'ems_special_events'));
const list = snap.docs.map(d => d.data());
list.sort((a, b) => new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime());
return jsonResponse(list);
}
if (path === '/api/special-events' && method === 'POST') {
const payload = parseBody(init?.body);
const id = payload.id || String(generateNumericId());
const newSE = {
...payload,
id,
created_at: payload.created_at || new Date().toISOString(),
is_active: payload.is_active !== undefined ? !!payload.is_active : true,
verificationStatus: payload.verificationStatus || 'APPROVED'
};
await setDoc(doc(db, 'ems_special_events', id), newSE);
return jsonResponse(newSE);
}
if (path.startsWith('/api/special-events/') && method === 'DELETE') {
const parts = path.split('/');
const id = parts[parts.length - 1];
await deleteDoc(doc(db, 'ems_special_events', String(id)));
return jsonResponse({ message: "Special event deleted successfully" });
}
// ----------------------------------------------------
// ANNOUNCEMENTS CONTROLLER
// ----------------------------------------------------

View File

@@ -55,6 +55,7 @@ export interface Event {
status?: 'Registered' | 'Event Ongoing' | 'Completed' | 'Scheduled' | 'APPROVED' | 'COMPLETED' | 'ONGOING' | 'REJECTED';
hasRegistrationFee?: boolean;
registrationFee?: number;
paymentLink?: string;
registrationDeadline?: string;
maxParticipants?: number;
durationDays?: number;
@@ -78,6 +79,7 @@ export interface Event {
conducting_dept?: string;
request_by_faculty?: string;
request_by_HOD?: string;
targetedBatch?: string;
}
export interface Announcement {
@@ -150,4 +152,12 @@ export interface SpecialEvent {
created_by: string;
is_active: boolean;
verificationStatus?: 'PENDING' | 'PENDING_HOD' | 'PENDING_ADMIN' | 'APPROVED' | 'REJECTED';
image?: string;
}
export interface DeptBatch {
id: number;
name: string;
department: string;
classes: string[]; // List of Year - Section strings, e.g. ["1st Year - A", "2nd Year - B"]
}