tripeur 4 лет назад
Родитель
Сommit
6c56e72724

+ 31 - 0
src/LoginPage.vue

@@ -0,0 +1,31 @@
+<template>
+  <div class="header">
+    <div class="logo"></div>
+    <a class="appname" href="/planner">BDLG planner</a>
+  </div>
+  <div class="container">
+    <login class="login-box" />
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import login from "./views/Login.vue";
+
+export default defineComponent({
+  components: { login },
+});
+</script>
+<style>
+.container {
+  margin-top: 80px;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+}
+.login-box {
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+  padding: 8px 32px 16px;
+  min-width: 420px;
+}
+</style>

+ 121 - 21
src/App.vue → src/PlannerApp.vue

@@ -1,17 +1,19 @@
 <template>
   <div class="header">
-    <div class="logo"></div>
-    <h1 class="appname">BDLG planner</h1>
+    <div class="logo" />
+    <router-link to="/planner" class="appname">BDLG planner</router-link>
     <nav class="tabs floating">
-      <router-link class="tab" active-class="selected" to="/"> Accueil </router-link>
-      <router-link class="tab" active-class="selected" to="/evenement">Planning</router-link>
-      <router-link class="tab" active-class="selected" to="/competences">
+      <router-link class="tab" active-class="selected" to="/planner/evenement">
+        Accueil
+      </router-link>
+      <router-link class="tab" active-class="selected" to="/planner/planning">Planning</router-link>
+      <router-link class="tab" active-class="selected" to="/planner/competences">
         Gestion des compétences
       </router-link>
-      <router-link class="tab" active-class="selected" to="/benevoles">
+      <router-link class="tab" active-class="selected" to="/planner/benevoles">
         Gestion des bénévoles
       </router-link>
-      <router-link class="tab" active-class="selected" to="/planningIndividuel">
+      <router-link class="tab" active-class="selected" to="/planner/planningIndividuel">
         Planning Individuel
       </router-link>
     </nav>
@@ -21,7 +23,9 @@
       @export="exportStateToJson"
       @import="(e) => importJsonState(e, false)"
       @localSave="localSave"
+      @save="save"
       @solve="solve"
+      @newEvenement="newEvenement"
     />
     <div v-if="optimisationInProgress" class="modal">
       <div class="modal-content">
@@ -53,11 +57,15 @@ import {
   HardConstraint,
 } from "./models/SolverInput";
 import Ressource, { IRessource, RessourceJSON } from "jc-timeline/lib/Ressource";
+import updatePlanningVersions from "@/mixins/updatePlanningVersions";
 import { MutationTypes } from "./store/Mutations";
 import dayjs from "dayjs";
 import { StateJSON } from "./store/State";
+const APIAdress = "http://localhost:8080/";
+
 const keyofEvent: Array<keyof Evenement> = ["name", "uuid", "start", "end"];
 export default defineComponent({
+  mixins: [updatePlanningVersions],
   data() {
     return {
       optimisationInProgress: false,
@@ -68,10 +76,22 @@ export default defineComponent({
   },
   mounted() {
     const previousState = window.localStorage.getItem("activeState");
-    toast({ html: "coucou" });
+    toast({ html: "Bienvenue" });
     if (previousState) {
       this.importJsonState(JSON.parse(previousState), false);
+      this.$router.push({ name: "Main" });
     }
+    this.$nextTick(() =>
+      fetch(`${APIAdress}api/evenements`)
+        .then((response) => {
+          if (response.status == 200) {
+            return response.json();
+          } else {
+            throw new Error(response.statusText);
+          }
+        })
+        .then((data) => this.$store.commit(MutationTypes.refreshSavedPlanning, data))
+    );
     window.onbeforeunload = () => {
       this.localSave();
     };
@@ -80,12 +100,75 @@ export default defineComponent({
     localSave() {
       window.localStorage.setItem("activeState", JSON.stringify(this.$store.getters.getJSONState));
     },
+    save() {
+      const body = {
+        uuid: this.$store.state.evenement.uuid,
+        name: this.$store.state.evenement.name,
+        content: JSON.stringify(this.$store.getters.getJSONState),
+      };
+      // local save
+      window.localStorage.setItem("activeState", body.content);
+      fetch(`${APIAdress}api/evenements/${body.uuid}`, {
+        method: "PUT",
+        body: JSON.stringify(body),
+        headers: {
+          "Content-Type": "application/json",
+        },
+      })
+        .then((response) => {
+          if (response.status == 200) {
+            toast({ html: "Données sauvegardées", classes: "success" });
+            return response.json();
+          } else if (response.status == 404) {
+            toast({
+              html:
+                "Erreur: Le planning n'existe pas dans la base de données.<br>Création du planning ",
+              classes: "error",
+              displayLength: 5000,
+            });
+            return fetch(`${APIAdress}api/evenements/`, {
+              method: "POST",
+              body: JSON.stringify(body),
+              headers: {
+                "Content-Type": "application/json",
+              },
+            }).then((response) => {
+              if (response.status == 200) {
+                toast({ html: "Données sauvegardées", classes: "success", displayLength: 5000 });
+                return response.json();
+              } else {
+                toast({
+                  html: "Erreur: Les données n'ont pas été sauvegardées",
+                  classes: "error",
+                  displayLength: 5000,
+                });
+                throw new Error(response.statusText);
+              }
+            });
+          }
+          throw new Error(response.statusText);
+        })
+        .then((data) => this.$store.commit(MutationTypes.newVersion, data))
+        .catch((err) => toast({ html: err }));
+    },
     clearToast() {
       if (this.lastToast) {
         this.lastToast.dismiss();
         this.lastToast = null;
       }
     },
+    newEvenement() {
+      const newState: StateJSON = {
+        evenement: new Evenement().toJSON(),
+        competences: [],
+        benevoles: [],
+        creneaux: [],
+        creneauGroups: [],
+      };
+      newState.evenement.name = "Nouvel événement";
+      this.save();
+      this.importJsonState(newState);
+    },
     exportStateToJson(): void {
       const obj: StateJSON = this.$store.getters.getJSONState;
       const mimeType = "data:text/json;charset=utf-8";
@@ -127,8 +210,14 @@ export default defineComponent({
         headers: { "Content-type": "application/json; charset=UTF-8" },
       };
       this.optimisationInProgress = true;
-      fetch(`http://localhost:8080/planning/solve`, options)
-        .then((response) => response.json())
+      fetch(`${APIAdress}planning/solve`, options)
+        .then((response) => {
+          if (response.status == 200) {
+            return response.json();
+          } else {
+            throw new Error(response.statusText);
+          }
+        })
         .then(this.updatePlanningWithNewPairing)
         .catch((error) => {
           toast({ html: "Pas de réponse du serveur<br>" + error.toString(), classes: "error" });
@@ -143,7 +232,12 @@ export default defineComponent({
         classes: "success",
         displayLength: 10000,
       });
-      if (data.message) toast({ html: data.message, classes: "warning", displayLength: 10000 });
+      if (data.message)
+        toast({
+          html: data.message.replaceAll("\n", "<br>"),
+          classes: "warning",
+          displayLength: 10000,
+        });
       this.displayExplanation(data.explanation);
 
       // Remove previous timeslot assignement
@@ -188,20 +282,23 @@ export default defineComponent({
       }
       this.explanation = scoreExplanationHTML;
       this.clearToast();
-      this.lastToast = toast({
-        html: `<span>Ouvrir l'explication du score</span><i class="material-icons">close<i>`,
-        displayLength: Infinity,
-        classes: "action",
-      });
-      this.lastToast.element.firstElementChild?.addEventListener(
-        "click",
-        () => (this.showExplanation = true)
-      );
-      this.lastToast.element.lastElementChild?.addEventListener("click", this.clearToast);
+      if (scoreExplanationHTML != "") {
+        this.lastToast = toast({
+          html: `<span>Ouvrir l'explication du score</span><i class="material-icons">close<i>`,
+          displayLength: Infinity,
+          classes: "action",
+        });
+        this.lastToast.element.firstElementChild?.addEventListener(
+          "click",
+          () => (this.showExplanation = true)
+        );
+        this.lastToast.element.lastElementChild?.addEventListener("click", this.clearToast);
+      }
     },
     importJsonState(obj: StateJSON, preserve = false) {
       // Remove previous content and load the main event title
       if (preserve == false) {
+        const prevUuid = this.$store.state.evenement.uuid;
         this.$store.commit(MutationTypes.resetState, undefined);
         const e = Evenement.fromJSON(obj.evenement);
         for (const k of keyofEvent) {
@@ -210,6 +307,9 @@ export default defineComponent({
             value: e[k],
           });
         }
+        if (e.uuid != prevUuid) {
+          this.updatePlanningVersions();
+        }
       }
       // Import constraint
       obj.competences.forEach((c) => {

+ 13 - 2
src/assets/css/main.css

@@ -86,6 +86,7 @@ body {
   white-space: nowrap;
   padding-left: 1rem;
   padding-right: 1rem;
+  text-decoration: none;
 }
 
 @font-face {
@@ -124,7 +125,7 @@ body {
   bottom: -4px;
   border-radius: 50%;
   font-size: 12px;
-  background: #e4002b;;
+  background: #e4002b;
   font-weight: bold;
 }
 .formcontrol {
@@ -216,4 +217,14 @@ body {
   100% {
     transform: rotateZ(0deg);
   }
-}
+}
+.centered-box {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin: 20px;
+}
+.centered-box > div {
+  box-shadow: 0 0 2px var(--color-neutral-400);
+  padding: 16px;
+}

+ 5 - 32
src/components/DataTable.vue

@@ -126,6 +126,7 @@
 import { defineComponent, PropType } from "vue";
 import Fuse from "fuse.js";
 import { Dictionary } from "lodash";
+import dayjs from "dayjs";
 
 interface DataTableLocale {
   rows_per_page: string;
@@ -335,45 +336,17 @@ export default defineComponent({
       const d = new Date();
       const dummy = document.createElement("a");
       dummy.href = mimeType + ", " + encodeURI(csvContent);
-      dummy.download =
-        documentPrefix +
-        "-" +
-        d.getFullYear() +
-        "-" +
-        (d.getMonth() + 1) +
-        "-" +
-        d.getDate() +
-        "-" +
-        d.getHours() +
-        "-" +
-        d.getMinutes() +
-        "-" +
-        d.getSeconds() +
-        ".csv";
+      dummy.download = documentPrefix + dayjs(d).format("-YYYY-MM-DD-hh-mm-ss[.csv]");
       document.body.appendChild(dummy);
       dummy.click();
     },
     renderTable() {
       let table = "<table><thead>";
-      table += "<tr>";
-      for (let i = 0; i < this.columns.length; i++) {
-        const column = this.columns[i];
-        table += "<th>";
-        table += column.label;
-        table += "</th>";
-      }
-      table += "</tr>";
+      table += `<tr>${this.columns.map((c) => `<th>${c.label}</th>`).join("")}</th>`;
       table += "</thead><tbody>";
       for (let i = 0; i < this.rows.length; i++) {
-        const row = this.rows[i];
-        table += "<tr>";
-        for (let j = 0; j < this.columns.length; j++) {
-          const column = this.columns[j];
-          table += "<td>";
-          table += row[column.field];
-          table += "</td>";
-        }
-        table += "</tr>";
+        const r = this.rows[i];
+        table += `<tr>${this.columns.map((c) => `<td>${r[c.field]}</td>`).join("")}</th>`;
       }
       table += "</tbody></table>";
       return table;

+ 72 - 0
src/components/EvenementDataTable.vue

@@ -0,0 +1,72 @@
+<template>
+  <table>
+    <thead>
+      <tr>
+        <th>Nom</th>
+        <th>Modifié le</th>
+        <th>Par</th>
+        <th>Charger</th>
+      </tr>
+    </thead>
+    <tbody>
+      <tr v-for="version in versions" :key="version.id">
+        <td>{{ version.name }}</td>
+        <td>{{ formatDate(version.lastModified) }}</td>
+        <td>{{ version.lastEditor.username }}</td>
+        <td>
+          <button class="btn icon small primary" @click="loadPreviousVersion(version.id)">
+            <i class="material-icons">launch</i>
+          </button>
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</template>
+
+<script lang="ts">
+import EvenementVersion from "@/models/EvenementVersion";
+import dayjs from "dayjs";
+import { defineComponent, PropType } from "vue";
+
+export default defineComponent({
+  emits: ["loadVersion"],
+  props: {
+    versions: { type: Array as PropType<Array<EvenementVersion>>, default: () => [] },
+  },
+  methods: {
+    formatDate(str: string): string {
+      return dayjs(str).format("YYYY MMM DD HH[h]mm ss[s]");
+    },
+    loadPreviousVersion(id: number) {
+      const version = this.versions.find((o) => o.id == id);
+      if (version) {
+        this.$emit("loadVersion", version);
+      }
+    },
+  },
+});
+</script>
+
+<style scoped>
+table {
+  border-collapse: collapse;
+}
+table > thead > tr > th {
+  padding: 8px;
+  width: 190px;
+  text-overflow: ellipsis;
+}
+table > thead > tr > th:last-child {
+  width: 50px;
+}
+table > tbody > tr > td {
+  text-align: center;
+  padding: 8px;
+}
+table > tbody > tr {
+  border-bottom: solid 1px var(--color-neutral-800);
+}
+table > tbody > tr:hover {
+  background: var(--color-neutral-800);
+}
+</style>

+ 5 - 1
src/components/input.vue

@@ -8,6 +8,7 @@
       <textarea
         class="input"
         :id="id"
+        :name="name"
         cols="30"
         rows="10"
         v-if="type == 'textarea'"
@@ -21,6 +22,7 @@
         class="input"
         :class="inputClass"
         id="id"
+        :name="name"
         :type="type"
         :placeholder="placeholder"
         v-model="value"
@@ -29,12 +31,13 @@
       />
     </div>
     <div class="formcontrol-help" v-if="helpText || helpLinkHref">
-      {{ helpText }}}
+      {{ helpText }}
       <div class="formcontrol-helplink">
         <a
           class="ds-link ds-link--underlined ds-link--xsmall"
           :href="helpLinkHref"
           @click="onHelpLinkClick"
+          v-if="helpLinkHref"
         >
           {{ helpLinkLabel }}
         </a>
@@ -54,6 +57,7 @@ export default defineComponent({
   props: {
     id: String,
     label: String,
+    name: String,
     modelValue: { type: [String, Number] },
     placeholder: { type: String, default: "" },
     helpText: String,

+ 5 - 0
src/login.ts

@@ -0,0 +1,5 @@
+import { createApp } from "vue";
+import Login from "./LoginPage.vue";
+
+const app = createApp(Login);
+app.mount("#app");

+ 1 - 1
src/main.ts

@@ -1,5 +1,5 @@
 import { createApp } from "vue";
-import App from "./App.vue";
+import App from "./PlannerApp.vue";
 import router from "./router";
 import { store } from "./store/Store";
 import "@/assets/css/main.css";

+ 19 - 0
src/mixins/fetchPlanningVersion.ts

@@ -0,0 +1,19 @@
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  emits: ["import"],
+  methods: {
+    fetchPlanningVersions(url: string) {
+      fetch(url)
+        .then((response) => {
+          if (response.status == 200) {
+            return response.json();
+          } else {
+            throw new Error(response.statusText);
+          }
+        })
+        .then((data) => this.$emit("import", data))
+        .catch((err) => console.error("Error while getting planning history", err));
+    },
+  },
+});

+ 19 - 0
src/mixins/updatePlanningVersions.ts

@@ -0,0 +1,19 @@
+import { MutationTypes } from "@/store/Mutations";
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  methods: {
+    updatePlanningVersions() {
+      fetch("/api/evenements/history/" + this.$store.state.evenement.uuid)
+        .then((response) => {
+          if (response.status == 200) {
+            return response.json();
+          } else {
+            throw new Error(response.statusText);
+          }
+        })
+        .then((data) => this.$store.commit(MutationTypes.refreshVersion, data))
+        .catch((err) => console.error("Error while getting planning history", err));
+    },
+  },
+});

+ 8 - 0
src/models/EvenementVersion.ts

@@ -0,0 +1,8 @@
+type EvenementVersion = {
+  id: number;
+  lastEditor: { username: string; email: null | string };
+  lastModified: string; //ISO string
+  name: string;
+  uuid: string;
+};
+export default EvenementVersion;

+ 13 - 7
src/router/index.ts

@@ -1,33 +1,39 @@
 import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
-import Home from "../views/Home.vue";
+import Evenement from "../views/Evenement.vue";
 import Planning from "@/views/Planning.vue";
 import CompetenceManager from "@/views/CompetenceManager.vue";
 import BenevoleManager from "@/views/BenevoleManager.vue";
-import PlanningPersonnel from "../views/PlanningPersonnel.vue";
+import PlanningPersonnel from "@/views/PlanningPersonnel.vue";
+import Home from "@/views/Home.vue";
 
 const routes: Array<RouteRecordRaw> = [
   {
-    path: "/",
+    path: "/planner/",
     name: "Home",
     component: Home,
   },
   {
-    path: "/evenement",
+    path: "/planner/evenement",
+    name: "Evenement",
+    component: Evenement,
+  },
+  {
+    path: "/planner/planning",
     name: "Planning",
     component: Planning,
   },
   {
-    path: "/competences",
+    path: "/planner/competences",
     name: "Competence",
     component: CompetenceManager,
   },
   {
-    path: "/benevoles",
+    path: "/planner/benevoles",
     name: "Benevoles",
     component: BenevoleManager,
   },
   {
-    path: "/planningIndividuel",
+    path: "/planner/planningIndividuel",
     name: "planningIndividuel",
     component: PlanningPersonnel,
   },

+ 30 - 0
src/store/Mutations.ts

@@ -2,6 +2,7 @@ import Benevole from "@/models/Benevole";
 import Competence from "@/models/Competence";
 import Creneau from "@/models/Creneau";
 import Evenement from "@/models/Evenement";
+import EvenementVersion from "@/models/EvenementVersion";
 import { Ressource } from "jc-timeline";
 import { MutationTree } from "vuex";
 import { State } from "./State";
@@ -29,6 +30,12 @@ export enum MutationTypes {
   addConstraint = "addConstraint",
   removeConstraint = "removeConstraint",
   editConstraint = "editConstraint",
+
+  newVersion = "newVersion",
+  refreshVersion = "refreshVersion",
+
+  pushSavedPlanning = "pushSavedPlanning",
+  refreshSavedPlanning = "refreshSavedPlanning",
 }
 interface CreneauPairing {
   creneauId: string;
@@ -74,6 +81,12 @@ export type Mutations<S = State> = {
     state: S,
     payload: { id: number; field: K; value: Competence[K] }
   ): boolean;
+
+  [MutationTypes.newVersion](state: S, payload: EvenementVersion): void;
+  [MutationTypes.refreshVersion](state: S, payload: Array<EvenementVersion>): void;
+
+  [MutationTypes.pushSavedPlanning](state: S, payload: EvenementVersion): void;
+  [MutationTypes.refreshSavedPlanning](state: S, payload: Array<EvenementVersion>): void;
 };
 export const mutations: MutationTree<State> & Mutations = {
   [MutationTypes.resetState](state): void {
@@ -183,4 +196,21 @@ export const mutations: MutationTree<State> & Mutations = {
     }
     return false;
   },
+
+  [MutationTypes.newVersion](state, payload) {
+    state.history = [payload, ...state.history];
+    if (state.savedPlanning.find((o) => o.uuid == payload.uuid)) {
+      state.savedPlanning = [payload, ...state.savedPlanning];
+    }
+  },
+  [MutationTypes.refreshVersion](state, payload) {
+    state.history = payload;
+  },
+
+  [MutationTypes.pushSavedPlanning](state, payload) {
+    state.savedPlanning = [payload, ...state.savedPlanning];
+  },
+  [MutationTypes.refreshSavedPlanning](state, payload) {
+    state.savedPlanning = payload;
+  },
 };

+ 3 - 0
src/store/State.ts

@@ -1,4 +1,5 @@
 import Evenement, { IEvenement } from "@/models/Evenement";
+import EvenementVersion from "@/models/EvenementVersion";
 import { Ressource, RessourceJSON } from "jc-timeline";
 import Benevole, { BenevoleJSON } from "../models/Benevole";
 import Competence, { ICompetence } from "../models/Competence";
@@ -18,5 +19,7 @@ export const state = {
   creneauGroupList: [] as Array<Ressource>,
   competenceList: [] as Array<Competence>,
   benevoleList: [] as Array<Benevole>,
+  history: [] as Array<EvenementVersion>,
+  savedPlanning: [] as Array<EvenementVersion>,
 };
 export type State = typeof state;

+ 207 - 0
src/views/Evenement.vue

@@ -0,0 +1,207 @@
+<template>
+  <div class="centered-box">
+    <div>
+      <h3>Planning Actif</h3>
+      <styled-input label="Identifiant" :modelValue="evenement.uuid" disabled />
+      <styled-input
+        label="Nom de l'événement"
+        :modelValue="evenement.name"
+        @input="inputListener($event, 'name')"
+      />
+      <datepicker label="Début" target="hour" v-model="start" />
+      <datepicker label="Fin" target="hour" v-model="end" />
+
+      <div class="actions" style="text-align: center">
+        <button class="btn success" @click="$emit('solve')">
+          <i class="material-icons">play_arrow</i> Résoudre la plannification
+        </button>
+      </div>
+      <div class="actions" style="margin-top: 8px">
+        <button class="btn primary small" @click="$emit('save')">
+          <i class="material-icons">save</i> Sauvergarder
+        </button>
+        <button class="btn primary small" @click="exportStateToJson">
+          <i class="material-icons">download</i> Télécharger les données
+        </button>
+        <button class="btn primary small" @click="clickInput">
+          <i class="material-icons">upload</i>Import des données
+        </button>
+        <button class="btn small icon error" @click="toggleModal">
+          <i class="material-icons">event_busy</i>
+        </button>
+        <input
+          ref="input"
+          style="display: none"
+          type="file"
+          accept=".json,application/json"
+          @change="importJsonState"
+        />
+      </div>
+    </div>
+    <div v-if="showModal" class="modal" @click="toggleModal">
+      <div class="modal-content" @click="(e) => e.stopPropagation()">
+        <h3>Confirmer la suppression des créneaux</h3>
+        <p>
+          Êtes vous sûr de vouloir supprimer tout les créneaux qui ne sont pas entre le<br />
+          {{ start.format("dddd DD MMMM YYYY, HH:mm") }}<br />
+          et le <br />{{ end.format("dddd DD MMMM YYYY, HH:mm") }}
+        </p>
+        <div class="actions" style="margin-top: 8px; text-align: right">
+          <button class="btn error small" @click="clearCreneau">
+            <i class="material-icons">delete_forever</i>Confirmer
+          </button>
+          <button class="btn primary small" @click="toggleModal">
+            <i class="material-icons">close</i>Annuler
+          </button>
+        </div>
+      </div>
+    </div>
+    <div>
+      <h2>
+        Version antérieurs
+        <button class="btn icon small secondary" @click="updatePlanningVersions">
+          <i class="material-icons">refresh</i>
+        </button>
+      </h2>
+      <evenement-data-table
+        :versions="planningVersions"
+        @loadVersion="loadVersion"
+      ></evenement-data-table>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { Dayjs } from "dayjs";
+import { defineComponent } from "vue";
+import { MutationTypes } from "@/store/Mutations";
+import Evenement from "@/models/Evenement";
+import EvenementVersion from "@/models/EvenementVersion";
+import styledInput from "@/components/input.vue";
+import datepicker from "@/components/date-picker.vue";
+import EvenementDataTable from "@/components/EvenementDataTable.vue";
+import updatePlanningVersions from "@/mixins/updatePlanningVersions";
+import fetchPlanningVersion from "@/mixins/fetchPlanningVersion";
+
+export default defineComponent({
+  mixins: [updatePlanningVersions, fetchPlanningVersion],
+  components: { styledInput, datepicker, EvenementDataTable },
+  data() {
+    return {
+      start: null as Dayjs | null,
+      end: null as Dayjs | null,
+      showModal: false,
+    };
+  },
+  watch: {
+    start(val: Dayjs, oldval) {
+      if (oldval && !val.isSame(this.evenement.startingDate)) {
+        this.$store.commit(MutationTypes.editEvenement, {
+          field: "start",
+          value: val.toDate(),
+        });
+      }
+    },
+    end(val: Dayjs, oldval) {
+      if (oldval && !val.isSame(this.evenement.endingDate, "minutes")) {
+        this.$store.commit(MutationTypes.editEvenement, {
+          field: "end",
+          value: val.subtract(1, "h").endOf("h").toDate(),
+        });
+      }
+    },
+    "evenement.start": function () {
+      this.initDate();
+    },
+    "evenement.end": function () {
+      this.initDate();
+    },
+  },
+  computed: {
+    evenement(): Evenement {
+      return this.$store.state.evenement;
+    },
+    planningVersions(): Array<EvenementVersion> {
+      return this.$store.state.history;
+    },
+  },
+  methods: {
+    loadVersion(version: EvenementVersion) {
+      this.fetchPlanningVersions(`/api/evenements/history/${version.uuid}/content/${version.id}`);
+    },
+    toggleModal() {
+      this.showModal = !this.showModal;
+    },
+    initDate() {
+      if (this.evenement) {
+        this.start = this.evenement.startingDate;
+        this.end = this.evenement.endingDate;
+      }
+    },
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    inputListener(event: any, field: keyof Evenement) {
+      this.$store.commit(MutationTypes.editEvenement, {
+        field: field,
+        value: event.target.value,
+      });
+    },
+    exportStateToJson() {
+      this.$emit("export");
+    },
+    clickInput() {
+      (this.$refs["input"] as HTMLElement).click();
+    },
+    importJsonState(event: InputEvent) {
+      const files = (event.target as HTMLInputElement).files;
+      if (files) {
+        const file = files[0];
+        if (file) {
+          var reader = new FileReader();
+          reader.onload = () => {
+            var obj = JSON.parse(reader.result as string);
+            this.$emit("import", obj);
+          };
+          reader.readAsText(file);
+        }
+      }
+    },
+    clearCreneau() {
+      this.showModal = false;
+      const max = this.$store.state.evenement.end;
+      const min = this.$store.state.evenement.start;
+      const toBeRemoved = this.$store.state.creneauList.filter((c) => max < c.start || c.end < min);
+
+      for (let elt of toBeRemoved) {
+        this.$store.commit(MutationTypes.removeCreneau, elt);
+      }
+    },
+  },
+  mounted() {
+    this.initDate();
+  },
+});
+</script>
+
+<style scoped>
+.centered-box {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin: 20px;
+  flex-direction: column;
+}
+.centered-box > div {
+  box-shadow: 0 0 2px var(--color-neutral-400);
+  padding: 16px;
+  margin-bottom: 32px;
+}
+h2 {
+  margin-top: 0px;
+  margin-left: 8px;
+}
+table {
+  margin: 8px;
+  max-height: 800px;
+  overflow: auto;
+}
+</style>

+ 22 - 152
src/views/Home.vue

@@ -1,172 +1,42 @@
 <template>
   <div class="centered-box">
     <div>
-      <h3>Bienvenue sur le gestionnaire du planning bénévoles</h3>
-      <styled-input label="Identifiant" :modelValue="evenement.uuid" disabled />
-      <styled-input
-        label="Nom de l'événement"
-        :modelValue="evenement.name"
-        @input="inputListener($event, 'name')"
-      />
-      <datepicker label="Début" target="hour" v-model="start" />
-      <datepicker label="Fin" target="hour" v-model="end" />
-
-      <div class="actions" style="text-align: center">
-        <button class="btn success" @click="$emit('solve')">
-          <i class="material-icons">play_arrow</i> Résoudre la plannification
-        </button>
-      </div>
-      <div class="actions" style="margin-top: 8px">
-        <button class="btn primary small" @click="$emit('localSave')">
-          <i class="material-icons">save</i> Sauvergarder
-        </button>
-        <button class="btn primary small" @click="exportStateToJson">
-          <i class="material-icons">download</i> Télécharger les données
+      <h2>Bienvenue sur le gestionnaire du planning bénévoles</h2>
+      <h3>
+        Planning existants
+        <button class="btn icon" @click="$emit('newEvenement')">
+          <i class="material-icons">edit</i>
         </button>
-        <button class="btn primary small" @click="clickInput">
-          <i class="material-icons">upload</i>Import des données
-        </button>
-        <button class="btn small icon error" @click="toggleModal">
-          <i class="material-icons">event_busy</i>
-        </button>
-        <input
-          ref="input"
-          style="display: none"
-          type="file"
-          accept=".json,application/json"
-          @change="importJsonState"
-        />
-      </div>
-    </div>
-    <div v-if="showModal" class="modal" @click="toggleModal">
-      <div class="modal-content" @click="(e) => e.stopPropagation()">
-        <h3>Confirmer la suppression des créneaux</h3>
-        <p>
-          Êtes vous sûr de vouloir supprimer tout les créneaux qui ne sont pas entre le<br />
-          {{ start.format("dddd DD MMMM YYYY, HH:mm") }}<br />
-          et le <br />{{ end.format("dddd DD MMMM YYYY, HH:mm") }}
-        </p>
-        <div class="actions" style="margin-top: 8px; text-align: right">
-          <button class="btn error small" @click="clearCreneau">
-            <i class="material-icons">delete_forever</i>Confirmer
-          </button>
-          <button class="btn primary small" @click="toggleModal">
-            <i class="material-icons">close</i>Annuler
-          </button>
-        </div>
-      </div>
+      </h3>
+      <evenement-data-table
+        :versions="savedPlanning"
+        @loadVersion="loadVersion"
+      ></evenement-data-table>
     </div>
   </div>
 </template>
 
 <script lang="ts">
-import Evenement from "@/models/Evenement";
-import { MutationTypes } from "@/store/Mutations";
-import { Dayjs } from "dayjs";
+import EvenementVersion from "@/models/EvenementVersion";
+import fetchPlanningVersions from "@/mixins/fetchPlanningVersion";
+import EvenementDataTable from "@/components/EvenementDataTable.vue";
 import { defineComponent } from "vue";
-import styledInput from "../components/input.vue";
-import datepicker from "../components/date-picker.vue";
+
 export default defineComponent({
-  components: { styledInput, datepicker },
-  data() {
-    return {
-      start: null as Dayjs | null,
-      end: null as Dayjs | null,
-      showModal: false,
-    };
-  },
-  watch: {
-    start(val: Dayjs, oldval) {
-      if (oldval && !val.isSame(this.evenement.startingDate)) {
-        this.$store.commit(MutationTypes.editEvenement, {
-          field: "start",
-          value: val.toDate(),
-        });
-      }
-    },
-    end(val: Dayjs, oldval) {
-      if (oldval && !val.isSame(this.evenement.endingDate, "minutes")) {
-        this.$store.commit(MutationTypes.editEvenement, {
-          field: "end",
-          value: val.subtract(1, "h").endOf("h").toDate(),
-        });
-      }
-    },
-    "evenement.start": function () {
-      this.initDate();
-    },
-    "evenement.end": function () {
-      this.initDate();
-    },
-  },
+  components: { EvenementDataTable },
+  mixins: [fetchPlanningVersions],
+  emits: ["newEvenement"],
   computed: {
-    evenement(): Evenement {
-      return this.$store.state.evenement;
+    savedPlanning(): Array<EvenementVersion> {
+      return this.$store.state.savedPlanning;
     },
   },
   methods: {
-    toggleModal() {
-      this.showModal = !this.showModal;
-    },
-    initDate() {
-      if (this.evenement) {
-        this.start = this.evenement.startingDate;
-        this.end = this.evenement.endingDate;
-      }
-    },
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    inputListener(event: any, field: keyof Evenement) {
-      this.$store.commit(MutationTypes.editEvenement, {
-        field: field,
-        value: event.target.value,
-      });
-    },
-    exportStateToJson() {
-      this.$emit("export");
+    loadVersion(version: EvenementVersion) {
+      this.fetchPlanningVersions(`/api/evenements/${version.uuid}`);
     },
-    clickInput() {
-      (this.$refs["input"] as HTMLElement).click();
-    },
-    importJsonState(event: InputEvent) {
-      const files = (event.target as HTMLInputElement).files;
-      if (files) {
-        const file = files[0];
-        if (file) {
-          var reader = new FileReader();
-          reader.onload = () => {
-            var obj = JSON.parse(reader.result as string);
-            this.$emit("import", obj);
-          };
-          reader.readAsText(file);
-        }
-      }
-    },
-    clearCreneau() {
-      this.showModal = false;
-      const max = this.$store.state.evenement.end;
-      const min = this.$store.state.evenement.start;
-      const toBeRemoved = this.$store.state.creneauList.filter((c) => max < c.start || c.end < min);
-
-      for (let elt of toBeRemoved) {
-        this.$store.commit(MutationTypes.removeCreneau, elt);
-      }
-    },
-  },
-  mounted() {
-    this.initDate();
   },
 });
 </script>
 
-<style scoped>
-.centered-box {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  margin: 20px;
-}
-.centered-box > div {
-  box-shadow: 0 0 2px var(--color-neutral-400);
-  padding: 16px;
-}
-</style>
+<style scoped></style>

+ 88 - 0
src/views/Login.vue

@@ -0,0 +1,88 @@
+<template>
+  <div>
+    <template v-if="signUp">
+      <h3>Inscription</h3>
+      <styled-input label="Nom d'utilisateur" v-model="login" />
+      <styled-input label="Adresse de messagerie" v-model="email" />
+      <styled-input label="Mot de passe" v-model="password" type="password" />
+      <styled-input
+        label="Confirmer le mot de passe"
+        v-model="passwordCheck"
+        type="password"
+        :validateInput="checkPassword"
+      />
+      <div style="text-align: center">
+        <button class="btn primary" @click="fetchRegister">S'inscrire</button>&nbsp;
+        <button class="btn secondary" @click="signUp = false">Se connecter</button>
+      </div>
+    </template>
+    <form v-else action="/login.html" method="POST">
+      <h3>Connexion</h3>
+      <styled-input label="Nom d'utilisateur" name="username" v-model="login" />
+      <styled-input
+        label="Mot de passe"
+        name="password"
+        v-model="password"
+        type="password"
+        :helpText="helpText"
+        :validateInput="passwordIncorrect"
+      />
+      <div style="text-align: center">
+        <input class="btn primary" name="submit" type="submit" value="Se connecter" />&nbsp;
+        <button class="btn secondary" @click="signUp = true">S'inscrire</button>
+      </div>
+    </form>
+  </div>
+</template>
+
+<script lang="ts">
+import "@/assets/css/main.css";
+import "@/assets/css/button.css";
+import Toast from "@/utils/Toast";
+import { defineComponent } from "vue";
+import styledInput from "../components/input.vue";
+
+export default defineComponent({
+  data() {
+    return {
+      signUp: false,
+      login: "",
+      password: "",
+      email: "",
+      passwordCheck: "",
+      error: false,
+    };
+  },
+  components: { styledInput },
+  computed: {
+    helpText(): string {
+      return this.error ? "Le couple nom d'utilisateur / mot de passe est incorrect." : "";
+    },
+    passwordIncorrect(): () => number {
+      return this.error ? () => -1 : () => 0;
+    },
+  },
+  methods: {
+    fetchRegister() {
+      if (this.password != this.passwordCheck) {
+        Toast({ html: "Les mot de passe ne correspondent pas.", classes: "error" });
+      }
+      // todo send a request to the server and not answer it
+    },
+    checkPassword(str: string) {
+      return this.password == str;
+    },
+    validePassword(str: string) {
+      return str.length > 8;
+    },
+  },
+  mounted() {
+    const param = new URLSearchParams(window.location.search);
+
+    const nextPath = param.get("next") ?? "/";
+    this.error = param.get("error") == "true" ?? false;
+  },
+});
+</script>
+
+<style scoped></style>

+ 13 - 3
vue.config.js

@@ -1,8 +1,18 @@
 /* eslint-disable @typescript-eslint/no-var-requires */
 /*const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
-
+ */
 module.exports = {
+  pages: {
+    index: { entry: "./src/main.ts", title: "BDLG planner", filename: "planner/index.html" },
+    login: {
+      entry: "./src/login.ts",
+      title: "BDLG planner Connexion",
+      filename: "login.html",
+    },
+  },
   configureWebpack: {
-    plugins: [new BundleAnalyzerPlugin()],
+    plugins: [
+      /*new BundleAnalyzerPlugin()*/
+    ],
   },
-};*/
+};