Merged in feature/trainer-cockpit-VBV-776--2024-11-14 (pull request #425)

Feature/trainer cockpit VBV-776  2024 11 14

Approved-by: Stéphanie Rotzetter
Approved-by: Elia Bieri
This commit is contained in:
Ramon Wenger 2024-11-27 14:14:05 +00:00
commit 2c4512ba91
26 changed files with 1007 additions and 674 deletions

View File

@ -0,0 +1,58 @@
import { i18nextInit, loadI18nextLocaleMessages } from "@/i18nextWrapper";
import AttendanceStatus from "@/pages/cockpit/cockpitPage/AttendanceStatus.vue";
import { config, mount } from "@vue/test-utils";
import i18next from "i18next";
import I18NextVue from "i18next-vue";
import { expect, vi } from "vitest";
describe("AttendanceStatus.vue", async () => {
vi.useFakeTimers();
const date = new Date(1999, 2, 31);
vi.setSystemTime(date);
await i18nextInit();
await loadI18nextLocaleMessages("de");
config.global.plugins = [[I18NextVue, { i18next }]];
test("Attendance check complete", () => {
const wrapper = mount(AttendanceStatus, {
props: {
done: true,
date: "",
},
});
expect(wrapper.text()).toContain("Du hast die Anwesenheit bestätigt.");
});
test("Attendance check future", () => {
const future = "1999-04-02T06:30:00+00:00";
const wrapper = mount(AttendanceStatus, {
props: {
done: false,
date: future,
},
});
expect(wrapper.text()).toContain("Der Präsenzkurs findet in 2 Tagen statt.");
});
test("Attendance check future", () => {
const future = "1999-04-01T06:30:00+00:00";
const wrapper = mount(AttendanceStatus, {
props: {
done: false,
date: future,
},
});
expect(wrapper.text()).toContain("Der Präsenzkurs findet in einem Tag statt.");
});
test("Attendance check now", () => {
const yesterday = "1999-03-30T06:30:00+00:00";
const wrapper = mount(AttendanceStatus, {
props: {
done: false,
date: yesterday,
},
});
expect(wrapper.text()).toContain("Überprüfe jetzt die Anwesenheit.");
});
});

View File

@ -0,0 +1,16 @@
import { expect, vi } from "vitest";
import { isInFuture } from "../dueDates/dueDatesUtils";
test("Date Utils", () => {
vi.useFakeTimers();
const date = new Date(1999, 2, 31);
vi.setSystemTime(date);
const today = "1999-03-31T06:30:00+00:00";
const yesterday = "1999-03-30T06:30:00+00:00";
const tomorrow = "1999-04-01T06:30:00+00:00";
expect(isInFuture(yesterday)).toBeFalsy();
expect(isInFuture(today)).toBeFalsy();
expect(isInFuture(tomorrow)).toBeTruthy();
});

View File

@ -59,3 +59,12 @@ export const getWeekday = (date: Dayjs) => {
} }
return ""; return "";
}; };
export const isInFuture = (date: string) => {
// is today before the prop date?
return dayjs().isBefore(date, "day");
};
export const howManyDaysInFuture = (date: string) => {
return dayjs(date).diff(dayjs().startOf("day"), "day");
};

View File

@ -11,7 +11,7 @@ const { t } = useTranslation();
<template v-if="isInCourse"> <template v-if="isInCourse">
<div class="flex h-full items-center border-r border-slate-500"> <div class="flex h-full items-center border-r border-slate-500">
<router-link to="/" class="flex items-center pr-3"> <router-link to="/" class="flex items-center pr-3">
<it-icon-arrow-left /> <it-icon-arrow-left class="fill-current text-slate-500" />
<span class="hidden text-slate-500 lg:inline"> <span class="hidden text-slate-500 lg:inline">
{{ t("a.Dashboard") }} {{ t("a.Dashboard") }}
</span> </span>

View File

@ -40,7 +40,7 @@ onMounted(() => {
<CourseSessionNavigation /> <CourseSessionNavigation />
</div> </div>
<div class="flex items-stretch justify-start space-x-8"> <div class="flex items-stretch justify-start gap-2 lg:gap-4">
<router-link <router-link
v-if="hasMediaLibraryMenu" v-if="hasMediaLibraryMenu"
:to=" :to="

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import ItFullScreenModal from "@/components/ui/ItFullScreenModal.vue"; import ItFullScreenModal from "@/components/ui/ItFullScreenModal.vue";
import { useVVByLink } from "@/composables"; import { useVVByLink } from "@/composables";
import { SETTINGS_ROUTE } from "@/router/names"; import { PERSONAL_PROFILE_ROUTE, SETTINGS_ROUTE } from "@/router/names";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { User } from "@/stores/user"; import type { User } from "@/stores/user";
import type { CourseSession } from "@/types"; import type { CourseSession } from "@/types";
@ -54,13 +54,16 @@ const mentorTabTitle = computed(() =>
const settingsRoute = { const settingsRoute = {
name: SETTINGS_ROUTE, name: SETTINGS_ROUTE,
}; };
const profileRoute = {
name: PERSONAL_PROFILE_ROUTE,
};
</script> </script>
<template> <template>
<ItFullScreenModal :show="show" @closemodal="emit('closemodal')"> <ItFullScreenModal :show="show" @closemodal="emit('closemodal')">
<div> <div class="-mx-4">
<div> <div>
<div v-if="user?.loggedIn" class="-mx-4 border-b px-8 pb-4"> <div v-if="user?.loggedIn" class="border-b px-8 pb-4">
<div class="-ml-4 flex"> <div class="-ml-4 flex">
<div v-if="user?.avatar_url"> <div v-if="user?.avatar_url">
<img <img
@ -71,16 +74,27 @@ const settingsRoute = {
</div> </div>
<div class="ml-6"> <div class="ml-6">
<h3>{{ user?.first_name }} {{ user?.last_name }}</h3> <h3>{{ user?.first_name }} {{ user?.last_name }}</h3>
<div class="mb-3 text-sm text-gray-800">{{ user.email }}</div>
<router-link
:to="profileRoute"
class="underline"
@click="emit('closemodal')"
>
{{ $t("a.Profil anzeigen") }}
</router-link>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<div v-if="courseSession" class="mt-6 border-b"> <div v-if="courseSession" class="border-b px-4 py-6">
<h4 class="px-4 text-sm text-gray-900">{{ courseSession.course.title }}</h4> <h4 class="mb-4 px-4 text-sm text-gray-900">
<ul class="mt-6 flex flex-col"> {{ courseSession.course.title }}
<li v-if="hasCockpitMenu" class="mb-6"> </h4>
<ul class="flex flex-col gap-2">
<li v-if="hasCockpitMenu">
<router-link <router-link
class="w-full px-4 py-2" class="block w-full px-4 py-2"
active-class="bg-gray-200 text-blue-900 font-bold" active-class="bg-gray-200 text-blue-900 font-bold"
data-cy="navigation-mobile-cockpit-link" data-cy="navigation-mobile-cockpit-link"
:to="getCockpitUrl(courseSession.course.slug)" :to="getCockpitUrl(courseSession.course.slug)"
@ -89,9 +103,9 @@ const settingsRoute = {
{{ $t("cockpit.title") }} {{ $t("cockpit.title") }}
</router-link> </router-link>
</li> </li>
<li v-if="hasPreviewMenu" class="mb-2 flex"> <li v-if="hasPreviewMenu">
<router-link <router-link
class="w-full px-4 py-2" class="block w-full px-4 py-2"
active-class="bg-gray-200 text-blue-900 font-bold" active-class="bg-gray-200 text-blue-900 font-bold"
data-cy="navigation-mobile-preview-link" data-cy="navigation-mobile-preview-link"
:to="getLearningPathUrl(courseSession.course.slug)" :to="getLearningPathUrl(courseSession.course.slug)"
@ -100,9 +114,9 @@ const settingsRoute = {
{{ $t("a.Vorschau Teilnehmer") }} {{ $t("a.Vorschau Teilnehmer") }}
</router-link> </router-link>
</li> </li>
<li v-if="hasLearningPathMenu" class="mb-2 flex"> <li v-if="hasLearningPathMenu">
<router-link <router-link
class="w-full px-4 py-2" class="block w-full px-4 py-2"
active-class="bg-gray-200 text-blue-900 font-bold" active-class="bg-gray-200 text-blue-900 font-bold"
data-cy="navigation-mobile-learning-path-link" data-cy="navigation-mobile-learning-path-link"
:to="getLearningPathUrl(courseSession.course.slug)" :to="getLearningPathUrl(courseSession.course.slug)"
@ -111,9 +125,9 @@ const settingsRoute = {
{{ $t("general.learningPath") }} {{ $t("general.learningPath") }}
</router-link> </router-link>
</li> </li>
<li v-if="hasCompetenceNaviMenu" class="mb-2 flex"> <li v-if="hasCompetenceNaviMenu">
<router-link <router-link
class="w-full px-4 py-2" class="block w-full px-4 py-2"
active-class="bg-gray-200 text-blue-900 font-bold" active-class="bg-gray-200 text-blue-900 font-bold"
data-cy="navigation-mobile-competence-profile-link" data-cy="navigation-mobile-competence-profile-link"
:to="getCompetenceNaviUrl(courseSession.course.slug)" :to="getCompetenceNaviUrl(courseSession.course.slug)"
@ -122,9 +136,9 @@ const settingsRoute = {
{{ $t("competences.title") }} {{ $t("competences.title") }}
</router-link> </router-link>
</li> </li>
<li v-if="hasLearningMentor" class="mb-2 flex"> <li v-if="hasLearningMentor">
<router-link <router-link
class="w-full px-4 py-2" class="block w-full px-4 py-2"
active-class="bg-gray-200 text-blue-900 font-bold" active-class="bg-gray-200 text-blue-900 font-bold"
data-cy="navigation-mobile-mentor-link" data-cy="navigation-mobile-mentor-link"
:to="getLearningMentorUrl(courseSession.course.slug)" :to="getLearningMentorUrl(courseSession.course.slug)"
@ -147,9 +161,9 @@ const settingsRoute = {
</li> </li>
</ul> </ul>
</div> </div>
<div class="mt-6 border-b"> <div v-if="courseSession" class="border-b px-4">
<ul> <ul class="flex flex-col gap-2 py-6">
<li v-if="courseSession && hasMediaLibraryMenu" class="mb-6 flex"> <li v-if="courseSession && hasMediaLibraryMenu" class="flex">
<router-link <router-link
data-cy="medialibrary-link" data-cy="medialibrary-link"
class="flex w-full items-center gap-2 px-4 py-2" class="flex w-full items-center gap-2 px-4 py-2"
@ -161,7 +175,7 @@ const settingsRoute = {
{{ $t("a.Mediathek") }} {{ $t("a.Mediathek") }}
</router-link> </router-link>
</li> </li>
<li v-if="courseSession && hasMediaLibraryMenu" class="mb-6 flex"> <li v-if="courseSession && hasMediaLibraryMenu" class="flex">
<router-link <router-link
data-cy="calendar-link" data-cy="calendar-link"
class="flex w-full items-center gap-2 px-4 py-2" class="flex w-full items-center gap-2 px-4 py-2"
@ -176,25 +190,28 @@ const settingsRoute = {
</li> </li>
</ul> </ul>
</div> </div>
<router-link
:to="settingsRoute" <div class="flex flex-col gap-2 px-4 py-6">
class="mt-6 flex w-full items-center gap-2 px-4 py-2" <router-link
active-class="bg-gray-200 text-blue-900 font-bold" v-if="user?.loggedIn"
v-if="user?.loggedIn" :to="settingsRoute"
type="button" class="flex w-full items-center gap-2 px-4 py-2"
> active-class="bg-gray-200 text-blue-900 font-bold"
<it-icon-settings /> type="button"
{{ $t("a.Einstellungen") }} >
</router-link> <it-icon-settings />
<button {{ $t("a.Einstellungen") }}
v-if="user?.loggedIn" </router-link>
type="button" <button
class="mt-6 flex items-center px-4 py-2" v-if="user?.loggedIn"
@click="$emit('logout')" type="button"
> class="flex items-center px-4 py-2"
<it-icon-logout class="inline-block" /> @click="$emit('logout')"
<span class="ml-1">{{ $t("mainNavigation.logout") }}</span> >
</button> <it-icon-logout class="inline-block" />
<span class="ml-1">{{ $t("mainNavigation.logout") }}</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -36,14 +36,13 @@ const showCourseSessionMenu = computed(
v-if="hasSessionTitle" v-if="hasSessionTitle"
class="nav-item-base inline-flex items-center lg:inline-flex" class="nav-item-base inline-flex items-center lg:inline-flex"
> >
<div data-cy="current-course-session-title" class="text-bold">
{{ selectedCourseSessionTitle }}
</div>
<Popover v-if="showCourseSessionMenu" class="relative"> <Popover v-if="showCourseSessionMenu" class="relative">
<PopoverButton <PopoverButton
class="group flex items-center rounded-md bg-transparent px-3 text-base focus:outline-none" class="group flex items-center gap-1 rounded-md bg-transparent px-3 text-base focus:outline-none"
> >
<span data-cy="current-course-session-title" class="text-bold">
{{ selectedCourseSessionTitle }}
</span>
<it-icon-arrow-down class="h-6 w-6" /> <it-icon-arrow-down class="h-6 w-6" />
</PopoverButton> </PopoverButton>
<PopoverPanel class="absolute left-0 z-10 mt-3 w-64 px-1 sm:px-0 lg:max-w-3xl"> <PopoverPanel class="absolute left-0 z-10 mt-3 w-64 px-1 sm:px-0 lg:max-w-3xl">
@ -59,5 +58,9 @@ const showCourseSessionMenu = computed(
</div> </div>
</PopoverPanel> </PopoverPanel>
</Popover> </Popover>
<div v-else data-cy="current-course-session-title" class="text-bold">
{{ selectedCourseSessionTitle }}
</div>
</div> </div>
</template> </template>

View File

@ -27,7 +27,7 @@ const isExternalLink = computed(() => {
target="_blank" target="_blank"
> >
<slot /> <slot />
<it-icon-external-link class="w-6" /> <it-icon-external-link class="h-6 w-6" />
</a> </a>
<!-- make `:to` explicit --> <!-- make `:to` explicit -->
<router-link <router-link

View File

@ -9,6 +9,8 @@ interface Props {
items?: DropdownSelectable[]; items?: DropdownSelectable[];
borderless?: boolean; borderless?: boolean;
placeholderText?: string | null; placeholderText?: string | null;
asHeading?: boolean; // style the dropdown to be used as a page heading
typeName?: string; // to display the type of the selected item, e.g. `Circle: Fahrzeug` instead of `Fahrzeug`
} }
const emit = defineEmits<{ const emit = defineEmits<{
@ -24,6 +26,8 @@ const props = withDefaults(defineProps<Props>(), {
}, },
items: () => [], items: () => [],
placeholderText: null, placeholderText: null,
asHeading: false,
typeName: "",
}); });
const dropdownSelected = computed<DropdownSelectable>({ const dropdownSelected = computed<DropdownSelectable>({
@ -36,26 +40,34 @@ const dropdownSelected = computed<DropdownSelectable>({
<Listbox v-model="dropdownSelected" as="div"> <Listbox v-model="dropdownSelected" as="div">
<div class="relative w-full"> <div class="relative w-full">
<ListboxButton <ListboxButton
class="relative flex w-full cursor-default flex-row items-center bg-white py-3 pl-5 pr-10 text-left" :class="[
:class="{ {
border: !props.borderless, border: !borderless && !asHeading,
'font-bold': !props.borderless, 'font-bold': !borderless,
}" },
asHeading
? 'group flex w-full items-center gap-1 rounded-md bg-transparent text-base focus:outline-none'
: 'relative flex w-full cursor-default flex-row items-center bg-white py-3 pl-5 pr-10 text-left',
]"
data-cy="dropdown-select" data-cy="dropdown-select"
> >
<span v-if="dropdownSelected.iconName" class="mr-4"> <span v-if="dropdownSelected.iconName" class="mr-4">
<component :is="dropdownSelected.iconName"></component> <component :is="dropdownSelected.iconName"></component>
</span> </span>
<span class="block truncate"> <span :class="[asHeading ? 'h-11 text-4xl' : '']" class="block truncate">
{{ dropdownSelected.name }} {{ typeName }} {{ dropdownSelected.name }}
<span v-if="placeholderText && !dropdownSelected.name" class="text-gray-900"> <span v-if="placeholderText && !dropdownSelected.name" class="text-gray-900">
{{ placeholderText }} {{ placeholderText }}
</span> </span>
</span> </span>
<span <span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2" class="pointer-events-none flex items-center pr-2"
:class="asHeading ? '' : 'absolute inset-y-0 right-0'"
> >
<it-icon-arrow-down class="h-5 w-5" aria-hidden="true" /> <it-icon-arrow-down
:class="asHeading ? 'h-12 w-12' : 'h-5 w-5'"
aria-hidden="true"
/>
</span> </span>
</ListboxButton> </ListboxButton>
@ -79,7 +91,7 @@ const dropdownSelected = computed<DropdownSelectable>({
active ? 'bg-blue-900 text-white' : 'text-black', active ? 'bg-blue-900 text-white' : 'text-black',
'relative cursor-default select-none py-2 pl-3 pr-9', 'relative cursor-default select-none py-2 pl-3 pr-9',
]" ]"
class="flex flex-row items-center" class="group flex flex-row items-center"
:data-cy="`dropdown-select-option-${item.name}`" :data-cy="`dropdown-select-option-${item.name}`"
> >
<span v-if="item.iconName" class="mr-4"> <span v-if="item.iconName" class="mr-4">
@ -98,7 +110,11 @@ const dropdownSelected = computed<DropdownSelectable>({
v-if="dropdownSelected" v-if="dropdownSelected"
class="absolute inset-y-0 right-0 flex items-center pr-4 text-blue-900" class="absolute inset-y-0 right-0 flex items-center pr-4 text-blue-900"
> >
<it-icon-check v-if="selected" class="h-5 w-5" aria-hidden="true" /> <it-icon-check
v-if="selected"
class="h-5 w-5 fill-current group-hover:text-white"
aria-hidden="true"
/>
</span> </span>
</li> </li>
</ListboxOption> </ListboxOption>

View File

@ -34,6 +34,7 @@
"a.An Durchführung teilnehmen": "An Durchführung teilnehmen", "a.An Durchführung teilnehmen": "An Durchführung teilnehmen",
"a.Anmelden": "Anmelden", "a.Anmelden": "Anmelden",
"a.Anwesenheit": "Anwesenheit", "a.Anwesenheit": "Anwesenheit",
"a.Anwesenheit anschauen": "Anwesenheit anschauen",
"a.Anwesenheit Präsenzkurse": "Anwesenheit Präsenzkurse", "a.Anwesenheit Präsenzkurse": "Anwesenheit Präsenzkurse",
"a.Anwesenheitskontrolle Präsenzkurse": "Anwesenheitskontrolle Präsenzkurse", "a.Anwesenheitskontrolle Präsenzkurse": "Anwesenheitskontrolle Präsenzkurse",
"a.Arbeiten": "Arbeiten", "a.Arbeiten": "Arbeiten",
@ -84,6 +85,8 @@
"a.Deine Änderungen wurden gespeichert": "Deine Änderungen wurden gespeichert", "a.Deine Änderungen wurden gespeichert": "Deine Änderungen wurden gespeichert",
"a.Der Lehrgang und die Prüfung zum Erwerb des Verbandszertifikats als Versicherungsvermittler/-in.": "Der Lehrgang und die Prüfung zum Erwerb des Verbandszertifikats als Versicherungsvermittler/-in.", "a.Der Lehrgang und die Prüfung zum Erwerb des Verbandszertifikats als Versicherungsvermittler/-in.": "Der Lehrgang und die Prüfung zum Erwerb des Verbandszertifikats als Versicherungsvermittler/-in.",
"a.Der Preis für den Lehrgang {course} beträgt {price}.": "Der Preis für den Lehrgang {course} beträgt {price} exkl. MWSt.", "a.Der Preis für den Lehrgang {course} beträgt {price}.": "Der Preis für den Lehrgang {course} beträgt {price} exkl. MWSt.",
"a.Der Präsenzkurs findet in {{days}} Tagen statt._one": "Der Präsenzkurs findet in einem Tag statt.",
"a.Der Präsenzkurs findet in {{days}} Tagen statt._other": "Der Präsenzkurs findet in {{count}} Tagen statt.",
"a.Details anschauen": "Details anschauen", "a.Details anschauen": "Details anschauen",
"a.Details anzeigen": "Details anzeigen", "a.Details anzeigen": "Details anzeigen",
"a.Deutsch": "Deutsch", "a.Deutsch": "Deutsch",
@ -95,6 +98,7 @@
"a.Du hast alles erledigt.": "Du hast alles erledigt.", "a.Du hast alles erledigt.": "Du hast alles erledigt.",
"a.Du hast deine Fremdeinschätzung freigegeben": "Du hast deine Fremdeinschätzung freigegeben.", "a.Du hast deine Fremdeinschätzung freigegeben": "Du hast deine Fremdeinschätzung freigegeben.",
"a.Du hast deine Selbsteinschätzung erfolgreich mit FULL_NAME geteilt.": "Du hast deine Selbsteinschätzung erfolgreich mit {{FULL_NAME}} geteilt.", "a.Du hast deine Selbsteinschätzung erfolgreich mit FULL_NAME geteilt.": "Du hast deine Selbsteinschätzung erfolgreich mit {{FULL_NAME}} geteilt.",
"a.Du hast die Anwesenheit bestätigt.": "Du hast die Anwesenheit bestätigt.",
"a.Du hast die Einladung von {name} erfolgreich akzeptiert.": "Du hast die Einladung von {name} erfolgreich akzeptiert.", "a.Du hast die Einladung von {name} erfolgreich akzeptiert.": "Du hast die Einladung von {name} erfolgreich akzeptiert.",
"a.Du hast erfolgreich ein Konto für EMAIL erstellt.": "Du hast erfolgreich ein Konto für {{email}} erstellt.", "a.Du hast erfolgreich ein Konto für EMAIL erstellt.": "Du hast erfolgreich ein Konto für {{email}} erstellt.",
"a.Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt.": "Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt.", "a.Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt.": "Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt.",
@ -310,6 +314,7 @@
"a.Überbetriebliche Kurse": "Überbetriebliche Kurse", "a.Überbetriebliche Kurse": "Überbetriebliche Kurse",
"a.Übergangslösung Innendienst-Mitarbeitende": "Übergangslösung Innendienst-Mitarbeitende", "a.Übergangslösung Innendienst-Mitarbeitende": "Übergangslösung Innendienst-Mitarbeitende",
"a.Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für FEEDBACK_REQUESTER frei": "Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für {{FEEDBACK_REQUESTER}} frei.", "a.Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für FEEDBACK_REQUESTER frei": "Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für {{FEEDBACK_REQUESTER}} frei.",
"a.Überprüfe jetzt die Anwesenheit.": "Überprüfe jetzt die Anwesenheit.",
"a.Übersicht": "Übersicht", "a.Übersicht": "Übersicht",
"a.Übersicht anschauen": "Übersicht anschauen", "a.Übersicht anschauen": "Übersicht anschauen",
"Abgabe": "Abgabe", "Abgabe": "Abgabe",

View File

@ -34,6 +34,7 @@
"a.An Durchführung teilnehmen": "Participer à la session", "a.An Durchführung teilnehmen": "Participer à la session",
"a.Anmelden": "Connexion", "a.Anmelden": "Connexion",
"a.Anwesenheit": "Présence", "a.Anwesenheit": "Présence",
"a.Anwesenheit anschauen": "Voir le contrôle de présence",
"a.Anwesenheit Präsenzkurse": "Présence aux cours", "a.Anwesenheit Präsenzkurse": "Présence aux cours",
"a.Anwesenheitskontrolle Präsenzkurse": "Contrôle de présence aux cours", "a.Anwesenheitskontrolle Präsenzkurse": "Contrôle de présence aux cours",
"a.Arbeiten": "Travaux", "a.Arbeiten": "Travaux",
@ -84,6 +85,8 @@
"a.Deine Änderungen wurden gespeichert": "Tes modifications ont été enregistrées", "a.Deine Änderungen wurden gespeichert": "Tes modifications ont été enregistrées",
"a.Der Lehrgang und die Prüfung zum Erwerb des Verbandszertifikats als Versicherungsvermittler/-in.": "Le cours et l'examen pour obtenir le certificat d'association comme courtier/agent d'assurance.", "a.Der Lehrgang und die Prüfung zum Erwerb des Verbandszertifikats als Versicherungsvermittler/-in.": "Le cours et l'examen pour obtenir le certificat d'association comme courtier/agent d'assurance.",
"a.Der Preis für den Lehrgang {course} beträgt {price}.": "Le prix de la formation {course} est de {price} hors TVA.", "a.Der Preis für den Lehrgang {course} beträgt {price}.": "Le prix de la formation {course} est de {price} hors TVA.",
"a.Der Präsenzkurs findet in {{days}} Tagen statt._one": "Le cours de présence se déroule en une jour.",
"a.Der Präsenzkurs findet in {{days}} Tagen statt._other": "Le cours de présence se déroule en {{count}} jours.",
"a.Details anschauen": "Voir les détails", "a.Details anschauen": "Voir les détails",
"a.Details anzeigen": "Afficher les détails", "a.Details anzeigen": "Afficher les détails",
"a.Deutsch": "Allemand", "a.Deutsch": "Allemand",
@ -95,6 +98,7 @@
"a.Du hast alles erledigt.": "Tu as tout fini.", "a.Du hast alles erledigt.": "Tu as tout fini.",
"a.Du hast deine Fremdeinschätzung freigegeben": "Tu as autorisé ton évaluation externe.", "a.Du hast deine Fremdeinschätzung freigegeben": "Tu as autorisé ton évaluation externe.",
"a.Du hast deine Selbsteinschätzung erfolgreich mit FULL_NAME geteilt.": "Tu as partagé avec succès ton auto-évaluation avec {{FULL_NAME}}.", "a.Du hast deine Selbsteinschätzung erfolgreich mit FULL_NAME geteilt.": "Tu as partagé avec succès ton auto-évaluation avec {{FULL_NAME}}.",
"a.Du hast die Anwesenheit bestätigt.": "Tu as confirmé la présence.",
"a.Du hast die Einladung von {name} erfolgreich akzeptiert.": "Tu as accepté avec succès l'invitation de {name}.", "a.Du hast die Einladung von {name} erfolgreich akzeptiert.": "Tu as accepté avec succès l'invitation de {name}.",
"a.Du hast erfolgreich ein Konto für EMAIL erstellt.": "Vous avez créé un compte avec succès pour {{email}}.", "a.Du hast erfolgreich ein Konto für EMAIL erstellt.": "Vous avez créé un compte avec succès pour {{email}}.",
"a.Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt.": "Tu peux partager ton auto-évaluation avec ton accompagnateur d'apprentissage afin qu'il puisse effectuer une évaluation externe.", "a.Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt.": "Tu peux partager ton auto-évaluation avec ton accompagnateur d'apprentissage afin qu'il puisse effectuer une évaluation externe.",
@ -310,6 +314,7 @@
"a.Überbetriebliche Kurse": "Cours interentreprises", "a.Überbetriebliche Kurse": "Cours interentreprises",
"a.Übergangslösung Innendienst-Mitarbeitende": "Solution transitoire pour le service interne", "a.Übergangslösung Innendienst-Mitarbeitende": "Solution transitoire pour le service interne",
"a.Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für FEEDBACK_REQUESTER frei": "Vérifie tes saisies ci-dessous et libère ensuite ton évaluation externe pour {{FEEDBACK_REQUESTER}}.", "a.Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für FEEDBACK_REQUESTER frei": "Vérifie tes saisies ci-dessous et libère ensuite ton évaluation externe pour {{FEEDBACK_REQUESTER}}.",
"a.Überprüfe jetzt die Anwesenheit.": "Vérifie maintenant la présence.",
"a.Übersicht": "Aperçu", "a.Übersicht": "Aperçu",
"a.Übersicht anschauen": "Consulter l'aperçu", "a.Übersicht anschauen": "Consulter l'aperçu",
"Abgabe": "Remise", "Abgabe": "Remise",

View File

@ -8,7 +8,6 @@
"a.Abgabetermin": "Termine di consegna", "a.Abgabetermin": "Termine di consegna",
"a.Abgezogene Punkte": "Punti detratti", "a.Abgezogene Punkte": "Punti detratti",
"a.Adresse": "Indirizzo", "a.Adresse": "Indirizzo",
"a.AGB": "Condizioni generali",
"a.Aktuell begleitest du niemanden als Lernbegleitung.": "Attualmente non stai accompagnando nessuno come mentore di apprendimento.", "a.Aktuell begleitest du niemanden als Lernbegleitung.": "Attualmente non stai accompagnando nessuno come mentore di apprendimento.",
"a.Aktuell begleitest du niemanden als Praxisbildner.": "Attualmente non stai accompagnando nessuno come formatore/-trice pratico/a.", "a.Aktuell begleitest du niemanden als Praxisbildner.": "Attualmente non stai accompagnando nessuno come formatore/-trice pratico/a.",
"a.Aktuell bist du leider keiner Durchführung zugewiesen.": "Attualmente non sei assegnato a nessuna sessione, purtroppo.", "a.Aktuell bist du leider keiner Durchführung zugewiesen.": "Attualmente non sei assegnato a nessuna sessione, purtroppo.",
@ -34,6 +33,7 @@
"a.An Durchführung teilnehmen": "Partecipare alla sessione", "a.An Durchführung teilnehmen": "Partecipare alla sessione",
"a.Anmelden": "Login", "a.Anmelden": "Login",
"a.Anwesenheit": "Presenza", "a.Anwesenheit": "Presenza",
"a.Anwesenheit anschauen": "Visualizza il controllo di presenza",
"a.Anwesenheit Präsenzkurse": "Presenza ai corsi", "a.Anwesenheit Präsenzkurse": "Presenza ai corsi",
"a.Anwesenheitskontrolle Präsenzkurse": "Controllo di presenza ai corsi", "a.Anwesenheitskontrolle Präsenzkurse": "Controllo di presenza ai corsi",
"a.Arbeiten": "Lavori", "a.Arbeiten": "Lavori",
@ -74,7 +74,6 @@
"a.Datei auswählen": "Selezionare il file", "a.Datei auswählen": "Selezionare il file",
"a.Datei hochladen": "Carica il file", "a.Datei hochladen": "Carica il file",
"a.Datei kann nicht gespeichert werden.": "Impossibile salvare il file.", "a.Datei kann nicht gespeichert werden.": "Impossibile salvare il file.",
"a.Datenschutzerklärung": "Informativa sulla privacy",
"a.Datum": "Data", "a.Datum": "Data",
"a.Debit-/Kreditkarte/Twint": "Carta di debito/credito / Twint", "a.Debit-/Kreditkarte/Twint": "Carta di debito/credito / Twint",
"a.Dein Feedback für x y wurde freigegeben.": "Il tuo feedback per {{x}} {{y}} è stato pubblicato.", "a.Dein Feedback für x y wurde freigegeben.": "Il tuo feedback per {{x}} {{y}} è stato pubblicato.",
@ -83,7 +82,9 @@
"a.Deine Selbsteinschätzung": "La tua autovalutazione", "a.Deine Selbsteinschätzung": "La tua autovalutazione",
"a.Deine Änderungen wurden gespeichert": "Le tue modifiche sono state salvate", "a.Deine Änderungen wurden gespeichert": "Le tue modifiche sono state salvate",
"a.Der Lehrgang und die Prüfung zum Erwerb des Verbandszertifikats als Versicherungsvermittler/-in.": "Il corso e l'esame per ottenere il certificato di associazione come intermediario/agente di assicurazione.", "a.Der Lehrgang und die Prüfung zum Erwerb des Verbandszertifikats als Versicherungsvermittler/-in.": "Il corso e l'esame per ottenere il certificato di associazione come intermediario/agente di assicurazione.",
"a.Der Preis für den Lehrgang {course} beträgt {price}.": "Il prezzo del {course} è {price} IVA esclusa.", "a.Der Preis für den Lehrgang {course} beträgt {price}.": "Il prezzo del {corso} è {prezzo} IVA esclusa.",
"a.Der Präsenzkurs findet in {{days}} Tagen statt._one": "Il corso di presenza si svolge in un giorno.",
"a.Der Präsenzkurs findet in {{days}} Tagen statt._other": "Il corso di presenza si svolge in {{count}} giorni.",
"a.Details anschauen": "Visualizza dettagli", "a.Details anschauen": "Visualizza dettagli",
"a.Details anzeigen": "Mostrare i dettagli", "a.Details anzeigen": "Mostrare i dettagli",
"a.Deutsch": "Tedesco", "a.Deutsch": "Tedesco",
@ -95,6 +96,7 @@
"a.Du hast alles erledigt.": "Hai fatto tutto.", "a.Du hast alles erledigt.": "Hai fatto tutto.",
"a.Du hast deine Fremdeinschätzung freigegeben": "Hai rilasciato la tua valutazione esterna.", "a.Du hast deine Fremdeinschätzung freigegeben": "Hai rilasciato la tua valutazione esterna.",
"a.Du hast deine Selbsteinschätzung erfolgreich mit FULL_NAME geteilt.": "Hai condiviso con successo la tua autovalutazione con {{FULL_NAME}}.", "a.Du hast deine Selbsteinschätzung erfolgreich mit FULL_NAME geteilt.": "Hai condiviso con successo la tua autovalutazione con {{FULL_NAME}}.",
"a.Du hast die Anwesenheit bestätigt.": "Hai confermato la presenza.",
"a.Du hast die Einladung von {name} erfolgreich akzeptiert.": "Hai accettato con successo l'invito di {name}.", "a.Du hast die Einladung von {name} erfolgreich akzeptiert.": "Hai accettato con successo l'invito di {name}.",
"a.Du hast erfolgreich ein Konto für EMAIL erstellt.": "Hai creato con successo un account per {{email}}.", "a.Du hast erfolgreich ein Konto für EMAIL erstellt.": "Hai creato con successo un account per {{email}}.",
"a.Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt.": "Puoi condividere la tua autovalutazione con il tuo tutor didattico affinché possa effettuare una valutazione esterna.", "a.Du kannst deine Selbsteinschätzung mit deiner Lernbegleitung teilen, damit sie eine Fremdeinschätzung vornimmt.": "Puoi condividere la tua autovalutazione con il tuo tutor didattico affinché possa effettuare una valutazione esterna.",
@ -117,7 +119,6 @@
"a.Ergebnisse bewerten": "Valutare i risultati", "a.Ergebnisse bewerten": "Valutare i risultati",
"a.Ergebnisse teilen": "Condividere i risultati", "a.Ergebnisse teilen": "Condividere i risultati",
"a.Erneut bearbeiten": "Modifica di nuovo", "a.Erneut bearbeiten": "Modifica di nuovo",
"a.Es gelten die {cembraTos} und die {cembraPrivacy} der CembraPay AG.": "Si applicano le {cembraTos} e l'{cembraPrivacy} di CembraPay AG.",
"a.Experte": "Esperto", "a.Experte": "Esperto",
"a.Feedback abschliessen": "Completa il feedback", "a.Feedback abschliessen": "Completa il feedback",
"a.Feedback ansehen": "Visualizza il feedback", "a.Feedback ansehen": "Visualizza il feedback",
@ -213,7 +214,6 @@
"a.Nicht bestanden": "Non superato", "a.Nicht bestanden": "Non superato",
"a.Nicht bewertet": "Non valutato", "a.Nicht bewertet": "Non valutato",
"a.Nichtleben": "Non vita", "a.Nichtleben": "Non vita",
"a.Noch nicht bestätigt": "Non ancora confermato",
"a.Note": "Note", "a.Note": "Note",
"a.NUMBER Elemente abgeschlossen": "{NUMBER} elementi completati", "a.NUMBER Elemente abgeschlossen": "{NUMBER} elementi completati",
"a.NUMBER Präsenztage abgeschlossen": "{NUMBER} giorni di presenza completati", "a.NUMBER Präsenztage abgeschlossen": "{NUMBER} giorni di presenza completati",
@ -225,7 +225,6 @@
"a.Personen, die du begleitest": "Persone che accompagni", "a.Personen, die du begleitest": "Persone che accompagni",
"a.Persönliche Informationen": "Informazioni personali", "a.Persönliche Informationen": "Informazioni personali",
"a.PLZ": "CAP", "a.PLZ": "CAP",
"a.Postleizahl hat das falsche Format": "Il codice postale ha un formato sbagliato",
"a.Praxisauftrag": "Lavoro pratico", "a.Praxisauftrag": "Lavoro pratico",
"a.Praxisaufträge anschauen": "Visualizzare gli incarichi pratici", "a.Praxisaufträge anschauen": "Visualizzare gli incarichi pratici",
"a.Praxisbildner": "Formatore pratico", "a.Praxisbildner": "Formatore pratico",
@ -270,7 +269,6 @@
"a.Teilnehmer": "Partecipanti", "a.Teilnehmer": "Partecipanti",
"a.Teilnehmer im": "I partecipanti al", "a.Teilnehmer im": "I partecipanti al",
"a.Teilnehmer nach Zulassungsprofilen im": "Partecipanti per profilo di ammissione nel", "a.Teilnehmer nach Zulassungsprofilen im": "Partecipanti per profilo di ammissione nel",
"a.Teilnehmer Vorschau": "Anteprima dei partecipanti",
"a.Telefonnummer": "Numero di telefono", "a.Telefonnummer": "Numero di telefono",
"a.Telefonnummer hat das falsche Format": "Il numero di telefono ha un formato sbagliato", "a.Telefonnummer hat das falsche Format": "Il numero di telefono ha un formato sbagliato",
"a.Termin": "Data", "a.Termin": "Data",
@ -310,6 +308,7 @@
"a.Überbetriebliche Kurse": "Corsi interaziendali", "a.Überbetriebliche Kurse": "Corsi interaziendali",
"a.Übergangslösung Innendienst-Mitarbeitende": "Soluzione transitoria per il servizio interno", "a.Übergangslösung Innendienst-Mitarbeitende": "Soluzione transitoria per il servizio interno",
"a.Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für FEEDBACK_REQUESTER frei": "Controlla le tue voci qui sotto e poi rilascia la tua valutazione esterna per {{FEEDBACK_REQUESTER}}.", "a.Überprüfe deine Eingaben unten und gib anschliessend deine Fremdeinschätzung für FEEDBACK_REQUESTER frei": "Controlla le tue voci qui sotto e poi rilascia la tua valutazione esterna per {{FEEDBACK_REQUESTER}}.",
"a.Überprüfe jetzt die Anwesenheit.": "Controllare la presenza ora.",
"a.Übersicht": "Panoramica", "a.Übersicht": "Panoramica",
"a.Übersicht anschauen": "Vedere la panoramica", "a.Übersicht anschauen": "Vedere la panoramica",
"Abgabe": "Consegna", "Abgabe": "Consegna",
@ -357,8 +356,6 @@
"Berufsbildner": "Formatore professionale", "Berufsbildner": "Formatore professionale",
"Bestanden": "Superato", "Bestanden": "Superato",
"Bewertung von x y": "Valutazione di {{x}} {{y}}", "Bewertung von x y": "Valutazione di {{x}} {{y}}",
"cembraPrivacyLink": "https://cembrapay.ch/it/privacy",
"cembraTosLink": "https://cembrapay.ch/it/terms/CP",
"Circle": "Cerchio", "Circle": "Cerchio",
"circlePage.circleContentBoxTitle": "Cosa apprenderai in questo Circle", "circlePage.circleContentBoxTitle": "Cosa apprenderai in questo Circle",
"circlePage.contactExpertButton": "Contattare il/la trainer", "circlePage.contactExpertButton": "Contattare il/la trainer",

View File

@ -1,27 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import ItCheckbox from "@/components/ui/ItCheckbox.vue"; import ItCheckbox from "@/components/ui/ItCheckbox.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import ItPersonRow from "@/components/ui/ItPersonRow.vue"; import ItPersonRow from "@/components/ui/ItPersonRow.vue";
import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables"; import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables";
import type { AttendanceUserStatus } from "@/gql/graphql"; import type {
import { graphqlClient } from "@/graphql/client"; AttendanceUserStatus,
CourseSessionAttendanceCourseObjectType,
} from "@/gql/graphql";
import { ATTENDANCE_CHECK_MUTATION } from "@/graphql/mutations"; import { ATTENDANCE_CHECK_MUTATION } from "@/graphql/mutations";
import { ATTENDANCE_CHECK_QUERY } from "@/graphql/queries"; import { ATTENDANCE_CHECK_QUERY } from "@/graphql/queries";
import { exportAttendance } from "@/services/dashboard"; import { exportAttendance } from "@/services/dashboard";
import { useExpertCockpitStore } from "@/stores/expertCockpit";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import type { DropdownSelectable } from "@/types";
import { openDataAsXls } from "@/utils/export"; import { openDataAsXls } from "@/utils/export";
import { useMutation } from "@urql/vue"; import { useMutation, useQuery } from "@urql/vue";
import dayjs from "dayjs"; import { useDateFormat } from "@vueuse/core";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
import log from "loglevel"; import log from "loglevel";
import { computed, onMounted, reactive, watch } from "vue"; import { computed, onMounted, ref } from "vue";
import AttendanceCheck from "../cockpitPage/AttendanceCheck.vue";
import AttendanceStatus from "../cockpitPage/AttendanceStatus.vue";
const { t } = useTranslation();
const attendanceMutation = useMutation(ATTENDANCE_CHECK_MUTATION); const attendanceMutation = useMutation(ATTENDANCE_CHECK_MUTATION);
const courseSessionDetailResult = useCourseSessionDetailQuery(); const courseSessionDetailResult = useCourseSessionDetailQuery();
const userStore = useUserStore(); const userStore = useUserStore();
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();
const expertCockpitStore = useExpertCockpitStore();
const attendanceCourses = computed(() => { const attendanceCourses = computed(() => {
return courseSessionDetailResult.courseSessionDetail.value?.attendance_courses ?? []; return courseSessionDetailResult.courseSessionDetail.value?.attendance_courses ?? [];
@ -31,46 +34,18 @@ const courseSessionDetail = computed(() => {
return courseSessionDetailResult.courseSessionDetail.value; return courseSessionDetailResult.courseSessionDetail.value;
}); });
const attendanceCourseCircleId = computed(() => { const currentCourse = computed(() => expertCockpitStore.currentCourse);
const selectedAttendandeCourse = attendanceCourses.value.find(
(course) => course.id === state.attendanceCourseSelected.id
);
return selectedAttendandeCourse?.learning_content?.circle?.id;
});
const presenceCoursesDropdownOptions = computed(() => { const userPresence = ref(new Map<string, boolean>());
return attendanceCourses.value.map( const disclaimerConfirmed = ref(false);
(attendanceCourse) => const attendanceSaved = ref(false);
({
id: attendanceCourse.id,
name: `${t("a.Präsenzkurs")} ${
attendanceCourse.learning_content.circle?.title
} ${dayjs(attendanceCourse.due_date?.start).format("DD.MM.YYYY")}`,
}) as DropdownSelectable
);
});
const state = reactive({ const { t } = useTranslation();
userPresence: new Map<string, boolean>(),
attendanceCourseSelected: presenceCoursesDropdownOptions.value[0],
disclaimerConfirmed: false,
attendanceSaved: false,
});
watch(
attendanceCourses,
(newVal) => {
if (newVal && newVal.length > 0) {
state.attendanceCourseSelected = presenceCoursesDropdownOptions.value[0];
}
},
{ immediate: true }
);
function resetState() { function resetState() {
state.userPresence = new Map<string, boolean>(); userPresence.value = new Map<string, boolean>();
state.disclaimerConfirmed = false; disclaimerConfirmed.value = false;
state.attendanceSaved = false; attendanceSaved.value = false;
} }
const onSubmit = async () => { const onSubmit = async () => {
@ -78,78 +53,89 @@ const onSubmit = async () => {
user_id: string; user_id: string;
status: AttendanceUserStatus; status: AttendanceUserStatus;
}; };
const attendanceUserList: UserPresence[] = Array.from(state.userPresence.keys()).map( const attendanceUserList: UserPresence[] = Array.from(userPresence.value.keys()).map(
(key) => ({ (key) => ({
user_id: key, user_id: key,
status: state.userPresence.get(key) ? "PRESENT" : "ABSENT", status: userPresence.value.get(key) ? "PRESENT" : "ABSENT",
}) })
); );
const res = await attendanceMutation.executeMutation({ const res = await attendanceMutation.executeMutation({
attendanceCourseId: state.attendanceCourseSelected.id.toString(), attendanceCourseId: (
currentCourse.value as CourseSessionAttendanceCourseObjectType
).id.toString(),
attendanceUserList: attendanceUserList, attendanceUserList: attendanceUserList,
}); });
if (res.error) { if (res.error) {
log.error("Could not submit attendance check: ", res.error); log.error("Could not submit attendance check: ", res.error);
return; return;
} }
state.disclaimerConfirmed = false; disclaimerConfirmed.value = false;
state.attendanceSaved = true; attendanceSaved.value = true;
log.info("Attendance check submitted: ", res); log.info("Attendance check submitted: ", res);
}; };
const loadAttendanceData = async () => { const loadAttendanceData = async () => {
resetState(); resetState();
// with changing variables `useQuery` does not seem to work correctly // with changing variables `useQuery` does not seem to work correctly
if (state.attendanceCourseSelected) { if (currentCourse.value) {
const res = await graphqlClient.query( const result = await useQuery({
ATTENDANCE_CHECK_QUERY, query: ATTENDANCE_CHECK_QUERY,
{ variables: {
courseSessionId: state.attendanceCourseSelected.id.toString(), courseSessionId: currentCourse.value.id.toString(),
}, },
{ requestPolicy: "network-only",
requestPolicy: "network-only", });
}
);
const attendanceUserList = const attendanceUserList =
res.data?.course_session_attendance_course?.attendance_user_list ?? []; result.data?.value?.course_session_attendance_course?.attendance_user_list ?? [];
for (const user of attendanceUserList) { for (const user of attendanceUserList) {
if (!user) continue; if (!user) continue;
state.userPresence.set(user.user_id, user.status === "PRESENT"); userPresence.value.set(user.user_id, user.status === "PRESENT");
} }
if (attendanceUserList.length !== 0) { if (attendanceUserList.length !== 0) {
state.attendanceSaved = true; attendanceSaved.value = true;
} }
} }
}; };
function editAgain() { function editAgain() {
state.attendanceSaved = false; attendanceSaved.value = false;
} }
const toggleDisclaimer = (newValue: boolean) => {
disclaimerConfirmed.value = newValue;
};
async function exportData() { async function exportData() {
const data = await exportAttendance( const data = await exportAttendance(
{ {
courseSessionIds: [Number(courseSession.value.id)], courseSessionIds: [Number(courseSession.value.id)],
circleIds: [Number(attendanceCourseCircleId.value)], circleIds: [Number(currentCourse.value?.learning_content.circle?.id)],
}, },
userStore.language userStore.language
); );
openDataAsXls(data.encoded_data, data.file_name); openDataAsXls(data.encoded_data, data.file_name);
} }
onMounted(() => { onMounted(async () => {
log.debug("AttendanceCheckPage mounted"); log.debug("AttendanceCheckPage mounted");
loadAttendanceData(); loadAttendanceData();
}); });
watch( const courseDueDate = computed(() => {
() => state.attendanceCourseSelected, if (currentCourse.value && currentCourse.value.due_date?.start) {
() => { return currentCourse.value.due_date.start;
log.debug("attendanceCourseSelected changed", state.attendanceCourseSelected); }
loadAttendanceData(); return "";
}, });
{ immediate: true }
); const formattedCourseDueDate = computed(() => {
if (courseDueDate.value) {
return useDateFormat(courseDueDate.value, "D. MMMM YYYY", {
locales: "de-CH",
});
}
return "";
});
</script> </script>
<template> <template>
@ -164,66 +150,50 @@ watch(
<span>{{ $t("general.back") }}</span> <span>{{ $t("general.back") }}</span>
</router-link> </router-link>
</nav> </nav>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between"></div>
<h3 class="pb-4 text-xl font-bold">{{ $t("a.Anwesenheit Präsenzkurse") }}</h3> <section v-if="attendanceCourses.length && currentCourse">
<button <div class="grid grid-cols-[2fr_1fr] justify-between gap-8 bg-white py-6">
v-if="state.attendanceSaved" <div class="col-span-1 flex flex-col gap-2 px-6">
class="flex" <h3 class="pb-1 text-4xl font-bold">{{ $t("a.Präsenzkurs") }}</h3>
data-cy="export-button" <h5>
@click="exportData" {{ t("a.Circle") }} «{{ currentCourse?.learning_content.circle?.title }}»
> </h5>
<it-icon-export></it-icon-export> <h5>{{ formattedCourseDueDate }}</h5>
<span class="ml inline-block">{{ $t("a.Als Excel exportieren") }}</span> </div>
</button> <button
</div> v-if="attendanceSaved"
<section v-if="attendanceCourses.length && state.attendanceCourseSelected"> class="col-span-1 mr-4 hidden justify-self-end lg:flex"
<div class="flex flex-row flex-wrap justify-between bg-white p-6"> data-cy="export-button"
<ItDropdownSelect @click="exportData"
v-model="state.attendanceCourseSelected"
:items="presenceCoursesDropdownOptions ?? []"
></ItDropdownSelect>
<div
v-if="!state.attendanceSaved"
class="flex flex-row flex-wrap items-center space-y-2 md:space-y-0"
> >
<ItCheckbox <it-icon-export class="fill-current text-blue-900"></it-icon-export>
:checkbox-item="{ <span class="ml inline-block text-blue-900">
value: true, {{ $t("a.Als Excel exportieren") }}
checked: state.disclaimerConfirmed, </span>
}" </button>
@toggle="state.disclaimerConfirmed = !state.disclaimerConfirmed" <div
></ItCheckbox> class="col-span-2 flex flex-col items-start gap-4 px-6 lg:gap-6"
<p class="w-64 pr-4 text-sm"> :class="attendanceSaved ? 'lg:flex-row lg:items-center' : 'gap-8 lg:gap-8'"
{{ >
$t( <AttendanceStatus
"Ich will die Anwesenheit der untenstehenden Personen definitiv bestätigen." class="inline-flex px-6"
) :done="attendanceSaved"
}} :date="courseDueDate"
</p> />
<button
class="btn-primary"
:disabled="!state.disclaimerConfirmed"
@click="onSubmit"
>
{{ $t("Anwesenheit bestätigen") }}
</button>
</div>
<div v-else class="self-center">
<p class="text-base">
{{ $t("a.Die Anwesenheit wurde definitiv bestätigt") }}
</p>
<button class="btn-link link" @click="editAgain()">
{{ $t("a.Erneut bearbeiten") }}
</button>
</div>
</div>
<div class="mt-4 flex flex-col bg-white p-6"> <AttendanceCheck
<div :attendance-saved="attendanceSaved"
v-for="(csu, index) in courseSessionDetailResult.filterMembers()" :disclaimer-confirmed="disclaimerConfirmed"
:key="csu.user_id" @reopen="editAgain"
> @toggle="toggleDisclaimer"
@confirm="onSubmit"
/>
</div>
<div class="col-span-2 border-t border-gray-500 px-6">
<ItPersonRow <ItPersonRow
v-for="(csu, index) in courseSessionDetailResult.filterMembers()"
:key="csu.user_id"
:name="`${csu.first_name} ${csu.last_name}`" :name="`${csu.first_name} ${csu.last_name}`"
:avatar-url="csu.avatar_url" :avatar-url="csu.avatar_url"
:class="0 === index ? 'border-none' : ''" :class="0 === index ? 'border-none' : ''"
@ -233,16 +203,13 @@ watch(
> >
<template #leading> <template #leading>
<ItCheckbox <ItCheckbox
:disabled="state.attendanceSaved" :disabled="attendanceSaved"
:checkbox-item="{ :checkbox-item="{
value: true, value: true,
checked: state.userPresence.get(csu.user_id) as boolean, checked: userPresence.get(csu.user_id) as boolean,
}" }"
@toggle=" @toggle="
state.userPresence.set( userPresence.set(csu.user_id, !userPresence.get(csu.user_id))
csu.user_id,
!state.userPresence.get(csu.user_id)
)
" "
></ItCheckbox> ></ItCheckbox>
</template> </template>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import ItCheckbox from "@/components/ui/ItCheckbox.vue";
export interface Props {
attendanceSaved: boolean;
disclaimerConfirmed: boolean;
}
defineProps<Props>();
defineEmits(["toggle", "reopen", "confirm"]);
</script>
<template>
<div v-if="!attendanceSaved" class="flex flex-col gap-4">
<div class="flex flex-row content-center items-center">
<ItCheckbox
:checkbox-item="{
value: true,
checked: disclaimerConfirmed,
}"
@toggle="$emit('toggle', !disclaimerConfirmed)"
></ItCheckbox>
<p class="text-sm">
{{
$t(
"Ich will die Anwesenheit der untenstehenden Personen definitiv bestätigen."
)
}}
</p>
</div>
<button
class="btn-primary w-64"
:disabled="!disclaimerConfirmed"
@click="$emit('confirm')"
>
{{ $t("Anwesenheit bestätigen") }}
</button>
</div>
<div v-else class="flex-inline">
<button class="btn-link link" @click="$emit('reopen')">
{{ $t("a.Erneut bearbeiten") }}
</button>
</div>
</template>

View File

@ -0,0 +1,81 @@
<script lang="ts" setup>
import type { CourseSessionAttendanceCourseObjectType } from "@/gql/graphql";
import { ATTENDANCE_CHECK_QUERY } from "@/graphql/queries";
import { ATTENDANCE_ROUTE } from "@/router/names";
import { useExpertCockpitStore } from "@/stores/expertCockpit";
import { getStatus } from "@/utils/attendance";
import { useQuery } from "@urql/vue";
import { useDateFormat } from "@vueuse/core";
import { useTranslation } from "i18next-vue";
import { computed } from "vue";
import AttendanceStatus from "./AttendanceStatus.vue";
const attendanceRoute = {
name: ATTENDANCE_ROUTE,
};
const { t } = useTranslation();
const expertCockpitStore = useExpertCockpitStore();
const currentCourse = computed(() => expertCockpitStore.currentCourse);
const shouldPause = computed(() => !currentCourse.value);
const result = useQuery({
query: ATTENDANCE_CHECK_QUERY,
variables: () => ({
courseSessionId: (
currentCourse.value as CourseSessionAttendanceCourseObjectType
).id.toString(),
}),
pause: shouldPause,
});
// todo: maybe we can move these next 3 computed values somewhere else, as they are also used in the AttendanceCheckPage component
const courseDueDate = computed(() => {
if (currentCourse.value && currentCourse.value.due_date?.start) {
return currentCourse.value.due_date.start;
}
return "";
});
const attendanceSaved = computed(() => {
const attendanceUserList =
result.data?.value?.course_session_attendance_course?.attendance_user_list ?? [];
return attendanceUserList.length !== 0;
});
const formattedCourseDueDate = computed(() => {
if (courseDueDate.value) {
return useDateFormat(courseDueDate.value, "D. MMMM YYYY", {
locales: "de-CH",
});
}
return "";
});
const status = computed(() => {
return getStatus(attendanceSaved.value, courseDueDate.value);
});
</script>
<template>
<div
class="my-4 flex flex-col items-start justify-between gap-4 bg-white p-6 lg:my-0 lg:flex-row lg:items-center lg:gap-0"
>
<div>
<h2 class="text-base font-bold">{{ t("a.Präsenzkurs") }}</h2>
<p class="text-sm text-gray-800">{{ formattedCourseDueDate }}</p>
</div>
<AttendanceStatus :date="courseDueDate" :done="attendanceSaved" />
<router-link
:to="attendanceRoute"
:class="
status === 'now' ? 'bg-blue-900 px-4 py-2 font-bold text-white' : 'underline'
"
>
<template v-if="status === 'now'">
{{ $t("Anwesenheit prüfen") }}
</template>
<template v-else>{{ $t("a.Anwesenheit anschauen") }}</template>
</router-link>
</div>
</template>

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import { howManyDaysInFuture } from "@/components/dueDates/dueDatesUtils";
import { getStatus } from "@/utils/attendance";
import { useTranslation } from "i18next-vue";
import { computed } from "vue";
export interface Props {
done: boolean;
date: string;
}
const { t } = useTranslation();
const props = defineProps<Props>();
const status = computed(() => {
return getStatus(props.done, props.date);
});
const style = computed(() => {
switch (status.value) {
case "done":
return "bg-green-200";
case "soon":
return "bg-gray-200";
case "now":
default:
return "bg-sky-200";
}
});
const icon = computed(() => {
switch (status.value) {
case "done":
return "it-icon-check";
case "soon":
case "now":
default:
return "it-icon-info";
}
});
const days = computed(() => {
return howManyDaysInFuture(props.date);
});
const text = computed(() => {
switch (status.value) {
case "done":
return t("a.Du hast die Anwesenheit bestätigt.");
case "soon":
return t("a.Der Präsenzkurs findet in {{days}} Tagen statt.", {
count: days.value,
});
case "now":
default:
return t("a.Überprüfe jetzt die Anwesenheit.");
}
});
</script>
<template>
<div
class="space-between inline-flex flex-row items-center gap-1 rounded py-1 pl-2 pr-4"
:class="style"
>
<component :is="icon" class="h-7 w-7" />
<p>{{ text }}</p>
</div>
</template>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import SubmissionsOverview from "@/components/cockpit/SubmissionsOverview.vue";
import UserStatusCount from "@/components/cockpit/UserStatusCount.vue";
import CourseSessionDueDatesList from "@/components/dueDates/CourseSessionDueDatesList.vue";
import LearningPathDiagram from "@/components/learningPath/LearningPathDiagram.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import ItPersonRow from "@/components/ui/ItPersonRow.vue";
import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables";
import { useExpertCockpitStore } from "@/stores/expertCockpit";
import AttendanceOverview from "./AttendanceOverview.vue";
const expertCockpitStore = useExpertCockpitStore();
const courseSession = useCurrentCourseSession();
const courseSessionDetailResult = useCourseSessionDetailQuery();
const props = defineProps<{
courseSlug: string;
}>();
</script>
<template>
<div v-if="expertCockpitStore.circles?.length">
<div v-if="expertCockpitStore.currentCircle" class="container-large pt-10">
<div class="mb-9 flex flex-col lg:flex-row lg:items-center lg:justify-between">
<ItDropdownSelect
:as-heading="true"
:model-value="expertCockpitStore.currentCircle"
type-name="Circle:"
class="mt-4 w-full lg:mt-0 lg:w-auto"
:items="expertCockpitStore.circles"
@update:model-value="expertCockpitStore.setCurrentCourseCircleFromEvent"
></ItDropdownSelect>
</div>
<!-- Status -->
<div class="mb-4 gap-4">
<AttendanceOverview />
</div>
<div class="mb-4 bg-white p-6">
<CourseSessionDueDatesList
:course-session-id="courseSession.id"
:circle-id="expertCockpitStore.currentCircle.id"
:max-count="4"
></CourseSessionDueDatesList>
</div>
<SubmissionsOverview
:course-session="courseSession"
:selected-circle="expertCockpitStore.currentCircle.id"
></SubmissionsOverview>
<div class="pt-4">
<!-- progress -->
<div
v-if="courseSessionDetailResult.filterMembers().length > 0"
class="bg-white p-6"
>
<h1 class="heading-3 mb-5">{{ $t("cockpit.progress") }}</h1>
<ul>
<ItPersonRow
v-for="csu in courseSessionDetailResult.filterMembers()"
:key="csu.user_id"
:name="`${csu.first_name} ${csu.last_name}`"
:avatar-url="csu.avatar_url"
>
<template #center>
<div
class="mt-2 flex w-full flex-col items-center justify-start lg:mt-0 lg:flex-row"
>
<LearningPathDiagram
:course-session-id="courseSession.id"
:course-slug="props.courseSlug"
:user-id="csu.user_id"
:show-circle-slugs="[expertCockpitStore.currentCircle.slug]"
diagram-type="singleSmall"
class="mr-4"
></LearningPathDiagram>
<p class="lg:min-w-[150px]">
{{ expertCockpitStore.currentCircle.title }}
</p>
<UserStatusCount
:course-slug="props.courseSlug"
:user-id="csu.user_id"
></UserStatusCount>
</div>
</template>
<template #link>
<router-link
:to="{
name: 'profileLearningPath',
params: { userId: csu.user_id, courseSlug: props.courseSlug },
}"
class="link w-full lg:text-right"
>
{{ $t("general.profileLink") }}
</router-link>
</template>
</ItPersonRow>
</ul>
</div>
</div>
</div>
<div v-else class="container-large mt-4">
<!-- No circle selected -->
<span class="text-lg text-orange-600">
{{ $t("a.Kein Circle verfügbar oder ausgewählt.") }}
</span>
</div>
</div>
<div v-else class="container-large mt-4">
<span class="text-lg text-orange-600">
<!-- No circle at all (should never happen, mostly
for us to reduce confusion why the cockpit is just empty...) -->
{{ $t("a.Kein Circle verfügbar oder ausgewählt.") }}
</span>
</div>
</template>

View File

@ -1,186 +0,0 @@
<script setup lang="ts">
import LearningPathDiagram from "@/components/learningPath/LearningPathDiagram.vue";
import ItPersonRow from "@/components/ui/ItPersonRow.vue";
import SubmissionsOverview from "@/components/cockpit/SubmissionsOverview.vue";
import UserStatusCount from "@/components/cockpit/UserStatusCount.vue";
import CourseSessionDueDatesList from "@/components/dueDates/CourseSessionDueDatesList.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
import { useExpertCockpitStore } from "@/stores/expertCockpit";
import log from "loglevel";
const props = defineProps<{
courseSlug: string;
}>();
log.debug("CockpitIndexPage created", props.courseSlug);
const { loading } = useExpertCockpitPageData(props.courseSlug);
const expertCockpitStore = useExpertCockpitStore();
const courseSession = useCurrentCourseSession();
const courseSessionDetailResult = useCourseSessionDetailQuery();
</script>
<template>
<div v-if="!loading" class="bg-gray-200">
<div v-if="expertCockpitStore.circles?.length">
<div v-if="expertCockpitStore.currentCircle" class="container-large">
<div class="mb-9 flex flex-col lg:flex-row lg:items-center lg:justify-between">
<h1>Cockpit</h1>
<ItDropdownSelect
:model-value="expertCockpitStore.currentCircle"
class="mt-4 w-full lg:mt-0 lg:w-96"
:items="expertCockpitStore.circles"
@update:model-value="expertCockpitStore.setCurrentCourseCircleFromEvent"
></ItDropdownSelect>
</div>
<!-- Status -->
<div class="mb-4 gap-4 lg:grid lg:grid-cols-3 lg:grid-rows-none">
<div class="my-4 flex flex-col justify-between bg-white p-6 lg:my-0">
<div>
<h3 class="heading-3 mb-4 flex items-center gap-2">
{{ $t("Trainerunterlagen") }}
</h3>
<div class="mb-4">
{{ $t("cockpit.trainerFilesText") }}
</div>
</div>
<div>
<a
href="https://vbvbern.sharepoint.com/sites/myVBV-AFA_K-CI"
class="btn-secondary min-w-min"
target="_blank"
>
{{ $t("MS Teams öffnen") }}
</a>
</div>
</div>
<div
v-if="courseSession.course.configuration.enable_circle_documents"
class="my-4 flex flex-col justify-between bg-white p-6 lg:my-0"
data-cy="circle-documents"
>
<div>
<h3 class="heading-3 mb-4 flex items-center gap-2">
{{ $t("a.Unterlagen für Teilnehmenden") }}
</h3>
<div class="mb-4">
{{ $t("a.Stelle deinen Lernenden zusätzliche Inhalte zur Verfügung.") }}
</div>
</div>
<div>
<router-link
:to="`/course/${props.courseSlug}/cockpit/documents`"
class="btn-secondary min-w-min"
>
{{ $t("a.Zum Unterlagen-Upload") }}
</router-link>
</div>
</div>
<div class="my-4 flex flex-col justify-between bg-white p-6 lg:my-0">
<div>
<h3 class="heading-3 mb-4 flex items-center gap-2">
{{ $t("a.Anwesenheitskontrolle Präsenzkurse") }}
</h3>
<div class="mb-4">
{{
$t(
"Hier überprüfst und bestätigst du die Anwesenheit deiner Teilnehmenden."
)
}}
</div>
</div>
<div>
<router-link
:to="`/course/${props.courseSlug}/cockpit/attendance`"
class="btn-secondary min-w-min"
>
{{ $t("Anwesenheit prüfen") }}
</router-link>
</div>
</div>
</div>
<div class="mb-4 bg-white p-6">
<CourseSessionDueDatesList
:course-session-id="courseSession.id"
:circle-id="expertCockpitStore.currentCircle.id"
:max-count="4"
></CourseSessionDueDatesList>
</div>
<SubmissionsOverview
:course-session="courseSession"
:selected-circle="expertCockpitStore.currentCircle.id"
></SubmissionsOverview>
<div class="pt-4">
<!-- progress -->
<div
v-if="courseSessionDetailResult.filterMembers().length > 0"
class="bg-white p-6"
>
<h1 class="heading-3 mb-5">{{ $t("cockpit.progress") }}</h1>
<ul>
<ItPersonRow
v-for="csu in courseSessionDetailResult.filterMembers()"
:key="csu.user_id"
:name="`${csu.first_name} ${csu.last_name}`"
:avatar-url="csu.avatar_url"
>
<template #center>
<div
class="mt-2 flex w-full flex-col items-center justify-start lg:mt-0 lg:flex-row"
>
<LearningPathDiagram
:course-session-id="courseSession.id"
:course-slug="props.courseSlug"
:user-id="csu.user_id"
:show-circle-slugs="[expertCockpitStore.currentCircle.slug]"
diagram-type="singleSmall"
class="mr-4"
></LearningPathDiagram>
<p class="lg:min-w-[150px]">
{{ expertCockpitStore.currentCircle.title }}
</p>
<UserStatusCount
:course-slug="props.courseSlug"
:user-id="csu.user_id"
></UserStatusCount>
</div>
</template>
<template #link>
<router-link
:to="{
name: 'profileLearningPath',
params: { userId: csu.user_id, courseSlug: props.courseSlug },
}"
class="link w-full lg:text-right"
>
{{ $t("general.profileLink") }}
</router-link>
</template>
</ItPersonRow>
</ul>
</div>
</div>
</div>
<div v-else class="container-large mt-4">
<!-- No circle selected -->
<span class="text-lg text-orange-600">
{{ $t("a.Kein Circle verfügbar oder ausgewählt.") }}
</span>
</div>
</div>
<div v-else class="container-large mt-4">
<span class="text-lg text-orange-600">
<!-- No circle at all (should never happen, mostly
for us to reduce confusion why the cockpit is just empty...) -->
{{ $t("a.Kein Circle verfügbar oder ausgewählt.") }}
</span>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,70 @@
<script setup lang="ts">
import SubNavigation from "@/components/header/SubNavigation.vue";
import { useCurrentCourseSession } from "@/composables";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
import { COCKPIT_ROUTE, DOCUMENTS_ROUTE } from "@/router/names";
import { useTranslation } from "i18next-vue";
import log from "loglevel";
import { computed } from "vue";
const props = defineProps<{
courseSlug: string;
}>();
log.debug("CockpitIndexPage created", props.courseSlug);
const { loading } = useExpertCockpitPageData(props.courseSlug);
const defaultRoute = {
name: COCKPIT_ROUTE,
};
// const attendanceRoute = {
// name: ATTENDANCE_ROUTE,
// };
const documentsRoute = {
name: DOCUMENTS_ROUTE,
};
const { t } = useTranslation();
const courseSession = useCurrentCourseSession();
const enableDocuments = computed(() => {
return !!courseSession.value.course.configuration.enable_circle_documents;
});
const items = computed(() => [
{ id: 1, name: t("a.Übersicht"), route: defaultRoute },
// { id: 2, name: t("a.Teilnehmer"), route: attendanceRoute }, // todo: re-enable with correct route in a later issue
...(enableDocuments.value
? [
{
id: 3,
name: t("a.Unterlagen"),
route: documentsRoute,
dataCy: "circle-documents",
},
]
: []),
{
id: 4,
name: "Vorschau Teilnehmer",
route: "https://iterativ.ch",
},
{
id: 5,
name: "MS Teams",
route: "https://vbvbern.sharepoint.com/sites/myVBV-AFA_K-CI",
},
]);
</script>
<template>
<div v-if="!loading" class="bg-gray-200">
<SubNavigation :items="items" />
<router-view />
</div>
</template>
<style scoped></style>

View File

@ -136,21 +136,13 @@ async function uploadDocument(data: DocumentUploadData) {
<template> <template>
<div class="bg-gray-200"> <div class="bg-gray-200">
<div v-if="courseSession" class="container-large"> <div v-if="courseSession" class="container-large">
<nav class="py-4 pb-4"> <main class="py-4">
<router-link
class="btn-text inline-flex items-center pl-0"
:to="`/course/${courseSession.course.slug}/cockpit`"
>
<it-icon-arrow-left />
<span>{{ t("general.back") }}</span>
</router-link>
</nav>
<main>
<div class="mb-9 flex flex-col lg:flex-row lg:items-center lg:justify-between"> <div class="mb-9 flex flex-col lg:flex-row lg:items-center lg:justify-between">
<h2>{{ t("a.Unterlagen für Teilnehmenden") }}</h2>
<ItDropdownSelect <ItDropdownSelect
:model-value="cockpitStore.currentCircle" :model-value="cockpitStore.currentCircle"
class="mt-4 w-full lg:mt-0 lg:w-96" class="mt-4 w-full lg:mt-0 lg:w-auto"
:as-heading="true"
type-name="Circle:"
:items="cockpitStore.circles" :items="cockpitStore.circles"
@update:model-value="cockpitStore.setCurrentCourseCircleFromEvent" @update:model-value="cockpitStore.setCurrentCourseCircleFromEvent"
></ItDropdownSelect> ></ItDropdownSelect>

View File

@ -56,17 +56,6 @@ const items: SubNavEntry[] = [
route: selfEvaluationRoute, route: selfEvaluationRoute,
}, },
{ id: 3, name: t("a.Handlungskompetenzen"), route: competencesRoute }, { id: 3, name: t("a.Handlungskompetenzen"), route: competencesRoute },
{
id: 4,
name: "MS Teams",
route: "https://iterativ.ch",
},
{
id: 5,
name: "Vorschau Teilnehmer",
route: "https://iterativ.ch",
},
]; ];
</script> </script>

View File

@ -14,9 +14,13 @@ import { addToHistory, setLastNavigationWasPush } from "@/router/history";
import { onboardingRedirect } from "@/router/onboarding"; import { onboardingRedirect } from "@/router/onboarding";
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import { import {
ATTENDANCE_ROUTE,
CERTIFICATES_ROUTE, CERTIFICATES_ROUTE,
COCKPIT_ROUTE,
COMPETENCE_ROUTE, COMPETENCE_ROUTE,
COMPETENCES_ROUTE, COMPETENCES_ROUTE,
DOCUMENTS_ROUTE,
PERSONAL_PROFILE_ROUTE,
SELF_EVALUATION_ROUTE, SELF_EVALUATION_ROUTE,
SETTINGS_ROUTE, SETTINGS_ROUTE,
} from "./names"; } from "./names";
@ -86,88 +90,269 @@ const router = createRouter({
props: true, props: true,
}, },
{ {
path: "/course/:courseSlug/media", path: "/course/:courseSlug",
props: true, props: true,
component: () => import("@/pages/mediaLibrary/MediaLibraryParentPage.vue"),
children: [ children: [
{ {
path: "", path: "media",
component: () => import("@/pages/mediaLibrary/MediaLibraryIndexPage.vue"), component: () => import("@/pages/mediaLibrary/MediaLibraryParentPage.vue"),
props: true,
children: [
{
path: "",
component: () => import("@/pages/mediaLibrary/MediaLibraryIndexPage.vue"),
props: true,
},
{
path: ":categorySlug",
props: true,
component: () =>
import("@/pages/mediaLibrary/MediaLibraryCategoryPage.vue"),
},
{
path: ":categorySlug/:contentSlug",
props: true,
component: () =>
import("@/pages/mediaLibrary/MediaLibraryContentPage.vue"),
},
],
}, },
{ {
path: ":categorySlug", path: "competence",
props: true, component: () => import("@/pages/competence/CompetenceParentPage.vue"),
component: () => import("@/pages/mediaLibrary/MediaLibraryCategoryPage.vue"), children: [
{
path: "",
props: true,
name: COMPETENCE_ROUTE,
component: () => import("@/pages/competence/CompetenceIndexPage.vue"),
},
{
path: "certificates",
name: CERTIFICATES_ROUTE,
props: true,
component: () =>
import("@/pages/competence/CompetenceCertificateListPage.vue"),
},
{
path: "certificates/:certificateSlug",
props: true,
component: () =>
import("@/pages/competence/CompetenceCertificateDetailPage.vue"),
},
{
name: SELF_EVALUATION_ROUTE,
path: "self-evaluation-and-feedback",
props: true,
component: () =>
import("@/pages/competence/SelfEvaluationAndFeedbackPage.vue"),
},
{
path: "competences",
name: COMPETENCES_ROUTE,
props: true,
component: () =>
import("@/pages/competence/ActionCompetenceListPage.vue"),
},
],
}, },
{ {
path: ":categorySlug/:contentSlug", path: "learn",
children: [
{
path: "",
props: true,
component: () =>
import("../pages/learningPath/learningPathPage/LearningPathPage.vue"),
},
{
path: ":circleSlug",
component: () =>
import("../pages/learningPath/circlePage/CirclePage.vue"),
props: true,
},
{
path: ":circleSlug/evaluate/:learningUnitSlug",
component: () =>
import(
"../pages/learningPath/selfEvaluationPage/SelfEvaluationPage.vue"
),
props: true,
},
{
path: ":circleSlug/:contentSlug",
component: () =>
import(
"../pages/learningPath/learningContentPage/LearningContentPage.vue"
),
props: true,
},
],
},
{
path: "profile/:userId",
component: () => import("@/pages/userProfile/UserProfilePage.vue"),
props: true, props: true,
component: () => import("@/pages/mediaLibrary/MediaLibraryContentPage.vue"), children: [
{
path: "learning-path",
component: () =>
import("@/pages/userProfile/LearningPathProfilePage.vue"),
props: true,
name: "profileLearningPath",
meta: {
hideChrome: true,
showCloseButton: true,
},
},
{
path: "competence",
component: () => import("@/pages/userProfile/CompetenceProfilePage.vue"),
props: true,
name: "profileCompetence",
meta: {
hideChrome: true,
showCloseButton: true,
},
children: [
{
path: "",
name: "competenceMain",
component: () =>
import(
"@/components/selfEvaluationFeedback/SelfEvaluationAndFeedbackOverview.vue"
),
},
{
path: "evaluations",
name: "competenceEvaluations",
component: () =>
import(
"@/components/selfEvaluationFeedback/SelfEvaluationAndFeedbackList.vue"
),
},
{
path: "certificates/:certificateSlug",
name: "competenceCertificateDetail",
props: true,
component: () =>
import("@/pages/competence/CompetenceCertificateDetailPage.vue"),
},
{
path: "certificates",
name: "competenceCertificates",
component: () =>
import("@/pages/competence/CompetenceCertificateListPage.vue"),
},
],
},
],
},
{
path: "learning-mentor",
component: () => import("@/pages/learningMentor/mentor/MentorIndexPage.vue"),
name: "learningMentor",
children: [
{
path: "",
component: () =>
import("@/pages/learningMentor/mentor/MentorParticipantsPage.vue"),
name: "mentorsAndParticipants",
},
{
path: "tasks",
component: () =>
import("@/pages/learningMentor/mentor/MentorOverviewPage.vue"),
name: "learningMentorOverview",
},
{
path: "self-evaluation-feedback/:learningUnitId",
component: () =>
import("@/pages/learningMentor/mentor/SelfEvaluationFeedbackPage.vue"),
name: "mentorSelfEvaluationFeedback",
props: true,
},
{
path: "details",
component: () =>
import("@/pages/learningMentor/mentor/MentorDetailParentPage.vue"),
children: [
{
path: "praxis-assignments/:praxisAssignmentId",
component: () =>
import(
"@/pages/learningMentor/mentor/MentorPraxisAssignmentPage.vue"
),
name: "learningMentorPraxisAssignments",
props: true,
},
{
path: "self-evaluation-feedback-assignments/:learningUnitId",
component: () =>
import(
"@/pages/learningMentor/mentor/MentorSelfEvaluationFeedbackAssignmentPage.vue"
),
name: "learningMentorSelfEvaluationFeedbackAssignments",
props: true,
},
],
},
],
},
{
path: "assignment-evaluation/:assignmentId/:userId",
component: () =>
import("@/pages/assignmentEvaluation/AssignmentEvaluationPage.vue"),
props: true,
},
{
path: "cockpit",
name: "cockpit",
component: () =>
import("@/pages/cockpit/cockpitPage/CockpitExpertParentPage.vue"),
props: true,
children: [
{
path: "",
component: () =>
import("@/pages/cockpit/cockpitPage/CockpitExpertHomePage.vue"),
name: COCKPIT_ROUTE,
props: true,
},
{
path: "profile/:userId/:circleSlug",
component: () => import("@/pages/cockpit/CockpitUserCirclePage.vue"),
props: true,
},
{
path: "feedback/:circleId",
component: () => import("@/pages/cockpit/FeedbackPage.vue"),
props: true,
},
{
path: "assignment/:assignmentId",
component: () =>
import("@/pages/cockpit/assignmentsPage/AssignmentsPage.vue"),
props: true,
},
{
path: "attendance",
component: () =>
import("@/pages/cockpit/attendanceCheckPage/AttendanceCheckPage.vue"),
props: true,
name: ATTENDANCE_ROUTE,
},
{
path: "documents",
component: () => import("@/pages/cockpit/documentPage/DocumentPage.vue"),
props: true,
name: DOCUMENTS_ROUTE,
},
],
}, },
], ],
}, },
{
path: "/course/:courseSlug/competence",
props: true,
component: () => import("@/pages/competence/CompetenceParentPage.vue"),
children: [
{
path: "",
props: true,
name: COMPETENCE_ROUTE,
component: () => import("@/pages/competence/CompetenceIndexPage.vue"),
},
{
path: "certificates",
name: CERTIFICATES_ROUTE,
props: true,
component: () =>
import("@/pages/competence/CompetenceCertificateListPage.vue"),
},
{
path: "certificates/:certificateSlug",
props: true,
component: () =>
import("@/pages/competence/CompetenceCertificateDetailPage.vue"),
},
{
name: SELF_EVALUATION_ROUTE,
path: "self-evaluation-and-feedback",
props: true,
component: () =>
import("@/pages/competence/SelfEvaluationAndFeedbackPage.vue"),
},
{
path: "competences",
name: COMPETENCES_ROUTE,
props: true,
component: () => import("@/pages/competence/ActionCompetenceListPage.vue"),
},
],
},
{
path: "/course/:courseSlug/learn",
component: () =>
import("../pages/learningPath/learningPathPage/LearningPathPage.vue"),
props: true,
},
{
path: "/course/:courseSlug/learn/:circleSlug",
component: () => import("../pages/learningPath/circlePage/CirclePage.vue"),
props: true,
},
{
path: "/course/:courseSlug/learn/:circleSlug/evaluate/:learningUnitSlug",
component: () =>
import("../pages/learningPath/selfEvaluationPage/SelfEvaluationPage.vue"),
props: true,
},
{
path: "/course/:courseSlug/learn/:circleSlug/:contentSlug",
component: () =>
import("../pages/learningPath/learningContentPage/LearningContentPage.vue"),
props: true,
},
{ {
path: "/lernbegleitung/:courseId/invitation/:invitationId", path: "/lernbegleitung/:courseId/invitation/:invitationId",
component: () => import("@/pages/learningMentor/InvitationAcceptPage.vue"), component: () => import("@/pages/learningMentor/InvitationAcceptPage.vue"),
@ -178,158 +363,6 @@ const router = createRouter({
public: true, public: true,
}, },
}, },
{
path: "/course/:courseSlug/profile/:userId",
component: () => import("@/pages/userProfile/UserProfilePage.vue"),
props: true,
children: [
{
path: "learning-path",
component: () => import("@/pages/userProfile/LearningPathProfilePage.vue"),
props: true,
name: "profileLearningPath",
meta: {
hideChrome: true,
showCloseButton: true,
},
},
{
path: "competence",
component: () => import("@/pages/userProfile/CompetenceProfilePage.vue"),
props: true,
name: "profileCompetence",
meta: {
hideChrome: true,
showCloseButton: true,
},
children: [
{
path: "",
name: "competenceMain",
component: () =>
import(
"@/components/selfEvaluationFeedback/SelfEvaluationAndFeedbackOverview.vue"
),
},
{
path: "evaluations",
name: "competenceEvaluations",
component: () =>
import(
"@/components/selfEvaluationFeedback/SelfEvaluationAndFeedbackList.vue"
),
},
{
path: "certificates/:certificateSlug",
name: "competenceCertificateDetail",
props: true,
component: () =>
import("@/pages/competence/CompetenceCertificateDetailPage.vue"),
},
{
path: "certificates",
name: "competenceCertificates",
component: () =>
import("@/pages/competence/CompetenceCertificateListPage.vue"),
},
],
},
],
},
{
path: "/course/:courseSlug/learning-mentor",
component: () => import("@/pages/learningMentor/mentor/MentorIndexPage.vue"),
props: true,
name: "learningMentor",
children: [
{
path: "",
component: () =>
import("@/pages/learningMentor/mentor/MentorParticipantsPage.vue"),
name: "mentorsAndParticipants",
},
{
path: "tasks",
component: () =>
import("@/pages/learningMentor/mentor/MentorOverviewPage.vue"),
name: "learningMentorOverview",
},
{
path: "self-evaluation-feedback/:learningUnitId",
component: () =>
import("@/pages/learningMentor/mentor/SelfEvaluationFeedbackPage.vue"),
name: "mentorSelfEvaluationFeedback",
props: true,
},
{
path: "details",
component: () =>
import("@/pages/learningMentor/mentor/MentorDetailParentPage.vue"),
children: [
{
path: "praxis-assignments/:praxisAssignmentId",
component: () =>
import("@/pages/learningMentor/mentor/MentorPraxisAssignmentPage.vue"),
name: "learningMentorPraxisAssignments",
props: true,
},
{
path: "self-evaluation-feedback-assignments/:learningUnitId",
component: () =>
import(
"@/pages/learningMentor/mentor/MentorSelfEvaluationFeedbackAssignmentPage.vue"
),
name: "learningMentorSelfEvaluationFeedbackAssignments",
props: true,
},
],
},
],
},
{
path: "/course/:courseSlug/assignment-evaluation/:assignmentId/:userId",
component: () =>
import("@/pages/assignmentEvaluation/AssignmentEvaluationPage.vue"),
props: true,
},
{
path: "/course/:courseSlug/cockpit",
name: "cockpit",
children: [
{
path: "",
component: () => import("@/pages/cockpit/cockpitPage/CockpitExpertPage.vue"),
props: true,
},
{
path: "profile/:userId/:circleSlug",
component: () => import("@/pages/cockpit/CockpitUserCirclePage.vue"),
props: true,
},
{
path: "feedback/:circleId",
component: () => import("@/pages/cockpit/FeedbackPage.vue"),
props: true,
},
{
path: "assignment/:assignmentId",
component: () =>
import("@/pages/cockpit/assignmentsPage/AssignmentsPage.vue"),
props: true,
},
{
path: "attendance",
component: () =>
import("@/pages/cockpit/attendanceCheckPage/AttendanceCheckPage.vue"),
props: true,
},
{
path: "documents",
component: () => import("@/pages/cockpit/documentPage/DocumentPage.vue"),
props: true,
},
],
},
{ {
path: "/statistic/:courseSlug", path: "/statistic/:courseSlug",
props: true, props: true,
@ -399,7 +432,7 @@ const router = createRouter({
{ {
path: "/profile", path: "/profile",
component: () => import("@/pages/personalProfile/PersonalProfilePage.vue"), component: () => import("@/pages/personalProfile/PersonalProfilePage.vue"),
name: "personalProfile", name: PERSONAL_PROFILE_ROUTE,
}, },
{ {
path: "/settings", path: "/settings",

View File

@ -3,3 +3,7 @@ export const CERTIFICATES_ROUTE = "certificates";
export const SELF_EVALUATION_ROUTE = "selfEvaluationAndFeedback"; export const SELF_EVALUATION_ROUTE = "selfEvaluationAndFeedback";
export const COMPETENCES_ROUTE = "competences"; export const COMPETENCES_ROUTE = "competences";
export const SETTINGS_ROUTE = "settings"; export const SETTINGS_ROUTE = "settings";
export const COCKPIT_ROUTE = "cockpit-home";
export const ATTENDANCE_ROUTE = "attendance";
export const DOCUMENTS_ROUTE = "documents";
export const PERSONAL_PROFILE_ROUTE = "personalProfile";

View File

@ -1,50 +1,14 @@
import { useCourseData } from "@/composables"; import { useCourseData, useCourseSessionDetailQuery } from "@/composables";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import type { CircleLight, CourseSessionUser, ExpertSessionUser } from "@/types"; import type { CircleLight, CourseSessionUser, ExpertSessionUser } from "@/types";
import log from "loglevel"; import log from "loglevel";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { computed, ref } from "vue";
type CircleExpertCockpit = CircleLight & { type CircleExpertCockpit = CircleLight & {
name: string; name: string;
}; };
export type ExpertCockpitStoreState = {
courseSessionMembers: CourseSessionUser[] | undefined;
circles: CircleExpertCockpit[] | undefined;
currentCircle: CircleExpertCockpit | undefined;
};
export const useExpertCockpitStore = defineStore({
id: "expertCockpit",
state: () => {
return {
courseSessionMembers: undefined,
circles: [],
currentCircle: undefined,
} as ExpertCockpitStoreState;
},
actions: {
async loadCircles(
courseSlug: string,
currentCourseSessionUser: CourseSessionUser | undefined
) {
log.debug("loadCircles called", courseSlug);
this.circles = await courseCircles(courseSlug, currentCourseSessionUser);
if (this.circles?.length) {
await this.setCurrentCourseCircle(this.circles[0].slug);
}
},
async setCurrentCourseCircle(circleSlug: string) {
this.currentCircle = this.circles?.find((c) => c.slug === circleSlug);
},
async setCurrentCourseCircleFromEvent(event: CircleLight) {
await this.setCurrentCourseCircle(event.slug);
},
},
});
async function courseCircles( async function courseCircles(
courseSlug: string, courseSlug: string,
currentCourseSessionUser: CourseSessionUser | undefined currentCourseSessionUser: CourseSessionUser | undefined
@ -74,3 +38,51 @@ async function courseCircles(
return []; return [];
} }
export const useExpertCockpitStore = defineStore("expertCockpit", () => {
const courseSessionMembers = ref<CourseSessionUser[] | undefined>(undefined);
const circles = ref<CircleExpertCockpit[] | undefined>([]);
const currentCircle = ref<CircleExpertCockpit | undefined>(undefined);
const courseSessionDetailResult = useCourseSessionDetailQuery();
const attendanceCourses = computed(() => {
return (
courseSessionDetailResult.courseSessionDetail.value?.attendance_courses ?? []
);
});
const currentCourse = computed(() => {
return attendanceCourses.value.find(
(i) => i.learning_content.circle?.id == currentCircle.value?.id
);
});
const loadCircles = async (
courseSlug: string,
currentCourseSessionUser: CourseSessionUser | undefined
) => {
log.debug("loadCircles called", courseSlug);
circles.value = await courseCircles(courseSlug, currentCourseSessionUser);
if (circles.value?.length) {
await setCurrentCourseCircle(circles.value[0].slug);
}
};
const setCurrentCourseCircle = async (circleSlug: string) => {
currentCircle.value = circles.value?.find((c) => c.slug === circleSlug);
};
const setCurrentCourseCircleFromEvent = async (event: CircleLight) => {
await setCurrentCourseCircle(event.slug);
};
return {
courseSessionMembers,
circles,
currentCircle,
loadCircles,
currentCourse,
setCurrentCourseCircleFromEvent,
};
});

View File

@ -0,0 +1,13 @@
import { isInFuture } from "@/components/dueDates/dueDatesUtils";
export type Status = "done" | "soon" | "now";
export const getStatus = (done: boolean, date: string): Status => {
if (done) {
return "done";
}
if (isInFuture(date)) {
return "soon";
}
return "now";
};

View File

@ -1,20 +1,24 @@
import {EXPERT_COCKPIT_URL, login} from "./helpers"; import { EXPERT_COCKPIT_URL, login } from "./helpers";
describe("settings.cy.js", () => { describe("settings.cy.js", () => {
beforeEach(() => { beforeEach(() => {
cy.manageCommand("cypress_reset"); cy.manageCommand("cypress_reset");
cy.intercept("/server/graphql").as("graphql");
}); });
describe("with circle documents enabled", () => { describe("with circle documents enabled", () => {
it("student can see circle documents", () => { it("student can see circle documents", () => {
login("test-student1@example.com", "test"); login("test-student1@example.com", "test");
cy.visit("/course/test-lehrgang/learn/fahrzeug"); cy.visit("/course/test-lehrgang/learn/fahrzeug");
cy.wait(["@graphql", "@graphql"]);
cy.get('[data-cy="circle-document-section"]').should("exist"); cy.get('[data-cy="circle-document-section"]').should("exist");
}); });
it("trainer can see circle documents", () => { it("trainer can see circle documents", () => {
login("test-trainer1@example.com", "test"); login("test-trainer1@example.com", "test");
cy.visit(EXPERT_COCKPIT_URL); cy.visit(EXPERT_COCKPIT_URL);
cy.wait(["@graphql", "@graphql"]);
cy.get('[data-cy="circle-documents"]').should("exist"); cy.get('[data-cy="circle-documents"]').should("exist");
}); });
}); });
@ -27,6 +31,7 @@ describe("settings.cy.js", () => {
it("student cannot see circle documents", () => { it("student cannot see circle documents", () => {
login("test-student1@example.com", "test"); login("test-student1@example.com", "test");
cy.visit("/course/test-lehrgang/learn/fahrzeug"); cy.visit("/course/test-lehrgang/learn/fahrzeug");
cy.wait(["@graphql", "@graphql"]);
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug");
cy.get('[data-cy="circle-document-section"]').should("not.exist"); cy.get('[data-cy="circle-document-section"]').should("not.exist");
}); });
@ -34,6 +39,7 @@ describe("settings.cy.js", () => {
it("trainer cannot see circle documents", () => { it("trainer cannot see circle documents", () => {
login("test-trainer1@example.com", "test"); login("test-trainer1@example.com", "test");
cy.visit(EXPERT_COCKPIT_URL); cy.visit(EXPERT_COCKPIT_URL);
cy.wait(["@graphql", "@graphql"]);
cy.get('[data-cy="circle-documents"]').should("not.exist"); cy.get('[data-cy="circle-documents"]').should("not.exist");
}); });
}); });