updated UI and logics
This commit is contained in:
@@ -9,6 +9,7 @@ import { UserManagement } from './components/dashboard/UserManagement';
|
|||||||
import { ClassManagement } from './components/dashboard/ClassManagement';
|
import { ClassManagement } from './components/dashboard/ClassManagement';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import { DialogProvider } from './context/DialogContext';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { StatusTimeline, type Event } from './components/dashboard/EventStatusTimeline';
|
import { StatusTimeline, type Event } from './components/dashboard/EventStatusTimeline';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -22,6 +23,8 @@ import { Overview } from './components/dashboard/Overview';
|
|||||||
import { ExcelImport } from './components/dashboard/ExcelImport';
|
import { ExcelImport } from './components/dashboard/ExcelImport';
|
||||||
import { StudentDashboard } from './components/dashboard/StudentDashboard';
|
import { StudentDashboard } from './components/dashboard/StudentDashboard';
|
||||||
import { StudentRegistrationsView } from './components/dashboard/StudentRegistrationsView';
|
import { StudentRegistrationsView } from './components/dashboard/StudentRegistrationsView';
|
||||||
|
import { ManageNoticesView } from './components/dashboard/ManageNoticesView';
|
||||||
|
|
||||||
|
|
||||||
const AppContent: React.FC = () => {
|
const AppContent: React.FC = () => {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
@@ -158,6 +161,8 @@ const AppContent: React.FC = () => {
|
|||||||
return <ExcelImport />;
|
return <ExcelImport />;
|
||||||
case 'student-registrations':
|
case 'student-registrations':
|
||||||
return (user?.role === 'FACULTY' || user?.role === 'HOD' || user?.role === 'ADMIN') ? <StudentRegistrationsView /> : isDashboard;
|
return (user?.role === 'FACULTY' || user?.role === 'HOD' || user?.role === 'ADMIN') ? <StudentRegistrationsView /> : isDashboard;
|
||||||
|
case 'manage-notices':
|
||||||
|
return user?.role === 'ADMIN' ? <ManageNoticesView /> : isDashboard;
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
return isDashboard;
|
return isDashboard;
|
||||||
default:
|
default:
|
||||||
@@ -187,9 +192,11 @@ const AppContent: React.FC = () => {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<DialogProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</DialogProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
CheckCircle
|
CheckCircle,
|
||||||
|
Bell
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { motion } from 'framer-motion';
|
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' || 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: BookOpen, label: 'Classes', id: 'classes' }] : []),
|
||||||
...(user?.role === 'ADMIN' ? [{ icon: Users, label: 'Manage Users', id: 'user-management' }] : []),
|
...(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' }] : [])
|
...((user?.role === 'FACULTY' || user?.role === 'HOD' || user?.role === 'ADMIN') ? [{ icon: Users, label: 'Student Hub', id: 'student-registrations' }] : [])
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { Pagination } from './Pagination';
|
import { Pagination } from './Pagination';
|
||||||
|
import { useDialog } from '../../context/DialogContext';
|
||||||
|
|
||||||
interface ClassMapping {
|
interface ClassMapping {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -34,6 +35,7 @@ import rsbLogo from '../../assets/images/rsb_logo.png';
|
|||||||
import ritLogo from '../../assets/images/college-logo.png';
|
import ritLogo from '../../assets/images/college-logo.png';
|
||||||
|
|
||||||
export const ClassManagement: React.FC = () => {
|
export const ClassManagement: React.FC = () => {
|
||||||
|
const { showAlert, showConfirm } = useDialog();
|
||||||
const [classes, setClasses] = useState<ClassMapping[]>([]);
|
const [classes, setClasses] = useState<ClassMapping[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -48,6 +50,16 @@ export const ClassManagement: React.FC = () => {
|
|||||||
sections: [] as string[]
|
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;
|
const currentDepartments = formData.institution === 'RIT' ? ritDepartments : rsbDepartments;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,8 +70,21 @@ export const ClassManagement: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClasses();
|
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 () => {
|
const fetchClasses = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_BASE_URL + '/api/classes');
|
const response = await fetch(API_BASE_URL + '/api/classes');
|
||||||
@@ -94,7 +119,7 @@ export const ClassManagement: React.FC = () => {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (formData.sections.length === 0) {
|
if (formData.sections.length === 0) {
|
||||||
alert('Please add at least one section');
|
showAlert('Required', 'Please add at least one section', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,51 +133,121 @@ export const ClassManagement: React.FC = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
fetchClasses();
|
fetchClasses();
|
||||||
setFormData({ ...formData, sections: [] });
|
setFormData({ ...formData, sections: [] });
|
||||||
|
showAlert('Success', 'Class mapping saved successfully.', 'success');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save class mapping:', error);
|
console.error('Failed to save class mapping:', error);
|
||||||
|
showAlert('Error', 'Failed to save class mapping.', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (window.confirm('Are you sure you want to delete this mapping?')) {
|
showConfirm(
|
||||||
|
'Delete Mapping',
|
||||||
|
'Are you sure you want to delete this mapping?',
|
||||||
|
async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/classes/${id}`, {
|
const response = await fetch(`${API_BASE_URL}/api/classes/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setClasses(prev => prev.filter(c => c.id !== id));
|
setClasses(prev => prev.filter(c => c.id !== id));
|
||||||
|
showAlert('Success', 'Mapping deleted successfully.', 'success');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed:', error);
|
console.error('Delete failed:', error);
|
||||||
|
showAlert('Error', 'Failed to delete mapping.', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePromote = async () => {
|
const handlePromote = async () => {
|
||||||
const confirmed = window.confirm(
|
showConfirm(
|
||||||
`⚠️ WARNING: ${formData.institution} ACADEMIC YEAR PROMOTION\n\n` +
|
'Academic Promotion',
|
||||||
`This action will shift all academic structures for ${formData.institution} forward:\n` +
|
`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?`,
|
||||||
'• 1st Year → 2nd Year\n' +
|
async () => {
|
||||||
'• 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 {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/classes/promote?institution=${formData.institution}`, {
|
const response = await fetch(`${API_BASE_URL}/api/classes/promote?institution=${formData.institution}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
fetchClasses();
|
fetchClasses();
|
||||||
alert(`${formData.institution} academic year promotion completed successfully!`);
|
showAlert('Success', `${formData.institution} academic year promotion completed successfully!`, 'success');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Promotion failed:', error);
|
console.error('Promotion failed:', error);
|
||||||
|
showAlert('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');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredClasses = classes
|
const filteredClasses = classes
|
||||||
@@ -169,6 +264,14 @@ export const ClassManagement: React.FC = () => {
|
|||||||
currentPage * itemsPerPage
|
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 (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -430,6 +533,111 @@ export const ClassManagement: React.FC = () => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -131,11 +131,17 @@ export const EventDetailsModal: React.FC<EventDetailsModalProps> = ({ isOpen, on
|
|||||||
Target Audience
|
Target Audience
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{event.academicYears.map((year: string) => (
|
{(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">
|
<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}
|
{year}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -100,6 +100,7 @@ interface EventProposalFormProps {
|
|||||||
budget?: string | number;
|
budget?: string | number;
|
||||||
hasRegistrationFee?: boolean;
|
hasRegistrationFee?: boolean;
|
||||||
registrationFee?: string | number;
|
registrationFee?: string | number;
|
||||||
|
paymentLink?: string;
|
||||||
targetDepartments?: string[];
|
targetDepartments?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
sponsors?: string[];
|
sponsors?: string[];
|
||||||
@@ -194,6 +195,7 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
|
|||||||
customEventType: '',
|
customEventType: '',
|
||||||
hasRegistrationFee: initialData?.hasRegistrationFee || false,
|
hasRegistrationFee: initialData?.hasRegistrationFee || false,
|
||||||
registrationFee: initialData?.registrationFee || '' as string | number,
|
registrationFee: initialData?.registrationFee || '' as string | number,
|
||||||
|
paymentLink: initialData?.paymentLink || '',
|
||||||
targetDepartments: initialData?.targetDepartments || (isFaculty ? (isPlacementCell ? [] : [user?.department || 'CSE']) : [] as string[]),
|
targetDepartments: initialData?.targetDepartments || (isFaculty ? (isPlacementCell ? [] : [user?.department || 'CSE']) : [] as string[]),
|
||||||
allDepts: false,
|
allDepts: false,
|
||||||
allBatches: 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] || '') : ''),
|
centreName: initialData?.centreName || (eventScope === 'CENTRE' ? ((CENTRE_MAPPING[user?.department || ''] || CENTRE_MAPPING['Placement Department'] || [])[0] || '') : ''),
|
||||||
isPublicEvent: initialData?.isPublicEvent || false,
|
isPublicEvent: initialData?.isPublicEvent || false,
|
||||||
image: initialData?.image || '',
|
image: initialData?.image || '',
|
||||||
status: initialData?.status || 'REQUESTED'
|
status: initialData?.status || 'REQUESTED',
|
||||||
|
targetedBatch: (initialData as any)?.targetedBatch || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const [newSponsor, setNewSponsor] = useState('');
|
const [newSponsor, setNewSponsor] = useState('');
|
||||||
@@ -230,12 +233,32 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
|
|||||||
}));
|
}));
|
||||||
}, [eventScope, user?.department]);
|
}, [eventScope, user?.department]);
|
||||||
|
|
||||||
|
const [batches, setBatches] = useState<any[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchClasses();
|
fetchClasses();
|
||||||
fetchExistingEvents();
|
fetchExistingEvents();
|
||||||
fetchClassStrengths();
|
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 () => {
|
const fetchClassStrengths = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_BASE_URL + '/api/admin/users');
|
const response = await fetch(API_BASE_URL + '/api/admin/users');
|
||||||
@@ -853,8 +876,9 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
|
|||||||
exit={{ opacity: 0, height: 0 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
className="md:col-span-2 overflow-hidden"
|
className="md:col-span-2 overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="p-6 bg-brand-glow border border-brand-indigo/10 rounded-[2rem] space-y-4">
|
<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">
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-brand-indigo">Fee per Delegate (₹)</label>
|
<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">
|
<div className="relative">
|
||||||
<span className="absolute left-6 top-1/2 -translate-y-1/2 text-brand-indigo font-bold">₹</span>
|
<span className="absolute left-6 top-1/2 -translate-y-1/2 text-brand-indigo font-bold">₹</span>
|
||||||
<input
|
<input
|
||||||
@@ -866,6 +890,19 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -1102,14 +1139,59 @@ export const EventProposalForm: React.FC<EventProposalFormProps> = ({ initialDat
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<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
|
<select
|
||||||
value={formData.academicYears[0] || ''}
|
value={formData.academicYears[0] || ''}
|
||||||
onChange={e => setFormData({...formData, academicYears: [e.target.value], targetedSections: [], allSections: false})}
|
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"
|
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>
|
<option value="">Select Academic Year</option>
|
||||||
{availableYears.map(year => <option key={year} value={year}>{year}</option>)}
|
{availableYears.map(year => <option key={year} value={year}>{year}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface Event {
|
|||||||
category: string;
|
category: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
targetedBatch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClassMapping {
|
interface ClassMapping {
|
||||||
@@ -63,9 +64,19 @@ const DepartmentCard: React.FC<{
|
|||||||
index: number;
|
index: number;
|
||||||
isApplicable: boolean;
|
isApplicable: boolean;
|
||||||
sections: string[];
|
sections: string[];
|
||||||
|
batches: any[];
|
||||||
onIncompleteClick?: (data: any) => void;
|
onIncompleteClick?: (data: any) => void;
|
||||||
isCompact?: boolean;
|
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 departmentEvents = events.filter(e => {
|
||||||
const status = e.status?.toUpperCase();
|
const status = e.status?.toUpperCase();
|
||||||
const isDeptMatch = e.department?.trim().toLowerCase() === deptName.trim().toLowerCase();
|
const isDeptMatch = e.department?.trim().toLowerCase() === deptName.trim().toLowerCase();
|
||||||
@@ -75,10 +86,9 @@ const DepartmentCard: React.FC<{
|
|||||||
(status === 'APPROVED' || status === 'COMPLETED' || status === 'PENDING_PR');
|
(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) => {
|
const getRequiredCount = (catId: string) => {
|
||||||
if (catId === 'INSTITUTIONAL') return 1;
|
return 1;
|
||||||
return sections.length > 1 ? 2 : 1;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +107,11 @@ const DepartmentCard: React.FC<{
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isApplicable ? (
|
{!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>
|
<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">
|
<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" />
|
<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 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -192,7 +215,17 @@ const DepartmentCard: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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" />
|
<AlertTriangle className="w-3.5 h-3.5 text-red-500 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
@@ -219,6 +252,7 @@ export const InstitutionalChecklist: React.FC<{
|
|||||||
const [classes, setClasses] = useState<ClassMapping[]>([]);
|
const [classes, setClasses] = useState<ClassMapping[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedBatch, setSelectedBatch] = useState<string>('');
|
const [selectedBatch, setSelectedBatch] = useState<string>('');
|
||||||
|
const [batches, setBatches] = useState<any[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(6);
|
const [itemsPerPage, setItemsPerPage] = useState(6);
|
||||||
|
|
||||||
@@ -228,9 +262,10 @@ export const InstitutionalChecklist: React.FC<{
|
|||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
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/events'),
|
||||||
fetch(API_BASE_URL + '/api/classes')
|
fetch(API_BASE_URL + '/api/classes'),
|
||||||
|
fetch(API_BASE_URL + '/api/batches')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (eventsRes.ok && classesRes.ok) {
|
if (eventsRes.ok && classesRes.ok) {
|
||||||
@@ -238,12 +273,14 @@ export const InstitutionalChecklist: React.FC<{
|
|||||||
eventsRes.json(),
|
eventsRes.json(),
|
||||||
classesRes.json()
|
classesRes.json()
|
||||||
]);
|
]);
|
||||||
|
const batchesData = batchesRes.ok ? await batchesRes.json() : [];
|
||||||
setEvents(eventsData);
|
setEvents(eventsData);
|
||||||
setClasses(classesData);
|
setClasses(classesData);
|
||||||
|
setBatches(batchesData);
|
||||||
|
|
||||||
const batches = Array.from(new Set(classesData.map((c: any) => c.academicYear.trim()))).sort().reverse();
|
const yearBatches = Array.from(new Set(classesData.map((c: any) => c.academicYear.trim()))).sort().reverse();
|
||||||
if (batches.length > 0 && !selectedBatch) {
|
if (yearBatches.length > 0 && !selectedBatch) {
|
||||||
setSelectedBatch(batches[0] as string);
|
setSelectedBatch(yearBatches[0] as string);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -347,6 +384,7 @@ export const InstitutionalChecklist: React.FC<{
|
|||||||
index={idx}
|
index={idx}
|
||||||
isApplicable={deptClasses.length > 0}
|
isApplicable={deptClasses.length > 0}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
|
batches={batches}
|
||||||
onIncompleteClick={onIncompleteClick}
|
onIncompleteClick={onIncompleteClick}
|
||||||
isCompact={isCompact}
|
isCompact={isCompact}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { UserRole, DashboardView, Event, Announcement, SpecialEvent } from '../../types';
|
import type { UserRole, DashboardView, Event, Announcement, SpecialEvent } from '../../types';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useDialog } from '../../context/DialogContext';
|
||||||
import { API_BASE_URL } from '../../lib/config';
|
import { API_BASE_URL } from '../../lib/config';
|
||||||
|
|
||||||
// Import Site 2 components
|
// Import Site 2 components
|
||||||
@@ -25,6 +26,7 @@ const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
|
|
||||||
export const StudentDashboard: React.FC = () => {
|
export const StudentDashboard: React.FC = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { showAlert } = useDialog();
|
||||||
|
|
||||||
// Tab Routing
|
// Tab Routing
|
||||||
const [activeView, setActiveView] = useState<DashboardView>('HOME');
|
const [activeView, setActiveView] = useState<DashboardView>('HOME');
|
||||||
@@ -36,6 +38,7 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
|
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
|
||||||
const [specialEvents, setSpecialEvents] = useState<SpecialEvent[]>([]);
|
const [specialEvents, setSpecialEvents] = useState<SpecialEvent[]>([]);
|
||||||
|
const [settings, setSettings] = useState<Record<string, any>>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Team Modals
|
// Team Modals
|
||||||
@@ -151,13 +154,23 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
|
|
||||||
// Fetch special events safely (defaulting to empty array if not supported)
|
// Fetch special events safely (defaulting to empty array if not supported)
|
||||||
try {
|
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) {
|
if (seRes.ok) {
|
||||||
const seData = await seRes.json();
|
const seData = await seRes.json();
|
||||||
setSpecialEvents(seData);
|
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([]);
|
setSpecialEvents([]);
|
||||||
|
setSettings({});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch student dashboard data from Firestore:", err);
|
console.error("Failed to fetch student dashboard data from Firestore:", err);
|
||||||
@@ -186,7 +199,7 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
}, [userRegistrations]);
|
}, [userRegistrations]);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const handleToggleBooking = async (eventId: string) => {
|
const handleToggleBooking = async (eventId: string, customDetails?: any) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const isBooked = bookedEventIds.includes(eventId);
|
const isBooked = bookedEventIds.includes(eventId);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
@@ -201,7 +214,7 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await fetchData();
|
await fetchData();
|
||||||
} else {
|
} else {
|
||||||
alert("Failed to cancel registration.");
|
showAlert("Error", "Failed to cancel registration.", "error");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Block booking if there's an unfinalized completed event (missing proof or missing OD approval)
|
// 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) {
|
if (unfinalized) {
|
||||||
const ev = events.find(e => String(e.id) === String(unfinalized.eventId || unfinalized.event_id));
|
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);
|
setIsProcessing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -231,13 +244,13 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
eventId: Number(eventId),
|
eventId: Number(eventId),
|
||||||
event_id: Number(eventId),
|
event_id: Number(eventId),
|
||||||
userEmail: user.email,
|
userEmail: customDetails?.email || user.email,
|
||||||
userName: user.fullName,
|
userName: customDetails?.userName || user.fullName,
|
||||||
regNo: user.regNo,
|
regNo: customDetails?.regNo || user.regNo,
|
||||||
phone: user.phone,
|
phone: customDetails?.phone || user.phone,
|
||||||
gender: user.gender,
|
gender: user.gender,
|
||||||
dept: user.department,
|
dept: customDetails?.dept || user.department,
|
||||||
section: user.section,
|
section: customDetails?.section || user.section,
|
||||||
year: user.year,
|
year: user.year,
|
||||||
college: user.collegeName || 'Rajalakshmi Institute of Technology'
|
college: user.collegeName || 'Rajalakshmi Institute of Technology'
|
||||||
};
|
};
|
||||||
@@ -253,7 +266,7 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
setActiveView('REGISTRATIONS');
|
setActiveView('REGISTRATIONS');
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
alert(err.message || "Registration failed.");
|
showAlert("Error", err.message || "Registration failed.", "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -266,7 +279,10 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
const generateCode = () => Math.random().toString(36).substring(2, 8).toUpperCase();
|
const generateCode = () => Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
|
||||||
const handleCreateTeam = async (eventId: string) => {
|
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);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
const code = generateCode();
|
const code = generateCode();
|
||||||
@@ -288,14 +304,17 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
await fetchData();
|
await fetchData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert("Failed to form team");
|
showAlert("Error", "Failed to form team", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJoinTeam = async (eventId: string) => {
|
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);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
// Fetch team registrations to validate
|
// Fetch team registrations to validate
|
||||||
@@ -304,7 +323,7 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
const teamRegs = await res.json();
|
const teamRegs = await res.json();
|
||||||
|
|
||||||
if (!teamRegs || teamRegs.length === 0) {
|
if (!teamRegs || teamRegs.length === 0) {
|
||||||
alert("Invalid team code for this event.");
|
showAlert("Invalid Team", "Invalid team code for this event.", "error");
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -313,13 +332,13 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
const eventVal = events.find(e => String(e.id) === String(eventId));
|
const eventVal = events.find(e => String(e.id) === String(eventId));
|
||||||
|
|
||||||
if (eventVal?.teamSizeLimit && teamRegs.length >= eventVal.teamSizeLimit) {
|
if (eventVal?.teamSizeLimit && teamRegs.length >= eventVal.teamSizeLimit) {
|
||||||
alert("Team is already full.");
|
showAlert("Team Full", "Team is already full.", "error");
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventVal?.teamComposition === 'INTER_DEPT' && leader && user?.department !== (leader.dept || leader.department)) {
|
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);
|
setIsProcessing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -342,7 +361,7 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
await fetchData();
|
await fetchData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert("Failed to join team");
|
showAlert("Error", "Failed to join team", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
@@ -388,6 +407,7 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
announcements={announcements}
|
announcements={announcements}
|
||||||
onNavigateToEvents={() => setActiveView('EVENTS')}
|
onNavigateToEvents={() => setActiveView('EVENTS')}
|
||||||
specialEvents={specialEvents}
|
specialEvents={specialEvents}
|
||||||
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -457,7 +477,7 @@ export const StudentDashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F3F4F6]">
|
<div className="min-h-screen bg-white">
|
||||||
<Navbar
|
<Navbar
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
onViewChange={(view) => {
|
onViewChange={(view) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Award
|
Award
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useDialog } from '../../context/DialogContext';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
interface Event {
|
interface Event {
|
||||||
@@ -52,6 +53,7 @@ interface Registration {
|
|||||||
|
|
||||||
export const StudentRegistrationsView: React.FC = () => {
|
export const StudentRegistrationsView: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { showAlert, showConfirm } = useDialog();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
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 daysList = ["Day 1", "Day 2", "Day 3"]; // Standard slots
|
||||||
|
|
||||||
|
const [batches, setBatches] = useState<any[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
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(() => {
|
useEffect(() => {
|
||||||
if (selectedEvent) {
|
if (selectedEvent) {
|
||||||
fetchRegistrations(selectedEvent.id);
|
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
|
// Toggle Attendance Cell local state
|
||||||
const handleToggleAttendance = (regId: string, day: string) => {
|
const handleToggleAttendance = (regId: string, day: string) => {
|
||||||
setAttendanceGrid(prev => ({
|
setAttendanceGrid(prev => ({
|
||||||
@@ -197,10 +241,10 @@ export const StudentRegistrationsView: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("Attendance records synchronized successfully!");
|
showAlert("Success", "Attendance records synchronized successfully!", "success");
|
||||||
await fetchRegistrations(selectedEvent!.id);
|
await fetchRegistrations(selectedEvent!.id);
|
||||||
} else {
|
} else {
|
||||||
alert("Failed to save attendance.");
|
showAlert("Error", "Failed to save attendance.", "error");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -223,7 +267,10 @@ export const StudentRegistrationsView: React.FC = () => {
|
|||||||
|
|
||||||
// Upload OD File to all registered students
|
// Upload OD File to all registered students
|
||||||
const handleUploadODFile = async () => {
|
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);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
// Loop over and update all registrations for this event
|
// Loop over and update all registrations for this event
|
||||||
@@ -234,7 +281,7 @@ export const StudentRegistrationsView: React.FC = () => {
|
|||||||
body: JSON.stringify({ odUrl: odFileBase64 })
|
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);
|
setOdFileBase64(null);
|
||||||
await fetchRegistrations(selectedEvent!.id);
|
await fetchRegistrations(selectedEvent!.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -256,7 +303,7 @@ export const StudentRegistrationsView: React.FC = () => {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setInspectCertReg(null);
|
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);
|
await fetchRegistrations(selectedEvent!.id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -271,6 +318,12 @@ export const StudentRegistrationsView: React.FC = () => {
|
|||||||
r.regNo.toLowerCase().includes(searchQuery.toLowerCase())
|
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 (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in duration-300">
|
<div className="space-y-8 animate-in fade-in duration-300">
|
||||||
{/* Header Info */}
|
{/* Header Info */}
|
||||||
@@ -377,12 +430,13 @@ export const StudentRegistrationsView: React.FC = () => {
|
|||||||
<th className="py-4">College</th>
|
<th className="py-4">College</th>
|
||||||
<th className="py-4">Alliance/Team</th>
|
<th className="py-4">Alliance/Team</th>
|
||||||
<th className="py-4 text-center">Payment Status</th>
|
<th className="py-4 text-center">Payment Status</th>
|
||||||
|
<th className="py-4 text-center">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-50">
|
<tbody className="divide-y divide-slate-50">
|
||||||
{filteredRoster.length === 0 ? (
|
{filteredRoster.length === 0 ? (
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredRoster.map((reg) => (
|
filteredRoster.map((reg) => (
|
||||||
@@ -393,6 +447,11 @@ export const StudentRegistrationsView: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-4 text-slate-600">
|
<td className="py-4 text-slate-600">
|
||||||
{reg.year} Year / SEC {reg.section}
|
{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>
|
<p className="text-[9px] text-brand-indigo mt-0.5">{reg.dept}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 text-slate-600 truncate max-w-[150px]">{reg.college}</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}
|
{reg.paymentStatus}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -450,17 +518,53 @@ export const StudentRegistrationsView: React.FC = () => {
|
|||||||
<th className="py-4">Student</th>
|
<th className="py-4">Student</th>
|
||||||
<th className="py-4">Register No</th>
|
<th className="py-4">Register No</th>
|
||||||
{daysList.map(day => <th key={day} className="py-4 text-center">{day}</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-50">
|
<tbody className="divide-y divide-slate-50">
|
||||||
{registrations.length === 0 ? (
|
{registrations.length === 0 ? (
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
registrations.map((reg) => (
|
registrations.map((reg) => (
|
||||||
<tr key={reg.id} className="hover:bg-slate-50/50">
|
<tr key={reg.id} className="hover:bg-slate-50/50">
|
||||||
<td className="py-4 font-black text-brand-navy">{reg.userName}</td>
|
<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>
|
<td className="py-4 text-slate-400">{reg.regNo}</td>
|
||||||
{daysList.map(day => (
|
{daysList.map(day => (
|
||||||
<td key={day} className="py-4 text-center">
|
<td key={day} className="py-4 text-center">
|
||||||
@@ -472,6 +576,24 @@ export const StudentRegistrationsView: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -318,10 +318,10 @@ export const UserManagement: React.FC = () => {
|
|||||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
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}>
|
<form onSubmit={handleSubmit} className="flex flex-col overflow-hidden">
|
||||||
<div className="p-8 bg-brand-navy text-white flex justify-between items-start">
|
<div className="p-8 bg-brand-navy text-white flex justify-between items-start shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-black tracking-tight">
|
<h3 className="text-2xl font-black tracking-tight">
|
||||||
{editingUser ? 'Edit User' : 'Add New User'}
|
{editingUser ? 'Edit User' : 'Add New User'}
|
||||||
@@ -337,7 +337,7 @@ export const UserManagement: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Full Name</label>
|
<label className="text-[10px] font-black uppercase tracking-widest text-text-muted mb-2 block">Full Name</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -625,16 +625,16 @@ export const UserManagement: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="shrink-0 w-full">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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'}
|
{editingUser ? 'Update User Credentials' : 'Create System User'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { CLUBS } from './constants';
|
|||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
event: Event;
|
event: Event;
|
||||||
isBooked: boolean;
|
isBooked: boolean;
|
||||||
onToggle: () => void;
|
onToggle: (customDetails?: any) => void;
|
||||||
onTrackStatus?: (event: Event) => void;
|
onTrackStatus?: (event: Event) => void;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
userRole?: string;
|
userRole?: string;
|
||||||
@@ -35,6 +35,55 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
|
|||||||
const ticketRef = useRef<HTMLDivElement>(null);
|
const ticketRef = useRef<HTMLDivElement>(null);
|
||||||
const [persistedTicket, setPersistedTicket] = useState<{id: string, qr: string} | null>(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(() => {
|
useEffect(() => {
|
||||||
if (showTicket && registration?.id) {
|
if (showTicket && registration?.id) {
|
||||||
const initializeTicket = async () => {
|
const initializeTicket = async () => {
|
||||||
@@ -69,8 +118,6 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
|
|||||||
}
|
}
|
||||||
}, [showTicket, registration?.id]);
|
}, [showTicket, registration?.id]);
|
||||||
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -131,11 +178,20 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
|
|||||||
if (!ticketRef.current) return;
|
if (!ticketRef.current) return;
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
const dataUrl = await toPng(ticketRef.current, { cacheBust: true, quality: 1, backgroundColor: '#1e293b' });
|
const dataUrl = await toPng(ticketRef.current, { cacheBust: true, quality: 2, backgroundColor: '#1e293b' });
|
||||||
const link = document.createElement('a');
|
const { jsPDF } = await import('jspdf');
|
||||||
link.download = `Pass_${event.title.replace(/\s+/g, '_')}.png`;
|
|
||||||
link.href = dataUrl;
|
const width = ticketRef.current.offsetWidth || 380;
|
||||||
link.click();
|
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) {
|
} catch (err) {
|
||||||
console.error("Capture failed:", err);
|
console.error("Capture failed:", err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -328,48 +384,133 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
|
|||||||
{showConfirm && (
|
{showConfirm && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-6 bg-slate-900/60 backdrop-blur-sm">
|
<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="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={`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`}>
|
<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>
|
<i className={`fas ${event.isTeamEvent ? 'fa-users' : 'fa-ticket'}`}></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-black text-[#1A202C] uppercase mb-4">
|
<h3 className="text-xl font-black text-[#1A202C] uppercase text-center mb-1">
|
||||||
{event.isTeamEvent ? 'Team Registration' : 'Confirm Pass?'}
|
{event.isTeamEvent ? 'Team Registration' : 'Confirm Pass?'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{event.isTeamEvent ? (
|
{event.isTeamEvent && (
|
||||||
<div className="space-y-6 mb-10">
|
<div className="bg-orange-50 border border-orange-100 rounded-2xl p-4 text-left">
|
||||||
<div className="bg-orange-50 border border-orange-100 rounded-2xl p-6 text-left">
|
<p className="text-[9px] font-black text-orange-600 uppercase tracking-widest mb-1 flex items-center gap-1.5">
|
||||||
<p className="text-[10px] font-black text-orange-600 uppercase tracking-widest mb-3 flex items-center gap-2">
|
|
||||||
<i className="fas fa-exclamation-triangle"></i> Important Notice
|
<i className="fas fa-exclamation-triangle"></i> Important Notice
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-orange-800 font-bold leading-relaxed lowercase first-letter:uppercase">
|
<p className="text-xs text-orange-800 font-bold leading-normal">
|
||||||
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.
|
This is a <span className="underline">team-based event</span>. You must create or join a team in the registrations section after registering.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</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">
|
{/* Paid Event Links Box */}
|
||||||
<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>
|
{event.paymentLink && (
|
||||||
<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`}>
|
<div className="bg-indigo-50 border border-brand-indigo/10 rounded-2xl p-4 text-left">
|
||||||
{event.isTeamEvent ? 'I Understand' : 'Confirm'}
|
<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-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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,8 +580,9 @@ const EventCard: React.FC<EventCardProps> = ({ event, isBooked, onToggle, onTrac
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1">Year / Section</span>
|
<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'}
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{event.isTeamEvent && registration?.team_name && (
|
{event.isTeamEvent && registration?.team_name && (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface HeroProps {
|
|||||||
|
|
||||||
const Hero: React.FC<HeroProps> = () => {
|
const Hero: React.FC<HeroProps> = () => {
|
||||||
return (
|
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 */}
|
{/* Optimized Video Background Container */}
|
||||||
<div className="absolute inset-0 z-0 overflow-hidden">
|
<div className="absolute inset-0 z-0 overflow-hidden">
|
||||||
<video
|
<video
|
||||||
|
|||||||
@@ -3,15 +3,23 @@ import type { Event, Announcement, SpecialEvent } from '../../types';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import SpecialEventsBanner from './SpecialEventsBanner';
|
import SpecialEventsBanner from './SpecialEventsBanner';
|
||||||
import AccreditationsSection from './AccreditationsSection';
|
import AccreditationsSection from './AccreditationsSection';
|
||||||
|
import ScrollNotification from './ScrollNotification';
|
||||||
|
|
||||||
interface HomeDashboardProps {
|
interface HomeDashboardProps {
|
||||||
events: Event[];
|
events: Event[];
|
||||||
announcements: Announcement[];
|
announcements: Announcement[];
|
||||||
onNavigateToEvents: () => void;
|
onNavigateToEvents: () => void;
|
||||||
specialEvents: SpecialEvent[];
|
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());
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,8 +80,11 @@ const HomeDashboard: React.FC<HomeDashboardProps> = ({ events = [], announcement
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Notification - displayed only when enabled by admin */}
|
||||||
|
<ScrollNotification settings={settings} />
|
||||||
|
|
||||||
{/* Notice Board Section */}
|
{/* 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 */}
|
{/* Cork texture pattern */}
|
||||||
<div className="absolute inset-0 opacity-30 bg-[url('https://www.transparenttextures.com/patterns/cork-board.png')] pointer-events-none"></div>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-12 p-6">
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{filteredAnnouncements.length > 0 ? (
|
{filteredAnnouncements.length > 0 ? (
|
||||||
filteredAnnouncements.slice(0, 6).map((ann) => {
|
filteredAnnouncements.slice(0, 3).map((ann) => {
|
||||||
const style = getNoteStyle(ann.id);
|
const style = getNoteStyle(ann.id);
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -119,6 +130,14 @@ const HomeDashboard: React.FC<HomeDashboardProps> = ({ events = [], announcement
|
|||||||
{ann.title}
|
{ann.title}
|
||||||
</h3>
|
</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">
|
<p className="font-sans text-gray-800 text-base flex-grow mb-6 leading-relaxed opacity-90">
|
||||||
{ann.message}
|
{ann.message}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,183 +1,255 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import type { SpecialEvent } from '../../types';
|
import type { SpecialEvent } from '../../types';
|
||||||
|
import { Sparkles, ArrowUpRight, Zap } from 'lucide-react';
|
||||||
|
|
||||||
interface SpecialEventsBannerProps {
|
interface SpecialEventsBannerProps {
|
||||||
specialEvents: SpecialEvent[];
|
specialEvents: SpecialEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpecialEventsBanner: React.FC<SpecialEventsBannerProps> = ({ specialEvents }) => {
|
const SpecialEventsBanner: React.FC<SpecialEventsBannerProps> = ({ specialEvents }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const hasEvents = specialEvents && specialEvents.length > 0;
|
const hasEvents = specialEvents && specialEvents.length > 0;
|
||||||
|
|
||||||
const handleRedirect = (link: string) => {
|
const handleRedirect = (link: string) => {
|
||||||
window.location.href = link;
|
if (link) {
|
||||||
|
window.open(link, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultTitle = "The Grand Chronicles of RIT";
|
if (!hasEvents) return null;
|
||||||
const defaultDescription = "Hark! The tides have brought forth prestigious gatherings and legendary challenges. Seek thy destiny in the links below.";
|
|
||||||
|
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 (
|
return (
|
||||||
<section className="py-12 relative overflow-hidden bg-transparent">
|
<section className="relative py-32 mt-36 mb-20 font-sans -mx-6 md:-mx-12 lg:-mx-24 bg-white">
|
||||||
{/* Background glow (Softer and centered) */}
|
{/* Full-width Skewed Light Multi-Gradient Background (Blue, Red/Rose, Pink) */}
|
||||||
<div className="absolute inset-0 z-0 opacity-5 pointer-events-none">
|
<div
|
||||||
<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>
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-2xl mx-auto px-6 relative z-10">
|
{/* Caution Tape Bottom */}
|
||||||
{/* New Header: Descriptive text above the scroll */}
|
<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="text-center mb-12">
|
<div className="h-[4px] w-full hazard-stripes" />
|
||||||
<h2 className="font-medieval text-3xl md:text-5xl text-[#3e2723] mb-4 opacity-90">
|
<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="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, margin: "-100px" }}
|
||||||
|
variants={headerVariants}
|
||||||
|
className="text-center mb-20 relative"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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
|
Special Events Registry
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-parchment text-sm md:text-base text-[#5d4037]/70 italic tracking-wide">
|
<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)]" />
|
||||||
Tap the ancient scroll to reveal the current decrees of RIT
|
<p className="text-slate-600 text-sm md:text-base max-w-xl mx-auto font-medium leading-relaxed">
|
||||||
</p>
|
Explore premium workshops, hackathons, and institutional programs selected for Rajalakshmi Institute of Technology.
|
||||||
<div className="w-16 h-[1px] bg-[#8d6e63]/20 mx-auto mt-6"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 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>
|
|
||||||
</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."}
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 pt-4">
|
{/* Animated Cards Grid */}
|
||||||
{specialEvents.map((event, index) => (
|
<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
|
<motion.div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
variants={{
|
variants={cardVariants}
|
||||||
closed: { opacity: 0, scale: 0.95 },
|
whileHover={{
|
||||||
open: { opacity: 1, scale: 1 }
|
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)"
|
||||||
}}
|
}}
|
||||||
transition={{ delay: isOpen ? 0.3 + (index * 0.1) : 0 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
className="group cursor-pointer relative"
|
|
||||||
onClick={() => handleRedirect(event.link)}
|
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"
|
||||||
>
|
>
|
||||||
<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">
|
{/* Animated inner glowing border gradient on hover */}
|
||||||
<h3 className="font-medieval text-xl md:text-2xl text-[#3e2723] mb-2 group-hover:text-[#795548] transition-colors">
|
<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" />
|
||||||
{event.title}
|
|
||||||
</h3>
|
<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" />
|
||||||
<p className="font-parchment text-xs md:text-sm text-[#5d4037] line-clamp-2 opacity-80">
|
|
||||||
{event.description}
|
<div>
|
||||||
</p>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="mt-3 flex justify-end">
|
{/* Glowing Icon Container */}
|
||||||
<span className="text-[9px] font-medieval uppercase tracking-[0.2em] text-[#8d6e63] group-hover:text-[#3e2723] flex items-center gap-2">
|
<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)]">
|
||||||
Behold <i className="fas fa-feather-pointed"></i>
|
<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>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
</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>
|
</div>
|
||||||
</motion.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>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.font-medieval { font-family: 'Pirata One', cursive; }
|
@import url('https://fonts.googleapis.com/css2?family=MedievalSharp&family=Cinzel:wght@700;900&family=Eagle+Lake&display=swap');
|
||||||
.font-parchment { font-family: 'Almendra', serif; }
|
|
||||||
|
.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>
|
`}</style>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SpecialEventsBanner;
|
export default SpecialEventsBanner;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ const StatusTrackerView: React.FC<StatusTrackerViewProps> = ({ event, registrati
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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(); }} />}
|
{showSuccess && <SuccessPopup onClose={() => { setShowSuccess(false); window.location.reload(); }} />}
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|||||||
127
RIT-EMS-main/frontend/src/context/DialogContext.tsx
Normal file
127
RIT-EMS-main/frontend/src/context/DialogContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -199,20 +199,32 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-register student if matches roll number format
|
// 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$/);
|
const studentMatch = emailLower.match(/^student\.(\d{6})@([a-zA-Z0-9&-_]+)\.ritchennai\.edu\.in$/);
|
||||||
if (studentMatch) {
|
let rollNo = studentMatch ? studentMatch[1] : '';
|
||||||
const rollNo = studentMatch[1];
|
let rawDept = studentMatch ? studentMatch[2] : 'CSE';
|
||||||
const rawDept = studentMatch[2].toLowerCase();
|
|
||||||
|
// 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)
|
// 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 currentYear = new Date().getFullYear();
|
||||||
const currentMonth = new Date().getMonth();
|
const currentMonth = new Date().getMonth();
|
||||||
const academicYearOffset = currentMonth >= 5 ? 1 : 0;
|
const academicYearOffset = currentMonth >= 5 ? 1 : 0;
|
||||||
const yearIndex = currentYear - joinYear + academicYearOffset;
|
const yearIndex = currentYear - joinYear + academicYearOffset;
|
||||||
const years = ["1st Year", "2nd Year", "3rd Year", "4th Year"];
|
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
|
// Map department
|
||||||
let dept = rawDept.toUpperCase();
|
let dept = rawDept.toUpperCase();
|
||||||
@@ -271,19 +283,31 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if matches student RIT format
|
// Check if matches student RIT format
|
||||||
|
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$/);
|
const studentMatch = emailLower.match(/^student\.(\d{6})@([a-zA-Z0-9&-_]+)\.ritchennai\.edu\.in$/);
|
||||||
if (studentMatch) {
|
let rollNo = studentMatch ? studentMatch[1] : '';
|
||||||
const rollNo = studentMatch[1];
|
let rawDept = studentMatch ? studentMatch[2] : 'CSE';
|
||||||
const rawDept = studentMatch[2].toLowerCase();
|
|
||||||
|
// 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)
|
// 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 currentYear = new Date().getFullYear();
|
||||||
const currentMonth = new Date().getMonth();
|
const currentMonth = new Date().getMonth();
|
||||||
const academicYearOffset = currentMonth >= 5 ? 1 : 0;
|
const academicYearOffset = currentMonth >= 5 ? 1 : 0;
|
||||||
const yearIndex = currentYear - joinYear + academicYearOffset;
|
const yearIndex = currentYear - joinYear + academicYearOffset;
|
||||||
const years = ["1st Year", "2nd Year", "3rd Year", "4th Year"];
|
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
|
// Map department
|
||||||
let dept = rawDept.toUpperCase();
|
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}` });
|
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
|
// NOTES CONTROLLER
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
@@ -642,9 +693,11 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
|
|||||||
budget: budgetStr === "" ? 0.0 : parseFloat(budgetStr),
|
budget: budgetStr === "" ? 0.0 : parseFloat(budgetStr),
|
||||||
hasRegistrationFee: !!payload.hasRegistrationFee,
|
hasRegistrationFee: !!payload.hasRegistrationFee,
|
||||||
registrationFee: feeStr === "" ? 0.0 : parseFloat(feeStr),
|
registrationFee: feeStr === "" ? 0.0 : parseFloat(feeStr),
|
||||||
|
paymentLink: payload.paymentLink || null,
|
||||||
centreName: payload.centreName || null,
|
centreName: payload.centreName || null,
|
||||||
isPublicEvent: !!payload.isPublicEvent,
|
isPublicEvent: !!payload.isPublicEvent,
|
||||||
image: payload.image || null,
|
image: payload.image || null,
|
||||||
|
targetedBatch: payload.targetedBatch || null,
|
||||||
proposer: {
|
proposer: {
|
||||||
id: proposer.id,
|
id: proposer.id,
|
||||||
fullName: proposer.fullName,
|
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.centreName !== undefined) updatedFields.centreName = payload.centreName;
|
||||||
if (payload.isPublicEvent !== undefined) updatedFields.isPublicEvent = !!payload.isPublicEvent;
|
if (payload.isPublicEvent !== undefined) updatedFields.isPublicEvent = !!payload.isPublicEvent;
|
||||||
if (payload.image !== undefined) updatedFields.image = payload.image;
|
if (payload.image !== undefined) updatedFields.image = payload.image;
|
||||||
|
if (payload.targetedBatch !== undefined) updatedFields.targetedBatch = payload.targetedBatch;
|
||||||
if (payload.status !== undefined && isAdmin) {
|
if (payload.status !== undefined && isAdmin) {
|
||||||
updatedFields.status = payload.status;
|
updatedFields.status = payload.status;
|
||||||
}
|
}
|
||||||
@@ -922,6 +976,7 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = generateNumericId();
|
const id = generateNumericId();
|
||||||
|
const isRitEmail = email.includes('@ritchennai.edu.in') || email.includes('.ritchennai.edu.in');
|
||||||
const newUser = {
|
const newUser = {
|
||||||
...payload,
|
...payload,
|
||||||
id,
|
id,
|
||||||
@@ -933,7 +988,7 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
|
|||||||
regNo: payload.regNo || '',
|
regNo: payload.regNo || '',
|
||||||
phone: payload.phone || '',
|
phone: payload.phone || '',
|
||||||
gender: payload.gender || 'Male',
|
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);
|
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);
|
return jsonResponse({ message: "You are already registered for this event." }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch event details
|
|
||||||
const eventSnap = await getDoc(doc(db, 'ems_events', String(eventId)));
|
const eventSnap = await getDoc(doc(db, 'ems_events', String(eventId)));
|
||||||
if (!eventSnap.exists()) {
|
if (!eventSnap.exists()) {
|
||||||
return jsonResponse({ message: "Event not found" }, 404);
|
return jsonResponse({ message: "Event not found" }, 404);
|
||||||
}
|
}
|
||||||
const event = eventSnap.data();
|
const event = eventSnap.data();
|
||||||
|
|
||||||
// Check deadline
|
|
||||||
if (event.endDate) {
|
if (event.endDate) {
|
||||||
const deadline = new Date(event.registrationFee ? (event.registrationDeadline || event.endDate) : event.endDate);
|
const deadline = new Date(event.registrationFee ? (event.registrationDeadline || event.endDate) : event.endDate);
|
||||||
if (new Date() > deadline) {
|
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 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 eventRegs = allRegsSnap.docs.map(d => d.data()).filter(r => String(r.eventId || r.event_id) === String(eventId));
|
||||||
const currentParticipants = eventRegs.length;
|
const currentParticipants = eventRegs.length;
|
||||||
|
|
||||||
// Validate overall capacity
|
|
||||||
if (event.maxParticipants && currentParticipants >= event.maxParticipants) {
|
if (event.maxParticipants && currentParticipants >= event.maxParticipants) {
|
||||||
return jsonResponse({ message: "Registration Blocked: The event has reached its capacity." }, 409);
|
return jsonResponse({ message: "Registration Blocked: The event has reached its capacity." }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quota validation
|
|
||||||
const studentDept = payload.dept || payload.department || 'CSE';
|
const studentDept = payload.dept || payload.department || 'CSE';
|
||||||
const studentSec = payload.section || 'A';
|
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 deptLimit = Number(event.deptLimits[studentDept]);
|
||||||
const deptRegs = eventRegs.filter(r => (r.dept || r.department) === studentDept);
|
const deptRegs = eventRegs.filter(r => (r.dept || r.department) === studentDept);
|
||||||
|
|
||||||
// Section limit check if present
|
|
||||||
if (event.deptSectionLimits && event.deptSectionLimits[studentDept]) {
|
if (event.deptSectionLimits && event.deptSectionLimits[studentDept]) {
|
||||||
const sectionLimits = event.deptSectionLimits[studentDept];
|
const sectionLimits = event.deptSectionLimits[studentDept];
|
||||||
const sectionLimit = Number(sectionLimits[studentSec] || 0);
|
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);
|
return jsonResponse({ message: `Registration Blocked: Quota for Section ${studentSec} of ${studentDept} is full.` }, 409);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Standard department limit check
|
|
||||||
if (deptRegs.length >= deptLimit) {
|
if (deptRegs.length >= deptLimit) {
|
||||||
return jsonResponse({ message: `Registration Blocked: Quota for department ${studentDept} is full.` }, 409);
|
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 isFree = event.hasRegistrationFee === false || event.registrationFee === 0 || event.pricingType === 'FREE';
|
||||||
const newReg = {
|
const newReg = {
|
||||||
...payload,
|
...registrationData,
|
||||||
id: regId,
|
id: regId,
|
||||||
userId,
|
userId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -1056,8 +1104,6 @@ window.fetch = async function(input: RequestInfo | URL, init?: RequestInit): Pro
|
|||||||
};
|
};
|
||||||
|
|
||||||
await setDoc(regDocRef, newReg);
|
await setDoc(regDocRef, newReg);
|
||||||
|
|
||||||
// Update event's currentParticipants
|
|
||||||
const eventDocRef = doc(db, 'ems_events', String(eventId));
|
const eventDocRef = doc(db, 'ems_events', String(eventId));
|
||||||
await updateDoc(eventDocRef, { currentParticipants: currentParticipants + 1 });
|
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" });
|
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
|
// ANNOUNCEMENTS CONTROLLER
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface Event {
|
|||||||
status?: 'Registered' | 'Event Ongoing' | 'Completed' | 'Scheduled' | 'APPROVED' | 'COMPLETED' | 'ONGOING' | 'REJECTED';
|
status?: 'Registered' | 'Event Ongoing' | 'Completed' | 'Scheduled' | 'APPROVED' | 'COMPLETED' | 'ONGOING' | 'REJECTED';
|
||||||
hasRegistrationFee?: boolean;
|
hasRegistrationFee?: boolean;
|
||||||
registrationFee?: number;
|
registrationFee?: number;
|
||||||
|
paymentLink?: string;
|
||||||
registrationDeadline?: string;
|
registrationDeadline?: string;
|
||||||
maxParticipants?: number;
|
maxParticipants?: number;
|
||||||
durationDays?: number;
|
durationDays?: number;
|
||||||
@@ -78,6 +79,7 @@ export interface Event {
|
|||||||
conducting_dept?: string;
|
conducting_dept?: string;
|
||||||
request_by_faculty?: string;
|
request_by_faculty?: string;
|
||||||
request_by_HOD?: string;
|
request_by_HOD?: string;
|
||||||
|
targetedBatch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Announcement {
|
export interface Announcement {
|
||||||
@@ -150,4 +152,12 @@ export interface SpecialEvent {
|
|||||||
created_by: string;
|
created_by: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
verificationStatus?: 'PENDING' | 'PENDING_HOD' | 'PENDING_ADMIN' | 'APPROVED' | 'REJECTED';
|
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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,35 +108,39 @@ const SpecialEventsBanner: React.FC<SpecialEventsBannerProps> = ({ specialEvents
|
|||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 pt-4">
|
<div className="relative w-full h-64 overflow-hidden rounded-lg">
|
||||||
{specialEvents.map((event, index) => (
|
{/* Slideshow container */}
|
||||||
<motion.div
|
<style>{`
|
||||||
|
.animate-slideshow {
|
||||||
|
display: flex;
|
||||||
|
width: ${specialEvents.length * 100}%;
|
||||||
|
animation: slide ${specialEvents.length * 8}s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes slide {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(-${((specialEvents.length - 1) / specialEvents.length) * 100}%); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="animate-slideshow">
|
||||||
|
{specialEvents.map((event) => (
|
||||||
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
variants={{
|
className="flex-shrink-0 w-full h-full cursor-pointer flex items-end bg-cover bg-center"
|
||||||
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)}
|
onClick={() => handleRedirect(event.link)}
|
||||||
|
style={{
|
||||||
|
backgroundImage: event.coverImage ? `url(${event.coverImage})` : "url('/parchment_texture.png')",
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundColor: event.coverImage ? undefined : '#6b5b95',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<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">
|
<div className="w-full bg-black/40 text-center py-2 text-white text-sm font-medieval">
|
||||||
<h3 className="font-medieval text-xl md:text-2xl text-[#3e2723] mb-2 group-hover:text-[#795548] transition-colors">
|
|
||||||
{event.title}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasEvents && (
|
{hasEvents && (
|
||||||
<div className="font-medieval text-[10px] text-[#8d6e63]/40 uppercase tracking-[0.4em] pt-8">
|
<div className="font-medieval text-[10px] text-[#8d6e63]/40 uppercase tracking-[0.4em] pt-8">
|
||||||
|
|||||||
BIN
image copy.png
Normal file
BIN
image copy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 619 KiB |
Reference in New Issue
Block a user