Added changes for new school year.
Build and Test App / Build-and-test-App (push) Failing after 22s Details
Build and Test API / Build-and-test-API (push) Successful in 50s Details

This commit is contained in:
Andrew Lalis 2025-09-01 16:22:29 -04:00
parent 2c08f1bdbd
commit 6177943db7
23 changed files with 476 additions and 140 deletions

View File

@ -6,8 +6,9 @@ CREATE TABLE classroom_compliance_class (
user_id BIGINT NOT NULL user_id BIGINT NOT NULL
REFERENCES auth_user(id) REFERENCES auth_user(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
score_expression VARCHAR(255) NOT NULL DEFAULT '0.5 * phone + 0.5 * behavior', score_expression VARCHAR(255) NOT NULL DEFAULT '0.5 * classroom_readiness + 0.5 * behavior',
score_period VARCHAR(64) NOT NULL DEFAULT 'week', score_period VARCHAR(64) NOT NULL DEFAULT 'week',
archived BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT unique_class_numbers_per_school_year CONSTRAINT unique_class_numbers_per_school_year
UNIQUE(number, school_year, user_id) UNIQUE(number, school_year, user_id)
); );
@ -36,15 +37,24 @@ CREATE TABLE classroom_compliance_entry (
absent BOOLEAN NOT NULL DEFAULT FALSE, absent BOOLEAN NOT NULL DEFAULT FALSE,
comment VARCHAR(2000) NOT NULL DEFAULT '', comment VARCHAR(2000) NOT NULL DEFAULT '',
phone_compliant BOOLEAN NULL DEFAULT NULL, phone_compliant BOOLEAN NULL DEFAULT NULL,
classroom_readiness BOOLEAN NULL DEFAULT NULL,
behavior_rating INT NULL DEFAULT NULL, behavior_rating INT NULL DEFAULT NULL,
CONSTRAINT absence_nulls_check CHECK ( CONSTRAINT absence_nulls_check CHECK (
(absent AND phone_compliant IS NULL AND behavior_rating IS NULL) OR (absent AND classroom_readiness IS NULL AND behavior_rating IS NULL) OR
(NOT absent AND phone_compliant IS NOT NULL AND behavior_rating IS NOT NULL) (NOT absent AND classroom_readiness IS NOT NULL AND behavior_rating IS NOT NULL)
), ),
CONSTRAINT unique_entry_per_date CONSTRAINT unique_entry_per_date
UNIQUE(class_id, student_id, date) UNIQUE(class_id, student_id, date)
); );
CREATE TABLE classroom_compliance_entry_comment_checklist (
entry_id BIGINT NOT NULL
REFERENCES classroom_compliance_entry(id)
ON UPDATE CASCADE ON DELETE CASCADE,
item VARCHAR(2000) NOT NULL,
PRIMARY KEY (entry_id, item)
);
CREATE TABLE classroom_compliance_class_note ( CREATE TABLE classroom_compliance_class_note (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
class_id BIGINT NOT NULL class_id BIGINT NOT NULL

View File

@ -10,9 +10,9 @@ import data_utils;
import api_modules.auth : getAdminUserOrThrow; import api_modules.auth : getAdminUserOrThrow;
struct Announcement { struct Announcement {
const ulong id; ulong id;
const string type; string type;
const string message; string message;
static Announcement parse(DataSetReader r) { static Announcement parse(DataSetReader r) {
return Announcement( return Announcement(

View File

@ -13,12 +13,12 @@ import db;
import data_utils; import data_utils;
struct User { struct User {
const ulong id; ulong id;
const string username; string username;
const string passwordHash; string passwordHash;
const ulong createdAt; ulong createdAt;
const bool isLocked; bool isLocked;
const bool isAdmin; bool isAdmin;
static User parse(DataSetReader r) { static User parse(DataSetReader r) {
return User( return User(

View File

@ -16,6 +16,7 @@ void registerApiEndpoints(PathHandler handler) {
const CLASS_PATH = ROOT_PATH ~ "/classes/:classId:ulong"; const CLASS_PATH = ROOT_PATH ~ "/classes/:classId:ulong";
handler.addMapping(Method.GET, CLASS_PATH, &getClass); handler.addMapping(Method.GET, CLASS_PATH, &getClass);
handler.addMapping(Method.DELETE, CLASS_PATH, &deleteClass); handler.addMapping(Method.DELETE, CLASS_PATH, &deleteClass);
handler.addMapping(Method.POST, CLASS_PATH ~ "/archived", &setArchived);
handler.addMapping(Method.GET, CLASS_PATH ~ "/notes", &getClassNotes); handler.addMapping(Method.GET, CLASS_PATH ~ "/notes", &getClassNotes);
handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote); handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote);
handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote); handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote);

View File

@ -48,7 +48,7 @@ void getClasses(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
const query = " const query = "
SELECT SELECT
c.id, c.number, c.school_year, c.id, c.number, c.school_year, c.archived,
COUNT(DISTINCT s.id) AS student_count, COUNT(DISTINCT s.id) AS student_count,
COUNT(DISTINCT e.id) AS entry_count, COUNT(DISTINCT e.id) AS entry_count,
MAX(e.date) AS last_entry_date MAX(e.date) AS last_entry_date
@ -63,6 +63,7 @@ void getClasses(ref HttpRequestContext ctx) {
ulong id; ulong id;
ushort number; ushort number;
string schoolYear; string schoolYear;
bool archived;
uint studentCount; uint studentCount;
uint entryCount; uint entryCount;
string lastEntryDate; string lastEntryDate;
@ -74,9 +75,10 @@ void getClasses(ref HttpRequestContext ctx) {
r.getUlong(1), r.getUlong(1),
r.getUshort(2), r.getUshort(2),
r.getString(3), r.getString(3),
r.getUint(4), r.getBoolean(4),
r.getUint(5), r.getUint(5),
r.getDate(6).toISOExtString() r.getUint(6),
r.getDate(7).toISOExtString()
), ),
user.id user.id
); );
@ -96,6 +98,7 @@ void deleteClass(ref HttpRequestContext ctx) {
scope(exit) conn.close(); scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user); auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
const query = "DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?"; const query = "DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?";
PreparedStatement ps = conn.prepareStatement(query); PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close(); scope(exit) ps.close();
@ -119,6 +122,7 @@ void createClassNote(ref HttpRequestContext ctx) {
scope(exit) conn.close(); scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user); auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
struct Payload {string content;} struct Payload {string content;}
Payload payload = readJsonPayload!(Payload)(ctx); Payload payload = readJsonPayload!(Payload)(ctx);
const query = "INSERT INTO classroom_compliance_class_note (class_id, content) VALUES (?, ?) RETURNING id"; const query = "INSERT INTO classroom_compliance_class_note (class_id, content) VALUES (?, ?) RETURNING id";
@ -137,6 +141,7 @@ void deleteClassNote(ref HttpRequestContext ctx) {
scope(exit) conn.close(); scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user); auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
ulong noteId = ctx.request.getPathParamAs!ulong("noteId"); ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
const query = "DELETE FROM classroom_compliance_class_note WHERE class_id = ? AND id = ?"; const query = "DELETE FROM classroom_compliance_class_note WHERE class_id = ? AND id = ?";
update(conn, query, cls.id, noteId); update(conn, query, cls.id, noteId);
@ -147,10 +152,20 @@ void resetStudentDesks(ref HttpRequestContext ctx) {
scope(exit) conn.close(); scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user); auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
const query = "UPDATE classroom_compliance_student SET desk_number = 0 WHERE class_id = ?"; const query = "UPDATE classroom_compliance_student SET desk_number = 0 WHERE class_id = ?";
update(conn, query, cls.id); update(conn, query, cls.id);
} }
void setArchived(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
const query = "UPDATE classroom_compliance_class SET archived = ? WHERE id = ?";
update(conn, query, !cls.archived, cls.id);
}
void updateScoreParameters(ref HttpRequestContext ctx) { void updateScoreParameters(ref HttpRequestContext ctx) {
import api_modules.classroom_compliance.score_eval : validateExpression; import api_modules.classroom_compliance.score_eval : validateExpression;
import std.algorithm : canFind; import std.algorithm : canFind;
@ -158,6 +173,7 @@ void updateScoreParameters(ref HttpRequestContext ctx) {
scope(exit) conn.close(); scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user); auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
struct Payload { struct Payload {
string scoreExpression; string scoreExpression;
string scorePeriod; string scorePeriod;

View File

@ -22,7 +22,9 @@ struct EntriesTableEntry {
ulong createdAt; ulong createdAt;
bool absent; bool absent;
string comment; string comment;
string[] checklistItems;
Optional!bool phoneCompliant; Optional!bool phoneCompliant;
Optional!bool classroomReadiness;
Optional!ubyte behaviorRating; Optional!ubyte behaviorRating;
JSONValue toJsonObj() const { JSONValue toJsonObj() const {
@ -32,12 +34,21 @@ struct EntriesTableEntry {
obj.object["createdAt"] = JSONValue(createdAt); obj.object["createdAt"] = JSONValue(createdAt);
obj.object["absent"] = JSONValue(absent); obj.object["absent"] = JSONValue(absent);
obj.object["comment"] = JSONValue(comment); obj.object["comment"] = JSONValue(comment);
obj.object["checklistItems"] = JSONValue.emptyArray;
foreach (ck; checklistItems) {
obj.object["checklistItems"].array ~= JSONValue(ck);
}
if (absent) { if (absent) {
obj.object["phoneCompliant"] = JSONValue(null); obj.object["phoneCompliant"] = JSONValue(null);
obj.object["classroomReadiness"] = JSONValue(null);
obj.object["behaviorRating"] = JSONValue(null); obj.object["behaviorRating"] = JSONValue(null);
} else { } else {
obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value); JSONValue pcValue = phoneCompliant.isNull ? JSONValue(null) : JSONValue(phoneCompliant.value);
obj.object["behaviorRating"] = JSONValue(behaviorRating.value); JSONValue crValue = classroomReadiness.isNull ? JSONValue(null) : JSONValue(classroomReadiness.value);
JSONValue bValue = behaviorRating.isNull ? JSONValue(null) : JSONValue(behaviorRating.value);
obj.object["phoneCompliant"] = pcValue;
obj.object["classroomReadiness"] = crValue;
obj.object["behaviorRating"] = bValue;
} }
return obj; return obj;
} }
@ -125,7 +136,14 @@ void getEntries(ref HttpRequestContext ctx) {
entry.created_at, entry.created_at,
entry.absent, entry.absent,
entry.comment, entry.comment,
(
SELECT STRING_AGG(ck.item, '|||')
FROM classroom_compliance_entry_comment_checklist ck
WHERE ck.entry_id = entry.id
),
entry.phone_compliant, entry.phone_compliant,
entry.classroom_readiness,
entry.behavior_rating, entry.behavior_rating,
student.id, student.id,
student.name, student.name,
@ -155,25 +173,34 @@ void getEntries(ref HttpRequestContext ctx) {
const absent = r.getBoolean(4); const absent = r.getBoolean(4);
Optional!bool phoneCompliant = absent Optional!bool phoneCompliant = absent
? Optional!bool.empty ? Optional!bool.empty
: Optional!bool.of(r.getBoolean(6)); : Optional!bool.of(r.getBoolean(7));
if (r.isNull(7)) phoneCompliant.isNull = true;
Optional!bool classroomReadiness = absent
? Optional!bool.empty
: Optional!bool.of(r.getBoolean(8));
if (r.isNull(8)) classroomReadiness.isNull = true;
Optional!ubyte behaviorRating = absent Optional!ubyte behaviorRating = absent
? Optional!ubyte.empty ? Optional!ubyte.empty
: Optional!ubyte.of(r.getUbyte(7)); : Optional!ubyte.of(r.getUbyte(9));
if (r.isNull(9)) behaviorRating.isNull = true;
import std.string : split;
EntriesTableEntry entryData = EntriesTableEntry( EntriesTableEntry entryData = EntriesTableEntry(
r.getUlong(1), r.getUlong(1),
r.getDate(2), r.getDate(2),
r.getUlong(3), r.getUlong(3),
r.getBoolean(4), r.getBoolean(4),
r.getString(5), r.getString(5),
r.getString(6).split("|||"),
phoneCompliant, phoneCompliant,
classroomReadiness,
behaviorRating behaviorRating
); );
ClassroomComplianceStudent student = ClassroomComplianceStudent( ClassroomComplianceStudent student = ClassroomComplianceStudent(
r.getUlong(8), r.getUlong(10),
r.getString(9), r.getString(11),
r.getUlong(12), r.getUlong(14),
r.getUshort(10), r.getUshort(12),
r.getBoolean(11) r.getBoolean(13)
); );
string dateStr = entryData.date.toISOExtString(); string dateStr = entryData.date.toISOExtString();
@ -252,6 +279,7 @@ void saveEntries(ref HttpRequestContext ctx) {
scope(exit) conn.close(); scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user); auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
JSONValue bodyContent = ctx.request.readBodyAsJson(); JSONValue bodyContent = ctx.request.readBodyAsJson();
try { try {
foreach (JSONValue studentObj; bodyContent.object["students"].array) { foreach (JSONValue studentObj; bodyContent.object["students"].array) {
@ -265,7 +293,16 @@ void saveEntries(ref HttpRequestContext ctx) {
Optional!ClassroomComplianceEntry existingEntry = findOne( Optional!ClassroomComplianceEntry existingEntry = findOne(
conn, conn,
"SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?", "SELECT id, class_id, student_id, date, created_at,
absent, comment,
(
SELECT STRING_AGG(ck.item, '|||')
FROM classroom_compliance_entry_comment_checklist ck
WHERE ck.entry_id = id
),
phone_compliant, classroom_readiness, behavior_rating
FROM classroom_compliance_entry
WHERE class_id = ? AND student_id = ? AND date = ?",
&ClassroomComplianceEntry.parse, &ClassroomComplianceEntry.parse,
cls.id, studentId, dateStr cls.id, studentId, dateStr
); );
@ -333,17 +370,25 @@ private void insertNewEntry(
bool absent = payload.object["absent"].boolean; bool absent = payload.object["absent"].boolean;
string comment = payload.object["comment"].str; string comment = payload.object["comment"].str;
if (comment is null) comment = ""; if (comment is null) comment = "";
Optional!bool phoneCompliant = Optional!bool.empty; string[] checklistItems;
if ("checklistItems" in payload.object) {
checklistItems = payload.object["checklistItems"].array
.map!(v => v.str)
.array;
}
Optional!bool classroomReadiness = Optional!bool.empty;
Optional!ubyte behaviorRating = Optional!ubyte.empty; Optional!ubyte behaviorRating = Optional!ubyte.empty;
if (!absent) { if (!absent) {
phoneCompliant = Optional!bool.of(payload.object["phoneCompliant"].boolean); classroomReadiness = Optional!bool.of(payload.object["classroomReadiness"].boolean);
behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer); behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer);
} }
// Do the main insert first.
import std.variant;
Variant newEntryId;
const query = " const query = "
INSERT INTO classroom_compliance_entry INSERT INTO classroom_compliance_entry
(class_id, student_id, date, absent, comment, phone_compliant, behavior_rating) (class_id, student_id, date, absent, comment, classroom_readiness, behavior_rating)
VALUES (?, ?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id";
PreparedStatement ps = conn.prepareStatement(query); PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close(); scope(exit) ps.close();
ps.setUlong(1, classId); ps.setUlong(1, classId);
@ -355,10 +400,11 @@ private void insertNewEntry(
ps.setNull(6); ps.setNull(6);
ps.setNull(7); ps.setNull(7);
} else { } else {
ps.setBoolean(6, phoneCompliant.value); ps.setBoolean(6, classroomReadiness.value);
ps.setUbyte(7, behaviorRating.value); ps.setUbyte(7, behaviorRating.value);
} }
ps.executeUpdate(); ps.executeUpdate(newEntryId);
updateEntryCommentChecklistItems(conn, newEntryId.coerce!ulong, checklistItems);
infoF!"Created new entry for student %d: %s"(studentId, payload); infoF!"Created new entry for student %d: %s"(studentId, payload);
} }
@ -374,15 +420,21 @@ private void updateEntry(
bool absent = obj.object["absent"].boolean; bool absent = obj.object["absent"].boolean;
string comment = obj.object["comment"].str; string comment = obj.object["comment"].str;
if (comment is null) comment = ""; if (comment is null) comment = "";
Optional!bool phoneCompliant = Optional!bool.empty; string[] checklistItems;
if ("checklistItems" in obj.object) {
checklistItems = obj.object["checklistItems"].array
.map!(v => v.str)
.array;
}
Optional!bool classroomReadiness = Optional!bool.empty;
Optional!ubyte behaviorRating = Optional!ubyte.empty; Optional!ubyte behaviorRating = Optional!ubyte.empty;
if (!absent) { if (!absent) {
phoneCompliant = Optional!bool.of(obj.object["phoneCompliant"].boolean); classroomReadiness = Optional!bool.of(obj.object["classroomReadiness"].boolean);
behaviorRating = Optional!ubyte.of(cast(ubyte) obj.object["behaviorRating"].integer); behaviorRating = Optional!ubyte.of(cast(ubyte) obj.object["behaviorRating"].integer);
} }
const query = " const query = "
UPDATE classroom_compliance_entry UPDATE classroom_compliance_entry
SET absent = ?, comment = ?, phone_compliant = ?, behavior_rating = ? SET absent = ?, comment = ?, classroom_readiness = ?, behavior_rating = ?
WHERE class_id = ? AND student_id = ? AND date = ? AND id = ? WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?
"; ";
PreparedStatement ps = conn.prepareStatement(query); PreparedStatement ps = conn.prepareStatement(query);
@ -393,7 +445,7 @@ private void updateEntry(
ps.setNull(3); ps.setNull(3);
ps.setNull(4); ps.setNull(4);
} else { } else {
ps.setBoolean(3, phoneCompliant.value); ps.setBoolean(3, classroomReadiness.value);
ps.setUbyte(4, behaviorRating.value); ps.setUbyte(4, behaviorRating.value);
} }
ps.setUlong(5, classId); ps.setUlong(5, classId);
@ -401,6 +453,27 @@ private void updateEntry(
ps.setString(7, dateStr); ps.setString(7, dateStr);
ps.setUlong(8, entryId); ps.setUlong(8, entryId);
ps.executeUpdate(); ps.executeUpdate();
updateEntryCommentChecklistItems(conn, entryId, checklistItems);
infoF!"Updated entry %d"(entryId); infoF!"Updated entry %d"(entryId);
} }
private void updateEntryCommentChecklistItems(Connection conn, ulong entryId, string[] items) {
PreparedStatement ps1 = conn.prepareStatement(
"DELETE FROM classroom_compliance_entry_comment_checklist WHERE entry_id = ?"
);
scope(exit) ps1.close();
ps1.setUlong(1, entryId);
ps1.executeUpdate();
const checklistItemsQuery = "
INSERT INTO classroom_compliance_entry_comment_checklist (entry_id, item)
VALUES (?, ?)
";
PreparedStatement ps2 = conn.prepareStatement(checklistItemsQuery);
scope(exit) ps2.close();
foreach (item; items) {
ps2.setUlong(1, entryId);
ps2.setString(2, item);
ps2.executeUpdate();
}
}

View File

@ -29,6 +29,7 @@ void getFullExport(ref HttpRequestContext ctx) {
e.created_at AS entry_created_at, e.created_at AS entry_created_at,
e.absent AS absent, e.absent AS absent,
e.phone_compliant AS phone_compliant, e.phone_compliant AS phone_compliant,
e.classroom_readiness AS classroom_readiness,
e.behavior_rating AS behavior_rating, e.behavior_rating AS behavior_rating,
e.comment AS comment e.comment AS comment
FROM classroom_compliance_class c FROM classroom_compliance_class c
@ -62,6 +63,7 @@ void getFullExport(ref HttpRequestContext ctx) {
CSVColumnDef("Entry Created At", (r, i) => formatCreationTimestamp(r.getUlong(i))), CSVColumnDef("Entry Created At", (r, i) => formatCreationTimestamp(r.getUlong(i))),
CSVColumnDef("Absence", (r, i) => r.getBoolean(i) ? "Absent" : "Present"), CSVColumnDef("Absence", (r, i) => r.getBoolean(i) ? "Absent" : "Present"),
CSVColumnDef("Phone Compliant", &formatPhoneCompliance), CSVColumnDef("Phone Compliant", &formatPhoneCompliance),
CSVColumnDef("Classroom Readiness", &formatClassroomReadiness),
CSVColumnDef("Behavior Rating", &formatBehaviorRating), CSVColumnDef("Behavior Rating", &formatBehaviorRating),
CSVColumnDef("Comment", &formatComment) CSVColumnDef("Comment", &formatComment)
]; ];
@ -95,6 +97,11 @@ private string formatPhoneCompliance(DataSetReader r, int i) {
return r.getBoolean(i) ? "Compliant" : "Non-compliant"; return r.getBoolean(i) ? "Compliant" : "Non-compliant";
} }
private string formatClassroomReadiness(DataSetReader r, int i) {
if (r.isNull(i)) return "N/A";
return r.getBoolean(i) ? "Ready" : "Not Ready";
}
private string formatBehaviorRating(DataSetReader r, int i) { private string formatBehaviorRating(DataSetReader r, int i) {
if (r.isNull(i)) return "N/A"; if (r.isNull(i)) return "N/A";
ubyte score = r.getUbyte(i); ubyte score = r.getUbyte(i);

View File

@ -15,6 +15,7 @@ void createStudent(ref HttpRequestContext ctx) {
scope(exit) conn.close(); scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user); auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
struct StudentPayload { struct StudentPayload {
string name; string name;
ushort deskNumber; ushort deskNumber;
@ -83,6 +84,8 @@ void updateStudent(ref HttpRequestContext ctx) {
Connection conn = getDb(); Connection conn = getDb();
scope(exit) conn.close(); scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
auto student = getStudentOrThrow(ctx, conn, user); auto student = getStudentOrThrow(ctx, conn, user);
struct StudentUpdatePayload { struct StudentUpdatePayload {
string name; string name;
@ -139,6 +142,8 @@ void deleteStudent(ref HttpRequestContext ctx) {
Connection conn = getDb(); Connection conn = getDb();
scope(exit) conn.close(); scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
auto student = getStudentOrThrow(ctx, conn, user); auto student = getStudentOrThrow(ctx, conn, user);
update( update(
conn, conn,
@ -154,7 +159,18 @@ void getStudentEntries(ref HttpRequestContext ctx) {
auto student = getStudentOrThrow(ctx, conn, user); auto student = getStudentOrThrow(ctx, conn, user);
auto entries = findAll( auto entries = findAll(
conn, conn,
"SELECT * FROM classroom_compliance_entry WHERE student_id = ? ORDER BY date DESC", "SELECT id, class_id, student_id, date, created_at,
absent, comment,
(
SELECT STRING_AGG(ck.item, '|||')
FROM classroom_compliance_entry_comment_checklist ck
WHERE ck.entry_id = id
),
phone_compliant, classroom_readiness, behavior_rating
FROM classroom_compliance_entry
WHERE student_id = ?
GROUP BY classroom_compliance_entry.id
ORDER BY date DESC",
&ClassroomComplianceEntry.parse, &ClassroomComplianceEntry.parse,
student.id student.id
); );
@ -221,6 +237,8 @@ void moveStudentToOtherClass(ref HttpRequestContext ctx) {
scope(exit) conn.close(); scope(exit) conn.close();
conn.setAutoCommit(false); conn.setAutoCommit(false);
User user = getUserOrThrow(ctx, conn); User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
auto student = getStudentOrThrow(ctx, conn, user); auto student = getStudentOrThrow(ctx, conn, user);
struct Payload { struct Payload {
ulong classId; ulong classId;

View File

@ -4,20 +4,23 @@ import ddbc : DataSetReader;
import handy_httpd.components.optional; import handy_httpd.components.optional;
import std.json; import std.json;
import std.datetime; import std.datetime;
import std.string : split;
struct ClassroomComplianceClass { struct ClassroomComplianceClass {
const ulong id; ulong id;
const ushort number; ushort number;
const string schoolYear; string schoolYear;
const ulong userId; bool archived;
const string scoreExpression; ulong userId;
const string scorePeriod; string scoreExpression;
string scorePeriod;
static ClassroomComplianceClass parse(DataSetReader r) { static ClassroomComplianceClass parse(DataSetReader r) {
return ClassroomComplianceClass( return ClassroomComplianceClass(
r.getUlong(1), r.getUlong(1),
r.getUshort(2), r.getUshort(2),
r.getString(3), r.getString(3),
r.getBoolean(7),
r.getUlong(4), r.getUlong(4),
r.getString(5), r.getString(5),
r.getString(6) r.getString(6)
@ -26,11 +29,11 @@ struct ClassroomComplianceClass {
} }
struct ClassroomComplianceStudent { struct ClassroomComplianceStudent {
const ulong id; ulong id;
const string name; string name;
const ulong classId; ulong classId;
const ushort deskNumber; ushort deskNumber;
const bool removed; bool removed;
static ClassroomComplianceStudent parse(DataSetReader r) { static ClassroomComplianceStudent parse(DataSetReader r) {
return ClassroomComplianceStudent( return ClassroomComplianceStudent(
@ -44,35 +47,32 @@ struct ClassroomComplianceStudent {
} }
struct ClassroomComplianceEntry { struct ClassroomComplianceEntry {
const ulong id; ulong id;
const ulong classId; ulong classId;
const ulong studentId; ulong studentId;
const Date date; Date date;
const ulong createdAt; ulong createdAt;
const bool absent; bool absent;
const string comment; string comment;
const Optional!bool phoneCompliant; string[] checklistItems;
const Optional!ubyte behaviorRating; Optional!bool phoneCompliant;
Optional!bool classroomReadiness;
Optional!ubyte behaviorRating;
static ClassroomComplianceEntry parse(DataSetReader r) { static ClassroomComplianceEntry parse(DataSetReader r) {
Optional!bool phone = !r.isNull(8) ClassroomComplianceEntry entry;
? Optional!bool.of(r.getBoolean(8)) entry.id = r.getUlong(1);
: Optional!bool.empty; entry.classId = r.getUlong(2);
Optional!ubyte behavior = !r.isNull(9) entry.studentId = r.getUlong(3);
? Optional!ubyte.of(r.getUbyte(9)) entry.date = r.getDate(4);
: Optional!ubyte.empty; entry.createdAt = r.getUlong(5);
entry.absent = r.getBoolean(6);
return ClassroomComplianceEntry( entry.comment = r.getString(7);
r.getUlong(1), entry.checklistItems = r.getString(8).split("|||");
r.getUlong(2), entry.phoneCompliant = r.isNull(9) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(9));
r.getUlong(3), entry.classroomReadiness = r.isNull(10) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(10));
r.getDate(4), entry.behaviorRating = r.isNull(11) ? Optional!ubyte.empty : Optional!ubyte.of(r.getUbyte(11));
r.getUlong(5), return entry;
r.getBoolean(6),
r.getString(7),
phone,
behavior
);
} }
JSONValue toJsonObj() const { JSONValue toJsonObj() const {
@ -84,28 +84,22 @@ struct ClassroomComplianceEntry {
obj.object["createdAt"] = JSONValue(createdAt); obj.object["createdAt"] = JSONValue(createdAt);
obj.object["absent"] = JSONValue(absent); obj.object["absent"] = JSONValue(absent);
obj.object["comment"] = JSONValue(comment); obj.object["comment"] = JSONValue(comment);
if (absent) { obj.object["checklistItems"] = JSONValue.emptyArray;
if (!phoneCompliant.isNull || !behaviorRating.isNull) { foreach (item; checklistItems) {
throw new Exception("Illegal entry state! Absent is true while values are not null!"); obj.object["checklistItems"].array ~= JSONValue(item);
}
obj.object["phoneCompliant"] = JSONValue(null);
obj.object["behaviorRating"] = JSONValue(null);
} else {
if (phoneCompliant.isNull || behaviorRating.isNull) {
throw new Exception("Illegal entry state! Absent is false while values are null!");
}
obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value);
obj.object["behaviorRating"] = JSONValue(behaviorRating.value);
} }
obj.object["phoneCompliant"] = phoneCompliant.isNull ? JSONValue(null) : JSONValue(phoneCompliant.value);
obj.object["classroomReadiness"] = classroomReadiness.isNull ? JSONValue(null) : JSONValue(classroomReadiness.value);
obj.object["behaviorRating"] = behaviorRating.isNull ? JSONValue(null) : JSONValue(behaviorRating.value);
return obj; return obj;
} }
} }
struct ClassroomComplianceClassNote { struct ClassroomComplianceClassNote {
const ulong id; ulong id;
const ulong classId; ulong classId;
const ulong createdAt; ulong createdAt;
const string content; string content;
static ClassroomComplianceClassNote parse(DataSetReader r) { static ClassroomComplianceClassNote parse(DataSetReader r) {
return ClassroomComplianceClassNote( return ClassroomComplianceClassNote(

View File

@ -15,7 +15,7 @@ enum ScorePeriod : string {
private struct ScoringParameters { private struct ScoringParameters {
Expr expr; Expr expr;
const DateRange dateRange; DateRange dateRange;
} }
/** /**
@ -54,6 +54,7 @@ Optional!double[ulong] getScores(
COUNT(id) AS entry_count, COUNT(id) AS entry_count,
SUM(CASE WHEN absent = TRUE THEN 1 ELSE 0 END) AS absence_count, SUM(CASE WHEN absent = TRUE THEN 1 ELSE 0 END) AS absence_count,
SUM(CASE WHEN phone_compliant = FALSE THEN 1 ELSE 0 END) AS phone_noncompliance_count, SUM(CASE WHEN phone_compliant = FALSE THEN 1 ELSE 0 END) AS phone_noncompliance_count,
SUM(CASE WHEN classroom_readiness = FALSE THEN 1 ELSE 0 END) AS not_classroom_ready_count,
SUM(CASE WHEN behavior_rating = 3 THEN 1 ELSE 0 END) AS behavior_good, SUM(CASE WHEN behavior_rating = 3 THEN 1 ELSE 0 END) AS behavior_good,
SUM(CASE WHEN behavior_rating = 2 THEN 1 ELSE 0 END) AS behavior_mediocre, SUM(CASE WHEN behavior_rating = 2 THEN 1 ELSE 0 END) AS behavior_mediocre,
SUM(CASE WHEN behavior_rating = 1 THEN 1 ELSE 0 END) AS behavior_poor SUM(CASE WHEN behavior_rating = 1 THEN 1 ELSE 0 END) AS behavior_poor
@ -74,14 +75,16 @@ Optional!double[ulong] getScores(
uint entryCount = r.getUint(2); uint entryCount = r.getUint(2);
uint absenceCount = r.getUint(3); uint absenceCount = r.getUint(3);
uint phoneNonComplianceCount = r.getUint(4); uint phoneNonComplianceCount = r.getUint(4);
uint behaviorGoodCount = r.getUint(5); uint notClassroomReadyCount = r.getUint(5);
uint behaviorMediocreCount = r.getUint(6); uint behaviorGoodCount = r.getUint(6);
uint behaviorPoorCount = r.getUint(7); uint behaviorMediocreCount = r.getUint(7);
uint behaviorPoorCount = r.getUint(8);
scores[studentId] = calculateScore( scores[studentId] = calculateScore(
params.expr, params.expr,
entryCount, entryCount,
absenceCount, absenceCount,
phoneNonComplianceCount, phoneNonComplianceCount,
notClassroomReadyCount,
behaviorGoodCount, behaviorGoodCount,
behaviorMediocreCount, behaviorMediocreCount,
behaviorPoorCount behaviorPoorCount
@ -132,6 +135,7 @@ private Optional!double calculateScore(
uint entryCount, uint entryCount,
uint absenceCount, uint absenceCount,
uint phoneNonComplianceCount, uint phoneNonComplianceCount,
uint notClassroomReadyCount,
uint behaviorGoodCount, uint behaviorGoodCount,
uint behaviorMediocreCount, uint behaviorMediocreCount,
uint behaviorPoorCount uint behaviorPoorCount
@ -151,6 +155,14 @@ private Optional!double calculateScore(
phoneCompliantCount = presentCount - phoneNonComplianceCount; phoneCompliantCount = presentCount - phoneNonComplianceCount;
} }
double phoneScore = phoneCompliantCount / cast(double) presentCount; double phoneScore = phoneCompliantCount / cast(double) presentCount;
// Classroom readiness score:
uint classroomReadyCount;
if (presentCount < notClassroomReadyCount) {
classroomReadyCount = 0;
} else {
classroomReadyCount = presentCount - notClassroomReadyCount;
}
double classroomReadinessScore = classroomReadyCount / cast(double) presentCount;
double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount; double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount;
double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount; double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount;
@ -159,6 +171,7 @@ private Optional!double calculateScore(
return Optional!double.of(scoreExpression.eval([ return Optional!double.of(scoreExpression.eval([
"phone": phoneScore, "phone": phoneScore,
"classroom_readiness": classroomReadinessScore,
"behavior": typicalBehaviorScore, "behavior": typicalBehaviorScore,
"behavior_good": behaviorGoodScore, "behavior_good": behaviorGoodScore,
"behavior_mediocre": behaviorMediocreScore, "behavior_mediocre": behaviorMediocreScore,

View File

@ -35,7 +35,8 @@ void initializeSchema() {
scope(exit) stmt.close(); scope(exit) stmt.close();
const string AUTH_SCHEMA = import("schema/auth.sql"); const string AUTH_SCHEMA = import("schema/auth.sql");
const string CLASSROOM_COMPLIANCE_SCHEMA = import("schema/classroom_compliance.sql"); const string CLASSROOM_COMPLIANCE_SCHEMA = import("schema/classroom_compliance.sql");
const schemas = [AUTH_SCHEMA, CLASSROOM_COMPLIANCE_SCHEMA]; const string ANNOUNCEMENT_SCHEMA = import("schema/announcement.sql");
const schemas = [AUTH_SCHEMA, CLASSROOM_COMPLIANCE_SCHEMA, ANNOUNCEMENT_SCHEMA];
uint schemaNumber = 1; uint schemaNumber = 1;
foreach (schema; schemas) { foreach (schema; schemas) {
infoF!"Intializing schema #%d."(schemaNumber++); infoF!"Intializing schema #%d."(schemaNumber++);

View File

@ -137,8 +137,8 @@ void addEntry(
) { ) {
const entryQuery = " const entryQuery = "
INSERT INTO classroom_compliance_entry INSERT INTO classroom_compliance_entry
(class_id, student_id, date, absent, comment, phone_compliant, behavior_rating) (class_id, student_id, date, absent, comment, phone_compliant, classroom_readiness, behavior_rating)
VALUES (?, ?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
PreparedStatement ps = conn.prepareStatement(entryQuery); PreparedStatement ps = conn.prepareStatement(entryQuery);
scope(exit) ps.close(); scope(exit) ps.close();
ps.setUlong(1, classId); ps.setUlong(1, classId);
@ -150,12 +150,13 @@ void addEntry(
} else { } else {
ps.setString(5, comment); ps.setString(5, comment);
} }
ps.setNull(6); // Always set phone_compliant as null from now on.
if (absent) { if (absent) {
ps.setNull(6);
ps.setNull(7); ps.setNull(7);
ps.setNull(8);
} else { } else {
ps.setBoolean(6, phoneCompliant); ps.setBoolean(7, true);
ps.setUint(7, behaviorRating); ps.setUint(8, behaviorRating);
} }
ps.executeUpdate(); ps.executeUpdate();
} }
@ -167,6 +168,7 @@ void deleteAllData(Connection conn) {
const tables = [ const tables = [
"announcement", "announcement",
"classroom_compliance_class_note", "classroom_compliance_class_note",
"classroom_compliance_entry_comment_checklist",
"classroom_compliance_entry", "classroom_compliance_entry",
"classroom_compliance_student", "classroom_compliance_student",
"classroom_compliance_class", "classroom_compliance_class",

View File

@ -4,6 +4,8 @@ export const BASE_URL = import.meta.env.VITE_API_URL + '/classroom-compliance'
export const EMOJI_PHONE_COMPLIANT = '📱' export const EMOJI_PHONE_COMPLIANT = '📱'
export const EMOJI_PHONE_NONCOMPLIANT = '📵' export const EMOJI_PHONE_NONCOMPLIANT = '📵'
export const EMOJI_CLASSROOM_READY = '🍎'
export const EMOJI_NOT_CLASSROOM_READY = '🐛'
export const EMOJI_PRESENT = '✅' export const EMOJI_PRESENT = '✅'
export const EMOJI_ABSENT = '❌' export const EMOJI_ABSENT = '❌'
export const EMOJI_BEHAVIOR_GOOD = '😇' export const EMOJI_BEHAVIOR_GOOD = '😇'
@ -16,6 +18,7 @@ export interface Class {
schoolYear: string schoolYear: string
scoreExpression: string scoreExpression: string
scorePeriod: string scorePeriod: string
archived: boolean
} }
export interface ClassesResponseClass { export interface ClassesResponseClass {
@ -25,6 +28,7 @@ export interface ClassesResponseClass {
studentCount: number studentCount: number
entryCount: number entryCount: number
lastEntryDate: string lastEntryDate: string
archived: boolean
} }
export interface Student { export interface Student {
@ -41,8 +45,10 @@ export interface Entry {
createdAt: number createdAt: number
absent: boolean absent: boolean
phoneCompliant: boolean | null phoneCompliant: boolean | null
classroomReadiness: boolean | null
behaviorRating: number | null behaviorRating: number | null
comment: string comment: string
checklistItems: string[]
} }
export function getDefaultEntry(dateStr: string): Entry { export function getDefaultEntry(dateStr: string): Entry {
@ -51,9 +57,11 @@ export function getDefaultEntry(dateStr: string): Entry {
date: dateStr, date: dateStr,
createdAt: Date.now(), createdAt: Date.now(),
absent: false, absent: false,
phoneCompliant: true, phoneCompliant: null,
classroomReadiness: true,
behaviorRating: 3, behaviorRating: 3,
comment: '', comment: '',
checklistItems: [],
} }
} }
@ -152,6 +160,10 @@ export class ClassroomComplianceAPIClient extends APIClient {
return new APIResponse(this.handleAPIResponseWithNoBody(promise)) return new APIResponse(this.handleAPIResponseWithNoBody(promise))
} }
toggleArchived(classId: number): APIResponse<void> {
return super.postWithNoExpectedResponse(`/classes/${classId}/archived`, {})
}
updateScoreParameters( updateScoreParameters(
classId: number, classId: number,
scoreExpression: string, scoreExpression: string,

View File

@ -9,7 +9,9 @@ const router = useRouter()
</script> </script>
<template> <template>
<div class="class-item" @click="router.push(`/classroom-compliance/classes/${cls.id}`)"> <div class="class-item" @click="router.push(`/classroom-compliance/classes/${cls.id}`)">
<h3>Class <span v-text="cls.number"></span></h3> <h3>Class <span v-text="cls.number"></span>&nbsp;<span v-text="cls.schoolYear"
style="font-weight: lighter; font-size: small;"></span>
</h3>
<p> <p>
{{ cls.studentCount }} students, {{ cls.studentCount }} students,
{{ cls.entryCount }} entries. {{ cls.entryCount }} entries.

View File

@ -4,6 +4,7 @@ import { useAuthStore } from '@/stores/auth';
const props = defineProps<{ const props = defineProps<{
note: ClassNote note: ClassNote
disabled?: boolean
}>() }>()
const emit = defineEmits(['noteDeleted']) const emit = defineEmits(['noteDeleted'])
const authStore = useAuthStore() const authStore = useAuthStore()
@ -21,7 +22,7 @@ async function deleteNote() {
<p class="class-note-item-content">{{ note.content }}</p> <p class="class-note-item-content">{{ note.content }}</p>
<div class="class-note-item-attributes-container"> <div class="class-note-item-attributes-container">
<p class="class-note-item-timestamp">{{ new Date(note.createdAt).toLocaleString() }}</p> <p class="class-note-item-timestamp">{{ new Date(note.createdAt).toLocaleString() }}</p>
<button class="class-note-item-delete-button" @click="deleteNote()">Delete</button> <button class="class-note-item-delete-button" @click="deleteNote()" :disabled="disabled">Delete</button>
</div> </div>
</div> </div>
</template> </template>

View File

@ -17,6 +17,7 @@ const notes: Ref<ClassNote[]> = ref([])
const noteContent: Ref<string> = ref('') const noteContent: Ref<string> = ref('')
const scoreExpression: Ref<string> = ref('') const scoreExpression: Ref<string> = ref('')
const scorePeriod: Ref<string> = ref('') const scorePeriod: Ref<string> = ref('')
const archived = computed(() => cls.value !== null && cls.value.archived)
const canUpdateScoreParameters = computed(() => { const canUpdateScoreParameters = computed(() => {
return cls.value && ( return cls.value && (
cls.value.scoreExpression !== scoreExpression.value || cls.value.scoreExpression !== scoreExpression.value ||
@ -53,6 +54,12 @@ async function deleteThisClass() {
await router.replace('/classroom-compliance') await router.replace('/classroom-compliance')
} }
async function toggleArchived() {
if (!cls.value) return
await apiClient.toggleArchived(cls.value.id).handleErrorsWithAlertNoBody()
loadClass()
}
async function submitNote() { async function submitNote() {
if (noteContent.value.trim().length < 1 || !cls.value) return if (noteContent.value.trim().length < 1 || !cls.value) return
const note = await apiClient.createClassNote(cls.value?.id, noteContent.value).handleErrorsWithAlert() const note = await apiClient.createClassNote(cls.value?.id, noteContent.value).handleErrorsWithAlert()
@ -113,22 +120,28 @@ function resetScoreParameters() {
<h1 class="align-center" style="margin-bottom: 0;">Class <span v-text="cls.number"></span></h1> <h1 class="align-center" style="margin-bottom: 0;">Class <span v-text="cls.number"></span></h1>
<p class="align-center" style="margin-top: 0; margin-bottom: 2em;">For the {{ cls.schoolYear }} school year.</p> <p class="align-center" style="margin-top: 0; margin-bottom: 2em;">For the {{ cls.schoolYear }} school year.</p>
<div class="button-bar align-center" style="margin-bottom: 1em;"> <div class="button-bar align-center" style="margin-bottom: 1em;">
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/edit-student`)">Add <button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/edit-student`)"
:disabled="archived">Add
Student</button> Student</button>
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/import-students`)">Import <button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/import-students`)"
:disabled="archived">Import
Students</button> Students</button>
<button type="button" @click="resetStudentDesks">Clear Assigned Desks</button> <button type="button" @click="resetStudentDesks" :disabled="archived">Clear Assigned Desks</button>
<button type="button" @click="deleteThisClass">Delete this Class</button> <button type="button" @click="deleteThisClass" :disabled="archived">Delete this Class</button>
<button type="button" @click="toggleArchived">
<span v-if="archived">Unarchive</span>
<span v-if="!archived">Archive</span>
</button>
</div> </div>
<EntriesTable :classId="cls.id" ref="entries-table" /> <EntriesTable :classId="cls.id" :disabled="archived" ref="entries-table" />
<div> <div>
<h3 style="margin-bottom: 0.25em;">Notes</h3> <h3 style="margin-bottom: 0.25em;">Notes</h3>
<form @submit.prevent="submitNote"> <form @submit.prevent="submitNote">
<textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1" <textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1" v-model="noteContent"
v-model="noteContent"></textarea> :readonly="archived"></textarea>
<button style="vertical-align: top; margin-left: 0.5em;" type="submit">Add Note</button> <button style="vertical-align: top; margin-left: 0.5em;" type="submit" :disabled="archived">Add Note</button>
</form> </form>
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" /> <ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
</div> </div>
@ -142,7 +155,7 @@ function resetScoreParameters() {
<div> <div>
<label for="score-expression-input">Expression</label> <label for="score-expression-input">Expression</label>
<textarea id="score-expression-input" v-model="scoreExpression" class="text-mono" minlength="1" <textarea id="score-expression-input" v-model="scoreExpression" class="text-mono" minlength="1"
maxlength="255" style="min-width: 500px; min-height: 50px;"></textarea> maxlength="255" style="min-width: 500px; min-height: 50px;" :readonly="archived"></textarea>
</div> </div>
<p class="form-input-hint"> <p class="form-input-hint">
The expression to use to calculate each student's score. This should be a simple mathematical expression that The expression to use to calculate each student's score. This should be a simple mathematical expression that
@ -169,7 +182,7 @@ function resetScoreParameters() {
</p> </p>
<div> <div>
<label for="score-period-select">Period</label> <label for="score-period-select">Period</label>
<select v-model="scorePeriod"> <select v-model="scorePeriod" :disabled="archived">
<option value="week">Weekly</option> <option value="week">Weekly</option>
</select> </select>
</div> </div>
@ -177,8 +190,9 @@ function resetScoreParameters() {
The period over which to calculate scores. The period over which to calculate scores.
</p> </p>
<div class="button-bar"> <div class="button-bar">
<button type="submit" :disabled="!canUpdateScoreParameters">Save</button> <button type="submit" :disabled="!canUpdateScoreParameters || archived">Save</button>
<button type="button" :disabled="!canUpdateScoreParameters" @click="resetScoreParameters">Reset</button> <button type="button" :disabled="!canUpdateScoreParameters || archived"
@click="resetScoreParameters">Reset</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ClassroomComplianceAPIClient, type ClassesResponseClass } from '@/api/classroom_compliance' import { ClassroomComplianceAPIClient, type ClassesResponseClass } 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, computed } from 'vue'
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue' import ClassItem from '@/apps/classroom_compliance/ClassItem.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const classes: Ref<ClassesResponseClass[]> = ref([]) const classes: Ref<ClassesResponseClass[]> = ref([])
const archivedClasses = computed(() => classes.value.filter(c => c.archived))
const activeClasses = computed(() => classes.value.filter(c => !c.archived))
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
@ -23,7 +25,11 @@ onMounted(() => {
<button type="button" @click="router.push('/classroom-compliance/edit-class')">Add Class</button> <button type="button" @click="router.push('/classroom-compliance/edit-class')">Add Class</button>
</div> </div>
<div> <div>
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" /> <ClassItem v-for="cls in activeClasses" :key="cls.id" :cls="cls" />
</div>
<div v-if="archivedClasses.length > 0">
<h3 style="text-align: center;">Archived Classes</h3>
<ClassItem v-for="cls in archivedClasses" :key="cls.id" :cls="cls" />
</div> </div>
</div> </div>
</template> </template>

View File

@ -16,7 +16,8 @@ import StudentNameCell from '@/apps/classroom_compliance/entries_table/StudentNa
const authStore = useAuthStore() const authStore = useAuthStore()
const props = defineProps<{ const props = defineProps<{
classId: number classId: number,
disabled: boolean
}>() }>()
const apiClient = new ClassroomComplianceAPIClient(authStore) const apiClient = new ClassroomComplianceAPIClient(authStore)
@ -160,6 +161,11 @@ async function showNextWeek() {
} }
async function saveEdits() { async function saveEdits() {
if (props.disabled) {
console.warn('Cannot save edits when disabled.')
await loadEntries()
return
}
if (!lastSaveState.value) { if (!lastSaveState.value) {
console.warn('No lastSaveState, cannot determine what edits were made.') console.warn('No lastSaveState, cannot determine what edits were made.')
await loadEntries() await loadEntries()
@ -237,6 +243,7 @@ function getVisibleStudentEntries(student: EntriesResponseStudent): Record<strin
} }
function addAllEntriesForDate(dateStr: string) { function addAllEntriesForDate(dateStr: string) {
if (props.disabled) return
for (let i = 0; i < students.value.length; i++) { for (let i = 0; i < students.value.length; i++) {
const student = students.value[i] const student = students.value[i]
if (student.removed) continue if (student.removed) continue
@ -293,7 +300,7 @@ defineExpose({
<th v-if="selectedView !== TableView.WHITEBOARD">#</th> <th v-if="selectedView !== TableView.WHITEBOARD">#</th>
<th>Student</th> <th>Student</th>
<th v-if="assignedDesks">Desk</th> <th v-if="assignedDesks">Desk</th>
<DateHeaderCell v-for="date in getVisibleDates()" :key="date" :date-str="date" <DateHeaderCell v-for="date in getVisibleDates()" :key="date" :date-str="date" :disabled="disabled"
@add-all-entries-clicked="addAllEntriesForDate(date)" /> @add-all-entries-clicked="addAllEntriesForDate(date)" />
<th v-if="selectedView !== TableView.WHITEBOARD">Score</th> <th v-if="selectedView !== TableView.WHITEBOARD">Score</th>
</tr> </tr>
@ -305,7 +312,8 @@ defineExpose({
<StudentNameCell :student="student" :class-id="classId" /> <StudentNameCell :student="student" :class-id="classId" />
<td v-if="assignedDesks" v-text="student.deskNumber"></td> <td v-if="assignedDesks" v-text="student.deskNumber"></td>
<EntryTableCell v-for="(entry, date) in getVisibleStudentEntries(student)" :key="date" <EntryTableCell v-for="(entry, date) in getVisibleStudentEntries(student)" :key="date"
v-model="student.entries[date]" :date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp" /> v-model="student.entries[date]" :date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp"
:disabled="disabled" />
<StudentScoreCell :score="student.score" v-if="selectedView !== TableView.WHITEBOARD" /> <StudentScoreCell :score="student.score" v-if="selectedView !== TableView.WHITEBOARD" />
</tr> </tr>
</tbody> </tbody>

View File

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, type Entry } from '@/api/classroom_compliance'; import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_CLASSROOM_READY, EMOJI_NOT_CLASSROOM_READY, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, type Entry } from '@/api/classroom_compliance';
const props = defineProps<{ const props = defineProps<{
entry: Entry entry: Entry
}>() }>()
function getFormattedDate() { function getFormattedDate() {
const d = new Date(props.entry.date) const d = new Date(props.entry.date + 'T00:00:00')
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return days[d.getDay()] + ', ' + d.toLocaleDateString() return days[d.getDay()] + ', ' + d.toLocaleDateString()
} }
@ -21,6 +21,9 @@ function getFormattedDate() {
<span v-if="entry.phoneCompliant === true">{{ EMOJI_PHONE_COMPLIANT }}</span> <span v-if="entry.phoneCompliant === true">{{ EMOJI_PHONE_COMPLIANT }}</span>
<span v-if="entry.phoneCompliant === false">{{ EMOJI_PHONE_NONCOMPLIANT }}</span> <span v-if="entry.phoneCompliant === false">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
<span v-if="entry.classroomReadiness === true">{{ EMOJI_CLASSROOM_READY }}</span>
<span v-if="entry.classroomReadiness === false">{{ EMOJI_NOT_CLASSROOM_READY }}</span>
<span v-if="entry.behaviorRating === 3">{{ EMOJI_BEHAVIOR_GOOD }}</span> <span v-if="entry.behaviorRating === 3">{{ EMOJI_BEHAVIOR_GOOD }}</span>
<span v-if="entry.behaviorRating === 2">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span> <span v-if="entry.behaviorRating === 2">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
<span v-if="entry.behaviorRating === 1">{{ EMOJI_BEHAVIOR_POOR }}</span> <span v-if="entry.behaviorRating === 1">{{ EMOJI_BEHAVIOR_POOR }}</span>
@ -28,6 +31,9 @@ function getFormattedDate() {
<p v-if="entry.comment.trim().length > 0" class="comment"> <p v-if="entry.comment.trim().length > 0" class="comment">
{{ entry.comment }} {{ entry.comment }}
</p> </p>
<ul v-if="entry.checklistItems">
<li v-for="item in entry.checklistItems" :key="item" v-text="item"></li>
</ul>
</div> </div>
</template> </template>
<style scoped> <style scoped>

View File

@ -2,7 +2,7 @@
import { ClassroomComplianceAPIClient, type Class, type Entry, type Student, type StudentStatisticsOverview } from '@/api/classroom_compliance' import { ClassroomComplianceAPIClient, type Class, type Entry, type Student, type StudentStatisticsOverview } from '@/api/classroom_compliance'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { onMounted, ref, useTemplateRef, type Ref } from 'vue' import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import StudentEntriesList from './StudentEntriesList.vue' import StudentEntriesList from './StudentEntriesList.vue'
import { APIError } from '@/api/base' import { APIError } from '@/api/base'
@ -18,9 +18,38 @@ const cls: Ref<Class | null> = ref(null)
const student: Ref<Student | null> = ref(null) const student: Ref<Student | null> = ref(null)
const entries: Ref<Entry[]> = ref([]) const entries: Ref<Entry[]> = ref([])
const statistics: Ref<StudentStatisticsOverview | null> = ref(null) const statistics: Ref<StudentStatisticsOverview | null> = ref(null)
// Filtered set of entries for "last week", used in the week overview dialog.
const lastWeeksEntries = computed(() => {
const now = new Date()
now.setHours(0, 0, 0, 0)
const toDate = new Date()
if (now.getDay() >= 1 && now.getDay() <= 5) {
// If we're currently in a week, shift the to-date to the Friday of this week.
const dayDiff = 5 - now.getDay()
toDate.setDate(now.getDate() + dayDiff)
} else {
// If it's saturday or sunday, shift back to the previous Friday.
if (now.getDay() === 6) {
toDate.setDate(now.getDate() - 1)
} else {
toDate.setDate(now.getDate() - 2)
}
}
const fromDate = new Date()
fromDate.setHours(0, 0, 0, 0)
fromDate.setDate(toDate.getDate() - 4)
const filtered = entries.value
.filter(e => new Date(e.date + 'T00:00:00').getTime() >= fromDate.getTime() &&
new Date(e.date + 'T00:00:00').getTime() <= toDate.getTime())
filtered.sort((a, b) => new Date(b.date + 'T00:00:00').getTime() - new Date(a.date + 'T00:00:00').getTime())
return filtered
})
const apiClient = new ClassroomComplianceAPIClient(authStore) const apiClient = new ClassroomComplianceAPIClient(authStore)
const deleteConfirmDialog = useTemplateRef('deleteConfirmDialog') const deleteConfirmDialog = useTemplateRef('deleteConfirmDialog')
const weekOverviewDialog = useTemplateRef('weekOverviewDialog')
onMounted(async () => { onMounted(async () => {
const classIdNumber = parseInt(props.classId, 10) const classIdNumber = parseInt(props.classId, 10)
cls.value = await apiClient.getClass(classIdNumber).handleErrorsWithAlert() cls.value = await apiClient.getClass(classIdNumber).handleErrorsWithAlert()
@ -58,6 +87,12 @@ async function deleteThisStudent() {
await apiClient.deleteStudent(cls.value.id, student.value.id) await apiClient.deleteStudent(cls.value.id, student.value.id)
await router.replace(`/classroom-compliance/classes/${cls.value.id}`) await router.replace(`/classroom-compliance/classes/${cls.value.id}`)
} }
function getFormattedDate(entry: Entry) {
const d = new Date(entry.date + 'T00:00:00')
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return days[d.getDay()] + ', ' + d.toLocaleDateString()
}
</script> </script>
<template> <template>
<div v-if="student" class="centered-content"> <div v-if="student" class="centered-content">
@ -73,6 +108,7 @@ async function deleteThisStudent() {
<button type="button" <button type="button"
@click="router.push(`/classroom-compliance/classes/${student.classId}/edit-student?studentId=${student.id}`)">Edit</button> @click="router.push(`/classroom-compliance/classes/${student.classId}/edit-student?studentId=${student.id}`)">Edit</button>
<button type="button" @click="deleteThisStudent">Delete</button> <button type="button" @click="deleteThisStudent">Delete</button>
<button type="button" @click="weekOverviewDialog?.showModal()">Week overview</button>
</div> </div>
<table class="student-properties-table"> <table class="student-properties-table">
@ -116,6 +152,49 @@ async function deleteThisStudent() {
</p> </p>
<p>This <strong>cannot</strong> be undone!</p> <p>This <strong>cannot</strong> be undone!</p>
</ConfirmDialog> </ConfirmDialog>
<dialog ref="weekOverviewDialog" method="dialog" class="weekly-overview-dialog">
<div>
<h2>This week's overview for <span v-text="student.name"></span></h2>
<div v-for="entry in lastWeeksEntries" :key="entry.id" class="weekly-overview-dialog-day">
<h4>{{ getFormattedDate(entry) }}</h4>
<div v-if="entry.classroomReadiness !== null">
<h5>Classroom Readiness</h5>
<p v-if="entry.classroomReadiness"> Ready for class</p>
<p v-if="!entry.classroomReadiness"> Not ready for class</p>
</div>
<div v-if="entry.behaviorRating !== null">
<h5>Behavior</h5>
<p v-if="entry.behaviorRating === 1">
Poor
</p>
<p v-if="entry.behaviorRating === 2">
Mediocre
</p>
<p v-if="entry.behaviorRating === 3">
Good
</p>
</div>
<div v-if="entry.comment.length > 0">
<h5>Instructor Remarks</h5>
<p>{{ entry.comment }}</p>
</div>
<div v-if="entry.checklistItems.length > 0">
<h5>Classroom Readiness & Behavioral Issues</h5>
<ul>
<li v-for="item in entry.checklistItems" :key="item" v-text="item"></li>
</ul>
</div>
</div>
</div>
<div class="button-bar align-right">
<button @click.prevent="weekOverviewDialog?.close()">Close</button>
</div>
</dialog>
</div> </div>
</template> </template>
<style scoped> <style scoped>
@ -133,4 +212,27 @@ async function deleteThisStudent() {
text-align: right; text-align: right;
font-family: 'SourceCodePro', monospace; font-family: 'SourceCodePro', monospace;
} }
.weekly-overview-dialog-day {
margin-left: 1rem;
}
.weekly-overview-dialog h4 {
margin: 1rem 0 -0.5rem -1rem;
}
.weekly-overview-dialog h5 {
margin: 1rem 0 0.5rem 0;
}
.weekly-overview-dialog p {
margin: 0.5rem 0 0.5rem 1rem;
font-size: 12px;
}
.weekly-overview-dialog ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
font-size: 12px;
}
</style> </style>

View File

@ -3,6 +3,7 @@ import { computed } from 'vue';
const props = defineProps<{ const props = defineProps<{
dateStr: string dateStr: string
disabled?: boolean
}>() }>()
defineEmits<{ defineEmits<{
@ -27,7 +28,7 @@ function getWeekday(date: Date): string {
<template> <template>
<th> <th>
<div class="date-header-container"> <div class="date-header-container">
<div class="date-header-button"> <div class="date-header-button" v-if="!disabled">
<span title="Add All Entries" @click="$emit('addAllEntriesClicked')">+</span> <span title="Add All Entries" @click="$emit('addAllEntriesClicked')">+</span>
</div> </div>
<div> <div>

View File

@ -1,10 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, getDefaultEntry, type Entry } from '@/api/classroom_compliance' import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_CLASSROOM_READY, EMOJI_NOT_CLASSROOM_READY, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, getDefaultEntry, type Entry } from '@/api/classroom_compliance'
import { computed, onMounted, ref, useTemplateRef, watch, type Ref } from 'vue' import { computed, onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'
const COMMENT_CHECKLIST_ITEMS = {
"Classroom Readiness": [
"Tardy",
"Out of Uniform",
"Use of wireless tech",
"10+ minute bathroom pass",
"Not having laptop"
],
"Behavior": [
"Talking out of turn",
"Throwing objects in class",
"Rude language / comments to peers",
"Disrespectful towards the teacher"
]
}
const props = defineProps<{ const props = defineProps<{
dateStr: string dateStr: string
lastSaveStateTimestamp: number lastSaveStateTimestamp: number
disabled?: boolean
}>() }>()
defineEmits<{ defineEmits<{
(e: 'editComment'): void (e: 'editComment'): void
@ -17,7 +34,7 @@ const initialEntryJson: Ref<string> = ref('')
const previouslyRemovedEntry: Ref<Entry | null> = ref(null) const previouslyRemovedEntry: Ref<Entry | null> = ref(null)
const entryChanged = computed(() => JSON.stringify(model.value) !== initialEntryJson.value) const entryChanged = computed(() => JSON.stringify(model.value) !== initialEntryJson.value)
const hasComment = computed(() => model.value && model.value.comment.trim().length > 0) const hasComment = computed(() => model.value && (model.value.comment.trim().length > 0 || model.value.checklistItems.length > 0))
const previousCommentValue: Ref<string> = ref('') const previousCommentValue: Ref<string> = ref('')
const commentEditorDialog = useTemplateRef('commentEditorDialog') const commentEditorDialog = useTemplateRef('commentEditorDialog')
@ -34,21 +51,24 @@ onMounted(() => {
}) })
function toggleAbsence() { function toggleAbsence() {
if (model.value) { if (model.value && !props.disabled) {
model.value.absent = !model.value.absent model.value.absent = !model.value.absent
if (model.value.absent) { if (model.value.absent) {
// Remove additional data if student is absent. // Remove additional data if student is absent.
model.value.phoneCompliant = null model.value.classroomReadiness = null
model.value.behaviorRating = null model.value.behaviorRating = null
} else { } else {
// Populate default additional data if student is no longer absent. // Populate default additional data if student is no longer absent.
model.value.phoneCompliant = true model.value.classroomReadiness = true
model.value.behaviorRating = 3 model.value.behaviorRating = 3
// If we have an initial entry known, restore data from that. // If we have an initial entry known, restore data from that.
if (initialEntryJson.value) { if (initialEntryJson.value) {
const initialEntry = JSON.parse(initialEntryJson.value) as Entry const initialEntry = JSON.parse(initialEntryJson.value) as Entry
if (initialEntry === null) return if (initialEntry === null) return
if (initialEntry.absent) return if (initialEntry.absent) return
if (initialEntry.classroomReadiness) {
model.value.classroomReadiness = initialEntry.classroomReadiness
}
if (initialEntry.phoneCompliant) { if (initialEntry.phoneCompliant) {
model.value.phoneCompliant = initialEntry.phoneCompliant model.value.phoneCompliant = initialEntry.phoneCompliant
} }
@ -61,13 +81,19 @@ function toggleAbsence() {
} }
function togglePhoneCompliance() { function togglePhoneCompliance() {
if (model.value && model.value.phoneCompliant !== null) { if (model.value && model.value.phoneCompliant !== null && !props.disabled) {
model.value.phoneCompliant = !model.value.phoneCompliant model.value.phoneCompliant = !model.value.phoneCompliant
} }
} }
function toggleClassroomReadiness() {
if (model.value && model.value.classroomReadiness !== null && !props.disabled) {
model.value.classroomReadiness = !model.value.classroomReadiness
}
}
function toggleBehaviorRating() { function toggleBehaviorRating() {
if (model.value && model.value.behaviorRating) { if (model.value && model.value.behaviorRating && !props.disabled) {
model.value.behaviorRating = model.value.behaviorRating - 1 model.value.behaviorRating = model.value.behaviorRating - 1
if (model.value.behaviorRating < 1) { if (model.value.behaviorRating < 1) {
model.value.behaviorRating = 3 model.value.behaviorRating = 3
@ -89,6 +115,7 @@ function cancelCommentEdit() {
} }
function removeEntry() { function removeEntry() {
if (props.disabled) return
if (model.value) { if (model.value) {
previouslyRemovedEntry.value = JSON.parse(JSON.stringify(model.value)) previouslyRemovedEntry.value = JSON.parse(JSON.stringify(model.value))
} }
@ -96,6 +123,7 @@ function removeEntry() {
} }
function addEntry() { function addEntry() {
if (props.disabled) return
if (previouslyRemovedEntry.value) { if (previouslyRemovedEntry.value) {
model.value = JSON.parse(JSON.stringify(previouslyRemovedEntry.value)) model.value = JSON.parse(JSON.stringify(previouslyRemovedEntry.value))
} else { } else {
@ -107,15 +135,22 @@ function addEntry() {
<td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }"> <td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }">
<div v-if="model" class="cell-container"> <div v-if="model" class="cell-container">
<div> <div>
<div class="status-item" @click="toggleAbsence"> <div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleAbsence">
<span v-if="model.absent" title="Absent">{{ EMOJI_ABSENT }}</span> <span v-if="model.absent" title="Absent">{{ EMOJI_ABSENT }}</span>
<span v-if="!model.absent" title="Present">{{ EMOJI_PRESENT }}</span> <span v-if="!model.absent" title="Present">{{ EMOJI_PRESENT }}</span>
</div> </div>
<div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent"> <div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="togglePhoneCompliance"
v-if="!model.absent && model.phoneCompliant !== null">
<span v-if="model.phoneCompliant" title="Phone Compliant">{{ EMOJI_PHONE_COMPLIANT }}</span> <span v-if="model.phoneCompliant" title="Phone Compliant">{{ EMOJI_PHONE_COMPLIANT }}</span>
<span v-if="!model.phoneCompliant" title="Phone Non-Compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span> <span v-if="!model.phoneCompliant" title="Phone Non-Compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
</div> </div>
<div class="status-item" @click="toggleBehaviorRating" v-if="!model.absent"> <div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleClassroomReadiness"
v-if="!model.absent && model.classroomReadiness !== null">
<span v-if="model.classroomReadiness" title="Ready for Class">{{ EMOJI_CLASSROOM_READY }}</span>
<span v-if="!model.classroomReadiness" title="Not Ready for Class">{{ EMOJI_NOT_CLASSROOM_READY }}</span>
</div>
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleBehaviorRating"
v-if="!model.absent">
<span v-if="model.behaviorRating === 3" title="Good Behavior">{{ EMOJI_BEHAVIOR_GOOD }}</span> <span v-if="model.behaviorRating === 3" title="Good Behavior">{{ EMOJI_BEHAVIOR_GOOD }}</span>
<span v-if="model.behaviorRating === 2" title="Mediocre Behavior">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span> <span v-if="model.behaviorRating === 2" title="Mediocre Behavior">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
<span v-if="model.behaviorRating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span> <span v-if="model.behaviorRating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span>
@ -142,10 +177,20 @@ function addEntry() {
<!-- A comment editor dialog that shows up when the user edits their comment. --> <!-- A comment editor dialog that shows up when the user edits their comment. -->
<dialog ref="commentEditorDialog" v-if="model"> <dialog ref="commentEditorDialog" v-if="model">
<textarea v-model="model.comment" style="min-width: 300px; min-height: 100px;" <textarea v-model="model.comment" style="min-width: 300px; min-height: 100px;"
@keydown.enter="commentEditorDialog?.close()"></textarea> @keydown.enter="commentEditorDialog?.close()" :readonly="disabled"></textarea>
<div>
<div v-for="options, category in COMMENT_CHECKLIST_ITEMS" :key="category">
<h3 v-text="category"></h3>
<label v-for="opt in options" :key="opt">
<input type="checkbox" v-model="model.checklistItems" :value="opt" />
<span v-text="opt"></span>
</label>
</div>
</div>
<div class="button-bar" style="text-align: right;"> <div class="button-bar" style="text-align: right;">
<button type="button" @click="commentEditorDialog?.close()">Confirm</button> <button type="button" @click="commentEditorDialog?.close()" :disabled="disabled">Confirm</button>
<button type="button" @click="model.comment = ''; commentEditorDialog?.close()">Clear Comment</button> <button type="button" @click="model.comment = ''; model.checklistItems = []; commentEditorDialog?.close()"
:disabled="disabled">Clear</button>
<button type="button" @click="cancelCommentEdit">Cancel</button> <button type="button" @click="cancelCommentEdit">Cancel</button>
</div> </div>
</dialog> </dialog>
@ -175,6 +220,10 @@ td {
cursor: pointer; cursor: pointer;
} }
.status-item-disabled {
cursor: default !important;
}
.status-item+.status-item { .status-item+.status-item {
margin-left: 0.5em; margin-left: 0.5em;
} }

View File

@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth';
import { onMounted, onUnmounted, ref, useTemplateRef, type Ref } from 'vue'; import { onMounted, onUnmounted, ref, useTemplateRef, type Ref } from 'vue';
import ConfirmDialog from './ConfirmDialog.vue'; import ConfirmDialog from './ConfirmDialog.vue';
const REFRESH_INTERVAL_MS = 5000 const REFRESH_INTERVAL_MS = 30000
const authStore = useAuthStore() const authStore = useAuthStore()
const client = new AnnouncementAPIClient(authStore) const client = new AnnouncementAPIClient(authStore)