Spaces:
Running
Running
Update index.html
Browse files- index.html +145 -106
index.html
CHANGED
|
@@ -192,6 +192,7 @@
|
|
| 192 |
const [activeTeacherSession, setActiveTeacherSession] = useState("MORNING");
|
| 193 |
const [selectedReportDay, setSelectedReportDay] = useState(() => { const d = new Date().getDay(); const idx = d === 0 || d === 6 ? 2 : d - 1; return DAYS[idx] || DAYS[2]; });
|
| 194 |
const [isGenerating, setIsGenerating] = useState(false);
|
|
|
|
| 195 |
const [hideFormLevel, setHideFormLevel] = useState(false);
|
| 196 |
const fileInputRef = useRef(null);
|
| 197 |
|
|
@@ -523,12 +524,71 @@
|
|
| 523 |
reader.readAsText(file);
|
| 524 |
};
|
| 525 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
const generateTimetable = async () => {
|
| 527 |
if (!profile || profile.role !== ROLES.HEAD_PREFECT) return;
|
| 528 |
-
if (!user || !user.email) {
|
| 529 |
-
showToast("You must be signed in with Gmail to generate and email the timetable.", "error");
|
| 530 |
-
return;
|
| 531 |
-
}
|
| 532 |
setIsGenerating(true);
|
| 533 |
try {
|
| 534 |
const deleteOps = [];
|
|
@@ -539,13 +599,13 @@
|
|
| 539 |
const prefects = users.filter((u) => u.role === ROLES.PREFECT || u.role === ROLES.HEAD_PREFECT);
|
| 540 |
if (prefects.length === 0) {
|
| 541 |
showToast("No prefects registered to assign!", "error");
|
| 542 |
-
setIsGenerating(false);
|
| 543 |
return;
|
| 544 |
}
|
| 545 |
|
| 546 |
const assignmentCounts = {};
|
| 547 |
prefects.forEach((p) => { assignmentCounts[p.id] = 0; });
|
| 548 |
const createOps = [];
|
|
|
|
| 549 |
let unassignedSlots = 0;
|
| 550 |
|
| 551 |
DAYS.forEach((day) => {
|
|
@@ -569,19 +629,24 @@
|
|
| 569 |
const chosen = candidates[Math.floor(Math.random() * candidates.length)];
|
| 570 |
assignmentCounts[chosen.id]++;
|
| 571 |
usedThisDay.add(chosen.id);
|
|
|
|
| 572 |
const newRef = db.collection("shifts").doc();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
createOps.push((batch) => {
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
sessionId: session.id,
|
| 577 |
-
sessionName: session.name,
|
| 578 |
-
locationId: loc.id,
|
| 579 |
-
locationName: loc.name,
|
| 580 |
-
userId: chosen.id,
|
| 581 |
-
userName: chosen.name,
|
| 582 |
-
status: "pending",
|
| 583 |
-
swapStatus: "none"
|
| 584 |
-
});
|
| 585 |
});
|
| 586 |
});
|
| 587 |
});
|
|
@@ -589,103 +654,71 @@
|
|
| 589 |
|
| 590 |
if (createOps.length === 0) {
|
| 591 |
showToast("Not enough prefects to fill any slots.", "error");
|
| 592 |
-
setIsGenerating(false);
|
| 593 |
return;
|
| 594 |
}
|
| 595 |
-
await commitInBatches(createOps);
|
| 596 |
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
// We therefore: generate/download the PDF, then open a pre-filled Gmail compose window.
|
| 600 |
-
const emailList = prefects.map((p) => p.email).filter((e) => e && e.includes("@"));
|
| 601 |
-
const generatedAt = new Date();
|
| 602 |
-
const fileName = "Prefect_Duty_Timetable_" + generatedAt.toISOString().slice(0, 10) + ".pdf";
|
| 603 |
-
|
| 604 |
-
try {
|
| 605 |
-
const jsPDF = window.jspdf && window.jspdf.jsPDF;
|
| 606 |
-
if (!jsPDF) throw new Error("PDF library did not load. Please check your internet connection and try again.");
|
| 607 |
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
|
|
|
|
|
|
| 616 |
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
|
|
|
|
|
|
| 621 |
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
doc.line(14, y + 2, 272, y + 2);
|
| 632 |
-
y += rowH;
|
| 633 |
-
doc.setFont("helvetica", "normal");
|
| 634 |
-
};
|
| 635 |
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
if (y > 190) {
|
| 639 |
-
doc.addPage();
|
| 640 |
-
y = 15;
|
| 641 |
-
drawHeader();
|
| 642 |
-
}
|
| 643 |
-
doc.setFontSize(7);
|
| 644 |
-
row.forEach((value, i) => {
|
| 645 |
-
let text = String(value);
|
| 646 |
-
const maxChars = i === 0 ? 16 : i === 1 ? 27 : i === 2 ? 34 : i === 3 ? 34 : 12;
|
| 647 |
-
if (text.length > maxChars) text = text.slice(0, maxChars - 1) + "…";
|
| 648 |
-
doc.text(text, colX[i], y);
|
| 649 |
-
});
|
| 650 |
-
doc.setDrawColor(220);
|
| 651 |
-
doc.line(14, y + 2, 272, y + 2);
|
| 652 |
-
doc.setDrawColor(0);
|
| 653 |
-
y += rowH;
|
| 654 |
-
});
|
| 655 |
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
doc.text(extra, 14, y + 4);
|
| 660 |
-
doc.save(fileName);
|
| 661 |
-
|
| 662 |
-
if (emailList.length > 0) {
|
| 663 |
-
const subject = encodeURIComponent("Prefect Duty Timetable");
|
| 664 |
-
const body = encodeURIComponent(
|
| 665 |
-
"Hi Prefects,\n\n" +
|
| 666 |
-
"The latest prefect duty timetable has been generated.\n\n" +
|
| 667 |
-
"Please download the PDF that was just generated by Prefect Duty Manager and attach it to this email before sending.\n\n" +
|
| 668 |
-
"Generated by: " + (user.displayName || user.email) + "\n" +
|
| 669 |
-
"Generated: " + generatedAt.toLocaleString() + "\n\n" +
|
| 670 |
-
"Thank you!"
|
| 671 |
-
);
|
| 672 |
-
const to = encodeURIComponent(emailList.join(","));
|
| 673 |
-
const gmailUrl = "https://mail.google.com/mail/?view=cm&fs=1&to=" + to + "&su=" + subject + "&body=" + body;
|
| 674 |
-
window.open(gmailUrl, "_blank", "noopener,noreferrer");
|
| 675 |
-
showToast("PDF downloaded. Gmail compose opened — attach " + fileName + " and press Send.", "success");
|
| 676 |
-
} else {
|
| 677 |
-
showToast("PDF downloaded as " + fileName + ". No prefect Gmail addresses are stored yet.", "info");
|
| 678 |
-
}
|
| 679 |
-
} catch (pdfError) {
|
| 680 |
-
console.error("PDF/email preparation error:", pdfError);
|
| 681 |
-
showToast("Timetable generated, but PDF preparation failed: " + (pdfError.message || "unknown error"), "error");
|
| 682 |
-
} finally {
|
| 683 |
-
setIsGenerating(false);
|
| 684 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 685 |
} catch (error) {
|
| 686 |
-
console.error("Error
|
| 687 |
-
showToast("Failed to
|
| 688 |
-
|
|
|
|
| 689 |
}
|
| 690 |
};
|
| 691 |
|
|
@@ -1262,10 +1295,16 @@
|
|
| 1262 |
)}
|
| 1263 |
</div>
|
| 1264 |
{profile.role === ROLES.HEAD_PREFECT && activeTab === "timetable" && (
|
| 1265 |
-
<
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1269 |
)}
|
| 1270 |
</div>
|
| 1271 |
|
|
|
|
| 192 |
const [activeTeacherSession, setActiveTeacherSession] = useState("MORNING");
|
| 193 |
const [selectedReportDay, setSelectedReportDay] = useState(() => { const d = new Date().getDay(); const idx = d === 0 || d === 6 ? 2 : d - 1; return DAYS[idx] || DAYS[2]; });
|
| 194 |
const [isGenerating, setIsGenerating] = useState(false);
|
| 195 |
+
const [isEmailing, setIsEmailing] = useState(false);
|
| 196 |
const [hideFormLevel, setHideFormLevel] = useState(false);
|
| 197 |
const fileInputRef = useRef(null);
|
| 198 |
|
|
|
|
| 524 |
reader.readAsText(file);
|
| 525 |
};
|
| 526 |
|
| 527 |
+
const buildTimetablePdf = (scheduleShifts, generatedAt = new Date()) => {
|
| 528 |
+
const jsPDF = window.jspdf && window.jspdf.jsPDF;
|
| 529 |
+
if (!jsPDF) throw new Error("PDF library did not load. Please refresh the page and try again.");
|
| 530 |
+
|
| 531 |
+
const safeShifts = Array.isArray(scheduleShifts) ? scheduleShifts : [];
|
| 532 |
+
const fileName = "Prefect_Duty_Timetable_" + generatedAt.toISOString().slice(0, 10) + ".pdf";
|
| 533 |
+
const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" });
|
| 534 |
+
|
| 535 |
+
doc.setFont("helvetica", "bold");
|
| 536 |
+
doc.setFontSize(18);
|
| 537 |
+
doc.text("Prefect Duty Timetable", 14, 16);
|
| 538 |
+
doc.setFont("helvetica", "normal");
|
| 539 |
+
doc.setFontSize(9);
|
| 540 |
+
doc.text("Generated by Prefect Duty Manager", 14, 22);
|
| 541 |
+
doc.text("Generated: " + generatedAt.toLocaleString(), 14, 27);
|
| 542 |
+
|
| 543 |
+
const headers = ["Day", "Session", "Location", "Prefect", "Status"];
|
| 544 |
+
const rows = safeShifts
|
| 545 |
+
.map((s) => [s.day || "", s.sessionName || s.sessionId || "", s.locationName || s.locationId || "", s.userName || "Unassigned", s.status || "pending"])
|
| 546 |
+
.sort((a, b) => (String(a[0]) + String(a[1]) + String(a[2])).localeCompare(String(b[0]) + String(b[1]) + String(b[2])));
|
| 547 |
+
|
| 548 |
+
const colX = [14, 43, 92, 160, 232];
|
| 549 |
+
const rowH = 7;
|
| 550 |
+
let y = 35;
|
| 551 |
+
|
| 552 |
+
const drawHeader = () => {
|
| 553 |
+
doc.setFont("helvetica", "bold");
|
| 554 |
+
doc.setFontSize(8);
|
| 555 |
+
headers.forEach((h, i) => doc.text(h, colX[i], y));
|
| 556 |
+
doc.line(14, y + 2, 272, y + 2);
|
| 557 |
+
y += rowH;
|
| 558 |
+
doc.setFont("helvetica", "normal");
|
| 559 |
+
};
|
| 560 |
+
|
| 561 |
+
drawHeader();
|
| 562 |
+
rows.forEach((row) => {
|
| 563 |
+
if (y > 190) {
|
| 564 |
+
doc.addPage();
|
| 565 |
+
y = 15;
|
| 566 |
+
drawHeader();
|
| 567 |
+
}
|
| 568 |
+
doc.setFontSize(7);
|
| 569 |
+
row.forEach((value, i) => {
|
| 570 |
+
let text = String(value);
|
| 571 |
+
const maxChars = i === 0 ? 16 : i === 1 ? 27 : i === 2 ? 34 : i === 3 ? 34 : 12;
|
| 572 |
+
if (text.length > maxChars) text = text.slice(0, maxChars - 1) + "…";
|
| 573 |
+
doc.text(text, colX[i], y);
|
| 574 |
+
});
|
| 575 |
+
doc.setDrawColor(220);
|
| 576 |
+
doc.line(14, y + 2, 272, y + 2);
|
| 577 |
+
doc.setDrawColor(0);
|
| 578 |
+
y += rowH;
|
| 579 |
+
});
|
| 580 |
+
|
| 581 |
+
if (rows.length === 0) {
|
| 582 |
+
doc.setFontSize(10);
|
| 583 |
+
doc.text("No duty timetable records found.", 14, y + 8);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
doc.save(fileName);
|
| 587 |
+
return fileName;
|
| 588 |
+
};
|
| 589 |
+
|
| 590 |
const generateTimetable = async () => {
|
| 591 |
if (!profile || profile.role !== ROLES.HEAD_PREFECT) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
setIsGenerating(true);
|
| 593 |
try {
|
| 594 |
const deleteOps = [];
|
|
|
|
| 599 |
const prefects = users.filter((u) => u.role === ROLES.PREFECT || u.role === ROLES.HEAD_PREFECT);
|
| 600 |
if (prefects.length === 0) {
|
| 601 |
showToast("No prefects registered to assign!", "error");
|
|
|
|
| 602 |
return;
|
| 603 |
}
|
| 604 |
|
| 605 |
const assignmentCounts = {};
|
| 606 |
prefects.forEach((p) => { assignmentCounts[p.id] = 0; });
|
| 607 |
const createOps = [];
|
| 608 |
+
const generatedShifts = [];
|
| 609 |
let unassignedSlots = 0;
|
| 610 |
|
| 611 |
DAYS.forEach((day) => {
|
|
|
|
| 629 |
const chosen = candidates[Math.floor(Math.random() * candidates.length)];
|
| 630 |
assignmentCounts[chosen.id]++;
|
| 631 |
usedThisDay.add(chosen.id);
|
| 632 |
+
|
| 633 |
const newRef = db.collection("shifts").doc();
|
| 634 |
+
const shiftData = {
|
| 635 |
+
id: newRef.id,
|
| 636 |
+
day,
|
| 637 |
+
sessionId: session.id,
|
| 638 |
+
sessionName: session.name,
|
| 639 |
+
locationId: loc.id,
|
| 640 |
+
locationName: loc.name,
|
| 641 |
+
userId: chosen.id,
|
| 642 |
+
userName: chosen.name,
|
| 643 |
+
status: "pending",
|
| 644 |
+
swapStatus: "none"
|
| 645 |
+
};
|
| 646 |
+
generatedShifts.push(shiftData);
|
| 647 |
createOps.push((batch) => {
|
| 648 |
+
const { id, ...data } = shiftData;
|
| 649 |
+
batch.set(newRef, data);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
});
|
| 651 |
});
|
| 652 |
});
|
|
|
|
| 654 |
|
| 655 |
if (createOps.length === 0) {
|
| 656 |
showToast("Not enough prefects to fill any slots.", "error");
|
|
|
|
| 657 |
return;
|
| 658 |
}
|
|
|
|
| 659 |
|
| 660 |
+
await commitInBatches(createOps);
|
| 661 |
+
showToast("New timetable generated and saved. No email was sent.", "success");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
|
| 663 |
+
// The schedule generator ONLY generates/saves. Emailing is a separate action.
|
| 664 |
+
// Keep generatedShifts available so the UI can immediately reflect what was created.
|
| 665 |
+
console.info("Generated timetable:", generatedShifts.length, "assignments;", unassignedSlots, "unassigned slots.");
|
| 666 |
+
} catch (error) {
|
| 667 |
+
console.error("Error generating timetable:", error);
|
| 668 |
+
showToast("Failed to generate timetable: " + (error.message || "unknown"), "error");
|
| 669 |
+
} finally {
|
| 670 |
+
setIsGenerating(false);
|
| 671 |
+
}
|
| 672 |
+
};
|
| 673 |
|
| 674 |
+
const emailCurrentSchedule = async () => {
|
| 675 |
+
if (!profile || profile.role !== ROLES.HEAD_PREFECT) return;
|
| 676 |
+
if (!user || !user.email) {
|
| 677 |
+
showToast("You must be signed in with Gmail to email the timetable.", "error");
|
| 678 |
+
return;
|
| 679 |
+
}
|
| 680 |
|
| 681 |
+
setIsEmailing(true);
|
| 682 |
+
try {
|
| 683 |
+
// Read the current schedule directly from Firestore so this button NEVER regenerates it.
|
| 684 |
+
const snap = await db.collection("shifts").get();
|
| 685 |
+
const currentShifts = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
| 686 |
+
if (currentShifts.length === 0) {
|
| 687 |
+
showToast("There is no timetable to email yet. Generate a schedule first.", "error");
|
| 688 |
+
return;
|
| 689 |
+
}
|
| 690 |
|
| 691 |
+
const emailList = users
|
| 692 |
+
.filter((u) => u.role === ROLES.PREFECT || u.role === ROLES.HEAD_PREFECT)
|
| 693 |
+
.map((p) => p.email)
|
| 694 |
+
.filter((e) => e && e.includes("@"));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
|
| 696 |
+
const generatedAt = new Date();
|
| 697 |
+
const fileName = buildTimetablePdf(currentShifts, generatedAt);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
|
| 699 |
+
if (emailList.length === 0) {
|
| 700 |
+
showToast("PDF downloaded as " + fileName + ". No prefect Gmail addresses are stored yet.", "info");
|
| 701 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
}
|
| 703 |
+
|
| 704 |
+
const subject = encodeURIComponent("Current Prefect Duty Timetable");
|
| 705 |
+
const body = encodeURIComponent(
|
| 706 |
+
"Hi Prefects,\n\n" +
|
| 707 |
+
"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" +
|
| 708 |
+
"Please attach the downloaded PDF: " + fileName + "\n\n" +
|
| 709 |
+
"Sent by: " + (user.displayName || user.email) + "\n" +
|
| 710 |
+
"Prepared: " + generatedAt.toLocaleString() + "\n\n" +
|
| 711 |
+
"Thank you!"
|
| 712 |
+
);
|
| 713 |
+
const to = encodeURIComponent(emailList.join(","));
|
| 714 |
+
const gmailUrl = "https://mail.google.com/mail/?view=cm&fs=1&to=" + to + "&su=" + subject + "&body=" + body;
|
| 715 |
+
window.open(gmailUrl, "_blank", "noopener,noreferrer");
|
| 716 |
+
showToast("Current schedule PDF downloaded. Gmail opened — attach " + fileName + " and press Send.", "success");
|
| 717 |
} catch (error) {
|
| 718 |
+
console.error("Error emailing current timetable:", error);
|
| 719 |
+
showToast("Failed to prepare email: " + (error.message || "unknown error"), "error");
|
| 720 |
+
} finally {
|
| 721 |
+
setIsEmailing(false);
|
| 722 |
}
|
| 723 |
};
|
| 724 |
|
|
|
|
| 1295 |
)}
|
| 1296 |
</div>
|
| 1297 |
{profile.role === ROLES.HEAD_PREFECT && activeTab === "timetable" && (
|
| 1298 |
+
<div className="w-full md:w-auto flex flex-col sm:flex-row gap-2">
|
| 1299 |
+
<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">
|
| 1300 |
+
{isGenerating ? <Icon name="refresh-cw" size={18} className="animate-spin" /> : <Icon name="sparkles" size={18} />}
|
| 1301 |
+
{isGenerating ? "Generating..." : "Generate Schedule"}
|
| 1302 |
+
</button>
|
| 1303 |
+
<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">
|
| 1304 |
+
{isEmailing ? <Icon name="refresh-cw" size={18} className="animate-spin" /> : <Icon name="mail" size={18} />}
|
| 1305 |
+
{isEmailing ? "Preparing Email..." : "Email Current Schedule"}
|
| 1306 |
+
</button>
|
| 1307 |
+
</div>
|
| 1308 |
)}
|
| 1309 |
</div>
|
| 1310 |
|