+
diff --git a/frontend/src/pages/SendData.tsx b/frontend/src/pages/SendData.tsx
new file mode 100644
index 0000000..597084e
--- /dev/null
+++ b/frontend/src/pages/SendData.tsx
@@ -0,0 +1,63 @@
+import { Link } from "react-router-dom";
+import Footer from "../components/Footer";
+import BackButton from "../components/BackButton";
+import NextButton from "../components/NextButton";
+import Header from "../components/Header";
+import { DB_ROOMS, DB_UNITS, getFile, getSpreadsheetData } from "../scripts/persistence";
+import { getTimetableProblem } from "../scripts/handleInput";
+import { useState } from "react";
+import { fetchTimetableSolution } from "../scripts/api";
+
+/**
+ * Page for containing UI elements that allow user to send input data to backend.
+ * Temporarily has a display for backend's response to confirm successful sending
+ * (will remove and replace with display page in next sprint).
+ *
+ * @returns button for sending timetable problem and temporary display for timetable solution.
+ * TODO: change button and UI elements to fit with VIT themes.
+ */
+export default function SendData() {
+
+ const [isGenerated, setIsGenerated] = useState("");
+
+ function generateTimetable() {
+ setIsGenerated("");
+ Promise.all([getFile(), getSpreadsheetData(DB_ROOMS), getSpreadsheetData(DB_UNITS)])
+ .then((responses) => {
+ const [enrolment, roomData, unitData] = [...responses];
+ if (!roomData) {
+ throw new Error("Error: room data not available");
+ }
+ else if (!unitData) {
+ throw new Error("Error: unit data not available");
+ }
+ return getTimetableProblem(enrolment, roomData, unitData);
+ })
+ .then((problem) => {
+ return fetchTimetableSolution(problem);
+ })
+ .then((solution) => {
+ console.log(solution);
+ setIsGenerated(JSON.stringify(solution, null, 2));
+ })
+ .catch((error) => {
+ alert(error);
+ })
+ }
+
+ return (
+ <>
+
+
+
{isGenerated.toString()}
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/pages/TimetableMod.tsx b/frontend/src/pages/TimetableMod.tsx
index b4900b7..ff9a38b 100644
--- a/frontend/src/pages/TimetableMod.tsx
+++ b/frontend/src/pages/TimetableMod.tsx
@@ -1,10 +1,17 @@
import { Link } from "react-router-dom";
+/**
+ * Renders the TimetableMod component to display and modify the generated
+ * timetable.
+ * Allows users to navigate back to the campus information page and proceed to
+ * the download page.
+ * @returns JSX element containing the page content with navigation links
+ */
export default function TimetableMod() {
return (
<>
This is the page to modify generated timetable
-
Go Back
+
Go Back
Go to Next
>
)
diff --git a/frontend/src/pages/spreadsheets/Building.tsx b/frontend/src/pages/spreadsheets/Building.tsx
index ca43b12..be3ccc9 100644
--- a/frontend/src/pages/spreadsheets/Building.tsx
+++ b/frontend/src/pages/spreadsheets/Building.tsx
@@ -1,11 +1,19 @@
import Spreadsheet from '../../components/Spreadsheet.tsx'
+import { DB_BUILDINGS } from '../../scripts/persistence.ts';
+/**
+ *
+ * @returns Spreadsheet input page for buildings information.
+ */
export default function Building() {
return (
<>
Building
-
+
>
);
};
\ No newline at end of file
diff --git a/frontend/src/pages/spreadsheets/Campus.tsx b/frontend/src/pages/spreadsheets/Campus.tsx
deleted file mode 100644
index 3515dff..0000000
--- a/frontend/src/pages/spreadsheets/Campus.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import Spreadsheet from '../../components/Spreadsheet.tsx'
-
-export default function Campus() {
-
- return (
- <>
-
Campus
-
- >
- );
-};
\ No newline at end of file
diff --git a/frontend/src/pages/spreadsheets/Room.tsx b/frontend/src/pages/spreadsheets/Room.tsx
index 64aa2f7..e07db9d 100644
--- a/frontend/src/pages/spreadsheets/Room.tsx
+++ b/frontend/src/pages/spreadsheets/Room.tsx
@@ -1,13 +1,19 @@
import Spreadsheet from '../../components/Spreadsheet.tsx'
+import { DB_ROOMS } from '../../scripts/persistence.ts';
+/**
+ *
+ * @returns Spreadsheet input page for rooms information.
+ */
export default function Room() {
return (
<>
Room
>
);
diff --git a/frontend/src/pages/spreadsheets/Unit.tsx b/frontend/src/pages/spreadsheets/Unit.tsx
index bb35ddb..b747cc3 100644
--- a/frontend/src/pages/spreadsheets/Unit.tsx
+++ b/frontend/src/pages/spreadsheets/Unit.tsx
@@ -1,11 +1,20 @@
import Spreadsheet from '../../components/Spreadsheet.tsx'
+import { DB_UNITS } from '../../scripts/persistence.ts';
+/**
+ *
+ * @returns Spreadsheet input page for units information.
+ */
export default function Unit() {
return (
<>
Unit
-
+
>
);
};
\ No newline at end of file
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
index fdfba8e..a0d04eb 100644
--- a/frontend/src/routes.tsx
+++ b/frontend/src/routes.tsx
@@ -1,13 +1,18 @@
import SemesterInfo from './pages/SemesterInfo.tsx'
import TimetableMod from './pages/TimetableMod.tsx'
-import Campus from './pages/spreadsheets/Campus.tsx'
import Building from './pages/spreadsheets/Building.tsx'
import Room from './pages/spreadsheets/Room.tsx'
import Unit from './pages/spreadsheets/Unit.tsx'
import Download from './pages/Download.tsx'
import Enrolment from './pages/Enrolment.tsx'
+import SendData from './pages/SendData.tsx'
-
+/**
+ * Defines the routes configuration for the application.
+ * Each route specifies a path and the corresponding component to render.
+ *
+ * An array of route objects, each containing path and element information.
+ */
const routes = [
{
path: "/",
@@ -17,12 +22,15 @@ const routes = [
path: "seminfo",
element:
,
children: [
- { path: "campus", element:
},
{ path: "building", element:
},
{ path: "room", element:
},
{ path: "unit", element:
},
],
},
+ {
+ path: "senddata",
+ element:
,
+ },
{
path: "timetablemod",
element:
,
diff --git a/frontend/src/scripts/api.ts b/frontend/src/scripts/api.ts
new file mode 100644
index 0000000..81fa9af
--- /dev/null
+++ b/frontend/src/scripts/api.ts
@@ -0,0 +1,81 @@
+/* Timetable solver backend endpoint URL */
+const API_URL = 'http://localhost:8080/timetabling';
+
+/* =========================================== Defining types =========================================== */
+
+export type TimetableProblem = TimetableBase & {
+ units: Unit[],
+}
+
+export type TimetableSolution = TimetableBase & {
+ units: Required
[],
+}
+
+export type TimetableBase = {
+ daysOfWeek: Weekday[],
+ startTimes: Time[],
+ rooms: Room[]
+}
+
+export type Unit = {
+ unitID: number,
+ name: string,
+ duration: number,
+ students: Student[],
+ wantsLab: boolean,
+ // fields to be assigned by backend's algorithm
+ room?: Room,
+ studentSize?: number
+ dayOfWeek?: Weekday,
+ startTime?: Time,
+ end?: Time,
+};
+
+export type Student = {
+ name: string
+};
+
+export type Room = {
+ id: string,
+ capacity: number,
+ lab: boolean
+}
+
+export type Weekday = "MONDAY" | "TUESDAY" | "WEDNESDAY" | "THURSDAY" | "FRIDAY"
+
+export type Time = string;
+
+/* ====================================================================================================== */
+
+
+/**
+ * Sends the timetabling problem to backend for solving. Return the solution received.
+ *
+ * @param problem A TimetableProblem is a list of units with no allocated time and room.
+ * @returns A TimetableSolution with all units allocated a time and a room.
+ */
+export async function fetchTimetableSolution(problem: TimetableProblem): Promise {
+ try {
+ const response = await fetch(API_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(problem)
+ });
+
+ if (!response.ok) {
+ if (response.status === 500) {
+ alert(response.statusText + " " + response.status + ": server was not able to solve the problem. Please check for missing input (i.e. make sure there are at least 1 available room and no rooms with duplicate ID).");
+ }
+ throw new Error(`HTTP error! Status: ${response.status} ${response.statusText}`);
+ }
+
+ const solution: TimetableSolution = await response.json();
+ return solution;
+ }
+ catch (error) {
+ console.log(error);
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/scripts/handleInput.ts b/frontend/src/scripts/handleInput.ts
new file mode 100644
index 0000000..629f45a
--- /dev/null
+++ b/frontend/src/scripts/handleInput.ts
@@ -0,0 +1,164 @@
+import readXlsxFile, { Row } from 'read-excel-file';
+import { CellValue } from 'jspreadsheet-ce';
+import { TimetableProblem, Unit, Room } from './api';
+import { DB_UNITS, storeSpreadsheetData } from './persistence';
+
+/**
+ * Function to validate uploaded enrolment data file.
+ *
+ * @param file enrolment data Excel file
+ * @returns true if uploaded file is an Excel file
+ */
+function isExcelFile(file: File) {
+ const fileExtension = file.name.split('.').pop();
+ if (fileExtension === undefined || !['xlsx', 'xls'].includes(fileExtension)) {
+ alert("Wrong file type, file type must be .xlsx or .xls");
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Function to validate uploaded enrolment data file.
+ *
+ * @param inputHeader header row of enrolment data Excel file
+ * @returns true if header row matches expected format for parsing.
+ */
+function validateEnrolmentHeader(inputHeader: Row) {
+ const header = ['StudentID', 'Student Name', 'Personal Email', 'University Email',
+ 'Student Type', 'Offer Type', 'Course Name', 'Campus', 'Original COE Start Date',
+ 'Course Start Date', 'Course End Date', 'COE Status', 'Specialisation', 'Pathway Indicator'];
+
+ if (inputHeader.length >= header.length && JSON.stringify(header) === JSON.stringify(inputHeader.slice(0, header.length))) {
+ return true;
+ }
+ else {
+ alert("Enrolment data header row is invalid");
+ return false;
+ }
+}
+
+/**
+ * Extract list of units from enrolment data and prefill spreadsheet input page.
+ *
+ * @param enrolmentExcel enrolment data Excel file
+ * @returns enrolment data Excel file
+ */
+export async function getUnitsList(enrolmentExcel: File) {
+ if (!isExcelFile(enrolmentExcel)) {
+ throw new Error(
+ "File is not .xlsx or .xls"
+ )
+ }
+
+ const [header] = await readXlsxFile(enrolmentExcel);
+
+ if (!validateEnrolmentHeader(header)) {
+ throw new Error(
+ "Enrolment data has wrong headers"
+ )
+ }
+
+ // console.log(header.slice(14));
+ const unitsList = header.slice(14).map(elem => elem.toString());
+ const unitsData: Record[] = unitsList.map((u) => {
+ return { 0: u, 1: '', 2: '', 3: '' };
+ });
+
+ storeSpreadsheetData(unitsData, DB_UNITS);
+
+ return enrolmentExcel;
+}
+
+/**
+ * Parse user input to create the timetabling problem.
+ *
+ * @param enrolmentExcel enrolment data Excel file
+ * @param roomSpreadsheet information of all rooms (spreadsheet input from user)
+ * @param unitSpreadsheet information of all units (spreadsheet input from user)
+ * @returns a TimetableProblem, which includes all available rooms, start times and unallocated units
+ */
+export async function getTimetableProblem(enrolmentExcel: File, roomSpreadsheet: Record[], unitSpreadsheet: Record[]) {
+ if (!isExcelFile(enrolmentExcel)) {
+ throw new Error(
+ "File is not .xlsx or .xls"
+ )
+ }
+
+ const [header, ...body] = await readXlsxFile(enrolmentExcel);
+
+ if (!validateEnrolmentHeader(header)) {
+ throw new Error(
+ "Enrolment data has wrong headers"
+ )
+ }
+
+ const unitsList = header.slice(14);
+ const units: Unit[] = unitsList.map((value, index) => {
+ return {
+ unitID: index,
+ name: value.toString(),
+ duration: 0,
+ students: [],
+ wantsLab: false
+ }
+ });
+
+ unitSpreadsheet.map((record, index) => {
+ if (index >= units.length) {
+ }
+ else {
+ const totalDuration = (Number(record['1']) + Number(record['2']) + Number(record['3'])) * 60;
+ const wantsLab = Number(record['3']) > 0;
+ units[index].duration = totalDuration;
+ units[index].wantsLab = wantsLab;
+ }
+ })
+
+ // check each row and add students to each unit they're enrolled in
+ for (let i = 0; i < body.length; i++) {
+ const enrolments = body[i].slice(14);
+ for (let j = 0; j < enrolments.length; j++) {
+ if (enrolments[j] === "ENRL") {
+ units[j].students.push({
+ name: body[i][0].toString()
+ })
+ }
+ }
+ }
+
+ const rooms: Room[] = roomSpreadsheet
+ .filter((record) => record['5'] as boolean)
+ .map((record) => {
+ return {
+ id: record['2'] as string,
+ capacity: record['3'] as number,
+ lab: record['4'] as boolean
+ }
+ });
+
+
+ const problem: TimetableProblem = {
+ units: units,
+ daysOfWeek: [
+ "MONDAY",
+ "TUESDAY",
+ "WEDNESDAY",
+ "THURSDAY",
+ "FRIDAY"
+ ],
+ startTimes: [
+ "08:00:00",
+ "09:00:00",
+ "10:00:00",
+ "11:00:00",
+ "12:00:00",
+ "13:00:00",
+ ],
+ rooms: rooms
+ }
+
+ console.log(problem);
+
+ return problem;
+}
diff --git a/frontend/src/scripts/persistence.ts b/frontend/src/scripts/persistence.ts
new file mode 100644
index 0000000..ecb9516
--- /dev/null
+++ b/frontend/src/scripts/persistence.ts
@@ -0,0 +1,147 @@
+import Dexie, { EntityTable } from "dexie";
+import { CellValue } from "jspreadsheet-ce";
+
+/* Define storage keys */
+const DB_NAME = 'TimetableInput';
+const DB_BUILDINGS = 'buildings';
+const DB_ROOMS = 'rooms';
+const DB_UNITS = 'units';
+
+/* Define record type of `file` objectStore */
+interface FileRecord {
+ id: number;
+ file: File;
+}
+
+/* Define record type of each spreadsheets objectStore */
+interface SpreadsheetRecord {
+ id?: number;
+ record: Record;
+}
+
+/* Initialise Dexie (indexedDB wrapper) */
+const db = new Dexie(DB_NAME) as Dexie & {
+ files: EntityTable;
+ buildings: EntityTable;
+ rooms: EntityTable;
+ units: EntityTable;
+};
+
+/* Initialise indexedDB objectStores */
+db.version(1).stores({
+ files: '++id, file',
+ buildings: '++id, record',
+ rooms: '++id, record',
+ units: '++id, record'
+});
+
+/**
+ * Save 1 file to the first row of the file table.
+ * Delete all other files.
+ * Intend for storing enrolment data Excel file.
+ *
+ * @param file any file
+ * @returns id of saved row
+ */
+export async function storeFile(file: File): Promise {
+ await db.files.clear();
+ const id = await db.files.add({
+ id: 0,
+ file: file
+ });
+ return id;
+}
+
+/**
+ * Get first file saved to the `file` objectStore.
+ * Intend for getting saved enrolment data Excel file.
+ *
+ * @returns file saved
+ */
+export async function getFile(): Promise {
+ const file = await db.files.orderBy('id').first();
+
+ if (file === undefined) {
+ throw new Error(
+ "getFile() failed"
+ );
+ }
+ return file.file;
+}
+
+/**
+ * Store data of an input spreadsheet to the corresponding objectStore in indexedDB.
+ *
+ * @param data data in one spreadsheet page
+ * @param storageObject the key of the objectStore (table) of the database we want to store data into
+ * @returns void
+ */
+export async function storeSpreadsheetData(data: Record[], storageObject: string) {
+ if (!data) {
+ return;
+ }
+
+ const records: SpreadsheetRecord[] = data.map((obj, idx) => {
+ return {
+ id: idx,
+ record: obj
+ }
+ });
+
+ try {
+ if (storageObject === DB_BUILDINGS) {
+ await db.buildings.clear();
+ await db.buildings.bulkPut(records);
+ }
+ else if (storageObject === DB_ROOMS) {
+ await db.rooms.clear();
+ await db.rooms.bulkPut(records);
+ }
+ else if (storageObject === DB_UNITS) {
+ await db.units.clear();
+ await db.units.bulkPut(records);
+ }
+ else {
+ throw new Error(
+ "storeSpreadsheetData: storageObject does not exist"
+ )
+ }
+ }
+ catch (error) {
+ console.log(error);
+ }
+}
+
+/**
+ * Get data from a specified indexedDB objectStore.
+ *
+ * @param storageObject the key of the objectStore (table) that we want to get data out of
+ * @returns spreadsheet data
+ */
+export async function getSpreadsheetData(storageObject: string): Promise[] | null> {
+ try {
+ if (storageObject === "buildings") {
+ const data = await db.buildings.toArray();
+ return data.map((obj) => obj.record);
+ }
+ else if (storageObject === "rooms") {
+ const data = await db.rooms.toArray();
+ return data.map((obj) => obj.record);
+ }
+ else if (storageObject === "units") {
+ const data = await db.units.toArray();
+ return data.map((obj) => obj.record);
+ }
+ else {
+ throw new Error(
+ "getSpreadsheetData: storageObject does not exist"
+ )
+ }
+ }
+ catch (error) {
+ console.log(error);
+ return null;
+ }
+}
+
+export { DB_BUILDINGS, DB_ROOMS, DB_UNITS }
\ No newline at end of file
diff --git a/frontend/src/styles/enrolment.css b/frontend/src/styles/enrolment.css
index ca0602e..f2bcc26 100644
--- a/frontend/src/styles/enrolment.css
+++ b/frontend/src/styles/enrolment.css
@@ -1,13 +1,11 @@
-/* Make sure the entire viewport height is used */
.app-container {
display: flex;
flex-direction: column;
- min-height: 100vh; /* Full viewport height */
+ min-height: 100vh;
}
-/* Content area that grows to fill space */
.content {
- flex: 1; /* This allows the content to grow and push the footer down */
+ flex: 1;
padding: 0;
display: grid;
grid-template-columns: 1fr 1fr;
@@ -27,7 +25,7 @@
}
.imageBox {
- overflow: hidden; /* Hide any overflow content */
+ overflow: hidden;
position: relative;
}
body {
diff --git a/frontend/src/styles/seminfo.css b/frontend/src/styles/seminfo.css
index c11a47f..fb4ff8c 100644
--- a/frontend/src/styles/seminfo.css
+++ b/frontend/src/styles/seminfo.css
@@ -3,5 +3,5 @@
display: flex;
justify-content: space-around;
align-items: center;
- gap: 500px;
+ gap: 50%;
}
\ No newline at end of file
diff --git a/frontend/src/tests/api.test.ts b/frontend/src/tests/api.test.ts
new file mode 100644
index 0000000..e6bffd0
--- /dev/null
+++ b/frontend/src/tests/api.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect } from 'vitest';
+import { fetchTimetableSolution, TimetableProblem } from '../scripts/api';
+import moment from 'moment';
+
+/**
+ * Test fetchTimetableSolution API method.
+ * Check if connection to backend is working.
+ * Check that output matches expected output.
+ */
+describe('fetchTimetableSolution', () => {
+ /**
+ * Validate end-to-end scheduling and data consistency of 1 API method call.
+ */
+ it('return TimetableSolution', async () => {
+ const problem: TimetableProblem = {
+ units: [{ unitID: 0, name: "Unit0", duration: 1200, students: [], wantsLab: true }],
+ daysOfWeek: ["MONDAY"],
+ startTimes: ["11:00:00"],
+ rooms: [{ id: "Room A", capacity: 10, lab: true }]
+ };
+
+ const solution = await fetchTimetableSolution(problem);
+ expect(solution).not.toBeNull();
+ expect(solution?.units[0].dayOfWeek).toEqual(problem.daysOfWeek[0]);
+ expect(solution?.units[0].startTime).toEqual(problem.startTimes[0]);
+ expect(solution?.units[0].end).toEqual(addSecondsToTimeString(problem.startTimes[0], problem.units[0].duration));
+ expect(solution?.units[0].room).toEqual(problem.rooms[0]);
+ expect(solution?.daysOfWeek).toEqual(problem.daysOfWeek);
+ expect(solution?.startTimes).toEqual(problem.startTimes);
+ expect(solution?.rooms).toEqual(problem.rooms);
+
+ });
+
+ /**
+ * Validate that backend server can handle multiple solve requests concurrently.
+ */
+ it ('can be called multiple times', async () => {
+ const problem: TimetableProblem = {
+ units: [{ unitID: 0, name: "Unit0", duration: 1200, students: [], wantsLab: true }],
+ daysOfWeek: ["MONDAY"],
+ startTimes: ["11:00:00"],
+ rooms: [{ id: "Room A", capacity: 10, lab: true }]
+ };
+
+ const solutions = await Promise.all([fetchTimetableSolution(problem), fetchTimetableSolution(problem), fetchTimetableSolution(problem)]);
+
+ for (let i = 0; i < solutions.length; i++) {
+ expect(solutions[i]).not.toBeNull();
+ }
+
+ });
+
+});
+
+/**
+ * Helper function.
+ * Add a string representing time with a number representing number of seconds to add.
+ *
+ * @param timeString string representing time
+ * @param secondsToAdd number in seconds
+ * @returns string representing time after specified seconds have been added to it
+ */
+function addSecondsToTimeString(timeString: string, secondsToAdd: number) {
+ const time = moment(timeString, 'HH:mm:ss');
+ time.add(secondsToAdd, 'seconds');
+ return time.format('HH:mm:ss');
+}
\ No newline at end of file
diff --git a/frontend/static.json b/frontend/static.json
new file mode 100644
index 0000000..94fb8eb
--- /dev/null
+++ b/frontend/static.json
@@ -0,0 +1,3 @@
+{
+ "root": "./dist"
+}
\ No newline at end of file