Added changes for new school year.
This commit is contained in:
		
							parent
							
								
									2c08f1bdbd
								
							
						
					
					
						commit
						6177943db7
					
				| 
						 | 
				
			
			@ -6,8 +6,9 @@ CREATE TABLE classroom_compliance_class (
 | 
			
		|||
	user_id BIGINT NOT NULL
 | 
			
		||||
		REFERENCES auth_user(id)
 | 
			
		||||
			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',
 | 
			
		||||
	archived BOOLEAN NOT NULL DEFAULT FALSE,
 | 
			
		||||
	CONSTRAINT unique_class_numbers_per_school_year
 | 
			
		||||
		UNIQUE(number, school_year, user_id)
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			@ -36,15 +37,24 @@ CREATE TABLE classroom_compliance_entry (
 | 
			
		|||
	absent BOOLEAN NOT NULL DEFAULT FALSE,
 | 
			
		||||
	comment VARCHAR(2000) NOT NULL DEFAULT '',
 | 
			
		||||
	phone_compliant BOOLEAN NULL DEFAULT NULL,
 | 
			
		||||
	classroom_readiness BOOLEAN NULL DEFAULT NULL,
 | 
			
		||||
	behavior_rating INT NULL DEFAULT NULL,
 | 
			
		||||
	CONSTRAINT absence_nulls_check CHECK (
 | 
			
		||||
		(absent AND phone_compliant IS NULL AND behavior_rating IS NULL) OR
 | 
			
		||||
		(NOT absent AND phone_compliant IS NOT NULL AND behavior_rating IS NOT NULL)
 | 
			
		||||
		(absent AND classroom_readiness IS NULL AND behavior_rating IS NULL) OR
 | 
			
		||||
		(NOT absent AND classroom_readiness IS NOT NULL AND behavior_rating IS NOT NULL)
 | 
			
		||||
	),
 | 
			
		||||
	CONSTRAINT unique_entry_per_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 (
 | 
			
		||||
	id BIGSERIAL PRIMARY KEY,
 | 
			
		||||
	class_id BIGINT NOT NULL
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,9 +10,9 @@ import data_utils;
 | 
			
		|||
import api_modules.auth : getAdminUserOrThrow;
 | 
			
		||||
 | 
			
		||||
struct Announcement {
 | 
			
		||||
    const ulong id;
 | 
			
		||||
    const string type;
 | 
			
		||||
    const string message;
 | 
			
		||||
    ulong id;
 | 
			
		||||
    string type;
 | 
			
		||||
    string message;
 | 
			
		||||
 | 
			
		||||
    static Announcement parse(DataSetReader r) {
 | 
			
		||||
        return Announcement(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,12 +13,12 @@ import db;
 | 
			
		|||
import data_utils;
 | 
			
		||||
 | 
			
		||||
struct User {
 | 
			
		||||
    const ulong id;
 | 
			
		||||
    const string username;
 | 
			
		||||
    const string passwordHash;
 | 
			
		||||
    const ulong createdAt;
 | 
			
		||||
    const bool isLocked;
 | 
			
		||||
    const bool isAdmin;
 | 
			
		||||
    ulong id;
 | 
			
		||||
    string username;
 | 
			
		||||
    string passwordHash;
 | 
			
		||||
    ulong createdAt;
 | 
			
		||||
    bool isLocked;
 | 
			
		||||
    bool isAdmin;
 | 
			
		||||
 | 
			
		||||
    static User parse(DataSetReader r) {
 | 
			
		||||
        return User(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@ void registerApiEndpoints(PathHandler handler) {
 | 
			
		|||
    const CLASS_PATH = ROOT_PATH ~ "/classes/:classId:ulong";
 | 
			
		||||
    handler.addMapping(Method.GET, CLASS_PATH, &getClass);
 | 
			
		||||
    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.POST, CLASS_PATH ~ "/notes", &createClassNote);
 | 
			
		||||
    handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -48,7 +48,7 @@ void getClasses(ref HttpRequestContext ctx) {
 | 
			
		|||
    User user = getUserOrThrow(ctx, conn);
 | 
			
		||||
    const query = "
 | 
			
		||||
    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 e.id) AS entry_count,
 | 
			
		||||
        MAX(e.date) AS last_entry_date
 | 
			
		||||
| 
						 | 
				
			
			@ -63,6 +63,7 @@ void getClasses(ref HttpRequestContext ctx) {
 | 
			
		|||
        ulong id;
 | 
			
		||||
        ushort number;
 | 
			
		||||
        string schoolYear;
 | 
			
		||||
        bool archived;
 | 
			
		||||
        uint studentCount;
 | 
			
		||||
        uint entryCount;
 | 
			
		||||
        string lastEntryDate;
 | 
			
		||||
| 
						 | 
				
			
			@ -74,9 +75,10 @@ void getClasses(ref HttpRequestContext ctx) {
 | 
			
		|||
            r.getUlong(1),
 | 
			
		||||
            r.getUshort(2),
 | 
			
		||||
            r.getString(3),
 | 
			
		||||
            r.getUint(4),
 | 
			
		||||
            r.getBoolean(4),
 | 
			
		||||
            r.getUint(5),
 | 
			
		||||
            r.getDate(6).toISOExtString()
 | 
			
		||||
            r.getUint(6),
 | 
			
		||||
            r.getDate(7).toISOExtString()
 | 
			
		||||
        ),
 | 
			
		||||
        user.id
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -96,6 +98,7 @@ void deleteClass(ref HttpRequestContext ctx) {
 | 
			
		|||
    scope(exit) conn.close();
 | 
			
		||||
    User user = getUserOrThrow(ctx, conn);
 | 
			
		||||
    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 = ?";
 | 
			
		||||
    PreparedStatement ps = conn.prepareStatement(query);
 | 
			
		||||
    scope(exit) ps.close();
 | 
			
		||||
| 
						 | 
				
			
			@ -119,6 +122,7 @@ void createClassNote(ref HttpRequestContext ctx) {
 | 
			
		|||
    scope(exit) conn.close();
 | 
			
		||||
    User user = getUserOrThrow(ctx, conn);
 | 
			
		||||
    auto cls = getClassOrThrow(ctx, conn, user);
 | 
			
		||||
    if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
 | 
			
		||||
    struct Payload {string content;}
 | 
			
		||||
    Payload payload = readJsonPayload!(Payload)(ctx);
 | 
			
		||||
    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();
 | 
			
		||||
    User user = getUserOrThrow(ctx, conn);
 | 
			
		||||
    auto cls = getClassOrThrow(ctx, conn, user);
 | 
			
		||||
    if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
 | 
			
		||||
    ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
 | 
			
		||||
    const query = "DELETE FROM classroom_compliance_class_note WHERE class_id = ? AND id = ?";
 | 
			
		||||
    update(conn, query, cls.id, noteId);
 | 
			
		||||
| 
						 | 
				
			
			@ -147,10 +152,20 @@ void resetStudentDesks(ref HttpRequestContext ctx) {
 | 
			
		|||
    scope(exit) conn.close();
 | 
			
		||||
    User user = getUserOrThrow(ctx, conn);
 | 
			
		||||
    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 = ?";
 | 
			
		||||
    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) {
 | 
			
		||||
    import api_modules.classroom_compliance.score_eval : validateExpression;
 | 
			
		||||
    import std.algorithm : canFind;
 | 
			
		||||
| 
						 | 
				
			
			@ -158,6 +173,7 @@ void updateScoreParameters(ref HttpRequestContext ctx) {
 | 
			
		|||
    scope(exit) conn.close();
 | 
			
		||||
    User user = getUserOrThrow(ctx, conn);
 | 
			
		||||
    auto cls = getClassOrThrow(ctx, conn, user);
 | 
			
		||||
    if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
 | 
			
		||||
    struct Payload {
 | 
			
		||||
        string scoreExpression;
 | 
			
		||||
        string scorePeriod;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,7 +22,9 @@ struct EntriesTableEntry {
 | 
			
		|||
    ulong createdAt;
 | 
			
		||||
    bool absent;
 | 
			
		||||
    string comment;
 | 
			
		||||
    string[] checklistItems;
 | 
			
		||||
    Optional!bool phoneCompliant;
 | 
			
		||||
    Optional!bool classroomReadiness;
 | 
			
		||||
    Optional!ubyte behaviorRating;
 | 
			
		||||
 | 
			
		||||
    JSONValue toJsonObj() const {
 | 
			
		||||
| 
						 | 
				
			
			@ -32,12 +34,21 @@ struct EntriesTableEntry {
 | 
			
		|||
        obj.object["createdAt"] = JSONValue(createdAt);
 | 
			
		||||
        obj.object["absent"] = JSONValue(absent);
 | 
			
		||||
        obj.object["comment"] = JSONValue(comment);
 | 
			
		||||
        obj.object["checklistItems"] = JSONValue.emptyArray;
 | 
			
		||||
        foreach (ck; checklistItems) {
 | 
			
		||||
            obj.object["checklistItems"].array ~= JSONValue(ck);
 | 
			
		||||
        }
 | 
			
		||||
        if (absent) {
 | 
			
		||||
            obj.object["phoneCompliant"] = JSONValue(null);
 | 
			
		||||
            obj.object["classroomReadiness"] = JSONValue(null);
 | 
			
		||||
            obj.object["behaviorRating"] = JSONValue(null);
 | 
			
		||||
        } else {
 | 
			
		||||
            obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value);
 | 
			
		||||
            obj.object["behaviorRating"] = JSONValue(behaviorRating.value);
 | 
			
		||||
            JSONValue pcValue = phoneCompliant.isNull ? JSONValue(null) : JSONValue(phoneCompliant.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;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -125,7 +136,14 @@ void getEntries(ref HttpRequestContext ctx) {
 | 
			
		|||
        entry.created_at,
 | 
			
		||||
        entry.absent,
 | 
			
		||||
        entry.comment,
 | 
			
		||||
        (
 | 
			
		||||
            SELECT STRING_AGG(ck.item, '|||')
 | 
			
		||||
            FROM classroom_compliance_entry_comment_checklist ck
 | 
			
		||||
            WHERE ck.entry_id = entry.id
 | 
			
		||||
 | 
			
		||||
        ),
 | 
			
		||||
        entry.phone_compliant,
 | 
			
		||||
        entry.classroom_readiness,
 | 
			
		||||
        entry.behavior_rating,
 | 
			
		||||
        student.id,
 | 
			
		||||
        student.name,
 | 
			
		||||
| 
						 | 
				
			
			@ -155,25 +173,34 @@ void getEntries(ref HttpRequestContext ctx) {
 | 
			
		|||
        const absent = r.getBoolean(4);
 | 
			
		||||
        Optional!bool phoneCompliant = absent
 | 
			
		||||
            ? 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.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(
 | 
			
		||||
            r.getUlong(1),
 | 
			
		||||
            r.getDate(2),
 | 
			
		||||
            r.getUlong(3),
 | 
			
		||||
            r.getBoolean(4),
 | 
			
		||||
            r.getString(5),
 | 
			
		||||
            r.getString(6).split("|||"),
 | 
			
		||||
            phoneCompliant,
 | 
			
		||||
            classroomReadiness,
 | 
			
		||||
            behaviorRating
 | 
			
		||||
        );
 | 
			
		||||
        ClassroomComplianceStudent student = ClassroomComplianceStudent(
 | 
			
		||||
            r.getUlong(8),
 | 
			
		||||
            r.getString(9),
 | 
			
		||||
            r.getUlong(12),
 | 
			
		||||
            r.getUshort(10),
 | 
			
		||||
            r.getBoolean(11)
 | 
			
		||||
            r.getUlong(10),
 | 
			
		||||
            r.getString(11),
 | 
			
		||||
            r.getUlong(14),
 | 
			
		||||
            r.getUshort(12),
 | 
			
		||||
            r.getBoolean(13)
 | 
			
		||||
        );
 | 
			
		||||
        string dateStr = entryData.date.toISOExtString();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -252,6 +279,7 @@ void saveEntries(ref HttpRequestContext ctx) {
 | 
			
		|||
    scope(exit) conn.close();
 | 
			
		||||
    User user = getUserOrThrow(ctx, conn);
 | 
			
		||||
    auto cls = getClassOrThrow(ctx, conn, user);
 | 
			
		||||
    if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
 | 
			
		||||
    JSONValue bodyContent = ctx.request.readBodyAsJson();
 | 
			
		||||
    try {
 | 
			
		||||
        foreach (JSONValue studentObj; bodyContent.object["students"].array) {
 | 
			
		||||
| 
						 | 
				
			
			@ -265,7 +293,16 @@ void saveEntries(ref HttpRequestContext ctx) {
 | 
			
		|||
 | 
			
		||||
                Optional!ClassroomComplianceEntry existingEntry = findOne(
 | 
			
		||||
                    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,
 | 
			
		||||
                    cls.id, studentId, dateStr
 | 
			
		||||
                );
 | 
			
		||||
| 
						 | 
				
			
			@ -333,17 +370,25 @@ private void insertNewEntry(
 | 
			
		|||
    bool absent = payload.object["absent"].boolean;
 | 
			
		||||
    string comment = payload.object["comment"].str;
 | 
			
		||||
    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;
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
    // Do the main insert first.
 | 
			
		||||
    import std.variant;
 | 
			
		||||
    Variant newEntryId;
 | 
			
		||||
    const query = "
 | 
			
		||||
    INSERT INTO classroom_compliance_entry
 | 
			
		||||
    (class_id, student_id, date, absent, comment, phone_compliant, behavior_rating)
 | 
			
		||||
    VALUES (?, ?, ?, ?, ?, ?, ?)";
 | 
			
		||||
 | 
			
		||||
    (class_id, student_id, date, absent, comment, classroom_readiness, behavior_rating)
 | 
			
		||||
    VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id";
 | 
			
		||||
    PreparedStatement ps = conn.prepareStatement(query);
 | 
			
		||||
    scope(exit) ps.close();
 | 
			
		||||
    ps.setUlong(1, classId);
 | 
			
		||||
| 
						 | 
				
			
			@ -355,10 +400,11 @@ private void insertNewEntry(
 | 
			
		|||
        ps.setNull(6);
 | 
			
		||||
        ps.setNull(7);
 | 
			
		||||
    } else {
 | 
			
		||||
        ps.setBoolean(6, phoneCompliant.value);
 | 
			
		||||
        ps.setBoolean(6, classroomReadiness.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);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -374,15 +420,21 @@ private void updateEntry(
 | 
			
		|||
    bool absent = obj.object["absent"].boolean;
 | 
			
		||||
    string comment = obj.object["comment"].str;
 | 
			
		||||
    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;
 | 
			
		||||
    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);
 | 
			
		||||
    }
 | 
			
		||||
    const query = "
 | 
			
		||||
    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 = ?
 | 
			
		||||
    ";
 | 
			
		||||
    PreparedStatement ps = conn.prepareStatement(query);
 | 
			
		||||
| 
						 | 
				
			
			@ -393,7 +445,7 @@ private void updateEntry(
 | 
			
		|||
        ps.setNull(3);
 | 
			
		||||
        ps.setNull(4);
 | 
			
		||||
    } else {
 | 
			
		||||
        ps.setBoolean(3, phoneCompliant.value);
 | 
			
		||||
        ps.setBoolean(3, classroomReadiness.value);
 | 
			
		||||
        ps.setUbyte(4, behaviorRating.value);
 | 
			
		||||
    }
 | 
			
		||||
    ps.setUlong(5, classId);
 | 
			
		||||
| 
						 | 
				
			
			@ -401,6 +453,27 @@ private void updateEntry(
 | 
			
		|||
    ps.setString(7, dateStr);
 | 
			
		||||
    ps.setUlong(8, entryId);
 | 
			
		||||
    ps.executeUpdate();
 | 
			
		||||
    updateEntryCommentChecklistItems(conn, entryId, checklistItems);
 | 
			
		||||
 | 
			
		||||
    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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,6 +29,7 @@ void getFullExport(ref HttpRequestContext ctx) {
 | 
			
		|||
        e.created_at AS entry_created_at,
 | 
			
		||||
        e.absent AS absent,
 | 
			
		||||
        e.phone_compliant AS phone_compliant,
 | 
			
		||||
        e.classroom_readiness AS classroom_readiness,
 | 
			
		||||
        e.behavior_rating AS behavior_rating,
 | 
			
		||||
        e.comment AS comment
 | 
			
		||||
    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("Absence", (r, i) => r.getBoolean(i) ? "Absent" : "Present"),
 | 
			
		||||
        CSVColumnDef("Phone Compliant", &formatPhoneCompliance),
 | 
			
		||||
        CSVColumnDef("Classroom Readiness", &formatClassroomReadiness),
 | 
			
		||||
        CSVColumnDef("Behavior Rating", &formatBehaviorRating),
 | 
			
		||||
        CSVColumnDef("Comment", &formatComment)
 | 
			
		||||
    ];
 | 
			
		||||
| 
						 | 
				
			
			@ -95,6 +97,11 @@ private string formatPhoneCompliance(DataSetReader r, int i) {
 | 
			
		|||
    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) {
 | 
			
		||||
    if (r.isNull(i)) return "N/A";
 | 
			
		||||
    ubyte score = r.getUbyte(i);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,7 @@ void createStudent(ref HttpRequestContext ctx) {
 | 
			
		|||
    scope(exit) conn.close();
 | 
			
		||||
    User user = getUserOrThrow(ctx, conn);
 | 
			
		||||
    auto cls = getClassOrThrow(ctx, conn, user);
 | 
			
		||||
    if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
 | 
			
		||||
    struct StudentPayload {
 | 
			
		||||
        string name;
 | 
			
		||||
        ushort deskNumber;
 | 
			
		||||
| 
						 | 
				
			
			@ -83,6 +84,8 @@ void updateStudent(ref HttpRequestContext ctx) {
 | 
			
		|||
    Connection conn = getDb();
 | 
			
		||||
    scope(exit) conn.close();
 | 
			
		||||
    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);
 | 
			
		||||
    struct StudentUpdatePayload {
 | 
			
		||||
        string name;
 | 
			
		||||
| 
						 | 
				
			
			@ -139,6 +142,8 @@ void deleteStudent(ref HttpRequestContext ctx) {
 | 
			
		|||
    Connection conn = getDb();
 | 
			
		||||
    scope(exit) conn.close();
 | 
			
		||||
    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);
 | 
			
		||||
    update(
 | 
			
		||||
        conn,
 | 
			
		||||
| 
						 | 
				
			
			@ -154,7 +159,18 @@ void getStudentEntries(ref HttpRequestContext ctx) {
 | 
			
		|||
    auto student = getStudentOrThrow(ctx, conn, user);
 | 
			
		||||
    auto entries = findAll(
 | 
			
		||||
        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,
 | 
			
		||||
        student.id
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -221,6 +237,8 @@ void moveStudentToOtherClass(ref HttpRequestContext ctx) {
 | 
			
		|||
    scope(exit) conn.close();
 | 
			
		||||
    conn.setAutoCommit(false);
 | 
			
		||||
    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);
 | 
			
		||||
    struct Payload {
 | 
			
		||||
        ulong classId;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,20 +4,23 @@ import ddbc : DataSetReader;
 | 
			
		|||
import handy_httpd.components.optional;
 | 
			
		||||
import std.json;
 | 
			
		||||
import std.datetime;
 | 
			
		||||
import std.string : split;
 | 
			
		||||
 | 
			
		||||
struct ClassroomComplianceClass {
 | 
			
		||||
    const ulong id;
 | 
			
		||||
    const ushort number;
 | 
			
		||||
    const string schoolYear;
 | 
			
		||||
    const ulong userId;
 | 
			
		||||
    const string scoreExpression;
 | 
			
		||||
    const string scorePeriod;
 | 
			
		||||
    ulong id;
 | 
			
		||||
    ushort number;
 | 
			
		||||
    string schoolYear;
 | 
			
		||||
    bool archived;
 | 
			
		||||
    ulong userId;
 | 
			
		||||
    string scoreExpression;
 | 
			
		||||
    string scorePeriod;
 | 
			
		||||
 | 
			
		||||
    static ClassroomComplianceClass parse(DataSetReader r) {
 | 
			
		||||
        return ClassroomComplianceClass(
 | 
			
		||||
            r.getUlong(1),
 | 
			
		||||
            r.getUshort(2),
 | 
			
		||||
            r.getString(3),
 | 
			
		||||
            r.getBoolean(7),
 | 
			
		||||
            r.getUlong(4),
 | 
			
		||||
            r.getString(5),
 | 
			
		||||
            r.getString(6)
 | 
			
		||||
| 
						 | 
				
			
			@ -26,11 +29,11 @@ struct ClassroomComplianceClass {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
struct ClassroomComplianceStudent {
 | 
			
		||||
    const ulong id;
 | 
			
		||||
    const string name;
 | 
			
		||||
    const ulong classId;
 | 
			
		||||
    const ushort deskNumber;
 | 
			
		||||
    const bool removed;
 | 
			
		||||
    ulong id;
 | 
			
		||||
    string name;
 | 
			
		||||
    ulong classId;
 | 
			
		||||
    ushort deskNumber;
 | 
			
		||||
    bool removed;
 | 
			
		||||
 | 
			
		||||
    static ClassroomComplianceStudent parse(DataSetReader r) {
 | 
			
		||||
        return ClassroomComplianceStudent(
 | 
			
		||||
| 
						 | 
				
			
			@ -44,35 +47,32 @@ struct ClassroomComplianceStudent {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
struct ClassroomComplianceEntry {
 | 
			
		||||
    const ulong id;
 | 
			
		||||
    const ulong classId;
 | 
			
		||||
    const ulong studentId;
 | 
			
		||||
    const Date date;
 | 
			
		||||
    const ulong createdAt;
 | 
			
		||||
    const bool absent;
 | 
			
		||||
    const string comment;
 | 
			
		||||
    const Optional!bool phoneCompliant;
 | 
			
		||||
    const Optional!ubyte behaviorRating;
 | 
			
		||||
    ulong id;
 | 
			
		||||
    ulong classId;
 | 
			
		||||
    ulong studentId;
 | 
			
		||||
    Date date;
 | 
			
		||||
    ulong createdAt;
 | 
			
		||||
    bool absent;
 | 
			
		||||
    string comment;
 | 
			
		||||
    string[] checklistItems;
 | 
			
		||||
    Optional!bool phoneCompliant;
 | 
			
		||||
    Optional!bool classroomReadiness;
 | 
			
		||||
    Optional!ubyte behaviorRating;
 | 
			
		||||
 | 
			
		||||
    static ClassroomComplianceEntry parse(DataSetReader r) {
 | 
			
		||||
        Optional!bool phone = !r.isNull(8)
 | 
			
		||||
            ? Optional!bool.of(r.getBoolean(8))
 | 
			
		||||
            : Optional!bool.empty;
 | 
			
		||||
        Optional!ubyte behavior = !r.isNull(9)
 | 
			
		||||
            ? Optional!ubyte.of(r.getUbyte(9))
 | 
			
		||||
            : Optional!ubyte.empty;
 | 
			
		||||
 | 
			
		||||
        return ClassroomComplianceEntry(
 | 
			
		||||
            r.getUlong(1),
 | 
			
		||||
            r.getUlong(2),
 | 
			
		||||
            r.getUlong(3),
 | 
			
		||||
            r.getDate(4),
 | 
			
		||||
            r.getUlong(5),
 | 
			
		||||
            r.getBoolean(6),
 | 
			
		||||
            r.getString(7),
 | 
			
		||||
            phone,
 | 
			
		||||
            behavior
 | 
			
		||||
        );
 | 
			
		||||
        ClassroomComplianceEntry entry;
 | 
			
		||||
        entry.id = r.getUlong(1);
 | 
			
		||||
        entry.classId = r.getUlong(2);
 | 
			
		||||
        entry.studentId = r.getUlong(3);
 | 
			
		||||
        entry.date = r.getDate(4);
 | 
			
		||||
        entry.createdAt = r.getUlong(5);
 | 
			
		||||
        entry.absent = r.getBoolean(6);
 | 
			
		||||
        entry.comment = r.getString(7);
 | 
			
		||||
        entry.checklistItems = r.getString(8).split("|||");
 | 
			
		||||
        entry.phoneCompliant = r.isNull(9) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(9));
 | 
			
		||||
        entry.classroomReadiness = r.isNull(10) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(10));
 | 
			
		||||
        entry.behaviorRating = r.isNull(11) ? Optional!ubyte.empty : Optional!ubyte.of(r.getUbyte(11));
 | 
			
		||||
        return entry;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    JSONValue toJsonObj() const {
 | 
			
		||||
| 
						 | 
				
			
			@ -84,28 +84,22 @@ struct ClassroomComplianceEntry {
 | 
			
		|||
        obj.object["createdAt"] = JSONValue(createdAt);
 | 
			
		||||
        obj.object["absent"] = JSONValue(absent);
 | 
			
		||||
        obj.object["comment"] = JSONValue(comment);
 | 
			
		||||
        if (absent) {
 | 
			
		||||
            if (!phoneCompliant.isNull || !behaviorRating.isNull) {
 | 
			
		||||
                throw new Exception("Illegal entry state! Absent is true while values are not null!");
 | 
			
		||||
            }
 | 
			
		||||
            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["checklistItems"] = JSONValue.emptyArray;
 | 
			
		||||
        foreach (item; checklistItems) {
 | 
			
		||||
            obj.object["checklistItems"].array ~= JSONValue(item);
 | 
			
		||||
        }
 | 
			
		||||
        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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct ClassroomComplianceClassNote {
 | 
			
		||||
    const ulong id;
 | 
			
		||||
    const ulong classId;
 | 
			
		||||
    const ulong createdAt;
 | 
			
		||||
    const string content;
 | 
			
		||||
    ulong id;
 | 
			
		||||
    ulong classId;
 | 
			
		||||
    ulong createdAt;
 | 
			
		||||
    string content;
 | 
			
		||||
 | 
			
		||||
    static ClassroomComplianceClassNote parse(DataSetReader r) {
 | 
			
		||||
        return ClassroomComplianceClassNote(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,7 +15,7 @@ enum ScorePeriod : string {
 | 
			
		|||
 | 
			
		||||
private struct ScoringParameters {
 | 
			
		||||
    Expr expr;
 | 
			
		||||
    const DateRange dateRange;
 | 
			
		||||
    DateRange dateRange;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			@ -54,6 +54,7 @@ Optional!double[ulong] getScores(
 | 
			
		|||
        COUNT(id) AS entry_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 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 = 2 THEN 1 ELSE 0 END) AS behavior_mediocre,
 | 
			
		||||
        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 absenceCount = r.getUint(3);
 | 
			
		||||
        uint phoneNonComplianceCount = r.getUint(4);
 | 
			
		||||
        uint behaviorGoodCount = r.getUint(5);
 | 
			
		||||
        uint behaviorMediocreCount = r.getUint(6);
 | 
			
		||||
        uint behaviorPoorCount = r.getUint(7);
 | 
			
		||||
        uint notClassroomReadyCount = r.getUint(5);
 | 
			
		||||
        uint behaviorGoodCount = r.getUint(6);
 | 
			
		||||
        uint behaviorMediocreCount = r.getUint(7);
 | 
			
		||||
        uint behaviorPoorCount = r.getUint(8);
 | 
			
		||||
        scores[studentId] = calculateScore(
 | 
			
		||||
            params.expr,
 | 
			
		||||
            entryCount,
 | 
			
		||||
            absenceCount,
 | 
			
		||||
            phoneNonComplianceCount,
 | 
			
		||||
            notClassroomReadyCount,
 | 
			
		||||
            behaviorGoodCount,
 | 
			
		||||
            behaviorMediocreCount,
 | 
			
		||||
            behaviorPoorCount
 | 
			
		||||
| 
						 | 
				
			
			@ -132,6 +135,7 @@ private Optional!double calculateScore(
 | 
			
		|||
    uint entryCount,
 | 
			
		||||
    uint absenceCount,
 | 
			
		||||
    uint phoneNonComplianceCount,
 | 
			
		||||
    uint notClassroomReadyCount,
 | 
			
		||||
    uint behaviorGoodCount,
 | 
			
		||||
    uint behaviorMediocreCount,
 | 
			
		||||
    uint behaviorPoorCount
 | 
			
		||||
| 
						 | 
				
			
			@ -151,6 +155,14 @@ private Optional!double calculateScore(
 | 
			
		|||
        phoneCompliantCount = presentCount - phoneNonComplianceCount;
 | 
			
		||||
    }
 | 
			
		||||
    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 behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount;
 | 
			
		||||
| 
						 | 
				
			
			@ -159,6 +171,7 @@ private Optional!double calculateScore(
 | 
			
		|||
 | 
			
		||||
    return Optional!double.of(scoreExpression.eval([
 | 
			
		||||
        "phone": phoneScore,
 | 
			
		||||
        "classroom_readiness": classroomReadinessScore,
 | 
			
		||||
        "behavior": typicalBehaviorScore,
 | 
			
		||||
        "behavior_good": behaviorGoodScore,
 | 
			
		||||
        "behavior_mediocre": behaviorMediocreScore,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -35,7 +35,8 @@ void initializeSchema() {
 | 
			
		|||
    scope(exit) stmt.close();
 | 
			
		||||
    const string AUTH_SCHEMA = import("schema/auth.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;
 | 
			
		||||
    foreach (schema; schemas) {
 | 
			
		||||
        infoF!"Intializing schema #%d."(schemaNumber++);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -137,8 +137,8 @@ void addEntry(
 | 
			
		|||
) {
 | 
			
		||||
    const entryQuery = "
 | 
			
		||||
    INSERT INTO classroom_compliance_entry
 | 
			
		||||
    (class_id, student_id, date, absent, comment, phone_compliant, behavior_rating)
 | 
			
		||||
    VALUES (?, ?, ?, ?, ?, ?, ?)";
 | 
			
		||||
    (class_id, student_id, date, absent, comment, phone_compliant, classroom_readiness, behavior_rating)
 | 
			
		||||
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
 | 
			
		||||
    PreparedStatement ps = conn.prepareStatement(entryQuery);
 | 
			
		||||
    scope(exit) ps.close();
 | 
			
		||||
    ps.setUlong(1, classId);
 | 
			
		||||
| 
						 | 
				
			
			@ -150,12 +150,13 @@ void addEntry(
 | 
			
		|||
    } else {
 | 
			
		||||
        ps.setString(5, comment);
 | 
			
		||||
    }
 | 
			
		||||
    ps.setNull(6); // Always set phone_compliant as null from now on.
 | 
			
		||||
    if (absent) {
 | 
			
		||||
        ps.setNull(6);
 | 
			
		||||
        ps.setNull(7);
 | 
			
		||||
        ps.setNull(8);
 | 
			
		||||
    } else {
 | 
			
		||||
        ps.setBoolean(6, phoneCompliant);
 | 
			
		||||
        ps.setUint(7, behaviorRating);
 | 
			
		||||
        ps.setBoolean(7, true);
 | 
			
		||||
        ps.setUint(8, behaviorRating);
 | 
			
		||||
    }
 | 
			
		||||
    ps.executeUpdate();
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -167,6 +168,7 @@ void deleteAllData(Connection conn) {
 | 
			
		|||
    const tables = [
 | 
			
		||||
        "announcement",
 | 
			
		||||
        "classroom_compliance_class_note",
 | 
			
		||||
        "classroom_compliance_entry_comment_checklist",
 | 
			
		||||
        "classroom_compliance_entry",
 | 
			
		||||
        "classroom_compliance_student",
 | 
			
		||||
        "classroom_compliance_class",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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_NONCOMPLIANT = '📵'
 | 
			
		||||
export const EMOJI_CLASSROOM_READY = '🍎'
 | 
			
		||||
export const EMOJI_NOT_CLASSROOM_READY = '🐛'
 | 
			
		||||
export const EMOJI_PRESENT = '✅'
 | 
			
		||||
export const EMOJI_ABSENT = '❌'
 | 
			
		||||
export const EMOJI_BEHAVIOR_GOOD = '😇'
 | 
			
		||||
| 
						 | 
				
			
			@ -16,6 +18,7 @@ export interface Class {
 | 
			
		|||
  schoolYear: string
 | 
			
		||||
  scoreExpression: string
 | 
			
		||||
  scorePeriod: string
 | 
			
		||||
  archived: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ClassesResponseClass {
 | 
			
		||||
| 
						 | 
				
			
			@ -25,6 +28,7 @@ export interface ClassesResponseClass {
 | 
			
		|||
  studentCount: number
 | 
			
		||||
  entryCount: number
 | 
			
		||||
  lastEntryDate: string
 | 
			
		||||
  archived: boolean
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Student {
 | 
			
		||||
| 
						 | 
				
			
			@ -41,8 +45,10 @@ export interface Entry {
 | 
			
		|||
  createdAt: number
 | 
			
		||||
  absent: boolean
 | 
			
		||||
  phoneCompliant: boolean | null
 | 
			
		||||
  classroomReadiness: boolean | null
 | 
			
		||||
  behaviorRating: number | null
 | 
			
		||||
  comment: string
 | 
			
		||||
  checklistItems: string[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getDefaultEntry(dateStr: string): Entry {
 | 
			
		||||
| 
						 | 
				
			
			@ -51,9 +57,11 @@ export function getDefaultEntry(dateStr: string): Entry {
 | 
			
		|||
    date: dateStr,
 | 
			
		||||
    createdAt: Date.now(),
 | 
			
		||||
    absent: false,
 | 
			
		||||
    phoneCompliant: true,
 | 
			
		||||
    phoneCompliant: null,
 | 
			
		||||
    classroomReadiness: true,
 | 
			
		||||
    behaviorRating: 3,
 | 
			
		||||
    comment: '',
 | 
			
		||||
    checklistItems: [],
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -152,6 +160,10 @@ export class ClassroomComplianceAPIClient extends APIClient {
 | 
			
		|||
    return new APIResponse(this.handleAPIResponseWithNoBody(promise))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toggleArchived(classId: number): APIResponse<void> {
 | 
			
		||||
    return super.postWithNoExpectedResponse(`/classes/${classId}/archived`, {})
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  updateScoreParameters(
 | 
			
		||||
    classId: number,
 | 
			
		||||
    scoreExpression: string,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,9 @@ const router = useRouter()
 | 
			
		|||
</script>
 | 
			
		||||
<template>
 | 
			
		||||
  <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> <span v-text="cls.schoolYear"
 | 
			
		||||
        style="font-weight: lighter; font-size: small;"></span>
 | 
			
		||||
    </h3>
 | 
			
		||||
    <p>
 | 
			
		||||
      {{ cls.studentCount }} students,
 | 
			
		||||
      {{ cls.entryCount }} entries.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ import { useAuthStore } from '@/stores/auth';
 | 
			
		|||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
  note: ClassNote
 | 
			
		||||
  disabled?: boolean
 | 
			
		||||
}>()
 | 
			
		||||
const emit = defineEmits(['noteDeleted'])
 | 
			
		||||
const authStore = useAuthStore()
 | 
			
		||||
| 
						 | 
				
			
			@ -21,7 +22,7 @@ async function deleteNote() {
 | 
			
		|||
    <p class="class-note-item-content">{{ note.content }}</p>
 | 
			
		||||
    <div class="class-note-item-attributes-container">
 | 
			
		||||
      <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>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,6 +17,7 @@ const notes: Ref<ClassNote[]> = ref([])
 | 
			
		|||
const noteContent: Ref<string> = ref('')
 | 
			
		||||
const scoreExpression: Ref<string> = ref('')
 | 
			
		||||
const scorePeriod: Ref<string> = ref('')
 | 
			
		||||
const archived = computed(() => cls.value !== null && cls.value.archived)
 | 
			
		||||
const canUpdateScoreParameters = computed(() => {
 | 
			
		||||
  return cls.value && (
 | 
			
		||||
    cls.value.scoreExpression !== scoreExpression.value ||
 | 
			
		||||
| 
						 | 
				
			
			@ -53,6 +54,12 @@ async function deleteThisClass() {
 | 
			
		|||
  await router.replace('/classroom-compliance')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function toggleArchived() {
 | 
			
		||||
  if (!cls.value) return
 | 
			
		||||
  await apiClient.toggleArchived(cls.value.id).handleErrorsWithAlertNoBody()
 | 
			
		||||
  loadClass()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function submitNote() {
 | 
			
		||||
  if (noteContent.value.trim().length < 1 || !cls.value) return
 | 
			
		||||
  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>
 | 
			
		||||
    <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;">
 | 
			
		||||
      <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>
 | 
			
		||||
      <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>
 | 
			
		||||
      <button type="button" @click="resetStudentDesks">Clear Assigned Desks</button>
 | 
			
		||||
      <button type="button" @click="deleteThisClass">Delete this Class</button>
 | 
			
		||||
      <button type="button" @click="resetStudentDesks" :disabled="archived">Clear Assigned Desks</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>
 | 
			
		||||
 | 
			
		||||
    <EntriesTable :classId="cls.id" ref="entries-table" />
 | 
			
		||||
    <EntriesTable :classId="cls.id" :disabled="archived" ref="entries-table" />
 | 
			
		||||
 | 
			
		||||
    <div>
 | 
			
		||||
      <h3 style="margin-bottom: 0.25em;">Notes</h3>
 | 
			
		||||
      <form @submit.prevent="submitNote">
 | 
			
		||||
        <textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1"
 | 
			
		||||
          v-model="noteContent"></textarea>
 | 
			
		||||
        <button style="vertical-align: top; margin-left: 0.5em;" type="submit">Add Note</button>
 | 
			
		||||
        <textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1" v-model="noteContent"
 | 
			
		||||
          :readonly="archived"></textarea>
 | 
			
		||||
        <button style="vertical-align: top; margin-left: 0.5em;" type="submit" :disabled="archived">Add Note</button>
 | 
			
		||||
      </form>
 | 
			
		||||
      <ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -142,7 +155,7 @@ function resetScoreParameters() {
 | 
			
		|||
        <div>
 | 
			
		||||
          <label for="score-expression-input">Expression</label>
 | 
			
		||||
          <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>
 | 
			
		||||
        <p class="form-input-hint">
 | 
			
		||||
          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>
 | 
			
		||||
        <div>
 | 
			
		||||
          <label for="score-period-select">Period</label>
 | 
			
		||||
          <select v-model="scorePeriod">
 | 
			
		||||
          <select v-model="scorePeriod" :disabled="archived">
 | 
			
		||||
            <option value="week">Weekly</option>
 | 
			
		||||
          </select>
 | 
			
		||||
        </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -177,8 +190,9 @@ function resetScoreParameters() {
 | 
			
		|||
          The period over which to calculate scores.
 | 
			
		||||
        </p>
 | 
			
		||||
        <div class="button-bar">
 | 
			
		||||
          <button type="submit" :disabled="!canUpdateScoreParameters">Save</button>
 | 
			
		||||
          <button type="button" :disabled="!canUpdateScoreParameters" @click="resetScoreParameters">Reset</button>
 | 
			
		||||
          <button type="submit" :disabled="!canUpdateScoreParameters || archived">Save</button>
 | 
			
		||||
          <button type="button" :disabled="!canUpdateScoreParameters || archived"
 | 
			
		||||
            @click="resetScoreParameters">Reset</button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,13 @@
 | 
			
		|||
<script setup lang="ts">
 | 
			
		||||
import { ClassroomComplianceAPIClient, type ClassesResponseClass } from '@/api/classroom_compliance'
 | 
			
		||||
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 { useRouter } from 'vue-router'
 | 
			
		||||
 | 
			
		||||
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 router = useRouter()
 | 
			
		||||
| 
						 | 
				
			
			@ -23,7 +25,11 @@ onMounted(() => {
 | 
			
		|||
      <button type="button" @click="router.push('/classroom-compliance/edit-class')">Add Class</button>
 | 
			
		||||
    </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>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,7 +16,8 @@ import StudentNameCell from '@/apps/classroom_compliance/entries_table/StudentNa
 | 
			
		|||
 | 
			
		||||
const authStore = useAuthStore()
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
  classId: number
 | 
			
		||||
  classId: number,
 | 
			
		||||
  disabled: boolean
 | 
			
		||||
}>()
 | 
			
		||||
const apiClient = new ClassroomComplianceAPIClient(authStore)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -160,6 +161,11 @@ async function showNextWeek() {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
async function saveEdits() {
 | 
			
		||||
  if (props.disabled) {
 | 
			
		||||
    console.warn('Cannot save edits when disabled.')
 | 
			
		||||
    await loadEntries()
 | 
			
		||||
    return
 | 
			
		||||
  }
 | 
			
		||||
  if (!lastSaveState.value) {
 | 
			
		||||
    console.warn('No lastSaveState, cannot determine what edits were made.')
 | 
			
		||||
    await loadEntries()
 | 
			
		||||
| 
						 | 
				
			
			@ -237,6 +243,7 @@ function getVisibleStudentEntries(student: EntriesResponseStudent): Record<strin
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function addAllEntriesForDate(dateStr: string) {
 | 
			
		||||
  if (props.disabled) return
 | 
			
		||||
  for (let i = 0; i < students.value.length; i++) {
 | 
			
		||||
    const student = students.value[i]
 | 
			
		||||
    if (student.removed) continue
 | 
			
		||||
| 
						 | 
				
			
			@ -293,7 +300,7 @@ defineExpose({
 | 
			
		|||
          <th v-if="selectedView !== TableView.WHITEBOARD">#</th>
 | 
			
		||||
          <th>Student</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)" />
 | 
			
		||||
          <th v-if="selectedView !== TableView.WHITEBOARD">Score</th>
 | 
			
		||||
        </tr>
 | 
			
		||||
| 
						 | 
				
			
			@ -301,11 +308,12 @@ defineExpose({
 | 
			
		|||
      <tbody>
 | 
			
		||||
        <tr v-for="(student, idx) in getVisibleStudents()" :key="student.id" style="height: 2em;">
 | 
			
		||||
          <td v-if="selectedView !== TableView.WHITEBOARD" style="text-align: right; padding-right: 0.5em;">{{ idx + 1
 | 
			
		||||
            }}.</td>
 | 
			
		||||
          }}.</td>
 | 
			
		||||
          <StudentNameCell :student="student" :class-id="classId" />
 | 
			
		||||
          <td v-if="assignedDesks" v-text="student.deskNumber"></td>
 | 
			
		||||
          <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" />
 | 
			
		||||
        </tr>
 | 
			
		||||
      </tbody>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,12 @@
 | 
			
		|||
<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<{
 | 
			
		||||
  entry: Entry
 | 
			
		||||
}>()
 | 
			
		||||
 | 
			
		||||
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'];
 | 
			
		||||
  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 === 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 === 2">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</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">
 | 
			
		||||
      {{ entry.comment }}
 | 
			
		||||
    </p>
 | 
			
		||||
    <ul v-if="entry.checklistItems">
 | 
			
		||||
      <li v-for="item in entry.checklistItems" :key="item" v-text="item"></li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
<style scoped>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
import { ClassroomComplianceAPIClient, type Class, type Entry, type Student, type StudentStatisticsOverview } from '@/api/classroom_compliance'
 | 
			
		||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
 | 
			
		||||
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 StudentEntriesList from './StudentEntriesList.vue'
 | 
			
		||||
import { APIError } from '@/api/base'
 | 
			
		||||
| 
						 | 
				
			
			@ -18,9 +18,38 @@ const cls: Ref<Class | null> = ref(null)
 | 
			
		|||
const student: Ref<Student | null> = ref(null)
 | 
			
		||||
const entries: Ref<Entry[]> = ref([])
 | 
			
		||||
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 deleteConfirmDialog = useTemplateRef('deleteConfirmDialog')
 | 
			
		||||
const weekOverviewDialog = useTemplateRef('weekOverviewDialog')
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  const classIdNumber = parseInt(props.classId, 10)
 | 
			
		||||
  cls.value = await apiClient.getClass(classIdNumber).handleErrorsWithAlert()
 | 
			
		||||
| 
						 | 
				
			
			@ -58,6 +87,12 @@ async function deleteThisStudent() {
 | 
			
		|||
  await apiClient.deleteStudent(cls.value.id, student.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>
 | 
			
		||||
<template>
 | 
			
		||||
  <div v-if="student" class="centered-content">
 | 
			
		||||
| 
						 | 
				
			
			@ -73,6 +108,7 @@ async function deleteThisStudent() {
 | 
			
		|||
      <button type="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="weekOverviewDialog?.showModal()">Week overview</button>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <table class="student-properties-table">
 | 
			
		||||
| 
						 | 
				
			
			@ -116,6 +152,49 @@ async function deleteThisStudent() {
 | 
			
		|||
      </p>
 | 
			
		||||
      <p>This <strong>cannot</strong> be undone!</p>
 | 
			
		||||
    </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>
 | 
			
		||||
</template>
 | 
			
		||||
<style scoped>
 | 
			
		||||
| 
						 | 
				
			
			@ -133,4 +212,27 @@ async function deleteThisStudent() {
 | 
			
		|||
  text-align: right;
 | 
			
		||||
  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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,7 @@ import { computed } from 'vue';
 | 
			
		|||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
  dateStr: string
 | 
			
		||||
  disabled?: boolean
 | 
			
		||||
}>()
 | 
			
		||||
 | 
			
		||||
defineEmits<{
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +28,7 @@ function getWeekday(date: Date): string {
 | 
			
		|||
<template>
 | 
			
		||||
  <th>
 | 
			
		||||
    <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>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,27 @@
 | 
			
		|||
<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'
 | 
			
		||||
 | 
			
		||||
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<{
 | 
			
		||||
  dateStr: string
 | 
			
		||||
  lastSaveStateTimestamp: number
 | 
			
		||||
  disabled?: boolean
 | 
			
		||||
}>()
 | 
			
		||||
defineEmits<{
 | 
			
		||||
  (e: 'editComment'): void
 | 
			
		||||
| 
						 | 
				
			
			@ -17,7 +34,7 @@ const initialEntryJson: Ref<string> = ref('')
 | 
			
		|||
const previouslyRemovedEntry: Ref<Entry | null> = ref(null)
 | 
			
		||||
 | 
			
		||||
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 commentEditorDialog = useTemplateRef('commentEditorDialog')
 | 
			
		||||
| 
						 | 
				
			
			@ -34,21 +51,24 @@ onMounted(() => {
 | 
			
		|||
})
 | 
			
		||||
 | 
			
		||||
function toggleAbsence() {
 | 
			
		||||
  if (model.value) {
 | 
			
		||||
  if (model.value && !props.disabled) {
 | 
			
		||||
    model.value.absent = !model.value.absent
 | 
			
		||||
    if (model.value.absent) {
 | 
			
		||||
      // Remove additional data if student is absent.
 | 
			
		||||
      model.value.phoneCompliant = null
 | 
			
		||||
      model.value.classroomReadiness = null
 | 
			
		||||
      model.value.behaviorRating = null
 | 
			
		||||
    } else {
 | 
			
		||||
      // Populate default additional data if student is no longer absent.
 | 
			
		||||
      model.value.phoneCompliant = true
 | 
			
		||||
      model.value.classroomReadiness = true
 | 
			
		||||
      model.value.behaviorRating = 3
 | 
			
		||||
      // If we have an initial entry known, restore data from that.
 | 
			
		||||
      if (initialEntryJson.value) {
 | 
			
		||||
        const initialEntry = JSON.parse(initialEntryJson.value) as Entry
 | 
			
		||||
        if (initialEntry === null) return
 | 
			
		||||
        if (initialEntry.absent) return
 | 
			
		||||
        if (initialEntry.classroomReadiness) {
 | 
			
		||||
          model.value.classroomReadiness = initialEntry.classroomReadiness
 | 
			
		||||
        }
 | 
			
		||||
        if (initialEntry.phoneCompliant) {
 | 
			
		||||
          model.value.phoneCompliant = initialEntry.phoneCompliant
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -61,13 +81,19 @@ function toggleAbsence() {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function togglePhoneCompliance() {
 | 
			
		||||
  if (model.value && model.value.phoneCompliant !== null) {
 | 
			
		||||
  if (model.value && model.value.phoneCompliant !== null && !props.disabled) {
 | 
			
		||||
    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() {
 | 
			
		||||
  if (model.value && model.value.behaviorRating) {
 | 
			
		||||
  if (model.value && model.value.behaviorRating && !props.disabled) {
 | 
			
		||||
    model.value.behaviorRating = model.value.behaviorRating - 1
 | 
			
		||||
    if (model.value.behaviorRating < 1) {
 | 
			
		||||
      model.value.behaviorRating = 3
 | 
			
		||||
| 
						 | 
				
			
			@ -89,6 +115,7 @@ function cancelCommentEdit() {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function removeEntry() {
 | 
			
		||||
  if (props.disabled) return
 | 
			
		||||
  if (model.value) {
 | 
			
		||||
    previouslyRemovedEntry.value = JSON.parse(JSON.stringify(model.value))
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -96,6 +123,7 @@ function removeEntry() {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function addEntry() {
 | 
			
		||||
  if (props.disabled) return
 | 
			
		||||
  if (previouslyRemovedEntry.value) {
 | 
			
		||||
    model.value = JSON.parse(JSON.stringify(previouslyRemovedEntry.value))
 | 
			
		||||
  } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -107,15 +135,22 @@ function addEntry() {
 | 
			
		|||
  <td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }">
 | 
			
		||||
    <div v-if="model" class="cell-container">
 | 
			
		||||
      <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="Present">{{ EMOJI_PRESENT }}</span>
 | 
			
		||||
        </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 Non-Compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
 | 
			
		||||
        </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 === 2" title="Mediocre Behavior">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</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. -->
 | 
			
		||||
    <dialog ref="commentEditorDialog" v-if="model">
 | 
			
		||||
      <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;">
 | 
			
		||||
        <button type="button" @click="commentEditorDialog?.close()">Confirm</button>
 | 
			
		||||
        <button type="button" @click="model.comment = ''; commentEditorDialog?.close()">Clear Comment</button>
 | 
			
		||||
        <button type="button" @click="commentEditorDialog?.close()" :disabled="disabled">Confirm</button>
 | 
			
		||||
        <button type="button" @click="model.comment = ''; model.checklistItems = []; commentEditorDialog?.close()"
 | 
			
		||||
          :disabled="disabled">Clear</button>
 | 
			
		||||
        <button type="button" @click="cancelCommentEdit">Cancel</button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </dialog>
 | 
			
		||||
| 
						 | 
				
			
			@ -175,6 +220,10 @@ td {
 | 
			
		|||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-item-disabled {
 | 
			
		||||
  cursor: default !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-item+.status-item {
 | 
			
		||||
  margin-left: 0.5em;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth';
 | 
			
		|||
import { onMounted, onUnmounted, ref, useTemplateRef, type Ref } from 'vue';
 | 
			
		||||
import ConfirmDialog from './ConfirmDialog.vue';
 | 
			
		||||
 | 
			
		||||
const REFRESH_INTERVAL_MS = 5000
 | 
			
		||||
const REFRESH_INTERVAL_MS = 30000
 | 
			
		||||
 | 
			
		||||
const authStore = useAuthStore()
 | 
			
		||||
const client = new AnnouncementAPIClient(authStore)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue