746 lines
33 KiB
TypeScript
746 lines
33 KiB
TypeScript
import { API_BASE_URL } from '../../lib/config';
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Users,
|
|
CheckSquare,
|
|
Upload,
|
|
FileCheck,
|
|
XCircle,
|
|
CheckCircle,
|
|
FileText,
|
|
Search,
|
|
ChevronRight,
|
|
Eye,
|
|
Award
|
|
} from 'lucide-react';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
import { useDialog } from '../../context/DialogContext';
|
|
import { cn } from '../../lib/utils';
|
|
|
|
interface Event {
|
|
id: number;
|
|
title: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
type: string;
|
|
location: string;
|
|
category: string;
|
|
department: string;
|
|
status: string;
|
|
}
|
|
|
|
interface Registration {
|
|
id: string;
|
|
userId: string;
|
|
eventId: number;
|
|
userName: string;
|
|
userEmail: string;
|
|
regNo: string;
|
|
phone: string;
|
|
gender: string;
|
|
dept: string;
|
|
section: string;
|
|
year: string;
|
|
college: string;
|
|
paymentStatus: 'PENDING' | 'COMPLETED';
|
|
teamCode: string | null;
|
|
teamName: string | null;
|
|
isTeamLeader: boolean;
|
|
certificationUrl: string | null;
|
|
certificationStatus: 'NOT_SUBMITTED' | 'PENDING_APPROVAL' | 'APPROVED';
|
|
odUrl: string | null;
|
|
}
|
|
|
|
export const StudentRegistrationsView: React.FC = () => {
|
|
const { user } = useAuth();
|
|
const { showAlert, showConfirm } = useDialog();
|
|
const [events, setEvents] = useState<Event[]>([]);
|
|
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
|
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
|
const [attendanceLogs, setAttendanceLogs] = useState<any[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
// Search and view states
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'attendance' | 'od' | 'certificates'>('roster');
|
|
|
|
// Audit modals
|
|
const [inspectCertReg, setInspectCertReg] = useState<Registration | null>(null);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
// OD Upload states
|
|
const [odFileBase64, setOdFileBase64] = useState<string | null>(null);
|
|
|
|
// Attendance checking state (maps registrationId -> dayLabel -> isPresent)
|
|
const [attendanceGrid, setAttendanceGrid] = useState<Record<string, Record<string, boolean>>>({});
|
|
|
|
const daysList = ["Day 1", "Day 2", "Day 3"]; // Standard slots
|
|
|
|
const [batches, setBatches] = useState<any[]>([]);
|
|
|
|
useEffect(() => {
|
|
fetchEvents();
|
|
fetchBatches();
|
|
}, []);
|
|
|
|
const fetchBatches = async () => {
|
|
try {
|
|
const res = await fetch(API_BASE_URL + '/api/batches');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setBatches(data);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (selectedEvent) {
|
|
fetchRegistrations(selectedEvent.id);
|
|
}
|
|
}, [selectedEvent]);
|
|
|
|
const fetchEvents = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await fetch(API_BASE_URL + '/api/events');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
// Faculty can manage their proposed events, Admins/HODs can manage all
|
|
let filtered = data.filter((e: Event) => e.status === 'APPROVED' || e.status === 'COMPLETED' || e.status === 'Event Ongoing');
|
|
if (user?.role === 'FACULTY') {
|
|
const userDepts = user.department ? user.department.split(',').map(d => d.trim().toLowerCase()) : [];
|
|
filtered = filtered.filter((e: Event) => userDepts.includes(e.department?.trim().toLowerCase()));
|
|
}
|
|
setEvents(filtered);
|
|
if (filtered.length > 0) {
|
|
setSelectedEvent(filtered[0]);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchRegistrations = async (eventId: number) => {
|
|
setIsLoading(true);
|
|
try {
|
|
const [regsRes, attRes] = await Promise.all([
|
|
fetch(API_BASE_URL + `/api/registrations?eventId=${eventId}`),
|
|
fetch(API_BASE_URL + `/api/attendance?eventId=${eventId}`)
|
|
]);
|
|
|
|
if (regsRes.ok && attRes.ok) {
|
|
const regsData = await regsRes.json();
|
|
const attData = await attRes.json();
|
|
|
|
setRegistrations(regsData);
|
|
setAttendanceLogs(attData);
|
|
|
|
// Pre-populate attendance grid from attendanceLogs
|
|
const grid: Record<string, Record<string, boolean>> = {};
|
|
regsData.forEach((r: Registration) => {
|
|
grid[r.id] = {};
|
|
daysList.forEach(day => {
|
|
const match = attData.find((a: any) => String(a.registrationId || a.registration_id) === String(r.id) && a.dayLabel === day);
|
|
grid[r.id][day] = match ? !!match.isPresent : false;
|
|
});
|
|
});
|
|
setAttendanceGrid(grid);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Toggle Payment Status Manually
|
|
const handleTogglePayment = async (reg: Registration) => {
|
|
setIsProcessing(true);
|
|
const newStatus = reg.paymentStatus === 'COMPLETED' ? 'PENDING' : 'COMPLETED';
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/registrations/${reg.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ paymentStatus: newStatus })
|
|
});
|
|
if (res.ok) {
|
|
await fetchRegistrations(selectedEvent!.id);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
// Dismiss Student from event
|
|
const handleDismissStudent = async (regId: string, studentName: string) => {
|
|
showConfirm(
|
|
"Dismiss Student",
|
|
`Are you sure you want to dismiss ${studentName} from this event? This will completely remove their registration.`,
|
|
async () => {
|
|
setIsProcessing(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/registrations/${regId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (res.ok) {
|
|
showAlert("Success", `${studentName} has been successfully dismissed.`, "success");
|
|
await fetchRegistrations(selectedEvent!.id);
|
|
} else {
|
|
showAlert("Error", "Failed to dismiss student.", "error");
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
showAlert("Error", "An error occurred while dismissing the student.", "error");
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
// Toggle Attendance Cell local state
|
|
const handleToggleAttendance = (regId: string, day: string) => {
|
|
setAttendanceGrid(prev => ({
|
|
...prev,
|
|
[regId]: {
|
|
...prev[regId],
|
|
[day]: !prev[regId][day]
|
|
}
|
|
}));
|
|
};
|
|
|
|
// Save Attendance Grid to Firestore
|
|
const handleSaveAttendance = async () => {
|
|
setIsProcessing(true);
|
|
try {
|
|
const payload: any[] = [];
|
|
Object.keys(attendanceGrid).forEach(regId => {
|
|
daysList.forEach(day => {
|
|
payload.push({
|
|
registrationId: regId,
|
|
eventId: selectedEvent!.id,
|
|
dayLabel: day,
|
|
batchLabel: "Slot 1",
|
|
isPresent: attendanceGrid[regId][day],
|
|
date: new Date().toLocaleDateString()
|
|
});
|
|
});
|
|
});
|
|
|
|
const res = await fetch(API_BASE_URL + '/api/attendance', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (res.ok) {
|
|
showAlert("Success", "Attendance records synchronized successfully!", "success");
|
|
await fetchRegistrations(selectedEvent!.id);
|
|
} else {
|
|
showAlert("Error", "Failed to save attendance.", "error");
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
// Handle OD PDF File selection
|
|
const handleODFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setOdFileBase64(reader.result as string);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
// Upload OD File to all registered students
|
|
const handleUploadODFile = async () => {
|
|
if (!odFileBase64) {
|
|
showAlert("Error", "Please select an On-Duty file first.", "error");
|
|
return;
|
|
}
|
|
setIsProcessing(true);
|
|
try {
|
|
// Loop over and update all registrations for this event
|
|
for (const reg of registrations) {
|
|
await fetch(`${API_BASE_URL}/api/registrations/${reg.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ odUrl: odFileBase64 })
|
|
});
|
|
}
|
|
showAlert("Success", "OD sheet broadcasted to all registered students successfully!", "success");
|
|
setOdFileBase64(null);
|
|
await fetchRegistrations(selectedEvent!.id);
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
// Approve or Reject screenshot proof
|
|
const handleAuditCertificate = async (regId: string, status: 'APPROVED' | 'NOT_SUBMITTED') => {
|
|
setIsProcessing(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/registrations/${regId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ certificationStatus: status })
|
|
});
|
|
|
|
if (res.ok) {
|
|
setInspectCertReg(null);
|
|
showAlert("Certificate Audited", status === 'APPROVED' ? "Certificate approved!" : "Certificate rejected.", status === 'APPROVED' ? "success" : "info");
|
|
await fetchRegistrations(selectedEvent!.id);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
} finally {
|
|
setIsProcessing(false);
|
|
}
|
|
};
|
|
|
|
const filteredRoster = registrations.filter(r =>
|
|
r.userName.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 (
|
|
<div className="space-y-8 animate-in fade-in duration-300">
|
|
{/* Header Info */}
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
|
<div>
|
|
<h2 className="text-3xl font-black text-text-dark tracking-tight">Student Engagement Hub</h2>
|
|
<p className="text-text-muted font-medium text-sm">Review registration rosters, log session attendance checklists, release OD files, and audit certificate proofs.</p>
|
|
</div>
|
|
|
|
{/* Event Selector Dropdown */}
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-xs font-black text-slate-400 uppercase tracking-widest">Selected Event:</label>
|
|
<select
|
|
value={selectedEvent?.id || ''}
|
|
onChange={(e) => {
|
|
const ev = events.find(ev => ev.id === Number(e.target.value));
|
|
if (ev) setSelectedEvent(ev);
|
|
}}
|
|
className="bg-white border border-slate-200 rounded-2xl py-3.5 px-5 text-xs font-black focus:outline-none focus:ring-2 focus:ring-brand-indigo/10 appearance-none cursor-pointer pr-10 shadow-sm"
|
|
>
|
|
{events.map(e => <option key={e.id} value={e.id}>{e.title}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedEvent ? (
|
|
<div className="space-y-8">
|
|
{/* Quick Metrics Panels */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
<div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm">
|
|
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-1">Passes Secured</span>
|
|
<p className="text-2xl font-black text-slate-900">{registrations.length}</p>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm">
|
|
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-1">Teamed Up</span>
|
|
<p className="text-2xl font-black text-slate-900">{registrations.filter(r => r.teamCode).length}</p>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm">
|
|
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-1">In Review Proofs</span>
|
|
<p className="text-2xl font-black text-amber-500">{registrations.filter(r => r.certificationStatus === 'PENDING_APPROVAL').length}</p>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-[2rem] border border-slate-100 shadow-sm">
|
|
<span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-1">Verified Certificates</span>
|
|
<p className="text-2xl font-black text-emerald-500">{registrations.filter(r => r.certificationStatus === 'APPROVED').length}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sub Navigation Tabs */}
|
|
<div className="flex bg-slate-100 p-1 rounded-2xl gap-1 max-w-md">
|
|
<button
|
|
onClick={() => setActiveSubTab('roster')}
|
|
className={cn("flex-1 py-3 rounded-xl text-[9px] font-black uppercase tracking-widest transition-all", activeSubTab === 'roster' ? "bg-white text-brand-indigo shadow-sm" : "text-text-muted hover:text-text-dark")}
|
|
>
|
|
Roster & Fee
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveSubTab('attendance')}
|
|
className={cn("flex-1 py-3 rounded-xl text-[9px] font-black uppercase tracking-widest transition-all", activeSubTab === 'attendance' ? "bg-white text-brand-indigo shadow-sm" : "text-text-muted hover:text-text-dark")}
|
|
>
|
|
Attendance
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveSubTab('od')}
|
|
className={cn("flex-1 py-3 rounded-xl text-[9px] font-black uppercase tracking-widest transition-all", activeSubTab === 'od' ? "bg-white text-brand-indigo shadow-sm" : "text-text-muted hover:text-text-dark")}
|
|
>
|
|
On-Duty Release
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveSubTab('certificates')}
|
|
className={cn("flex-1 py-3 rounded-xl text-[9px] font-black uppercase tracking-widest transition-all", activeSubTab === 'certificates' ? "bg-white text-brand-indigo shadow-sm" : "text-text-muted hover:text-text-dark")}
|
|
>
|
|
Audits
|
|
</button>
|
|
</div>
|
|
|
|
{/* Sub Tab Contents */}
|
|
<div className="bg-white rounded-[2.5rem] border border-slate-100 shadow-sm p-8">
|
|
|
|
{/* SUB TAB 1: ROSTER & MANUAL FEE */}
|
|
{activeSubTab === 'roster' && (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between gap-6">
|
|
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight">Registration Roster</h3>
|
|
|
|
{/* Search Roster */}
|
|
<div className="relative max-w-xs w-full">
|
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search name or regNo..."
|
|
className="w-full bg-slate-50 border border-slate-100 rounded-xl py-2.5 pl-10 pr-4 text-xs font-semibold focus:outline-none focus:bg-white focus:border-brand-indigo/20 transition-all"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs font-bold text-slate-500 uppercase tracking-widest">
|
|
<thead>
|
|
<tr className="border-b border-slate-100 text-slate-400">
|
|
<th className="py-4">Student Details</th>
|
|
<th className="py-4">Class</th>
|
|
<th className="py-4">College</th>
|
|
<th className="py-4">Alliance/Team</th>
|
|
<th className="py-4 text-center">Payment Status</th>
|
|
<th className="py-4 text-center">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-50">
|
|
{filteredRoster.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="py-8 text-center text-slate-300 italic font-bold">No registered students matched the search.</td>
|
|
</tr>
|
|
) : (
|
|
filteredRoster.map((reg) => (
|
|
<tr key={reg.id} className="hover:bg-slate-50/50">
|
|
<td className="py-4">
|
|
<p className="text-sm font-black text-brand-navy">{reg.userName}</p>
|
|
<p className="text-[10px] text-slate-400 mt-0.5">{reg.regNo} | {reg.phone}</p>
|
|
</td>
|
|
<td className="py-4 text-slate-600">
|
|
{reg.year} Year / SEC {reg.section}
|
|
{getStudentBatchName(reg) && (
|
|
<span className="ml-1.5 px-1.5 py-0.5 bg-brand-indigo/10 text-brand-indigo text-[8px] font-black rounded">
|
|
{getStudentBatchName(reg)}
|
|
</span>
|
|
)}
|
|
<p className="text-[9px] text-brand-indigo mt-0.5">{reg.dept}</p>
|
|
</td>
|
|
<td className="py-4 text-slate-600 truncate max-w-[150px]">{reg.college}</td>
|
|
<td className="py-4">
|
|
{reg.teamCode ? (
|
|
<div>
|
|
<p className="text-slate-800 font-black text-[11px]">{reg.teamName}</p>
|
|
<p className="text-[9px] text-slate-400">Code: {reg.teamCode}</p>
|
|
</div>
|
|
) : (
|
|
<span className="text-slate-300 font-normal">Individual</span>
|
|
)}
|
|
</td>
|
|
<td className="py-4 text-center">
|
|
<button
|
|
onClick={() => handleTogglePayment(reg)}
|
|
disabled={isProcessing}
|
|
className={cn(
|
|
"px-3 py-1.5 rounded-lg text-[9px] font-black tracking-widest border transition-all",
|
|
reg.paymentStatus === 'COMPLETED'
|
|
? "bg-emerald-50 border-emerald-100 text-emerald-600 hover:bg-emerald-100"
|
|
: "bg-red-50 border-red-100 text-red-600 hover:bg-red-100 animate-pulse"
|
|
)}
|
|
>
|
|
{reg.paymentStatus}
|
|
</button>
|
|
</td>
|
|
<td className="py-4 text-center">
|
|
<button
|
|
onClick={() => handleDismissStudent(reg.id, reg.userName)}
|
|
disabled={isProcessing}
|
|
className="px-3 py-1.5 bg-rose-50 border border-rose-100 text-rose-600 rounded-lg text-[9px] font-black tracking-widest uppercase hover:bg-rose-100 hover:text-rose-700 transition-all"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* SUB TAB 2: SESSION ATTENDANCE GRID */}
|
|
{activeSubTab === 'attendance' && (
|
|
<div className="space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight">Mark Slot Presence</h3>
|
|
<button
|
|
onClick={handleSaveAttendance}
|
|
disabled={isProcessing}
|
|
className="px-6 py-2.5 bg-brand-navy text-white rounded-xl font-black text-[10px] uppercase tracking-widest hover:scale-[1.02] transition-all shadow-md"
|
|
>
|
|
{isProcessing ? 'Saving...' : 'Save Attendance'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs font-bold text-slate-500 uppercase tracking-widest">
|
|
<thead>
|
|
<tr className="border-b border-slate-100 text-slate-400">
|
|
<th className="py-4">Student</th>
|
|
<th className="py-4">Register No</th>
|
|
{daysList.map(day => <th key={day} className="py-4 text-center">{day}</th>)}
|
|
<th className="py-4 text-center">
|
|
<div className="flex flex-col items-center gap-1">
|
|
<span className="text-[10px]">Mark All</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
registrations.length > 0 &&
|
|
registrations.every(reg =>
|
|
daysList.every(day => attendanceGrid[reg.id]?.[day] || false)
|
|
)
|
|
}
|
|
onChange={(e) => {
|
|
const checked = e.target.checked;
|
|
setAttendanceGrid(prev => {
|
|
const next = { ...prev };
|
|
registrations.forEach(reg => {
|
|
next[reg.id] = next[reg.id] || {};
|
|
daysList.forEach(day => {
|
|
next[reg.id][day] = checked;
|
|
});
|
|
});
|
|
return next;
|
|
});
|
|
}}
|
|
className="w-4 h-4 rounded border-slate-300 text-brand-indigo focus:ring-brand-indigo cursor-pointer"
|
|
title="Toggle all days for all students"
|
|
/>
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-50">
|
|
{registrations.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="py-8 text-center text-slate-300 italic font-bold">No students registered yet.</td>
|
|
</tr>
|
|
) : (
|
|
registrations.map((reg) => (
|
|
<tr key={reg.id} className="hover:bg-slate-50/50">
|
|
<td className="py-4 font-black text-brand-navy">
|
|
{reg.userName}
|
|
{getStudentBatchName(reg) && (
|
|
<span className="ml-1.5 px-1 py-0.5 bg-brand-indigo/10 text-brand-indigo text-[7px] font-black rounded block w-max mt-0.5">
|
|
{getStudentBatchName(reg)}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-4 text-slate-400">{reg.regNo}</td>
|
|
{daysList.map(day => (
|
|
<td key={day} className="py-4 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={attendanceGrid[reg.id]?.[day] || false}
|
|
onChange={() => handleToggleAttendance(reg.id, day)}
|
|
className="w-4.5 h-4.5 rounded border-slate-300 text-brand-indigo focus:ring-brand-indigo cursor-pointer"
|
|
/>
|
|
</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>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* SUB TAB 3: ON-DUTY UPLOADER */}
|
|
{activeSubTab === 'od' && (
|
|
<div className="space-y-6 max-w-xl">
|
|
<div>
|
|
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight">On-Duty Official Release</h3>
|
|
<p className="text-xs text-slate-400 font-semibold mt-1">Upload the unified signed On-Duty letter for this event. It will be released to all registered students automatically.</p>
|
|
</div>
|
|
|
|
<div className="p-8 border-2 border-dashed border-slate-200 rounded-[2rem] text-center space-y-4">
|
|
<Upload className="w-10 h-10 text-slate-300 mx-auto mb-2" />
|
|
<div className="relative">
|
|
<input
|
|
type="file"
|
|
accept="application/pdf,image/*"
|
|
onChange={handleODFileChange}
|
|
className="hidden"
|
|
id="od-sheet-uploader"
|
|
/>
|
|
<label
|
|
htmlFor="od-sheet-uploader"
|
|
className="px-6 py-3 bg-slate-50 border border-slate-200 text-slate-600 rounded-xl font-black text-[10px] uppercase tracking-widest cursor-pointer hover:bg-slate-100 transition-all inline-block"
|
|
>
|
|
{odFileBase64 ? 'OD Document Loaded' : 'Choose OD Letter'}
|
|
</label>
|
|
</div>
|
|
{odFileBase64 && (
|
|
<p className="text-[10px] font-black text-emerald-500 uppercase tracking-wider">File captured ready to broadcast</p>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleUploadODFile}
|
|
disabled={isProcessing || !odFileBase64}
|
|
className="w-full py-4 bg-brand-indigo text-white rounded-xl font-black text-[10px] uppercase tracking-widest shadow-md shadow-brand-indigo/10 disabled:opacity-50"
|
|
>
|
|
{isProcessing ? 'Releasing ODs...' : 'Broadcast OD to All Students'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* SUB TAB 4: CERTIFICATE AUDITOR */}
|
|
{activeSubTab === 'certificates' && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight">Audit Student Proofs</h3>
|
|
<p className="text-xs text-slate-400 font-semibold mt-1">Inspect uploaded completion screenshot proofs and approve them to unlock certificate downloads.</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{registrations.filter(r => r.certificationStatus === 'PENDING_APPROVAL').length === 0 ? (
|
|
<div className="col-span-2 py-16 bg-slate-50 rounded-3xl border border-dashed border-slate-200 text-center text-slate-400 font-bold uppercase tracking-wider text-xs">
|
|
<FileCheck className="w-12 h-12 text-slate-300 mx-auto mb-4 opacity-50" />
|
|
All certificate proofs have been successfully audited!
|
|
</div>
|
|
) : (
|
|
registrations.filter(r => r.certificationStatus === 'PENDING_APPROVAL').map((reg) => (
|
|
<div
|
|
key={reg.id}
|
|
className="bg-slate-50 p-6 rounded-3xl border border-slate-100 flex items-center justify-between hover:border-slate-200 transition-all"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-black text-slate-800 uppercase">{reg.userName}</p>
|
|
<p className="text-[9px] text-slate-400 font-bold uppercase tracking-wider mt-0.5">{reg.regNo} | {reg.dept}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setInspectCertReg(reg)}
|
|
className="px-4 py-2.5 bg-brand-glow text-brand-indigo rounded-xl font-black text-[10px] uppercase tracking-widest flex items-center gap-1.5 shadow-sm border border-brand-indigo/5"
|
|
>
|
|
<Eye className="w-4 h-4" /> Inspect Proof
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-[2.5rem] p-20 text-center border border-slate-100 shadow-sm">
|
|
<Users className="w-16 h-16 text-slate-300 mx-auto mb-6 opacity-20" />
|
|
<h3 className="text-xl font-black text-slate-800 mb-2">No Live Events Available</h3>
|
|
<p className="text-slate-400 font-medium text-sm">Create/approve events in your main dashboard first to access student hub audits.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* INSPECT SCREENSHOT MODAL */}
|
|
{inspectCertReg && (
|
|
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-6 bg-slate-900/60 backdrop-blur-sm">
|
|
<div className="bg-white rounded-[3rem] w-full max-w-md overflow-hidden shadow-2xl relative border border-slate-100 flex flex-col justify-between max-h-[85vh]">
|
|
<div className="p-6 bg-slate-900 text-white flex justify-between items-center">
|
|
<div>
|
|
<h3 className="text-lg font-black uppercase leading-none">Inspect Screenshot</h3>
|
|
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-1">{inspectCertReg.userName} | {inspectCertReg.regNo}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setInspectCertReg(null)}
|
|
className="p-1.5 hover:bg-white/10 rounded-lg text-white"
|
|
>
|
|
<XCircle className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Proof Body */}
|
|
<div className="p-8 overflow-y-auto max-h-[50vh] flex flex-col items-center">
|
|
{inspectCertReg.certificationUrl ? (
|
|
<img
|
|
src={inspectCertReg.certificationUrl}
|
|
alt="Completion proof"
|
|
className="w-full object-contain rounded-2xl border border-slate-200 shadow-sm"
|
|
/>
|
|
) : (
|
|
<p className="text-xs text-slate-400 font-bold uppercase tracking-widest">No proof uploaded</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions Bar */}
|
|
<div className="p-6 bg-slate-50 border-t border-slate-100 flex gap-4">
|
|
<button
|
|
onClick={() => handleAuditCertificate(inspectCertReg.id, 'NOT_SUBMITTED')}
|
|
disabled={isProcessing}
|
|
className="flex-1 py-3.5 bg-red-50 text-red-600 rounded-xl font-black text-[10px] uppercase tracking-widest border border-red-100 hover:bg-red-100"
|
|
>
|
|
Reject Proof
|
|
</button>
|
|
<button
|
|
onClick={() => handleAuditCertificate(inspectCertReg.id, 'APPROVED')}
|
|
disabled={isProcessing}
|
|
className="flex-1 py-3.5 bg-emerald-500 text-white rounded-xl font-black text-[10px] uppercase tracking-widest shadow-md shadow-emerald-100 hover:bg-emerald-600"
|
|
>
|
|
Approve & Unlock Cert
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|