Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • AlekSIS/official/AlekSIS-App-Alsijil
  • sunweaver/AlekSIS-App-Alsijil
  • 8tincsoVluke/AlekSIS-App-Alsijil
  • perfreicpo/AlekSIS-App-Alsijil
  • noifobarep/AlekSIS-App-Alsijil
  • 7ingannisdo/AlekSIS-App-Alsijil
  • unmruntartpa/AlekSIS-App-Alsijil
  • balrorebta/AlekSIS-App-Alsijil
  • comliFdifwa/AlekSIS-App-Alsijil
  • 3ranaadza/AlekSIS-App-Alsijil
10 results
Show changes
Showing
with 2113 additions and 0 deletions
<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
:load-selected-chip="loading"
:custom-absence-reasons="absenceReasons"
: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
:load-selected-chip="loading"
:custom-absence-reasons="absenceReasons"
: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>
<script>
export default {
name: "TardinessChip",
props: {
tardiness: {
type: Number,
required: false,
default: 0,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
extends: "v-chip",
};
</script>
<template>
<v-chip dense outlined v-bind="$attrs" v-on="$listeners">
<v-avatar left>
<v-icon small>mdi-clock-alert-outline</v-icon>
</v-avatar>
<slot name="prepend" />
<slot>
{{ $tc("alsijil.personal_notes.minutes_late", tardiness) }}
</slot>
<slot name="append" />
<v-avatar right v-if="loading">
<v-progress-circular indeterminate :size="16" :width="2" />
</v-avatar>
</v-chip>
</template>
<script>
import { DateTime } from "luxon";
import documentationPartMixin from "../documentation/documentationPartMixin";
import ConfirmDialog from "aleksis.core/components/generic/dialogs/ConfirmDialog.vue";
import formRulesMixin from "aleksis.core/mixins/formRulesMixin.js";
export default {
name: "TardinessField",
components: { ConfirmDialog },
mixins: [documentationPartMixin, formRulesMixin],
props: {
value: {
type: Number,
default: null,
required: false,
},
participations: {
type: Array,
required: true,
},
},
computed: {
lessonLength() {
const lessonStart = DateTime.fromISO(this.documentation.datetimeStart);
const lessonEnd = DateTime.fromISO(this.documentation.datetimeEnd);
let diff = lessonEnd.diff(lessonStart, "minutes");
return diff.toObject().minutes;
},
defaultTimes() {
const lessonStart = DateTime.fromISO(this.documentation.datetimeStart);
const lessonEnd = DateTime.fromISO(this.documentation.datetimeEnd);
const now = DateTime.now();
let current = [];
if (now >= lessonStart && now <= lessonEnd) {
const diff = parseInt(
now.diff(lessonStart, "minutes").toObject().minutes,
);
current.push({
text: diff,
value: diff,
current: true,
});
}
return current.concat([
{
text: 5,
value: 5,
},
{
text: 10,
value: 10,
},
{
text: 15,
value: 15,
},
]);
},
},
methods: {
lessonLengthRule(time) {
return (
time == null ||
time <= this.lessonLength ||
this.$t("alsijil.personal_notes.lesson_length_exceeded")
);
},
saveValue(value) {
this.$emit("input", value);
this.previousValue = value;
},
confirm() {
this.saveValue(0);
},
cancel() {
this.saveValue(this.previousValue);
},
processValueObjectOptional(value) {
if (Object.hasOwn(value, "value")) {
return value.value;
}
return value;
},
set(newValue) {
newValue = this.processValueObjectOptional(newValue);
if (!newValue || parseInt(newValue) === 0) {
// this is a DELETE action, show the dialog, ...
this.showDeleteConfirm = true;
return;
}
this.saveValue(parseInt(newValue));
},
},
data() {
return {
showDeleteConfirm: false,
previousValue: 0,
};
},
mounted() {
this.previousValue = this.value;
},
};
</script>
<template>
<v-combobox
outlined
class="mt-1"
prepend-inner-icon="mdi-clock-alert-outline"
:suffix="$t('time.minutes')"
:label="$t('alsijil.personal_notes.tardiness')"
:rules="
$rules()
.isANumber.isAWholeNumber.isGreaterThan(0)
.build([lessonLengthRule])
.map((f) => (v) => f(this.processValueObjectOptional(v)))
"
:items="defaultTimes"
:value="value"
@change="set($event)"
v-bind="$attrs"
>
<template #item="{ item }">
<v-list-item-icon v-if="item.current">
<v-icon>mdi-shimmer</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{
$tc(
item.current
? "alsijil.personal_notes.minutes_late_current"
: "time.minutes_n",
item.value,
)
}}
</v-list-item-title>
</v-list-item-content>
</template>
<template #append>
<confirm-dialog
v-model="showDeleteConfirm"
@confirm="confirm"
@cancel="cancel"
>
<template #title>
{{ $t("alsijil.personal_notes.confirm_delete") }}
</template>
<template #text>
{{
$t("alsijil.personal_notes.confirm_delete_tardiness", {
tardiness: previousValue,
name: participations.map((p) => p.person.firstName).join(", "),
})
}}
</template>
</confirm-dialog>
</template>
</v-combobox>
</template>
<style scoped>
.mt-n1-5 {
margin-top: -6px;
}
</style>
# Uses core persons query
query persons {
allPersons: absenceCreationPersons {
id
fullName
}
}
query lessonsForPersons($persons: [ID]!, $start: DateTime!, $end: DateTime!) {
items: lessonsForPersons(persons: $persons, start: $start, end: $end) {
id
lessons {
id
datetimeStart
datetimeEnd
course {
id
name
}
subject {
id
name
shortName
colourFg
colourBg
}
}
}
}
# Use absencesInputType?
mutation createAbsencesForPersons(
$persons: [ID]!
$start: DateTime!
$end: DateTime!
$comment: String
$reason: ID!
) {
createAbsencesForPersons(
persons: $persons
start: $start
end: $end
comment: $comment
reason: $reason
) {
ok
items: participationStatuses {
id
isOptimistic
relatedDocumentation {
id
}
absenceReason {
id
name
shortName
colour
}
}
}
}
mutation updateParticipationStatuses(
$input: [BatchPatchParticipationStatusInput]!
) {
updateParticipationStatuses(input: $input) {
items: participationStatuses {
id
relatedDocumentation {
id
}
absenceReason {
id
name
shortName
colour
}
notesWithExtraMark {
id
extraMark {
id
showInCoursebook
}
}
notesWithNote {
id
note
}
tardiness
isOptimistic
}
}
}
mutation touchDocumentation($documentationId: ID!) {
touchDocumentation(documentationId: $documentationId) {
items: documentation {
id
participations {
id
person {
id
firstName
fullName
}
absenceReason {
id
name
shortName
colour
}
notesWithExtraMark {
id
extraMark {
id
showInCoursebook
}
}
notesWithNote {
id
note
}
tardiness
isOptimistic
}
}
}
}
mutation extendParticipationStatuses($input: [ID]!) {
extendParticipationStatuses(input: $input) {
items: participations {
id
relatedDocumentation {
id
}
absenceReason {
id
name
shortName
colour
}
}
absences {
id
}
}
}
/**
* 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,
};
},
},
};
/**
* Mixin to provide shared functionality needed to send updated participation data to the server
*/
import { createPersonalNotes } from "../personal_notes/personal_notes.graphql";
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 if (field === "extraMark") {
// Too much different logic → own method
this.addExtraMarks(participations, value);
return;
} else {
console.error(`Wrong field '${field}' for sendToServer`);
return;
}
this.beforeSendToServer(participations, field, value);
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;
});
this.duringUpdateSendToServer(
participations,
field,
value,
incomingStatuses,
);
return storedDocumentations;
},
);
this.afterSendToServer(participations, field, value);
},
addExtraMarks(participations, extraMarkId) {
// Get all participation statuses without this extra mark and get the respective person ids
const participants = participations
.filter(
(participation) =>
!participation.notesWithExtraMark.some(
(note) => note.extraMark.id === extraMarkId,
),
)
.map((participation) => participation.person.id);
// CREATE new personal note
this.mutate(
createPersonalNotes,
{
input: participants.map((person) => ({
documentation: this.documentation.id,
person: person,
extraMark: extraMarkId,
})),
},
(storedDocumentations, incomingPersonalNotes) => {
const documentation = storedDocumentations.find(
(doc) => doc.id === this.documentation.id,
);
incomingPersonalNotes.forEach((note, index) => {
const participationStatus = documentation.participations.find(
(part) => part.person.id === participants[index],
);
participationStatus.notesWithExtraMark.push(note);
});
return storedDocumentations;
},
);
},
beforeSendToServer(_participations, _field, _value) {
// Noop hook
},
duringUpdateSendToServer(_participations, _field, _value, _incoming) {
// Noop hook
},
afterSendToServer(_participations, _field, _value) {
// Noop hook
},
},
};
/**
* Mixin to provide shared functionality needed to update participations
*/
import documentationPartMixin from "../documentation/documentationPartMixin";
import sendToServerMixin from "./sendToServerMixin";
export default {
mixins: [documentationPartMixin, sendToServerMixin],
};
query groupsByPerson {
groups: groupsByPerson {
id
name
}
}
query coursesOfPerson {
courses: coursesOfPerson {
id
name
}
}
query documentationsForCoursebook(
$own: Boolean!
$objId: ID
$objType: String
$dateStart: Date!
$dateEnd: Date!
$incomplete: Boolean
$absencesExist: Boolean
) {
items: documentationsForCoursebook(
own: $own
objId: $objId
objType: $objType
dateStart: $dateStart
dateEnd: $dateEnd
incomplete: $incomplete
absencesExist: $absencesExist
) {
id
course {
id
name
}
amends {
id
title
slotNumberStart
slotNumberEnd
amends {
id
title
slotNumberStart
slotNumberEnd
teachers {
id
shortName
fullName
avatarContentUrl
}
subject {
id
name
shortName
colourFg
colourBg
}
}
cancelled
}
teachers {
id
shortName
fullName
avatarContentUrl
}
subject {
id
name
shortName
colourFg
colourBg
}
participations {
id
person {
id
firstName
fullName
}
absenceReason {
id
name
shortName
colour
}
notesWithExtraMark {
id
extraMark {
id
showInCoursebook
}
}
notesWithNote {
id
note
}
tardiness
isOptimistic
}
topic
homework
groupNote
datetimeStart
datetimeEnd
dateStart
dateEnd
oldId
canEdit
futureNotice
futureNoticeParticipationStatus
canEditParticipationStatus
canViewParticipationStatus
}
}
mutation createOrUpdateDocumentations($input: [DocumentationInputType]!) {
createOrUpdateDocumentations(input: $input) {
items: documentations {
id
topic
homework
groupNote
oldId
participations {
id
person {
id
firstName
fullName
}
absenceReason {
id
name
shortName
colour
}
notesWithExtraMark {
id
extraMark {
id
showInCoursebook
}
}
notesWithNote {
id
note
}
tardiness
isOptimistic
}
subject {
id
name
shortName
colourFg
colourBg
}
}
}
}
<template>
<v-card :class="{ 'my-1 full-width': true, 'd-flex flex-column': !compact }">
<v-card-title v-if="!compact">
<lesson-information
v-bind="{ ...$attrs, ...documentationPartProps }"
:is-create="false"
:gql-patch-mutation="documentationsMutation"
/>
</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"
:is-create="false"
:gql-patch-mutation="documentationsMutation"
/>
<lesson-summary
ref="summary"
v-bind="{ ...$attrs, ...documentationPartProps }"
:is-create="false"
:gql-patch-mutation="documentationsMutation"
@open="$emit('open')"
@loading="loading = $event"
@save="$emit('close')"
@dirty="dirty = $event"
/>
<lesson-notes v-bind="documentationPartProps" />
</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"
:disabled="!dirty"
/>
<cancel-button
v-if="!documentation.canEdit"
i18n-key="actions.close"
@click="$emit('close')"
/>
</v-card-actions>
</v-card>
</template>
<script>
import LessonInformation from "./LessonInformation.vue";
import LessonSummary from "./LessonSummary.vue";
import LessonNotes from "./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 "./documentationPartMixin";
export default {
name: "Documentation",
components: {
LessonInformation,
LessonSummary,
LessonNotes,
SaveButton,
CancelButton,
},
emits: ["open", "close", "dirty"],
mixins: [documentationPartMixin],
data() {
return {
loading: false,
documentationsMutation: createOrUpdateDocumentations,
dirty: false,
};
},
methods: {
save() {
this.$refs.summary.save();
this.$emit("close");
},
},
watch: {
dirty(dirty) {
this.$emit("dirty", dirty);
},
},
};
</script>
<style scoped>
.main-body {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1em;
}
.vertical {
grid-template-columns: minmax(0, 1fr);
}
</style>
<template>
<v-card outlined dense rounded="lg" v-bind="$attrs" v-on="$listeners">
<template v-if="documentation.topic">
<div class="font-weight-medium mr-2">
{{ $t("alsijil.coursebook.summary.topic.label") }}:
</div>
<div class="text-truncate">{{ documentation.topic }}</div>
</template>
<template v-if="documentation.homework">
<div class="font-weight-medium mr-2">
{{ $t("alsijil.coursebook.summary.homework.label") }}:
</div>
<div class="text-truncate">{{ documentation.homework }}</div>
</template>
<template v-if="documentation.groupNote">
<div class="font-weight-medium mr-2">
{{ $t("alsijil.coursebook.summary.group_note.label") }}:
</div>
<div class="text-truncate">{{ documentation.groupNote }}</div>
</template>
</v-card>
</template>
<script>
export default {
name: "DocumentationCompactDetails",
props: {
documentation: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div v-bind="$attrs" v-on="$listeners">
<v-card outlined dense rounded="lg" class="mb-2">
<v-card-title class="text-subtitle-2 pb-1 font-weight-medium">
{{ $t("alsijil.coursebook.summary.topic.label") }}
</v-card-title>
<v-card-text>{{ documentation.topic || "" }}</v-card-text>
</v-card>
<v-card outlined dense rounded="lg" class="mb-2">
<v-card-title class="text-subtitle-2 pb-1 font-weight-medium">
{{ $t("alsijil.coursebook.summary.homework.label") }}
</v-card-title>
<v-card-text>
{{
documentation.homework ||
$t("alsijil.coursebook.summary.homework.empty_yet")
}}
</v-card-text>
</v-card>
<v-card outlined dense rounded="lg">
<v-card-title class="text-subtitle-2 pb-1 font-weight-medium">
{{ $t("alsijil.coursebook.summary.group_note.label") }}
</v-card-title>
<v-card-text>
{{
documentation.groupNote ||
$t("alsijil.coursebook.summary.group_note.empty")
}}
</v-card-text>
</v-card>
</div>
</template>
<script>
export default {
name: "DocumentationFullDetails",
props: {
documentation: {
type: Object,
required: true,
},
},
};
</script>
<template>
<v-card class="my-2 full-width">
<div class="full-width d-flex flex-column align-stretch flex-md-row">
<v-card-text>
<v-skeleton-loader
type="avatar, heading, chip"
class="d-flex full-width align-center gap"
height="100%"
/>
</v-card-text>
<v-card-text>
<v-skeleton-loader
type="heading@2"
class="d-flex full-width align-center gap"
height="100%"
/>
</v-card-text>
<v-card-text>
<v-skeleton-loader
type="chip@3"
class="d-flex full-width align-center justify-end gap"
height="100%"
/>
</v-card-text>
</div>
</v-card>
</template>
<script>
export default {
name: "DocumentationLoader",
};
</script>
<!-- Wrapper around Documentation.vue -->
<!-- That uses it either as list item or as editable modal dialog. -->
<template>
<mobile-fullscreen-dialog
v-model="popup"
max-width="500px"
:persistent="dirty"
>
<template #activator="activator">
<!-- list view -> activate dialog -->
<documentation
compact
v-bind="$attrs"
:dialog-activator="activator"
:extra-marks="extraMarks"
:absence-reasons="absenceReasons"
:subjects="subjects"
/>
</template>
<!-- dialog view -> deactivate dialog -->
<!-- cancel | save (through lesson-summary) -->
<documentation
v-bind="$attrs"
:extra-marks="extraMarks"
:absence-reasons="absenceReasons"
:subjects="subjects"
@close="popup = false"
@dirty="dirty = $event"
/>
</mobile-fullscreen-dialog>
</template>
<script>
import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue";
import Documentation from "./Documentation.vue";
export default {
name: "DocumentationModal",
components: {
MobileFullscreenDialog,
Documentation,
},
data() {
return {
popup: false,
dirty: false,
};
},
props: {
extraMarks: {
type: Array,
required: true,
},
absenceReasons: {
type: Array,
required: true,
},
subjects: {
type: Array,
required: true,
},
},
};
</script>
<template>
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<v-icon
:color="currentStatus?.color"
class="mr-md-4"
v-on="on"
v-bind="attrs"
>{{ currentStatus?.icon }}</v-icon
>
</template>
<span>{{ currentStatus?.text }}</span>
</v-tooltip>
</template>
<script>
import documentationPartMixin from "./documentationPartMixin";
import { DateTime } from "luxon";
export default {
name: "DocumentationStatus",
mixins: [documentationPartMixin],
data() {
return {
statusChoices: [
{
name: "available",
text: this.$t("alsijil.coursebook.status.available"),
icon: "$success",
color: "success",
},
{
name: "missing",
text: this.$t("alsijil.coursebook.status.missing"),
icon: "$warning",
color: "error",
},
{
name: "running",
text: this.$t("alsijil.coursebook.status.running"),
icon: "mdi-play-outline",
color: "warning",
},
{
name: "substitution",
text: this.$t("alsijil.coursebook.status.substitution"),
icon: "$info",
color: "warning",
},
{
name: "cancelled",
text: this.$t("alsijil.coursebook.status.cancelled"),
icon: "mdi-cancel",
color: "error",
},
{
name: "pending",
text: this.$t("alsijil.coursebook.status.pending"),
icon: "mdi-clipboard-clock-outline",
color: "blue",
},
],
statusTimeout: null,
currentStatusName: "",
};
},
computed: {
currentStatus() {
return this.statusChoices.find((s) => s.name === this.currentStatusName);
},
documentationDateTimeStart() {
return DateTime.fromISO(this.documentation.datetimeStart);
},
documentationDateTimeEnd() {
return DateTime.fromISO(this.documentation.datetimeEnd);
},
},
methods: {
updateStatus() {
if (this.documentation?.amends.cancelled) {
this.currentStatusName = "cancelled";
} else if (this.documentation.topic) {
this.currentStatusName = "available";
} else if (DateTime.now() > this.documentationDateTimeEnd) {
this.currentStatusName = "missing";
} else if (this.documentation?.amends.amends) {
this.currentStatusName = "substitution";
} else if (
DateTime.now() > this.documentationDateTimeStart &&
DateTime.now() < this.documentationDateTimeEnd
) {
this.currentStatusName = "running";
} else {
this.currentStatusName = "pending";
}
},
},
watch: {
documentation: {
handler() {
this.updateStatus();
},
deep: true,
},
},
mounted() {
this.updateStatus();
if (DateTime.now() < this.documentationDateTimeStart) {
this.statusTimeout = setTimeout(
this.updateStatus,
this.documentationDateTimeStart
.diff(DateTime.now(), "seconds")
.toObject(),
);
} else if (DateTime.now() < this.documentationDateTimeEnd) {
this.statusTimeout = setTimeout(
this.updateStatus,
this.documentationDateTimeEnd
.diff(DateTime.now(), "seconds")
.toObject(),
);
}
},
beforeDestroy() {
if (this.statusTimeout) {
clearTimeout(this.statusTimeout);
}
},
};
</script>
<script setup>
import DocumentationStatus from "./DocumentationStatus.vue";
import PersonChip from "aleksis.core/components/person/PersonChip.vue";
import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
import SubjectChipSelectField from "aleksis.apps.cursus/components/SubjectChipSelectField.vue";
</script>
<template>
<div :class="{ 'full-width grid': true, 'large-grid': largeGrid }">
<div class="d-flex">
<documentation-status v-if="compact" v-bind="documentationPartProps" />
<div
v-if="documentation.amends?.slotNumberStart"
:class="{
'text-h5 mr-3 d-flex flex-column justify-center slot-number': true,
'ml-2 slot-number-mobile': !largeGrid,
}"
>
<span
v-if="
documentation.amends?.slotNumberStart ==
documentation.amends?.slotNumberEnd
"
>
{{ documentation.amends?.slotNumberStart }}.
</span>
<span v-else>
{{ documentation.amends?.slotNumberStart }}.–{{
documentation.amends?.slotNumberEnd
}}.
</span>
</div>
<div :class="{ 'text-right d-flex flex-column fit-content': largeGrid }">
<time :datetime="documentation.datetimeStart" class="text-no-wrap">
{{ $d(toDateTime(documentation.datetimeStart), "shortTime") }}
</time>
<span v-if="!largeGrid"></span>
<time :datetime="documentation.datetimeEnd" class="text-no-wrap">
{{ $d(toDateTime(documentation.datetimeEnd), "shortTime") }}
</time>
</div>
</div>
<span
:class="{
'text-right': !largeGrid,
'text-subtitle-1': largeGrid,
'font-weight-medium': largeGrid,
}"
>
{{
documentation.course?.name ||
documentation.amends.title ||
documentation.amends.amends.title
}}
</span>
<div
:class="{
'd-flex align-center flex-wrap gap': true,
'justify-center': largeGrid,
'justify-start': !largeGrid,
}"
>
<template v-if="documentation.subject">
<subject-chip-select-field
v-if="documentation.canEdit"
:items="subjects"
:value="documentation.subject"
:disabled="loading"
:loading="loading"
@input="editSubject"
/>
<subject-chip
v-else
:subject="documentation.subject"
:disabled="loading"
/>
</template>
<subject-chip
v-if="
documentation?.amends?.amends?.subject &&
documentation.amends.amends.subject.id !== documentation.subject.id
"
:subject="documentation.amends.amends.subject"
v-bind="compact ? dialogActivator.attrs : {}"
v-on="compact ? dialogActivator.on : {}"
class="text-decoration-line-through"
disabled
/>
</div>
<div
:class="{
'd-flex align-center flex-wrap gap': true,
'justify-end': !largeGrid,
}"
>
<person-chip
v-for="teacher in documentation.teachers"
:key="documentation.id + '-teacher-' + teacher.id"
:person="teacher"
:no-link="compact"
v-bind="compact ? dialogActivator.attrs : {}"
v-on="compact ? dialogActivator.on : {}"
/>
<person-chip
v-for="teacher in amendedTeachers"
:key="documentation.id + '-amendedTeacher-' + teacher.id"
:person="teacher"
:no-link="compact"
v-bind="compact ? dialogActivator.attrs : {}"
v-on="compact ? dialogActivator.on : {}"
class="text-decoration-line-through"
disabled
/>
</div>
</div>
</template>
<script>
import { DateTime } from "luxon";
import createOrPatchMixin from "aleksis.core/mixins/createOrPatchMixin.js";
import documentationPartMixin from "./documentationPartMixin";
import documentationCacheUpdateMixin from "./documentationCacheUpdateMixin";
export default {
name: "LessonInformation",
mixins: [
createOrPatchMixin,
documentationCacheUpdateMixin,
documentationPartMixin,
],
methods: {
toDateTime(dateString) {
return DateTime.fromISO(dateString);
},
editSubject(subject) {
this.createOrPatch([
{
id: this.documentation.id,
subject: subject.id,
},
]);
},
},
computed: {
largeGrid() {
return this.compact && !this.$vuetify.breakpoint.mobile;
},
amendedTeachers() {
if (
this.documentation?.amends?.amends?.teachers &&
this.documentation.amends.amends.teachers.length
) {
return this.documentation.amends.amends.teachers.filter(
(at) => !this.documentation.teachers.includes((t) => t.id === at.id),
);
}
return [];
},
},
};
</script>
<style scoped>
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 1em;
align-content: start;
}
.large-grid {
grid-template-columns: 1fr 1fr 1fr 1fr;
align-content: unset;
}
.grid:last-child {
justify-self: end;
justify-content: end;
}
.fit-content {
width: fit-content;
}
.gap {
gap: 0.25em;
}
.slot-number {
font-size: 1.6rem !important;
font-weight: 300;
line-height: 1.6rem;
}
.slot-number-mobile {
font-size: 1.4rem !important;
line-height: 1.4rem;
}
</style>
<script setup>
import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue";
import ExtraMarkChip from "../../extra_marks/ExtraMarkChip.vue";
import ExtraMarksNote from "../personal_notes/ExtraMarksNote.vue";
import TardinessChip from "../absences/TardinessChip.vue";
import PersonalNoteChip from "../personal_notes/PersonalNoteChip.vue";
import TextNoteCard from "../personal_notes/TextNoteCard.vue";
</script>
<template>
<div>
<div
class="d-flex align-center justify-space-between justify-md-end flex-wrap gap"
v-if="compact || documentation.canViewParticipationStatus"
>
<v-chip
dense
color="success"
outlined
v-if="total > 0 && documentation.canViewParticipationStatus"
>
{{
$t("alsijil.coursebook.participations.present_number", {
present,
total,
})
}}
</v-chip>
<v-chip
dense
color="success"
outlined
@click="$emit('open')"
v-bind="dialogActivator.attrs"
v-on="dialogActivator.on"
v-else-if="
total == 1 &&
present == 1 &&
!documentation.canViewParticipationStatus
"
>
{{ $t("alsijil.coursebook.participations.present") }}
</v-chip>
<template v-if="documentation.canViewParticipationStatus">
<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>
</template>
<template v-else>
<absence-reason-chip
v-for="[reasonId, participations] in Object.entries(absences)"
:key="'reason-' + reasonId"
:absence-reason="participations[0].absenceReason"
dense
@click="$emit('open')"
v-bind="dialogActivator.attrs"
v-on="dialogActivator.on"
/>
</template>
<template v-if="documentation.canViewParticipationStatus">
<extra-mark-chip
v-for="[markId, [mark, ...participations]] in Object.entries(
extraMarkChips,
)"
:key="'extra-mark-' + markId"
:extra-mark="mark"
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>
</extra-mark-chip>
</template>
<template v-else>
<extra-mark-chip
v-for="[markId, [mark, ...participations]] in Object.entries(
extraMarkChips,
)"
:key="'extra-mark-' + markId"
:extra-mark="mark"
dense
@click="$emit('open')"
v-bind="dialogActivator.attrs"
v-on="dialogActivator.on"
/>
</template>
<template v-if="documentation.canViewParticipationStatus">
<tardiness-chip v-if="tardyParticipations.length > 0">
<template #default>
{{ $t("alsijil.personal_notes.late") }}
</template>
<template #append>
<span
>:
{{
tardyParticipations
.slice(0, 5)
.map((participation) => participation.person.firstName)
.join(", ")
}}
<span v-if="tardyParticipations.length > 5">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+{{ tardyParticipations.length - 5 }}
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
</span>
</span>
</template>
</tardiness-chip>
</template>
<template v-else>
<tardiness-chip
v-if="tardyParticipations.length > 0"
:tardiness="
tardyParticipations.length == 1
? tardyParticipations[0].tardiness
: undefined
"
@click="$emit('open')"
v-bind="dialogActivator.attrs"
v-on="dialogActivator.on"
/>
</template>
<template v-if="!documentation.canViewParticipationStatus && total == 1">
<personal-note-chip
v-for="note in documentation?.participations[0]?.notesWithNote"
:key="'text-note-note-' + note.id"
:note="note"
@click="$emit('open')"
v-bind="dialogActivator.attrs"
v-on="dialogActivator.on"
/>
</template>
<manage-students-trigger
v-if="documentation.canEditParticipationStatus"
:label-key="manageStudentsLabelKey"
v-bind="documentationPartProps"
/>
</div>
<!-- not compact -->
<div class="main-body" v-else>
<template
v-if="
tardyParticipations.length > 0 || Object.entries(absences).length > 0
"
>
<v-divider />
<div
class="d-flex align-center justify-space-between justify-md-end flex-wrap gap"
>
<tardiness-chip
v-if="tardyParticipations.length > 0"
:tardiness="
tardyParticipations.length == 1
? tardyParticipations[0].tardiness
: undefined
"
/>
<absence-reason-chip
v-for="[reasonId, participations] in Object.entries(absences)"
:key="'reason-' + reasonId"
:absence-reason="participations[0].absenceReason"
dense
/>
</div>
</template>
<template v-if="total == 1">
<v-divider />
<extra-marks-note
v-bind="documentationPartProps"
:participation="documentation?.participations[0]"
:value="documentation?.participations[0].notesWithExtraMark"
:disabled="true"
/>
</template>
<template
v-if="
total == 1 &&
documentation?.participations[0]?.notesWithNote.length > 0
"
>
<v-divider />
<div>
<text-note-card
v-for="note in documentation?.participations[0]?.notesWithNote"
:key="'text-note-note-' + note.id"
:note="note"
/>
</div>
</template>
</div>
</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;
},
/**
* Return the number of present people.
*/
present() {
return this.documentation.participations.filter(
(p) => p.absenceReason === null,
).length;
},
/**
* Get all course attendants who have an absence reason, grouped by that reason.
*/
absences() {
return Object.groupBy(
this.documentation.participations.filter(
(p) => p.absenceReason !== null,
),
({ absenceReason }) => absenceReason.id,
);
},
/**
* Parse and combine all extraMark notes.
*
* Notes with extraMarks are grouped by ExtraMark. ExtraMarks with the showInCoursebook property set to false are ignored.
* @return An object where the keys are extraMark IDs and the values have the structure [extraMark, note1, note2, ..., noteN]
*/
extraMarkChips() {
// Apply the inner function to each participation, with value being the resulting object
return this.documentation.participations.reduce((value, p) => {
// Go through every extra mark of this participation
for (const { extraMark } of p.notesWithExtraMark) {
// Only proceed if the extraMark should be displayed here
if (!extraMark.showInCoursebook) {
continue;
}
// value[extraMark.id] is an Array with the structure [extraMark, note1, note2, ..., noteN]
if (value[extraMark.id]) {
value[extraMark.id].push(p);
} else {
value[extraMark.id] = [
this.extraMarks.find((e) => e.id === extraMark.id),
p,
];
}
}
return value;
}, {});
},
/**
* Return a list Participations with a set tardiness
*/
tardyParticipations() {
return this.documentation.participations.filter((p) => p.tardiness);
},
manageStudentsLabelKey() {
if (this.total == 0) {
return "alsijil.coursebook.notes.show_list";
}
return "";
},
},
};
</script>
<style scoped>
.gap {
gap: 0.25em;
}
.main-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1em;
}
</style>
<template>
<div>
<!-- compact -->
<div
class="d-flex flex-column flex-md-row align-stretch align-md-center gap justify-start fill-height"
v-if="compact"
>
<documentation-compact-details
v-bind="dialogActivator.attrs"
v-on="dialogActivator.on"
v-if="
!documentation.canEdit &&
(documentation.topic ||
documentation.homework ||
documentation.groupNote)
"
:documentation="documentation"
@click="$emit('open')"
:class="{
'flex-grow-1 min-width pa-1 read-only-grid': true,
'full-width': $vuetify.breakpoint.mobile,
}"
/>
<v-alert
v-else-if="documentation.futureNotice"
type="warning"
outlined
class="min-width flex-grow-1 mb-0"
>
{{ $t("alsijil.coursebook.notices.future") }}
</v-alert>
<v-alert
v-else-if="!documentation.canEdit"
type="info"
outlined
class="min-width flex-grow-1 mb-0"
>
{{ $t("alsijil.coursebook.notices.no_entry") }}
</v-alert>
<v-text-field
v-if="documentation.canEdit"
:class="{
'flex-grow-1 min-width': true,
'full-width': $vuetify.breakpoint.mobile,
}"
hide-details
outlined
:label="$t('alsijil.coursebook.summary.topic.label')"
:value="documentation.topic"
@input="topic = $event"
@focusout="save"
@keydown.enter="saveAndBlur"
:loading="loading"
>
<template #append>
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<v-scroll-x-transition>
<v-icon
v-if="appendIcon"
:color="appendIconColor"
v-on="on"
v-bind="attrs"
>{{ appendIcon }}</v-icon
>
</v-scroll-x-transition>
</template>
<span>{{ appendIconTooltip }}</span>
</v-tooltip>
</template>
</v-text-field>
<div
:class="{
'flex-grow-1 max-width': true,
'full-width': $vuetify.breakpoint.mobile,
}"
v-if="documentation.canEdit"
>
<v-card
v-bind="dialogActivator.attrs"
v-on="dialogActivator.on"
outlined
@click="$emit('open')"
class="max-width grid-layout pa-1"
dense
rounded="lg"
>
<span class="max-width text-truncate">{{
documentation.homework
? $t("alsijil.coursebook.summary.homework.value", documentation)
: $t("alsijil.coursebook.summary.homework.empty")
}}</span>
<v-icon right class="float-right">{{ homeworkIcon }}</v-icon>
<span class="max-width text-truncate">{{
documentation.groupNote
? $t("alsijil.coursebook.summary.group_note.value", documentation)
: $t("alsijil.coursebook.summary.group_note.empty")
}}</span>
<v-icon right class="float-right">{{ groupNoteIcon }}</v-icon>
</v-card>
</div>
</div>
<!-- not compact -->
<!-- Are focusout & enter enough trigger? -->
<v-text-field
filled
v-if="!compact && documentation.canEdit"
:label="$t('alsijil.coursebook.summary.topic.label')"
:value="documentation.topic"
@input="topic = $event"
/>
<v-textarea
filled
auto-grow
rows="3"
clearable
v-if="!compact && documentation.canEdit"
:label="$t('alsijil.coursebook.summary.homework.label')"
:value="documentation.homework"
@input="homework = $event ? $event : ''"
/>
<v-textarea
filled
auto-grow
rows="3"
clearable
v-if="!compact && documentation.canEdit"
:label="$t('alsijil.coursebook.summary.group_note.label')"
:value="documentation.groupNote"
@input="groupNote = $event ? $event : ''"
/>
<documentation-full-details
v-if="!compact && !documentation.canEdit"
:documentation="documentation"
/>
</div>
</template>
<script setup>
import DocumentationCompactDetails from "./DocumentationCompactDetails.vue";
import DocumentationFullDetails from "./DocumentationFullDetails.vue";
</script>
<script>
import createOrPatchMixin from "aleksis.core/mixins/createOrPatchMixin.js";
import documentationPartMixin from "./documentationPartMixin";
import documentationCacheUpdateMixin from "./documentationCacheUpdateMixin";
export default {
name: "LessonSummary",
mixins: [
createOrPatchMixin,
documentationCacheUpdateMixin,
documentationPartMixin,
],
emits: ["open", "dirty"],
data() {
return {
topic: null,
homework: null,
groupNote: null,
appendIcon: null,
topicError: null,
};
},
methods: {
handleAppendIconSuccess() {
this.topicError = null;
this.appendIcon = "$success";
setTimeout(() => {
this.appendIcon = "";
}, 3000);
},
save() {
if (
this.topic !== null ||
this.homework !== null ||
this.groupNote !== null
) {
this.createOrPatch([
{
id: this.documentation.id,
...(this.topic !== null && { topic: this.topic }),
...(this.homework !== null && { homework: this.homework }),
...(this.groupNote !== null && { groupNote: this.groupNote }),
},
]);
this.topic = null;
this.homework = null;
this.groupNote = null;
}
},
saveAndBlur(event) {
this.save();
event.target.blur();
},
handleError(error) {
this.appendIcon = "$error";
this.topicError = error;
},
},
computed: {
homeworkIcon() {
if (this.documentation.homework) {
return this.documentation.canEdit
? "mdi-book-edit-outline"
: "mdi-book-alert-outline";
}
return this.documentation.canEdit
? "mdi-book-plus-outline"
: "mdi-book-off-outline";
},
groupNoteIcon() {
if (this.documentation.groupNote) {
return this.documentation.canEdit
? "mdi-note-edit-outline"
: "mdi-note-alert-outline";
}
return this.documentation.canEdit
? "mdi-note-plus-outline"
: "mdi-note-off-outline";
},
minWidth() {
return Math.min(this.documentation?.topic?.length || 15, 15) + "ch";
},
maxWidth() {
return this.$vuetify.breakpoint.mobile ? "100%" : "20ch";
},
appendIconColor() {
return (
{ $success: "success", $error: "error" }[this.appendIcon] || "primary"
);
},
appendIconTooltip() {
return (
{
$success: this.$t("alsijil.coursebook.summary.topic.status.success"),
$error: this.$t("alsijil.coursebook.summary.topic.status.error", {
error: this.topicError,
}),
}[this.appendIcon] || ""
);
},
dirty() {
return !(
this.topic === this.documentation.topic &&
this.homework === this.documentation.homework &&
this.groupNote === this.documentation.groupNote
);
},
},
mounted() {
this.$on("save", this.handleAppendIconSuccess);
this.topic = this.documentation.topic;
this.homework = this.documentation.homework;
this.groupNote = this.documentation.groupNote;
},
watch: {
dirty(dirty) {
this.$emit("dirty", dirty);
},
},
};
</script>
<style scoped>
.min-width {
min-width: v-bind(minWidth);
}
.max-width {
max-width: v-bind(maxWidth);
}
.gap {
gap: 1em;
}
.grid-layout {
display: grid;
grid-template-columns: auto min-content;
}
.read-only-grid {
display: grid;
grid-template-columns: min-content auto;
grid-template-rows: auto;
}
</style>
/**
* Mixin to provide the cache update functionality used after creating or patching documentations
*/
export default {
methods: {
handleUpdateAfterCreateOrPatch(itemId) {
return (cached, incoming) => {
for (const object of incoming) {
console.log("summary: handleUpdateAfterCreateOrPatch", object);
// Replace the current documentation
const index = cached.findIndex(
(o) => o[itemId] === this.documentation.id,
);
// merged with the incoming partial documentation
// if creation of proper documentation from dummy one, set ID of documentation currently being edited as oldID so that key in coursebook doesn't change
cached[index] = {
...this.documentation,
...object,
oldId:
this.documentation.id !== object.id
? this.documentation.id
: this.documentation.oldId,
};
}
return cached;
};
},
},
};
/**
* Mixin to provide common fields for all components specific to a singular documentation inside the coursebook
*/
export default {
props: {
/**
* The documentation in question
*/
documentation: {
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)
*/
compact: {
type: Boolean,
required: false,
default: false,
},
/**
* Activator attributes and event listeners to open documentation dialog in different places
*/
dialogActivator: {
type: Object,
required: false,
default: () => ({ attrs: {}, on: {} }),
},
/**
* Once loaded list of all extra marks to avoid excessive network and database queries
*/
extraMarks: {
type: Array,
required: true,
},
/**
* Once loaded list of absence reasons to avoid excessive network and database queries
*/
absenceReasons: {
type: Array,
required: true,
},
/**
* Once loaded list of subjects to avoid excessive network and database queries
*/
subjects: {
type: Array,
required: true,
},
},
computed: {
/**
* All necessary props bundled together to easily pass to child components
* @returns {{compact: Boolean, documentation: Object, dialogActivator: Object<{attrs: Object, on: Object}>}}
*/
documentationPartProps() {
return {
documentation: this.documentation,
compact: this.compact,
dialogActivator: this.dialogActivator,
affectedQuery: this.affectedQuery,
extraMarks: this.extraMarks,
absenceReasons: this.absenceReasons,
subjects: this.subjects,
};
},
},
};