debug / index.html
syuanteng315's picture
Update index.html
666f0cd verified
Raw History Blame Contribute Delete
69 kB
<!DOCTYPE html>
<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>