Files
itsc-timetable/components/Timetable.tsx
2025-05-07 15:36:33 +08:00

303 lines
8.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from "react";
import { get, post } from "@/common";
import { collection } from "@/store";
interface Conflicts {
[index: string]: HTMLInputElement[];
}
interface ConflictsTmp {
[index: string]: Set<string>;
}
interface IndexToElement {
[index: string]: HTMLInputElement;
}
interface IndexToCell {
[index: string]: HTMLTableCellElement;
}
const indexToElement: IndexToElement = {};
const indexToCell: IndexToCell = {};
const conflicts: Conflicts = {};
const marks: (HTMLInputElement | null)[][] = [];
const tds: (HTMLTableCellElement | null)[][] = [];
const downloadObjectAsJson = (exportObj: any, exportName: string) => {
const dataStr =
"data:text/json;charset=utf-8," +
encodeURIComponent(JSON.stringify(exportObj));
const downloadAnchorNode = document.createElement("a");
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", `${exportName}.json`);
document.body.appendChild(downloadAnchorNode); // required for firefox
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
const Timetable = ({
user,
isRegular = false,
disableNetwork = false,
disableConflictCheck = false,
replaceInputType = "checkbox",
apiRecordEndPoint = "/api/record",
openRecordMode = false,
hideDownloadButton = true,
}) => {
const [editable, setEditable] = React.useState(true);
const ref = React.useRef();
const handleSelect = async (event: Event) => {
const target: HTMLInputElement = event.target;
console.log("select", target.name, target.checked);
if (disableConflictCheck) return;
const changedInputs: any = [];
// find whether there are checked input in conflict
for (const input of conflicts[target.name]) {
if (input.name === target.name) continue;
if (input.checked) {
alert("Error: Conflict select");
location.reload();
return;
}
}
for (const input of conflicts[target.name]) {
if (input.name === target.name) continue;
if (target.checked) {
if (input.getAttribute("disabled") === null) {
input.setAttribute("disabled", "true");
changedInputs.push({ input, disable: false });
}
} else {
if (input.getAttribute("disabled") === "true") {
input.removeAttribute("disabled");
changedInputs.push({ input, disable: true });
}
}
}
if (disableNetwork) return;
// Immediately revert the checkbox state
target.checked = !target.checked;
// post request
const json = await post("/api/record", {
name: target.name,
checked: !target.checked,
user,
});
if (json.error !== undefined) {
alert(json.error);
// revert conflict changed input
for (const { input, disable } of changedInputs) {
if (disable) {
input.setAttribute("disabled", "true");
} else {
input.removeAttribute("disabled");
}
}
}
};
const handleInput = (event: React.ChangeEvent<HTMLInputElement>): boolean => {
const { target } = event;
// validate
if (target?.children[0]?.tagName !== "TABLE") {
console.log("not a table");
return false;
}
// turn off editable
setEditable(false);
// empty marks
marks.length = 0;
// empty tds
tds.length = 0;
const table = target.children[0];
table.setAttribute("border", "0");
// mark cell
const conflictsTmp: ConflictsTmp = {};
const tbody = table.children[table.children.length - 1];
for (const tr_index in tbody.children) {
const tr = tbody.children[tr_index];
const row: (HTMLInputElement | null)[] = [];
const rowTD: (HTMLTableCellElement | null)[] = [];
for (const td_index in tr.children) {
const td: HTMLTableCellElement = tr.children[td_index];
if (td.tagName !== "TD") continue;
if (td.getAttribute("bgcolor")?.toUpperCase() !== "#39CEFF") {
// const position (assigned by supervisor)
if (td?.textContent?.trim() === user) {
const constSelected = document.createElement("input");
constSelected.setAttribute("type", "checkbox");
constSelected.setAttribute("checked", "1");
constSelected.setAttribute("disabled", "1");
td.innerHTML = "";
td.appendChild(constSelected);
}
row.push(null);
continue;
}
const index = `${tr_index},${td_index}`;
const placeholders = td.textContent?.trim().split(",");
if (placeholders === undefined) continue;
if (conflictsTmp[index] === undefined) conflictsTmp[index] = new Set();
for (const ph of placeholders) conflictsTmp[index].add(ph);
// mount click event
const input = document.createElement("input");
input.setAttribute("type", replaceInputType);
input.onchange = handleSelect;
input.name = index;
td.innerHTML = "";
td.appendChild(input);
indexToElement[index] = input;
indexToCell[index] = td;
row.push(input);
rowTD.push(td);
}
marks.push(row);
tds.push(rowTD);
// console.log("marks", marks);
}
// resolve conflicts
for (const index in conflictsTmp) {
if (conflicts[index] === undefined) conflicts[index] = [];
for (const ph of Array.from(conflictsTmp[index])) {
for (const conflictIndex in conflictsTmp) {
if (conflictsTmp[conflictIndex].has(ph))
conflicts[index].push(indexToElement[conflictIndex]);
}
}
}
console.log(conflicts);
return true;
};
const refresh = async () => {
const json = await get(`${apiRecordEndPoint}?name=${user}`);
if (openRecordMode) {
for (const index in json) {
const input = indexToElement[index];
const td = indexToCell[index];
if (json[index] !== user) {
td.innerHTML = json[index];
td.removeAttribute("bgcolor");
} else {
input.checked = true;
input.disabled = true;
}
}
return;
}
const occupied: string[] = json.occupied;
const myselect: string[] = json.myselect;
// console.log(json);
for (const index in indexToElement) {
if (occupied.includes(index)) {
indexToElement[index].style.display = "none";
} else {
indexToElement[index].style.display = "";
}
const includes = myselect.includes(index);
indexToElement[index].checked = includes;
// after checked, find conflicts input
if (disableConflictCheck) continue;
if (includes) {
for (const input of conflicts[index]) {
if (input.name === index) continue;
input.setAttribute("disabled", "true");
}
}
}
};
React.useEffect(() => {
if (disableNetwork || isRegular) return;
const interval = setInterval(() => {
refresh();
}, 1500);
return () => {
clearInterval(interval);
};
}, []);
React.useEffect(() => {
const main = async () => {
const json = await get(isRegular ? "/api/html-regular" : "/api/html");
ref.current.innerHTML = json.html;
handleInput({ target: ref.current });
refresh();
};
main();
// Auto refresh the entire timetable every 3 minutes
const fullRefreshInterval = setInterval(() => {
main();
}, 3 * 60 * 1000); // 3 minutes in milliseconds
return () => {
clearInterval(fullRefreshInterval);
};
}, []);
const DownloadMarks = async () => {
console.log("download marks", marks);
const data = {
user,
selections: {},
};
for (const row of marks) {
for (const input of row) {
if (input === null) continue;
if (input.checked) {
data.selections[input.name] = 1;
} else if (parseFloat(input.value)) {
data.selections[input.name] = parseFloat(input.value);
}
}
}
console.log(data);
downloadObjectAsJson(data, user);
};
return (
<>
<h2 style={{ textAlign: "center" }}>ITSC </h2>
<h3 style={{ textAlign: "center" }}>Login as {user}</h3>
<h3 style={{ textAlign: "center" }}></h3>
<div
align="center"
ref={ref}
contentEditable={editable}
style={{
overflow: "scroll",
}}
onInput={handleInput}
></div>
{!hideDownloadButton && (
<p style={{ display: "flex", justifyContent: "center" }}>
<button onClick={DownloadMarks}>Download Selection</button>
</p>
)}
<div style={{ display: "none" }} id="download-dom"></div>
</>
);
};
export default Timetable;