Added labels for students, and one more checklist item.
This commit is contained in:
parent
338d861906
commit
b1f9cfa710
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue