Skip to content
Snippets Groups Projects
Verified Commit d05affe7 authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Merge branch 'master' into 266-add-statistics-page-for-absences

parents 0afdb47f f8f366cb
No related branches found
No related tags found
1 merge request!361Resolve "Add statistics page for absences"
Pipeline #189821 failed
Showing
with 453 additions and 122 deletions
<template>
<v-list-item :style="{ scrollMarginTop: '145px' }" two-line>
<v-list-item :style="{ scrollMarginTop: '145px' }" two-line class="px-0">
<v-list-item-content>
<v-subheader class="text-h6">{{
<v-subheader class="text-h6 px-1">{{
$d(date, "dateWithWeekday")
}}</v-subheader>
<v-list max-width="100%" class="pt-0 mt-n1">
<v-list-item
v-for="doc in docs"
:key="'documentation-' + (doc.oldId || doc.id)"
class="px-1"
>
<documentation-modal
:documentation="doc"
......
......@@ -45,6 +45,10 @@
<script>
import { coursesOfPerson, groupsByPerson } from "./coursebook.graphql";
const TYPENAMES_TO_TYPES = {
CourseType: "course",
GroupType: "group",
};
export default {
name: "CoursebookFilters",
data() {
......@@ -73,9 +77,9 @@ export default {
selectable() {
return [
{ header: this.$t("alsijil.coursebook.filter.groups") },
...this.groups.map((group) => ({ type: "group", ...group })),
...this.groups,
{ header: this.$t("alsijil.coursebook.filter.courses") },
...this.courses.map((course) => ({ type: "course", ...course })),
...this.courses,
];
},
selectLoading() {
......@@ -86,14 +90,16 @@ export default {
},
currentObj() {
return this.selectable.find(
(o) => o.type === this.value.objType && o.id === this.value.objId,
(o) =>
TYPENAMES_TO_TYPES[o.__typename] === this.value.objType &&
o.id === this.value.objId,
);
},
},
methods: {
selectObject(selection) {
this.$emit("input", {
objType: selection ? selection.type : null,
objType: selection ? TYPENAMES_TO_TYPES[selection.__typename] : null,
objId: selection ? selection.id : null,
});
},
......
<template>
<div>
<v-list-item v-for="i in numberOfDays" :key="'i-' + i">
<v-list-item v-for="i in numberOfDays" :key="'i-' + i" class="px-0">
<v-list-item-content>
<v-list-item-title>
<v-skeleton-loader type="heading" />
</v-list-item-title>
<v-list max-width="100%">
<v-list-item v-for="j in numberOfDocs" :key="'j-' + j">
<v-list-item v-for="j in numberOfDocs" :key="'j-' + j" class="px-1">
<DocumentationLoader />
</v-list-item>
</v-list>
......
......@@ -5,20 +5,17 @@
<!-- -> popup = true -->
</template>
<template #title>
<!-- Abwesenheit/Entschuldigung erfassen -->
<!-- Abwesenheit/Entschuldigung Zusammenfassung -->
<!-- Abwesenheit/Entschuldigung erfassen -->
<!-- Abwesenheit/Entschuldigung Zusammenfassung -->
</template>
<template #content>
<absence-form v-if="form" />
<absence-summary v-else />
<absence-form v-if="form" />
<absence-summary v-else />
</template>
<template #actions>
<!-- secondary -->
<!-- TODO: Return to form on cancel? form=true -->
<cancel-button
@click="popup = false"
disabled="loading"
/>
<cancel-button @click="popup = false" disabled="loading" />
<!-- primary -->
<save-button
v-if="form"
......
......@@ -6,28 +6,22 @@
</v-row>
<v-row>
<!-- Start -->
<v-col
cols="12"
:sm="6"
>
<v-col cols="12" :sm="6">
<date-field
:value="value"
@input="$emit('input', $event)"
:label="$t('date_select.label')"
:disabled="loading"
/>
/>
</v-col>
<!-- End -->
<v-col
cols="12"
:sm="6"
>
<v-col cols="12" :sm="6">
<date-field
:value="value"
@input="$emit('input', $event)"
:label="$t('date_select.label')"
:disabled="loading"
/>
/>
</v-col>
</v-row>
<v-row>
......
......@@ -6,7 +6,7 @@
:enable-create="false"
:enable-edit="false"
:elevated="false"
>
>
<template #default="{ items }">
<!-- expandable card per person -->
</template>
......
<script>
import AbsenceReasonButtons from "aleksis.apps.kolego/components/AbsenceReasonButtons.vue";
import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue";
import AbsenceReasonGroupSelect from "aleksis.apps.kolego/components/AbsenceReasonGroupSelect.vue";
import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue";
import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue";
import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
import documentationPartMixin from "../documentation/documentationPartMixin";
import LessonInformation from "../documentation/LessonInformation.vue";
import { updateParticipationStatuses } from "./participationStatus.graphql";
import SlideIterator from "aleksis.core/components/generic/SlideIterator.vue";
export default {
name: "ManageStudentsDialog",
extends: MobileFullscreenDialog,
components: {
AbsenceReasonChip,
AbsenceReasonGroupSelect,
AbsenceReasonButtons,
CancelButton,
LessonInformation,
MobileFullscreenDialog,
SlideIterator,
},
mixins: [documentationPartMixin, mutateMixin],
data() {
return {
dialog: false,
search: "",
loadSelected: false,
selected: [],
isExpanded: false,
};
},
props: {
loadingIndicator: {
type: Boolean,
default: false,
required: false,
},
},
computed: {
items() {
return this.documentation.participations;
},
},
methods: {
sendToServer(participations, field, value) {
if (field !== "absenceReason") return;
this.mutate(
updateParticipationStatuses,
{
input: participations.map((participation) => ({
id: participation.id,
absenceReason: value === "present" ? null : value,
})),
},
(storedDocumentations, incomingStatuses) => {
const documentation = storedDocumentations.find(
(doc) => doc.id === this.documentation.id,
);
incomingStatuses.forEach((newStatus) => {
const participationStatus = documentation.participations.find(
(part) => part.id === newStatus.id,
);
participationStatus.absenceReason = newStatus.absenceReason;
participationStatus.isOptimistic = newStatus.isOptimistic;
});
return storedDocumentations;
},
);
},
handleMultipleAction(absenceReasonId) {
this.loadSelected = true;
this.sendToServer(this.selected, "absenceReason", absenceReasonId);
this.$once("save", this.resetMultipleAction);
},
resetMultipleAction() {
this.loadSelected = false;
this.$set(this.selected, []);
this.$refs.iterator.selected = [];
},
},
};
</script>
<template>
<mobile-fullscreen-dialog
scrollable
v-bind="$attrs"
v-on="$listeners"
v-model="dialog"
>
<template #activator="activator">
<slot name="activator" v-bind="activator" />
</template>
<template #title>
<lesson-information v-bind="documentationPartProps" :compact="false" />
<v-scroll-x-transition leave-absolute>
<v-text-field
v-show="!isExpanded"
type="search"
v-model="search"
clearable
rounded
hide-details
single-line
prepend-inner-icon="$search"
dense
outlined
:placeholder="$t('actions.search')"
class="pt-4 full-width"
/>
</v-scroll-x-transition>
<v-scroll-x-transition>
<div v-show="selected.length > 0" class="full-width mt-4">
<absence-reason-buttons
allow-empty
empty-value="present"
@input="handleMultipleAction"
/>
</div>
</v-scroll-x-transition>
</template>
<template #content>
<slide-iterator
ref="iterator"
v-model="selected"
:items="items"
:search="search"
:item-key-getter="
(item) => 'documentation-' + documentation.id + '-student-' + item.id
"
:is-expanded.sync="isExpanded"
:loading="loadingIndicator || loadSelected"
:load-only-selected="loadSelected"
:disabled="loading"
>
<template #listItemContent="{ item }">
<v-list-item-title>
{{ item.person.fullName }}
</v-list-item-title>
<v-list-item-subtitle v-if="item.absenceReason">
<absence-reason-chip small :absence-reason="item.absenceReason" />
</v-list-item-subtitle>
</template>
<template #expandedItem="{ item, close }">
<v-card-title>
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" icon @click="close">
<v-icon>$prev</v-icon>
</v-btn>
</template>
<span v-t="'actions.back_to_overview'" />
</v-tooltip>
{{ item.person.fullName }}
</v-card-title>
<v-card-text>
<absence-reason-group-select
allow-empty
empty-value="present"
:loadSelectedChip="loading"
:value="item.absenceReason?.id || 'present'"
@input="sendToServer([item], 'absenceReason', $event)"
/>
</v-card-text>
</template>
</slide-iterator>
</template>
<template #actions>
<cancel-button
@click="dialog = false"
i18n-key="actions.close"
v-show="$vuetify.breakpoint.mobile"
/>
</template>
</mobile-fullscreen-dialog>
</template>
<style scoped></style>
<script>
import { DateTime } from "luxon";
import ManageStudentsDialog from "./ManageStudentsDialog.vue";
import documentationPartMixin from "../documentation/documentationPartMixin";
import { touchDocumentation } from "./participationStatus.graphql";
import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
export default {
name: "ManageStudentsTrigger",
components: { ManageStudentsDialog },
mixins: [documentationPartMixin, mutateMixin],
data() {
return {
canOpenParticipation: false,
timeout: null,
};
},
mounted() {
const lessonStart = DateTime.fromISO(this.documentation.datetimeStart);
const now = DateTime.now();
this.canOpenParticipation = now >= lessonStart;
if (!this.canOpenParticipation) {
this.timeout = setTimeout(
() => (this.canOpenParticipation = true),
lessonStart.diff(now).toObject().milliseconds,
);
}
},
beforeDestroy() {
if (this.timeout) {
clearTimeout(this.timeout);
}
},
methods: {
touchDocumentation() {
this.mutate(
touchDocumentation,
{
documentationId: this.documentation.id,
},
(storedDocumentations, incoming) => {
// ID may be different now
return storedDocumentations.map((doc) =>
doc.id === this.documentation.id
? Object.assign(doc, incoming, { oldId: doc.id })
: doc,
);
},
);
},
},
};
</script>
<template>
<manage-students-dialog
v-bind="documentationPartProps"
@update="() => null"
:loading-indicator="loading"
>
<template #activator="{ attrs, on }">
<v-chip
dense
color="primary"
outlined
:disabled="!canOpenParticipation || loading"
v-bind="attrs"
v-on="on"
@click="touchDocumentation"
>
<v-icon>$edit</v-icon>
</v-chip>
</template>
</manage-students-dialog>
</template>
<style scoped></style>
......@@ -7,16 +7,8 @@ query persons {
}
}
query lessonsForPersons(
$persons: [ID!]!
$start: Date!
$end: Date!
) {
items: lessonsForPersons(
persons: $persons
start: $start
end: $end
) {
query lessonsForPersons($persons: [ID!]!, $start: Date!, $end: Date!) {
items: lessonsForPersons(persons: $persons, start: $start, end: $end) {
id
lessons {
id
......
mutation updateParticipationStatuses(
$input: [BatchPatchParticipationStatusInput]!
) {
updateParticipationStatuses(input: $input) {
items: participationStatuses {
id
isOptimistic
relatedDocumentation {
id
}
absenceReason {
id
name
shortName
colour
}
}
}
}
mutation touchDocumentation($documentationId: ID!) {
touchDocumentation(documentationId: $documentationId) {
items: documentation {
id
participations {
id
person {
id
firstName
fullName
}
absenceReason {
id
name
shortName
colour
}
isOptimistic
}
}
}
}
......@@ -9,10 +9,6 @@ query coursesOfPerson {
courses: coursesOfPerson {
id
name
groups {
id
name
}
}
}
......@@ -70,6 +66,21 @@ query documentationsForCoursebook(
colourFg
colourBg
}
participations {
id
person {
id
firstName
fullName
}
absenceReason {
id
name
shortName
colour
}
isOptimistic
}
topic
homework
groupNote
......@@ -92,6 +103,21 @@ mutation createOrUpdateDocumentations($input: [DocumentationInputType]!) {
homework
groupNote
oldId
participations {
id
person {
id
firstName
fullName
}
absenceReason {
id
name
shortName
colour
}
isOptimistic
}
}
}
}
......@@ -61,7 +61,7 @@ import PersonChip from "aleksis.core/components/person/PersonChip.vue";
v-for="teacher in documentation.teachers"
:key="documentation.id + '-teacher-' + teacher.id"
:person="teacher"
no-link
:no-link="compact"
v-bind="compact ? dialogActivator.attrs : {}"
v-on="compact ? dialogActivator.on : {}"
/>
......@@ -69,7 +69,7 @@ import PersonChip from "aleksis.core/components/person/PersonChip.vue";
v-for="teacher in amendedTeachers"
:key="documentation.id + '-amendedTeacher-' + teacher.id"
:person="teacher"
no-link
:no-link="compact"
v-bind="compact ? dialogActivator.attrs : {}"
v-on="compact ? dialogActivator.on : {}"
class="text-decoration-line-through"
......
<script setup>
import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue";
</script>
<template>
<div
class="d-flex align-center justify-space-between justify-md-end flex-wrap gap"
>
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<v-chip dense color="success">
<v-chip small dense class="mr-2" color="green darken-3 white--text"
>26</v-chip
>
von 30 anwesend
</v-chip>
<v-chip dense color="warning">
<v-chip small dense class="mr-2" color="orange darken-3 white--text"
>3</v-chip
>
entschuldigt
</v-chip>
<v-chip dense color="error">
<v-chip small dense class="mr-2" color="red darken-3 white--text"
>1</v-chip
>
unentschuldigt
<v-chip dense color="success" outlined v-if="total > 0">
{{ $t("alsijil.coursebook.present_number", { present, total }) }}
</v-chip>
<v-chip dense color="grey lighten-1">
<v-chip small dense class="mr-2" color="grey darken-1 white--text"
>4</v-chip
>
Hausaufgaben vergessen
</v-chip>
<v-chip dense color="primary" outlined>
<v-icon>$edit</v-icon>
</v-chip>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
<absence-reason-chip
v-for="[reasonId, participations] in Object.entries(absences)"
:key="'reason-' + reasonId"
:absence-reason="participations[0].absenceReason"
dense
>
<template #append>
<span
>:
<span>
{{
participations
.slice(0, 5)
.map((participation) => participation.person.firstName)
.join(", ")
}}
</span>
<span v-if="participations.length > 5">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+{{ participations.length - 5 }}
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
</span>
</span>
</template>
</absence-reason-chip>
<manage-students-trigger v-bind="documentationPartProps" />
</div>
</template>
<script>
import documentationPartMixin from "./documentationPartMixin";
import ManageStudentsTrigger from "../absences/ManageStudentsTrigger.vue";
export default {
name: "LessonNotes",
components: { ManageStudentsTrigger },
mixins: [documentationPartMixin],
computed: {
total() {
return this.documentation.participations.length;
},
present() {
return this.documentation.participations.filter(
(p) => p.absenceReason === null,
).length;
},
absences() {
// Get all course attendants who have an absence reason
return Object.groupBy(
this.documentation.participations.filter(
(p) => p.absenceReason !== null,
),
({ absenceReason }) => absenceReason.id,
);
},
},
};
</script>
......
......@@ -10,6 +10,13 @@ export default {
type: Object,
required: true,
},
/**
* The query used by the coursebook. Used to update the store when data changes.
*/
affectedQuery: {
type: Object,
required: true,
},
/**
* Whether the documentation is currently in the compact mode (meaning coursebook row)
*/
......@@ -38,6 +45,7 @@ export default {
documentation: this.documentation,
compact: this.compact,
dialogActivator: this.dialogActivator,
affectedQuery: this.affectedQuery,
};
},
},
......
<template>
<statistics-for-person-card
:person="{ id: 100 }"
/>
<statistics-for-person-card :person="{ id: 100 }" />
</template>
<script>
......@@ -10,7 +8,7 @@ import StatisticsForPersonCard from "./StatisticsForPersonCard.vue";
export default {
name: "MockPerson",
components: {
StatisticsForPersonCard
StatisticsForPersonCard,
},
extends: "StatisticsForPersonCard",
};
......
<template>
<v-skeleton-loader v-if="loading"
type="card"
/>
<v-skeleton-loader v-if="loading" type="card" />
<v-card v-else>
<v-card-text class="d-flex flex-column" style="gap: .5em">
<v-card-text class="d-flex flex-column" style="gap: 0.5em">
<absence-reason-chip
v-for="absenceReason in absenceReasons"
:absenceReason="absenceReason.absenceReason"
......@@ -19,7 +17,7 @@ import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.
export default {
name: "StatisticsAbsencesCard",
components: {
AbsenceReasonChip
AbsenceReasonChip,
},
props: {
absenceReasons: {
......
<template>
<v-skeleton-loader v-if="loading"
type="card"
/>
<v-skeleton-loader v-if="loading" type="card" />
<v-card v-else>
<v-card-text class="d-flex flex-column" style="gap: .5em">
<v-card-text class="d-flex flex-column" style="gap: 0.5em">
<counter-chip
v-for="extraMark in extraMarks"
:value="extraMark.extraMark.id"
......
<template>
<v-card>
<v-skeleton-loader v-if="$apollo.loading"
type="card-heading"
/>
<v-skeleton-loader v-if="$apollo.loading" type="card-heading" />
<v-card-title v-else-if="compact">
{{ $t("alsijil.coursebook.statistics.person_compact.title") }}
<v-spacer />
......@@ -36,7 +34,8 @@
:loading="$apollo.loading"
/>
</div>
<statistics-personal-notes-list v-if="compact"
<statistics-personal-notes-list
v-if="compact"
class="flex-grow-1"
:loading="$apollo.loading"
/>
......@@ -85,9 +84,7 @@ export default {
statistics: {
query: statisticsByPerson,
variables() {
const term = this.schoolTerm
? { term: this.schoolTerm.id }
: {};
const term = this.schoolTerm ? { term: this.schoolTerm.id } : {};
return {
person: this.person.id,
......
......@@ -13,7 +13,7 @@
:enable-create="false"
:enable-edit="false"
:elevated="false"
>
>
<template #item="{ item }">
<v-list-item :key="item.id">
<v-list-item-content>
......@@ -30,10 +30,7 @@
{{ $d(DateTime.fromISO(item.datetimeEnd), "shortTime") }}
</time>
<!-- teacher -->
<person-chip
:person="item.teacher"
no-link
/>
<person-chip :person="item.teacher" no-link />
<!-- group -->
<div>
{{ item.groupShortName }}
......@@ -54,13 +51,11 @@
>
{{ extraMark.name }}
</v-chip>
</v-list-item-title>
<v-list-item-subtitle>
item.personalNote
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
</v-list-item-title>
<v-list-item-subtitle> item.personalNote </v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
</template>
</c-r-u-d-iterator>
</div>
......@@ -69,7 +64,7 @@
:compact="false"
:person="{ id: personId }"
:school-term="{ id: schoolTerm }"
/>
/>
</div>
</template>
......
<template>
<v-skeleton-loader v-if="loading"
type="card"
/>
<v-skeleton-loader v-if="loading" type="card" />
<v-card v-else>
<v-virtual-scroll
:items="personalNotes"
height="150"
item-height="75"
>
<v-virtual-scroll :items="personalNotes" height="150" item-height="75">
<template v-slot:default="{ item }">
<v-list-item :key="item">
<v-list-item-content>
<v-list-item-title class="d-flex">
<!-- new_personal_note.documentation.course.groups.FORALL.shortName -->
<div>
5a
</div>
<div>5a</div>
<!-- new_personal_note.documentation.subject/amends.subject -->
<!-- TODO: In subject-chip -->
<div>
Ma
</div>
<div>Ma</div>
<v-spacer />
<!-- new_personal_note.documentation.datetimeStart.toDate() -->
<div>
<v-list-item-subtitle>
01.01.2031
</v-list-item-subtitle>
<v-list-item-subtitle> 01.01.2031 </v-list-item-subtitle>
</div>
</v-list-item-title>
<v-list-item-subtitle>
......@@ -37,7 +25,7 @@
</v-list-item>
<v-divider></v-divider>
</template>
</v-virtual-scroll>
</v-virtual-scroll>
</v-card>
</template>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment