From 3a682e046d8f2792532bb8c4caf8fcf716c40f06 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Fri, 27 Dec 2024 15:14:58 -0500 Subject: [PATCH] Implemented most of the app. --- api/schema/classroom_compliance.sql | 3 +- api/source/api_modules/classroom_compliance.d | 335 ++++++++++++------ app/src/App.vue | 14 +- app/src/alerts.ts | 11 + app/src/api/auth.ts | 28 +- app/src/api/base.ts | 134 +++++++ app/src/api/classroom_compliance.ts | 154 ++++---- .../apps/classroom_compliance/ClassItem.vue | 2 +- .../apps/classroom_compliance/ClassView.vue | 44 ++- .../apps/classroom_compliance/ClassesView.vue | 18 +- .../classroom_compliance/EditClassView.vue | 78 ++++ .../classroom_compliance/EditStudentView.vue | 117 ++++++ .../classroom_compliance/EntriesTable.vue | 147 +++++++- .../classroom_compliance/EntryTableCell.vue | 116 +++++- .../apps/classroom_compliance/MainView.vue | 10 +- .../apps/classroom_compliance/StudentView.vue | 72 ++++ app/src/components/AlertDialog.vue | 9 + app/src/components/ConfirmDialog.vue | 54 +++ app/src/router/index.ts | 14 + app/src/views/LoginView.vue | 7 +- 20 files changed, 1122 insertions(+), 245 deletions(-) create mode 100644 app/src/alerts.ts create mode 100644 app/src/api/base.ts create mode 100644 app/src/apps/classroom_compliance/EditClassView.vue create mode 100644 app/src/apps/classroom_compliance/EditStudentView.vue create mode 100644 app/src/apps/classroom_compliance/StudentView.vue create mode 100644 app/src/components/AlertDialog.vue create mode 100644 app/src/components/ConfirmDialog.vue diff --git a/api/schema/classroom_compliance.sql b/api/schema/classroom_compliance.sql index 8e8be60..b1486c6 100644 --- a/api/schema/classroom_compliance.sql +++ b/api/schema/classroom_compliance.sql @@ -19,7 +19,8 @@ CREATE TABLE classroom_compliance_entry ( id INTEGER PRIMARY KEY, class_id INTEGER NOT NULL REFERENCES classroom_compliance_class(id) 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, created_at INTEGER NOT NULL, absent INTEGER NOT NULL DEFAULT 0 diff --git a/api/source/api_modules/classroom_compliance.d b/api/source/api_modules/classroom_compliance.d index 733d8cd..32f0d0c 100644 --- a/api/source/api_modules/classroom_compliance.d +++ b/api/source/api_modules/classroom_compliance.d @@ -6,6 +6,10 @@ import d2sqlite3; import slf4d; import std.typecons : Nullable; import std.datetime; +import std.format; +import std.json; +import std.algorithm; +import std.array; import db; import data_utils; @@ -58,11 +62,12 @@ void registerApiEndpoints(PathHandler handler) { handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent); handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents); 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.DELETE, STUDENT_PATH, &deleteStudent); - handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &createEntry); handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries); + handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries); } void createClass(ref HttpRequestContext ctx) { @@ -82,7 +87,7 @@ void createClass(ref HttpRequestContext ctx) { ); if (classNumberExists) { 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; } 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 { string name; ushort deskNumber; + bool removed; } auto payload = readJsonPayload!(StudentPayload)(ctx); auto db = getDb(); @@ -150,7 +156,7 @@ void createStudent(ref HttpRequestContext ctx) { ); if (studentExists) { 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; } bool deskAlreadyOccupied = payload.deskNumber != 0 && canFind( @@ -161,11 +167,11 @@ void createStudent(ref HttpRequestContext ctx) { ); if (deskAlreadyOccupied) { 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( - "INSERT INTO classroom_compliance_student (name, class_id, desk_number) VALUES (?, ?)", - payload.name, cls.id, payload.deskNumber + "INSERT INTO classroom_compliance_student (name, class_id, desk_number, removed) VALUES (?, ?, ?, ?)", + payload.name, cls.id, payload.deskNumber, payload.removed ); ulong studentId = db.lastInsertRowid(); auto student = findOne!(ClassroomComplianceStudent)( @@ -188,22 +194,30 @@ void getStudents(ref HttpRequestContext ctx) { 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) { User user = getUserOrThrow(ctx); auto student = getStudentOrThrow(ctx, user); struct StudentUpdatePayload { string name; ushort deskNumber; + bool removed; } auto payload = readJsonPayload!(StudentUpdatePayload)(ctx); // If there is nothing to update, quit. if ( payload.name == student.name && payload.deskNumber == student.deskNumber + && payload.removed == student.removed ) return; // Check that the new name doesn't already exist. auto db = getDb(); - bool newNameExists = canFind( + bool newNameExists = payload.name != student.name && canFind( db, "SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?", payload.name, @@ -211,11 +225,11 @@ void updateStudent(ref HttpRequestContext ctx) { ); if (newNameExists) { 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; } // 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, "SELECT id FROM classroom_compliance_student WHERE class_id = ? AND desk_number = ?", student.classId, @@ -227,9 +241,10 @@ void updateStudent(ref HttpRequestContext ctx) { return; } 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.deskNumber, + payload.removed, student.id ); auto updatedStudent = findOne!(ClassroomComplianceStudent)( @@ -270,75 +285,6 @@ ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User ).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) { User user = getUserOrThrow(ctx); 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()); 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 = " SELECT entry.id, @@ -392,32 +354,16 @@ void getEntries(ref HttpRequestContext ctx) { AND entry.date >= ? AND entry.date <= ? ORDER BY - entry.date ASC, - student.desk_number ASC, - student.name ASC + student.id ASC, + entry.date ASC "; ResultRange result = db.execute(query, cls.id, fromDate.toISOExtString(), toDate.toISOExtString()); // Serialize the results into a custom-formatted response object. - import std.json; - JSONValue response = JSONValue.emptyObject; - JSONValue[ulong] studentObjects; 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; entry.object["id"] = JSONValue(row.peek!ulong(0)); 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)); JSONValue phone = JSONValue(null); @@ -431,12 +377,27 @@ void getEntries(ref HttpRequestContext ctx) { } entry.object["phone"] = phone; entry.object["behavior"] = behavior; - 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. response.object["dates"] = JSONValue.emptyArray; + // Also fill in "null" for any students that don't have an entry on one of these days. Date d = fromDate; while (d <= toDate) { @@ -449,8 +410,180 @@ void getEntries(ref HttpRequestContext ctx) { } d += days(1); } - response.object["students"] = JSONValue(studentObjects.values); + response.object["students"] = JSONValue(studentObjects); string jsonStr = response.toJSON(); 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); +} diff --git a/app/src/App.vue b/app/src/App.vue index 828fb3d..fb64af3 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -1,8 +1,15 @@ diff --git a/app/src/alerts.ts b/app/src/alerts.ts new file mode 100644 index 0000000..46b0502 --- /dev/null +++ b/app/src/alerts.ts @@ -0,0 +1,11 @@ +export function showAlert(msg: string): Promise { + 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()) + }) +} diff --git a/app/src/api/auth.ts b/app/src/api/auth.ts index 3bab7de..d11eca6 100644 --- a/app/src/api/auth.ts +++ b/app/src/api/auth.ts @@ -1,3 +1,5 @@ +import { APIClient, APIResponse, type AuthStoreType } from './base' + const BASE_URL = import.meta.env.VITE_API_URL + '/auth' export interface User { @@ -8,22 +10,18 @@ export interface User { isAdmin: boolean } -export function getAuthHeaders(basicAuth: string) { - return { - Authorization: 'Basic ' + basicAuth, +export class AuthenticationAPIClient extends APIClient { + constructor(authStore: AuthStoreType) { + super(BASE_URL, authStore) } -} -export async function login(username: string, password: string): Promise { - const basicAuth = btoa(username + ':' + password) - const response = await fetch(BASE_URL + '/login', { - method: 'POST', - headers: { - Authorization: 'Basic ' + basicAuth, - }, - }) - if (!response.ok) { - return null + login(username: string, password: string): APIResponse { + const promise = fetch(this.baseUrl + '/login', { + method: 'POST', + headers: { + Authorization: 'Basic ' + btoa(username + ':' + password), + }, + }) + return new APIResponse(this.handleAPIResponse(promise)) } - return (await response.json()) as User } diff --git a/app/src/api/base.ts b/app/src/api/base.ts new file mode 100644 index 0000000..f94dd28 --- /dev/null +++ b/app/src/api/base.ts @@ -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 { + result: Promise + constructor(result: Promise) { + this.result = result + } + + async handleErrorsWithAlert(): Promise { + const value = await this.result + if (value instanceof APIError) { + await showAlert(value.message) + return null + } + return value + } + + async getOrThrow(): Promise { + const value = await this.result + if (value instanceof APIError) throw value + return value + } +} + +export type AuthStoreType = ReturnType + +export abstract class APIClient { + readonly baseUrl: string + authStore: AuthStoreType + constructor(baseUrl: string, authStore: AuthStoreType) { + this.baseUrl = baseUrl + this.authStore = authStore + } + + protected get(url: string): APIResponse { + const promise = fetch(this.baseUrl + url, { headers: this.getAuthHeaders() }) + return new APIResponse(this.handleAPIResponse(promise)) + } + + protected post(url: string, body: B): APIResponse { + const promise = fetch(this.baseUrl + url, { + headers: this.getAuthHeaders(), + method: 'POST', + body: JSON.stringify(body), + }) + return new APIResponse(this.handleAPIResponse(promise)) + } + + protected postWithNoExpectedResponse(url: string, body: B): APIResponse { + const promise = fetch(this.baseUrl + url, { + headers: this.getAuthHeaders(), + method: 'POST', + body: JSON.stringify(body), + }) + return new APIResponse(this.handleAPIResponseWithNoBody(promise)) + } + + protected put(url: string, body: B): APIResponse { + 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 { + 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(promise: Promise): Promise { + 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, + ): Promise { + 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) + } + } +} diff --git a/app/src/api/classroom_compliance.ts b/app/src/api/classroom_compliance.ts index 75fd560..8e214d8 100644 --- a/app/src/api/classroom_compliance.ts +++ b/app/src/api/classroom_compliance.ts @@ -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' @@ -16,100 +16,120 @@ export interface Student { removed: boolean } -export interface EntryResponseItemPhone { +export interface EntryPhone { compliant: boolean } -export interface EntryResponseItemBehavior { +export interface EntryBehavior { rating: number comment?: string } -export interface EntryResponseItem { +export interface Entry { id: number date: string - createdAt: string + createdAt: number absent: boolean - phone?: EntryResponseItemPhone - behavior?: EntryResponseItemBehavior + phone: EntryPhone | null + 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 name: string deskNumber: number removed: boolean - entries: Record + entries: Record } export interface EntriesResponse { - students: EntryResponseStudent[] + students: EntriesResponseStudent[] dates: string[] } -function getClassPath(classId: number): string { - return BASE_URL + '/classes/' + classId +export interface StudentDataPayload { + name: string + deskNumber: number + removed: boolean } -export async function createClass( - auth: string, - number: number, - schoolYear: string, -): Promise { - 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 interface EntriesPayloadStudent { + id: number + entries: Record } -export async function getClasses(auth: string): Promise { - const response = await fetch(BASE_URL + '/classes', { - headers: getAuthHeaders(auth), - }) - return (await response.json()) as Class[] +export interface EntriesPayload { + students: EntriesPayloadStudent[] } -export async function getClass(auth: string, id: number): Promise { - const response = await fetch(getClassPath(id), { - headers: getAuthHeaders(auth), - }) - return (await response.json()) as Class -} +export class ClassroomComplianceAPIClient extends APIClient { + constructor(authStore: AuthStoreType) { + super(BASE_URL, authStore) + } -export async function deleteClass(auth: string, classId: number): Promise { - const response = await fetch(getClassPath(classId), { - method: 'DELETE', - headers: getAuthHeaders(auth), - }) - if (!response.ok) { - throw new Error('Failed to delete class.') + createClass(number: number, schoolYear: string): APIResponse { + return super.post('/classes', { number: number, schoolYear: schoolYear }) + } + + getClasses(): APIResponse { + return super.get('/classes') + } + + getClass(classId: number): APIResponse { + return super.get(`/classes/${classId}`) + } + + deleteClass(classId: number): APIResponse { + return super.delete(`/classes/${classId}`) + } + + getStudents(classId: number): APIResponse { + return super.get(`/classes/${classId}/students`) + } + + getStudent(classId: number, studentId: number): APIResponse { + return super.get(`/classes/${classId}/students/${studentId}`) + } + + createStudent(classId: number, data: StudentDataPayload): APIResponse { + return super.post(`/classes/${classId}/students`, data) + } + + updateStudent( + classId: number, + studentId: number, + data: StudentDataPayload, + ): APIResponse { + return super.put(`/classes/${classId}/students/${studentId}`, data) + } + + deleteStudent(classId: number, studentId: number): APIResponse { + return super.delete(`/classes/${classId}/students/${studentId}`) + } + + getEntries(classId: number, fromDate?: Date, toDate?: Date): APIResponse { + const params = new URLSearchParams() + if (fromDate) { + params.append('from', fromDate.toISOString().substring(0, 10)) + } + if (toDate) { + params.append('to', toDate.toISOString().substring(0, 10)) + } + return super.get(`/classes/${classId}/entries?${params.toString()}`) + } + + saveEntries(classId: number, payload: EntriesPayload): APIResponse { + return super.postWithNoExpectedResponse(`/classes/${classId}/entries`, payload) } } - -export async function getStudents(auth: string, classId: number): Promise { - const response = await fetch(getClassPath(classId) + '/students', { - headers: getAuthHeaders(auth), - }) - return (await response.json()) as Student[] -} - -export async function getEntries( - auth: string, - classId: number, - fromDate?: Date, - toDate?: Date, -): Promise { - const params = new URLSearchParams() - if (fromDate) { - params.append('from', fromDate.toISOString().substring(0, 10)) - } - if (toDate) { - params.append('to', toDate.toISOString().substring(0, 10)) - } - const response = await fetch(getClassPath(classId) + '/entries?' + params.toString(), { - headers: getAuthHeaders(auth), - }) - return (await response.json()) as EntriesResponse -} diff --git a/app/src/apps/classroom_compliance/ClassItem.vue b/app/src/apps/classroom_compliance/ClassItem.vue index b198f45..184accc 100644 --- a/app/src/apps/classroom_compliance/ClassItem.vue +++ b/app/src/apps/classroom_compliance/ClassItem.vue @@ -1,5 +1,5 @@ diff --git a/app/src/apps/classroom_compliance/ClassesView.vue b/app/src/apps/classroom_compliance/ClassesView.vue index 2b1cbeb..8d590b5 100644 --- a/app/src/apps/classroom_compliance/ClassesView.vue +++ b/app/src/apps/classroom_compliance/ClassesView.vue @@ -1,17 +1,23 @@ diff --git a/app/src/apps/classroom_compliance/EditClassView.vue b/app/src/apps/classroom_compliance/EditClassView.vue new file mode 100644 index 0000000..0948460 --- /dev/null +++ b/app/src/apps/classroom_compliance/EditClassView.vue @@ -0,0 +1,78 @@ + + diff --git a/app/src/apps/classroom_compliance/EditStudentView.vue b/app/src/apps/classroom_compliance/EditStudentView.vue new file mode 100644 index 0000000..1a321e7 --- /dev/null +++ b/app/src/apps/classroom_compliance/EditStudentView.vue @@ -0,0 +1,117 @@ + + diff --git a/app/src/apps/classroom_compliance/EntriesTable.vue b/app/src/apps/classroom_compliance/EntriesTable.vue index 662f2fc..89398d0 100644 --- a/app/src/apps/classroom_compliance/EntriesTable.vue +++ b/app/src/apps/classroom_compliance/EntriesTable.vue @@ -1,18 +1,33 @@