diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue index 7c1b9ac900da53ff7ab97a5b459e9be70891521f..b9e723c2926d6cf5ae8a532bc5a70c615ccbd5f7 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/Coursebook.vue @@ -1,6 +1,6 @@ <template> <div> - <c-r-u-d-iterator + <infinite-scrolling-date-sorted-c-r-u-d-iterator i18n-key="alsijil.coursebook" :gql-query="gqlQuery" :gql-additional-query-args="gqlQueryArgs" @@ -15,85 +15,90 @@ use-deep-search > <template #additionalActions="{ attrs, on }"> - <coursebook-filters v-model="filters" /> + <coursebook-filters :page-type="pageType" v-model="filters" /> + <v-expand-transition> + <v-card + outlined + class="full-width" + v-show=" + pageType === 'absences' && combinedSelectedParticipations.length + " + > + <v-card-text> + <v-row align="center"> + <v-col cols="6"> + {{ + $tc( + "alsijil.coursebook.absences.action_for_selected", + combinedSelectedParticipations.length, + ) + }} + </v-col> + <v-col cols="6"> + <absence-reason-buttons + allow-empty + empty-value="present" + @input="handleMultipleAction" + /> + </v-col> + </v-row> + </v-card-text> + </v-card> + </v-expand-transition> </template> - <template #default="{ items }"> - <coursebook-loader /> - <coursebook-day - v-for="{ date, docs, first, last } in groupDocsByDay(items)" - v-intersect="{ - handler: intersectHandler(date, first, last), - options: { - rootMargin: '-' + topMargin + 'px 0px 0px 0px', - threshold: [0, 1], - }, - }" - :date="date" - :docs="docs" - :lastQuery="lastQuery" - :focus-on-mount="initDate && initDate.toMillis() === date.toMillis()" - @init="transition" - :key="'day-' + date" - ref="days" - :extra-marks="extraMarks" - /> - <coursebook-loader /> - <date-select-footer - :value="currentDate" - @input="gotoDate" - @prev="gotoPrev" - @next="gotoNext" + <template #item="{ item, lastQuery }"> + <component + :is="itemComponent" + :extraMarks="extraMarks" + :documentation="item" + :affectedQuery="lastQuery" + :value="(selectedParticipations[item.id] ??= [])" + @input="selectParticipation(item.id, $event)" /> </template> + <template #loading> <coursebook-loader :number-of-days="10" :number-of-docs="5" /> </template> - <template #no-data> - <CoursebookEmptyMessage icon="mdi-book-off-outline"> - {{ $t("alsijil.coursebook.no_data") }} - </CoursebookEmptyMessage> + <template #itemLoader> + <DocumentationLoader /> </template> - - <template #no-results> - <CoursebookEmptyMessage icon="mdi-book-alert-outline"> - {{ - $t("alsijil.coursebook.no_results", { - search: $refs.iterator.search, - }) - }} - </CoursebookEmptyMessage> - </template> - </c-r-u-d-iterator> - <absence-creation-dialog /> + </infinite-scrolling-date-sorted-c-r-u-d-iterator> + <v-scale-transition> + <absence-creation-dialog v-if="pageType === 'absences'" /> + </v-scale-transition> </div> </template> <script> -import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue"; -import DateSelectFooter from "aleksis.core/components/generic/DateSelectFooter.vue"; -import CoursebookDay from "./CoursebookDay.vue"; +import InfiniteScrollingDateSortedCRUDIterator from "aleksis.core/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue"; import { DateTime, Interval } from "luxon"; import { documentationsForCoursebook } from "./coursebook.graphql"; +import AbsenceReasonButtons from "aleksis.apps.kolego/components/AbsenceReasonButtons.vue"; import CoursebookFilters from "./CoursebookFilters.vue"; import CoursebookLoader from "./CoursebookLoader.vue"; -import CoursebookEmptyMessage from "./CoursebookEmptyMessage.vue"; -import { extraMarks } from "../extra_marks/extra_marks.graphql"; - +import DocumentationModal from "./documentation/DocumentationModal.vue"; +import DocumentationAbsencesModal from "./absences/DocumentationAbsencesModal.vue"; import AbsenceCreationDialog from "./absences/AbsenceCreationDialog.vue"; +import { extraMarks } from "../extra_marks/extra_marks.graphql"; +import DocumentationLoader from "./documentation/DocumentationLoader.vue"; +import sendToServerMixin from "./absences/sendToServerMixin"; export default { name: "Coursebook", components: { - CoursebookEmptyMessage, + DocumentationLoader, + AbsenceReasonButtons, CoursebookFilters, CoursebookLoader, - CRUDIterator, - DateSelectFooter, - CoursebookDay, + DocumentationModal, + DocumentationAbsencesModal, + InfiniteScrollingDateSortedCRUDIterator, AbsenceCreationDialog, }, + mixins: [sendToServerMixin], props: { filterType: { type: String, @@ -109,6 +114,11 @@ export default { required: false, default: null, }, + pageType: { + type: String, + required: false, + default: "documentations", + }, /** * Number of consecutive to load at once * This number of days is initially loaded and loaded @@ -138,11 +148,13 @@ export default { groups: [], courses: [], incomplete: false, + absencesExist: true, ready: false, initDate: false, currentDate: "", hashUpdater: false, extraMarks: [], + selectedParticipations: {}, }; }, apollo: { @@ -162,6 +174,7 @@ export default { dateStart: this.dateStart, dateEnd: this.dateEnd, incomplete: !!this.incomplete, + absencesExist: !!this.absencesExist && this.pageType === "absences", }; }, filters: { @@ -171,15 +184,20 @@ export default { objId: this.objId, filterType: this.filterType, incomplete: this.incomplete, + pageType: this.pageType, + absencesExist: this.absencesExist, }; }, set(selectedFilters) { if (Object.hasOwn(selectedFilters, "incomplete")) { this.incomplete = selectedFilters.incomplete; + } else if (Object.hasOwn(selectedFilters, "absencesExist")) { + this.absencesExist = selectedFilters.absencesExist; } else if ( Object.hasOwn(selectedFilters, "filterType") || Object.hasOwn(selectedFilters, "objId") || - Object.hasOwn(selectedFilters, "objType") + Object.hasOwn(selectedFilters, "objType") || + Object.hasOwn(selectedFilters, "pageType") ) { this.$router.push({ name: "alsijil.coursebook", @@ -189,194 +207,65 @@ export default { : this.filterType, objType: selectedFilters.objType, objId: selectedFilters.objId, + pageType: selectedFilters.pageType + ? selectedFilters.pageType + : this.pageType, }, hash: this.$route.hash, }); // computed should not have side effects // but this was actually done before filters was refactored into // its own component - this.resetDate(); + this.$refs.iterator.resetDate(); // might skip query until both set = atomic - } - }, - }, - }, - methods: { - resetDate(toDate) { - // Assure current date - console.log("Resetting date range", this.$route.hash); - this.currentDate = toDate || this.$route.hash?.substring(1); - if (!this.currentDate) { - console.log("Set default date"); - this.setDate(DateTime.now().toISODate()); - } - - const date = DateTime.fromISO(this.currentDate); - this.initDate = date; - this.dateStart = date.minus({ days: this.dayIncrement }).toISODate(); - this.dateEnd = date.plus({ days: this.dayIncrement }).toISODate(); - }, - transition() { - this.initDate = false; - this.ready = true; - }, - groupDocsByDay(docs) { - // => {dt: {date: dt, docs: doc ...} ...} - const docsByDay = docs.reduce((byDay, doc) => { - // This works with dummy. Does actual doc have dateStart instead? - const day = DateTime.fromISO(doc.datetimeStart).startOf("day"); - byDay[day] ??= { date: day, docs: [] }; - byDay[day].docs.push(doc); - return byDay; - }, {}); - // => [{date: dt, docs: doc ..., idx: idx, lastIdx: last-idx} ...] - // sorting is necessary since backend can send docs unordered - return Object.keys(docsByDay) - .sort() - .map((key, idx, { length }) => { - const day = docsByDay[key]; - day.first = idx === 0; - const lastIdx = length - 1; - day.last = idx === lastIdx; - return day; - }); - }, - // docsByDay: {dt: [dt doc ...] ...} - fetchMore(from, to, then) { - console.log("fetching", from, to); - this.lastQuery.fetchMore({ - variables: { - dateStart: from, - dateEnd: to, - }, - // Transform the previous result with new data - updateQuery: (previousResult, { fetchMoreResult }) => { - console.log("Received more"); - then(); - return { items: previousResult.items.concat(fetchMoreResult.items) }; - }, - }); - }, - setDate(date) { - this.currentDate = date; - if (!this.hashUpdater) { - this.hashUpdater = window.requestIdleCallback(() => { - if (!(this.$route.hash.substring(1) === this.currentDate)) { - this.$router.replace({ hash: this.currentDate }); - } - this.hashUpdater = false; - }); - } - }, - fixScrollPos(height, top) { - this.$nextTick(() => { - if (height < document.documentElement.scrollHeight) { - document.documentElement.scrollTop = - document.documentElement.scrollHeight - height + top; - this.ready = true; - } else { - // Update top, could have changed in the meantime. - this.fixScrollPos(height, document.documentElement.scrollTop); - } - }); - }, - intersectHandler(date, first, last) { - let once = true; - return (entries) => { - const entry = entries[0]; - if (entry.isIntersecting) { - if (entry.boundingClientRect.top <= this.topMargin || first) { - console.log("@ ", date.toISODate()); - this.setDate(date.toISODate()); - } - - if (once && this.ready && first) { - console.log("load up", date.toISODate()); - this.ready = false; - this.fetchMore( - date.minus({ days: this.dayIncrement }).toISODate(), - date.minus({ days: 1 }).toISODate(), - () => { - this.fixScrollPos( - document.documentElement.scrollHeight, - document.documentElement.scrollTop, - ); - }, - ); - once = false; - } else if (once && this.ready && last) { - console.log("load down", date.toISODate()); - this.ready = false; - this.fetchMore( - date.plus({ days: 1 }).toISODate(), - date.plus({ days: this.dayIncrement }).toISODate(), - () => { - this.ready = true; - }, + if (Object.hasOwn(selectedFilters, "pageType")) { + this.absencesExist = true; + this.$setToolBarTitle( + this.$t(`alsijil.coursebook.title_${selectedFilters.pageType}`), + null, ); - once = false; } } - }; + }, }, - // Improve me? - // The navigation logic could be a bit simpler if the current days - // where known as a sorted array (= result of groupDocsByDay) But - // then the list would need its own component and this gets rather - // complicated. Then the calendar could also show the present days - // / gray out the missing. - // - // Next two: arg date is ts object - findPrev(date) { - return this.$refs.days - .map((day) => day.date) - .sort() - .reverse() - .find((date2) => date2 < date); + itemComponent() { + if (this.pageType === "documentations") { + return "DocumentationModal"; + } else { + return "DocumentationAbsencesModal"; + } }, - findNext(date) { - return this.$refs.days - .map((day) => day.date) - .sort() - .find((date2) => date2 > date); + combinedSelectedParticipations() { + return Object.values(this.selectedParticipations).flat(); }, - gotoDate(date) { - const present = this.$refs.days.find( - (day) => day.date.toISODate() === date, + }, + methods: { + selectParticipation(id, value) { + this.selectedParticipations = Object.assign( + {}, + this.selectedParticipations, + { [id]: value }, ); - - if (present) { - // React immediatly -> smoother navigation - // Also intersect handler does not always react to scrollIntoView - this.setDate(date); - present.focus("smooth"); - } else { - const prev = this.findPrev(DateTime.fromISO(date)); - const next = this.findNext(DateTime.fromISO(date)); - if (prev && next) { - // In between two present days -> goto prev - this.gotoDate(prev.toISODate()); - } else { - // Outsite present day range - this.resetDate(date); - } - } }, - gotoPrev() { - const prev = this.findPrev(DateTime.fromISO(this.currentDate)); - if (prev) { - this.gotoDate(prev.toISODate()); - } + handleMultipleAction(absenceReasonId) { + this.loadSelectedParticiptions = true; + this.sendToServer( + this.combinedSelectedParticipations, + "absenceReason", + absenceReasonId, + ); + this.$once("save", this.resetMultipleAction); }, - gotoNext() { - const next = this.findNext(DateTime.fromISO(this.currentDate)); - if (next) { - this.gotoDate(next.toISODate()); - } + resetMultipleAction() { + this.loadSelectedParticiptions = false; + this.selectedParticipations = {}; }, }, - created() { - this.resetDate(); + mounted() { + this.$setToolBarTitle( + this.$t(`alsijil.coursebook.title_${this.pageType}`), + null, + ); }, }; </script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue deleted file mode 100644 index c4a677c9f5f72227537f7d842baa51902d9859ac..0000000000000000000000000000000000000000 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue +++ /dev/null @@ -1,72 +0,0 @@ -<template> - <v-list-item :style="{ scrollMarginTop: '145px' }" two-line class="px-0"> - <v-list-item-content> - <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" - :extra-marks="extraMarks" - :affected-query="lastQuery" - /> - </v-list-item> - </v-list> - </v-list-item-content> - </v-list-item> -</template> - -<script> -import DocumentationModal from "./documentation/DocumentationModal.vue"; -export default { - name: "CoursebookDay", - components: { - DocumentationModal, - }, - props: { - date: { - type: Object, - required: true, - }, - docs: { - type: Array, - required: true, - }, - lastQuery: { - type: Object, - required: true, - }, - focusOnMount: { - type: Boolean, - required: false, - default: false, - }, - extraMarks: { - type: Array, - required: true, - }, - }, - emits: ["init"], - methods: { - focus(how) { - this.$el.scrollIntoView({ - behavior: how, - block: "start", - inline: "nearest", - }); - console.log("focused @", this.date.toISODate()); - }, - }, - mounted() { - if (this.focusOnMount) { - this.$nextTick(this.focus("instant")); - this.$emit("init"); - } - }, -}; -</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookEmptyMessage.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookEmptyMessage.vue deleted file mode 100644 index 346ecc63c272ab5c57a1da9f5e5f78b825739a79..0000000000000000000000000000000000000000 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookEmptyMessage.vue +++ /dev/null @@ -1,25 +0,0 @@ -<template> - <v-list-item> - <v-list-item-content - class="d-flex justify-center align-center flex-column full-width" - > - <div class="mb-4"> - <v-icon large color="primary">{{ icon }}</v-icon> - </div> - <v-list-item-title> - <slot></slot> - </v-list-item-title> - </v-list-item-content> - </v-list-item> -</template> -<script> -export default { - name: "CoursebookEmptyMessage", - props: { - icon: { - type: String, - default: "mdi-book-alert-outline", - }, - }, -}; -</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue index 00ff6b02fe2420d21396c500d20eca9a0f4678b1..6b9dcc92fb433ff320f0c76790656c7b76c0472f 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue @@ -1,5 +1,7 @@ <template> - <div class="d-flex flex-grow-1 justify-end"> + <div + class="d-flex flex-column flex-sm-row flex-nowrap flex-grow-1 justify-end gap align-stretch" + > <v-autocomplete :items="selectable" item-text="name" @@ -16,7 +18,7 @@ @click:clear="selectObject" class="max-width" /> - <div class="ml-6"> + <div class="mx-6"> <v-switch :loading="selectLoading" :label="$t('alsijil.coursebook.filter.own')" @@ -39,7 +41,29 @@ inset hide-details /> + <v-switch + v-if="pageType === 'absences'" + :loading="selectLoading" + :label="$t('alsijil.coursebook.filter.absences_exist')" + :input-value="value.absencesExist" + @change=" + $emit('input', { + absencesExist: $event, + }) + " + dense + inset + hide-details + /> </div> + <v-btn + outlined + color="primary" + :loading="selectLoading" + @click="togglePageType()" + > + {{ pageTypeButtonText }} + </v-btn> </div> </template> @@ -64,6 +88,11 @@ export default { type: Object, required: true, }, + pageType: { + type: String, + required: false, + default: "documentations", + }, }, emits: ["input"], apollo: { @@ -96,6 +125,13 @@ export default { o.id === this.value.objId, ); }, + pageTypeButtonText() { + if (this.value.pageType === "documentations") { + return this.$t("alsijil.coursebook.filter.page_type.absences"); + } else { + return this.$t("alsijil.coursebook.filter.page_type.documentations"); + } + }, }, methods: { selectObject(selection) { @@ -111,6 +147,16 @@ export default { objId: this.value.objId, }); }, + togglePageType() { + this.$emit("input", { + pageType: + this.value.pageType === "documentations" + ? "absences" + : "documentations", + objType: this.value.objType, + objId: this.value.objId, + }); + }, }, }; </script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/DocumentationAbsences.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/DocumentationAbsences.vue new file mode 100644 index 0000000000000000000000000000000000000000..ef97f2ddb73cd0eb5714e1a8818e97fb54fde67a --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/DocumentationAbsences.vue @@ -0,0 +1,110 @@ +<template> + <v-card :class="{ 'my-1 full-width': true, 'd-flex flex-column': !compact }"> + <v-card-title v-if="!compact"> + <lesson-information v-bind="documentationPartProps" /> + </v-card-title> + + <v-card-text + class="full-width main-body" + :class="{ + vertical: !compact || $vuetify.breakpoint.mobile, + 'pa-2': compact, + }" + > + <lesson-information v-if="compact" v-bind="documentationPartProps" /> + + <lesson-notes class="span-2" v-bind="documentationPartProps" /> + <participation-list + :include-present="false" + class="participation-list" + v-bind="documentationPartProps" + :value="value" + @input="$emit('input', $event)" + /> + </v-card-text> + <v-spacer /> + <v-divider /> + <v-card-actions v-if="!compact"> + <v-spacer /> + <cancel-button + v-if="documentation.canEdit" + @click="$emit('close')" + :disabled="loading" + /> + <save-button + v-if="documentation.canEdit" + @click="save" + :loading="loading" + /> + <cancel-button + v-if="!documentation.canEdit" + i18n-key="actions.close" + @click="$emit('close')" + /> + </v-card-actions> + </v-card> +</template> + +<script> +import ParticipationList from "./ParticipationList.vue"; +import LessonInformation from "../documentation/LessonInformation.vue"; +import LessonNotes from "../documentation/LessonNotes.vue"; + +import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue"; +import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue"; + +import { createOrUpdateDocumentations } from "../coursebook.graphql"; + +import documentationPartMixin from "../documentation/documentationPartMixin"; + +export default { + name: "DocumentationAbsences", + components: { + ParticipationList, + LessonInformation, + LessonNotes, + SaveButton, + CancelButton, + }, + emits: ["open", "close"], + mixins: [documentationPartMixin], + data() { + return { + loading: false, + documentationsMutation: createOrUpdateDocumentations, + selectedParticipations: [], + }; + }, + props: { + value: { + type: Array, + required: true, + }, + }, + methods: { + save() { + this.$refs.summary.save(); + this.$emit("close"); + }, + }, +}; +</script> + +<style scoped> +.main-body { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: min-content min-content; + column-gap: 1em; +} +.participation-list { + grid-column-start: 1; + grid-column-end: span 3; +} +.span-2 { + grid-column-end: span 2; +} +.vertical > * { + grid-column-end: span 3; +} +</style> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/DocumentationAbsencesModal.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/DocumentationAbsencesModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..1e092b4ee14c410f5f5a0f04cc8bec9e57c2a6c3 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/DocumentationAbsencesModal.vue @@ -0,0 +1,45 @@ +<!-- Wrapper around DocumentationAbsences.vue --> +<!-- That uses it either as list item or as editable modal dialog. --> +<template> + <mobile-fullscreen-dialog v-model="popup" max-width="500px"> + <template #activator="activator"> + <!-- list view -> activate dialog --> + <documentation-absences + compact + v-bind="$attrs" + :dialog-activator="activator" + :value="value" + @input="$emit('input', $event)" + /> + </template> + <!-- dialog view -> deactivate dialog --> + <!-- cancel | save (through lesson-summary) --> + <documentation v-bind="$attrs" @close="popup = false" /> + </mobile-fullscreen-dialog> +</template> + +<script> +import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue"; +import DocumentationAbsences from "./DocumentationAbsences.vue"; +import Documentation from "../documentation/Documentation.vue"; + +export default { + name: "DocumentationAbsencesModal", + components: { + MobileFullscreenDialog, + Documentation, + DocumentationAbsences, + }, + data() { + return { + popup: false, + }; + }, + props: { + value: { + type: Array, + required: true, + }, + }, +}; +</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue index 3e7820f39eb4608ad9c3f4512c5934e491d48335..aa9176a8bbf4e3bb61c6641580e1eb5c5b96f980 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue @@ -4,11 +4,9 @@ import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip. import AbsenceReasonGroupSelect from "aleksis.apps.kolego/components/AbsenceReasonGroupSelect.vue"; import DialogCloseButton from "aleksis.core/components/generic/buttons/DialogCloseButton.vue"; import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue"; -import mutateMixin from "aleksis.core/mixins/mutateMixin.js"; +import updateParticipationMixin from "./updateParticipationMixin.js"; import deepSearchMixin from "aleksis.core/mixins/deepSearchMixin.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"; import PersonalNotes from "../personal_notes/PersonalNotes.vue"; import ExtraMarkChip from "../../extra_marks/ExtraMarkChip.vue"; @@ -31,7 +29,7 @@ export default { TardinessField, DialogCloseButton, }, - mixins: [documentationPartMixin, mutateMixin, deepSearchMixin], + mixins: [updateParticipationMixin, deepSearchMixin], data() { return { dialog: false, @@ -59,48 +57,6 @@ export default { }, }, methods: { - sendToServer(participations, field, value) { - let fieldValue; - - if (field === "absenceReason") { - fieldValue = { - absenceReason: value === "present" ? null : value, - }; - } else if (field === "tardiness") { - fieldValue = { - tardiness: value, - }; - } else { - console.error(`Wrong field '${field}' for sendToServer`); - return; - } - - this.mutate( - updateParticipationStatuses, - { - input: participations.map((participation) => ({ - id: participation.id, - ...fieldValue, - })), - }, - (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.tardiness = newStatus.tardiness; - participationStatus.isOptimistic = newStatus.isOptimistic; - }); - - return storedDocumentations; - }, - ); - }, handleMultipleAction(absenceReasonId) { this.loadSelected = true; this.sendToServer(this.selected, "absenceReason", absenceReasonId); diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ParticipationList.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ParticipationList.vue new file mode 100644 index 0000000000000000000000000000000000000000..0b3f650d805699c2bf18c217a35d85f736f0aa4f --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ParticipationList.vue @@ -0,0 +1,96 @@ +<script setup> +import AbsenceReasonGroupSelect from "aleksis.apps.kolego/components/AbsenceReasonGroupSelect.vue"; +</script> + +<template> + <v-list v-if="filteredParticipations.length"> + <v-divider /> + + <v-list-item-group :value="value" multiple @change="changeSelect"> + <template v-for="(participation, index) in filteredParticipations"> + <v-list-item + :key="`documentation-${documentation.id}-participation-${participation.id}`" + :value="participation.id" + v-bind="$attrs" + two-line + > + <template #default="{ active }"> + <v-list-item-action> + <v-checkbox :input-value="active" /> + </v-list-item-action> + <v-list-item-content> + <v-list-item-title> + {{ participation.person.fullName }} + </v-list-item-title> + <absence-reason-group-select + v-if="participation.absenceReason && !compact" + class="full-width" + allow-empty + empty-value="present" + :loadSelectedChip="loading" + :value="participation.absenceReason?.id || 'present'" + @input="sendToServer([participation], 'absenceReason', $event)" + /> + </v-list-item-content> + <v-list-item-action v-if="participation.absenceReason && compact"> + <absence-reason-group-select + allow-empty + empty-value="present" + :loadSelectedChip="loading" + :value="participation.absenceReason?.id || 'present'" + @input="sendToServer([participation], 'absenceReason', $event)" + /> + </v-list-item-action> + </template> + </v-list-item> + <v-divider + v-if="index < filteredParticipations.length - 1" + :key="index" + ></v-divider> + </template> + </v-list-item-group> + </v-list> +</template> + +<script> +import updateParticipationMixin from "./updateParticipationMixin"; + +export default { + name: "ParticipationList", + mixins: [updateParticipationMixin], + data() { + return { + loading: false, + participationDialogs: false, + isExpanded: false, + }; + }, + props: { + includePresent: { + type: Boolean, + required: false, + default: true, + }, + value: { + type: Array, + required: true, + }, + }, + computed: { + filteredParticipations() { + if (!this.includePresent) { + return this.documentation.participations.filter( + (p) => !!p.absenceReason, + ); + } else { + return this.documentation.participations; + } + }, + }, + methods: { + changeSelect(value) { + this.$emit("input", value); + }, + }, +}; +</script> diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql index dd50495972c020550dec310081f0c8c149473d9e..ce296d2b684c1f45928e1d94e8e584f7b845bb69 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql @@ -4,7 +4,6 @@ mutation updateParticipationStatuses( updateParticipationStatuses(input: $input) { items: participationStatuses { id - isOptimistic relatedDocumentation { id } @@ -14,7 +13,19 @@ mutation updateParticipationStatuses( shortName colour } + notesWithExtraMark { + id + extraMark { + id + showInCoursebook + } + } + notesWithNote { + id + note + } tardiness + isOptimistic } } } diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/selectParticipationMixin.js b/aleksis/apps/alsijil/frontend/components/coursebook/absences/selectParticipationMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..2d6d522692690c6def6aaa53e2dc0e7835019681 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/selectParticipationMixin.js @@ -0,0 +1,27 @@ +/** + * Mixin to provide passing through functionality for the events emitted when (de)selecting participations on the absence overview page + */ +export default { + emits: ["select", "deselect"], + methods: { + handleSelect(participation) { + this.$emit("select", participation); + }, + handleDeselect(participation) { + this.$emit("deselect", participation); + }, + }, + + computed: { + /** + * All necessary listeners bundled together to easily pass to child components + * @returns {{select: Function, deselect: Function}} + */ + selectListeners() { + return { + select: this.handleSelect, + deselect: this.handleDeselect, + }; + }, + }, +}; diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/sendToServerMixin.js b/aleksis/apps/alsijil/frontend/components/coursebook/absences/sendToServerMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..d75f72173b80315705e6cf3f86fe614caeafe051 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/sendToServerMixin.js @@ -0,0 +1,54 @@ +/** + * Mixin to provide shared functionality needed to send updated participation data to the server + */ +import { updateParticipationStatuses } from "./participationStatus.graphql"; +import mutateMixin from "aleksis.core/mixins/mutateMixin.js"; + +export default { + mixins: [mutateMixin], + methods: { + sendToServer(participations, field, value) { + let fieldValue; + + if (field === "absenceReason") { + fieldValue = { + absenceReason: value === "present" ? null : value, + }; + } else if (field === "tardiness") { + fieldValue = { + tardiness: value, + }; + } else { + console.error(`Wrong field '${field}' for sendToServer`); + return; + } + + this.mutate( + updateParticipationStatuses, + { + input: participations.map((participation) => ({ + id: participation?.id || participation, + ...fieldValue, + })), + }, + (storedDocumentations, incomingStatuses) => { + // TODO: what should happen here in places where there is more than one documentation? + 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.tardiness = newStatus.tardiness; + participationStatus.isOptimistic = newStatus.isOptimistic; + }); + + return storedDocumentations; + }, + ); + }, + }, +}; diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/updateParticipationMixin.js b/aleksis/apps/alsijil/frontend/components/coursebook/absences/updateParticipationMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..2ed2c537d8bda9d11b2757468f72ad19cb89b7c6 --- /dev/null +++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/updateParticipationMixin.js @@ -0,0 +1,9 @@ +/** + * Mixin to provide shared functionality needed to update participations + */ +import documentationPartMixin from "../documentation/documentationPartMixin"; +import sendToServerMixin from "./sendToServerMixin"; + +export default { + mixins: [documentationPartMixin, sendToServerMixin], +}; diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql index 73f0dc3c281a5e03cfbcd7d29dff9f8dc2e61d2b..f78545d8db703b193e313cb36f7293d419bbcd92 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql +++ b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql @@ -19,6 +19,7 @@ query documentationsForCoursebook( $dateStart: Date! $dateEnd: Date! $incomplete: Boolean + $absencesExist: Boolean ) { items: documentationsForCoursebook( own: $own @@ -27,6 +28,7 @@ query documentationsForCoursebook( dateStart: $dateStart dateEnd: $dateEnd incomplete: $incomplete + absencesExist: $absencesExist ) { id course { @@ -130,6 +132,17 @@ mutation createOrUpdateDocumentations($input: [DocumentationInputType]!) { shortName colour } + notesWithExtraMark { + id + extraMark { + id + showInCoursebook + } + } + notesWithNote { + id + note + } tardiness isOptimistic } diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue index 630085bd593f145e5a8a15c50d686a2e48cf6a20..a679830c89c0b3fd77a5d712efaea5931b24332c 100644 --- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue +++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/DocumentationModal.vue @@ -13,7 +13,11 @@ </template> <!-- dialog view -> deactivate dialog --> <!-- cancel | save (through lesson-summary) --> - <documentation v-bind="$attrs" @close="popup = false" /> + <documentation + v-bind="$attrs" + :extra-marks="extraMarks" + @close="popup = false" + /> </mobile-fullscreen-dialog> </template> diff --git a/aleksis/apps/alsijil/frontend/index.js b/aleksis/apps/alsijil/frontend/index.js index e73b19d83631d0953a269f037fc064257bb187d4..fb69e3eacf436cc238afef95e3777f9c218f9480 100644 --- a/aleksis/apps/alsijil/frontend/index.js +++ b/aleksis/apps/alsijil/frontend/index.js @@ -24,6 +24,7 @@ export default { name: "alsijil.coursebook", params: { filterType: "my", + pageType: "documentations", }, hash: "#" + DateTime.now().toISODate(), }; @@ -40,7 +41,7 @@ export default { }, children: [ { - path: ":filterType(my|all)/:objType(group|course|teacher)?/:objId(\\d+)?/", + path: ":pageType(documentations|absences)/:filterType(my|all)/:objType(group|course|teacher)?/:objId(\\d+)?/", component: () => import("./components/coursebook/Coursebook.vue"), name: "alsijil.coursebook", meta: { diff --git a/aleksis/apps/alsijil/frontend/messages/de.json b/aleksis/apps/alsijil/frontend/messages/de.json index c2b56f0159cb184f5b9fd49d8505fc4771bc2d92..8f70c6b9cd87fe46727c705bd85d9a041e2c3ea4 100644 --- a/aleksis/apps/alsijil/frontend/messages/de.json +++ b/aleksis/apps/alsijil/frontend/messages/de.json @@ -61,6 +61,8 @@ } } }, + "title_absences": "Kursbuch · Abwesenheiten", + "title_documentations": "Kursbuch", "title_plural": "Kursbuch" }, "excuse_types": { diff --git a/aleksis/apps/alsijil/frontend/messages/en.json b/aleksis/apps/alsijil/frontend/messages/en.json index 3ad99b40cadee92070cf5a2b1ab287343b2a8e6f..17769c47dbb20ca106eab4f05b046c51c158498a 100644 --- a/aleksis/apps/alsijil/frontend/messages/en.json +++ b/aleksis/apps/alsijil/frontend/messages/en.json @@ -45,6 +45,8 @@ "menu_title": "Coursebook", "page_title": "Coursebook for {name}", "title_plural": "Coursebook", + "title_documentations": "Coursebook", + "title_absences": "Coursebook · Absences", "status": { "available": "Documentation available", "missing": "Documentation missing", @@ -81,12 +83,18 @@ "missing": "Only show incomplete lessons", "groups": "Groups", "courses": "Courses", - "filter_for_obj": "Filter for group and course" + "filter_for_obj": "Filter for group and course", + "page_type": { + "documentations": "Show documentations", + "absences": "Show absences" + }, + "absences_exist": "Only show lessons with absent participants" }, "present_number": "{present}/{total} present", "no_data": "No lessons for the selected groups and courses in this period", "no_results": "No search results for {search}", "absences": { + "action_for_selected": "Mark selected participant as: | Mark {count} selected participants as", "title": "Register absences", "button": "Register absences", "summary": "Summary", diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py index 56406ce4e9ff8e8dc0a3e2995d15eef45d8044a7..63b4805f7009ba72fcfe42ab6bb6b17635aa2940 100644 --- a/aleksis/apps/alsijil/models.py +++ b/aleksis/apps/alsijil/models.py @@ -534,6 +534,7 @@ class Documentation(CalendarEvent): datetime_end: datetime, events: list, incomplete: Optional[bool] = False, + absences_exist: Optional[bool] = False, ) -> tuple: """Get all the documentations for the events. Create dummy documentations if none exist. @@ -563,10 +564,16 @@ class Documentation(CalendarEvent): doc = next(existing_documentations_event, None) if doc: - if incomplete and doc.topic: + if (incomplete and doc.topic) or ( + absences_exist + and ( + not doc.participations.all() + or not [d for d in doc.participations.all() if d.absence_reason] + ) + ): continue docs.append(doc) - else: + elif not absences_exist: if event_reference_obj.amends: if event_reference_obj.course: course = event_reference_obj.course diff --git a/aleksis/apps/alsijil/schema/__init__.py b/aleksis/apps/alsijil/schema/__init__.py index 681b0cd3796d2ee86c219a6e9bc4ec562e484752..49bfbe7abb2b04515c30ccd931c4d9d847578caf 100644 --- a/aleksis/apps/alsijil/schema/__init__.py +++ b/aleksis/apps/alsijil/schema/__init__.py @@ -51,6 +51,7 @@ class Query(graphene.ObjectType): date_start=graphene.Date(required=True), date_end=graphene.Date(required=True), incomplete=graphene.Boolean(required=False), + absences_exist=graphene.Boolean(required=False), ) groups_by_person = FilterOrderList(GroupType, person=graphene.ID()) @@ -81,6 +82,7 @@ class Query(graphene.ObjectType): obj_type=None, obj_id=None, incomplete=False, + absences_exist=False, **kwargs, ): if ( @@ -131,6 +133,7 @@ class Query(graphene.ObjectType): datetime.combine(date_end, datetime.max.time()), events, incomplete, + absences_exist, ) return docs + dummies