Spaces:
Running
Running
Download index.html from syuanteng315/debug: direct link, hf CLI and curl.
- Browser
- Download file 69 kB
-
https://huggingface.co/spaces/syuanteng315/debug/resolve/main/index.html
- Command line
-
hf download hf://spaces/syuanteng315/debug/index.html
-
curl -L -o index.html https://huggingface.co/spaces/syuanteng315/debug/resolve/main/index.html
69 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <!-- Required for OAuth popups to keep a usable opener relationship on Chromium. --> | |
| <meta http-equiv="Cross-Origin-Opener-Policy" content="same-origin-allow-popups" /> | |
| <title>Prefect Duty Manager</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js"></script> | |
| <script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js"></script> | |
| <script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-auth-compat.js"></script> | |
| <script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-firestore-compat.js"></script> | |
| <script src="https://www.gstatic.com/firebasejs/10.14.1/firebase-analytics-compat.js"></script> | |
| <script src="https://unpkg.com/lucide@0.460.0/dist/umd/lucide.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> | |
| <style> | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .animate-fadeIn { animation: fadeIn 0.4s ease-out forwards; } | |
| </style> | |
| </head> | |
| <body class="bg-slate-50"> | |
| <div id="root"></div> | |
| <script type="text/babel"> | |
| const { useState, useEffect, useRef, useCallback } = React; | |
| // ========== Firebase Config ========== | |
| const firebaseConfig = { | |
| apiKey: "AIzaSyDdVymn14ouymkCfOxoZpYlLLYDaB-KVYI", | |
| authDomain: "hello-c0b3d.firebaseapp.com", | |
| projectId: "hello-c0b3d", | |
| storageBucket: "hello-c0b3d.firebasestorage.app", | |
| messagingSenderId: "242209971260", | |
| appId: "1:242209971260:web:7e9f37419cf247f907b739", | |
| measurementId: "G-D35148HGKM" | |
| }; | |
| if (!firebase.apps.length) { | |
| firebase.initializeApp(firebaseConfig); | |
| } | |
| // Basic Firebase configuration diagnostic. | |
| if (!firebaseConfig.apiKey || !firebaseConfig.authDomain || !firebaseConfig.projectId || !firebaseConfig.appId) { | |
| console.error("Firebase configuration is incomplete.", firebaseConfig); | |
| } | |
| try { | |
| if (typeof window !== "undefined") firebase.analytics(); | |
| } catch (e) { | |
| console.warn("Firebase Analytics could not be initialized."); | |
| } | |
| const auth = firebase.auth(); | |
| const db = firebase.firestore(); | |
| // Helpful diagnostics when deployed on Hugging Face or another static host. | |
| // These do not expose credentials and make deployment problems visible in | |
| // the browser console instead of silently failing after Google selection. | |
| console.info("Firebase Auth host:", window.location.hostname); | |
| console.info("Firebase authDomain:", firebaseConfig.authDomain); | |
| if (window.location.protocol !== "https:" && window.location.hostname !== "localhost") { | |
| console.warn("Firebase Google sign-in should be served over HTTPS."); | |
| } | |
| const googleProvider = new firebase.auth.GoogleAuthProvider(); | |
| googleProvider.setCustomParameters({ prompt: "select_account" }); | |
| googleProvider.addScope("email"); | |
| googleProvider.addScope("profile"); | |
| // ========== Constants ========== | |
| const ROLES = { | |
| PREFECT: "Prefect", | |
| HEAD_PREFECT: "Head Prefect", | |
| TEACHER: "Discipline Teacher" | |
| }; | |
| const FORM_LEVELS = { | |
| UPPER: "Upper Form", | |
| LOWER: "Lower Form", | |
| NA: "N/A" | |
| }; | |
| const SESSIONS = [ | |
| { id: "MORNING", name: "Morning Duty", time: "6:30 AM - 7:05 AM" }, | |
| { id: "RECESS_LOWER", name: "Lower Form Recess", time: "9:25 AM - 10:00 AM" }, | |
| { id: "RECESS_UPPER", name: "Upper Form Recess", time: "9:55 AM - 10:30 AM" } | |
| ]; | |
| const LOCATIONS = [ | |
| { id: "gate", name: "Main Gate", x: 20, y: 80 }, | |
| { id: "canteen", name: "Canteen", x: 80, y: 20 }, | |
| { id: "blockA", name: "Block A", x: 20, y: 30 }, | |
| { id: "blockB", name: "Block B", x: 50, y: 50 }, | |
| { id: "library", name: "Library", x: 80, y: 70 } | |
| ]; | |
| const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; | |
| function Icon({ name, size = 20, className = "" }) { | |
| const ref = useRef(null); | |
| useEffect(() => { | |
| if (ref.current && window.lucide) { | |
| ref.current.innerHTML = ""; | |
| const i = document.createElement("i"); | |
| i.setAttribute("data-lucide", name); | |
| i.style.width = size + "px"; | |
| i.style.height = size + "px"; | |
| ref.current.appendChild(i); | |
| window.lucide.createIcons({ nodes: [i] }); | |
| } | |
| }, [name, size]); | |
| return ( | |
| <span ref={ref} className={"inline-flex items-center justify-center " + className} style={{ width: size, height: size }} /> | |
| ); | |
| } | |
| function Toast({ message, type, onClose }) { | |
| useEffect(() => { | |
| const t = setTimeout(onClose, 3500); | |
| return () => clearTimeout(t); | |
| }, []); | |
| const bg = { success: "bg-green-500", error: "bg-red-500", info: "bg-blue-500" }; | |
| return ( | |
| <div className={"fixed bottom-4 right-4 " + (bg[type] || bg.info) + " text-white px-6 py-3 rounded-lg shadow-lg flex items-center gap-2 z-50"}> | |
| {type === "success" && <Icon name="check-circle" size={20} />} | |
| {type === "error" && <Icon name="x-circle" size={20} />} | |
| {type === "info" && <Icon name="alert-circle" size={20} />} | |
| <span>{message}</span> | |
| </div> | |
| ); | |
| } | |
| function SchoolMap({ activeLocationId, onSelectLocation }) { | |
| return ( | |
| <div className="relative w-full max-w-2xl mx-auto h-96 bg-slate-100 rounded-xl border-2 border-slate-200 overflow-hidden shadow-inner"> | |
| <div className="absolute inset-0 opacity-20" style={{ backgroundImage: "url('https://www.transparenttextures.com/patterns/cubes.png')" }} /> | |
| <div className="absolute top-1/4 left-1/4 right-1/4 h-12 bg-slate-300 rounded-lg" /> | |
| <div className="absolute top-1/4 bottom-1/4 left-1/2 w-12 bg-slate-300 rounded-lg -translate-x-1/2" /> | |
| {LOCATIONS.map((loc) => { | |
| const isActive = activeLocationId === loc.id; | |
| return ( | |
| <div | |
| key={loc.id} | |
| onClick={() => onSelectLocation && onSelectLocation(loc)} | |
| className={"absolute flex flex-col items-center justify-center cursor-pointer transition-all " + (onSelectLocation ? "hover:scale-110" : "")} | |
| style={{ left: loc.x + "%", top: loc.y + "%", transform: "translate(-50%, -50%)" }} | |
| > | |
| <div className={"relative flex items-center justify-center w-16 h-16 rounded-2xl shadow-md " + (isActive ? "bg-indigo-600 border-4 border-indigo-200" : "bg-white border-2 border-slate-200")}> | |
| {isActive && ( | |
| <> | |
| <div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full animate-ping" /> | |
| <div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full" /> | |
| </> | |
| )} | |
| <Icon name="map-pin" size={24} className={isActive ? "text-white" : "text-slate-600"} /> | |
| </div> | |
| <span className={"mt-2 font-bold text-sm px-2 py-1 rounded bg-white shadow-sm " + (isActive ? "text-indigo-700" : "text-slate-600")}> | |
| {loc.name} | |
| </span> | |
| </div> | |
| ); | |
| })} | |
| <div className="absolute bottom-2 left-2 text-xs font-semibold text-slate-400 bg-white px-2 py-1 rounded shadow-sm">Campus Layout</div> | |
| </div> | |
| ); | |
| } | |
| async function commitInBatches(ops, batchSize = 400) { | |
| for (let i = 0; i < ops.length; i += batchSize) { | |
| const batch = db.batch(); | |
| ops.slice(i, i + batchSize).forEach((fn) => fn(batch)); | |
| await batch.commit(); | |
| } | |
| } | |
| function App() { | |
| const [user, setUser] = useState(null); | |
| const [profile, setProfile] = useState(null); | |
| const [loading, setLoading] = useState(true); | |
| const [authLoading, setAuthLoading] = useState(false); | |
| const [toast, setToast] = useState(null); | |
| const [permissionError, setPermissionError] = useState(false); | |
| const [users, setUsers] = useState([]); | |
| const [shifts, setShifts] = useState([]); | |
| const [swapRequests, setSwapRequests] = useState([]); | |
| const [activeTab, setActiveTab] = useState("timetable"); | |
| const [selectedTeacherLocation, setSelectedTeacherLocation] = useState(null); | |
| const [activeTeacherSession, setActiveTeacherSession] = useState("MORNING"); | |
| const [selectedReportDay, setSelectedReportDay] = useState(() => { const d = new Date().getDay(); const idx = d === 0 || d === 6 ? 2 : d - 1; return DAYS[idx] || DAYS[2]; }); | |
| const [isGenerating, setIsGenerating] = useState(false); | |
| const [isEmailing, setIsEmailing] = useState(false); | |
| const [hideFormLevel, setHideFormLevel] = useState(false); | |
| const fileInputRef = useRef(null); | |
| const showToast = useCallback((message, type = "success") => { | |
| setToast({ message, type }); | |
| }, []); | |
| // Auth: persistence + redirect result + listeners | |
| useEffect(() => { | |
| let unsubProfile = () => {}; | |
| let unsubUsers = () => {}; | |
| let unsubShifts = () => {}; | |
| let unsubSwaps = () => {}; | |
| let unsubAuth = () => {}; | |
| let cancelled = false; | |
| (async () => { | |
| try { | |
| await auth.setPersistence(firebase.auth.Auth.Persistence.LOCAL); | |
| } catch (e) { | |
| console.warn("setPersistence failed", e); | |
| } | |
| // Must resolve redirect result before relying on session (HF / cross-site) | |
| try { | |
| const result = await auth.getRedirectResult(); | |
| if (result && result.user && !cancelled) { | |
| showToast("Signed in as " + result.user.email, "success"); | |
| } | |
| } catch (error) { | |
| console.error("Redirect result error:", error); | |
| if (!cancelled) { | |
| showAuthError(error); | |
| setAuthLoading(false); | |
| } | |
| } | |
| if (cancelled) return; | |
| unsubAuth = auth.onAuthStateChanged((currentUser) => { | |
| unsubProfile(); | |
| unsubUsers(); | |
| unsubShifts(); | |
| unsubSwaps(); | |
| setUser(currentUser); | |
| if (currentUser) { | |
| const handleFirebaseError = (err) => { | |
| console.error(err); | |
| if (err.code === "permission-denied" || (err.message && err.message.includes("permission"))) { | |
| setPermissionError(true); | |
| setLoading(false); | |
| } | |
| }; | |
| unsubProfile = db.collection("users").doc(currentUser.uid).onSnapshot( | |
| (docSnap) => { | |
| setProfile(docSnap.exists ? docSnap.data() : null); | |
| setLoading(false); | |
| }, | |
| handleFirebaseError | |
| ); | |
| unsubUsers = db.collection("users").onSnapshot( | |
| (snap) => setUsers(snap.docs.map((d) => ({ id: d.id, ...d.data() }))), | |
| handleFirebaseError | |
| ); | |
| unsubShifts = db.collection("shifts").onSnapshot( | |
| (snap) => setShifts(snap.docs.map((d) => ({ id: d.id, ...d.data() }))), | |
| handleFirebaseError | |
| ); | |
| unsubSwaps = db.collection("swapRequests").onSnapshot( | |
| (snap) => setSwapRequests(snap.docs.map((d) => ({ id: d.id, ...d.data() }))), | |
| handleFirebaseError | |
| ); | |
| } else { | |
| setProfile(null); | |
| setLoading(false); | |
| } | |
| }); | |
| })(); | |
| return () => { | |
| cancelled = true; | |
| unsubAuth(); | |
| unsubProfile(); | |
| unsubUsers(); | |
| unsubShifts(); | |
| unsubSwaps(); | |
| }; | |
| }, [showToast]); | |
| // ========== Google Sign-In (HF/Chrome recovery) ========== | |
| // The popup is still the primary method. If Chrome closes the OAuth | |
| // helper before Firebase finalizes the credential, we use redirect as | |
| // the recovery path for ONLY that specific error. | |
| const handleGoogleLogin = async () => { | |
| if (authLoading) return; | |
| setAuthLoading(true); | |
| setPermissionError(false); | |
| const waitForAuthState = (timeoutMs = 5000) => new Promise((resolve) => { | |
| let done = false; | |
| let timer = null; | |
| let unsubscribe = () => {}; | |
| const finish = (value) => { | |
| if (done) return; | |
| done = true; | |
| if (timer) clearTimeout(timer); | |
| try { unsubscribe(); } catch (_) {} | |
| resolve(value || null); | |
| }; | |
| unsubscribe = auth.onAuthStateChanged((currentUser) => { | |
| if (currentUser) finish(currentUser); | |
| }); | |
| if (auth.currentUser) { | |
| finish(auth.currentUser); | |
| return; | |
| } | |
| timer = setTimeout(() => finish(null), timeoutMs); | |
| }); | |
| try { | |
| const result = await auth.signInWithPopup(googleProvider); | |
| const signedInUser = | |
| (result && result.user) || | |
| auth.currentUser || | |
| await waitForAuthState(3000); | |
| if (signedInUser) { | |
| showToast("Signed in as " + (signedInUser.email || "Google account"), "success"); | |
| setAuthLoading(false); | |
| return; | |
| } | |
| throw { | |
| code: "auth/no-user-after-popup", | |
| message: "Google completed, but Firebase did not return a signed-in user." | |
| }; | |
| } catch (error) { | |
| console.error("Google popup sign-in failed:", error); | |
| // IMPORTANT: popup-closed-by-user can mean the OAuth helper was | |
| // closed by Chrome before Firebase finished its final handshake. | |
| // In this exact case, use the top-level redirect recovery flow. | |
| if (error && error.code === "auth/popup-closed-by-user") { | |
| const recoveredUser = await waitForAuthState(2500); | |
| if (recoveredUser) { | |
| showToast("Signed in as " + (recoveredUser.email || "Google account"), "success"); | |
| setAuthLoading(false); | |
| return; | |
| } | |
| try { | |
| showToast("Google account selected. Completing secure sign-in…", "info"); | |
| await auth.signInWithRedirect(googleProvider); | |
| return; // Browser navigates away. | |
| } catch (redirectError) { | |
| console.error("Google redirect recovery failed:", redirectError); | |
| showAuthError(redirectError); | |
| setAuthLoading(false); | |
| return; | |
| } | |
| } | |
| showAuthError(error); | |
| setAuthLoading(false); | |
| } | |
| }; | |
| const showAuthError = (error) => { | |
| console.error("Authentication error:", error); | |
| const code = error && error.code ? error.code : ""; | |
| let msg = "Google sign-in failed."; | |
| if (code === "auth/unauthorized-domain") { | |
| msg = | |
| "Firebase rejected this website domain (" + | |
| window.location.hostname + | |
| "). Add this exact domain in Firebase → Authentication → Settings → Authorized domains."; | |
| setPermissionError(true); | |
| } else if (code === "auth/operation-not-allowed") { | |
| msg = | |
| "Google Sign-In is disabled. Enable Google under Firebase → Authentication → Sign-in method."; | |
| setPermissionError(true); | |
| } else if (code === "auth/invalid-api-key") { | |
| msg = | |
| "The Firebase API key is invalid. Check the Firebase configuration."; | |
| setPermissionError(true); | |
| } else if (code === "auth/app-not-authorized") { | |
| msg = | |
| "This app is not authorized for the current Firebase project."; | |
| setPermissionError(true); | |
| } else if (code === "auth/popup-blocked") { | |
| msg = | |
| "Chrome blocked the Google login popup. Allow popups for this site and try again."; | |
| } else if (code === "auth/popup-closed-by-user") { | |
| msg = | |
| "Google account selection finished, but Firebase did not receive the login. " + | |
| "This can happen when Chrome blocks popup communication. Please reload the page and try again."; | |
| } else if (code === "auth/cancelled-popup-request") { | |
| msg = | |
| "Another Google login popup is already running. Wait a moment and try again."; | |
| } else if (code === "auth/operation-not-supported-in-this-environment") { | |
| msg = | |
| "Google popup login is not supported in this browser environment."; | |
| } else if (code === "auth/network-request-failed") { | |
| msg = | |
| "Network error. Check your internet connection and try again."; | |
| } else if (code === "auth/account-exists-with-different-credential") { | |
| msg = | |
| "This email already exists with another sign-in method."; | |
| } else if (code === "auth/no-user-after-popup") { | |
| msg = | |
| "Google authentication completed, but Firebase did not return the user. " + | |
| "Please reload the page and try again."; | |
| } else if (error && error.message) { | |
| msg = error.message; | |
| } | |
| showToast(msg, "error"); | |
| }; | |
| const handleLogout = async () => { | |
| try { | |
| await auth.signOut(); | |
| setProfile(null); | |
| setActiveTab("timetable"); | |
| showToast("Signed out.", "info"); | |
| } catch (error) { | |
| console.error("Logout error:", error); | |
| } | |
| }; | |
| const handleRegister = async (e) => { | |
| e.preventDefault(); | |
| if (!user) return; | |
| const name = (e.target.name.value || "").trim() || user.displayName || ""; | |
| const role = e.target.role.value; | |
| const formLevel = role === ROLES.TEACHER ? FORM_LEVELS.NA : (e.target.formLevel ? e.target.formLevel.value : FORM_LEVELS.LOWER); | |
| if (!name) { | |
| showToast("Please enter your name.", "error"); | |
| return; | |
| } | |
| try { | |
| await db.collection("users").doc(user.uid).set({ | |
| name, | |
| email: user.email || "", | |
| photoURL: user.photoURL || "", | |
| role, | |
| formLevel, | |
| attendanceCount: 0, | |
| skipCount: 0, | |
| createdAt: new Date().toISOString() | |
| }); | |
| showToast("Welcome, " + name + "!"); | |
| } catch (error) { | |
| console.error("Registration error:", error); | |
| if (error.code === "permission-denied" || (error.message && error.message.includes("permission"))) { | |
| setPermissionError(true); | |
| } | |
| showToast("Failed to save profile. " + error.message, "error"); | |
| } | |
| }; | |
| const handleCsvImport = (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = async (event) => { | |
| try { | |
| const text = event.target.result; | |
| const rows = text.split(/\r?\n/).map((r) => r.trim()).filter(Boolean); | |
| const ops = []; | |
| let imported = 0; | |
| const seen = new Set(); | |
| rows.forEach((row, index) => { | |
| if (index === 0 && row.toLowerCase().includes("name")) return; | |
| const columns = row.split(","); | |
| const name = columns[0] ? columns[0].trim() : ""; | |
| const levelStr = columns[1] ? columns[1].trim().toLowerCase() : ""; | |
| if (!name || seen.has(name.toLowerCase())) return; | |
| seen.add(name.toLowerCase()); | |
| const level = levelStr.includes("upper") ? FORM_LEVELS.UPPER : FORM_LEVELS.LOWER; | |
| const newRef = db.collection("users").doc(); | |
| ops.push((batch) => { | |
| batch.set(newRef, { | |
| name, | |
| email: "", | |
| role: ROLES.PREFECT, | |
| formLevel: level, | |
| attendanceCount: 0, | |
| skipCount: 0, | |
| createdAt: new Date().toISOString(), | |
| isImported: true | |
| }); | |
| }); | |
| imported++; | |
| }); | |
| if (ops.length === 0) { | |
| showToast("No valid prefects found in CSV.", "error"); | |
| return; | |
| } | |
| await commitInBatches(ops); | |
| showToast("Successfully imported " + imported + " prefects!", "success"); | |
| if (fileInputRef.current) fileInputRef.current.value = ""; | |
| } catch (error) { | |
| console.error("CSV Import error:", error); | |
| showToast("Failed to parse or import CSV file.", "error"); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| }; | |
| const buildTimetablePdf = (scheduleShifts, generatedAt = new Date()) => { | |
| const jsPDF = window.jspdf && window.jspdf.jsPDF; | |
| if (!jsPDF) throw new Error("PDF library did not load. Please refresh the page and try again."); | |
| const safeShifts = Array.isArray(scheduleShifts) ? scheduleShifts : []; | |
| const fileName = "Prefect_Duty_Timetable_" + generatedAt.toISOString().slice(0, 10) + ".pdf"; | |
| const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" }); | |
| doc.setFont("helvetica", "bold"); | |
| doc.setFontSize(18); | |
| doc.text("Prefect Duty Timetable", 14, 16); | |
| doc.setFont("helvetica", "normal"); | |
| doc.setFontSize(9); | |
| doc.text("Generated by Prefect Duty Manager", 14, 22); | |
| doc.text("Generated: " + generatedAt.toLocaleString(), 14, 27); | |
| const headers = ["Day", "Session", "Location", "Prefect", "Status"]; | |
| const rows = safeShifts | |
| .map((s) => [s.day || "", s.sessionName || s.sessionId || "", s.locationName || s.locationId || "", s.userName || "Unassigned", s.status || "pending"]) | |
| .sort((a, b) => (String(a[0]) + String(a[1]) + String(a[2])).localeCompare(String(b[0]) + String(b[1]) + String(b[2]))); | |
| const colX = [14, 43, 92, 160, 232]; | |
| const rowH = 7; | |
| let y = 35; | |
| const drawHeader = () => { | |
| doc.setFont("helvetica", "bold"); | |
| doc.setFontSize(8); | |
| headers.forEach((h, i) => doc.text(h, colX[i], y)); | |
| doc.line(14, y + 2, 272, y + 2); | |
| y += rowH; | |
| doc.setFont("helvetica", "normal"); | |
| }; | |
| drawHeader(); | |
| rows.forEach((row) => { | |
| if (y > 190) { | |
| doc.addPage(); | |
| y = 15; | |
| drawHeader(); | |
| } | |
| doc.setFontSize(7); | |
| row.forEach((value, i) => { | |
| let text = String(value); | |
| const maxChars = i === 0 ? 16 : i === 1 ? 27 : i === 2 ? 34 : i === 3 ? 34 : 12; | |
| if (text.length > maxChars) text = text.slice(0, maxChars - 1) + "…"; | |
| doc.text(text, colX[i], y); | |
| }); | |
| doc.setDrawColor(220); | |
| doc.line(14, y + 2, 272, y + 2); | |
| doc.setDrawColor(0); | |
| y += rowH; | |
| }); | |
| if (rows.length === 0) { | |
| doc.setFontSize(10); | |
| doc.text("No duty timetable records found.", 14, y + 8); | |
| } | |
| doc.save(fileName); | |
| return fileName; | |
| }; | |
| const generateTimetable = async () => { | |
| if (!profile || profile.role !== ROLES.HEAD_PREFECT) return; | |
| setIsGenerating(true); | |
| try { | |
| const deleteOps = []; | |
| shifts.forEach((s) => deleteOps.push((b) => b.delete(db.collection("shifts").doc(s.id)))); | |
| swapRequests.forEach((r) => deleteOps.push((b) => b.delete(db.collection("swapRequests").doc(r.id)))); | |
| if (deleteOps.length) await commitInBatches(deleteOps); | |
| const prefects = users.filter((u) => u.role === ROLES.PREFECT || u.role === ROLES.HEAD_PREFECT); | |
| if (prefects.length === 0) { | |
| showToast("No prefects registered to assign!", "error"); | |
| return; | |
| } | |
| const assignmentCounts = {}; | |
| prefects.forEach((p) => { assignmentCounts[p.id] = 0; }); | |
| const createOps = []; | |
| const generatedShifts = []; | |
| let unassignedSlots = 0; | |
| DAYS.forEach((day) => { | |
| const usedThisDay = new Set(); | |
| SESSIONS.forEach((session) => { | |
| LOCATIONS.forEach((loc) => { | |
| let eligible = prefects; | |
| if (session.id === "RECESS_LOWER") { | |
| eligible = prefects.filter((p) => p.formLevel === FORM_LEVELS.UPPER || p.role === ROLES.HEAD_PREFECT); | |
| } else if (session.id === "RECESS_UPPER") { | |
| eligible = prefects.filter((p) => p.formLevel === FORM_LEVELS.LOWER || p.role === ROLES.HEAD_PREFECT); | |
| } | |
| const available = eligible.filter((p) => !usedThisDay.has(p.id)); | |
| if (available.length === 0) { | |
| unassignedSlots++; | |
| return; | |
| } | |
| const sorted = [...available].sort((a, b) => assignmentCounts[a.id] - assignmentCounts[b.id]); | |
| const lowest = assignmentCounts[sorted[0].id]; | |
| const candidates = sorted.filter((p) => assignmentCounts[p.id] === lowest); | |
| const chosen = candidates[Math.floor(Math.random() * candidates.length)]; | |
| assignmentCounts[chosen.id]++; | |
| usedThisDay.add(chosen.id); | |
| const newRef = db.collection("shifts").doc(); | |
| const shiftData = { | |
| id: newRef.id, | |
| day, | |
| sessionId: session.id, | |
| sessionName: session.name, | |
| locationId: loc.id, | |
| locationName: loc.name, | |
| userId: chosen.id, | |
| userName: chosen.name, | |
| status: "pending", | |
| swapStatus: "none" | |
| }; | |
| generatedShifts.push(shiftData); | |
| createOps.push((batch) => { | |
| const { id, ...data } = shiftData; | |
| batch.set(newRef, data); | |
| }); | |
| }); | |
| }); | |
| }); | |
| if (createOps.length === 0) { | |
| showToast("Not enough prefects to fill any slots.", "error"); | |
| return; | |
| } | |
| await commitInBatches(createOps); | |
| showToast("New timetable generated and saved. No email was sent.", "success"); | |
| // The schedule generator ONLY generates/saves. Emailing is a separate action. | |
| // Keep generatedShifts available so the UI can immediately reflect what was created. | |
| console.info("Generated timetable:", generatedShifts.length, "assignments;", unassignedSlots, "unassigned slots."); | |
| } catch (error) { | |
| console.error("Error generating timetable:", error); | |
| showToast("Failed to generate timetable: " + (error.message || "unknown"), "error"); | |
| } finally { | |
| setIsGenerating(false); | |
| } | |
| }; | |
| const emailCurrentSchedule = async () => { | |
| if (!profile || profile.role !== ROLES.HEAD_PREFECT) return; | |
| if (!user || !user.email) { | |
| showToast("You must be signed in with Gmail to email the timetable.", "error"); | |
| return; | |
| } | |
| setIsEmailing(true); | |
| try { | |
| // Read the current schedule directly from Firestore so this button NEVER regenerates it. | |
| const snap = await db.collection("shifts").get(); | |
| const currentShifts = snap.docs.map((d) => ({ id: d.id, ...d.data() })); | |
| if (currentShifts.length === 0) { | |
| showToast("There is no timetable to email yet. Generate a schedule first.", "error"); | |
| return; | |
| } | |
| const emailList = users | |
| .filter((u) => u.role === ROLES.PREFECT || u.role === ROLES.HEAD_PREFECT) | |
| .map((p) => p.email) | |
| .filter((e) => e && e.includes("@")); | |
| const generatedAt = new Date(); | |
| const fileName = buildTimetablePdf(currentShifts, generatedAt); | |
| if (emailList.length === 0) { | |
| showToast("PDF downloaded as " + fileName + ". No prefect Gmail addresses are stored yet.", "info"); | |
| return; | |
| } | |
| const subject = encodeURIComponent("Current Prefect Duty Timetable"); | |
| const body = encodeURIComponent( | |
| "Hi Prefects,\n\n" + | |
| "Attached is the current prefect duty timetable. This email uses the schedule already saved in Prefect Duty Manager; it does NOT generate a new schedule.\n\n" + | |
| "Please attach the downloaded PDF: " + fileName + "\n\n" + | |
| "Sent by: " + (user.displayName || user.email) + "\n" + | |
| "Prepared: " + generatedAt.toLocaleString() + "\n\n" + | |
| "Thank you!" | |
| ); | |
| const to = encodeURIComponent(emailList.join(",")); | |
| const gmailUrl = "https://mail.google.com/mail/?view=cm&fs=1&to=" + to + "&su=" + subject + "&body=" + body; | |
| window.open(gmailUrl, "_blank", "noopener,noreferrer"); | |
| showToast("Current schedule PDF downloaded. Gmail opened — attach " + fileName + " and press Send.", "success"); | |
| } catch (error) { | |
| console.error("Error emailing current timetable:", error); | |
| showToast("Failed to prepare email: " + (error.message || "unknown error"), "error"); | |
| } finally { | |
| setIsEmailing(false); | |
| } | |
| }; | |
| const requestSwap = async (shiftId) => { | |
| if (!user || !profile) return; | |
| try { | |
| const shiftRef = db.collection("shifts").doc(shiftId); | |
| const shiftSnap = await shiftRef.get(); | |
| if (!shiftSnap.exists) { showToast("Shift not found.", "error"); return; } | |
| const data = shiftSnap.data(); | |
| if (data.userId !== user.uid) { showToast("You can only request swap for your own shifts.", "error"); return; } | |
| if (data.swapStatus === "requested") { showToast("Swap already requested.", "info"); return; } | |
| if (data.status !== "pending") { showToast("Cannot swap a marked shift.", "error"); return; } | |
| await shiftRef.update({ swapStatus: "requested" }); | |
| await db.collection("swapRequests").add({ | |
| shiftId, | |
| requestedByUserId: user.uid, | |
| requestedByUserName: profile.name, | |
| status: "open", | |
| timestamp: new Date().toISOString() | |
| }); | |
| showToast("Swap requested. Waiting for someone to cover.", "info"); | |
| } catch (error) { | |
| console.error(error); | |
| showToast("Failed to request swap.", "error"); | |
| } | |
| }; | |
| const acceptSwap = async (requestId, shiftId) => { | |
| if (!user || !profile) return; | |
| try { | |
| const shiftRef = db.collection("shifts").doc(shiftId); | |
| const reqRef = db.collection("swapRequests").doc(requestId); | |
| const shiftSnap = await shiftRef.get(); | |
| if (!shiftSnap.exists) { showToast("Shift not found.", "error"); return; } | |
| const shiftData = shiftSnap.data(); | |
| if (shiftData.userId === user.uid) { showToast("Cannot cover your own shift.", "error"); return; } | |
| if (shiftData.status !== "pending") { showToast("This shift is already marked.", "error"); return; } | |
| await shiftRef.update({ userId: user.uid, userName: profile.name, swapStatus: "covered" }); | |
| await reqRef.update({ status: "fulfilled", coveredByUserId: user.uid, coveredByUserName: profile.name }); | |
| showToast("You have successfully taken over the shift!"); | |
| } catch (error) { | |
| console.error(error); | |
| showToast("Failed to accept swap.", "error"); | |
| } | |
| }; | |
| const markAttendance = async (shiftId, status, userId) => { | |
| if (!user) return; | |
| try { | |
| const shiftRef = db.collection("shifts").doc(shiftId); | |
| const shiftSnap = await shiftRef.get(); | |
| if (!shiftSnap.exists) { showToast("Shift not found.", "error"); return; } | |
| if (shiftSnap.data().status !== "pending") { showToast("This shift has already been marked.", "error"); return; } | |
| await shiftRef.update({ status }); | |
| const userRef = db.collection("users").doc(userId); | |
| const userSnap = await userRef.get(); | |
| if (userSnap.exists) { | |
| const d = userSnap.data(); | |
| if (status === "present") await userRef.update({ attendanceCount: (d.attendanceCount || 0) + 1 }); | |
| else if (status === "absent") await userRef.update({ skipCount: (d.skipCount || 0) + 1 }); | |
| } | |
| showToast("Marked as " + status); | |
| } catch (error) { | |
| console.error(error); | |
| showToast("Failed to mark attendance.", "error"); | |
| } | |
| }; | |
| const exportReport = () => { | |
| try { | |
| const JsPDF = window.jspdf && window.jspdf.jsPDF; | |
| if (!JsPDF) { | |
| showToast("PDF library did not load. Please refresh the page and try again.", "error"); | |
| return; | |
| } | |
| const reportDay = selectedReportDay || getTodayName(); | |
| const now = new Date(); | |
| const fileName = "Discipline_Monitoring_Report_" + reportDay + "_" + now.toISOString().slice(0, 10) + ".pdf"; | |
| const doc = new JsPDF({ orientation: "landscape", unit: "mm", format: "a4" }); | |
| const allShifts = (Array.isArray(shifts) ? [...shifts] : []).filter(s => s.day === reportDay); | |
| const present = allShifts.filter(s => s.status === "present").length; | |
| const absent = allShifts.filter(s => s.status === "absent").length; | |
| const pending = allShifts.filter(s => !s.status || s.status === "pending").length; | |
| const assigned = allShifts.filter(s => s.userId || s.userName).length; | |
| doc.setFont("helvetica", "bold"); | |
| doc.setFontSize(18); | |
| doc.text("Discipline Monitoring Report", 14, 16); | |
| doc.setFont("helvetica", "normal"); | |
| doc.setFontSize(9); | |
| doc.text("Prefect Duty Manager", 14, 22); | |
| doc.text("Generated: " + now.toLocaleString(), 14, 27); | |
| doc.text("Report day: " + reportDay, 14, 32); | |
| doc.setFont("helvetica", "bold"); | |
| doc.setFontSize(10); | |
| doc.text("Summary", 14, 42); | |
| doc.setFont("helvetica", "normal"); | |
| doc.setFontSize(9); | |
| doc.text("Total shifts: " + allShifts.length, 14, 49); | |
| doc.text("Assigned: " + assigned, 70, 49); | |
| doc.text("Present: " + present, 125, 49); | |
| doc.text("Absent: " + absent, 175, 49); | |
| doc.text("Pending: " + pending, 225, 49); | |
| const headers = ["Day", "Session", "Location", "Prefect", "Status"]; | |
| const rows = allShifts | |
| .map(s => [ | |
| s.day || "", | |
| s.sessionName || s.sessionId || "", | |
| s.locationName || s.locationId || "", | |
| s.userName || "Unassigned", | |
| s.status || "pending" | |
| ]) | |
| .sort((a, b) => (String(a[1]) + String(a[2]) + String(a[3])).localeCompare(String(b[1]) + String(b[2]) + String(b[3]))); | |
| const colX = [14, 43, 92, 160, 232]; | |
| const rowH = 7; | |
| let y = 68; | |
| const drawHeader = () => { | |
| doc.setFont("helvetica", "bold"); | |
| doc.setFontSize(8); | |
| headers.forEach((h, i) => doc.text(h, colX[i], y)); | |
| doc.setLineWidth(0.3); | |
| doc.line(14, y + 2, 275, y + 2); | |
| y += rowH; | |
| doc.setFont("helvetica", "normal"); | |
| }; | |
| drawHeader(); | |
| rows.forEach((row) => { | |
| if (y > 190) { | |
| doc.addPage(); | |
| y = 16; | |
| doc.setFont("helvetica", "bold"); | |
| doc.setFontSize(10); | |
| doc.text("Discipline Monitoring Report - " + reportDay, 14, y); | |
| y += 8; | |
| drawHeader(); | |
| } | |
| doc.setFontSize(8); | |
| row.forEach((value, i) => { | |
| const maxWidth = i === 2 ? 62 : (i === 3 ? 65 : 45); | |
| const text = doc.splitTextToSize(String(value), maxWidth)[0] || ""; | |
| doc.text(text, colX[i], y); | |
| }); | |
| y += rowH; | |
| }); | |
| if (rows.length === 0) { | |
| doc.setFontSize(10); | |
| doc.text("No duty records found for " + reportDay + ".", 14, y + 8); | |
| } | |
| doc.save(fileName); | |
| showToast("PDF report for " + reportDay + " downloaded.", "success"); | |
| } catch (error) { | |
| console.error("Failed to export PDF report:", error); | |
| showToast("Failed to export PDF report: " + (error && error.message ? error.message : "Unknown error"), "error"); | |
| } | |
| }; | |
| const getTodayName = () => { | |
| const d = new Date().getDay(); | |
| const idx = d === 0 || d === 6 ? 2 : d - 1; | |
| return DAYS[idx] || DAYS[2]; | |
| }; | |
| const renderTimetable = () => ( | |
| <div className="overflow-x-auto bg-white rounded-xl shadow-sm border border-slate-200"> | |
| <table className="w-full text-left border-collapse"> | |
| <thead> | |
| <tr className="bg-slate-50 text-slate-600 text-sm uppercase tracking-wider border-b border-slate-200"> | |
| <th className="p-4 font-semibold">Session & Location</th> | |
| {DAYS.map((day) => ( | |
| <th key={day} className="p-4 font-semibold text-center border-l border-slate-200">{day}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {SESSIONS.map((session) => ( | |
| <React.Fragment key={session.id}> | |
| <tr className="bg-indigo-50/50 border-y border-slate-200"> | |
| <td colSpan={DAYS.length + 1} className="p-3 font-bold text-indigo-800 text-sm"> | |
| {session.name} <span className="text-indigo-500 font-normal ml-2">({session.time})</span> | |
| </td> | |
| </tr> | |
| {LOCATIONS.map((loc) => ( | |
| <tr key={session.id + "-" + loc.id} className="border-b border-slate-100 hover:bg-slate-50"> | |
| <td className="p-4 pl-6 font-medium text-slate-700 whitespace-nowrap"> | |
| <div className="flex items-center gap-2"> | |
| <Icon name="map-pin" size={16} className="text-slate-400" /> | |
| {loc.name} | |
| </div> | |
| </td> | |
| {DAYS.map((day) => { | |
| const shift = shifts.find((s) => s.locationId === loc.id && s.day === day && s.sessionId === session.id); | |
| if (!shift) { | |
| return <td key={day} className="p-4 border-l border-slate-200 text-center text-slate-400 text-sm italic">Unassigned</td>; | |
| } | |
| const isMine = shift.userId === (user && user.uid); | |
| const isSwapping = shift.swapStatus === "requested"; | |
| return ( | |
| <td key={day} className={"p-4 border-l border-slate-200 " + (isMine ? "bg-indigo-50/30" : "")}> | |
| <div className="flex flex-col items-center gap-1"> | |
| <span className={"font-semibold " + (isMine ? "text-indigo-700" : "text-slate-700")}>{shift.userName}</span> | |
| {(profile.role === ROLES.HEAD_PREFECT || profile.role === ROLES.TEACHER) && shift.status !== "pending" && ( | |
| <span className={"text-xs px-2 py-0.5 rounded-full " + (shift.status === "present" ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700")}>{shift.status}</span> | |
| )} | |
| {isMine && !isSwapping && shift.status === "pending" && ( | |
| <button onClick={() => requestSwap(shift.id)} className="text-xs flex items-center gap-1 text-orange-600 hover:bg-orange-50 px-2 py-1 rounded mt-1"> | |
| <Icon name="refresh-cw" size={12} /> Swap | |
| </button> | |
| )} | |
| {isSwapping && !isMine && shift.status === "pending" && ( | |
| <button | |
| onClick={() => { | |
| const req = swapRequests.find((r) => r.shiftId === shift.id && r.status === "open"); | |
| if (req) acceptSwap(req.id, shift.id); | |
| }} | |
| className="text-xs bg-orange-500 hover:bg-orange-600 text-white px-2 py-1 rounded shadow-sm mt-1 animate-pulse flex items-center gap-1" | |
| > | |
| <Icon name="arrow-right-left" size={12} /> Cover | |
| </button> | |
| )} | |
| {isSwapping && isMine && <span className="text-xs text-orange-500 italic mt-1">Pending Swap</span>} | |
| {shift.swapStatus === "covered" && <span className="text-xs text-green-600 italic mt-1">Covered</span>} | |
| </div> | |
| </td> | |
| ); | |
| })} | |
| </tr> | |
| ))} | |
| </React.Fragment> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| ); | |
| const renderTeacherDashboard = () => { | |
| const today = getTodayName(); | |
| const todaysShifts = shifts.filter((s) => s.day === today && s.sessionId === activeTeacherSession); | |
| return ( | |
| <div className="space-y-6 animate-fadeIn"> | |
| <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 bg-white p-6 rounded-xl shadow-sm border border-slate-200"> | |
| <div> | |
| <h2 className="text-2xl font-bold text-slate-800">Discipline Monitoring</h2> | |
| <p className="text-slate-500">Live tracker and reporting dashboard ({today}).</p> | |
| </div> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| <label className="text-sm font-medium text-slate-600">Export:</label> | |
| <select | |
| value={selectedReportDay} | |
| onChange={(e) => setSelectedReportDay(e.target.value)} | |
| className="border border-slate-300 rounded-lg px-3 py-2 bg-white text-sm font-medium text-slate-700 focus:ring-2 focus:ring-indigo-500 outline-none" | |
| > | |
| {DAYS.map((day) => <option key={day} value={day}>{day}</option>)} | |
| </select> | |
| <button onClick={exportReport} className="flex items-center gap-2 bg-slate-800 hover:bg-slate-900 text-white px-4 py-2 rounded-lg font-medium shadow-sm"> | |
| <Icon name="download" size={18} /> Export PDF | |
| </button> | |
| </div> | |
| </div> | |
| <div className="flex gap-2 p-1 bg-slate-100 rounded-lg w-fit flex-wrap"> | |
| {SESSIONS.map((session) => ( | |
| <button | |
| key={session.id} | |
| onClick={() => { setActiveTeacherSession(session.id); setSelectedTeacherLocation(null); }} | |
| className={"px-4 py-2 rounded-md font-medium text-sm " + (activeTeacherSession === session.id ? "bg-white text-indigo-700 shadow-sm" : "text-slate-600 hover:text-slate-800")} | |
| > | |
| {session.name} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="grid md:grid-cols-3 gap-6"> | |
| <div className="md:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-slate-200"> | |
| <h3 className="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2"> | |
| <Icon name="shield-alert" size={20} className="text-red-500" /> Live Duty Map - {(SESSIONS.find((s) => s.id === activeTeacherSession) || {}).time} | |
| </h3> | |
| <SchoolMap activeLocationId={selectedTeacherLocation} /> | |
| <p className="text-sm text-slate-500 text-center mt-4 italic">Select a prefect below to locate them on the campus map.</p> | |
| </div> | |
| <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 flex flex-col"> | |
| <h3 className="text-lg font-bold text-slate-800 mb-4">Active Roster</h3> | |
| <div className="flex-1 overflow-y-auto space-y-2 max-h-80"> | |
| {todaysShifts.length === 0 ? ( | |
| <p className="text-slate-500 text-sm">No shifts assigned for today.</p> | |
| ) : ( | |
| todaysShifts.map((shift) => ( | |
| <div | |
| key={shift.id} | |
| onClick={() => setSelectedTeacherLocation(shift.locationId)} | |
| className={"p-3 rounded-lg cursor-pointer border " + (selectedTeacherLocation === shift.locationId ? "bg-indigo-50 border-indigo-200 shadow-sm" : "bg-slate-50 border-slate-100 hover:bg-slate-100")} | |
| > | |
| <div className="font-semibold text-slate-800 flex justify-between items-center"> | |
| {shift.userName} | |
| {shift.status === "absent" && <Icon name="x-circle" size={16} className="text-red-500" />} | |
| {shift.status === "present" && <Icon name="check-circle" size={16} className="text-green-500" />} | |
| </div> | |
| <div className="text-sm text-slate-500 flex items-center gap-1 mt-1"> | |
| <Icon name="map-pin" size={14} /> {shift.locationName} | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const renderAttendance = () => { | |
| const today = getTodayName(); | |
| let pending = shifts.filter((s) => s.status === "pending" && s.day === today); | |
| if (pending.length === 0) pending = shifts.filter((s) => s.status === "pending"); | |
| return ( | |
| <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 animate-fadeIn"> | |
| <h2 className="text-xl font-bold text-slate-800 mb-2">Daily Attendance Marking</h2> | |
| <p className="text-sm text-slate-500 mb-6">Showing {pending.some((s) => s.day === today) ? "today (" + today + ")" : "all pending"} shifts</p> | |
| <div className="space-y-4"> | |
| {pending.length === 0 ? ( | |
| <p className="text-slate-500 bg-slate-50 p-4 rounded-lg text-center">No pending shifts to mark.</p> | |
| ) : ( | |
| pending.map((shift) => ( | |
| <div key={shift.id} className="flex flex-col md:flex-row items-center justify-between p-4 bg-slate-50 rounded-lg border border-slate-100 hover:shadow-md"> | |
| <div className="flex items-center gap-4 mb-4 md:mb-0"> | |
| <div className="w-10 h-10 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center font-bold text-lg"> | |
| {(shift.userName || "?").charAt(0)} | |
| </div> | |
| <div> | |
| <div className="font-bold text-slate-800">{shift.userName}</div> | |
| <div className="text-sm text-slate-500 flex items-center gap-1 flex-wrap"> | |
| <Icon name="calendar" size={14} /> {shift.day} · <Icon name="map-pin" size={14} /> {shift.locationName} · {shift.sessionName} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex gap-2 w-full md:w-auto"> | |
| <button onClick={() => markAttendance(shift.id, "present", shift.userId)} className="flex-1 md:flex-none flex items-center justify-center gap-1 bg-green-100 hover:bg-green-200 text-green-700 px-4 py-2 rounded-lg font-medium"> | |
| <Icon name="check" size={18} /> Present | |
| </button> | |
| <button onClick={() => markAttendance(shift.id, "absent", shift.userId)} className="flex-1 md:flex-none flex items-center justify-center gap-1 bg-red-100 hover:bg-red-200 text-red-700 px-4 py-2 rounded-lg font-medium"> | |
| <Icon name="x-circle" size={18} /> Absent | |
| </button> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const renderCertificates = () => { | |
| const isEligible = profile.skipCount === 0 && (profile.attendanceCount || 0) > 0; | |
| return ( | |
| <div className="animate-fadeIn max-w-2xl mx-auto"> | |
| <div className="text-center mb-8"> | |
| <h2 className="text-3xl font-extrabold text-slate-800">My Motivation & Rewards</h2> | |
| <p className="text-slate-500 mt-2">Maintain perfect attendance to unlock your digital certificates.</p> | |
| </div> | |
| <div className="grid gap-6"> | |
| <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-semibold text-slate-500 uppercase tracking-wider">Attendance Record</p> | |
| <p className="text-3xl font-bold text-slate-800 mt-1">{profile.attendanceCount || 0} <span className="text-lg text-slate-500 font-medium">Days Present</span></p> | |
| </div> | |
| <div className="text-right"> | |
| <p className="text-sm font-semibold text-slate-500 uppercase tracking-wider">Missed Duty</p> | |
| <p className={"text-3xl font-bold mt-1 " + ((profile.skipCount || 0) > 0 ? "text-red-500" : "text-green-500")}>{profile.skipCount || 0}</p> | |
| </div> | |
| </div> | |
| {isEligible ? ( | |
| <div className="relative bg-gradient-to-br from-amber-100 to-amber-200 p-1 border-2 border-amber-300 rounded-2xl overflow-hidden shadow-lg hover:scale-105 transition-transform"> | |
| <div className="bg-white/60 backdrop-blur-sm p-8 rounded-xl flex flex-col items-center text-center relative z-10"> | |
| <Icon name="award" size={64} className="text-amber-500 mb-4" /> | |
| <h3 className="text-2xl font-black text-amber-900" style={{ fontFamily: "serif" }}>Certificate of Excellence</h3> | |
| <p className="text-amber-800 mt-2 font-medium">Awarded to</p> | |
| <p className="text-3xl font-bold text-slate-900 my-4 border-b-2 border-amber-400 pb-2 inline-block px-8">{profile.name}</p> | |
| <p className="text-amber-800">For outstanding dedication and perfect attendance in prefect duties.</p> | |
| <button onClick={() => showToast("Downloading PDF Certificate...", "success")} className="mt-8 bg-amber-500 hover:bg-amber-600 text-white px-6 py-2 rounded-full font-bold shadow-md flex items-center gap-2"> | |
| <Icon name="download" size={18} /> Export PDF | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="bg-slate-50 border-2 border-dashed border-slate-300 rounded-2xl p-10 text-center flex flex-col items-center"> | |
| <div className="w-20 h-20 bg-slate-200 rounded-full flex items-center justify-center mb-4"> | |
| <Icon name="award" size={32} className="text-slate-400" /> | |
| </div> | |
| <h3 className="text-xl font-bold text-slate-700">Certificate Locked</h3> | |
| <p className="text-slate-500 mt-2 max-w-md">Complete at least one duty and maintain a zero-skip record to unlock your certificate.</p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="min-h-screen bg-slate-50 flex items-center justify-center text-indigo-600"> | |
| <Icon name="refresh-cw" size={32} className="animate-spin" /> | |
| </div> | |
| ); | |
| } | |
| if (permissionError) { | |
| return ( | |
| <div className="min-h-screen bg-slate-50 flex items-center justify-center p-4"> | |
| <div className="bg-white p-8 rounded-2xl shadow-2xl w-full max-w-2xl border-t-4 border-red-500 animate-fadeIn"> | |
| <div className="flex justify-center mb-6"> | |
| <div className="bg-red-100 p-4 rounded-full text-red-600"><Icon name="alert-circle" size={48} /></div> | |
| </div> | |
| <h1 className="text-2xl font-bold text-center text-slate-800 mb-4">Firebase Setup Required</h1> | |
| <p className="text-slate-600 mb-6 text-center">Enable Google sign-in and authorize your Hugging Face domain.</p> | |
| <div className="bg-slate-100 p-6 rounded-lg text-sm text-slate-700 space-y-4"> | |
| <p className="font-bold text-lg border-b pb-2">1. Enable Google Sign-In</p> | |
| <ol className="list-decimal pl-5 space-y-2"> | |
| <li>Open <a href="https://console.firebase.google.com/" target="_blank" rel="noreferrer" className="text-indigo-600 underline">Firebase Console</a></li> | |
| <li>Select project <strong>hello-c0b3d</strong></li> | |
| <li>Authentication → Sign-in method → <strong>Google</strong> → Enable → Save</li> | |
| </ol> | |
| <p className="font-bold text-lg border-b pb-2 mt-4">2. Authorized domains (Hugging Face)</p> | |
| <ol className="list-decimal pl-5 space-y-2"> | |
| <li>Authentication → Settings → Authorized domains → Add domain</li> | |
| <li>Add this exact current host: <code className="bg-white px-1 rounded break-all">{window.location.hostname}</code></li> | |
| <li>For Hugging Face, open the public <strong>.hf.space</strong> URL directly. Do not sign in from a <strong>huggingface.co/spaces/...</strong> embed page.</li> | |
| <li>Also keep <code className="bg-white px-1 rounded">localhost</code> and <code className="bg-white px-1 rounded">hello-c0b3d.firebaseapp.com</code></li> | |
| </ol> | |
| <p className="font-bold text-lg border-b pb-2 mt-4">3. Firestore Rules</p> | |
| <pre className="bg-slate-800 text-green-400 p-4 rounded-md overflow-x-auto text-xs whitespace-pre-wrap">{"rules_version = '2';\nservice cloud.firestore {\n match /databases/{database}/documents {\n match /{document=**} {\n allow read, write: if request.auth != null;\n }\n }\n}"}</pre> | |
| <p className="text-xs text-slate-500">Open the app via https://YOUR-SPACE.hf.space (not the huggingface.co embed page).</p> | |
| </div> | |
| <button onClick={() => window.location.reload()} className="w-full mt-6 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 rounded-lg shadow-md flex items-center justify-center gap-2"> | |
| <Icon name="refresh-cw" size={18} /> Reload App | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (!user) { | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center p-4"> | |
| <div className="bg-white p-8 rounded-2xl shadow-2xl w-full max-w-md animate-fadeIn text-center"> | |
| <div className="flex justify-center mb-6"> | |
| <div className="bg-indigo-100 p-4 rounded-full text-indigo-600"><Icon name="shield" size={48} /></div> | |
| </div> | |
| <h1 className="text-2xl font-bold text-slate-800 mb-2">Prefect Duty Manager</h1> | |
| <p className="text-slate-500 mb-2">Sign in with your school Gmail to continue</p> | |
| <p className="text-xs text-slate-400 mb-2">Secure Google sign-in • popup + redirect recovery</p> | |
| <p className="text-xs text-slate-400 mb-2 font-mono break-all"> | |
| Host: {window.location.hostname || "localhost"} | |
| </p> | |
| <p className="text-[11px] text-slate-400 mb-8"> | |
| If Google does not open, make sure this exact host is listed in Firebase Authorized domains. | |
| </p> | |
| <button | |
| onClick={handleGoogleLogin} | |
| disabled={authLoading} | |
| className="w-full flex items-center justify-center gap-3 bg-white border-2 border-slate-200 hover:border-indigo-400 hover:bg-indigo-50 text-slate-800 font-bold py-3 px-4 rounded-lg shadow-sm disabled:opacity-60" | |
| > | |
| {authLoading ? <Icon name="refresh-cw" size={20} className="animate-spin" /> : ( | |
| <svg width="20" height="20" viewBox="0 0 48 48"> | |
| <path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/> | |
| <path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/> | |
| <path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/> | |
| <path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/> | |
| </svg> | |
| )} | |
| {authLoading ? "Signing in..." : "Sign in with Google"} | |
| </button> | |
| </div> | |
| {toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />} | |
| </div> | |
| ); | |
| } | |
| if (!profile) { | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center p-4"> | |
| <div className="bg-white p-8 rounded-2xl shadow-2xl w-full max-w-md animate-fadeIn"> | |
| <div className="flex justify-center mb-4"> | |
| {user.photoURL ? ( | |
| <img src={user.photoURL} alt="" className="w-16 h-16 rounded-full border-2 border-indigo-200" /> | |
| ) : ( | |
| <div className="bg-indigo-100 p-4 rounded-full text-indigo-600"><Icon name="shield" size={40} /></div> | |
| )} | |
| </div> | |
| <h1 className="text-2xl font-bold text-center text-slate-800 mb-1">Complete your profile</h1> | |
| <p className="text-center text-slate-500 text-sm mb-1">{user.email}</p> | |
| <p className="text-center text-slate-400 text-xs mb-6">This Gmail will be used for duty emails</p> | |
| <form onSubmit={handleRegister} className="space-y-5"> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">Full Name</label> | |
| <input required name="name" type="text" defaultValue={user.displayName || ""} className="w-full border border-slate-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 outline-none" placeholder="Enter your name" /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">Role</label> | |
| <select required name="role" className="w-full border border-slate-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 outline-none bg-white" onChange={(e) => setHideFormLevel(e.target.value === ROLES.TEACHER)}> | |
| <option value={ROLES.PREFECT}>Prefect</option> | |
| <option value={ROLES.HEAD_PREFECT}>Head Prefect</option> | |
| <option value={ROLES.TEACHER}>Discipline Teacher</option> | |
| </select> | |
| </div> | |
| {!hideFormLevel && ( | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">Form Level</label> | |
| <select name="formLevel" className="w-full border border-slate-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 outline-none bg-white"> | |
| <option value={FORM_LEVELS.LOWER}>Lower Form (Form 1-3)</option> | |
| <option value={FORM_LEVELS.UPPER}>Upper Form (Form 4-5)</option> | |
| </select> | |
| </div> | |
| )} | |
| <button type="submit" className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 rounded-lg shadow-md mt-2">Enter System</button> | |
| <button type="button" onClick={handleLogout} className="w-full text-sm text-slate-500 hover:text-red-500 py-2">Sign out and use a different account</button> | |
| </form> | |
| </div> | |
| {toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />} | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="min-h-screen bg-slate-50 text-slate-800 font-sans"> | |
| <header className="bg-white border-b border-slate-200 sticky top-0 z-40 shadow-sm"> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <Icon name="shield" size={28} className="text-indigo-600" /> | |
| <div> | |
| <h1 className="font-bold text-lg leading-tight hidden sm:block">Prefect Duty System</h1> | |
| <span className="text-xs font-semibold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-full">{profile.role}</span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="text-right hidden md:block"> | |
| <p className="text-sm font-bold text-slate-800">{profile.name}</p> | |
| <p className="text-xs text-slate-500">{profile.email || user.email}</p> | |
| </div> | |
| {user.photoURL && <img src={user.photoURL} alt="" className="w-8 h-8 rounded-full border border-slate-200 hidden sm:block" />} | |
| <button onClick={handleLogout} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-full" title="Sign out"> | |
| <Icon name="log-out" size={20} /> | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | |
| <div className="mb-8 flex flex-col md:flex-row gap-4 items-start md:items-center justify-between"> | |
| <div className="flex bg-white rounded-lg p-1 shadow-sm border border-slate-200 w-full md:w-auto flex-wrap"> | |
| <button onClick={() => setActiveTab("timetable")} className={"flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2 rounded-md font-medium text-sm " + (activeTab === "timetable" ? "bg-indigo-50 text-indigo-700 shadow-sm" : "text-slate-600 hover:bg-slate-50")}> | |
| <Icon name="calendar" size={16} /> Timetable | |
| </button> | |
| {profile.role === ROLES.HEAD_PREFECT && ( | |
| <button onClick={() => setActiveTab("attendance")} className={"flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2 rounded-md font-medium text-sm " + (activeTab === "attendance" ? "bg-indigo-50 text-indigo-700 shadow-sm" : "text-slate-600 hover:bg-slate-50")}> | |
| <Icon name="check-circle" size={16} /> Attendance | |
| </button> | |
| )} | |
| {profile.role === ROLES.PREFECT && ( | |
| <button onClick={() => setActiveTab("certs")} className={"flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2 rounded-md font-medium text-sm " + (activeTab === "certs" ? "bg-indigo-50 text-indigo-700 shadow-sm" : "text-slate-600 hover:bg-slate-50")}> | |
| <Icon name="award" size={16} /> Rewards | |
| </button> | |
| )} | |
| {profile.role === ROLES.TEACHER && ( | |
| <button onClick={() => setActiveTab("teacher")} className={"flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2 rounded-md font-medium text-sm " + (activeTab === "teacher" ? "bg-indigo-50 text-indigo-700 shadow-sm" : "text-slate-600 hover:bg-slate-50")}> | |
| <Icon name="shield-alert" size={16} /> Live Map & Reports | |
| </button> | |
| )} | |
| </div> | |
| {profile.role === ROLES.HEAD_PREFECT && activeTab === "timetable" && ( | |
| <div className="w-full md:w-auto flex flex-col sm:flex-row gap-2"> | |
| <button onClick={generateTimetable} disabled={isGenerating || isEmailing} className="flex-1 md:flex-none flex items-center justify-center gap-2 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white px-5 py-2.5 rounded-lg font-bold shadow-md disabled:opacity-70"> | |
| {isGenerating ? <Icon name="refresh-cw" size={18} className="animate-spin" /> : <Icon name="sparkles" size={18} />} | |
| {isGenerating ? "Generating..." : "Generate Schedule"} | |
| </button> | |
| <button onClick={emailCurrentSchedule} disabled={isGenerating || isEmailing} className="flex-1 md:flex-none flex items-center justify-center gap-2 bg-white hover:bg-slate-50 text-indigo-700 border border-indigo-200 px-5 py-2.5 rounded-lg font-bold shadow-sm disabled:opacity-70"> | |
| {isEmailing ? <Icon name="refresh-cw" size={18} className="animate-spin" /> : <Icon name="mail" size={18} />} | |
| {isEmailing ? "Preparing Email..." : "Email Current Schedule"} | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| <div className="mb-20"> | |
| {activeTab === "timetable" && ( | |
| <div className="animate-fadeIn"> | |
| <div className="mb-4 flex flex-col md:flex-row justify-between items-start md:items-center gap-4"> | |
| <div> | |
| <h2 className="text-2xl font-bold text-slate-800">Weekly Duty Timetable</h2> | |
| <p className="text-slate-500 text-sm mt-1">AI Balanced Roster · Max 1 duty per prefect per day · Upper guards Lower recess, and vice versa</p> | |
| </div> | |
| {profile.role === ROLES.HEAD_PREFECT && ( | |
| <div className="flex items-center gap-2 bg-white p-1 rounded-lg border border-slate-200 shadow-sm"> | |
| <input type="file" accept=".csv" className="hidden" id="csv-upload" ref={fileInputRef} onChange={handleCsvImport} /> | |
| <label htmlFor="csv-upload" className="cursor-pointer flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded-md"> | |
| <Icon name="upload" size={16} className="text-indigo-600" /> Import Prefects (CSV) | |
| </label> | |
| </div> | |
| )} | |
| </div> | |
| {shifts.length > 0 ? renderTimetable() : ( | |
| <div className="text-center py-20 bg-white rounded-xl border border-dashed border-slate-300"> | |
| <Icon name="file-text" size={48} className="mx-auto text-slate-300 mb-4" /> | |
| <p className="text-slate-500 font-medium">No timetable generated yet.</p> | |
| {profile.role === ROLES.HEAD_PREFECT && <p className="text-sm text-slate-400 mt-1">Click the generate button above.</p>} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {activeTab === "attendance" && profile.role === ROLES.HEAD_PREFECT && renderAttendance()} | |
| {activeTab === "certs" && profile.role === ROLES.PREFECT && renderCertificates()} | |
| {activeTab === "teacher" && profile.role === ROLES.TEACHER && renderTeacherDashboard()} | |
| </div> | |
| </main> | |
| {toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />} | |
| </div> | |
| ); | |
| } | |
| ReactDOM.createRoot(document.getElementById("root")).render(<App />); | |
| </script> | |
| </body> | |
| </html> |