feat: review backend and frontend

- update to the latest version of Java/SpringBoot
- update to the latest version NuxtJS
- add account/password update
- add account creation
- add account password reset
- add bundle to regroup questions and add default questions on user creation
- add bundle creation
This commit is contained in:
2024-07-03 15:55:34 +02:00
parent f86d794239
commit b6e86f0641
207 changed files with 5570 additions and 40453 deletions

View File

@@ -0,0 +1,128 @@
<script lang="ts" setup>
import {useNotificationStore} from "~/store/notification";
import {useAccountStore} from "~/store/account";
import type {ApiError} from "~/composables/fetch-api";
definePageMeta({
layout: 'main-header'
});
const email = ref();
const username = ref();
const emailConfirmation = ref();
const password = ref();
const confirmationPassword = ref();
const conditionChecked = ref(false);
const cguModalVisible = ref(false);
function createAccount() {
if (emailConfirmation.value !== email.value) {
useNotificationStore().pushNotification("warn", {message: "Saisir le même e-mail dans les champs 'E-mail' et 'Confirmation de l'e-mail'."});
} else if (password.value !== confirmationPassword.value) {
useNotificationStore().pushNotification("warn", {message: "Saisir le même mot de passe dans les champs 'Mot de passe' et 'Confirmation du mot de passe'."});
} else {
useAccountStore().create(username.value, email.value, password.value)
.then(() => {
useNotificationStore().pushNotification("success", {
message: "Votre compte a bien été créé.",
details: "Vous allez recevoir un e-mail."
});
navigateTo("/login");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn", {message: apiError.message, details});
});
}
}
</script>
<template>
<div>
<section>
<h1>Créer un compte</h1>
<form class="form" @submit.prevent="createAccount">
<label for="username">Nom de l'équipe *</label>
<input id="username" v-model="username" type="text" autocomplete="username" required>
<label for="email">E-mail *</label>
<input id="email" v-model="email" type="email" autocomplete="email" required>
<label for="emailConfirmation">Confirmation de l'e-mail *</label>
<input id="emailConfirmation" v-model="emailConfirmation" type="email" required>
<p class="form__help">Votre mot de passe doit fait au moins 8 caractères, et être composé dau moins une
majuscule, une minuscule,
un chiffre de 0 à 9, et un caractère spécial parmi @?!#$&;,:</p>
<label for="newPassword">Mot de passe *</label>
<input id="newPassword" v-model="password" type="password" required>
<label for="confirmationPassword">Confirmation du mot de passe *</label>
<input id="confirmationPassword" v-model="confirmationPassword" type="password" required>
<label>
<input type="checkbox" v-model="conditionChecked" required />
En continuant, jaccepte
<button class="button-link" @click="cguModalVisible = true" type="button">les conditions d'utilisation de Boussole PLUSS et jai lu la politique de
confidentialité
</button>
</label>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/login" aria-label="Retour à la page précédente"></nuxt-link>
<button class="button orange" type="submit" :disabled="!conditionChecked">Enregistrer</button>
</div>
</form>
</section>
</div>
<cgu-modal :visible="cguModalVisible" @close="cguModalVisible = false; conditionChecked = false;" @validate="conditionChecked = true; cguModalVisible = false"/>
</template>
<style lang="scss" scoped>
@import "assets/css/spacing";
.form {
//.checkbox {
// input {
// position: absolute;
// opacity: 0;
// cursor: pointer;
// height: 0;
// width: 0;
// }
// input:checked ~ &-checkmark:after {
// display: block;
// }
//
// &-checkmark {
// position: absolute;
// top: 0;
// left: 0;
// height: 25px;
// width: 25px;
// background-color: #eee;
//
// &:after {
// left: 9px;
// top: 5px;
// width: 5px;
// height: 10px;
// border: solid white;
// border-width: 0 3px 3px 0;
// rotate: 45deg;
// }
// }
// &-checkmark:after {
// content: "";
// position: absolute;
// display: none;
// }
// &:hover input ~ &-checkmark {
// background-color: #ccc;
// }
//}
input[type="checkbox"] {
grid-column: span 2 / 3;
margin-top: $medium;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
import {useAccountStore} from "~/store/account";
import {useNotificationStore} from "~/store/notification";
import {useAuthStore} from "~/store/auth";
import type {ApiError} from "~/composables/fetch-api";
const email = ref(useAuthStore().user.email);
const username = ref(useAuthStore().user.username);
const emailConfirmation = ref();
function updateAccount() {
if (emailConfirmation.value !== email.value) {
useNotificationStore().pushNotification("warn", {message: "Saisir le même e-mail dans les champs 'E-mail' et 'Confirmation de l'e-mail'."});
} else {
useAccountStore().update(username.value, email.value)
.then(async () => {
if (useAuthStore().user.email !== email.value) {
await useAuthStore().refreshSession();
}
useNotificationStore().pushNotification("success", {message: "Votre compte a bien été mis à jour."});
useAuthStore().user.username = username.value;
navigateTo("/bundle");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn", {message: apiError.message, details});
});
}
}
</script>
<template>
<div>
<section>
<h1>Mon compte</h1>
<form class="form" @submit.prevent="updateAccount">
<label for="username">Nom de l'équipe *</label>
<input id="username" v-model="username" type="text" autocomplete="username" required>
<label for="email">E-mail *</label>
<input id="email" v-model="email" type="email" autocomplete="email" required>
<label for="emailConfirmation">Confirmation de l'e-mail *</label>
<input id="emailConfirmation" v-model="emailConfirmation" type="email" required>
<button class="button orange" type="submit">Enregistrer</button>
</form>
<div class="button-container">
<nuxt-link to="/bundle" class="button gray button-back" aria-label="Retour à la page principale">
</nuxt-link>
<nuxt-link to="/account/password" class="button blue" type="submit">Modifier mon mot de passe</nuxt-link>
</div>
</section>
</div>
</template>
<style lang="scss" scoped>
@import "assets/css/spacing";
.form {
& [type="submit"] {
grid-column: span 2 / 3;
margin-top: $small;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<script lang="ts" setup>
import {useAccountStore} from "~/store/account";
import type {ApiError} from "~/composables/fetch-api";
import {useNotificationStore} from "~/store/notification";
definePageMeta({
layout: 'main-header'
});
const email = ref();
const newPassword = ref();
const confirmationPassword = ref();
onMounted(() => {
if (!useRoute().query.token) {
navigateTo("/");
}
});
function resetPassword() {
const token = useRoute().query.token;
useAccountStore().resetPassword(token, email.value, newPassword.value, confirmationPassword.value)
.then(() => {
useNotificationStore().pushNotification("success", {message: "Votre mot de passe a bien été ré-initialisé."});
navigateTo("/login");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn", {message: apiError.message, details});
});
}
</script>
<template>
<section>
<h1>-initialisé mon mot de passe</h1>
<form class="form" @submit.prevent="resetPassword">
<label for="email">Mon e-mail</label>
<input id="email" v-model="email" type="email" autocomplete="username" required>
<p class="form__help">Votre mot de passe doit fait au moins 8 caractères, et être composé dau moins une
majuscule, une minuscule, un chiffre de 0 à 9, et un caractère spécial parmi @?!#$&;,:</p>
<label for="newPassword">Nouveau mot de passe</label>
<input id="newPassword" v-model="newPassword" type="password" required>
<label for="confirmationPassword">Confirmation du mot de passe</label>
<input id="confirmationPassword" v-model="confirmationPassword" type="password" required>
<div class="button-container">
<button class="button orange" type="submit">Enregistrer</button>
</div>
</form>
</section>
</template>

View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
import {useAccountStore} from "~/store/account";
import type {ApiError} from "~/composables/fetch-api";
import {useNotificationStore} from "~/store/notification";
const currentPassword = ref();
const newPassword = ref();
const confirmationPassword = ref();
function updatePassword() {
useAccountStore().updatePassword(currentPassword.value, newPassword.value, confirmationPassword.value)
.then(() => {
useNotificationStore().pushNotification("success",{message: "Votre mot de passe a bien été modifié."});
navigateTo("/account");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn",{message: apiError.message, details});
});
}
</script>
<template>
<section>
<h1>Modifier mon mot de passe</h1>
<form class="form" @submit.prevent="updatePassword">
<label for="currentPassword">Mot de passe actuel</label>
<input id="currentPassword" v-model="currentPassword" type="password" autocomplete="currentPassword" required>
<p class="form__help">Votre mot de passe doit fait au moins 8 caractères, et être composé dau moins une
majuscule, une minuscule,
un chiffre de 0 à 9, et un caractère spécial parmi @?!#$&;,:</p>
<label for="newPassword">Nouveau mot de passe</label>
<input id="newPassword" v-model="newPassword" type="password" required>
<label for="confirmationPassword">Confirmation du mot de passe</label>
<input id="confirmationPassword" v-model="confirmationPassword" type="password" required>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/account" aria-label="Retour à la page précédente"></nuxt-link>
<button class="button orange" type="submit">Enregistrer</button>
</div>
</form>
</section>
</template>

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import {useAccountStore} from "~/store/account";
import {useNotificationStore} from "~/store/notification";
import type {ApiError} from "~/composables/fetch-api";
definePageMeta({
layout: 'main-header'
});
const email = ref();
const loading = ref(false);
function sendEmail() {
loading.value = true;
useAccountStore()
.requestPasswordReset(email.value)
.then(() => {
useNotificationStore().pushNotification("success", {message: "Consultez vos emails pour réinitialiser votre mot de passe."})
navigateTo("login");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn", {message: apiError.message, details});
})
.finally(() => {
loading.value = false;
});
}
</script>
<template>
<section>
<h1>Mot de passe oublié</h1>
<form class="form" @submit.prevent="sendEmail">
<p>Entrez votre email pour recevoir un lien permettant de réinitialiser le mot de passe associé à votre
compte.</p>
<input v-model="email" type="email" placeholder="E-mail" required/>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/login" aria-label="Retour à la page de login"></nuxt-link>
<button class="button orange" type="submit">Envoyer l'e-mail</button>
</div>
<loader class="loader" v-if="loading"/>
</form>
</section>
</template>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: $small;
}
.loader {
align-self: center;
}
</style>

View File

@@ -0,0 +1,182 @@
<script lang="ts" setup>
import {type QuestionCreation, useBundleStore} from "~/store/bundle";
import {useNotificationStore} from "~/store/notification";
import type {ApiError} from "~/composables/fetch-api";
import {type Axe, useAxeStore} from "~/store/axe";
import {type Question, useQuestionStore} from "~/store/question";
const axes = ref<Axe[]>();
const label = ref();
const presentation = ref();
const questions = ref<Map<number, QuestionCreation[]>>(new Map());
const questionsExample = ref<Map<number, QuestionCreation[]>>(new Map());
const modalVisible = ref(false);
const currentAxe = ref<Axe>();
const currentQuestions = ref<Question[]>();
onMounted(() => {
useAxeStore().findAxes().then(response => {
response.forEach(axe => {
useQuestionStore().findDefaults(axe.id).then(response => {
questions.value.set(axe.id, response);
});
useQuestionStore().findAll(axe.id).then(response => {
questionsExample.value.set(axe.id, response);
});
});
axes.value = response;
});
});
function createBundle() {
const newQuestions = [];
let errors = [];
questions.value.forEach((value, axeId) => {
if (value.length === 0) {
const axeNumber = axes.value.filter(a => a.id === axeId)[0].identifier;
errors.push(`L'axe ${axeNumber} n'a pas de question.`);
}
value.forEach((q: Question, index)=> {
newQuestions.push({
axeId: axeId,
label: q.label,
description: q.description,
index: index+1
});
if (!q.label.trim()) {
const axeNumber = axes.value.filter(a => a.id === axeId)[0].identifier;
errors.push(`L'une des question de l'axe ${axeNumber} contient un libellé non rempli.`);
}
});
});
if (errors.length > 0) {
useNotificationStore().pushNotification("warn",
{message: `La boussole contient des erreurs !`, details: errors});
}else {
useBundleStore()
.create({
label: label.value,
presentation: presentation.value,
questions: newQuestions
})
.then(() => {
useNotificationStore().pushNotification("success",{message: "La boussole a bien été créée."});
navigateTo("/bundle");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn",{message: apiError.message, details});
});
}
}
function css(axe: Axe) {
return {
'--color': axe.color
}
}
function showAxeModal(axe: Axe) {
currentAxe.value = axe;
modalVisible.value = true;
currentQuestions.value = questions.value.get(axe.id);
document.body.style.overflowY = "hidden";
}
function hideAxeModal() {
currentAxe.value = undefined;
currentQuestions.value = undefined;
modalVisible.value = false;
document.body.style.overflowY = "auto";
}
function onQuestionsChange({axeId, newQuestions}) {
questions.value.set(axeId, newQuestions);
}
function numberOfQuestions(axe: Axe) {
const q = questions.value.get(axe.id)
return q ? q.length: 0;
}
</script>
<template>
<h1>Créer une nouvelle Boussole</h1>
<section>
<form class="form" @submit.prevent="createBundle">
<label for="label">Nom de la boussole *</label>
<input id="label" v-model="label" type="text" required maxlength="50">
<label for="label">Présentation</label>
<input id="label" v-model="presentation" type="text" maxlength="100">
<ul class="axe-list">
<li class="axe-list__item" v-for="axe in axes" :style="css(axe)">
<h2>{{ axe.identifier }} - {{ axe.shortTitle }}</h2>
<div class="axe-list__item__content">
<div class="axe-list__item__content-text">
<p>{{ axe.title }}</p>
<p>{{ numberOfQuestions(axe) }} question{{numberOfQuestions(axe) > 1? 's': ''}} configurée{{numberOfQuestions(axe) > 1? 's': ''}}</p>
</div>
<button class="button blue" type="button" @click="showAxeModal(axe)">
Configurer
</button>
</div>
</li>
</ul>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/bundle" aria-label="Précédent"></nuxt-link>
<button class="button orange">Valider</button>
</div>
</form>
</section>
<bundle-axe-modal v-if="currentAxe" :visible="modalVisible"
:axe="currentAxe"
:questions="questions.get(currentAxe.id)"
:questions-example="questionsExample.get(currentAxe.id)"
@close="hideAxeModal()"
@changed="(axeId, newQuestions) => onQuestionsChange(axeId, newQuestions)"/>
</template>
<style lang="scss" scoped>
@import "assets/css/color";
@import "assets/css/spacing";
@import "assets/css/font";
.axe-list {
display: flex;
flex-direction: column;
list-style: none;
gap: $xxx_small;
margin: 0;
&__item {
padding: $xx_small 0;
border-bottom: 2px solid var(--color);
h2 {
font-size: $secondary-font-size;
margin: 0;
}
&__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: $xx_small;
&-text p + p {
margin: $xxx_small 0;
}
&-text p:last-child {
font-style: italic;
}
}
}
}
</style>

View File

@@ -0,0 +1,126 @@
<script lang="ts" setup>
import {type Bundle, useBundleStore} from "~/store/bundle";
const showModal = ref(false);
const bundles = ref<Bundle[]>([]);
const loading = ref(false);
onMounted(() => {
loading.value = true;
useBundleStore().findAll().then((response: Quiz[]) => {
bundles.value = response;
}).finally(() => {
loading.value = false;
});
});
function navigateToDashboard(bundle: Bundle) {
useBundleStore().setCurrentBundle(bundle);
navigateTo("/dashboard");
}
function navigateToQuiz(bundle: Bundle) {
useBundleStore().setCurrentBundle(bundle);
navigateTo("/quiz");
}
</script>
<template>
<section v-if="!loading" class="section">
<h1>Bienvenue sur votre espace dauto-évaluation Boussole&nbsp;!</h1>
<p>Vous allez pouvoir dès maintenant évaluer votre projet de Production Locale Utile Solidaire et Soutenable au
regard des <button class="button-link" @click="showModal = true">10 balises du référentiel</button>.
</p>
<p>Cliquez ci-dessous sur la <strong>Boussole de référence</strong>, pour évaluer votre projet sur des questions
déjà configurées. Pour configurez vous-même vos questions, cliquez sur <strong>Personnaliser votre Boussole</strong>.</p>
<p>Ce tableau de bord vous permet de suivre vos différentes auto-évaluations sur tous vos projets&nbsp;!</p>
<ul class="bundle-list">
<li v-for="bundle in bundles">
<article class="bundle-list__item">
<main>
<h1>{{ bundle.label }}</h1>
<p>{{ bundle.presentation }}</p>
<dl class="bundle-list__item__attribute">
<dt>Dernière auto-évaluation réalisée</dt>
<dd>{{ bundle.lastQuizzDate ? formatDate(bundle.lastQuizzDate) : 'NA' }}</dd>
<dt>Nombre d'auto-évaluation</dt>
<dd>{{ bundle.numberOfQuizzes || 0 }}</dd>
</dl>
</main>
<footer class="bundle-list__item__button-container">
<button class="button blue" @click="navigateToDashboard(bundle)">Voir mes évaluations</button>
<button class="button orange" @click="navigateToQuiz(bundle)">S'évaluer</button>
</footer>
</article>
</li>
</ul>
<div class="button-container">
<nuxt-link to="/bundle/create">Personnaliser votre Boussole</nuxt-link>
</div>
</section>
<loader v-else/>
<axe-definition-modal :visible="showModal" @close="showModal=false" />
</template>
<style lang="scss" scoped>
.section {
margin-block: $medium;
}
.bundle-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22.5rem, 1fr));
grid-gap: $small;
list-style: none;
margin-top: $x_medium;
&__item {
display: flex;
flex-direction: column;
height: 100%;
gap: $small;
padding: $small;
border-radius: 20px;
@include border-shadow();
h1 {
font-size: $title-font-size;
margin: 0 0 $xxx_small 0;
}
p {
margin: 0 0 $xxx_small 0;
}
&__attribute {
dt {
font-weight: bold;
float: left;
clear: left;
&:after {
content: ' :';
margin-right: $xxxx_small;
}
}
dd {
padding-bottom: $xxxx_small;
}
}
&__button-container {
display: flex;
justify-content: center;
gap: $xxx_small;
margin-top: auto;
}
}
}
</style>

20
frontend/pages/cgu.vue Normal file
View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
definePageMeta({
layout: 'main-header'
});
</script>
<template>
<section>
<h1>Conditions Générales dUtilisation de la Boussole PLUSS</h1>
<cgu />
<nuxt-link class="button gray button-link" to="/">Retour à l'accueil</nuxt-link>
</section>
</template>
<style lang="scss" scoped>
.button-link {
margin: $medium 0;
}
</style>

View File

@@ -1,74 +1,61 @@
<template>
<div class="content">
<team-header/>
<hr/>
<div v-if="loading" class="center" >
<loader/>
</div>
<section v-if="currentResult" class="last-quiz">
<div class="last-quiz-header">
<span class="date"> {{ currentResult.createdDate | formatDate }}</span>
<nuxt-link
class="link" :to="{ path: '/details', query: { quiz: currentResult.id }}">
+ Voir le détail
</nuxt-link>
</div>
<div v-if="currentResult && currentResult.scores" class="chart-area">
<polar-area-chart :data="currentResult.scores.map(value => value.scoreAvg)"/>
<Legend/>
</div>
</section>
<section v-if="quizzes.length > 0" class="history" >
<div v-for="q in quizzes" :key="q.id">
<button @click="setCurrent(q)"><span>Boussole - {{ q.createdDate | formatDate }}</span><span></span></button>
</div>
</section>
<section v-else-if="!loading" class="center">
Aucune auto-évaluation n'a été faite. Veuillez en réaliser une première.
</section>
<div class="button-container">
<nuxt-link class="button orange" to="/quiz">Nouveau</nuxt-link>
</div>
</div>
</template>
<script lang="ts" setup>
<script lang="ts">
import {Component, Vue} from "vue-property-decorator";
import {AxiosResponse} from "axios";
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
import {RestResponse} from "~/repositories/models/rest-response.model";
import {Quiz} from "~/repositories/models/quiz.model";
import {type Quiz, useQuizStore} from "~/store/quiz";
import {useBundleStore} from "~/store/bundle";
@Component
export default class History extends Vue {
const quizzes = ref<Quiz[]>([]);
const currentResult = ref<Quiz>();
readonly quizRepository = RepositoryFactory.get('quiz');
onMounted(() => {
useQuizStore().findQuizzes(useBundleStore().selectedBundle.id).then((response: Page<Quiz>) => {
quizzes.value = response.content;
currentResult.value = quizzes.value.length > 0 ? quizzes.value[0] : null;
});
});
private quizzes: Quiz[] = [];
private currentResult: Quiz | null = null;
private loading = true;
async mounted() {
await this.quizRepository.findMine().then((response: AxiosResponse<RestResponse<Quiz>>) => {
this.quizzes = response.data._embedded.quizzes;
});
this.currentResult = this.quizzes.length > 0 ? this.quizzes[0] : null;
this.loading = false;
}
private setCurrent(quiz: Quiz) {
this.currentResult = quiz;
}
function setCurrent(quiz: Quiz) {
currentResult.value = quiz;
}
</script>
<style lang="scss" scoped>
@import "assets/css/color";
@import "assets/css/spacing";
@import "assets/css/font";
<template>
<section v-if="currentResult" class="last-quiz">
<div class="last-quiz-header">
<span class="date"> {{ formatDateTime(currentResult.createdDate) }}</span>
<nuxt-link
class="button blue" :to="{ path: '/details', query: { quiz: currentResult.id }}">
+ Voir le détail
</nuxt-link>
</div>
<div v-if="currentResult && currentResult.axes" class="chart-area">
<polar-area-chart :data="currentResult.axes.map(value => value.average)"/>
<Legend/>
</div>
</section>
<section v-if="quizzes.length > 0">
<ul class="history">
<li class="history__item" v-for="q in quizzes">
<input :id="q.id" type="radio" @change="setCurrent(q)" :checked="q === currentResult" name="quiz"/>
<label :for="q.id">
<span>{{useBundleStore().selectedBundle.label }} - {{ formatDateTime(q.createdDate) }}</span>
</label>
</li>
</ul>
</section>
<section v-else class="center">
Aucune auto-évaluation n'a été faite. Veuillez en réaliser une première.
</section>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/bundle" aria-label="Retour à la page précédente"></nuxt-link>
<nuxt-link class="button orange" to="/quiz">S'évaluer</nuxt-link>
</div>
</template>
<style lang="scss" scoped>
.last-quiz {
margin-bottom: $x_small 0;
margin: $small 0;
}
.last-quiz-header {
@@ -80,47 +67,51 @@ export default class History extends Vue {
color: $gray_4;
font-weight: 700;
}
.link {
color: $blue;
}
}
.chart-area {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
margin: $x_small 0;
gap: $small;
}
.history {
margin-top: $x_small;
}
.history button {
margin-top: $small;
display: flex;
width: 100%;
justify-content: space-between;
padding: 16px;
margin: $xxx_small 0;
flex-direction: column;
gap: $xxx_small;
list-style: none;
background: $gray_1;
border-radius: 8px;
border: none;
color: $gray_4;
font-weight: 700;
font-size: $tertiary-font-size;
.history__item {
&:hover {
text-decoration: none;
cursor: pointer;
background: $gray_3;
color: $gray_1;
input {
position: absolute;
opacity: 0;
&:checked + label {
outline: 3px solid $gray_4;
}
}
label {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: $gray_1;
border-radius: 8px;
color: $gray_4;
font-weight: 700;
font-size: $tertiary-font-size;
&:hover {
text-decoration: none;
cursor: pointer;
background: $gray_3;
color: $gray_1;
}
}
}
}
.button-container {
margin-bottom: $x_small;
}
</style>

View File

@@ -1,91 +1,84 @@
<script lang="ts" setup>
import {type AxeResponses, type QuizResponse, useQuizStore} from "~/store/quiz";
import Quiz from "~/pages/quiz.vue";
import {type Axe, useAxeStore} from "~/store/axe";
const loading = ref(true);
const notFound = ref(false);
const quiz = ref<Quiz>();
const axes = ref<Axe[]>();
onMounted(() => {
if (!useRoute().query.quiz) {
navigateTo("/dashboard");
}
loading.value = true;
notFound.value = false;
const quizId = Number.parseInt(useRoute().query.quiz as string);
useQuizStore().findById(quizId)
.then((result: Quiz) => {
quiz.value = result;
})
.then(() => {
useAxeStore().findAxes().then(result => {
axes.value = result.filter(axe => {
return quiz.value.axes.filter(axeResponse => axeResponse.axeIdentifier === axe.identifier).length > 0;
});
});
})
.catch(() => {
notFound.value = true;
})
.finally(() => {
loading.value = false;
});
});
function getAverage(axe: Axe): number {
const axeResponses = quiz.value.axes.filter((response: AxeResponses) => response.axeIdentifier === axe.identifier);
if (axeResponses.length === 1) {
return axeResponses[0].average;
}
return 0;
}
function getResponses(axe: Axe): QuizResponse[] {
const axeResponses = quiz.value.axes.filter((response: AxeResponses) => response.axeIdentifier === axe.identifier);
if (axeResponses.length === 1) {
return axeResponses[0].responses;
}
return [];
}
function print() {
window.print();
}
</script>
<template>
<div class="content">
<team-header/>
<hr/>
<div v-if="!loading">
<span class="date">{{ quiz.createdDate | formatDate }}</span>
<quiz-axe-details
v-for="axe in axes"
:key="axe.identifier"
:axe="axe"
:score="getScore(axe)"
:responses="getResponses(axe)"/>
</div>
<loader v-else class="center"/>
<div class="button-container">
<nuxt-link class="button orange" to="/dashboard">Retour à l'accueil</nuxt-link>
</div>
<section v-if="!loading">
<span class="date">{{ formatDateTime(quiz.createdDate) }}</span>
<quiz-axe-details
v-for="axe in axes"
:axe="axe"
:average="getAverage(axe)"
:responses="getResponses(axe)"/>
</section>
<section v-else-if="notFound">
<p class="center">Le quizz demandé n'existe pas !</p>
</section>
<loader v-else class="center"/>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/dashboard" aria-label="Retour à l'accueil"></nuxt-link>
<button class="button orange button-print" @click="print">Imprimer</button>
</div>
</template>
<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";
import {AxiosResponse} from "axios";
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
import {Quiz, ResponseWithQuestion, Score} from "~/repositories/models/quiz.model";
import QuizAxeDetails from "~/components/QuizAxeDetails.vue";
import {Axe} from "~/repositories/models/axe.model";
import {RestResponse} from "~/repositories/models/rest-response.model";
@Component({
components: {QuizAxeDetails}
})
export default class Result extends Vue {
readonly axeRepository = RepositoryFactory.get('axe');
readonly quizRepository = RepositoryFactory.get('quiz');
private axes: Axe[] = [];
private quiz: Quiz | null = null;
private responses: ResponseWithQuestion[] = [];
private loading = false;
created() {
if (!this.$route.query.quiz) {
this.$router.push("/dashboard");
}
try {
this.loading = true;
const quizId = Number.parseInt(this.$route.query.quiz as string);
this.quizRepository.findById(quizId)
.then((response: AxiosResponse<Quiz>) => {
this.quiz = response.data;
this.responses = response.data._embedded.responses;
return response;
})
.then(() => {
return this.axeRepository.findAll()
.then((response: AxiosResponse<RestResponse<Axe>>) => {
this.axes = response.data._embedded.axes;
return response;
});
})
.finally(() => {
this.loading = false;
});
} catch (e: any) {
console.info("error", e);
this.loading = false;
}
}
getScore(axe: Axe): Score {
const responses = this.getResponses(axe);
return {
axeIdentifier: axe.identifier,
scoreAvg: responses.reduce((total, response) => total + response.score, 0) / responses.length
};
}
getResponses(axe: Axe) {
return this.responses.filter((response: ResponseWithQuestion) => response.axeIdentifier === axe.identifier);
}
}
</script>
<style lang="scss" scoped>
@import "assets/css/color";
@import "assets/css/font";
section {
margin: $small 0;
}
.date {
font-weight: 700;
@@ -93,4 +86,10 @@ export default class Result extends Vue {
color: $gray_4;
}
@media print {
.button-container {
display: none;
}
}
</style>

14
frontend/pages/error.vue Normal file
View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps({
error: Object as () => NuxtError
})
</script>
<template>
<div>
<h1>{{ error.statusCode }}</h1>
<nuxt-link to="/">Go back home</nuxt-link>
</div>
</template>

View File

@@ -1,3 +1,9 @@
<script setup lang="ts">
definePageMeta({
layout: 'main-header'
});
</script>
<template>
<Home />
<home />
</template>

View File

@@ -1,80 +1,60 @@
<template>
<div>
<Header/>
<form id="login_form" class="content login" @submit.prevent="authenticate">
<input v-model="username" type="text" required autocomplete="username" placeholder="Nom de l'équipe" aria-label="Nom de l'équipe"/>
<input id="code" v-model="password" type="password" required autocomplete="current-password" placeholder="Code" aria-label="Code"/>
<div>
<label>
<input v-model="conditionChecked" type="checkbox" required/>
En continuant, jaccepte les conditions d'utilisation de Boussole PLUSS et jai lu la politique de
confidentialité
</label>
</div>
<button class="button blue" type="submit" :disabled="!conditionChecked">
Continuer
</button>
<div class="error-container">{{ error }}</div>
</form>
</div>
</template>
<script lang="ts" setup>
import {useAuthStore} from "~/store/auth";
import {useNotificationStore} from "~/store/notification";
<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";
import {HTTPResponse} from "@nuxtjs/auth-next/dist";
definePageMeta({
layout: 'main-header'
});
@Component
export default class Login extends Vue {
private username = "";
private password = "";
private conditionChecked: boolean = false;
private error = "";
const email = ref("");
const password = ref("");
async authenticate() {
try {
this.error = "";
const response = await this.$auth.loginWith('local', {
data: {
username: this.username,
password: this.password
}
}) as HTTPResponse;
if (response && response.data) {
const { token } = response.data;
this.$axios.defaults.headers.common = { Authorization: `Bearer ${token}` };
} else {
console.error("Unable to login, no data in HTTP response")
function authenticate() {
useAuthStore()
.login(email.value, password.value)
.then(() => {
if (useAuthStore().authenticated) {
navigateTo("/bundle");
}
} catch (e: any) {
this.error = e;
console.info("error", e);
}
}
})
.catch(e => {
useNotificationStore().pushNotification("warn", e);
});
}
</script>
<template>
<form class="login" @submit.prevent="authenticate">
<input v-model="email" type="email" required autocomplete="username" placeholder="E-mail" aria-label="E-mail"/>
<input id="code" v-model="password" type="password" required autocomplete="current-password"
placeholder="Mot de passe" aria-label="Mot de passe"/>
<button class="button orange" type="submit">
Continuer
</button>
<nuxt-link class="link-forget-password" to="/account/password/reset">J'ai oublié mon mot de passe</nuxt-link>
<nuxt-link class="button blue" to="/account/create">Créer un compte</nuxt-link>
</form>
</template>
<style lang="scss" scoped>
@import "assets/css/spacing";
form.login {
input[type="text"] {
margin: $x_small 0;
}
input[type="password"] {
margin-bottom: $small;
}
display: flex;
flex-direction: column;
gap: $xxx_small;
padding: $x_medium 0;
button {
margin: $medium 0;
margin-block: $x_medium 0;
}
}
.error-container {
display: flex;
justify-content: center;
color: red;
.link-forget-password {
align-self: center;
margin-block: $xx_small $xx_medium;
}
}
</style>

View File

@@ -1,172 +1,105 @@
<script lang="ts" setup>
import type {Axe} from "~/store/axe";
import {useQuizStore} from "~/store/quiz";
const currentAxe = ref<Axe>();
const currentAxeIdentifier = ref(1);
const questions = computed(() => useQuizStore().questions);
const axes = computed(() => useQuizStore().axes);
const loading = ref(true);
const saving = ref(false);
const isFullRated = ref(false);
const store = useQuizStore();
onMounted(() => {
loading.value = true;
store.initialize().finally(() => {
store.resetResponses();
initializeState();
loading.value = false;
});
});
function showPrevious() {
if (currentAxeIdentifier.value > 1) {
currentAxeIdentifier.value--;
initializeState();
}
}
function showNext() {
if (currentAxeIdentifier.value < axes.value.length) {
currentAxeIdentifier.value++;
initializeState();
setTimeout(() => {
scrollTop();
}, 50)
}
}
function initializeState() {
currentAxe.value = axes.value.find(value => value.identifier === currentAxeIdentifier.value);
const questions = store.questionsRatedPerAxe.get(currentAxeIdentifier.value);
if (questions) {
isFullRated.value = questions.filter(value => !value.rated).length == 0;
} else {
isFullRated.value = false;
}
}
function scrollTop() {
window.scrollTo({
top: 60,
behavior: "smooth",
});
}
function saveResult() {
store.save().then((response) => {
navigateTo({path: "result", query: {quiz: response.id + ""}});
});
}
function onRate(event: { isFullRated: boolean }) {
isFullRated.value = event.isFullRated;
}
</script>
<template>
<div class="content">
<team-header/>
<hr/>
<div v-if="!loading && questions && questions.get(currentAxe.identifier)">
<quiz-part
:key="currentAxe.identifier" :axe-number="currentAxe.identifier" :total-axes="axes.length"
:title="currentAxe.title"
:description="currentAxe.description"
:color="currentAxe.color"
:icon="'balise_' + currentAxe.identifier + '.svg'"
:questions="questions.get(currentAxe.identifier)"
@rate="onRate"
/>
<div v-if="!loading">
<quiz-part
:key="currentAxe.identifier" :axe-number="currentAxe.identifier" :total-axes="axes.length"
:title="currentAxe.title"
:description="currentAxe.description"
:color="currentAxe.color"
:icon="'balise_' + currentAxe.identifier + '.svg'"
:questions="questions.get(currentAxe.identifier)"
@rate="onRate"
/>
<div class="button-container">
<nuxt-link
v-if="currentAxeIdentifier <= 1"
class="button gray"
to="/dashboard" aria-label="Précédent">
</nuxt-link>
<button v-if="currentAxeIdentifier > 1" class="button gray" @click="showPrevious"></button>
<button
v-if="currentAxeIdentifier < axes.length" class="button blue" :disabled="!isFilled"
@click="showNext"
>Suivant
</button>
<button
v-if="currentAxeIdentifier >= axes.length" class="button orange"
:disabled="!isFilled || saving" @click="saveResult()"
>Valider
</button>
</div>
</div>
<div v-else class="center">
<loader/>
<div class="button-container">
<nuxt-link
v-if="currentAxeIdentifier <= 1"
class="button gray button-back"
to="/dashboard" aria-label="Précédent">
</nuxt-link>
<button v-if="currentAxeIdentifier > 1" class="button gray" @click="showPrevious"></button>
<button
v-if="currentAxeIdentifier < axes.length" class="button blue" :disabled="!isFullRated"
@click="showNext"
>Suivant
</button>
<button
v-if="currentAxeIdentifier >= axes.length" class="button orange"
:disabled="!isFullRated || saving" @click="saveResult()"
>Valider
</button>
</div>
</div>
<div v-else class="center">
<loader/>
</div>
</template>
<script lang="ts">
import {AxiosResponse} from "axios";
import {Component, Vue} from "nuxt-property-decorator";
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
import {Axe} from "~/repositories/models/axe.model";
import {RestResponse} from "~/repositories/models/rest-response.model";
import {Question} from "~/repositories/models/question.model";
import {Quiz} from "~/repositories/models/quiz.model";
import {quizStore} from "~/utils/store-accessor";
@Component
export default class Login extends Vue {
readonly axeRepository = RepositoryFactory.get("axe");
readonly questionRepository = RepositoryFactory.get("question");
private axes: Axe[] = [];
private currentAxe?: Axe | undefined;
private currentAxeIdentifier = 1;
private questions: Map<number, Question[]> = new Map<number, []>();
private loading = true;
private saving = false;
private isFullRated = false;
mounted() {
this.loading = true;
this.axeRepository
.findAll()
.then((response: AxiosResponse<RestResponse<Axe>>) => {
this.axes = response.data._embedded.axes;
const promises: any[] = [];
this.axes.forEach(axe => {
promises.push(
this.questionRepository
.findAllByAxeId(axe.identifier)
.then((response: AxiosResponse<RestResponse<Question>>) => {
return {
axeId: axe.identifier,
questions: response.data._embedded.questions
};
}));
});
Promise.all(promises).then((axeQuestions) => {
axeQuestions.forEach(axeQuestion => {
this.questions.set(axeQuestion.axeId, axeQuestion.questions)
});
quizStore.initialize(this.questions);
this.initializeState();
this.loading = false;
});
});
}
showPrevious() {
if (this.currentAxeIdentifier > 1) {
this.currentAxeIdentifier--;
this.initializeState();
}
}
showNext() {
if (this.currentAxeIdentifier < this.axes.length) {
this.currentAxeIdentifier++;
this.initializeState();
setTimeout(() => {
this.scrollTop();
}, 50)
}
}
initializeState() {
this.currentAxe = this.axes.find(value => value.identifier === this.currentAxeIdentifier);
const questions = quizStore.questionsRatedPerAxe.get(this.currentAxeIdentifier);
const unratedQuestions = questions ? questions.filter(value => !value.rated) : [];
this.isFullRated = unratedQuestions.length === 0;
}
scrollTop() {
window.scrollTo({
top: 60,
behavior: "smooth",
});
}
saveResult() {
const responsesFormatted: { score: number; comment?: string; questionId: number }[] = [];
quizStore.responses.forEach((value, key) => {
responsesFormatted.push({
score: value.score ? value.score : 0,
comment: value.comment,
questionId: key
});
});
this.saving = true;
RepositoryFactory.get('quiz').save(responsesFormatted).then((response: AxiosResponse<Quiz>) => {
this.saving = false;
quizStore.reset();
this.$router.push({path: "/result", query: {quiz: response.data.id + ""}});
});
}
onRate(event: { isFullRated: boolean }) {
this.isFullRated = event.isFullRated;
}
get isFilled() {
return this.isFullRated;
}
}
</script>
<style lang="scss" scoped>
.button-container {
display: flex;
flex-direction: row;
justify-content: center;
> a {
margin-right: 5px;
}
> button {
margin-left: 5px;
}
}
</style>

View File

@@ -1,20 +0,0 @@
<template>
<div>
Redirect
</div>
</template>
<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";
@Component
export default class Redirect extends Vue {
created(){
if (this.$auth.user) {
this.$router.push(`/dashboard`);
} else {
window.location.reload()
}
}
}
</script>

View File

@@ -1,48 +1,32 @@
<template>
<div class="content">
<team-header/>
<hr/>
<section>
<h1>Bravo !</h1>
<p class="text-center">Merci pour votre contribution à la production locale et bravo pour votre implication.</p>
<div v-if="!loading" class="chart-area">
<polar-area-chart :data="scores"/>
<Legend/>
</div>
<loader v-else class="center"/>
<nuxt-link class="button orange" to="/dashboard">Retour à l'accueil</nuxt-link>
<script lang="ts" setup>
</section>
</div>
import {type Quiz, useQuizStore} from "~/store/quiz";
const scores = ref<number[]>();
const loading = ref(true);
onMounted(() => {
loading.value = true;
useQuizStore().findById(useRoute().query.quiz).then((quiz: Quiz) => {
scores.value = quiz.axes.map(value => value.average);
})
.finally(() => loading.value = false);
});
</script>
<template>
<section>
<h1>Bravo !</h1>
<p class="text-center">Merci pour votre contribution à la production locale et bravo pour votre implication.</p>
<div v-if="!loading" class="chart-area">
<polar-area-chart :data="scores"/>
<Legend/>
</div>
<loader v-else class="center"/>
<nuxt-link class="button orange" to="/dashboard">Retour à l'accueil</nuxt-link>
</section>
</template>
<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";
import {AxiosResponse} from "axios";
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
import { Score} from "~/repositories/models/quiz.model";
@Component
export default class Result extends Vue {
readonly quizRepository = RepositoryFactory.get('quiz');
private scores: number[] = [];
private loading = false;
mounted() {
this.loading = true;
try {
this.quizRepository.findScores(this.$route.query.quiz).then((response: AxiosResponse<Score[]>) => {
this.scores = response.data.map(value => value.scoreAvg);
});
} catch (e: any) {
console.info("error", e);
} finally {
this.loading = false;
}
}
}
</script>
<style lang="scss" scoped>
@@ -52,7 +36,7 @@ export default class Result extends Vue {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
margin: $x_small 0;
margin: $small 0;
}
</style>