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:
		
							
								
								
									
										128
									
								
								frontend/pages/account/create.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								frontend/pages/account/create.vue
									
									
									
									
									
										Normal 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é d’au 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, j’accepte | ||||
|           <button class="button-link" @click="cguModalVisible = true" type="button">les conditions d'utilisation de Boussole PLUSS et j’ai 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> | ||||
							
								
								
									
										68
									
								
								frontend/pages/account/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								frontend/pages/account/index.vue
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										55
									
								
								frontend/pages/account/password/confirm-reset.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								frontend/pages/account/password/confirm-reset.vue
									
									
									
									
									
										Normal 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>Ré-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é d’au 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> | ||||
|  | ||||
							
								
								
									
										46
									
								
								frontend/pages/account/password/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/pages/account/password/index.vue
									
									
									
									
									
										Normal 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é d’au 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> | ||||
|  | ||||
							
								
								
									
										61
									
								
								frontend/pages/account/password/reset.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								frontend/pages/account/password/reset.vue
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										182
									
								
								frontend/pages/bundle/create.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								frontend/pages/bundle/create.vue
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										126
									
								
								frontend/pages/bundle/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								frontend/pages/bundle/index.vue
									
									
									
									
									
										Normal 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 d’auto-évaluation Boussole !</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 !</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
									
								
							
							
						
						
									
										20
									
								
								frontend/pages/cgu.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| <script lang="ts" setup> | ||||
|  | ||||
| definePageMeta({ | ||||
|   layout: 'main-header' | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <section> | ||||
|     <h1>Conditions Générales d’Utilisation 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> | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										14
									
								
								frontend/pages/error.vue
									
									
									
									
									
										Normal 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> | ||||
| @@ -1,3 +1,9 @@ | ||||
| <script setup lang="ts"> | ||||
| definePageMeta({ | ||||
|   layout: 'main-header' | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <Home /> | ||||
|   <home /> | ||||
| </template> | ||||
|   | ||||
| @@ -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, j’accepte les conditions d'utilisation de Boussole PLUSS et j’ai 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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user