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
);
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 (
id BIGSERIAL PRIMARY KEY,
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.GET, STUDENT_PATH ~ "/entries", &getStudentEntries);
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.POST, CLASS_PATH ~ "/entries", &saveEntries);

View File

@ -279,3 +279,37 @@ void moveStudentToOtherClass(ref HttpRequestContext ctx) {
conn.commit();
// 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> {
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

@ -308,8 +308,9 @@ 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>
<StudentNameCell :student="student" :class-id="classId" />
}}.</td>
<StudentNameCell :student="student" :class-id="classId"
:show-labels="selectedView !== TableView.WHITEBOARD" />
<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"

View File

@ -17,6 +17,8 @@ const router = useRouter()
const cls: Ref<Class | null> = ref(null)
const student: Ref<Student | null> = ref(null)
const entries: Ref<Entry[]> = ref([])
const labels: Ref<string[]> = ref([])
const newLabel = ref('')
const statistics: Ref<StudentStatisticsOverview | null> = ref(null)
// Filtered set of entries for "last week", used in the week overview dialog.
const lastWeeksEntries = computed(() => {
@ -50,6 +52,8 @@ const lastWeeksEntries = computed(() => {
const apiClient = new ClassroomComplianceAPIClient(authStore)
const deleteConfirmDialog = useTemplateRef('deleteConfirmDialog')
const weekOverviewDialog = useTemplateRef('weekOverviewDialog')
const labelsDialog = useTemplateRef('labelsDialog')
onMounted(async () => {
const classIdNumber = parseInt(props.classId, 10)
cls.value = await apiClient.getClass(classIdNumber).handleErrorsWithAlert()
@ -78,8 +82,23 @@ onMounted(async () => {
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() {
if (!cls.value || !student.value || cls.value.archived) return
const choice = await deleteConfirmDialog.value?.show()
@ -93,6 +112,27 @@ function getFormattedDate(entry: Entry) {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
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>
<template>
<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="weekOverviewDialog?.showModal()" :disabled="lastWeeksEntries.length < 1">Week
overview</button>
<button type="button" @click="labelsDialog?.showModal()">Labels</button>
</div>
<table class="student-properties-table">
@ -154,6 +195,7 @@ function getFormattedDate(entry: Entry) {
<p>This <strong>cannot</strong> be undone!</p>
</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">
<div>
<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>
</div>
</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>
</template>
<style scoped>

View File

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

View File

@ -1,9 +1,28 @@
<script setup lang="ts">
import type { EntriesResponseStudent } from '@/api/classroom_compliance';
defineProps<{
import { APIError } from '@/api/base';
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,
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>
<template>
<td class="student-name-cell" :class="{ 'student-removed': student.removed }">
@ -11,6 +30,11 @@ defineProps<{
class="student-link">
{{ student.name }}
</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">
<RouterLink :to="'/classroom-compliance/classes/' + student.classId">In another class</RouterLink>
</div>
@ -18,10 +42,19 @@ defineProps<{
</template>
<style scoped>
.student-name-cell {
padding-left: 0.5em;
padding-left: 0.25rem;
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 {
text-decoration: none;
color: inherit;