Added labels for students, and one more checklist item.
Build and Test App / Build-and-test-App (push) Successful in 34s Details
Build and Test API / Build-and-test-API (push) Successful in 53s Details

This commit is contained in:
Andrew Lalis 2025-09-02 16:01:46 -04:00
parent 338d861906
commit b1f9cfa710
8 changed files with 158 additions and 6 deletions

View File

@ -23,6 +23,14 @@ CREATE TABLE classroom_compliance_student (
removed BOOLEAN NOT NULL DEFAULT FALSE removed BOOLEAN NOT NULL DEFAULT FALSE
); );
CREATE TABLE classroom_compliance_student_label (
student_id BIGINT NOT NULL
REFERENCES classroom_compliance_student(id)
ON UPDATE CASCADE ON DELETE CASCADE,
label VARCHAR(255) NOT NULL,
PRIMARY KEY (student_id, label)
);
CREATE TABLE classroom_compliance_entry ( CREATE TABLE classroom_compliance_entry (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
class_id BIGINT NOT NULL class_id BIGINT NOT NULL

View File

@ -32,6 +32,8 @@ void registerApiEndpoints(PathHandler handler) {
handler.addMapping(Method.PUT, STUDENT_PATH ~ "/class", &moveStudentToOtherClass); handler.addMapping(Method.PUT, STUDENT_PATH ~ "/class", &moveStudentToOtherClass);
handler.addMapping(Method.GET, STUDENT_PATH ~ "/entries", &getStudentEntries); handler.addMapping(Method.GET, STUDENT_PATH ~ "/entries", &getStudentEntries);
handler.addMapping(Method.GET, STUDENT_PATH ~ "/overview", &getStudentOverview); handler.addMapping(Method.GET, STUDENT_PATH ~ "/overview", &getStudentOverview);
handler.addMapping(Method.GET, STUDENT_PATH ~ "/labels", &getStudentLabels);
handler.addMapping(Method.POST, STUDENT_PATH ~ "/labels", &updateStudentLabels);
handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries); handler.addMapping(Method.GET, CLASS_PATH ~ "/entries", &getEntries);
handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries); handler.addMapping(Method.POST, CLASS_PATH ~ "/entries", &saveEntries);

View File

@ -279,3 +279,37 @@ void moveStudentToOtherClass(ref HttpRequestContext ctx) {
conn.commit(); conn.commit();
// We just return 200 OK, no response body. // We just return 200 OK, no response body.
} }
void getStudentLabels(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto student = getStudentOrThrow(ctx, conn, user);
string[] labels = findAll(
conn,
"SELECT label FROM classroom_compliance_student_label WHERE student_id = ? ORDER BY label ASC",
(r) => r.getString(1),
student.id
);
writeJsonBody(ctx, labels);
}
void updateStudentLabels(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto student = getStudentOrThrow(ctx, conn, user);
string[] labels = readJsonPayload!(string[])(ctx);
conn.setAutoCommit(false);
update(conn, "DELETE FROM classroom_compliance_student_label WHERE student_id = ?", student.id);
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO classroom_compliance_student_label (student_id, label) VALUES (?, ?)"
);
foreach (label; labels) {
ps.setUlong(1, student.id);
ps.setString(2, label);
ps.executeUpdate();
}
conn.commit();
}

View File

@ -245,4 +245,15 @@ export class ClassroomComplianceAPIClient extends APIClient {
): APIResponse<StudentStatisticsOverview> { ): APIResponse<StudentStatisticsOverview> {
return super.get(`/classes/${classId}/students/${studentId}/overview`) return super.get(`/classes/${classId}/students/${studentId}/overview`)
} }
getStudentLabels(classId: number, studentId: number): APIResponse<string[]> {
return super.get(`/classes/${classId}/students/${studentId}/labels`)
}
updateStudentLabels(classId: number, studentId: number, labels: string[]): APIResponse<void> {
return super.postWithNoExpectedResponse(
`/classes/${classId}/students/${studentId}/labels`,
labels,
)
}
} }

View File

@ -309,7 +309,8 @@ defineExpose({
<tr v-for="(student, idx) in getVisibleStudents()" :key="student.id" style="height: 2em;"> <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 v-if="selectedView !== TableView.WHITEBOARD" style="text-align: right; padding-right: 0.5em;">{{ idx + 1
}}.</td> }}.</td>
<StudentNameCell :student="student" :class-id="classId" /> <StudentNameCell :student="student" :class-id="classId"
:show-labels="selectedView !== TableView.WHITEBOARD" />
<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"

View File

@ -17,6 +17,8 @@ const router = useRouter()
const cls: Ref<Class | null> = ref(null) 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 labels: Ref<string[]> = ref([])
const newLabel = 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. // Filtered set of entries for "last week", used in the week overview dialog.
const lastWeeksEntries = computed(() => { const lastWeeksEntries = computed(() => {
@ -50,6 +52,8 @@ const lastWeeksEntries = computed(() => {
const apiClient = new ClassroomComplianceAPIClient(authStore) const apiClient = new ClassroomComplianceAPIClient(authStore)
const deleteConfirmDialog = useTemplateRef('deleteConfirmDialog') const deleteConfirmDialog = useTemplateRef('deleteConfirmDialog')
const weekOverviewDialog = useTemplateRef('weekOverviewDialog') const weekOverviewDialog = useTemplateRef('weekOverviewDialog')
const labelsDialog = useTemplateRef('labelsDialog')
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()
@ -78,8 +82,23 @@ onMounted(async () => {
console.warn('Failed to get student statistics: ', result.message) console.warn('Failed to get student statistics: ', result.message)
} }
}) })
fetchLabels()
}) })
async function fetchLabels() {
if (!cls.value || !student.value) {
labels.value = []
return
}
const result = await apiClient.getStudentLabels(cls.value.id, student.value.id).result
if (result instanceof APIError) {
console.error("Failed to get labels.", result.message)
labels.value = []
} else {
labels.value = result
}
}
async function deleteThisStudent() { async function deleteThisStudent() {
if (!cls.value || !student.value || cls.value.archived) return if (!cls.value || !student.value || cls.value.archived) return
const choice = await deleteConfirmDialog.value?.show() const choice = await deleteConfirmDialog.value?.show()
@ -93,6 +112,27 @@ function getFormattedDate(entry: Entry) {
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()
} }
async function addLabel() {
if (!cls.value || !student.value || cls.value.archived) return
const newLabels = [...labels.value, newLabel.value.trim()]
const success = await apiClient.updateStudentLabels(cls.value.id, student.value.id, newLabels)
.handleErrorsWithAlertNoBody()
if (success) {
newLabel.value = ''
await fetchLabels()
}
}
async function deleteLabel(label: string) {
if (!cls.value || !student.value || cls.value.archived) return
const newLabels = labels.value.filter(s => s !== label)
const success = await apiClient.updateStudentLabels(cls.value.id, student.value.id, newLabels)
.handleErrorsWithAlertNoBody()
if (success) {
await fetchLabels()
}
}
</script> </script>
<template> <template>
<div v-if="student" class="centered-content"> <div v-if="student" class="centered-content">
@ -110,6 +150,7 @@ function getFormattedDate(entry: Entry) {
<button type="button" @click="deleteThisStudent" :disabled="cls?.archived">Delete</button> <button type="button" @click="deleteThisStudent" :disabled="cls?.archived">Delete</button>
<button type="button" @click="weekOverviewDialog?.showModal()" :disabled="lastWeeksEntries.length < 1">Week <button type="button" @click="weekOverviewDialog?.showModal()" :disabled="lastWeeksEntries.length < 1">Week
overview</button> overview</button>
<button type="button" @click="labelsDialog?.showModal()">Labels</button>
</div> </div>
<table class="student-properties-table"> <table class="student-properties-table">
@ -154,6 +195,7 @@ function getFormattedDate(entry: Entry) {
<p>This <strong>cannot</strong> be undone!</p> <p>This <strong>cannot</strong> be undone!</p>
</ConfirmDialog> </ConfirmDialog>
<!-- Dialog that, when opened, shows an overview of the student's activity for the current week. -->
<dialog ref="weekOverviewDialog" method="dialog" class="weekly-overview-dialog"> <dialog ref="weekOverviewDialog" method="dialog" class="weekly-overview-dialog">
<div> <div>
<h2>This week's overview for <span v-text="student.name"></span></h2> <h2>This week's overview for <span v-text="student.name"></span></h2>
@ -196,6 +238,26 @@ function getFormattedDate(entry: Entry) {
<button @click.prevent="weekOverviewDialog?.close()">Close</button> <button @click.prevent="weekOverviewDialog?.close()">Close</button>
</div> </div>
</dialog> </dialog>
<!-- Dialog for editing a student's labels. -->
<dialog ref="labelsDialog" method="dialog">
<div>
<h2>Labels</h2>
<div v-for="label in labels" :key="label"
style="display: flex; flex-direction: row; justify-content: space-between; margin: 0.75rem 0;">
<span>{{ label }}</span>
<span style="cursor: pointer;" @click="deleteLabel(label)"></span>
</div>
<div>
<input type="text" v-model="newLabel" />
<button type="button" :disabled="newLabel.trim().length === 0 || cls?.archived"
@click="addLabel()">Add</button>
</div>
</div>
<div>
<button type="button" @click="labelsDialog?.close()">Close</button>
</div>
</dialog>
</div> </div>
</template> </template>
<style scoped> <style scoped>

View File

@ -8,7 +8,8 @@ const COMMENT_CHECKLIST_ITEMS = {
"Out of Uniform", "Out of Uniform",
"Use of wireless tech", "Use of wireless tech",
"10+ minute bathroom pass", "10+ minute bathroom pass",
"Not having laptop" "Not having laptop",
"Not in assigned seat"
], ],
"Behavior": [ "Behavior": [
"Talking out of turn", "Talking out of turn",

View File

@ -1,9 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { EntriesResponseStudent } from '@/api/classroom_compliance'; import { APIError } from '@/api/base';
defineProps<{ import { ClassroomComplianceAPIClient, type EntriesResponseStudent } from '@/api/classroom_compliance';
import { useAuthStore } from '@/stores/auth';
import { onMounted, ref, type Ref } from 'vue';
const props = defineProps<{
student: EntriesResponseStudent, student: EntriesResponseStudent,
classId: number, classId: number,
showLabels: boolean
}>() }>()
const authStore = useAuthStore()
const labels: Ref<string[]> = ref([])
onMounted(() => {
const api = new ClassroomComplianceAPIClient(authStore)
api.getStudentLabels(props.classId, props.student.id).result
.then(values => {
if (values instanceof APIError) {
console.error("Failed to get labels for student", props.student)
} else {
labels.value = values
}
})
})
</script> </script>
<template> <template>
<td class="student-name-cell" :class="{ 'student-removed': student.removed }"> <td class="student-name-cell" :class="{ 'student-removed': student.removed }">
@ -11,6 +30,11 @@ defineProps<{
class="student-link"> class="student-link">
{{ student.name }} {{ student.name }}
</RouterLink> </RouterLink>
<span v-if="showLabels">
<span v-for="label in labels" :key="label" class="student-name-cell-label">
{{ label }}
</span>
</span>
<div v-if="classId !== student.classId" class="other-class-text"> <div v-if="classId !== student.classId" class="other-class-text">
<RouterLink :to="'/classroom-compliance/classes/' + student.classId">In another class</RouterLink> <RouterLink :to="'/classroom-compliance/classes/' + student.classId">In another class</RouterLink>
</div> </div>
@ -18,10 +42,19 @@ defineProps<{
</template> </template>
<style scoped> <style scoped>
.student-name-cell { .student-name-cell {
padding-left: 0.5em; padding-left: 0.25rem;
text-align: left; text-align: left;
} }
.student-name-cell-label {
font-size: 10px;
background-color: rgb(121, 99, 3);
border: 1px solid gray;
border-radius: 0.25rem;
padding: 0.05rem 0.2rem;
margin: 0 0.1rem;
}
.student-link { .student-link {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;