Implemented most of the app.
This commit is contained in:
parent
7166b995f7
commit
3a682e046d
|
@ -19,7 +19,8 @@ CREATE TABLE classroom_compliance_entry (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
student_id INTEGER NOT NULL REFERENCES classroom_compliance_student(id),
|
student_id INTEGER NOT NULL REFERENCES classroom_compliance_student(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
date TEXT NOT NULL,
|
date TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
absent INTEGER NOT NULL DEFAULT 0
|
absent INTEGER NOT NULL DEFAULT 0
|
||||||
|
|
|
@ -6,6 +6,10 @@ import d2sqlite3;
|
||||||
import slf4d;
|
import slf4d;
|
||||||
import std.typecons : Nullable;
|
import std.typecons : Nullable;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
import std.format;
|
||||||
|
import std.json;
|
||||||
|
import std.algorithm;
|
||||||
|
import std.array;
|
||||||
|
|
||||||
import db;
|
import db;
|
||||||
import data_utils;
|
import data_utils;
|
||||||
|
@ -58,11 +62,12 @@ void registerApiEndpoints(PathHandler handler) {
|
||||||
handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent);
|
handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent);
|
||||||
handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents);
|
handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents);
|
||||||
const STUDENT_PATH = CLASS_PATH ~ "/students/:studentId:ulong";
|
const STUDENT_PATH = CLASS_PATH ~ "/students/:studentId:ulong";
|
||||||
|
handler.addMapping(Method.GET, STUDENT_PATH, &getStudent);
|
||||||
handler.addMapping(Method.PUT, STUDENT_PATH, &updateStudent);
|
handler.addMapping(Method.PUT, STUDENT_PATH, &updateStudent);
|
||||||
handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent);
|
handler.addMapping(Method.DELETE, STUDENT_PATH, &deleteStudent);
|
||||||
|
|
||||||
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &createEntry);
|
|
||||||
handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries);
|
handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries);
|
||||||
|
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
void createClass(ref HttpRequestContext ctx) {
|
void createClass(ref HttpRequestContext ctx) {
|
||||||
|
@ -82,7 +87,7 @@ void createClass(ref HttpRequestContext ctx) {
|
||||||
);
|
);
|
||||||
if (classNumberExists) {
|
if (classNumberExists) {
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
ctx.response.writeBodyString("There is already a class with this number, for this school year.");
|
ctx.response.writeBodyString("There is already a class with this number, for the same school year.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
auto stmt = db.prepare("INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?)");
|
auto stmt = db.prepare("INSERT INTO classroom_compliance_class (number, school_year, user_id) VALUES (?, ?, ?)");
|
||||||
|
@ -139,6 +144,7 @@ void createStudent(ref HttpRequestContext ctx) {
|
||||||
struct StudentPayload {
|
struct StudentPayload {
|
||||||
string name;
|
string name;
|
||||||
ushort deskNumber;
|
ushort deskNumber;
|
||||||
|
bool removed;
|
||||||
}
|
}
|
||||||
auto payload = readJsonPayload!(StudentPayload)(ctx);
|
auto payload = readJsonPayload!(StudentPayload)(ctx);
|
||||||
auto db = getDb();
|
auto db = getDb();
|
||||||
|
@ -150,7 +156,7 @@ void createStudent(ref HttpRequestContext ctx) {
|
||||||
);
|
);
|
||||||
if (studentExists) {
|
if (studentExists) {
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
ctx.response.writeBodyString("Student with that name already exists.");
|
ctx.response.writeBodyString("A student with that name already exists in this class.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bool deskAlreadyOccupied = payload.deskNumber != 0 && canFind(
|
bool deskAlreadyOccupied = payload.deskNumber != 0 && canFind(
|
||||||
|
@ -161,11 +167,11 @@ void createStudent(ref HttpRequestContext ctx) {
|
||||||
);
|
);
|
||||||
if (deskAlreadyOccupied) {
|
if (deskAlreadyOccupied) {
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
ctx.response.writeBodyString("There is already a student assigned to that desk.");
|
ctx.response.writeBodyString("There is already a student assigned to that desk number.");
|
||||||
}
|
}
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO classroom_compliance_student (name, class_id, desk_number) VALUES (?, ?)",
|
"INSERT INTO classroom_compliance_student (name, class_id, desk_number, removed) VALUES (?, ?, ?, ?)",
|
||||||
payload.name, cls.id, payload.deskNumber
|
payload.name, cls.id, payload.deskNumber, payload.removed
|
||||||
);
|
);
|
||||||
ulong studentId = db.lastInsertRowid();
|
ulong studentId = db.lastInsertRowid();
|
||||||
auto student = findOne!(ClassroomComplianceStudent)(
|
auto student = findOne!(ClassroomComplianceStudent)(
|
||||||
|
@ -188,22 +194,30 @@ void getStudents(ref HttpRequestContext ctx) {
|
||||||
writeJsonBody(ctx, students);
|
writeJsonBody(ctx, students);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void getStudent(ref HttpRequestContext ctx) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto student = getStudentOrThrow(ctx, user);
|
||||||
|
writeJsonBody(ctx, student);
|
||||||
|
}
|
||||||
|
|
||||||
void updateStudent(ref HttpRequestContext ctx) {
|
void updateStudent(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
User user = getUserOrThrow(ctx);
|
||||||
auto student = getStudentOrThrow(ctx, user);
|
auto student = getStudentOrThrow(ctx, user);
|
||||||
struct StudentUpdatePayload {
|
struct StudentUpdatePayload {
|
||||||
string name;
|
string name;
|
||||||
ushort deskNumber;
|
ushort deskNumber;
|
||||||
|
bool removed;
|
||||||
}
|
}
|
||||||
auto payload = readJsonPayload!(StudentUpdatePayload)(ctx);
|
auto payload = readJsonPayload!(StudentUpdatePayload)(ctx);
|
||||||
// If there is nothing to update, quit.
|
// If there is nothing to update, quit.
|
||||||
if (
|
if (
|
||||||
payload.name == student.name
|
payload.name == student.name
|
||||||
&& payload.deskNumber == student.deskNumber
|
&& payload.deskNumber == student.deskNumber
|
||||||
|
&& payload.removed == student.removed
|
||||||
) return;
|
) return;
|
||||||
// Check that the new name doesn't already exist.
|
// Check that the new name doesn't already exist.
|
||||||
auto db = getDb();
|
auto db = getDb();
|
||||||
bool newNameExists = canFind(
|
bool newNameExists = payload.name != student.name && canFind(
|
||||||
db,
|
db,
|
||||||
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
|
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
|
||||||
payload.name,
|
payload.name,
|
||||||
|
@ -211,11 +225,11 @@ void updateStudent(ref HttpRequestContext ctx) {
|
||||||
);
|
);
|
||||||
if (newNameExists) {
|
if (newNameExists) {
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
ctx.response.writeBodyString("Student with that name already exists.");
|
ctx.response.writeBodyString("A student with that name already exists in this class.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check that if a new desk number is assigned, that it's not already assigned to anyone else.
|
// Check that if a new desk number is assigned, that it's not already assigned to anyone else.
|
||||||
bool newDeskOccupied = payload.deskNumber != 0 && canFind(
|
bool newDeskOccupied = payload.deskNumber != 0 && payload.deskNumber != student.deskNumber && canFind(
|
||||||
db,
|
db,
|
||||||
"SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?",
|
"SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?",
|
||||||
student.classId,
|
student.classId,
|
||||||
|
@ -227,9 +241,10 @@ void updateStudent(ref HttpRequestContext ctx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE classroom_compliance_student SET name = ?, desk_number = ? WHERE id = ?",
|
"UPDATE classroom_compliance_student SET name = ?, desk_number = ?, removed = ? WHERE id = ?",
|
||||||
payload.name,
|
payload.name,
|
||||||
payload.deskNumber,
|
payload.deskNumber,
|
||||||
|
payload.removed,
|
||||||
student.id
|
student.id
|
||||||
);
|
);
|
||||||
auto updatedStudent = findOne!(ClassroomComplianceStudent)(
|
auto updatedStudent = findOne!(ClassroomComplianceStudent)(
|
||||||
|
@ -270,75 +285,6 @@ ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User
|
||||||
).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
}
|
}
|
||||||
|
|
||||||
void createEntry(ref HttpRequestContext ctx) {
|
|
||||||
User user = getUserOrThrow(ctx);
|
|
||||||
auto cls = getClassOrThrow(ctx, user);
|
|
||||||
struct EntryPhonePayload {
|
|
||||||
bool compliant;
|
|
||||||
}
|
|
||||||
struct EntryBehaviorPayload {
|
|
||||||
int rating;
|
|
||||||
Nullable!string comment;
|
|
||||||
}
|
|
||||||
struct EntryPayload {
|
|
||||||
ulong studentId;
|
|
||||||
string date;
|
|
||||||
bool absent;
|
|
||||||
Nullable!EntryPhonePayload phoneCompliance;
|
|
||||||
Nullable!EntryBehaviorPayload behaviorCompliance;
|
|
||||||
}
|
|
||||||
auto payload = readJsonPayload!(EntryPayload)(ctx);
|
|
||||||
auto db = getDb();
|
|
||||||
bool entryAlreadyExists = canFind(
|
|
||||||
db,
|
|
||||||
"SELECT id FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
|
||||||
cls.id,
|
|
||||||
payload.studentId,
|
|
||||||
payload.date
|
|
||||||
);
|
|
||||||
if (entryAlreadyExists) {
|
|
||||||
ctx.response.status = HttpStatus.BAD_REQUEST;
|
|
||||||
ctx.response.writeBodyString("An entry already exists for this student and date.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Insert the entry and its attached entities in a transaction.
|
|
||||||
db.begin();
|
|
||||||
try {
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO classroom_compliance_entry (class_id, student_id, date, created_at, absent)
|
|
||||||
VALUES (?, ?, ?, ?, ?)",
|
|
||||||
cls.id,
|
|
||||||
payload.studentId,
|
|
||||||
payload.date,
|
|
||||||
getUnixTimestampMillis(),
|
|
||||||
payload.absent
|
|
||||||
);
|
|
||||||
ulong entryId = db.lastInsertRowid();
|
|
||||||
if (!payload.absent && !payload.phoneCompliance.isNull) {
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO classroom_compliance_entry_phone (entry_id, compliant) VALUES (?, ?)",
|
|
||||||
entryId,
|
|
||||||
payload.phoneCompliance.get().compliant
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!payload.absent && !payload.behaviorCompliance.isNull) {
|
|
||||||
Nullable!string comment = payload.behaviorCompliance.get().comment;
|
|
||||||
if (!comment.isNull && (comment.get() is null || comment.get().length == 0)) {
|
|
||||||
comment.nullify();
|
|
||||||
}
|
|
||||||
db.execute(
|
|
||||||
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating, comment) VALUES (?, ?, ?)",
|
|
||||||
entryId,
|
|
||||||
payload.behaviorCompliance.get().rating,
|
|
||||||
comment
|
|
||||||
);
|
|
||||||
}
|
|
||||||
db.commit();
|
|
||||||
} catch (Exception e) {
|
|
||||||
db.rollback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void getEntries(ref HttpRequestContext ctx) {
|
void getEntries(ref HttpRequestContext ctx) {
|
||||||
User user = getUserOrThrow(ctx);
|
User user = getUserOrThrow(ctx);
|
||||||
auto cls = getClassOrThrow(ctx, user);
|
auto cls = getClassOrThrow(ctx, user);
|
||||||
|
@ -367,6 +313,22 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString());
|
infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString());
|
||||||
|
|
||||||
auto db = getDb();
|
auto db = getDb();
|
||||||
|
|
||||||
|
ClassroomComplianceStudent[] students = findAll!(ClassroomComplianceStudent)(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
|
||||||
|
cls.id
|
||||||
|
);
|
||||||
|
JSONValue[] studentObjects = students.map!((s) {
|
||||||
|
JSONValue obj = JSONValue.emptyObject;
|
||||||
|
obj.object["id"] = JSONValue(s.id);
|
||||||
|
obj.object["deskNumber"] = JSONValue(s.deskNumber);
|
||||||
|
obj.object["name"] = JSONValue(s.name);
|
||||||
|
obj.object["removed"] = JSONValue(s.removed);
|
||||||
|
obj.object["entries"] = JSONValue.emptyObject;
|
||||||
|
return obj;
|
||||||
|
}).array;
|
||||||
|
|
||||||
const query = "
|
const query = "
|
||||||
SELECT
|
SELECT
|
||||||
entry.id,
|
entry.id,
|
||||||
|
@ -392,32 +354,16 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
AND entry.date >= ?
|
AND entry.date >= ?
|
||||||
AND entry.date <= ?
|
AND entry.date <= ?
|
||||||
ORDER BY
|
ORDER BY
|
||||||
entry.date ASC,
|
student.id ASC,
|
||||||
student.desk_number ASC,
|
entry.date ASC
|
||||||
student.name ASC
|
|
||||||
";
|
";
|
||||||
ResultRange result = db.execute(query, cls.id, fromDate.toISOExtString(), toDate.toISOExtString());
|
ResultRange result = db.execute(query, cls.id, fromDate.toISOExtString(), toDate.toISOExtString());
|
||||||
// Serialize the results into a custom-formatted response object.
|
// Serialize the results into a custom-formatted response object.
|
||||||
import std.json;
|
|
||||||
JSONValue response = JSONValue.emptyObject;
|
|
||||||
JSONValue[ulong] studentObjects;
|
|
||||||
foreach (row; result) {
|
foreach (row; result) {
|
||||||
ulong studentId = row.peek!ulong(4);
|
|
||||||
if (studentId !in studentObjects) {
|
|
||||||
JSONValue student = JSONValue.emptyObject;
|
|
||||||
student.object["id"] = JSONValue(row.peek!ulong(4));
|
|
||||||
student.object["name"] = JSONValue(row.peek!string(5));
|
|
||||||
student.object["deskNumber"] = JSONValue(row.peek!ushort(6));
|
|
||||||
student.object["removed"] = JSONValue(row.peek!bool(7));
|
|
||||||
student.object["entries"] = JSONValue.emptyObject;
|
|
||||||
studentObjects[studentId] = student;
|
|
||||||
}
|
|
||||||
JSONValue studentObj = studentObjects[studentId];
|
|
||||||
|
|
||||||
JSONValue entry = JSONValue.emptyObject;
|
JSONValue entry = JSONValue.emptyObject;
|
||||||
entry.object["id"] = JSONValue(row.peek!ulong(0));
|
entry.object["id"] = JSONValue(row.peek!ulong(0));
|
||||||
entry.object["date"] = JSONValue(row.peek!string(1));
|
entry.object["date"] = JSONValue(row.peek!string(1));
|
||||||
entry.object["createdAt"] = JSONValue(row.peek!string(2));
|
entry.object["createdAt"] = JSONValue(row.peek!ulong(2));
|
||||||
entry.object["absent"] = JSONValue(row.peek!bool(3));
|
entry.object["absent"] = JSONValue(row.peek!bool(3));
|
||||||
|
|
||||||
JSONValue phone = JSONValue(null);
|
JSONValue phone = JSONValue(null);
|
||||||
|
@ -431,12 +377,27 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
entry.object["phone"] = phone;
|
entry.object["phone"] = phone;
|
||||||
entry.object["behavior"] = behavior;
|
entry.object["behavior"] = behavior;
|
||||||
|
|
||||||
string dateStr = entry.object["date"].str();
|
string dateStr = entry.object["date"].str();
|
||||||
studentObj.object["entries"].object[dateStr] = entry;
|
|
||||||
|
// Find the student object this entry belongs to, then add it to their list.
|
||||||
|
ulong studentId = row.peek!ulong(4);
|
||||||
|
bool studentFound = false;
|
||||||
|
foreach (idx, student; students) {
|
||||||
|
if (student.id == studentId) {
|
||||||
|
studentObjects[idx].object["entries"].object[dateStr] = entry;
|
||||||
|
studentFound = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!studentFound) {
|
||||||
|
throw new Exception("Failed to find student.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONValue response = JSONValue.emptyObject;
|
||||||
// Provide the list of dates that we're providing data for, to make it easier for the frontend.
|
// Provide the list of dates that we're providing data for, to make it easier for the frontend.
|
||||||
response.object["dates"] = JSONValue.emptyArray;
|
response.object["dates"] = JSONValue.emptyArray;
|
||||||
|
|
||||||
// Also fill in "null" for any students that don't have an entry on one of these days.
|
// Also fill in "null" for any students that don't have an entry on one of these days.
|
||||||
Date d = fromDate;
|
Date d = fromDate;
|
||||||
while (d <= toDate) {
|
while (d <= toDate) {
|
||||||
|
@ -449,8 +410,180 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
d += days(1);
|
d += days(1);
|
||||||
}
|
}
|
||||||
response.object["students"] = JSONValue(studentObjects.values);
|
response.object["students"] = JSONValue(studentObjects);
|
||||||
|
|
||||||
string jsonStr = response.toJSON();
|
string jsonStr = response.toJSON();
|
||||||
ctx.response.writeBodyString(jsonStr, "application/json");
|
ctx.response.writeBodyString(jsonStr, "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void saveEntries(ref HttpRequestContext ctx) {
|
||||||
|
User user = getUserOrThrow(ctx);
|
||||||
|
auto cls = getClassOrThrow(ctx, user);
|
||||||
|
JSONValue bodyContent = ctx.request.readBodyAsJson();
|
||||||
|
auto db = getDb();
|
||||||
|
db.begin();
|
||||||
|
foreach (JSONValue studentObj; bodyContent.object["students"].array) {
|
||||||
|
ulong studentId = studentObj.object["id"].integer();
|
||||||
|
JSONValue entries = studentObj.object["entries"];
|
||||||
|
foreach (string dateStr, JSONValue entry; entries.object) {
|
||||||
|
if (entry.isNull) {
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
||||||
|
cls.id,
|
||||||
|
studentId,
|
||||||
|
dateStr
|
||||||
|
);
|
||||||
|
infoF!"Deleted entry for student %s on %s"(studentId, dateStr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional!ClassroomComplianceEntry existingEntry = findOne!(ClassroomComplianceEntry)(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
||||||
|
cls.id, studentId, dateStr
|
||||||
|
);
|
||||||
|
|
||||||
|
ulong entryId = entry.object["id"].integer();
|
||||||
|
bool creatingNewEntry = entryId == 0;
|
||||||
|
|
||||||
|
if (creatingNewEntry) {
|
||||||
|
if (!existingEntry.isNull) {
|
||||||
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
|
ctx.response.writeBodyString(
|
||||||
|
format!"Cannot create a new entry for student %d on %s when one already exists."(
|
||||||
|
studentId,
|
||||||
|
dateStr
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertNewEntry(db, cls.id, studentId, dateStr, entry);
|
||||||
|
} else {
|
||||||
|
if (existingEntry.isNull) {
|
||||||
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||||||
|
ctx.response.writeBodyString(
|
||||||
|
format!"Cannot update entry %d because it doesn't exist."(
|
||||||
|
entryId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateEntry(db, cls.id, studentId, dateStr, entryId, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertNewEntry(
|
||||||
|
ref Database db,
|
||||||
|
ulong classId,
|
||||||
|
ulong studentId,
|
||||||
|
string dateStr,
|
||||||
|
JSONValue payload
|
||||||
|
) {
|
||||||
|
ulong createdAt = getUnixTimestampMillis();
|
||||||
|
bool absent = payload.object["absent"].boolean;
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO classroom_compliance_entry
|
||||||
|
(class_id, student_id, date, created_at, absent)
|
||||||
|
VALUES (?, ?, ?, ?, ?)",
|
||||||
|
classId, studentId, dateStr, createdAt, absent
|
||||||
|
);
|
||||||
|
if (!absent) {
|
||||||
|
ulong entryId = db.lastInsertRowid();
|
||||||
|
if ("phone" !in payload.object || payload.object["phone"].type != JSONType.object) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing phone data.");
|
||||||
|
}
|
||||||
|
if ("behavior" !in payload.object || payload.object["behavior"].type != JSONType.object) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing behavior data.");
|
||||||
|
}
|
||||||
|
bool phoneCompliance = payload.object["phone"].object["compliant"].boolean;
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO classroom_compliance_entry_phone (entry_id, compliant)
|
||||||
|
VALUES (?, ?)",
|
||||||
|
entryId, phoneCompliance
|
||||||
|
);
|
||||||
|
ubyte behaviorRating = cast(ubyte) payload.object["behavior"].object["rating"].integer;
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating, comment)
|
||||||
|
VALUES (?, ?, ?)",
|
||||||
|
entryId, behaviorRating, ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
infoF!"Created new entry for student %d: %s"(studentId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateEntry(
|
||||||
|
ref Database db,
|
||||||
|
ulong classId,
|
||||||
|
ulong studentId,
|
||||||
|
string dateStr,
|
||||||
|
ulong entryId,
|
||||||
|
JSONValue obj
|
||||||
|
) {
|
||||||
|
bool absent = obj.object["absent"].boolean;
|
||||||
|
db.execute(
|
||||||
|
"UPDATE classroom_compliance_entry
|
||||||
|
SET absent = ?
|
||||||
|
WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?",
|
||||||
|
absent, classId, studentId, dateStr, entryId
|
||||||
|
);
|
||||||
|
if (absent) {
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM classroom_compliance_entry_phone WHERE entry_id = ?",
|
||||||
|
entryId
|
||||||
|
);
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM classroom_compliance_entry_behavior WHERE entry_id = ?",
|
||||||
|
entryId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bool phoneCompliant = obj.object["phone"].object["compliant"].boolean;
|
||||||
|
bool phoneDataExists = canFind(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM classroom_compliance_entry_phone WHERE entry_id = ?",
|
||||||
|
entryId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (phoneDataExists) {
|
||||||
|
db.execute(
|
||||||
|
"UPDATE classroom_compliance_entry_phone
|
||||||
|
SET compliant = ?
|
||||||
|
WHERE entry_id = ?",
|
||||||
|
phoneCompliant,
|
||||||
|
entryId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO classroom_compliance_entry_phone (entry_id, compliant)
|
||||||
|
VALUES (?, ?)",
|
||||||
|
entryId, phoneCompliant
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ubyte behaviorRating = cast(ubyte) obj.object["behavior"].object["rating"].integer;
|
||||||
|
bool behaviorDataExists = canFind(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM classroom_compliance_entry_behavior WHERE entry_id = ?",
|
||||||
|
entryId
|
||||||
|
);
|
||||||
|
if (behaviorDataExists) {
|
||||||
|
db.execute(
|
||||||
|
"UPDATE classroom_compliance_entry_behavior
|
||||||
|
SET rating = ?
|
||||||
|
WHERE entry_id = ?",
|
||||||
|
behaviorRating,
|
||||||
|
entryId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating)
|
||||||
|
VALUES (?, ?)",
|
||||||
|
entryId, behaviorRating
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
infoF!"Updated entry %d"(entryId);
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import AlertDialog from './components/AlertDialog.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function logOut() {
|
||||||
|
authStore.logOut()
|
||||||
|
await router.replace('/')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -15,7 +22,7 @@ const authStore = useAuthStore()
|
||||||
<span v-if="authStore.state">
|
<span v-if="authStore.state">
|
||||||
Welcome, <span v-text="authStore.state.username"></span>
|
Welcome, <span v-text="authStore.state.username"></span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" @click="authStore.logOut" v-if="authStore.state">Log out</button>
|
<button type="button" @click="logOut" v-if="authStore.state">Log out</button>
|
||||||
</nav>
|
</nav>
|
||||||
<nav v-if="authStore.state">
|
<nav v-if="authStore.state">
|
||||||
Apps:
|
Apps:
|
||||||
|
@ -26,6 +33,9 @@ const authStore = useAuthStore()
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|
||||||
|
<!-- Global dialog elements are included here below, hidden by default. -->
|
||||||
|
<AlertDialog />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function showAlert(msg: string): Promise<void> {
|
||||||
|
const dialog = document.getElementById('alert-dialog') as HTMLDialogElement
|
||||||
|
const messageBox = document.getElementById('alert-dialog-message') as HTMLParagraphElement
|
||||||
|
const closeButton = document.getElementById('alert-dialog-close-button') as HTMLButtonElement
|
||||||
|
closeButton.addEventListener('click', () => dialog.close())
|
||||||
|
messageBox.innerText = msg
|
||||||
|
dialog.showModal()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialog.addEventListener('close', () => resolve())
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { APIClient, APIResponse, type AuthStoreType } from './base'
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL + '/auth'
|
const BASE_URL = import.meta.env.VITE_API_URL + '/auth'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
@ -8,22 +10,18 @@ export interface User {
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthHeaders(basicAuth: string) {
|
export class AuthenticationAPIClient extends APIClient {
|
||||||
return {
|
constructor(authStore: AuthStoreType) {
|
||||||
Authorization: 'Basic ' + basicAuth,
|
super(BASE_URL, authStore)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<User | null> {
|
login(username: string, password: string): APIResponse<User> {
|
||||||
const basicAuth = btoa(username + ':' + password)
|
const promise = fetch(this.baseUrl + '/login', {
|
||||||
const response = await fetch(BASE_URL + '/login', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'Basic ' + basicAuth,
|
Authorization: 'Basic ' + btoa(username + ':' + password),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
return new APIResponse(this.handleAPIResponse(promise))
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
return (await response.json()) as User
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { showAlert } from '@/alerts'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
export abstract class APIError {
|
||||||
|
message: string
|
||||||
|
constructor(message: string) {
|
||||||
|
this.message = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BadRequestError extends APIError {}
|
||||||
|
|
||||||
|
export class InternalServerError extends APIError {}
|
||||||
|
|
||||||
|
export class NetworkError extends APIError {}
|
||||||
|
|
||||||
|
export class AuthenticationError extends APIError {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a generic API response that that will be completed in the future
|
||||||
|
* using a given promise, which resolves either to a specified response type,
|
||||||
|
* or an API Error type.
|
||||||
|
*/
|
||||||
|
export class APIResponse<T> {
|
||||||
|
result: Promise<T | APIError>
|
||||||
|
constructor(result: Promise<T | APIError>) {
|
||||||
|
this.result = result
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleErrorsWithAlert(): Promise<T | null> {
|
||||||
|
const value = await this.result
|
||||||
|
if (value instanceof APIError) {
|
||||||
|
await showAlert(value.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrThrow(): Promise<T> {
|
||||||
|
const value = await this.result
|
||||||
|
if (value instanceof APIError) throw value
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthStoreType = ReturnType<typeof useAuthStore>
|
||||||
|
|
||||||
|
export abstract class APIClient {
|
||||||
|
readonly baseUrl: string
|
||||||
|
authStore: AuthStoreType
|
||||||
|
constructor(baseUrl: string, authStore: AuthStoreType) {
|
||||||
|
this.baseUrl = baseUrl
|
||||||
|
this.authStore = authStore
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get<T>(url: string): APIResponse<T> {
|
||||||
|
const promise = fetch(this.baseUrl + url, { headers: this.getAuthHeaders() })
|
||||||
|
return new APIResponse(this.handleAPIResponse(promise))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected post<T, B>(url: string, body: B): APIResponse<T> {
|
||||||
|
const promise = fetch(this.baseUrl + url, {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
return new APIResponse(this.handleAPIResponse(promise))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected postWithNoExpectedResponse<B>(url: string, body: B): APIResponse<void> {
|
||||||
|
const promise = fetch(this.baseUrl + url, {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
return new APIResponse(this.handleAPIResponseWithNoBody(promise))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected put<T, B>(url: string, body: B): APIResponse<T> {
|
||||||
|
const promise = fetch(this.baseUrl + url, {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
return new APIResponse(this.handleAPIResponse(promise))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected delete(url: string): APIResponse<void> {
|
||||||
|
const promise = fetch(this.baseUrl + url, {
|
||||||
|
headers: this.getAuthHeaders(),
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
return new APIResponse(this.handleAPIResponseWithNoBody(promise))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAuthHeaders() {
|
||||||
|
return {
|
||||||
|
Authorization: 'Basic ' + this.authStore.getBasicAuth(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handleAPIResponse<T>(promise: Promise<Response>): Promise<T | APIError> {
|
||||||
|
try {
|
||||||
|
const response = await promise
|
||||||
|
if (response.ok) {
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
if (response.status === 400) {
|
||||||
|
return new BadRequestError(await response.text())
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
return new AuthenticationError(await response.text())
|
||||||
|
}
|
||||||
|
return new InternalServerError(await response.text())
|
||||||
|
} catch (error) {
|
||||||
|
return new NetworkError('' + error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handleAPIResponseWithNoBody(
|
||||||
|
promise: Promise<Response>,
|
||||||
|
): Promise<void | APIError> {
|
||||||
|
try {
|
||||||
|
const response = await promise
|
||||||
|
if (response.ok) return undefined
|
||||||
|
if (response.status === 401) {
|
||||||
|
return new AuthenticationError(await response.text())
|
||||||
|
}
|
||||||
|
return new InternalServerError(await response.text())
|
||||||
|
} catch (error) {
|
||||||
|
return new NetworkError('' + error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { getAuthHeaders } from '@/api/auth'
|
import { APIClient, type APIResponse, type AuthStoreType } from './base'
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_URL + '/classroom-compliance'
|
const BASE_URL = import.meta.env.VITE_API_URL + '/classroom-compliance'
|
||||||
|
|
||||||
|
@ -16,91 +16,109 @@ export interface Student {
|
||||||
removed: boolean
|
removed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntryResponseItemPhone {
|
export interface EntryPhone {
|
||||||
compliant: boolean
|
compliant: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntryResponseItemBehavior {
|
export interface EntryBehavior {
|
||||||
rating: number
|
rating: number
|
||||||
comment?: string
|
comment?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntryResponseItem {
|
export interface Entry {
|
||||||
id: number
|
id: number
|
||||||
date: string
|
date: string
|
||||||
createdAt: string
|
createdAt: number
|
||||||
absent: boolean
|
absent: boolean
|
||||||
phone?: EntryResponseItemPhone
|
phone: EntryPhone | null
|
||||||
behavior?: EntryResponseItemBehavior
|
behavior: EntryBehavior | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntryResponseStudent {
|
export function getDefaultEntry(dateStr: string): Entry {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
date: dateStr,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
absent: false,
|
||||||
|
phone: { compliant: true },
|
||||||
|
behavior: { rating: 3 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntriesResponseStudent {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
deskNumber: number
|
deskNumber: number
|
||||||
removed: boolean
|
removed: boolean
|
||||||
entries: Record<string, EntryResponseItem | null>
|
entries: Record<string, Entry | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntriesResponse {
|
export interface EntriesResponse {
|
||||||
students: EntryResponseStudent[]
|
students: EntriesResponseStudent[]
|
||||||
dates: string[]
|
dates: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClassPath(classId: number): string {
|
export interface StudentDataPayload {
|
||||||
return BASE_URL + '/classes/' + classId
|
name: string
|
||||||
|
deskNumber: number
|
||||||
|
removed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createClass(
|
export interface EntriesPayloadStudent {
|
||||||
auth: string,
|
id: number
|
||||||
number: number,
|
entries: Record<string, Entry | null>
|
||||||
schoolYear: string,
|
|
||||||
): Promise<Class> {
|
|
||||||
const response = await fetch(BASE_URL + '/classes', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(auth),
|
|
||||||
body: JSON.stringify({ number: number, schoolYear: schoolYear }),
|
|
||||||
})
|
|
||||||
return (await response.json()) as Class
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getClasses(auth: string): Promise<Class[]> {
|
export interface EntriesPayload {
|
||||||
const response = await fetch(BASE_URL + '/classes', {
|
students: EntriesPayloadStudent[]
|
||||||
headers: getAuthHeaders(auth),
|
|
||||||
})
|
|
||||||
return (await response.json()) as Class[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getClass(auth: string, id: number): Promise<Class> {
|
export class ClassroomComplianceAPIClient extends APIClient {
|
||||||
const response = await fetch(getClassPath(id), {
|
constructor(authStore: AuthStoreType) {
|
||||||
headers: getAuthHeaders(auth),
|
super(BASE_URL, authStore)
|
||||||
})
|
|
||||||
return (await response.json()) as Class
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteClass(auth: string, classId: number): Promise<void> {
|
createClass(number: number, schoolYear: string): APIResponse<Class> {
|
||||||
const response = await fetch(getClassPath(classId), {
|
return super.post('/classes', { number: number, schoolYear: schoolYear })
|
||||||
method: 'DELETE',
|
|
||||||
headers: getAuthHeaders(auth),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to delete class.')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStudents(auth: string, classId: number): Promise<Student[]> {
|
getClasses(): APIResponse<Class[]> {
|
||||||
const response = await fetch(getClassPath(classId) + '/students', {
|
return super.get('/classes')
|
||||||
headers: getAuthHeaders(auth),
|
|
||||||
})
|
|
||||||
return (await response.json()) as Student[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEntries(
|
getClass(classId: number): APIResponse<Class> {
|
||||||
auth: string,
|
return super.get(`/classes/${classId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteClass(classId: number): APIResponse<void> {
|
||||||
|
return super.delete(`/classes/${classId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStudents(classId: number): APIResponse<Student[]> {
|
||||||
|
return super.get(`/classes/${classId}/students`)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStudent(classId: number, studentId: number): APIResponse<Student> {
|
||||||
|
return super.get(`/classes/${classId}/students/${studentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
createStudent(classId: number, data: StudentDataPayload): APIResponse<Student> {
|
||||||
|
return super.post(`/classes/${classId}/students`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStudent(
|
||||||
classId: number,
|
classId: number,
|
||||||
fromDate?: Date,
|
studentId: number,
|
||||||
toDate?: Date,
|
data: StudentDataPayload,
|
||||||
): Promise<EntriesResponse> {
|
): APIResponse<Student> {
|
||||||
|
return super.put(`/classes/${classId}/students/${studentId}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteStudent(classId: number, studentId: number): APIResponse<void> {
|
||||||
|
return super.delete(`/classes/${classId}/students/${studentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
getEntries(classId: number, fromDate?: Date, toDate?: Date): APIResponse<EntriesResponse> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (fromDate) {
|
if (fromDate) {
|
||||||
params.append('from', fromDate.toISOString().substring(0, 10))
|
params.append('from', fromDate.toISOString().substring(0, 10))
|
||||||
|
@ -108,8 +126,10 @@ export async function getEntries(
|
||||||
if (toDate) {
|
if (toDate) {
|
||||||
params.append('to', toDate.toISOString().substring(0, 10))
|
params.append('to', toDate.toISOString().substring(0, 10))
|
||||||
}
|
}
|
||||||
const response = await fetch(getClassPath(classId) + '/entries?' + params.toString(), {
|
return super.get(`/classes/${classId}/entries?${params.toString()}`)
|
||||||
headers: getAuthHeaders(auth),
|
}
|
||||||
})
|
|
||||||
return (await response.json()) as EntriesResponse
|
saveEntries(classId: number, payload: EntriesPayload): APIResponse<void> {
|
||||||
|
return super.postWithNoExpectedResponse(`/classes/${classId}/entries`, payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type Class } from '@/api/classroom_compliance';
|
import { type Class } from '@/api/classroom_compliance'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
cls: Class
|
cls: Class
|
||||||
|
|
|
@ -1,22 +1,34 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getClass, getStudents, type Class, type Student } from '@/api/classroom_compliance';
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||||
import { onMounted, ref, type Ref } from 'vue';
|
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue'
|
||||||
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue';
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
|
import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compliance'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id: string
|
id: string
|
||||||
}>()
|
}>()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
const cls: Ref<Class | null> = ref(null)
|
const cls: Ref<Class | null> = ref(null)
|
||||||
const students: Ref<Student[]> = ref([])
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
|
const deleteClassDialog = useTemplateRef('deleteClassDialog')
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const idNumber = parseInt(props.id, 10)
|
const idNumber = parseInt(props.id, 10)
|
||||||
cls.value = await getClass(authStore.getBasicAuth(), idNumber)
|
cls.value = await apiClient.getClass(idNumber).handleErrorsWithAlert()
|
||||||
getStudents(authStore.getBasicAuth(), idNumber).then(r => {
|
if (!cls.value) {
|
||||||
students.value = r
|
await router.back()
|
||||||
})
|
return
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function deleteThisClass() {
|
||||||
|
if (!cls.value || !(await deleteClassDialog.value?.show())) return
|
||||||
|
await apiClient.deleteClass(cls.value.id).handleErrorsWithAlert()
|
||||||
|
await router.replace('/classroom-compliance')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div v-if="cls">
|
<div v-if="cls">
|
||||||
|
@ -26,12 +38,22 @@ onMounted(async () => {
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<span>Actions: </span>
|
<span>Actions: </span>
|
||||||
<button type="button">Add Student - WIP</button>
|
<RouterLink :to="'/classroom-compliance/classes/' + cls.id + '/edit-student'"
|
||||||
<button type="button">Delete this Class</button>
|
>Add Student</RouterLink
|
||||||
|
>
|
||||||
|
<button type="button" @click="deleteThisClass">Delete this Class</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EntriesTable :classId="cls.id" />
|
<EntriesTable :classId="cls.id" />
|
||||||
|
|
||||||
|
<ConfirmDialog ref="deleteClassDialog">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to delete this class? All data associated with it (settings, students,
|
||||||
|
entries, grades, etc.) will be <strong>permanently deleted</strong>, and deleted data is not
|
||||||
|
recoverable.
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
@ -1,17 +1,23 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type Class, getClasses } from '@/api/classroom_compliance';
|
import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compliance'
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { type Ref, ref, onMounted } from 'vue';
|
import { type Ref, ref, onMounted } from 'vue'
|
||||||
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue';
|
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue'
|
||||||
|
|
||||||
const classes: Ref<Class[]> = ref([])
|
const classes: Ref<Class[]> = ref([])
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
classes.value = await getClasses(authStore.getBasicAuth())
|
classes.value = (await apiClient.getClasses().handleErrorsWithAlert()) ?? []
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<RouterLink to="/classroom-compliance/edit-class">Add Class</RouterLink>
|
||||||
|
</div>
|
||||||
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" />
|
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compliance'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { onMounted, ref, type Ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const cls: Ref<Class | null> = ref(null)
|
||||||
|
|
||||||
|
interface ClassFormData {
|
||||||
|
number: number
|
||||||
|
schoolYear: string
|
||||||
|
}
|
||||||
|
const formData: Ref<ClassFormData> = ref({ number: 1, schoolYear: '2024-2025' })
|
||||||
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if ('classId' in route.query && typeof route.query.classId === 'string') {
|
||||||
|
const classId = parseInt(route.query.classId, 10)
|
||||||
|
cls.value = await apiClient.getClass(classId).handleErrorsWithAlert()
|
||||||
|
if (!cls.value) {
|
||||||
|
await router.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formData.value.number = cls.value.number
|
||||||
|
formData.value.schoolYear = cls.value.schoolYear
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
if (cls.value) {
|
||||||
|
// TODO: Update class
|
||||||
|
} else {
|
||||||
|
const result = await apiClient
|
||||||
|
.createClass(formData.value.number, formData.value.schoolYear)
|
||||||
|
.handleErrorsWithAlert()
|
||||||
|
if (result) {
|
||||||
|
await router.replace(`/classroom-compliance/classes/${result.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (cls.value) {
|
||||||
|
formData.value.number = cls.value.number
|
||||||
|
formData.value.schoolYear = cls.value.schoolYear
|
||||||
|
} else {
|
||||||
|
formData.value.number = 1
|
||||||
|
formData.value.schoolYear = '2024-2025'
|
||||||
|
}
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2>
|
||||||
|
<span v-if="cls" v-text="'Edit Class ' + cls.id"></span>
|
||||||
|
<span v-if="!cls">Add New Class</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitForm" @reset.prevent="resetForm">
|
||||||
|
<div>
|
||||||
|
<label for="number-input">Number</label>
|
||||||
|
<input id="number-input" type="number" v-model="formData.number" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="school-year-input">School Year (example: "2024-2025")</label>
|
||||||
|
<input id="school-year-input" type="text" v-model="formData.schoolYear" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="reset">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,117 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ClassroomComplianceAPIClient,
|
||||||
|
type Class,
|
||||||
|
type Student,
|
||||||
|
type StudentDataPayload,
|
||||||
|
} from '@/api/classroom_compliance'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { onMounted, ref, type Ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
classId: string
|
||||||
|
}>()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const cls: Ref<Class | null> = ref(null)
|
||||||
|
const student: Ref<Student | null> = ref(null)
|
||||||
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
|
interface StudentFormData {
|
||||||
|
name: string
|
||||||
|
deskNumber: number | null
|
||||||
|
removed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData: Ref<StudentFormData> = ref({ name: '', deskNumber: null, removed: false })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const classIdNumber = parseInt(props.classId, 10)
|
||||||
|
cls.value = await apiClient.getClass(classIdNumber).handleErrorsWithAlert()
|
||||||
|
if (!cls.value) {
|
||||||
|
await router.replace('/classroom-compliance')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ('studentId' in route.query && typeof route.query.studentId === 'string') {
|
||||||
|
const studentId = parseInt(route.query.studentId, 10)
|
||||||
|
student.value = await apiClient.getStudent(classIdNumber, studentId).handleErrorsWithAlert()
|
||||||
|
if (!student.value) {
|
||||||
|
await router.replace(`/classroom-compliance/classes/${classIdNumber}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formData.value.name = student.value.name
|
||||||
|
formData.value.deskNumber = student.value.deskNumber
|
||||||
|
formData.value.removed = student.value.removed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
const classId = parseInt(props.classId, 10)
|
||||||
|
const data: StudentDataPayload = {
|
||||||
|
name: formData.value.name,
|
||||||
|
deskNumber: formData.value.deskNumber ?? 0,
|
||||||
|
removed: formData.value.removed,
|
||||||
|
}
|
||||||
|
if (student.value) {
|
||||||
|
const updatedStudent = await apiClient
|
||||||
|
.updateStudent(classId, student.value.id, data)
|
||||||
|
.handleErrorsWithAlert()
|
||||||
|
if (updatedStudent) {
|
||||||
|
await router.replace(`/classroom-compliance/classes/${classId}/students/${updatedStudent.id}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newStudent = await apiClient.createStudent(classId, data).handleErrorsWithAlert()
|
||||||
|
if (newStudent) {
|
||||||
|
await router.replace(`/classroom-compliance/classes/${classId}/students/${newStudent.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (student.value) {
|
||||||
|
formData.value = {
|
||||||
|
name: student.value.name,
|
||||||
|
deskNumber: student.value.deskNumber,
|
||||||
|
removed: student.value.removed,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formData.value = {
|
||||||
|
name: '',
|
||||||
|
deskNumber: null,
|
||||||
|
removed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="cls">
|
||||||
|
<h2>
|
||||||
|
<span v-if="student" v-text="'Edit ' + student.name"></span>
|
||||||
|
<span v-if="!student">Add New Student</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>From class <span v-text="cls.number + ', ' + cls.schoolYear"></span></p>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitForm" @reset.prevent="resetForm">
|
||||||
|
<div>
|
||||||
|
<label for="name-input">Name</label>
|
||||||
|
<input id="name-input" type="text" v-model="formData.name" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="desk-input">Desk Number</label>
|
||||||
|
<input id="desk-input" type="number" v-model="formData.deskNumber" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="removed-checkbox">Removed</label>
|
||||||
|
<input id="removed-checkbox" type="checkbox" v-model="formData.removed" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="reset">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,18 +1,33 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getEntries, type EntryResponseStudent } from '@/api/classroom_compliance';
|
import {
|
||||||
import { useAuthStore } from '@/stores/auth';
|
ClassroomComplianceAPIClient,
|
||||||
import { onMounted, ref, type Ref } from 'vue';
|
getDefaultEntry,
|
||||||
import EntryTableCell from './EntryTableCell.vue';
|
type EntriesPayload,
|
||||||
|
type EntriesPayloadStudent,
|
||||||
|
type EntriesResponseStudent,
|
||||||
|
} from '@/api/classroom_compliance'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { computed, onMounted, ref, type Ref } from 'vue'
|
||||||
|
import EntryTableCell from './EntryTableCell.vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
classId: number
|
classId: number
|
||||||
}>()
|
}>()
|
||||||
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
|
||||||
const students: Ref<EntryResponseStudent[]> = ref([])
|
const students: Ref<EntriesResponseStudent[]> = ref([])
|
||||||
|
const lastSaveState: Ref<string | null> = ref(null)
|
||||||
|
const lastSaveStateTimestamp: Ref<number> = ref(0)
|
||||||
const dates: Ref<string[]> = ref([])
|
const dates: Ref<string[]> = ref([])
|
||||||
const toDate: Ref<Date> = ref(new Date())
|
const toDate: Ref<Date> = ref(new Date())
|
||||||
const fromDate: Ref<Date> = ref(new Date())
|
const fromDate: Ref<Date> = ref(new Date())
|
||||||
|
|
||||||
|
const entriesChangedSinceLastSave = computed(() => {
|
||||||
|
return lastSaveState.value === null || lastSaveState.value !== JSON.stringify(students.value)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
toDate.value.setHours(0, 0, 0, 0)
|
toDate.value.setHours(0, 0, 0, 0)
|
||||||
fromDate.value.setHours(0, 0, 0, 0)
|
fromDate.value.setHours(0, 0, 0, 0)
|
||||||
|
@ -21,14 +36,20 @@ onMounted(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
const entries = await getEntries(
|
const entries = await apiClient
|
||||||
authStore.getBasicAuth(),
|
.getEntries(props.classId, fromDate.value, toDate.value)
|
||||||
props.classId,
|
.handleErrorsWithAlert()
|
||||||
fromDate.value,
|
if (entries) {
|
||||||
toDate.value
|
|
||||||
)
|
|
||||||
students.value = entries.students
|
students.value = entries.students
|
||||||
|
lastSaveState.value = JSON.stringify(entries.students)
|
||||||
|
lastSaveStateTimestamp.value = Date.now()
|
||||||
dates.value = entries.dates
|
dates.value = entries.dates
|
||||||
|
} else {
|
||||||
|
students.value = []
|
||||||
|
lastSaveState.value = null
|
||||||
|
lastSaveStateTimestamp.value = Date.now()
|
||||||
|
dates.value = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftDateRange(days: number) {
|
function shiftDateRange(days: number) {
|
||||||
|
@ -41,30 +62,113 @@ async function showPreviousDay() {
|
||||||
await loadEntries()
|
await loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showToday() {
|
||||||
|
toDate.value = new Date()
|
||||||
|
toDate.value.setHours(0, 0, 0, 0)
|
||||||
|
fromDate.value = new Date()
|
||||||
|
fromDate.value.setHours(0, 0, 0, 0)
|
||||||
|
fromDate.value.setDate(fromDate.value.getDate() - 4)
|
||||||
|
await loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
async function showNextDay() {
|
async function showNextDay() {
|
||||||
shiftDateRange(1)
|
shiftDateRange(1)
|
||||||
await loadEntries()
|
await loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveEdits() {
|
||||||
|
if (!lastSaveState.value) {
|
||||||
|
console.warn('No lastSaveState, cannot determine what edits were made.')
|
||||||
|
await loadEntries()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: EntriesPayload = { students: [] }
|
||||||
|
// Get a list of edits which have changed.
|
||||||
|
const lastSaveStateObj: EntriesResponseStudent[] = JSON.parse(lastSaveState.value)
|
||||||
|
for (let i = 0; i < students.value.length; i++) {
|
||||||
|
const student: EntriesResponseStudent = students.value[i]
|
||||||
|
const studentPayload: EntriesPayloadStudent = { id: student.id, entries: {} }
|
||||||
|
for (const [dateStr, entry] of Object.entries(student.entries)) {
|
||||||
|
const lastSaveStateEntry = lastSaveStateObj[i].entries[dateStr]
|
||||||
|
if (JSON.stringify(lastSaveStateEntry) !== JSON.stringify(entry)) {
|
||||||
|
studentPayload.entries[dateStr] = JSON.parse(JSON.stringify(entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(studentPayload.entries).length > 0) {
|
||||||
|
payload.students.push(studentPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await apiClient.saveEntries(props.classId, payload).handleErrorsWithAlert()
|
||||||
|
await loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discardEdits() {
|
||||||
|
if (lastSaveState.value) {
|
||||||
|
students.value = JSON.parse(lastSaveState.value)
|
||||||
|
} else {
|
||||||
|
await loadEntries()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDate(dateStr: string): Date {
|
||||||
|
const year = parseInt(dateStr.substring(0, 4), 10)
|
||||||
|
const month = parseInt(dateStr.substring(5, 7), 10)
|
||||||
|
const day = parseInt(dateStr.substring(8, 10), 10)
|
||||||
|
return new Date(year, month - 1, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekday(date: Date): string {
|
||||||
|
const names = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||||
|
return names[date.getDay()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAllEntriesForDate(dateStr: string) {
|
||||||
|
for (let i = 0; i < students.value.length; i++) {
|
||||||
|
const student = students.value[i]
|
||||||
|
if (student.removed) continue
|
||||||
|
if (!(dateStr in student.entries) || student.entries[dateStr] === null) {
|
||||||
|
student.entries[dateStr] = getDefaultEntry(dateStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div class="buttons-bar">
|
||||||
<button type="button" @click="showPreviousDay">Previous Day</button>
|
<button type="button" @click="showPreviousDay">Previous Day</button>
|
||||||
|
<button type="button" @click="showToday">Today</button>
|
||||||
<button type="button" @click="showNextDay">Next Day</button>
|
<button type="button" @click="showNextDay">Next Day</button>
|
||||||
|
<button type="button" @click="saveEdits" :disabled="!entriesChangedSinceLastSave">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="discardEdits" :disabled="!entriesChangedSinceLastSave">
|
||||||
|
Discard Edits
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="entries-table">
|
<table class="entries-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Student</th>
|
<th>Student</th>
|
||||||
<th>Desk</th>
|
<th>Desk</th>
|
||||||
<th v-for="date in dates" :key="date" v-text="date"></th>
|
<th v-for="date in dates" :key="date">
|
||||||
|
<span>{{ getDate(date).toLocaleDateString() }}</span>
|
||||||
|
<span @click="addAllEntriesForDate(date)">➕</span>
|
||||||
|
<br />
|
||||||
|
<span>{{ getWeekday(getDate(date)) }}</span>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="student in students" :key="student.id">
|
<tr v-for="student in students" :key="student.id">
|
||||||
<td v-text="student.name" :class="{ 'student-removed': student.removed }"></td>
|
<td :class="{ 'student-removed': student.removed }">
|
||||||
|
<RouterLink :to="'/classroom-compliance/classes/' + classId + '/students/' + student.id">
|
||||||
|
<span v-text="student.name"></span>
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
<td v-text="student.deskNumber"></td>
|
<td v-text="student.deskNumber"></td>
|
||||||
<EntryTableCell v-for="(entry, date) in student.entries" :key="date" :entry="entry" />
|
<EntryTableCell v-for="(entry, date) in student.entries" :key="date" v-model="student.entries[date]"
|
||||||
|
:date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp" />
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -87,4 +191,13 @@ async function showNextDay() {
|
||||||
.student-removed {
|
.student-removed {
|
||||||
background-color: lightgray;
|
background-color: lightgray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttons-bar {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons-bar>button+button {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,26 +1,103 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EntryResponseItem } from '@/api/classroom_compliance';
|
import { getDefaultEntry, type Entry } from '@/api/classroom_compliance'
|
||||||
|
import { computed, onMounted, ref, watch, type Ref } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
entry: EntryResponseItem | null
|
dateStr: string
|
||||||
|
lastSaveStateTimestamp: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const model = defineModel<Entry | null>({
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
const initialEntryJson: Ref<string> = ref('')
|
||||||
|
const previouslyRemovedEntry: Ref<Entry | null> = ref(null)
|
||||||
|
|
||||||
|
const entryChanged = computed(() => JSON.stringify(model.value) !== initialEntryJson.value)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initialEntryJson.value = JSON.stringify(model.value)
|
||||||
|
watch(
|
||||||
|
() => props.lastSaveStateTimestamp,
|
||||||
|
() => {
|
||||||
|
initialEntryJson.value = JSON.stringify(model.value)
|
||||||
|
previouslyRemovedEntry.value = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleAbsence() {
|
||||||
|
if (model.value) {
|
||||||
|
model.value.absent = !model.value.absent
|
||||||
|
if (model.value.absent) {
|
||||||
|
// Remove additional data if student is absent.
|
||||||
|
model.value.phone = null
|
||||||
|
model.value.behavior = null
|
||||||
|
} else {
|
||||||
|
// Populate default additional data if student is no longer absent.
|
||||||
|
model.value.phone = { compliant: true }
|
||||||
|
model.value.behavior = { rating: 3 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePhoneCompliance() {
|
||||||
|
if (model.value && model.value.phone) {
|
||||||
|
model.value.phone.compliant = !model.value.phone.compliant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBehaviorRating() {
|
||||||
|
if (model.value && model.value.behavior) {
|
||||||
|
model.value.behavior.rating = model.value.behavior.rating - 1
|
||||||
|
if (model.value.behavior.rating < 1) {
|
||||||
|
model.value.behavior.rating = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEntry() {
|
||||||
|
if (model.value) {
|
||||||
|
previouslyRemovedEntry.value = JSON.parse(JSON.stringify(model.value))
|
||||||
|
}
|
||||||
|
model.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEntry() {
|
||||||
|
if (previouslyRemovedEntry.value) {
|
||||||
|
model.value = JSON.parse(JSON.stringify(previouslyRemovedEntry.value))
|
||||||
|
} else {
|
||||||
|
model.value = getDefaultEntry(props.dateStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<td v-if="entry" :class="{ absent: entry.absent }">
|
<td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }">
|
||||||
<span v-if="entry.absent">Absent</span>
|
<div v-if="model">
|
||||||
<div v-if="!entry.absent">
|
<div class="status-item" @click="toggleAbsence">
|
||||||
<div class="status-item">
|
<span v-if="model.absent">Absent</span>
|
||||||
<span v-if="entry.phone?.compliant">📱</span>
|
<span v-if="!model.absent">Present</span>
|
||||||
<span v-if="!entry.phone?.compliant">📵</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item">
|
<div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent">
|
||||||
<span v-if="entry.behavior?.rating === 3">😇</span>
|
<span v-if="model.phone?.compliant">📱</span>
|
||||||
<span v-if="entry.behavior?.rating === 2">😐</span>
|
<span v-if="!model.phone?.compliant">📵</span>
|
||||||
<span v-if="entry.behavior?.rating === 1">😡</span>
|
</div>
|
||||||
|
<div class="status-item" @click="toggleBehaviorRating" v-if="!model.absent">
|
||||||
|
<span v-if="model.behavior?.rating === 3">😇</span>
|
||||||
|
<span v-if="model.behavior?.rating === 2">😐</span>
|
||||||
|
<span v-if="model.behavior?.rating === 1">😡</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item" @click="removeEntry">
|
||||||
|
<span>🗑️</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="model === null">
|
||||||
|
<div class="status-item" @click="addEntry">
|
||||||
|
<span>➕</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td v-if="entry === null" class="missing-entry"></td>
|
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
td {
|
td {
|
||||||
|
@ -30,8 +107,7 @@ td {
|
||||||
|
|
||||||
.missing-entry {
|
.missing-entry {
|
||||||
background-color: lightgray;
|
background-color: lightgray;
|
||||||
text-align: center;
|
text-align: right;
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.absent {
|
.absent {
|
||||||
|
@ -39,8 +115,16 @@ td {
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.changed {
|
||||||
|
border: 2px solid orange !important;
|
||||||
|
}
|
||||||
|
|
||||||
.status-item {
|
.status-item {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-item+.status-item {
|
||||||
|
margin-left: 0.25em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts"></script>
|
||||||
</script>
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<h1>Classroom Compliance</h1>
|
<h1>Classroom Compliance</h1>
|
||||||
<p>With this application, you can track each student's compliance to various classroom policies, like:</p>
|
<p>
|
||||||
|
With this application, you can track each student's compliance to various classroom policies,
|
||||||
|
like:
|
||||||
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Attendance</li>
|
<li>Attendance</li>
|
||||||
<li>Phone Usage (or lack thereof)</li>
|
<li>Phone Usage (or lack thereof)</li>
|
||||||
<li>Behavior</li>
|
<li>Behavior</li>
|
||||||
</ul>
|
</ul>
|
||||||
<hr>
|
<hr />
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ClassroomComplianceAPIClient, type Class, type Student } from '@/api/classroom_compliance'
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
classId: string
|
||||||
|
studentId: string
|
||||||
|
}>()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const cls: Ref<Class | null> = ref(null)
|
||||||
|
const student: Ref<Student | null> = ref(null)
|
||||||
|
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||||
|
const deleteConfirmDialog = useTemplateRef('deleteConfirmDialog')
|
||||||
|
onMounted(async () => {
|
||||||
|
const classIdNumber = parseInt(props.classId, 10)
|
||||||
|
cls.value = await apiClient.getClass(classIdNumber).handleErrorsWithAlert()
|
||||||
|
if (!cls.value) {
|
||||||
|
await router.replace('/classroom-compliance')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const studentIdNumber = parseInt(props.studentId, 10)
|
||||||
|
student.value = await apiClient.getStudent(classIdNumber, studentIdNumber).handleErrorsWithAlert()
|
||||||
|
if (!student.value) {
|
||||||
|
await router.replace(`/classroom-compliance/classes/${cls.value.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function deleteThisStudent() {
|
||||||
|
if (!cls.value || !student.value) return
|
||||||
|
const choice = await deleteConfirmDialog.value?.show()
|
||||||
|
if (!choice) return
|
||||||
|
await apiClient.deleteStudent(cls.value.id, student.value.id)
|
||||||
|
await router.replace(`/classroom-compliance/classes/${cls.value.id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="student">
|
||||||
|
<h2 v-text="student.name"></h2>
|
||||||
|
<p>
|
||||||
|
From
|
||||||
|
<RouterLink :to="'/classroom-compliance/classes/' + classId">
|
||||||
|
<span v-text="'class ' + cls?.number + ', ' + cls?.schoolYear"></span>
|
||||||
|
</RouterLink>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Internal ID: <span v-text="student.id"></span></li>
|
||||||
|
<li>Removed: <span v-text="student.removed"></span></li>
|
||||||
|
<li>Desk number: <span v-text="student.deskNumber"></span></li>
|
||||||
|
</ul>
|
||||||
|
<RouterLink
|
||||||
|
:to="
|
||||||
|
'/classroom-compliance/classes/' + student.classId + '/edit-student?studentId=' + student.id
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</RouterLink>
|
||||||
|
<button type="button" @click="deleteThisStudent">Delete</button>
|
||||||
|
|
||||||
|
<ConfirmDialog ref="deleteConfirmDialog">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to delete <span v-text="student.name"></span>? This will permanently
|
||||||
|
delete all records for them.
|
||||||
|
</p>
|
||||||
|
<p>This <strong>cannot</strong> be undone!</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<script setup lang="ts"></script>
|
||||||
|
<template>
|
||||||
|
<dialog id="alert-dialog">
|
||||||
|
<p id="alert-dialog-message"></p>
|
||||||
|
<div>
|
||||||
|
<button type="button" id="alert-dialog-close-button">Close</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
|
@ -0,0 +1,54 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const dialog = ref<HTMLDialogElement>()
|
||||||
|
const result = ref(false)
|
||||||
|
|
||||||
|
function onCancelClicked() {
|
||||||
|
result.value = false
|
||||||
|
dialog.value?.close('false')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirmClicked() {
|
||||||
|
result.value = true
|
||||||
|
dialog.value?.close('true')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(): Promise<boolean> {
|
||||||
|
dialog.value?.showModal()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialog.value?.addEventListener('close', () => {
|
||||||
|
if (dialog.value?.returnValue === 'true') {
|
||||||
|
resolve(true)
|
||||||
|
} else {
|
||||||
|
resolve(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<dialog ref="dialog" method="dialog">
|
||||||
|
<form>
|
||||||
|
<slot></slot>
|
||||||
|
|
||||||
|
<div class="confirm-dialog-buttons">
|
||||||
|
<button @click.prevent="onConfirmClicked">Confirm</button>
|
||||||
|
<button @click.prevent="onCancelClicked">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
<style>
|
||||||
|
.confirm-dialog-buttons {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog-buttons > button {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -26,6 +26,20 @@ const router = createRouter({
|
||||||
component: () => import('@/apps/classroom_compliance/ClassView.vue'),
|
component: () => import('@/apps/classroom_compliance/ClassView.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'edit-class',
|
||||||
|
component: () => import('@/apps/classroom_compliance/EditClassView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'classes/:classId/students/:studentId',
|
||||||
|
component: () => import('@/apps/classroom_compliance/StudentView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'classes/:classId/edit-student',
|
||||||
|
component: () => import('@/apps/classroom_compliance/EditStudentView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { login } from '@/api/auth';
|
import { AuthenticationAPIClient } from '@/api/auth'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
@ -12,14 +12,13 @@ interface Credentials {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const credentials: Ref<Credentials> = ref({ username: '', password: '' })
|
const credentials: Ref<Credentials> = ref({ username: '', password: '' })
|
||||||
|
const apiClient = new AuthenticationAPIClient(authStore)
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
const user = await login(credentials.value.username, credentials.value.password)
|
const user = await apiClient.login(credentials.value.username, credentials.value.password).handleErrorsWithAlert()
|
||||||
if (user) {
|
if (user) {
|
||||||
authStore.logIn(credentials.value.username, credentials.value.password, user)
|
authStore.logIn(credentials.value.username, credentials.value.password, user)
|
||||||
await router.replace('/')
|
await router.replace('/')
|
||||||
} else {
|
|
||||||
console.warn('Invalid credentials.')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
Reference in New Issue