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,
|
||||
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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import AlertDialog from './components/AlertDialog.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function logOut() {
|
||||
authStore.logOut()
|
||||
await router.replace('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -15,7 +22,7 @@ const authStore = useAuthStore()
|
|||
<span v-if="authStore.state">
|
||||
Welcome, <span v-text="authStore.state.username"></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 v-if="authStore.state">
|
||||
Apps:
|
||||
|
@ -26,6 +33,9 @@ const authStore = useAuthStore()
|
|||
</header>
|
||||
|
||||
<RouterView />
|
||||
|
||||
<!-- Global dialog elements are included here below, hidden by default. -->
|
||||
<AlertDialog />
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
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<User | null> {
|
||||
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<User> {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
@ -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<string, EntryResponseItem | null>
|
||||
entries: Record<string, Entry | null>
|
||||
}
|
||||
|
||||
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<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 interface EntriesPayloadStudent {
|
||||
id: number
|
||||
entries: Record<string, Entry | null>
|
||||
}
|
||||
|
||||
export async function getClasses(auth: string): Promise<Class[]> {
|
||||
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<Class> {
|
||||
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<void> {
|
||||
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<Class> {
|
||||
return super.post('/classes', { number: number, schoolYear: schoolYear })
|
||||
}
|
||||
|
||||
getClasses(): APIResponse<Class[]> {
|
||||
return super.get('/classes')
|
||||
}
|
||||
|
||||
getClass(classId: number): APIResponse<Class> {
|
||||
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,
|
||||
studentId: number,
|
||||
data: StudentDataPayload,
|
||||
): 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()
|
||||
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<void> {
|
||||
return super.postWithNoExpectedResponse(`/classes/${classId}/entries`, payload)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStudents(auth: string, classId: number): Promise<Student[]> {
|
||||
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<EntriesResponse> {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { type Class } from '@/api/classroom_compliance';
|
||||
import { type Class } from '@/api/classroom_compliance'
|
||||
|
||||
defineProps<{
|
||||
cls: Class
|
||||
|
|
|
@ -1,22 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import { getClass, getStudents, type Class, type Student } from '@/api/classroom_compliance';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue';
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { onMounted, ref, useTemplateRef, type Ref } from '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<{
|
||||
id: string
|
||||
}>()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const cls: Ref<Class | null> = ref(null)
|
||||
const students: Ref<Student[]> = ref([])
|
||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||
|
||||
const deleteClassDialog = useTemplateRef('deleteClassDialog')
|
||||
onMounted(async () => {
|
||||
const idNumber = parseInt(props.id, 10)
|
||||
cls.value = await getClass(authStore.getBasicAuth(), idNumber)
|
||||
getStudents(authStore.getBasicAuth(), idNumber).then(r => {
|
||||
students.value = r
|
||||
})
|
||||
cls.value = await apiClient.getClass(idNumber).handleErrorsWithAlert()
|
||||
if (!cls.value) {
|
||||
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>
|
||||
<template>
|
||||
<div v-if="cls">
|
||||
|
@ -26,12 +38,22 @@ onMounted(async () => {
|
|||
<div>
|
||||
<div>
|
||||
<span>Actions: </span>
|
||||
<button type="button">Add Student - WIP</button>
|
||||
<button type="button">Delete this Class</button>
|
||||
<RouterLink :to="'/classroom-compliance/classes/' + cls.id + '/edit-student'"
|
||||
>Add Student</RouterLink
|
||||
>
|
||||
<button type="button" @click="deleteThisClass">Delete this Class</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
|
|
|
@ -1,17 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { type Class, getClasses } from '@/api/classroom_compliance';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { type Ref, ref, onMounted } from 'vue';
|
||||
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue';
|
||||
import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compliance'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { type Ref, ref, onMounted } from 'vue'
|
||||
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue'
|
||||
|
||||
const classes: Ref<Class[]> = ref([])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
|
||||
|
||||
onMounted(async () => {
|
||||
classes.value = await getClasses(authStore.getBasicAuth())
|
||||
classes.value = (await apiClient.getClasses().handleErrorsWithAlert()) ?? []
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" />
|
||||
<div>
|
||||
<div>
|
||||
<RouterLink to="/classroom-compliance/edit-class">Add Class</RouterLink>
|
||||
</div>
|
||||
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" />
|
||||
</div>
|
||||
</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">
|
||||
import { getEntries, type EntryResponseStudent } from '@/api/classroom_compliance';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { onMounted, ref, type Ref } from 'vue';
|
||||
import EntryTableCell from './EntryTableCell.vue';
|
||||
import {
|
||||
ClassroomComplianceAPIClient,
|
||||
getDefaultEntry,
|
||||
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 props = defineProps<{
|
||||
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 toDate: 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 () => {
|
||||
toDate.value.setHours(0, 0, 0, 0)
|
||||
fromDate.value.setHours(0, 0, 0, 0)
|
||||
|
@ -21,14 +36,20 @@ onMounted(async () => {
|
|||
})
|
||||
|
||||
async function loadEntries() {
|
||||
const entries = await getEntries(
|
||||
authStore.getBasicAuth(),
|
||||
props.classId,
|
||||
fromDate.value,
|
||||
toDate.value
|
||||
)
|
||||
students.value = entries.students
|
||||
dates.value = entries.dates
|
||||
const entries = await apiClient
|
||||
.getEntries(props.classId, fromDate.value, toDate.value)
|
||||
.handleErrorsWithAlert()
|
||||
if (entries) {
|
||||
students.value = entries.students
|
||||
lastSaveState.value = JSON.stringify(entries.students)
|
||||
lastSaveStateTimestamp.value = Date.now()
|
||||
dates.value = entries.dates
|
||||
} else {
|
||||
students.value = []
|
||||
lastSaveState.value = null
|
||||
lastSaveStateTimestamp.value = Date.now()
|
||||
dates.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function shiftDateRange(days: number) {
|
||||
|
@ -41,30 +62,113 @@ async function showPreviousDay() {
|
|||
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() {
|
||||
shiftDateRange(1)
|
||||
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>
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<div class="buttons-bar">
|
||||
<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="saveEdits" :disabled="!entriesChangedSinceLastSave">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" @click="discardEdits" :disabled="!entriesChangedSinceLastSave">
|
||||
Discard Edits
|
||||
</button>
|
||||
</div>
|
||||
<table class="entries-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -87,4 +191,13 @@ async function showNextDay() {
|
|||
.student-removed {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.buttons-bar {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.buttons-bar>button+button {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,26 +1,103 @@
|
|||
<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<{
|
||||
entry: EntryResponseItem | null
|
||||
const props = defineProps<{
|
||||
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>
|
||||
<template>
|
||||
<td v-if="entry" :class="{ absent: entry.absent }">
|
||||
<span v-if="entry.absent">Absent</span>
|
||||
<div v-if="!entry.absent">
|
||||
<div class="status-item">
|
||||
<span v-if="entry.phone?.compliant">📱</span>
|
||||
<span v-if="!entry.phone?.compliant">📵</span>
|
||||
<td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }">
|
||||
<div v-if="model">
|
||||
<div class="status-item" @click="toggleAbsence">
|
||||
<span v-if="model.absent">Absent</span>
|
||||
<span v-if="!model.absent">Present</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span v-if="entry.behavior?.rating === 3">😇</span>
|
||||
<span v-if="entry.behavior?.rating === 2">😐</span>
|
||||
<span v-if="entry.behavior?.rating === 1">😡</span>
|
||||
<div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent">
|
||||
<span v-if="model.phone?.compliant">📱</span>
|
||||
<span v-if="!model.phone?.compliant">📵</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>
|
||||
</td>
|
||||
<td v-if="entry === null" class="missing-entry"></td>
|
||||
</template>
|
||||
<style scoped>
|
||||
td {
|
||||
|
@ -30,8 +107,7 @@ td {
|
|||
|
||||
.missing-entry {
|
||||
background-color: lightgray;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.absent {
|
||||
|
@ -39,8 +115,16 @@ td {
|
|||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.changed {
|
||||
border: 2px solid orange !important;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-item+.status-item {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<main>
|
||||
<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>
|
||||
<li>Attendance</li>
|
||||
<li>Phone Usage (or lack thereof)</li>
|
||||
<li>Behavior</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<hr />
|
||||
|
||||
<RouterView />
|
||||
</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'),
|
||||
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">
|
||||
import { login } from '@/api/auth';
|
||||
import { AuthenticationAPIClient } from '@/api/auth'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
@ -12,14 +12,13 @@ interface Credentials {
|
|||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const credentials: Ref<Credentials> = ref({ username: '', password: '' })
|
||||
const apiClient = new AuthenticationAPIClient(authStore)
|
||||
|
||||
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) {
|
||||
authStore.logIn(credentials.value.username, credentials.value.password, user)
|
||||
await router.replace('/')
|
||||
} else {
|
||||
console.warn('Invalid credentials.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
Loading…
Reference in New Issue