Implemented most of the app.

This commit is contained in:
Andrew Lalis 2024-12-27 15:14:58 -05:00
parent 7166b995f7
commit 3a682e046d
20 changed files with 1122 additions and 245 deletions

View File

@ -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

View File

@ -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);
}

View File

@ -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>

11
app/src/alerts.ts Normal file
View File

@ -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())
})
}

View File

@ -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
} }

134
app/src/api/base.ts Normal file
View File

@ -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)
}
}
}

View File

@ -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)
}
} }

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
},
], ],
}, },
], ],

View File

@ -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>