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
Commits on Source (183)
Showing
with 1777 additions and 8 deletions
<template>
<c-r-u-d-iterator
i18n-key="alsijil.coursebook"
:gql-query="gqlQuery"
:gql-additional-query-args="gqlQueryArgs"
:enable-create="false"
:enable-edit="false"
:elevated="false"
:items-per-page="-1"
@lastQuery="lastQuery = $event"
ref="iterator"
hide-default-footer
use-deep-search
>
<template #additionalActions="{ attrs, on }">
<div class="d-flex flex-grow-1 justify-end">
<v-autocomplete
:items="selectable"
item-text="name"
clearable
return-object
filled
dense
hide-details
:placeholder="$t('alsijil.coursebook.filter.filter_for_obj')"
:loading="selectLoading"
:value="currentObj"
@input="changeSelection"
@click:clear="changeSelection"
class="max-width"
/>
<div class="ml-6">
<v-switch
:loading="selectLoading"
:label="$t('alsijil.coursebook.filter.own')"
:input-value="filterType === 'my'"
@change="
changeSelection({
filterType: $event ? 'my' : 'all',
type: objType,
id: objId,
})
"
dense
inset
hide-details
/>
<v-switch
:loading="selectLoading"
:label="$t('alsijil.coursebook.filter.missing')"
v-model="incomplete"
dense
inset
hide-details
/>
</div>
</div>
</template>
<template #default="{ items }">
<v-list-item
v-for="day in groupDocsByDay(items)"
two-line
:key="'day-' + day[0]"
>
<v-list-item-content :id="'documentation_' + day[0].toISODate()">
<v-subheader class="text-h6">{{
$d(day[0], "dateWithWeekday")
}}</v-subheader>
<v-list max-width="100%" class="pt-0 mt-n1">
<v-list-item
v-for="doc in day.slice(1)"
:key="'documentation-' + (doc.oldId || doc.id)"
>
<documentation-modal
:documentation="doc"
:affected-query="lastQuery"
/>
</v-list-item>
</v-list>
</v-list-item-content>
</v-list-item>
<coursebook-date-select :value="date" @click="handleDateMove" />
</template>
<template #loading>
<CoursebookLoader />
</template>
<template #no-data>
<CoursebookEmptyMessage icon="mdi-book-off-outline">
{{ $t("alsijil.coursebook.no_data") }}
</CoursebookEmptyMessage>
</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>
</template>
<script>
import CRUDIterator from "aleksis.core/components/generic/CRUDIterator.vue";
import DocumentationModal from "./documentation/DocumentationModal.vue";
import { DateTime } from "luxon";
import {
coursesOfPerson,
documentationsForCoursebook,
groupsByPerson,
} from "./coursebook.graphql";
import CoursebookLoader from "./CoursebookLoader.vue";
import CoursebookEmptyMessage from "./CoursebookEmptyMessage.vue";
import CoursebookDateSelect from "./CoursebookDateSelect.vue";
export default {
name: "Coursebook",
components: {
CoursebookDateSelect,
CoursebookEmptyMessage,
CoursebookLoader,
CRUDIterator,
DocumentationModal,
},
props: {
filterType: {
type: String,
required: true,
},
objId: {
type: [Number, String],
required: false,
default: null,
},
objType: {
type: String,
required: false,
default: null,
},
// ISODate
date: {
type: String,
required: false,
default: "",
},
},
data() {
return {
gqlQuery: documentationsForCoursebook,
lastQuery: null,
// Placeholder values while query isn't completed yet
groups: [],
courses: [],
dateStart: null,
dateEnd: null,
incomplete: false,
};
},
apollo: {
groups: {
query: groupsByPerson,
},
courses: {
query: coursesOfPerson,
},
},
computed: {
gqlQueryArgs() {
return {
// Assure courseId is a number
own: this.filterType === "all" ? false : true,
objId: this.objId ? Number(this.objId) : null,
objType: this.objType?.toUpperCase(),
dateStart: this.dateStart ?? this.date,
dateEnd:
this.dateEnd ??
DateTime.fromISO(this.date).plus({ weeks: 1 }).toISODate(),
incomplete: !!this.incomplete,
};
},
selectable() {
return [
{ header: this.$t("alsijil.coursebook.filter.groups") },
...this.groups.map((group) => ({ type: "group", ...group })),
{ header: this.$t("alsijil.coursebook.filter.courses") },
...this.courses.map((course) => ({ type: "course", ...course })),
];
},
currentObj() {
return this.selectable.find(
(o) => o.type === this.objType && o.id === this.objId,
);
},
selectLoading() {
return (
this.$apollo.queries.groups.loading ||
this.$apollo.queries.courses.loading
);
},
},
methods: {
changeSelection(selection) {
this.$router.push({
name: "alsijil.coursebook_by_type_and_date",
params: {
filterType: selection.filterType
? selection.filterType
: this.filterType,
objType: selection.type,
objId: selection.id,
date: this.date,
},
});
},
// => [[dt doc ...] ...]
groupDocsByDay(docs) {
const byDay = docs.reduce((byDay, doc) => {
// This works with dummy. Does actual doc have dateStart instead?
const day = DateTime.fromISO(doc.datetimeStart).startOf("day");
byDay[day] ??= [day];
byDay[day].push(doc);
return byDay;
}, {});
return Object.keys(byDay)
.sort()
.map((key) => byDay[key]);
},
/**
* @param {"prev"|"next"} direction
*/
handleDateMove(direction) {
const dateStartParsed = DateTime.fromISO(this.dateStart);
const dateEndParsed = DateTime.fromISO(this.dateEnd);
const dateParsed = DateTime.fromISO(this.date);
const newDate =
direction === "prev"
? dateParsed.minus({ days: 1 })
: dateParsed.plus({ days: 1 });
/*
TODO:
Everything below this line is also needed for when a date is selected via the calendar.
→ probably move this into a different function and create a second event listener for the input event.
*/
// Load 3 days into the future/past
if (dateStartParsed >= newDate) {
this.dateStart = newDate.minus({ days: 3 }).toISODate();
}
if (dateEndParsed <= newDate) {
this.dateEnd = newDate.plus({ days: 3 }).toISODate();
}
this.$router.push({
name: "alsijil.coursebook_by_type_and_date",
params: {
filterType: this.filterType,
objType: this.objType,
objId: this.objId,
date: newDate.toISODate(),
},
});
// Define the function to find the nearest ID
const ids = Array.from(
document.querySelectorAll("[id^='documentation_']"),
).map((el) => el.id);
// TODO: This should only be done after loading the new data
const nearestId = this.findNearestId(newDate, direction, ids);
this.$vuetify.goTo("#" + nearestId);
},
findNearestId(targetDate, direction, ids) {
const sortedIds = ids
.map((id) => DateTime.fromISO(id.split("_")[1]))
.sort((a, b) => a - b);
if (direction === "prev") {
sortedIds.reverse();
}
const nearestId =
sortedIds.find((id) =>
direction === "next" ? id >= targetDate : id <= targetDate,
) || sortedIds[sortedIds.length - 1];
return "documentation_" + nearestId.toISODate();
},
},
mounted() {
this.dateStart = this.date;
this.dateEnd = DateTime.fromISO(this.dateStart)
.plus({ weeks: 1 })
.toISODate();
},
};
</script>
<style>
.max-width {
max-width: 25rem;
}
</style>
<script>
import DateField from "aleksis.core/components/generic/forms/DateField.vue";
import { DateTime } from "luxon";
export default {
name: "CoursebookDateSelect",
components: {
DateField,
},
props: {
value: {
type: String,
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
PREV: "prev",
NEXT: "next",
};
},
methods: {
/**
* @param {"prev"|"next"} direction
*/
handleClick(direction) {
this.$emit("click", direction);
this.$emit(direction);
if (direction === this.PREV) {
this.$emit(
"input",
DateTime.fromISO(this.value).minus({ days: 1 }).toISODate(),
);
} else {
this.$emit(
"input",
DateTime.fromISO(this.value).plus({ days: 1 }).toISODate(),
);
}
},
},
emits: ["input", "click", "prev", "next"],
};
</script>
<template>
<v-footer app inset padless id="date-select-footer">
<v-card tile class="full-width">
<v-card-title id="content">
<div class="d-flex align-center justify-center full-width">
<v-btn icon large class="me-4" @click="handleClick(PREV)">
<v-icon>$prev</v-icon>
</v-btn>
<div class="flex-grow-0">
<date-field
solo-inverted
flat
hide-details
:value="value"
@input="$emit('input', $event)"
:label="$t('alsijil.coursebook.date_select.label')"
:disabled="loading"
readonly
/>
</div>
<v-btn icon large class="ms-4" @click="handleClick(NEXT)">
<v-icon>$next</v-icon>
</v-btn>
</div>
</v-card-title>
</v-card>
</v-footer>
</template>
<style scoped>
#content {
margin: auto;
}
#date-select-footer {
z-index: 5;
}
</style>
<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>
<template>
<div>
<v-list-item v-for="i in 10" :key="'i-' + i">
<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 5" :key="'j-' + j">
<DocumentationLoader />
</v-list-item>
</v-list>
</v-list-item-content>
</v-list-item>
</div>
</template>
<script>
import DocumentationLoader from "./documentation/DocumentationLoader.vue";
export default {
name: "CoursebookLoader",
components: { DocumentationLoader },
};
</script>
query groupsByPerson {
groups: groupsByPerson {
id
name
}
}
query coursesOfPerson {
courses: coursesOfPerson {
id
name
groups {
id
name
}
}
}
query documentationsForCoursebook(
$own: Boolean!
$objId: ID
$objType: String
$dateStart: Date!
$dateEnd: Date!
$incomplete: Boolean
) {
items: documentationsForCoursebook(
own: $own
objId: $objId
objType: $objType
dateStart: $dateStart
dateEnd: $dateEnd
incomplete: $incomplete
) {
id
course {
id
name
}
lessonEvent {
id
amends {
id
teachers {
id
shortName
fullName
avatarContentUrl
}
subject {
id
name
shortName
colourFg
colourBg
}
}
cancelled
}
teachers {
id
shortName
fullName
avatarContentUrl
}
subject {
id
name
shortName
colourFg
colourBg
}
topic
homework
groupNote
datetimeStart
datetimeEnd
dateStart
dateEnd
oldId
canEdit
futureNotice
canDelete
}
}
mutation createOrUpdateDocumentations($input: [DocumentationInputType]!) {
createOrUpdateDocumentations(input: $input) {
items: documentations {
id
topic
homework
groupNote
oldId
}
}
}
<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-summary
ref="summary"
v-bind="{ ...$attrs, ...documentationPartProps }"
:is-create="false"
:gql-patch-mutation="documentationsMutation"
@open="$emit('open')"
@loading="loading = $event"
@save="$emit('close')"
/>
<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"
/>
<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"],
mixins: [documentationPartMixin],
data() {
return {
loading: false,
documentationsMutation: createOrUpdateDocumentations,
};
},
methods: {
save() {
this.$refs.summary.save();
this.$emit("close");
},
},
};
</script>
<style scoped>
.main-body {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1em;
}
.vertical {
grid-template-columns: 1fr;
}
</style>
<template>
<v-card outlined dense rounded="lg" v-bind="$attrs" v-on="$listeners">
<div class="font-weight-medium mr-2">
{{ $t("alsijil.coursebook.summary.topic") }}:
</div>
<div class="text-truncate">{{ documentation.topic || "" }}</div>
<div class="font-weight-medium mr-2">
{{ $t("alsijil.coursebook.summary.homework.label") }}:
</div>
<div class="text-truncate">{{ documentation.homework || "" }}</div>
<div class="font-weight-medium mr-2">
{{ $t("alsijil.coursebook.summary.group_note.label") }}:
</div>
<div class="text-truncate">{{ documentation.groupNote || "" }}</div>
</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") }}
</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 || "" }}</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 || "" }}</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">
<template #activator="activator">
<!-- list view -> activate dialog -->
<documentation compact v-bind="$attrs" :dialog-activator="activator" />
</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 Documentation from "./Documentation.vue";
export default {
name: "DocumentationModal",
components: {
MobileFullscreenDialog,
Documentation,
},
data() {
return {
popup: false,
};
},
};
</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?.lessonEvent.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?.lessonEvent.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";
</script>
<template>
<div :class="{ 'full-width grid': true, 'large-grid': largeGrid }">
<div class="d-flex">
<documentation-status v-if="compact" v-bind="documentationPartProps" />
<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 }}
</span>
<subject-chip
v-if="documentation.subject"
:subject="documentation.subject"
v-bind="compact ? dialogActivator.attrs : {}"
v-on="compact ? dialogActivator.on : {}"
/>
<subject-chip
v-if="documentation?.lessonEvent?.amends?.subject && documentation.lessonEvent.amends.subject.id !== documentation.subject.id"
:subject="documentation.lessonEvent.amends.subject"
v-bind="compact ? dialogActivator.attrs : {}"
v-on="compact ? dialogActivator.on : {}"
class="text-decoration-line-through"
disabled
/>
<div :class="{ 'd-flex align-center flex-wrap gap': true }">
<person-chip
v-for="teacher in documentation.teachers"
:key="documentation.id + '-teacher-' + teacher.id"
:person="teacher"
no-link
v-bind="compact ? dialogActivator.attrs : {}"
v-on="compact ? dialogActivator.on : {}"
/>
<person-chip
v-for="teacher in amendedTeachers"
:person="teacher"
no-link
v-bind="compact ? dialogActivator.attrs : {}"
v-on="compact ? dialogActivator.on : {}"
class="text-decoration-line-through"
disabled
/>
</div>
</div>
</template>
<script>
import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
import { DateTime } from "luxon";
import documentationPartMixin from "./documentationPartMixin";
export default {
name: "LessonInformation",
mixins: [documentationPartMixin],
components: {
SubjectChip,
},
methods: {
toDateTime(dateString) {
return DateTime.fromISO(dateString);
},
},
computed: {
largeGrid() {
return this.compact && !this.$vuetify.breakpoint.mobile;
},
amendedTeachers() {
if (this.documentation?.lessonEvent?.amends?.teachers && this.documentation.lessonEvent.amends.teachers.length) {
return this.documentation.lessonEvent.amends.teachers.filter((at) => !this.documentation.teachers.includes((t) => t.id === at.id));
}
return [];
}
},
};
</script>
<style scoped>
.grid {
display: grid;
grid-template-columns: auto auto;
align-items: center;
gap: 1em;
align-content: start;
}
.large-grid {
grid-template-columns: 1fr 1fr 1fr 1fr;
align-content: unset;
}
.grid:not(.large-grid):nth-child(odd) {
justify-self: start;
}
.grid:not(.large-grid):nth-child(even) {
justify-self: end;
}
.grid.large-grid:nth-child(3) {
justify-self: center;
}
.grid:last-child {
justify-self: end;
justify-content: end;
}
.fit-content {
width: fit-content;
}
.gap {
gap: 0.25em;
}
</style>
<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>
<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 -->
</div>
</template>
<script>
import documentationPartMixin from "./documentationPartMixin";
export default {
name: "LessonNotes",
mixins: [documentationPartMixin],
};
</script>
<style scoped>
.gap {
gap: 0.25em;
}
</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')"
:value="documentation.topic"
@input="topic = $event"
@focusout="save"
@keydown.enter="saveAndBlur"
:loading="loading"
>
<template #append>
<v-scroll-x-transition>
<v-icon v-if="appendIcon" :color="appendIconColor">{{
appendIcon
}}</v-icon>
</v-scroll-x-transition>
</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')"
: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"
/>
<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"
/>
<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";
export default {
name: "LessonSummary",
mixins: [createOrPatchMixin, documentationPartMixin],
emits: ["open"],
data() {
return {
topic: null,
homework: null,
groupNote: null,
appendIcon: null,
};
},
methods: {
handleUpdateAfterCreateOrPatch(itemId, wasCreate) {
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;
};
},
handleAppendIconSuccess() {
this.appendIcon = "$success";
setTimeout(() => {
this.appendIcon = "";
}, 3000);
},
save() {
if (
this.topic !== null ||
this.homework !== null ||
this.groupNote !== null
) {
const topic = this.topic !== null ? { topic: this.topic } : {};
const homework =
this.homework !== null ? { homework: this.homework } : {};
const groupNote =
this.groupNote !== null ? { groupNote: this.groupNote } : {};
this.createOrPatch([
{
id: this.documentation.id,
...topic,
...homework,
...groupNote,
},
]);
this.topic = null;
this.homework = null;
this.groupNote = null;
}
},
saveAndBlur(event) {
this.save();
event.target.blur();
},
handleError() {
this.appendIcon = "$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"
);
},
},
mounted() {
this.$on("save", this.handleAppendIconSuccess);
},
};
</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 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,
},
/**
* 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: {} }),
},
},
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,
};
},
},
};
......@@ -2,12 +2,14 @@ import {
notLoggedInValidator,
hasPersonValidator,
} from "aleksis.core/routeValidators";
import { DateTime } from "luxon";
export default {
meta: {
inMenu: true,
titleKey: "alsijil.menu_title",
icon: "mdi-account-group-outline",
iconActive: "mdi-account-group",
validators: [hasPersonValidator],
},
props: {
......@@ -19,7 +21,6 @@ export default {
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
name: "alsijil.lessonPeriod",
meta: {
inMenu: true,
titleKey: "alsijil.lesson.menu_title",
icon: "mdi-alarm",
permission: "alsijil.view_lesson_menu_rule",
......@@ -57,7 +58,6 @@ export default {
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
name: "alsijil.weekView",
meta: {
inMenu: true,
titleKey: "alsijil.week.menu_title",
icon: "mdi-view-week-outline",
permission: "alsijil.view_week_menu_rule",
......@@ -119,7 +119,6 @@ export default {
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
name: "alsijil.myGroups",
meta: {
inMenu: true,
titleKey: "alsijil.groups.menu_title",
icon: "mdi-account-multiple-outline",
permission: "alsijil.view_my_groups_rule",
......@@ -141,7 +140,6 @@ export default {
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
name: "alsijil.myStudents",
meta: {
inMenu: true,
titleKey: "alsijil.persons.menu_title",
icon: "mdi-account-school-outline",
permission: "alsijil.view_my_students_rule",
......@@ -163,7 +161,6 @@ export default {
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
name: "alsijil.overviewMe",
meta: {
inMenu: true,
titleKey: "alsijil.my_overview.menu_title",
icon: "mdi-chart-box-outline",
permission: "alsijil.view_person_overview_menu_rule",
......@@ -193,7 +190,6 @@ export default {
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
name: "alsijil.registerAbsence",
meta: {
inMenu: true,
titleKey: "alsijil.absence.menu_title",
icon: "mdi-message-alert-outline",
permission: "alsijil.view_register_absence_rule",
......@@ -210,6 +206,7 @@ export default {
inMenu: true,
titleKey: "alsijil.extra_marks.menu_title",
icon: "mdi-label-variant-outline",
iconActive: "mdi-label-variant",
permission: "alsijil.view_extramarks_rule",
},
props: {
......@@ -248,6 +245,7 @@ export default {
inMenu: true,
titleKey: "alsijil.excuse_types.menu_title",
icon: "mdi-label-outline",
iconActive: "mdi-label",
permission: "alsijil.view_excusetypes_rule",
},
props: {
......@@ -286,6 +284,7 @@ export default {
inMenu: true,
titleKey: "alsijil.group_roles.menu_title_manage",
icon: "mdi-clipboard-plus-outline",
iconActive: "mdi-clipboard-plus",
permission: "alsijil.view_grouproles_rule",
},
props: {
......@@ -372,6 +371,7 @@ export default {
inMenu: true,
titleKey: "alsijil.group_roles.menu_title_assign",
icon: "mdi-clipboard-account-outline",
iconActive: "mdi-clipboard-account",
permission: "alsijil.assign_grouprole_for_multiple_rule",
},
props: {
......@@ -383,7 +383,6 @@ export default {
component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
name: "alsijil.allRegisterObjects",
meta: {
inMenu: true,
titleKey: "alsijil.all_lessons.menu_title",
icon: "mdi-format-list-text",
permission: "alsijil.view_register_objects_list_rule",
......@@ -392,5 +391,41 @@ export default {
byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
},
},
{
path: "coursebook/",
component: () => import("./components/coursebook/Coursebook.vue"),
redirect: () => {
return {
name: "alsijil.coursebook_by_type_and_date",
params: {
date: DateTime.now().toISODate(),
filterType: "my",
},
};
},
name: "alsijil.coursebook",
props: true,
meta: {
inMenu: true,
icon: "mdi-book-education-outline",
iconActive: "mdi-book-education",
titleKey: "alsijil.coursebook.menu_title",
toolbarTitle: "alsijil.coursebook.menu_title",
permission: "core.view_calendar_feed_rule",
},
children: [
{
path: ":date(\\d\\d\\d\\d-\\d\\d-\\d\\d)/:filterType(my|all)/:objType(group|course|teacher)?/:objId(\\d+)?/",
component: () => import("./components/coursebook/Coursebook.vue"),
name: "alsijil.coursebook_by_type_and_date",
meta: {
titleKey: "alsijil.coursebook.menu_title",
toolbarTitle: "alsijil.coursebook.menu_title",
permission: "core.view_calendar_feed_rule",
fullWidth: true,
},
},
],
},
],
};
......@@ -31,6 +31,41 @@
},
"all_lessons": {
"menu_title": "Alle Stunden"
},
"coursebook": {
"menu_title": "Kursbuch",
"page_title": "Kursbuch für {name}",
"title_plural": "Kursbuch",
"status": {
"available": "Kursbucheintrag vorhanden",
"missing": "Kursbucheintrag fehlt",
"running": "Stunde läuft",
"substitution": "Vertretungsstunde",
"cancelled": "Stunde fällt aus",
"pending": "Stunde in der Zukunft"
},
"summary": {
"topic": "Thema",
"homework": {
"label": "Hausaufgaben",
"value": "HA: {homework}",
"empty": "Keine Hausaufgaben"
},
"group_note": {
"label": "Gruppennotiz",
"value": "GN: {groupNote}",
"empty": "Keine Gruppennotiz"
}
},
"filter": {
"own": "Nur eigene Stunden anzeigen",
"missing": "Nur unvollständige Stunden anzeigen",
"groups": "Gruppen",
"courses": "Kurse",
"filter_for_obj": "Nach Gruppe und Kurs filtern"
},
"no_data": "Keine Stunden der ausgewählten Gruppen und Kurse im aktuellen Zeitraum",
"no_results": "Keine Suchergebnisse für {search}"
}
}
}
......@@ -31,6 +31,45 @@
"all_lessons": {
"menu_title": "All lessons"
},
"menu_title": "Class register"
"menu_title": "Class register",
"coursebook": {
"menu_title": "Coursebook",
"page_title": "Coursebook for {name}",
"title_plural": "Coursebook",
"status": {
"available": "Documentation available",
"missing": "Documentation missing",
"running": "Lesson running",
"substitution": "Substitution lesson",
"cancelled": "Lesson cancelled",
"pending": "Lesson pending"
},
"summary": {
"topic": "Topic",
"homework": {
"label": "Homework",
"value": "HW: {homework}",
"empty": "No homework"
},
"group_note": {
"label": "Group note",
"value": "GN: {groupNote}",
"empty": "No group note"
}
},
"notices": {
"future": "Editing this lesson isn't allowed as this lesson is in the future.",
"no_entry": "There is no entry for this lesson yet."
},
"filter": {
"own": "Only show own lessons",
"missing": "Only show incomplete lessons",
"groups": "Groups",
"courses": "Courses",
"filter_for_obj": "Filter for group and course"
},
"no_data": "No lessons for the selected groups and courses in this period",
"no_results": "No search results for {search}"
}
}
}
# Generated by Django 4.2.4 on 2023-08-13 14:53
import aleksis.apps.alsijil.models
import aleksis.core.managers
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("chronos", "0016_lessonevent"),
("core", "0052_site_related_name"),
("cursus", "0001_initial"),
("alsijil", "0019_drop_sites"),
]
operations = [
migrations.CreateModel(
name="Documentation",
fields=[
(
"calendarevent_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="core.calendarevent",
),
),
(
"topic",
models.CharField(blank=True, max_length=255, verbose_name="Lesson topic"),
),
("homework", models.CharField(blank=True, max_length=255, verbose_name="Homework")),
(
"group_note",
models.CharField(blank=True, max_length=255, verbose_name="Group note"),
),
(
"course",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="documentations",
to="cursus.course",
),
),
(
"lesson_event",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="documentation",
to="chronos.lessonevent",
),
),
(
"subject",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="cursus.subject",
),
),
],
options={
"verbose_name": "Documentation",
"verbose_name_plural": "Documentations",
},
bases=("core.calendarevent",),
),
migrations.CreateModel(
name="Participation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"managed_by_app_label",
models.CharField(
blank=True,
editable=False,
max_length=255,
verbose_name="App label of app responsible for managing this instance",
),
),
("extended_data", models.JSONField(default=dict, editable=False)),
("remarks", models.CharField(blank=True, max_length=255)),
(
"documentation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="participations",
to="alsijil.documentation",
),
),
(
"extra_marks",
models.ManyToManyField(
blank=True, to="alsijil.extramark", verbose_name="Extra marks"
),
),
("groups_of_person", models.ManyToManyField(related_name="+", to="core.group")),
(
"person",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="participations",
to="core.person",
),
),
],
options={
"verbose_name": "Participation note",
"verbose_name_plural": "Participation notes",
"ordering": ["documentation", "person__last_name", "person__first_name"],
},
bases=(aleksis.apps.alsijil.models.RegisterObjectRelatedMixin, models.Model),
managers=[
("objects", aleksis.core.managers.AlekSISBaseManager()),
],
),
migrations.AddConstraint(
model_name="participation",
constraint=models.UniqueConstraint(
fields=("documentation", "person"), name="unique_participation_per_documentation"
),
),
migrations.AddConstraint(
model_name="documentation",
constraint=models.CheckConstraint(
check=models.Q(
("course__isnull", True), ("lesson_event__isnull", True), _negated=True
),
name="either_course_or_lesson_event",
),
),
]
# Generated by Django 4.2.9 on 2024-01-11 20:43
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cursus', '0001_initial'),
('core', '0052_site_related_name'),
('chronos', '0016_lessonevent'),
('kolego', '0003_refactor_absence'),
('alsijil', '0020_add_documentation_and_participation'),
]
operations = [
migrations.CreateModel(
name='NewPersonalNote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('managed_by_app_label', models.CharField(blank=True, editable=False, max_length=255, verbose_name='App label of app responsible for managing this instance')),
('extended_data', models.JSONField(default=dict, editable=False)),
('note', models.TextField(blank=True, verbose_name='Note')),
],
options={
'verbose_name': 'Personal Note',
'verbose_name_plural': 'Personal Notes',
},
),
migrations.CreateModel(
name='ParticipationStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('managed_by_app_label', models.CharField(blank=True, editable=False, max_length=255, verbose_name='App label of app responsible for managing this instance')),
('extended_data', models.JSONField(default=dict, editable=False)),
('absent', models.BooleanField(verbose_name='Absent')),
('absence_reason', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='kolego.absencereason', verbose_name='Absence Reason')),
('base_absence', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='absences', to='kolego.absence', verbose_name='Base Absence')),
],
options={
'verbose_name': 'Participation Status',
'verbose_name_plural': 'Participation Status',
'ordering': ['documentation', 'person__last_name', 'person__first_name'],
},
),
migrations.AlterField(
model_name='documentation',
name='course',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='documentations', to='cursus.course', verbose_name='Course'),
),
migrations.AlterField(
model_name='documentation',
name='group_note',
field=models.CharField(blank=True, max_length=255, verbose_name='Group Note'),
),
migrations.AlterField(
model_name='documentation',
name='lesson_event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='documentation', to='chronos.lessonevent', verbose_name='Lesson Event'),
),
migrations.AlterField(
model_name='documentation',
name='subject',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='cursus.subject', verbose_name='Subject'),
),
migrations.AlterField(
model_name='documentation',
name='topic',
field=models.CharField(blank=True, max_length=255, verbose_name='Lesson Topic'),
),
migrations.DeleteModel(
name='Participation',
),
migrations.AddField(
model_name='participationstatus',
name='documentation',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participations', to='alsijil.documentation', verbose_name='Documentation'),
),
migrations.AddField(
model_name='participationstatus',
name='groups_of_person',
field=models.ManyToManyField(related_name='+', to='core.group', verbose_name='Groups of Person'),
),
migrations.AddField(
model_name='participationstatus',
name='person',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participations', to='core.person', verbose_name='Person'),
),
migrations.AddField(
model_name='newpersonalnote',
name='documentation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='personal_notes', to='alsijil.documentation', verbose_name='Documentation'),
),
migrations.AddField(
model_name='newpersonalnote',
name='extra_mark',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='alsijil.extramark', verbose_name='Extra Mark'),
),
migrations.AddField(
model_name='newpersonalnote',
name='person',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='new_personal_notes', to='core.person', verbose_name='Person'),
),
migrations.AddConstraint(
model_name='participationstatus',
constraint=models.UniqueConstraint(fields=('documentation', 'person'), name='unique_participation_status_per_documentation'),
),
migrations.AddConstraint(
model_name='newpersonalnote',
constraint=models.CheckConstraint(check=models.Q(models.Q(('note', ''), _negated=True), ('extra_mark__isnull', False), _connector='OR'), name='unique_absence_per_documentation'),
),
]