tripeur 4 년 전
부모
커밋
e5e935f0d2

+ 20 - 15
package-lock.json

@@ -3315,9 +3315,9 @@
       }
     },
     "core-js": {
-      "version": "3.11.2",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.11.2.tgz",
-      "integrity": "sha512-3tfrrO1JpJSYGKnd9LKTBPqgUES/UYiCzMKeqwR1+jF16q4kD1BY2NvqkfuzXwQ6+CIWm55V9cjD7PQd+hijdw=="
+      "version": "3.12.1",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.12.1.tgz",
+      "integrity": "sha512-Ne9DKPHTObRuB09Dru5AjwKjY4cJHVGu+y5f7coGn1E9Grkc3p2iBwE9AI/nJzsE29mQF7oq+mhYYRqOMFN1Bw=="
     },
     "core-util-is": {
       "version": "1.0.2",
@@ -5542,6 +5542,11 @@
       "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
       "dev": true
     },
+    "fuse.js": {
+      "version": "6.4.6",
+      "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.4.6.tgz",
+      "integrity": "sha512-/gYxR/0VpXmWSfZOIPS3rWwU8SHgsRTwWuXhyb2O6s7aRuVtHtxCkR33bNYu3wyLyNx/Wpv0vU7FZy8Vj53VNw=="
+    },
     "gauge": {
       "version": "2.7.4",
       "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
@@ -6904,11 +6909,12 @@
       "dev": true
     },
     "jc-timeline": {
-      "version": "git+http://gitlab.jaquin.fr/clovis/jc-timeline.git#888f6c4bd4c9d96c893e1d244514f1a63ff6f3c6",
+      "version": "git+http://gitlab.jaquin.fr/clovis/jc-timeline.git#e24f7f59c7bfaebfa3a1eb10b78052d9eab118aa",
       "from": "git+http://gitlab.jaquin.fr/clovis/jc-timeline.git",
       "requires": {
         "dayjs": "^1.10.4",
         "lit-element": "^2.4.0",
+        "prettier": "^2.2.1",
         "simplebar": "^5.3.0"
       }
     },
@@ -7110,17 +7116,17 @@
       "dev": true
     },
     "lit-element": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.5.0.tgz",
-      "integrity": "sha512-SS6Bmm7FYw/RVeD6YD3gAjrT0ss6rOQHaacUnDCyVE3sDuUpEmr+Gjl0QUHnD8+0mM5apBbnA60NkFJ2kqcOMA==",
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.5.1.tgz",
+      "integrity": "sha512-ogu7PiJTA33bEK0xGu1dmaX5vhcRjBXCFexPja0e7P7jqLhTpNKYRPmE+GmiCaRVAbiQKGkUgkh/i6+bh++dPQ==",
       "requires": {
         "lit-html": "^1.1.1"
       }
     },
     "lit-html": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.0.tgz",
-      "integrity": "sha512-cgaqPSgqHRaTH/P1DnWD/dQxudtrHqD0xo1AoyOGJZir2rXgsvTg77z6Pitwk9B+kL23EakD62HV3x8sT01aWQ=="
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz",
+      "integrity": "sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA=="
     },
     "load-json-file": {
       "version": "1.1.0",
@@ -10202,8 +10208,7 @@
     "prettier": {
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
-      "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==",
-      "dev": true
+      "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q=="
     },
     "prettier-linter-helpers": {
       "version": "1.0.0",
@@ -11284,9 +11289,9 @@
       }
     },
     "simplebar": {
-      "version": "5.3.2",
-      "resolved": "https://registry.npmjs.org/simplebar/-/simplebar-5.3.2.tgz",
-      "integrity": "sha512-z43AVhLPoERlilEi+4o4ED4yHAckhEw0uCeR9SUS0fh07PjUfPtfMcGed8P8zb0YO3t+w2tUe1/RJnV4y0wjeA==",
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/simplebar/-/simplebar-5.3.3.tgz",
+      "integrity": "sha512-OfuSX47Axq9aR6rp9WK3YefAg+1Qw3UKKxS46PdElPpd+FWXMj17/nispYxsHtU3F7mv+ilmqELWmRt7KUgHgg==",
       "requires": {
         "can-use-dom": "^0.1.0",
         "core-js": "^3.0.1",

+ 1 - 0
package.json

@@ -9,6 +9,7 @@
   },
   "dependencies": {
     "dayjs": "^1.10.4",
+    "fuse.js": "^6.4.6",
     "jc-timeline": "git+http://gitlab.jaquin.fr/clovis/jc-timeline.git",
     "postcss": "^8.2.13",
     "uuid": "^8.3.2",

+ 106 - 8
src/App.vue

@@ -3,28 +3,126 @@
     <div class="logo"></div>
     <h1 class="appname">BDLG planner</h1>
     <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="/competence"
-        >Gestion des compétences</router-link
-      >
-      <router-link class="tab" active-class="selected" to="/benevoles"
-        >Gestion des bénévoles</router-link
-      >
-      <router-link class="tab" active-class="selected" to="/">Planning Individuel</router-link>
+      <router-link class="tab" active-class="selected" to="/competences">
+        Gestion des compétences
+      </router-link>
+      <router-link class="tab" active-class="selected" to="/benevoles">
+        Gestion des bénévoles
+      </router-link>
+      <router-link class="tab" active-class="selected" to="/planningIndividuel">
+        Planning Individuel
+      </router-link>
     </nav>
   </div>
   <div class="container">
-    <router-view />
+    <router-view
+      @export="exportStateToJson"
+      @import="(e) => importJsonState(e, false)"
+      @localSave="localSave"
+    />
   </div>
 </template>
 <script lang="ts">
 import { defineComponent } from "vue";
 import toast from "./utils/Toast";
 import "@/assets/css/tabs.css";
+import Evenement from "./models/Evenement";
+import Benevole from "./models/Benevole";
+import Creneau from "./models/Creneau";
+import Competence from "./models/Competence";
+import Ressource, { IRessource, RessourceJSON } from "jc-timeline/lib/Ressource";
+import { MutationTypes } from "./store/Mutations";
+import dayjs from "dayjs";
+import { StateJSON } from "./store/State";
+
+const keyofEvent: Array<keyof Evenement> = ["name", "uuid", "start", "end"];
 export default defineComponent({
   mounted() {
     const a = () => toast({ html: "test", inDuration: 500, outDuration: 500, displayLength: 2000 });
     a();
+    const previousState = window.localStorage.getItem("activeState");
+    if (previousState) {
+      this.importJsonState(JSON.parse(previousState), false);
+    }
+    window.onbeforeunload = () => {
+      this.localSave();
+    };
+  },
+  methods: {
+    localSave() {
+      window.localStorage.setItem("activeState", JSON.stringify(this.$store.getters.getJSONState));
+    },
+    exportStateToJson(): void {
+      const obj: StateJSON = this.$store.getters.getJSONState;
+      const mimeType = "data:text/json;charset=utf-8";
+      const dummy = document.createElement("a");
+      dummy.href = mimeType + ", " + encodeURI(JSON.stringify(obj));
+      dummy.download =
+        "Planning-" + this.$store.state.evenement.name + dayjs().format("-YYYY-MM-DD[.json]");
+      document.body.appendChild(dummy);
+      dummy.click();
+      setTimeout(() => document.body.removeChild(dummy), 10000);
+    },
+    importJsonState(obj: StateJSON, preserve = false) {
+      console.log(obj);
+      // Remove previous content and load the main event title
+      if (preserve == false) {
+        this.$store.commit(MutationTypes.resetState, undefined);
+        const e = Evenement.fromJSON(obj.evenement);
+        for (const k of keyofEvent) {
+          this.$store.commit(MutationTypes.editEvenement, { field: k, value: e[k] });
+        }
+      }
+      // Import constraint
+      obj.competences.forEach((c) => {
+        this.$store.commit(MutationTypes.addConstraint, Competence.fromJSON(c));
+      });
+      // Import Benevoles
+      obj.benevoles.forEach((b) => {
+        this.$store.commit(MutationTypes.addBenevole, Benevole.fromJSON(b));
+      });
+      // Import creneau group
+      const dict: { [k: string]: RessourceJSON } = {};
+      obj.creneauGroups.forEach((element) => {
+        dict[element.id] = element;
+      });
+      // map parent to children
+      const creneauGroups = obj.creneauGroups.map((ressource) => {
+        const iRessource: IRessource = { ...ressource };
+        if (iRessource.parentId) {
+          if (iRessource.parentId in dict) {
+            iRessource.parent = dict[iRessource.parentId];
+          } else {
+            throw new Error("Missing parent of creneau group : " + ressource.id);
+          }
+        }
+        return iRessource;
+      });
+      // Push the items to this application
+      creneauGroups.forEach((r) => {
+        this.$store.commit(MutationTypes.addCreneauGroup, new Ressource(r));
+      });
+      // Import Creneau
+      for (let c of obj.creneaux) {
+        const creneau = Creneau.fromJSON(c);
+        this.$store.commit(MutationTypes.addCreneau, creneau);
+        // add the creneau to the corresponding benevole
+        for (const id of creneau.benevoleIdList) {
+          const b = this.$store.getters.getBenevoleById(id);
+          if (b) {
+            this.$store.commit(MutationTypes.editBenevole, {
+              id: id,
+              field: "creneauIdList",
+              value: Array.from(new Set([...b.creneauIdList, creneau.id])),
+            });
+          } else {
+            throw new Error(`The benevole ${id} was not find `);
+          }
+        }
+      }
+    },
   },
 });
 </script>

+ 1 - 3
src/assets/css/button.css

@@ -59,8 +59,6 @@
   min-width: 2rem;
   line-height: 1.5rem;
 }
-.btn.icon{
-}
 .btn.icon.small{
   padding: calc(0.25rem + 1px) ;
 }
@@ -143,7 +141,7 @@
   background-color: transparent;
 }
 .btn.secondary:hover {
-  background-color: #f0f4f6;
+  background-color: var(--color-neutral-800);
 }
 .btn.secondary:active {
   background-color: #e2e9ed;

+ 145 - 0
src/assets/css/chip.css

@@ -0,0 +1,145 @@
+.chip {
+    all: initial;
+    color: var(--color-neutral-100);
+    border: none;
+    cursor: default;
+    height: 1.5rem;
+    display: -webkit-inline-box;
+    display: inline-flex;
+    outline: 0;
+    padding: 0;
+    font-size: 0.875rem;
+    box-sizing: border-box;
+    -webkit-box-align: center;
+            align-items: center;
+    font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
+    font-weight: 500;
+    white-space: nowrap;
+    border-radius: 0.75rem;
+    vertical-align: middle;
+    -webkit-box-pack: center;
+            justify-content: center;
+    background-color: var(--color-accent-800);
+  }
+  .chip +.chip {
+    margin-left:4px
+  }
+  .chip-label {
+    overflow: hidden;
+    line-height: 1.5rem;
+    white-space: nowrap;
+    padding-left: 0.75rem;
+    padding-right: 0.75rem;
+    text-overflow: ellipsis;
+  }
+  .chip-remove-btn {
+    width: 18px;
+    height: 18px;
+    border: 1px solid transparent;
+    cursor: pointer;
+    margin: 0 0.25rem 0 -0.25rem;
+    display: -webkit-inline-box;
+    display: inline-flex;
+    outline: 0;
+    position: relative;
+    box-sizing: border-box;
+    text-align: center;
+    -webkit-box-align: center;
+            align-items: center;
+    font-family: inherit;
+    -webkit-user-select: none;
+       -moz-user-select: none;
+        -ms-user-select: none;
+            user-select: none;
+    white-space: nowrap;
+    border-radius: 50%;
+    vertical-align: middle;
+    -moz-appearance: none;
+    -webkit-box-pack: center;
+            justify-content: center;
+    text-decoration: none;
+    background-color: var(--color-accent-600);      
+    color:var(--color-accent-200);
+    -webkit-appearance: none;
+    -webkit-tap-highlight-color: transparent;
+
+  }
+  .chip-remove-btn:before{
+    font-family: 'Material Icons';
+    font-weight: normal;
+    font-style: normal;
+    font-size: 15px;
+    content:'clear';
+  }
+  .chip-remove-btn::-moz-focus-inner {
+    border-style: none;
+  }
+  .chip-remove-btn:focus {
+    outline: none;
+  }
+  .chip-remove-btn:focus:after {
+    top: -5px;
+    left: -5px;
+    right: -5px;
+    border: 2px solid var(--color-accent-400);
+    bottom: -5px;
+    content: "";
+    position: absolute;
+    border-radius: 50%;
+  }
+  .chip-remove-btn:hover {
+    background-color: var(--color-accent-500);
+  }
+  .chip-remove-btn:active {
+    background-color: var(--color-accent-600);
+  }
+  .chip-icon.material-icons {
+    width: 1rem;
+    height: 1rem;
+    margin: 0 -0.25rem 0 0.25rem;
+    padding: 1px 0.25rem;
+    font-size: 0.875rem;
+  }
+  .chip-icon svg {
+    font-size: 0.875rem;
+  }
+  .chip--clickable {
+    color: var(--color-accent-200);
+    border: 2px solid transparent;
+    cursor: pointer;
+    -webkit-user-select: none;
+       -moz-user-select: none;
+        -ms-user-select: none;
+            user-select: none;
+    -webkit-tap-highlight-color: transparent;
+  }
+  .chip--clickable::-moz-focus-inner {
+    border-style: none;
+  }
+  .chip--clickable:focus {
+    border: 2px solid var(--color-accent-400); 
+    outline: none;
+  }
+  .chip--clickable:hover {
+    background-color: var(--color-accent-600);   
+  }
+  .chip--clickable:active {
+    color: #ffffff;
+    background-color: var(--color-accent-500); 
+  }
+  .chip--info {
+    color: #282e3a;
+    background-color: #a3d4f0;
+  }
+  .chip--success {
+    color: #282e3a;
+    background-color: #a5f2d8;
+  }
+  .chip--warning {
+    color: #282e3a;
+    background-color: #ffe3cc;
+  }
+  .chip--error {
+    color: #282e3a;
+    background-color: #f9ccd4;
+  }

+ 40 - 0
src/assets/css/editor-panel.css

@@ -0,0 +1,40 @@
+.editor-panel {
+    margin-top: 8px;
+    padding: 12px;
+    max-width: 400px;
+    width: 400px;
+    box-shadow: 0 0 2px 2px var(--color-neutral-600);
+    height: 100%;
+  }
+.editor-panel > .actions {
+    display: flex;
+    justify-content: center;
+    margin-bottom:12px;
+  }
+  .editor-panel > .actions > * {
+    margin: 4px 8px;
+  }
+  .editor-panel .s3 {
+    max-width: calc(25% - 8px);
+  }
+  .editor-panel .s6 {
+    max-width: calc(50% - 8px);
+  }
+  .editor-panel .s9 {
+    max-width: calc(75% - 8px);
+  }
+  .editor-panel > form > * {
+    width: 100%;
+  }
+  .editor-panel > form {
+    display: flex;
+    justify-content: space-between;
+    flex-wrap: wrap;
+  }
+  .editor-panel > h3 {
+    margin-top: 0px;
+    margin-bottom: 8px;
+  }
+  .editor-panel .empty{
+    text-align: center;
+  }

+ 9 - 5
src/assets/css/main.css

@@ -8,15 +8,18 @@
     --color-primary-100:hsl(10, 59%, 25%);
     --color-primary-200:hsl(10, 59%, 45%);
     --color-primary-400:hsl(37, 100%, 45%);
-    --color-primary-600:hsl(37, 100%, 65%);
+    --color-primary-600:hsl(37, 100%, 65%);    
+    --color-primary-800:hsl(37, 100%, 85%);
 
     --color-accent-100:hsl(179, 52%, 15%);
     --color-accent-200:hsl(179, 52%, 25%);
-    --color-accent-400:hsl(172, 58%, 40%);
+    --color-accent-400:hsl(172, 58%, 40%);    
+    --color-accent-500:hsl(172, 58%, 53%);
     --color-accent-600:hsl(172, 58%, 66%);
-    --color-accent-800:hsl(172, 60%, 70%);
-    --color-accent-850:hsl(172, 85%, 90%);
-    --color-accent-900:hsl(172, 95%, 95%);
+    --color-accent-700:hsl(172, 60%, 70%);
+    --color-accent-800:hsl(172, 60%, 80%);
+    --color-accent-850:hsl(172, 70%, 90%);
+    --color-accent-900:hsl(172, 85%, 95%);
     --color-accent-950:hsl(172, 96%, 98%);
 
     --color-neutral-100:hsl(37, 5%, 15%);
@@ -24,6 +27,7 @@
     --color-neutral-300:hsl(37, 5%, 35%);
     --color-neutral-400:hsl(37, 5%, 45%);
     --color-neutral-600:hsl(37, 5%, 60%);
+    --color-neutral-800:hsl(37, 5%, 80%);
 }
 
 body{

+ 22 - 9
src/assets/css/multiple-select.css

@@ -6,14 +6,14 @@ position: relative;
 display: block;
 width: 100%;
 position: relative;
-height: 2rem;
 }
 .select-multiple > .dropdown-options {
   display: none;
   box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.14);
   border-top: solid 2px var(--color-accent-600);
   z-index: 100;
-  position: relative;
+  position: absolute;
+  width: 100%;
   background-color: white;
   }
 .select-multiple.select-multiple--expanded > .dropdown-options {
@@ -30,6 +30,7 @@ height: 2rem;
 }
 
 .dropdown-options > div.select--checked {
+  position:relative;
   font-weight: bold;
   color: var(--color-neutral-100);
   background-color: #f8f9fb;
@@ -56,8 +57,19 @@ border-radius: 4px;
 border: solid 1px var(--color-accent-600);
 }
 .dropdown-options.pickable > div.select--checked:before {
-background-color: var(--color-accent-600);
-background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjRweCIgaGVpZ2h0PSIyNHB4IiB2aWV3Qm94PSIwIDAgMjQgMjQiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CjxnIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgPGc+CiAgICAgICAgPHBvbHlnb24gcG9pbnRzPSIwIDAgMjQgMCAyNCAyNCAwIDI0Ij48L3BvbHlnb24+CiAgICAgICAgPHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iTTUuMTA0NjQzMDYsMTIuNTk5NTc0MSBDNC42MjAzNTEyLDEyLjE0Nzk3MiAzLjg1MzM2NDg0LDEyLjE0Nzk3MiAzLjM2OTA3Mjk5LDEyLjU5OTU3NDEgQzIuODc2OTc1NjcsMTMuMDU4NDU0OSAyLjg3Njk3NTY3LDEzLjc5NjQ0MzQgMy4zNjkwNzI5OSwxNC4yNTUzMjQxIEw3Ljc2NDY5MjI1LDE4LjM1NDIzOTEgQzguMjQ4OTg0MSwxOC44MDU4NDEzIDkuMDI2NDYxMiwxOC44MDU4NDEzIDkuNTEwNzUzMDYsMTguMzU0MjM5MSBMMjAuNjMwODY4MSw3Ljk5NDUxNDQ1IEMyMS4xMjMwMzI3LDcuNTM1NTcwOSAyMS4xMjMwMzI3LDYuNzk3NTgyMzYgMjAuNjMwOTM1NCw2LjMzODcwMTYyIEMyMC4xNDY2NDM2LDUuODg3MDk5NDYgMTkuMzc5NjU3Miw1Ljg4NzA5OTQ2IDE4Ljg5NTM2NTQsNi4zMzg3MDE2MiBMOC42NDI5NjgwMiwxNS44OTkwNjIxIEw1LjEwNDY0MzA2LDEyLjU5OTU3NDEgWiI+PC9wYXRoPgogICAgPC9nPgo8L2c+Cjwvc3ZnPg==");
+  background-color: var(--color-accent-600);
+}
+.dropdown-options.pickable > div.select--checked:after{
+  position: absolute;
+  content: "";
+  border: 2px solid transparent;
+  border-right-color: white;
+  border-bottom-color: white;
+  width:8px;
+  height: 14px;
+  left:13px;
+  top:8px;  
+  transform: rotate(37deg);
 }
 .select-multiple > .select-multiple-value {
 all: initial;
@@ -67,20 +79,19 @@ color: var(--color-neutral-100);
 cursor: pointer;
 width: 100%;
 border: none;
-height: 2rem;
 margin: 0;
 padding: 0.2rem 0.5rem;
 font-size: 0.875rem;
 -webkit-appearance: none;
 -moz-appearance: none;
 appearance: none;
-box-shadow: 0 -1px 0 0 var(--color-accent-800) inset;
+box-shadow: 0 -1px 0 0 var(--color-accent-700) inset;
 background-color: var(--color-accent-950);
 box-sizing: border-box;
 -webkit-transition: 0.1s ease-in-out;
 transition: 0.1s ease-in-out;
 font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
-line-height: 1.25rem;
+line-height: 1.3rem;
 padding-right: 2rem;
 -webkit-transition-property: box-shadow, border;
 transition-property: box-shadow, border;
@@ -108,7 +119,9 @@ content: "expand_more";
 cursor: pointer;
 background-color: var(--color-accent-850);
 }
-
+.select-multiple-value > .chip + .chip{
+  margin-left:4px;
+}
 .select-multiple-input {
 border: 0;
 background: transparent;
@@ -116,7 +129,7 @@ font-size: 0.875rem;
 line-height: 1.4rem;
 }
 .select-multiple >.select-multiple-value:focus-within {
-box-shadow: 0 0 0 2px var(--color-accent-800);
+box-shadow: 0 0 0 2px var(--color-accent-700);
 border-color: transparent;
 background-color: var(--color-accent-950);
 }

+ 8 - 6
src/components/AutoCompleteInput.vue

@@ -15,7 +15,7 @@
           ref="input"
           class="select-multiple-input"
           :placeholder="placeholder"
-          :size="Math.max(value.length, placeholder.length)"
+          :size="Math.max(value.length, placeholder.length, 10)"
           v-model="value"
           @keyup="onKeyup"
         />
@@ -99,11 +99,13 @@ export default defineComponent({
       e.stopPropagation();
     },
     closeDropDown: function (e: MouseEvent) {
-      if (!(this.$refs["container"] as HTMLElement).contains(e.target as Node)) {
-        this.expanded = false;
-        window.removeEventListener("click", this.closeDropDown);
-        e.stopPropagation();
-        if (this.value == "") this.$emit("update:modelValue", this.value);
+      if (this.$refs["container"]) {
+        if (!(this.$refs["container"] as HTMLElement).contains(e.target as Node)) {
+          this.expanded = false;
+          window.removeEventListener("click", this.closeDropDown);
+          e.stopPropagation();
+          if (this.value == "") this.$emit("update:modelValue", this.value);
+        }
       }
     },
     toggle(v: AutocompleteValues, e: MouseEvent) {

+ 226 - 0
src/components/CreneauViewer.vue

@@ -0,0 +1,226 @@
+<template>
+  <div>
+    <div class="agenda-creneau-header" @click="onClick">
+      <span class="agenda-creneau-time">{{ creneau.horaire }} </span>
+      <span class="agenda-creneau-title">{{ creneau.title }}</span>
+      <button class="agenda-creneau-action">
+        <i class="material-icons">{{ icon }}</i>
+      </button>
+    </div>
+    <transition
+      name="calendar-creneau-transition"
+      @before-enter="collapse"
+      @enter="expand"
+      @before-leave="expand"
+      @leave="collapse"
+    >
+      <div v-if="showDescription" class="agenda-creneau-details">
+        <div class="agenda-creneau-details--title">Description</div>
+        <div class="agenda-creneau-details--content">{{ parseDescription }}</div>
+        <div class="agenda-creneau-details--title">Liste des bénévoles</div>
+        <div class="agenda-creneau-details--content">
+          <div
+            class="chip chip--clickable"
+            v-for="benevole in creneauBenevoleList"
+            @click="onClickContact(benevole)"
+            :key="benevole.id"
+          >
+            <i class="material-icons chip-icon">person</i>
+            <span class="chip-label">{{ benevole.shortame }}</span>
+          </div>
+        </div>
+        <template v-if="prevCreneau">
+          <div class="agenda-creneau-details--title">Ceux que tu remplace</div>
+          <div class="agenda-creneau-details--content">
+            <div
+              class="chip chip--clickable"
+              v-for="benevole in prevCreneauBenevoleList"
+              @click="onClickContact(benevole)"
+              :key="benevole.id"
+            >
+              <i class="material-icons chip-icon">person</i>
+              <span class="chip-label">{{ benevole.shortame }}</span>
+            </div>
+          </div>
+        </template>
+        <template v-if="nextCreneau">
+          <div class="agenda-creneau-details--title">Ceux qui te remplacerons</div>
+          <div class="agenda-creneau-details--content">
+            <div
+              class="chip chip--clickable"
+              v-for="benevole in nextCreneauBenevoleList"
+              @click="onClickContact(benevole)"
+              :key="benevole.id"
+            >
+              <i class="material-icons chip-icon">person</i>
+              <span class="chip-label">{{ benevole.shortame }}</span>
+            </div>
+          </div>
+        </template>
+      </div>
+    </transition>
+  </div>
+</template>
+
+<script lang="ts">
+import Benevole from "@/models/Benevole";
+import Creneau from "@/models/Creneau";
+import { defineComponent, PropType } from "vue";
+
+export default defineComponent({
+  props: {
+    creneau: { type: Object as PropType<Creneau> },
+    currentBenevole: { type: Object as PropType<Benevole> },
+  },
+  data() {
+    return {
+      showDescription: false,
+    };
+  },
+  watch: {},
+  computed: {
+    icon(): string {
+      return this.showDescription ? "remove_circle" : "add_circle";
+    },
+    parseDescription(): string {
+      if (this.creneau?.description) {
+        return this.creneau?.description;
+      }
+      return "Aucune description pour ce créneau";
+    },
+    creneauBenevoleList(): Array<Benevole> {
+      if (this.creneau) {
+        return this.getBenevoleforCreneau(this.creneau);
+      }
+      return [];
+    },
+    sameParentCreneau(): Array<Creneau> {
+      if (this.creneau) {
+        const c = this.creneau;
+        const filter = (creneau: Creneau) =>
+          creneau.id !== c.id && creneau.ressourceId === c.ressourceId;
+        return this.$store.state.creneauList.filter(filter);
+      }
+      return [];
+    },
+    prevCreneau(): Creneau | undefined {
+      if (this.creneau) {
+        const creneau = this.creneau;
+        return this.sameParentCreneau.reduce((acc: Creneau | undefined, c: Creneau) => {
+          const timeDiff = creneau.start.getTime() - c.end.getTime();
+          if (0 <= timeDiff && timeDiff < 60 * 30 * 1000) {
+            return c;
+          }
+          return acc;
+        }, undefined);
+      }
+      return undefined;
+    },
+    nextCreneau(): Creneau | undefined {
+      if (this.creneau) {
+        const creneau = this.creneau;
+        return this.sameParentCreneau.reduce((acc: Creneau | undefined, c: Creneau) => {
+          const timeDiff = c.start.getTime() - creneau.end.getTime();
+          if (0 <= timeDiff && timeDiff < 60 * 30 * 1000) {
+            return c;
+          }
+          return acc;
+        }, undefined);
+      }
+      return undefined;
+    },
+    prevCreneauBenevoleList(): Array<Benevole> {
+      return this.prevCreneau ? this.getBenevoleforCreneau(this.prevCreneau) : [];
+    },
+    nextCreneauBenevoleList(): Array<Benevole> {
+      return this.nextCreneau ? this.getBenevoleforCreneau(this.nextCreneau) : [];
+    },
+  },
+  methods: {
+    onClick: function () {
+      this.showDescription = !this.showDescription;
+    },
+    onClickContact: function (benevole: Benevole) {
+      this.$emit("contact", benevole);
+    },
+    getBenevoleforCreneau(creneau: Creneau): Array<Benevole> {
+      return creneau.benevoleIdList
+        .map((id) => this.$store.getters.getBenevoleById(id))
+        .filter((o) => o !== undefined) as Array<Benevole>;
+    },
+    collapse(element: HTMLElement): void {
+      element.style.height = "0";
+    },
+    expand(element: HTMLElement): void {
+      element.style.height = `${element.scrollHeight}px`;
+    },
+  },
+});
+</script>
+
+<style scoped>
+.agenda-creneau-header {
+  padding: 12px;
+  background: var(--color-accent-850);
+  position: relative;
+  border-bottom: 1px solid var(--color-accent-600);
+}
+.agenda-creneau-time {
+  padding-right: 2rem;
+}
+
+.agenda-creneau-title {
+  font-weight: bold;
+  text-transform: capitalize;
+}
+.agenda-creneau-action {
+  position: absolute;
+  top: 8px;
+  right: 12px;
+  background: transparent;
+  outline: 0;
+  border: 0;
+  cursor: pointer;
+}
+.agenda-creneau-action:hover {
+  color: var(--color-neutral-400);
+}
+
+.transformation-container {
+  height: auto;
+  overflow: hidden;
+}
+.agenda-creneau-details {
+  padding: 4px 12px;
+  display: flex;
+  flex-wrap: wrap;
+  overflow: hidden;
+  opacity: 1;
+  transform-origin: 0px 0px;
+  transition: height 0.3s ease-in-out;
+}
+.agenda-creneau-details--title {
+  width: 100%;
+  font-weight: 600;
+  margin: 8px 0px;
+}
+.agenda-creneau-details--content {
+  width: 100%;
+  margin: 0px 0px 8px;
+}
+@media (min-width: 600px) {
+  .agenda-creneau-details--title {
+    width: 16.6%;
+    margin-bottom: 12px;
+  }
+  .agenda-creneau-details--content {
+    width: 33.3%;
+    padding-right: 8px;
+    margin: 8px 0px 16px;
+  }
+}
+
+.agenda-creneau-details--content .chip {
+  margin: 2px;
+}
+</style>

+ 776 - 0
src/components/DataTable.vue

@@ -0,0 +1,776 @@
+<template>
+  <div class="material-table" ref="table">
+    <div class="table-header">
+      <span class="table-title">{{ title }}</span>
+      <div class="actions">
+        <button
+          v-for="(button, index) in shownCustomButtons"
+          :key="index"
+          href="javascript:undefined"
+          class="btn small secondary"
+          @click="button.onclick"
+        >
+          <i class="material-icons">{{ button.icon }}</i>
+        </button>
+        <button v-if="exportable" class="btn icon small secondary" @click="exportExcel">
+          <i class="material-icons">download</i>
+        </button>
+        <button v-if="searchable" class="btn icon small secondary" @click="search">
+          <i class="material-icons">search</i>
+        </button>
+      </div>
+    </div>
+    <div v-show="searching">
+      <div id="search-input-container" @click="focusSearchInput">
+        <input
+          ref="searchinput"
+          id="search-input"
+          type="search"
+          class="form-control"
+          :size="Math.max(searchInput.length, lang['search_data'].length)"
+          :placeholder="lang['search_data']"
+          v-model="searchInput"
+        />
+      </div>
+    </div>
+    <table class="datatable" ref="table">
+      <thead>
+        <tr>
+          <th
+            v-for="(column, index) in columns"
+            :key="index"
+            :class="columnClass(column, index)"
+            :style="{ width: column.width ? column.width : 'auto' }"
+            @click="sort(index)"
+          >
+            {{ column.label }}
+          </th>
+          <slot name="thead-tr" />
+        </tr>
+      </thead>
+
+      <tbody>
+        <tr
+          v-for="(row, index) in paginated"
+          :key="index"
+          :class="{ clickable: clickable }"
+          @click="click(row)"
+        >
+          <td
+            v-for="(column, columnIndex) in columns"
+            :key="columnIndex"
+            :class="[column.numeric ? 'numeric' : '', column.class]"
+          >
+            <div v-if="!column.html">
+              {{ row[column.field] }}
+            </div>
+            <div v-if="column.html" v-html="row[column.field]" />
+          </td>
+          <slot name="tbody-tr" :row="row" />
+        </tr>
+
+        <template v-if="rows.length === 0">
+          <template v-if="loadingAnimation === true">
+            <tr v-for="n in currentPerPage === -1 ? 10 : currentPerPage" :key="n">
+              <td :colspan="columns.length">
+                <div class="loading-skeleton"></div>
+              </td>
+            </tr>
+          </template>
+          <template v-else>
+            <tr>
+              <td :colspan="columns.length" style="text-align: center; font-size: 1.2rem">
+                {{ lang["empty"] }}
+              </td>
+            </tr>
+          </template>
+        </template>
+      </tbody>
+    </table>
+
+    <div v-if="paginate" class="table-footer">
+      <div :class="{ 'datatable-length': true, rtl: lang.__is_rtl }">
+        <label>
+          <span>{{ lang["rows_per_page"] }}:</span>
+          <select class="browser-default" @change="onTableLength">
+            <option
+              v-for="(option, index) in perPageOptions"
+              :key="index"
+              :value="option"
+              :selected="option == currentPerPage"
+            >
+              {{ option === -1 ? lang["all"] : option }}
+            </option>
+          </select>
+        </label>
+      </div>
+      <div :class="{ 'datatable-info': true, rtl: lang.__is_rtl }">
+        {{ (currentPage - 1) * currentPerPage ? (currentPage - 1) * currentPerPage : 1 }} -
+        {{ Math.min(searchedRows.length, currentPerPage * currentPage) }}
+        {{ lang["out_of_pages"] }}
+        {{ searchedRows.length }}
+      </div>
+      <div class="material-pagination">
+        <button class="btn icon small ghost" tabindex="0" @click.prevent="previousPage">
+          <i class="material-icons">chevron_left</i>
+        </button>
+        <button class="btn icon small ghost" tabindex="0" @click.prevent="nextPage">
+          <i class="material-icons">chevron_right</i>
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from "vue";
+import Fuse from "fuse.js";
+import { Dictionary } from "lodash";
+
+interface DataTableLocale {
+  rows_per_page: string;
+  out_of_pages: string;
+  all: string;
+  search_data: string;
+  empty: string;
+}
+var locales: Dictionary<DataTableLocale> = {
+  en: {
+    rows_per_page: "Rows per page",
+    out_of_pages: "of",
+    all: "All",
+    search_data: "Search data",
+    empty: "No data to be displayed",
+  },
+  fr: {
+    rows_per_page: "Lignes par page",
+    out_of_pages: "de",
+    all: "Tout",
+    search_data: "Rechercher des données",
+    empty: "Pas de donnée à afficher",
+  },
+};
+interface CustomButton {
+  icon: string;
+  hide: boolean;
+  onclick: (e: MouseEvent) => void;
+}
+interface DataTableColumn<T> {
+  label: string; // Column name
+  field: keyof T; // Field name from row
+  numeric: boolean; // Affects sorting
+  html: boolean;
+  export?: keyof T; // Exported field name from row
+  class?: string; // Exported field name from row
+  width?: number; // Fixed width of the column
+}
+export interface DataTableData<T> {
+  columns: Array<DataTableColumn<T>>;
+  items: Array<T>;
+}
+export interface DataTableObject {
+  [k: string]: string | number;
+}
+
+export default defineComponent({
+  name: "DataTable",
+  emits: ["row-click"],
+  props: {
+    title: {
+      type: String,
+      required: true,
+    },
+    data: {
+      type: Object as PropType<DataTableData<DataTableObject>>,
+      required: true,
+    },
+    clickable: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+    customButtons: {
+      type: Array as PropType<Array<CustomButton>>,
+      required: false,
+      default: () => [],
+    },
+    perPage: {
+      type: Array as PropType<Array<number>>,
+      required: false,
+      default: () => [10, 20, 30, 40, 50],
+    },
+    defaultPerPage: {
+      type: Number,
+      required: false,
+      default: null,
+    },
+    sortable: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+    searchable: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+    exactSearch: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    serverSearch: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    serverSearchFunc: {
+      type: Function,
+      required: false,
+    },
+    paginate: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+    exportable: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+    locale: {
+      type: String,
+      required: false,
+      default: "en",
+    },
+    initSortCol: {
+      type: Number,
+      required: false,
+      default: -1,
+    },
+    loadingAnimation: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+  },
+  data: () => ({
+    currentPage: 1,
+    currentPerPage: 10,
+    sortColumn: -1,
+    sortType: "asc",
+    searching: false,
+    searchInput: "",
+  }),
+  methods: {
+    nextPage() {
+      if (this.searchedRows.length > this.currentPerPage * this.currentPage) ++this.currentPage;
+    },
+    previousPage() {
+      if (this.currentPage > 1) --this.currentPage;
+    },
+    onTableLength(e: { target: { value: string } }) {
+      this.currentPerPage = parseInt(e.target.value);
+    },
+    sort(index: number) {
+      if (!this.sortable) return;
+      if (this.sortColumn === index) {
+        this.sortType = this.sortType === "asc" ? "desc" : "asc";
+      } else {
+        this.sortType = "asc";
+        this.sortColumn = index;
+      }
+    },
+    search() {
+      this.searching = !this.searching;
+      setTimeout(this.focusSearchInput, 100);
+    },
+    focusSearchInput() {
+      const searchbox = this.$refs["searchinput"] as HTMLElement;
+      if (searchbox) searchbox.focus();
+    },
+    columnClass(column: DataTableColumn<DataTableObject>, index: number) {
+      const output: Dictionary<boolean> = {
+        sorting: this.sortable,
+        "sorting-desc": this.sortColumn === index && this.sortType === "desc",
+        "sorting-asc": this.sortColumn === index && this.sortType !== "desc",
+        numeric: column.numeric,
+      };
+      if (column.class) {
+        output[column.class] = true;
+      }
+      return output;
+    },
+    click(row: DataTableObject): void {
+      if (!this.clickable) {
+        return;
+      }
+      if (getSelection()?.toString()) {
+        // Return if some text is selected instead of firing the row-click event.
+        return;
+      }
+      this.$emit("row-click", row);
+    },
+    exportExcel() {
+      const mimeType = "data:text/csv;charset=utf-8";
+      const strSpeparator = ",";
+      const sepReg = new RegExp("[" + strSpeparator + '\r\n"]');
+      let csvContent =
+        this.columns.map((o: DataTableColumn<DataTableObject>) => o.label).join(strSpeparator) +
+        "\r\n";
+      const escapeText = function (str: string) {
+        return sepReg.test(str)
+          ? '"' + str.replaceAll(/"/g, '""').replaceAll(/\r/g, "\\r").replaceAll(/\n/g, "\\n") + '"'
+          : str;
+      };
+      this.rows.forEach((r) => {
+        csvContent +=
+          this.columns
+            .map((c) =>
+              escapeText(c.export == undefined ? r[c.field].toString() : r[c.export].toString())
+            )
+            .join(strSpeparator) + "\r\n";
+      });
+      const documentPrefix = this.title ? this.title.replace(/ /g, "-") : "Sheet";
+      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";
+      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 += "</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>";
+      }
+      table += "</tbody></table>";
+      return table;
+    },
+  },
+  watch: {
+    perPageOptions(newOptions) {
+      // If defaultPerPage is provided and it's a valid option, set as current per page
+      if (newOptions.indexOf(this.defaultPerPage) > -1) {
+        this.currentPerPage = this.defaultPerPage;
+      } else {
+        // Set current page to first value
+        this.currentPerPage = newOptions[0];
+      }
+    },
+    searchInput(newSearchInput) {
+      if (this.searching && this.serverSearch && this.serverSearchFunc)
+        this.serverSearchFunc(newSearchInput);
+    },
+  },
+  computed: {
+    rows(): Array<DataTableObject> {
+      return this.data.items;
+    },
+    columns(): Array<DataTableColumn<DataTableObject>> {
+      return this.data.columns;
+    },
+    shownCustomButtons(): Array<CustomButton> {
+      return this.customButtons.filter((b) => !b.hide);
+    },
+    perPageOptions() {
+      let options: Array<number> = (Array.isArray(this.perPage) && this.perPage) || [
+        10,
+        20,
+        30,
+        40,
+        50,
+      ];
+      // Sort options
+      options.sort((a: number, b: number) => a - b);
+      // And add "All"
+      options.push(-1);
+      return options;
+    },
+    sortedRows(): Array<DataTableObject> {
+      let rows = this.rows;
+      if (this.sortable !== false)
+        if (this.sortColumn > -1) {
+          const col = this.columns[this.sortColumn];
+          let cook: (x: DataTableObject) => string | number;
+          if (col.numeric) {
+            cook = (x: DataTableObject) => x[col.field] as number;
+          } else {
+            cook = (x: DataTableObject) => (x[col.field] as string).toLowerCase();
+          }
+          return rows.sort((x, y) => {
+            const key_x = cook(x);
+            const key_y = cook(y);
+            return (
+              (key_x < key_y ? -1 : key_x > key_y ? 1 : 0) * (this.sortType === "desc" ? -1 : 1)
+            );
+          });
+        }
+      return rows;
+    },
+    searchedRows(): Array<DataTableObject> {
+      if (this.searching && !this.serverSearch && this.searchInput) {
+        const searchConfig: Fuse.IFuseOptions<DataTableObject> = {
+          keys: this.columns.map((c) => c.field.toString()),
+        };
+        if (this.exactSearch) {
+          //return only exact matches
+          (searchConfig.threshold = 0), (searchConfig.distance = 0);
+        }
+        return new Fuse(this.sortedRows, searchConfig)
+          .search(this.searchInput)
+          .map((o: { item: DataTableObject }) => o.item);
+      }
+      return this.sortedRows;
+    },
+    paginated(): Array<DataTableObject> {
+      let paginatedRows: DataTableObject[] = this.searchedRows;
+      if (this.paginate && this.currentPerPage !== -1)
+        paginatedRows = paginatedRows.slice(
+          (this.currentPage - 1) * this.currentPerPage,
+          this.currentPerPage === -1
+            ? paginatedRows.length + 1
+            : this.currentPage * this.currentPerPage
+        );
+      return paginatedRows;
+    },
+    lang(): DataTableLocale {
+      return this.locale in locales ? locales[this.locale] : locales["en"];
+    },
+  },
+  mounted() {
+    if (!(this.locale in locales))
+      console.error(`vue-materialize-datable: Invalid locale '${this.locale}'`);
+    this.sortColumn = this.initSortCol;
+  },
+});
+</script>
+
+<style>
+.material-table {
+  padding: 0;
+  box-shadow: 0 0 2px 2px var(--color-neutral-600);
+}
+
+tr.clickable {
+  cursor: pointer;
+}
+.actions > .btn:not(:last-child) {
+  margin-right: 2px;
+}
+
+#search-input-container {
+  position: relative;
+  padding: 0 14px 0 24px;
+  border-bottom: solid 1px var(--color-accent-800);
+  background: var(--color-accent-950);
+  cursor: text;
+}
+#search-input-container:focus-within {
+  box-shadow: 0 0 0 2px var(--color-accent-800);
+  border-color: transparent;
+  background-color: var(--color-accent-950);
+}
+
+#search-input {
+  margin: 0;
+  border: transparent 0 !important;
+  height: 30px;
+  color: rgba(0, 0, 0, 0.84);
+  outline: 0;
+  background: transparent;
+}
+search-input-container::after {
+  content: "search";
+  font-family: "Material Icons";
+  font-weight: normal;
+  font-style: normal;
+}
+
+.datatable {
+  table-layout: auto;
+  width: 100%;
+  border-collapse: collapse;
+}
+
+.table-header {
+  height: 64px;
+  padding-left: 24px;
+  padding-right: 14px;
+  -webkit-align-items: center;
+  -ms-flex-align: center;
+  align-items: center;
+  display: flex;
+  -webkit-display: flex;
+  border-bottom: solid 1px #dddddd;
+}
+
+.table-header .actions {
+  display: -webkit-flex;
+  margin-left: auto;
+}
+
+.table-footer {
+  height: 56px;
+  padding-left: 24px;
+  padding-right: 14px;
+  display: -webkit-flex;
+  display: flex;
+  -webkit-flex-direction: row;
+  flex-direction: row;
+  -webkit-justify-content: flex-end;
+  justify-content: flex-end;
+  -webkit-align-items: center;
+  align-items: center;
+  font-size: 12px !important;
+  color: rgba(0, 0, 0, 0.54);
+}
+
+.table-footer .datatable-length {
+  display: -webkit-flex;
+  display: flex;
+}
+
+.table-footer .datatable-length select {
+  outline: none;
+}
+
+.table-footer label {
+  font-size: 12px;
+  color: rgba(0, 0, 0, 0.54);
+  display: -webkit-flex;
+  display: flex;
+  -webkit-flex-direction: row;
+  /* works with row or column */
+  flex-direction: row;
+  -webkit-align-items: center;
+  align-items: center;
+  -webkit-justify-content: center;
+  justify-content: center;
+}
+
+.table-footer .select-wrapper {
+  display: -webkit-flex;
+  display: flex;
+  -webkit-flex-direction: row;
+  /* works with row or column */
+  flex-direction: row;
+  -webkit-align-items: center;
+  align-items: center;
+  -webkit-justify-content: center;
+  justify-content: center;
+}
+
+.table-footer .datatable-info,
+.table-footer .datatable-length {
+  margin-right: 32px;
+}
+
+.table-footer .material-pagination {
+  display: flex;
+  -webkit-display: flex;
+  margin: 0;
+}
+
+.table-footer .material-pagination li a {
+  color: rgba(0, 0, 0, 0.54);
+  padding: 0 8px;
+  font-size: 24px;
+}
+
+.table-footer .select-wrapper input.select-dropdown {
+  margin: 0;
+  border-bottom: none;
+  height: auto;
+  line-height: normal;
+  font-size: 12px;
+  width: 40px;
+  text-align: right;
+}
+
+.table-footer select {
+  background-color: transparent;
+  width: auto;
+  padding: 0;
+  border: 0;
+  border-radius: 0;
+  height: auto;
+  margin-left: 20px;
+}
+
+.table-title {
+  font-size: 20px;
+  color: #000;
+}
+
+.datatable tr td {
+  padding: 0 0 0 24px;
+  height: 48px;
+  font-size: 13px;
+  color: rgba(0, 0, 0, 0.87);
+  border-bottom: solid 1px #dddddd;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.datatable td,
+.datatable th {
+  border-radius: 0;
+}
+
+.datatable th {
+  font-size: 14px;
+  font-weight: 500;
+  color: #757575;
+  cursor: pointer;
+  white-space: nowrap;
+  padding: 0;
+  height: 56px;
+  padding-left: 24px;
+  vertical-align: middle;
+  outline: none !important;
+  overflow: hidden;
+  text-overflow: initial;
+  border-bottom: solid 1px #dddddd;
+}
+
+.datatable th:hover {
+  overflow: visible;
+  text-overflow: initial;
+}
+
+.datatable th.sorting-asc,
+.datatable th.sorting-desc {
+  color: rgba(0, 0, 0, 0.87);
+}
+
+.datatable th.sorting:after {
+  font-family: "Material Icons";
+  font-weight: normal;
+  font-style: normal;
+  font-size: 18px;
+  line-height: 1.2rem;
+  letter-spacing: normal;
+  text-transform: none;
+  display: inline-block;
+  word-wrap: normal;
+  -webkit-font-feature-settings: "liga";
+  font-feature-settings: "liga";
+  -webkit-font-smoothing: antialiased;
+  content: "arrow_back";
+  -webkit-transform: rotate(90deg);
+  transform: rotate(90deg);
+  display: inline-block;
+  vertical-align: middle;
+  opacity: 0;
+  margin-left: 2px;
+  margin-top: -2px;
+}
+
+.datatable th.sorting:hover:after,
+.datatable th.sorting-asc:after,
+.datatable th.sorting-desc:after {
+  opacity: 1;
+}
+
+.datatable th.sorting-desc:after {
+  content: "arrow_forward";
+}
+
+.datatable tbody tr:hover {
+  background-color: #eee;
+}
+
+.datatable th:last-child,
+.datatable td:last-child {
+  padding-right: 14px;
+}
+
+.datatable th:first-child,
+.datatable td:first-child {
+  padding-left: 24px;
+}
+
+.rtl {
+  direction: rtl;
+}
+.loading-skeleton {
+  background: var(--color-neutral-800);
+  height: 20px;
+  margin: 12px 16px;
+  border-radius: 4px;
+  position: relative;
+  overflow: hidden;
+}
+.loading-skeleton::before {
+  content: "";
+  display: block;
+  position: absolute;
+  left: -200px;
+  top: 0;
+  height: 100%;
+  width: 200px;
+  background: linear-gradient(
+    to right,
+    transparent 0%,
+    rgba(255, 255, 255, 0.4) 50%,
+    transparent 100%
+  );
+  animation: load 3s linear infinite;
+}
+@keyframes load {
+  0% {
+    left: -200px;
+  }
+  100% {
+    left: 100%;
+  }
+}
+td.fitwidth {
+  width: 1px;
+  white-space: nowrap;
+  text-align: center;
+}
+.overflow-cell {
+  max-width: 70px;
+}
+
+.overflow-cell div {
+  max-width: 100%;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+</style>

+ 141 - 0
src/components/EditeurBenevole.vue

@@ -0,0 +1,141 @@
+<template>
+  <div class="editor-panel">
+    <h3>Modifier les informations sur les bénévoles</h3>
+    <div class="actions">
+      <button class="btn small primary" v-on:click="emitCreationOrder">
+        <i class="material-icons">create</i>Nouveau
+      </button>
+      <button
+        class="btn small error"
+        :disabled="benevole === undefined"
+        v-on:click="emitDeleteOrder"
+      >
+        <i class="material-icons">delete_forever</i>Supprimer
+      </button>
+    </div>
+    <div class="empty" v-if="benevole === undefined">
+      Veuillez selectioner une ligne du tableau.
+    </div>
+    <form v-else>
+      <styled-input
+        class="s3"
+        label="Identifiant"
+        id="competence_id"
+        type="text"
+        :modelValue="benevole.id"
+        disabled
+      />
+      <styled-input
+        label="Telephone"
+        class="s9"
+        id="phone"
+        type="text"
+        :modelValue="benevole.phone"
+        @input="inputListener($event, 'phone')"
+      />
+      <styled-input
+        class="s6"
+        label="Prénom"
+        id="last_name"
+        type="text"
+        :modelValue="benevole.name"
+        @input="inputListener($event, 'name')"
+      />
+      <styled-input
+        class="s6"
+        label="Nom"
+        id="family_name"
+        type="text"
+        :modelValue="benevole.surname"
+        @input="inputListener($event, 'surname')"
+      />
+      <styled-input
+        label="Email"
+        id="email"
+        type="email"
+        :modelValue="benevole.email"
+        @input="inputListener($event, 'email')"
+      />
+      <h3 style="padding-bottom: 0.8rem">Propriétés</h3>
+      <chips-input
+        label="Préference & compétences"
+        id="preference_selection"
+        placeholder="Choisir un élément"
+        secondary-placeholder="+ élément"
+        :autocompleteList="autocompleteList"
+        :strict-autocomplete="true"
+        v-model="competenceIdList"
+      ></chips-input>
+    </form>
+  </div>
+</template>
+
+<script lang="ts">
+import Benevole from "@/models/Benevole";
+import Competence from "@/models/Competence";
+import styledInput from "./input.vue";
+import chipsInput from "./SelectChipInput.vue";
+import { defineComponent, PropType } from "vue";
+import { MutationTypes } from "@/store/Mutations";
+import AutocompleteValues from "@/models/AutocompleteOptions";
+
+export default defineComponent({
+  name: "EditeurBenevole",
+  components: { styledInput, chipsInput },
+  props: { benevole: { type: Object as PropType<Benevole> } },
+  data: function () {
+    return {
+      refreshFrom: null,
+      competenceIdList: [] as Array<string>,
+    };
+  },
+  watch: {
+    benevole: function (b: Benevole) {
+      this.competenceIdList = b.competenceIdList.map((s) => s.toString());
+    },
+    competenceIdList(val: Array<string>) {
+      const new_arr = val.map((s) => parseInt(s));
+      const old_arr = this.benevole?.competenceIdList;
+      if (
+        new_arr.length !== old_arr?.length ||
+        new_arr.reduce((acc: boolean, n: number) => acc && old_arr.includes(n), true)
+      ) {
+        this.updateBenevole("competenceIdList", new_arr);
+      }
+    },
+  },
+  computed: {
+    autocompleteList(): Array<AutocompleteValues> {
+      return this.competenceList.map((contraint) => {
+        return { id: contraint.id.toString(), name: contraint.fullname };
+      });
+    },
+    competenceList(): Array<Competence> {
+      return this.$store.state.competenceList;
+    },
+  },
+  methods: {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    inputListener(event: any, field: keyof Benevole) {
+      this.updateBenevole(field, event.target.value);
+    },
+    updateBenevole<K extends keyof Benevole>(k: K, value: Benevole[K]) {
+      if (this.benevole) {
+        this.$store.commit(MutationTypes.editBenevole, {
+          id: this.benevole.id,
+          field: k,
+          value: value,
+        });
+      }
+    },
+    emitCreationOrder: function () {
+      this.$emit("create");
+    },
+    emitDeleteOrder: function () {
+      this.$emit("delete", this.benevole);
+    },
+  },
+});
+</script>
+
+<style scoped></style>

+ 105 - 0
src/components/EditeurCompetence.vue

@@ -0,0 +1,105 @@
+<template>
+  <div class="editor-panel">
+    <h3>Modifier une compétence ou préférence</h3>
+    <div class="actions">
+      <button class="btn small primary" v-on:click="emitCreationOrder">
+        <i class="material-icons">create</i>Nouveau
+      </button>
+      <button
+        class="btn small error"
+        :disabled="competence === undefined"
+        v-on:click="emitDeleteOrder"
+      >
+        <i class="material-icons">delete_forever</i>Supprimer
+      </button>
+    </div>
+    <div class="empty" v-if="competence === undefined">
+      Veuillez selectioner une ligne du tableau.
+    </div>
+    <form v-else>
+      <styled-input
+        class="s3"
+        label="Identifiant"
+        id="competence_id"
+        type="text"
+        :modelValue="competence.id"
+        disabled
+      />
+      <styled-input
+        class="s9"
+        label="Titre"
+        id="name"
+        type="text"
+        :modelValue="competence.name"
+        @input="inputListener($event, 'name')"
+      />
+      <styled-input
+        label="Description"
+        id="competence_description"
+        type="textarea"
+        :modelValue="competence.description"
+        @input="inputListener($event, 'description')"
+      />
+      <h4 style="margin-top: 0.8rem">Propriétés</h4>
+      <checkbox
+        id="competence_isPreference"
+        label="Préference"
+        :modelValue="competence.isPreference"
+        @input="checkboxListener($event, 'isPreference')"
+      />
+      <checkbox
+        id="competence_isTeachable"
+        label="Apprenable"
+        :modelValue="competence.isTeachable"
+        @input="checkboxListener($event, 'isTeachable')"
+      />
+    </form>
+  </div>
+</template>
+
+<script lang="ts">
+import Competence from "@/models/Competence";
+import styledInput from "./input.vue";
+import checkbox from "./checkBox.vue";
+import { defineComponent, PropType } from "vue";
+import { MutationTypes } from "@/store/Mutations";
+import "@/assets/css/editor-panel.css";
+
+export default defineComponent({
+  name: "EditeurCompetence",
+  components: { styledInput, checkbox },
+  props: { competence: { type: Object as PropType<Competence> } },
+  data() {
+    return {};
+  },
+  watch: {},
+  computed: {},
+  methods: {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    inputListener(event: any, field: keyof Competence) {
+      this.updateCompetence(field, event.target.value);
+    },
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    checkboxListener(event: any, field: keyof Competence) {
+      this.updateCompetence(field, event.target.checked);
+    },
+    updateCompetence<K extends keyof Competence>(k: K, value: Competence[K]) {
+      if (this.competence) {
+        this.$store.commit(MutationTypes.editConstraint, {
+          id: this.competence.id,
+          field: k,
+          value: value,
+        });
+      }
+    },
+    emitCreationOrder: function () {
+      this.$emit("create");
+    },
+    emitDeleteOrder: function () {
+      this.$emit("delete", this.competence);
+    },
+  },
+});
+</script>
+
+<style scoped></style>

+ 71 - 49
src/components/EditeurCreneau.vue

@@ -1,6 +1,6 @@
 <template>
-  <div>
-    <h3 class="center-align">Modifier un créneau</h3>
+  <div class="editor-panel">
+    <h3>Modifier un créneau</h3>
     <div class="actions">
       <button class="btn small primary" v-on:click="emitCreationOrder">
         <i class="material-icons right">create</i>Nouveau
@@ -20,7 +20,7 @@
         <i class="material-icons right">delete_forever</i>Supprimer
       </button>
     </div>
-    <div class="center-align" v-if="creneau === undefined">Veuillez selectioner un creneau.</div>
+    <div class="empty" v-if="creneau === undefined">Veuillez selectioner un creneau.</div>
     <form v-else>
       <styled-input
         label="Titre"
@@ -30,7 +30,7 @@
         @input="inputListener($event, 'title')"
       />
       <styled-input
-        class="small"
+        class="s6"
         label="Date"
         id="creneauDate"
         type="text"
@@ -38,7 +38,7 @@
         v-model.lazy="jour"
       />
       <styled-input
-        class="small"
+        class="s6"
         label="Heure"
         id="creneauHeure"
         type="text"
@@ -46,14 +46,14 @@
         v-model.lazy="heure"
       />
       <styled-input
-        class="small"
+        class="s6"
         label="Durée (min)"
         id="creneauDuree"
         type="number"
         v-model.lazy="duree"
       />
       <styled-input
-        class="small"
+        class="s6"
         label="Heure fin"
         id="disabled"
         type="text"
@@ -61,7 +61,7 @@
         disabled
       />
       <styled-input
-        class="small"
+        class="s6"
         label="Bénévole minimum"
         id="minAttendee"
         type="number"
@@ -69,22 +69,14 @@
         @input="inputListener($event, 'minAttendee')"
       />
       <styled-input
-        class="small"
-        label="Bénévole minimum"
+        class="s6"
+        label="Bénévole max"
         id="minAttendee"
         type="number"
         :optional="true"
         :modelValue="creneau.maxAttendee"
         @input="inputListener($event, 'maxAttendee')"
       />
-      <styled-input
-        label="Pénibilité"
-        id="penibility"
-        type="number"
-        class="small"
-        :modelValue="creneau.penibility"
-        @input="inputListener($event, 'penibility')"
-      />
 
       <styled-input
         label="Description"
@@ -102,8 +94,7 @@
         secondary-placeholder="+ compétence"
         :autocomplete-list="autocompleteCompetencesList"
         :strict-autocomplete="true"
-        :value="creneau.competencesIdList"
-        @input="inputListener($event, 'competencesIdList')"
+        v-model="competencesStrIdList"
       ></chips-input>
       <chips-input
         label="Bénévoles"
@@ -112,9 +103,17 @@
         secondary-placeholder="+ bénévole"
         :autocomplete-list="autocompleteBenevolesList"
         :strict-autocomplete="true"
-        :value="creneau.benevoleIdList"
-        @input="inputListener($event, 'benevoleIdList')"
+        v-model="benevoleStrIdList"
       ></chips-input>
+
+      <styled-input
+        label="Pénibilité"
+        id="penibility"
+        type="number"
+        class="s6"
+        :modelValue="creneau.penibility"
+        @input="inputListener($event, 'penibility')"
+      />
     </form>
   </div>
 </template>
@@ -127,6 +126,10 @@ import styledInput from "./input.vue";
 import chipsInput from "../components/SelectChipInput.vue";
 import dayjs from "dayjs";
 
+import "@/assets/css/editor-panel.css";
+import { MutationTypes } from "@/store/Mutations";
+
+// TODO Understand why the component only syncro after a first edition of the date
 export default defineComponent({
   name: "EditeurCreneau",
   components: { "chips-input": chipsInput, styledInput },
@@ -140,6 +143,8 @@ export default defineComponent({
       jour: "",
       heure: "",
       duree: "",
+      benevoleStrIdList: [] as Array<string>,
+      competencesStrIdList: [] as Array<string>,
     };
   },
   watch: {
@@ -149,6 +154,34 @@ export default defineComponent({
     "creneau.end": function () {
       this.updateForm();
     },
+    "creneau.benevoleIdList": function (val: Array<number>) {
+      this.benevoleStrIdList = val.map((s) => s.toString());
+    },
+    creneau: function (val: Creneau) {
+      this.competencesStrIdList = val.competencesIdList.map((s) => s.toString());
+    },
+    competencesStrIdList(val: Array<string>) {
+      if (this.creneau) {
+        const new_arr = val.map((s) => parseInt(s));
+        const old_arr = this.creneau?.competencesIdList;
+        if (
+          new_arr.length !== old_arr?.length ||
+          new_arr.reduce((acc: boolean, n: number) => acc && old_arr.includes(n), true)
+        ) {
+          this.updateCreneau("competencesIdList", new_arr);
+        }
+      }
+    },
+    benevoleStrIdList(new_val: Array<string>) {
+      if (this.creneau) {
+        const new_list = new_val.map((s) => parseInt(s));
+        const old_list = this.creneau?.benevoleIdList;
+        const addList = new_list.filter((i) => !old_list.includes(i));
+        const removeList = old_list.filter((i) => !new_list.includes(i));
+        addList.forEach(this.addBenevole2Creneau);
+        removeList.forEach(this.removeBenevole2Creneau);
+      }
+    },
     jour: function () {
       this.updateDates();
     },
@@ -218,6 +251,20 @@ export default defineComponent({
         this.$emit("edit", payload);
       }
     },
+    addBenevole2Creneau(id: number) {
+      if (this.creneau)
+        this.$store.commit(MutationTypes.addBenevole2Creneau, {
+          creneauId: this.creneau.id,
+          benevoleId: id,
+        });
+    },
+    removeBenevole2Creneau(id: number) {
+      if (this.creneau)
+        this.$store.commit(MutationTypes.removeBenevole2Creneau, {
+          creneauId: this.creneau.id,
+          benevoleId: id,
+        });
+    },
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     inputListener(event: any, field: keyof Creneau) {
       this.updateCreneau(field, event.target.value);
@@ -244,34 +291,9 @@ export default defineComponent({
   },
   mounted() {
     this.updateForm();
+    if (this.creneau) this.benevoleStrIdList = this.creneau.benevoleIdList.map((s) => s.toString());
   },
 });
 </script>
 
-<style scoped>
-.actions {
-  display: flex;
-  justify-content: center;
-}
-.actions > * {
-  margin: 8px;
-}
-.small {
-  max-width: calc(50% - 8px);
-}
-form > * {
-  width: 100%;
-}
-form {
-  display: flex;
-  justify-content: space-between;
-  flex-wrap: wrap;
-}
-h3 {
-  margin-top: 0px;
-  margin-bottom: 8px;
-}
-.formcontrol:not(:last-child) {
-  margin-bottom: 4px;
-}
-</style>
+<style scoped></style>

+ 8 - 22
src/components/EditeurCreneauGroup.vue

@@ -1,16 +1,16 @@
 <template>
-  <div>
-    <h3 class="center-align">Modifier une ligne</h3>
-    <div class="button-holder center-align">
+  <div class="editor-panel">
+    <h3>Modifier une ligne</h3>
+    <div class="actions">
       <button class="btn small primary" v-on:click="emitCreationOrder">
-        <i class="material-icons right">create</i>Créer une ligne
+        <i class="material-icons right">create</i>Ajouter une ligne
       </button>
       <button
         class="btn small error"
         :disabled="creneauGroup === undefined"
         v-on:click="emitDeleteOrder"
       >
-        <i class="material-icons">delete_forever</i>Supprimer la ligne selectionée
+        <i class="material-icons">delete_forever</i>Supprimer la ligne
       </button>
     </div>
 
@@ -47,7 +47,7 @@
       >
       </auto-complete-input>
     </form>
-    <div class="center-align" v-else>Veuillez selectioner un ligne de creneau.</div>
+    <div class="empty" v-else>Veuillez selectioner un ligne de creneau.</div>
   </div>
 </template>
 
@@ -56,6 +56,7 @@ import { defineComponent } from "vue";
 import { Ressource } from "jc-timeline/lib/Ressource";
 import AutoCompleteInput from "./AutoCompleteInput.vue";
 import styledInput from "./input.vue";
+import "@/assets/css/editor-panel.css";
 import AutocompleteOptions from "@/models/AutocompleteOptions";
 
 export default defineComponent({
@@ -120,19 +121,4 @@ export default defineComponent({
 });
 </script>
 
-<style scoped>
-.center-align {
-  text-align: center;
-}
-.button-holder {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-}
-.button-holder > * {
-  margin-right: 4px;
-}
-form {
-  margin-top: 16px;
-}
-</style>
+<style scoped></style>

+ 23 - 18
src/components/SelectChipInput.vue

@@ -7,12 +7,12 @@
     <div ref="container" class="select-multiple" :class="{ 'select-multiple--expanded': expanded }">
       <div class="select-multiple-value" @click="openDropDown">
         <span v-if="checkedItems.length === 0"></span>
-        <div v-for="item of displayedItems" class="ds-chip" :key="item.id">
-          <span class="ds-chip-label">{{ item.text }}</span>
-          <button aria-label="Clear" class="ds-chip-remove-btn" @click="toggle(item.id, $event)" />
+        <div v-for="item of displayedItems" class="chip" :key="item.id">
+          <span class="chip-label">{{ item.text }}</span>
+          <button aria-label="Clear" class="chip-remove-btn" @click="toggle(item.index, $event)" />
         </div>
-        <div v-if="checkedItems.length > maxItemDisplayed" class="ds-chip">
-          <div class="ds-chip-label">+{{ checkedItems.length - maxItemDisplayed }}</div>
+        <div v-if="checkedItems.length > maxItemDisplayed" class="chip">
+          <div class="chip-label">+{{ checkedItems.length - maxItemDisplayed }}</div>
         </div>
         <input
           class="select-multiple-input"
@@ -23,7 +23,7 @@
           @keyup="keyUp"
         />
       </div>
-      <div class="dropdown-options">
+      <div class="dropdown-options pickable">
         <div
           v-for="(item, index) in filteredItems"
           :class="{
@@ -40,6 +40,7 @@
 </template>
 
 <script lang="ts">
+import "@/assets/css/chip.css";
 import AutocompleteValues from "@/models/AutocompleteOptions";
 import { defineComponent, PropType } from "vue";
 interface selectItem {
@@ -54,7 +55,7 @@ export default defineComponent({
     label: String,
     optional: Boolean,
     // Values selected by the component
-    valueModel: {
+    modelValue: {
       type: Array as PropType<Array<string>>,
       default: () => [],
     },
@@ -89,21 +90,22 @@ export default defineComponent({
     };
   },
   watch: {
-    valueModel: function () {
+    modelValue: function (val) {
       this.updateItems();
     },
   },
-  emit: ["update:valueModel"],
+  emit: ["update:modelValue"],
   methods: {
     toggle(idx: number, e: MouseEvent): void {
       this.items[idx].isChecked = !this.items[idx].isChecked;
-      this.$emit(
-        "update:valueModel",
-        this.items.filter((o) => o.isChecked).map((o) => o.value)
-      );
+      this.emitValue();
       this.input.focus();
       e.stopPropagation();
     },
+    emitValue() {
+      const values = this.items.filter((o) => o.isChecked).map((o) => o.value);
+      this.$emit("update:modelValue", values);
+    },
     updateItems(): void {
       this.items = this.autocompleteList.map(
         (o: AutocompleteValues | string, idx: number): selectItem => {
@@ -117,7 +119,7 @@ export default defineComponent({
           }
           return {
             index: idx,
-            isChecked: this.valueModel.includes(id),
+            isChecked: this.modelValue.includes(id),
             value: id,
             text: txt,
           };
@@ -131,10 +133,12 @@ export default defineComponent({
       e.stopPropagation();
     },
     closeDropDown: function (e: MouseEvent) {
-      if (!(this.$refs["container"] as HTMLElement).contains(e.target as Node)) {
-        this.expanded = false;
-        window.removeEventListener("click", this.closeDropDown);
-        e.stopPropagation();
+      if (this.$refs["container"]) {
+        if (!(this.$refs["container"] as HTMLElement).contains(e.target as Node)) {
+          this.expanded = false;
+          window.removeEventListener("click", this.closeDropDown);
+          e.stopPropagation();
+        }
       }
     },
     highlight(txt: string): string {
@@ -155,6 +159,7 @@ export default defineComponent({
     keyUp(e: KeyboardEvent) {
       if (e.key == "Enter") {
         if (this.selectedItem) this.selectedItem.isChecked = !this.selectedItem.isChecked;
+        this.emitValue();
       }
       if (e.key == "ArrowUp") {
         this.activeIndex--;

+ 99 - 0
src/components/checkBox.vue

@@ -0,0 +1,99 @@
+<template>
+  <label :class="{ filled }" :for="id">
+    <input :id="id" type="checkbox" v-model="checked" />
+    <span>{{ label }}</span>
+  </label>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  data() {
+    return { checked: true };
+  },
+  props: {
+    modelValue: { type: Boolean, required: true },
+    label: String,
+    id: String,
+    filled: Boolean,
+  },
+  watch: {
+    modelValue(value: boolean) {
+      if (value != this.checked) this.checked = value;
+    },
+    checked(value) {
+      if (value != this.modelValue) this.$emit("update:modelValue", value);
+    },
+  },
+  mounted() {
+    this.checked = this.modelValue;
+  },
+});
+</script>
+
+<style scoped>
+label {
+  display: inline-block;
+  color: var(--color-neutral-200);
+  display: flex;
+  font-size: 0.9rem;
+  font-family: inherit;
+  font-weight: 500;
+  line-height: 1.5rem;
+  margin-bottom: 0.2rem;
+}
+input[type="checkbox"]:not(:checked),
+input[type="checkbox"]:checked {
+  position: absolute;
+  opacity: 0;
+  pointer-events: none;
+}
+[type="checkbox"] + span {
+  position: relative;
+  padding-left: 25px;
+  cursor: pointer;
+  display: inline-block;
+  height: 25px;
+  line-height: 20px;
+  font-size: 1rem;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+.filled > [type="checkbox"] + span::before,
+[type="checkbox"] + span::after {
+  content: "";
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 18px;
+  height: 18px;
+  border: 2px solid var(--color-neutral-200);
+  border-radius: 3px;
+  transition: all 0.2s;
+}
+.filled > [type="checkbox"]:checked + span::before {
+  background: var(--color-accent-850);
+  border-color: var(--color-accent-850);
+}
+[type="checkbox"]:checked + span::after {
+  top: 0px;
+  left: -2px;
+  width: 10px;
+  height: 16px;
+  border-color: var(--color-accent-400);
+  transform: border-top-color 0s, border-left-color 0s;
+  border-top-color: transparent;
+  border-left-color: transparent;
+  transform: rotateZ(37deg);
+  transform-origin: 100% 100%;
+}
+.filled > [type="checkbox"]:checked + span::after {
+  top: 1px;
+  left: 0px;
+  width: 8px;
+  height: 14px;
+}
+</style>

+ 2 - 2
src/components/input.vue

@@ -126,7 +126,7 @@ export default defineComponent({
   -webkit-appearance: none;
   -moz-appearance: none;
   appearance: none;
-  box-shadow: 0 -1px 0 0 var(--color-accent-800) inset;
+  box-shadow: 0 -1px 0 0 var(--color-accent-700) inset;
   box-sizing: border-box;
   -webkit-transition: 0.1s ease-in-out;
   transition: 0.1s ease-in-out;
@@ -152,7 +152,7 @@ export default defineComponent({
   background-color: var(--color-accent-850);
 }
 .input:focus {
-  box-shadow: 0 0 0 2px var(--color-accent-800);
+  box-shadow: 0 0 0 2px var(--color-accent-700);
   border-color: transparent;
   background-color: var(--color-accent-950);
 }

+ 23 - 56
src/models/Benevole.ts

@@ -1,5 +1,5 @@
 interface IBenevole {
-  id: number;
+  id?: number;
   name: string;
   surname?: string;
   phone?: string;
@@ -8,6 +8,7 @@ interface IBenevole {
   competenceIdList?: number[];
   creneauIdList?: string[];
 }
+export type BenevoleJSON = Omit<IBenevole, "creneauIdList">;
 
 export default class Benevole {
   static maxId = 0;
@@ -46,10 +47,10 @@ export default class Benevole {
     return cleaned.replace(/(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, "$1 $2 $3 $4 $5");
   }
   get fullname(): string {
-    return this.name + (this.surname !== "" ? " " + this.surname : "") + " " + this.fanfare;
+    return this.name + (this.surname !== "" ? " " + this.surname : "");
   }
   get shortame(): string {
-    return this.name + " (" + this.fanfare + ")";
+    return this.name;
   }
   get imgPlaceholder(): string {
     return (
@@ -58,62 +59,14 @@ export default class Benevole {
       this.surname.charAt(0).toUpperCase()
     );
   }
-  get fanfare(): string {
-    const defaultValue = "Exte";
-    return defaultValue;
-    /*
-    if (this.competenceIdList.length == 0) {
-      return defaultValue;
-    } else {
-      const output = this.fanfareList.join(",");
-      return output == "" ? defaultValue : output;
-        }
-      */
-  }
-  /*
-  get fanfareList() {
-    return competenceList
-      .filter((o) => o.name.startsWith("Fanfare"))
-      .filter((o) => this.competenceIdList.indexOf(o.id) > -1)
-      .map((o) => o.name.substring(8));
-  }
-  get totalTime() {
-    if (app) {
-      return (
-        app.creneauList
-          .filter((creneau) => this.creneauIdList.indexOf(creneau.id) > -1)
-          .reduce((acc, creneau) => acc + creneau.time.end - creneau.time.start, 0) / 3_600_000
-      );
-    }
-    return 0;
-  }
-  get renderPreference() {
-    // global competenceList
-    const tmpId = Date.now().toString();
-    const content = competenceList
-      .filter((o) => !o.name.startsWith("Fanfare"))
-      .filter((o) => this.competenceIdList.indexOf(o.id) > -1)
-      .map((o) => o.name);
-
-    // Prepare content
-    const span =
-      "<span id='" +
-      tmpId +
-      "'class='tooltipped' data-position='top' data-tooltip='" +
-      content.join("<br>") +
-      "'>" +
-      content.join(", ") +
-      "</span>";
-    return span;
-  }*/
   static fromObject(obj: IBenevole) {
     let id: number;
-    if (isNaN(obj.id)) {
+    if (obj.id && !isNaN(obj.id)) {
+      id = obj.id;
+      this.maxId = Math.max(obj.id, this.maxId);
+    } else {
       this.maxId += 1;
       id = this.maxId;
-    } else {
-      id = obj.id;
-      this.maxId = obj.id > this.maxId ? obj.id : this.maxId;
     }
     return new Benevole(
       id,
@@ -127,7 +80,7 @@ export default class Benevole {
     );
   }
 
-  toPlainObject() {
+  toPlainObject(): IBenevole {
     return {
       id: this.id,
       name: this.name,
@@ -139,4 +92,18 @@ export default class Benevole {
       creneauIdList: this.creneauIdList,
     };
   }
+  toJSON(): BenevoleJSON {
+    const obj = this.toPlainObject();
+    delete obj.creneauIdList;
+    return obj;
+  }
+  static fromJSON(input: string | BenevoleJSON): Benevole {
+    let obj: BenevoleJSON;
+    if (typeof input == "string") {
+      obj = JSON.parse(input);
+    } else {
+      obj = input as BenevoleJSON;
+    }
+    return Benevole.fromObject(obj);
+  }
 }

+ 51 - 20
src/models/Competence.ts

@@ -1,18 +1,40 @@
-interface ICompetence {
-  id: number;
+export interface ICompetence {
+  id?: number;
   name: string;
   description: string;
-  isPreference: boolean;
-  isTeachable: boolean;
+  isPreference?: boolean;
+  isTeachable?: boolean;
 }
 export default class Competence {
   static maxId = 0;
   id: number;
   name: string;
   description: string;
-  isPreference: boolean;
-  isTeachable: boolean;
-  constructor(id: number, name: string, description: string, isPreference: boolean) {
+  private _isPreference!: boolean;
+  renderPreference!: string;
+  renderTeachable!: string;
+  public get isPreference(): boolean {
+    return this._isPreference;
+  }
+  public set isPreference(value: boolean) {
+    this._isPreference = value;
+    this.renderPreference = Competence.getBox(value);
+  }
+  private _isTeachable!: boolean;
+  public get isTeachable(): boolean {
+    return this._isTeachable;
+  }
+  public set isTeachable(value: boolean) {
+    this._isTeachable = value;
+    this.renderTeachable = Competence.getBox(value);
+  }
+  constructor(
+    id: number,
+    name: string,
+    description: string,
+    isPreference = true,
+    isTeachable = false
+  ) {
     // Change the current max id
     if (!isNaN(id)) Competence.maxId = id > Competence.maxId ? id : Competence.maxId;
 
@@ -20,17 +42,13 @@ export default class Competence {
     this.name = name;
     this.description = description;
     this.isPreference = isPreference;
-    this.isTeachable = false;
-  }
-  get renderPreference(): string {
-    const icon = this.isPreference ? "check_box" : "check_box_outline_blank";
-    return '<i class="material-icons">' + icon + "</i>";
+    this.isTeachable = isTeachable;
   }
-  get renderTeachable(): string {
-    const icon = this.isTeachable ? "check_box" : "check_box_outline_blank";
-    return '<i class="material-icons">' + icon + "</i>";
+  static getBox(v: boolean) {
+    return `<i class="material-icons">${v ? "check_box" : "check_box_outline_blank"}</i>`;
   }
   get overflowDescription(): string {
+    // TODO implement tooltiped description
     return this.description;
   }
   get fullname(): string {
@@ -38,19 +56,20 @@ export default class Competence {
   }
   static fromObject(obj: ICompetence): Competence {
     let id: number;
-    if (isNaN(obj.id)) {
-      this.maxId += 1;
-      id = this.maxId;
-    } else {
+    if (obj.id && !isNaN(obj.id)) {
       id = obj.id;
       this.maxId = obj.id > this.maxId ? obj.id : this.maxId;
+    } else {
+      this.maxId += 1;
+      id = this.maxId;
     }
 
     return new Competence(
       id,
       obj.name,
       obj.description ? obj.description : "",
-      obj.isPreference ? obj.isPreference : false
+      obj.isPreference !== undefined ? obj.isPreference : true,
+      obj.isTeachable !== undefined ? obj.isTeachable : false
     );
   }
 
@@ -63,4 +82,16 @@ export default class Competence {
       isTeachable: this.isTeachable,
     };
   }
+  toJSON(): ICompetence {
+    return this.toPlainObject();
+  }
+  static fromJSON(input: string | ICompetence): Competence {
+    let obj: ICompetence;
+    if (typeof input == "string") {
+      obj = JSON.parse(input);
+    } else {
+      obj = input as ICompetence;
+    }
+    return Competence.fromObject(obj);
+  }
 }

+ 18 - 4
src/models/Creneau.ts

@@ -1,6 +1,6 @@
 import dayjs from "dayjs";
-import { isString } from "lodash";
-import { Event } from "jc-timeline";
+import { isString, omit } from "lodash";
+import { Event, EventJSON } from "jc-timeline";
 
 export interface ICreneau {
   event: Event;
@@ -11,6 +11,9 @@ export interface ICreneau {
   competencesIdList: Array<number>;
   description: string;
 }
+export type CreneauJSON = Omit<ICreneau, "event"> & {
+  event: EventJSON;
+};
 class Creneau implements ICreneau {
   event: Event;
 
@@ -106,9 +109,11 @@ class Creneau implements ICreneau {
     const spanClass = this.benevoleIdList.length == 0 ? "red" : missingbenevole > 0 ? "orange" : "";
     this.event.content = `<div class="bubble ${spanClass}">${missingbenevole}</div>`;
   }
-  toPlainObject(): ICreneau {
+  toJSON(): CreneauJSON {
+    const e = this.event.toJSON();
+    delete e.content;
     return {
-      event: this.event,
+      event: e,
       penibility: this.penibility,
       minAttendee: this.minAttendee,
       maxAttendee: this.maxAttendee,
@@ -117,6 +122,15 @@ class Creneau implements ICreneau {
       description: this.description,
     };
   }
+  static fromJSON(input: string | CreneauJSON): Creneau {
+    let obj: CreneauJSON;
+    if (typeof input == "string") {
+      obj = JSON.parse(input);
+    } else {
+      obj = input as CreneauJSON;
+    }
+    return new Creneau({ ...obj, event: Event.fromJSON(obj.event) });
+  }
 }
 
 export default Creneau;

+ 58 - 0
src/models/Evenement.ts

@@ -0,0 +1,58 @@
+import { v4 as uuidv4 } from "uuid";
+import dayjs from "dayjs";
+export interface IEvenement {
+  name: string;
+  uuid: string;
+  end: string;
+  start: string;
+}
+export default class Evenement {
+  name: string;
+  uuid: string;
+  startingDate: dayjs.Dayjs;
+  endingDate: dayjs.Dayjs;
+
+  constructor() {
+    this.name = "";
+    this.uuid = uuidv4();
+    this.startingDate = dayjs().startOf("d");
+    this.endingDate = dayjs().endOf("d");
+  }
+  set start(date: Date) {
+    this.startingDate = dayjs(date);
+  }
+  get start() {
+    return this.startingDate.toDate();
+  }
+
+  set end(date: Date) {
+    this.endingDate = dayjs(date);
+  }
+  get end() {
+    return this.endingDate.toDate();
+  }
+
+  toJSON() {
+    return {
+      name: this.name,
+      uuid: this.uuid,
+      start: this.startingDate.toISOString(),
+      end: this.endingDate.toISOString(),
+    };
+  }
+  static fromJSON(input: string | IEvenement) {
+    let obj: IEvenement;
+    if (typeof input == "string") {
+      obj = JSON.parse(input);
+    } else {
+      obj = input as IEvenement;
+    }
+    const output = new Evenement();
+    output.name = obj.name;
+    output.uuid = obj.uuid;
+    output.startingDate = dayjs(obj.start);
+    output.endingDate = dayjs(obj.end);
+
+    return output;
+  }
+}

+ 18 - 0
src/router/index.ts

@@ -1,6 +1,9 @@
 import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
 import Home from "../views/Home.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";
 
 const routes: Array<RouteRecordRaw> = [
   {
@@ -13,6 +16,21 @@ const routes: Array<RouteRecordRaw> = [
     name: "Planning",
     component: Planning,
   },
+  {
+    path: "/competences",
+    name: "Competence",
+    component: CompetenceManager,
+  },
+  {
+    path: "/benevoles",
+    name: "Benevoles",
+    component: BenevoleManager,
+  },
+  {
+    path: "/planningIndividuel",
+    name: "planningIndividuel",
+    component: PlanningPersonnel,
+  },
 ];
 
 const router = createRouter({

+ 1 - 1
src/store/Actions.ts

@@ -1,5 +1,5 @@
 import { ActionTree, ActionContext } from "vuex";
-import { State } from "./state";
+import { State } from "./State";
 import { Mutations, MutationTypes } from "./Mutations";
 import Creneau, { ICreneau } from "@/models/Creneau";
 

+ 11 - 1
src/store/Getters.ts

@@ -3,13 +3,14 @@ import Competence from "@/models/Competence";
 import Creneau from "@/models/Creneau";
 import { Ressource } from "jc-timeline";
 import { GetterTree } from "vuex";
-import { State } from "./state";
+import { State, StateJSON } from "./State";
 
 export type Getters = {
   getCreneauById(state: State): (id: string) => Creneau | undefined;
   getCreneauGroupById(state: State): (id: string) => Ressource | undefined;
   getCompetenceById(state: State): (id: number) => Competence | undefined;
   getBenevoleById(state: State): (id: number) => Benevole | undefined;
+  getJSONState(state: State): StateJSON;
 };
 
 export const getters: GetterTree<State, State> & Getters = {
@@ -25,4 +26,13 @@ export const getters: GetterTree<State, State> & Getters = {
   getBenevoleById: (state) => (id: number) => {
     return state.benevoleList.find((o) => o.id == id);
   },
+  getJSONState: (s) => {
+    return {
+      evenement: s.evenement.toJSON(),
+      competences: s.competenceList.map((o) => o.toJSON()),
+      benevoles: s.benevoleList.map((o) => o.toJSON()),
+      creneaux: s.creneauList.map((o) => o.toJSON()),
+      creneauGroups: s.creneauGroupList.map((o) => o.toJSON()),
+    };
+  },
 };

+ 103 - 15
src/store/Mutations.ts

@@ -1,9 +1,15 @@
+import Benevole from "@/models/Benevole";
+import Competence from "@/models/Competence";
 import Creneau from "@/models/Creneau";
+import Evenement from "@/models/Evenement";
 import { Ressource } from "jc-timeline";
 import { MutationTree } from "vuex";
-import { State } from "./state";
+import { State, StateJSON } from "./State";
 
 export enum MutationTypes {
+  resetState = "resetState",
+  editEvenement = "editEvenement",
+
   addCreneau = "addCreneau",
   removeCreneau = "removeCreneau",
   editCreneau = "editCreneau",
@@ -14,6 +20,14 @@ export enum MutationTypes {
   removeCreneauGroup = "removeCreneauGroup",
   editCreneauGroup = "editCreneauGroup",
   reorderCreneauGroup = "reorderCreneauGroup",
+
+  addBenevole = "addBenevole",
+  removeBenevole = "removeBenevole",
+  editBenevole = "editBenevole",
+
+  addConstraint = "addConstraint",
+  removeConstraint = "removeConstraint",
+  editConstraint = "editConstraint",
 }
 interface CreneauPairing {
   creneauId: string;
@@ -21,6 +35,12 @@ interface CreneauPairing {
 }
 
 export type Mutations<S = State> = {
+  [MutationTypes.resetState](state: S): void;
+  [MutationTypes.editEvenement]<K extends keyof Evenement>(
+    state: S,
+    payload: { field: K; value: Evenement[K] }
+  ): void;
+
   [MutationTypes.addCreneau](state: S, payload: Creneau): void;
   [MutationTypes.removeCreneau](state: S, payload: Creneau): void;
   [MutationTypes.editCreneau]<K extends keyof Creneau>(
@@ -38,8 +58,34 @@ export type Mutations<S = State> = {
 
   [MutationTypes.addBenevole2Creneau](state: S, payload: CreneauPairing): void;
   [MutationTypes.removeBenevole2Creneau](state: S, payload: CreneauPairing): void;
+
+  [MutationTypes.addBenevole](state: S, payload: Benevole): void;
+  [MutationTypes.removeBenevole](state: S, payload: Benevole): void;
+  [MutationTypes.editBenevole]<K extends keyof Benevole>(
+    state: S,
+    payload: { id: number; field: K; value: Benevole[K] }
+  ): boolean;
+
+  [MutationTypes.addConstraint](state: S, payload: Competence): void;
+  [MutationTypes.removeConstraint](state: S, payload: Competence): void;
+  [MutationTypes.editConstraint]<K extends keyof Competence>(
+    state: S,
+    payload: { id: number; field: K; value: Competence[K] }
+  ): boolean;
 };
 export const mutations: MutationTree<State> & Mutations = {
+  [MutationTypes.resetState](state): void {
+    state.evenement = new Evenement();
+    state.creneauList = [];
+    state.creneauGroupList = [];
+    state.competenceList = [];
+    state.benevoleList = [];
+  },
+  [MutationTypes.editEvenement](state, payload): void {
+    state.evenement[payload.field] = payload.value;
+  },
+
+  // Creneau Management
   [MutationTypes.addCreneau](state, creneau) {
     state.creneauList = [...state.creneauList, creneau];
   },
@@ -54,6 +100,24 @@ export const mutations: MutationTree<State> & Mutations = {
     }
     return false;
   },
+
+  [MutationTypes.addBenevole2Creneau](state, pair: CreneauPairing) {
+    const benevole = state.benevoleList.find((o) => o.id == pair.benevoleId);
+    const creneau = state.creneauList.find((o) => o.id == pair.creneauId);
+    if (creneau && benevole) {
+      benevole.creneauIdList = [...benevole.creneauIdList, pair.creneauId];
+      creneau.benevoleIdList = [...creneau.benevoleIdList, pair.benevoleId];
+    }
+  },
+  [MutationTypes.removeBenevole2Creneau](state, pair: CreneauPairing) {
+    const benevole = state.benevoleList.find((o) => o.id == pair.benevoleId);
+    const creneau = state.creneauList.find((o) => o.id == pair.creneauId);
+    if (benevole)
+      benevole.creneauIdList = benevole.creneauIdList.filter((id) => id !== pair.creneauId);
+    if (creneau)
+      creneau.benevoleIdList = creneau.benevoleIdList.filter((id) => id !== pair.benevoleId);
+  },
+  // Creneau Group Management
   [MutationTypes.reorderCreneauGroup](state, payload) {
     state.creneauGroupList = payload;
     return true;
@@ -72,21 +136,45 @@ export const mutations: MutationTree<State> & Mutations = {
     }
     return false;
   },
-
-  [MutationTypes.addBenevole2Creneau](state, pair: CreneauPairing) {
-    const benevole = state.benevoleList.find((o) => o.id == pair.benevoleId);
-    const creneau = state.creneauList.find((o) => o.id == pair.creneauId);
-    if (creneau && benevole) {
-      benevole.creneauIdList.push(pair.creneauId);
-      creneau.benevoleIdList.push(pair.benevoleId);
+  // Benevole Management
+  [MutationTypes.addBenevole](state, payload) {
+    state.benevoleList = [...state.benevoleList, payload];
+  },
+  [MutationTypes.removeBenevole](state, payload) {
+    state.creneauList.forEach(
+      (creneau) =>
+        (creneau.benevoleIdList = creneau.benevoleIdList.filter((id) => id !== payload.id))
+    );
+    state.benevoleList = state.benevoleList.filter((c) => c.id !== payload.id);
+  },
+  [MutationTypes.editBenevole](state, payload) {
+    const el = state.benevoleList.find((o) => o.id == payload.id);
+    if (el) {
+      el[payload.field] = payload.value;
+      return true;
     }
+    return false;
   },
-  [MutationTypes.removeBenevole2Creneau](state, pair: CreneauPairing) {
-    const benevole = state.benevoleList.find((o) => o.id == pair.benevoleId);
-    const creneau = state.creneauList.find((o) => o.id == pair.creneauId);
-    if (benevole)
-      benevole.creneauIdList = benevole.creneauIdList.filter((id) => id !== pair.creneauId);
-    if (creneau)
-      creneau.benevoleIdList = creneau.benevoleIdList.filter((id) => id !== pair.benevoleId);
+  // Constraint Management
+  [MutationTypes.addConstraint](state, payload) {
+    state.competenceList = [...state.competenceList, payload];
+  },
+  [MutationTypes.removeConstraint](state, payload) {
+    const filterFunction = (id: number) => id !== payload.id;
+    state.benevoleList.forEach(
+      (benevole) => (benevole.competenceIdList = benevole.competenceIdList.filter(filterFunction))
+    );
+    state.creneauList.forEach(
+      (creneau) => (creneau.competencesIdList = creneau.competencesIdList.filter(filterFunction))
+    );
+    state.competenceList = state.competenceList.filter((c) => c.id !== payload.id);
+  },
+  [MutationTypes.editConstraint](state, payload) {
+    const el = state.competenceList.find((o) => o.id == payload.id);
+    if (el) {
+      el[payload.field] = payload.value;
+      return true;
+    }
+    return false;
   },
 };

+ 14 - 5
src/store/State.ts

@@ -1,10 +1,19 @@
-import { Ressource } from "jc-timeline";
-import Benevole from "../models/Benevole";
-import Competence from "../models/Competence";
-import Creneau from "../models/Creneau";
+import Evenement, { IEvenement } from "@/models/Evenement";
+import { Ressource, RessourceJSON } from "jc-timeline";
+import Benevole, { BenevoleJSON } from "../models/Benevole";
+import Competence, { ICompetence } from "../models/Competence";
+import Creneau, { CreneauJSON } from "../models/Creneau";
+
+export type StateJSON = {
+  evenement: IEvenement;
+  competences: Array<ICompetence>;
+  benevoles: Array<BenevoleJSON>;
+  creneaux: Array<CreneauJSON>;
+  creneauGroups: Array<RessourceJSON>;
+};
 
 export const state = {
-  eventId: "" as string,
+  evenement: new Evenement() as Evenement,
   creneauList: [] as Array<Creneau>,
   creneauGroupList: [] as Array<Ressource>,
   competenceList: [] as Array<Competence>,

+ 138 - 3
src/views/BenevoleManager.vue

@@ -1,11 +1,146 @@
 <template>
-  <div></div>
+  <div class="page">
+    <data-table
+      class="data-table"
+      title="Liste des bénévoles"
+      :data="data"
+      :clickable="true"
+      :loadingAnimation="loading"
+      locale="fr"
+      @row-click="onRowClick"
+    ></data-table>
+    <editeur-benevole
+      :benevole="currentBenevole"
+      @create="createBenevole"
+      @delete="deleteBenevole"
+    />
+  </div>
 </template>
 
 <script lang="ts">
+import Competence from "@/models/Competence";
+import EditeurBenevole from "@/components/EditeurBenevole.vue";
+import DataTable, { DataTableData, DataTableObject } from "@/components/DataTable.vue";
 import { defineComponent } from "vue";
+import { MutationTypes } from "@/store/Mutations";
+import Benevole from "@/models/Benevole";
 
-export default defineComponent({});
+export default defineComponent({
+  name: "EditeurCreneau",
+  components: { "data-table": DataTable, EditeurBenevole },
+  data: () => ({
+    columns: [
+      {
+        label: "Id",
+        field: "id",
+        numeric: true,
+        html: false,
+        class: "fitwidth",
+      },
+      {
+        label: "Prénom",
+        field: "name",
+        numeric: false,
+        html: false,
+        class: "overflow-cell",
+      },
+      {
+        label: "Nom",
+        field: "surname",
+        numeric: false,
+        html: false,
+        class: "overflow-cell",
+      },
+      {
+        label: "Fanfare",
+        field: "fanfare",
+        numeric: false,
+        html: false,
+        class: "fitwidth",
+        export: "fanfare",
+      },
+      {
+        label: "Duree créneau(h)",
+        field: "creneauLength",
+        numeric: true,
+        html: false,
+        class: "fitwidth",
+        export: "creneauLength",
+      },
+    ],
+    currentBenevole: undefined as Benevole | undefined,
+    loading: true,
+  }),
+  computed: {
+    competenceList(): Array<Competence> {
+      return this.$store.state.competenceList;
+    },
+    fanfareList(): Array<Competence> {
+      return this.competenceList.filter((o) => o.name.startsWith("Fanfare"));
+    },
+    benevoleList(): Array<Benevole> {
+      return this.$store.state.benevoleList;
+    },
+    data(): DataTableData<DataTableObject> {
+      return {
+        items: this.benevoleList.map((b) => {
+          return {
+            id: b.id,
+            name: b.name,
+            surname: b.surname,
+            fanfare: this.getFanfareForBenevole(b),
+            creneauLength: this.getBenevoleTotalLength(b),
+          };
+        }),
+        columns: this.columns,
+      };
+    },
+  },
+  methods: {
+    getFanfareForBenevole(benevole: Benevole): string {
+      if (benevole.competenceIdList.length == 0) {
+        return "Exte";
+      } else {
+        const fanfare = this.fanfareList
+          .filter((o) => benevole.competenceIdList.indexOf(o.id) > -1)
+          .map((o) => o.name.substring(8))
+          .join(", ");
+        return fanfare ? fanfare : "Exte";
+      }
+    },
+    getBenevoleTotalLength(benevole: Benevole): number {
+      return this.$store.state.creneauList
+        .filter((c) => c.benevoleIdList.indexOf(benevole.id) > -1)
+        .reduce((acc, creneau) => acc + creneau.durationH, 0);
+    },
+    createBenevole() {
+      const temp = Benevole.fromObject({ name: "Benevole i", surname: "" });
+      this.$store.commit(MutationTypes.addBenevole, temp);
+      this.currentBenevole = temp;
+    },
+    deleteBenevole(payload: Benevole) {
+      this.$store.commit(MutationTypes.removeBenevole, payload);
+      this.currentBenevole = undefined;
+    },
+    onRowClick(row: { id: number }) {
+      this.currentBenevole = this.$store.getters.getBenevoleById(row.id);
+    },
+  },
+  mounted() {
+    this.loading = false;
+  },
+});
 </script>
 
-<style scoped></style>
+<style scoped>
+.page {
+  display: flex;
+  margin: 0px 10%;
+  justify-content: center;
+}
+.data-table {
+  margin: 8px 16px;
+  width: calc(100% - 432px);
+  height: min-content;
+}
+</style>

+ 0 - 15
src/views/CompetenceManage.vue

@@ -1,15 +0,0 @@
-<template>
-  <div></div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from "vue";
-
-export default defineComponent({
-  setup() {
-    return {};
-  },
-});
-</script>
-
-<style scoped></style>

+ 124 - 0
src/views/CompetenceManager.vue

@@ -0,0 +1,124 @@
+<template>
+  <div class="page">
+    <data-table
+      class="data-table"
+      title="Liste des contraintes"
+      :data="data"
+      :clickable="true"
+      :loadingAnimation="loading"
+      locale="fr"
+      @row-click="onRowClick"
+    ></data-table>
+    <editeur-contrainte
+      :competence="currentCompetence"
+      @create="createCompetence"
+      @delete="deleteCompetence"
+    />
+  </div>
+</template>
+
+<script lang="ts">
+import Competence from "@/models/Competence";
+import EditeurContrainte from "@/components/EditeurCompetence.vue";
+import DataTable, { DataTableData, DataTableObject } from "@/components/DataTable.vue";
+import { defineComponent } from "vue";
+import { MutationTypes } from "@/store/Mutations";
+
+export default defineComponent({
+  name: "EditeurCreneau",
+  components: { "data-table": DataTable, EditeurContrainte },
+  data: () => ({
+    columns: [
+      {
+        label: "Id",
+        field: "id",
+        numeric: true,
+        html: false,
+        class: "fitwidth",
+      },
+      {
+        label: "Nom",
+        field: "name",
+        numeric: false,
+        html: false,
+        class: "overflow-cell",
+      },
+      {
+        label: "Description",
+        field: "overflowDescription",
+        numeric: false,
+        html: true,
+        class: "overflow-cell",
+      },
+      {
+        label: "Preference",
+        field: "renderPreference",
+        numeric: false,
+        html: true,
+        class: "fitwidth",
+        export: "isPreference",
+      },
+      {
+        label: "Apprenable",
+        field: "renderTeachable",
+        numeric: false,
+        html: true,
+        class: "fitwidth",
+        export: "isTeachable",
+      },
+    ],
+    currentCompetence: undefined as Competence | undefined,
+    loading: true,
+  }),
+  computed: {
+    competenceList(): Array<Competence> {
+      return this.$store.state.competenceList;
+    },
+    data(): DataTableData<DataTableObject> {
+      return {
+        items: this.competenceList.map((c) => {
+          return {
+            id: c.id,
+            name: c.name,
+            overflowDescription: c.overflowDescription,
+            renderPreference: c.renderPreference,
+            isPreference: c.isPreference.toString(),
+            renderTeachable: c.renderTeachable,
+            isTeachable: c.isTeachable.toString(),
+          };
+        }),
+        columns: this.columns,
+      };
+    },
+  },
+  methods: {
+    createCompetence() {
+      const comp = Competence.fromObject({ name: "Nouvelle contrainte", description: "" });
+      this.$store.commit(MutationTypes.addConstraint, comp);
+    },
+    deleteCompetence(payload: Competence) {
+      this.$store.commit(MutationTypes.removeConstraint, payload);
+      this.currentCompetence = undefined;
+    },
+    onRowClick(row: { id: number }) {
+      this.currentCompetence = this.$store.getters.getCompetenceById(row.id);
+    },
+  },
+  mounted() {
+    this.loading = false;
+  },
+});
+</script>
+
+<style scoped>
+.page {
+  display: flex;
+  margin: 0px 10%;
+  justify-content: center;
+}
+.data-table {
+  margin: 8px 16px;
+  width: calc(100% - 432px);
+  height: min-content;
+}
+</style>

+ 100 - 8
src/views/Home.vue

@@ -1,14 +1,106 @@
 <template>
-  <div class="home">
-    <img alt="Vue logo" src="../assets/logo.png" />
+  <div class="centered-box">
+    <div>
+      <h3>Bienvenue sur le gestionnaire des 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')"
+      />
+
+      <styled-input
+        label="Début"
+        :modelValue="evenement.start"
+        @input="dateListener($event, 'start')"
+      />
+
+      <styled-input label="Fin" :modelValue="evenement.end" @input="dateListener($event, 'end')" />
+
+      <div class="actions" style="text-align: center">
+        <button class="btn success" @click="exportStateToJson">
+          <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
+        </button>
+        <button class="btn primary small" @click="clickInput">
+          <i class="material-icons">upload</i>Import des données
+        </button>
+        <input
+          ref="input"
+          style="display: none"
+          type="file"
+          accept=".json,application/json"
+          @change="importJsonState"
+        />
+      </div>
+    </div>
   </div>
 </template>
 
 <script lang="ts">
-import { Options, Vue } from "vue-class-component";
-
-@Options({
-  components: {},
-})
-export default class Home extends Vue {}
+import Evenement from "@/models/Evenement";
+import { MutationTypes } from "@/store/Mutations";
+import dayjs from "dayjs";
+import { defineComponent } from "vue";
+import styledInput from "../components/input.vue";
+export default defineComponent({
+  components: { styledInput },
+  computed: {
+    evenement(): Evenement {
+      return this.$store.state.evenement;
+    },
+  },
+  methods: {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    dateListener(event: any, field: keyof Evenement) {
+      const new_date = dayjs(event.target.value);
+      if (new_date.isValid())
+        this.$store.commit(MutationTypes.editEvenement, { field: field, value: new_date.toDate() });
+    },
+    // 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);
+        }
+      }
+    },
+  },
+});
 </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>

+ 16 - 16
src/views/Planning.vue

@@ -31,8 +31,8 @@
         :slotduration="slotduration"
         :legendspan="legendspan"
         :slotwidth="slotwidth"
-        start="2021-04-04T22:00:00.000Z"
-        end="2021-04-06T21:59:00.000Z"
+        :start="start"
+        :end="end"
         @event-change="eventChangeHandler"
         @item-selected="selectionChangeHandler"
         @reorder-ressource="ressourceChangeHandler"
@@ -41,7 +41,6 @@
 
     <editeur-creneau
       v-if="currentCreneau"
-      class="editor"
       :creneau="currentCreneau"
       @create="createCreneau"
       @delete="deleteCreneau"
@@ -51,7 +50,6 @@
     <editeur-ligne
       v-else
       :creneauGroupId="currentCreneauGroupId"
-      class="editor"
       @create="createRessource"
       @delete="deleteRessource"
       @edit="updateCreneauGroup"
@@ -151,7 +149,7 @@ export default defineComponent({
     },
     duplicateCreneau(payload: Creneau) {
       const newCreneau = new Creneau({
-        ...payload.toPlainObject(),
+        ...payload.toJSON(),
         event: new jcEvent({ ...payload.event, id: uuidv4() }),
       });
       newCreneau.title = "Copy de " + newCreneau.title;
@@ -278,7 +276,6 @@ export default defineComponent({
   computed: {
     timeline(): Timeline {
       const output = this.$refs["timeline"];
-      console.log(output);
       return output as Timeline;
     },
     currentCreneauGroupId(): string {
@@ -293,6 +290,12 @@ export default defineComponent({
     creneauGroupList(): Array<Ressource> {
       return this.$store.state.creneauGroupList;
     },
+    start(): dayjs.Dayjs {
+      return this.$store.state.evenement.startingDate;
+    },
+    end(): dayjs.Dayjs {
+      return this.$store.state.evenement.endingDate;
+    },
   },
   watch: {},
   mounted() {
@@ -311,6 +314,7 @@ export default defineComponent({
     border-radius: 50%;
     font-size: 12px;
     font-weight: bold;
+    z-index:10;
     background: green;
 }
 .bubble.red{
@@ -319,6 +323,7 @@ export default defineComponent({
 .bubble.orange{
     background: orange;
 }`;
+    this.timeline.clearSelectedItems();
   },
 });
 </script>
@@ -326,27 +331,22 @@ export default defineComponent({
 <style lang="scss" scoped>
 .container {
   display: flex;
+  justify-content: center;
 }
 .timeline {
   margin: 0px 16px;
 }
 jc-timeline {
+  margin-top: 8px;
   max-width: 1000px;
   display: block;
 }
-
-.editor {
-  margin-top: 16px;
-  padding: 12px;
-  max-width: 400px;
-  box-shadow: 0 0 2px 2px var(--color-neutral-600);
-  height: 100%;
-}
 .actions {
   display: inline-flex;
+  justify-content: left;
+  margin-bottom: 12px;
 }
-
-.actions > button {
+.actions > .btn {
   margin-right: 4px;
 }
 </style>

+ 153 - 4
src/views/PlanningPersonnel.vue

@@ -1,15 +1,164 @@
 <template>
-  <div></div>
+  <div class="container">
+    <div class="planning-container">
+      <auto-complete-input
+        class="search-benevole"
+        id="benevole-selection-input"
+        label="Choisir un bénévole"
+        :autocompleteList="autocompleteData"
+        :strictAutocomplete="true"
+        v-model="benevoleId"
+      />
+      <div
+        class="daily-agenda"
+        v-for="(thisDayCreneauList, day) in personalCreneauListPerDay"
+        :key="day"
+      >
+        <div class="day-header">{{ day }}</div>
+        <div>
+          <creneau-viewer
+            v-for="creneau in thisDayCreneauList"
+            :key="creneau.id"
+            :creneau="creneau"
+            :current-benevole="currentBenevole"
+            @contact="onContact"
+          ></creneau-viewer>
+        </div>
+      </div>
+    </div>
+  </div>
 </template>
 
 <script lang="ts">
+import CreneauViewer from "../components/CreneauViewer.vue";
+import AutoCompleteInput from "../components/AutoCompleteInput.vue";
 import { defineComponent } from "vue";
+import Creneau from "@/models/Creneau";
+import Benevole from "@/models/Benevole";
+import AutocompleteValues from "@/models/AutocompleteOptions";
+import { Dictionary } from "node_modules/@types/lodash";
+import dayjs from "dayjs";
 
 export default defineComponent({
-  data() {
-    return {};
+  components: { CreneauViewer, AutoCompleteInput },
+  data: function () {
+    return {
+      currentBenevole: undefined as Benevole | undefined,
+      benevoleId: "",
+    };
+  },
+  watch: {
+    benevoleId(val) {
+      this.updateCurrentBenevole();
+    },
+  },
+  computed: {
+    creneauList(): Array<Creneau> {
+      return this.$store.state.creneauList;
+    },
+    benevoleList(): Array<Benevole> {
+      return this.$store.state.benevoleList;
+    },
+    autocompleteData(): Array<AutocompleteValues> {
+      return this.benevoleList.map((o) => {
+        return { id: o.id.toString(), name: o.fullname };
+      });
+    },
+    personalCreneauListPerDay(): Dictionary<Array<Creneau>> {
+      if (this.currentBenevole !== undefined) {
+        const benevole = this.currentBenevole;
+        return (
+          this.creneauList
+            // filter creneau for this benevole
+            .filter((o) => o.benevoleIdList.indexOf(benevole.id) > -1)
+            // sort by date
+            .sort((a, b) => a.start.getTime() - b.start.getTime())
+            // Groupe créneau by day
+            .reduce((acc: Dictionary<Array<Creneau>>, creneau: Creneau) => {
+              const day = dayjs(creneau.start).format("dddd D MMMM");
+              if (!(day in acc)) {
+                acc[day] = [];
+              }
+              acc[day].push(creneau);
+              return acc;
+            }, {})
+        );
+      } else {
+        return {};
+      }
+    },
+  },
+  methods: {
+    updateCurrentBenevole: function () {
+      const id = parseInt(this.benevoleId);
+      const bList = this.benevoleList.filter((b) => b.id == id);
+      if (bList.length > 0) {
+        this.currentBenevole = bList[0];
+      }
+    },
+    onContact: function (benevole: Benevole) {
+      this.$emit("contact", benevole);
+    },
   },
 });
 </script>
 
-<style scoped></style>
+<style scoped>
+.container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+}
+.planning-container {
+  max-width: 800px;
+  width: 100%;
+}
+.search-benevole {
+  padding: 12px;
+  margin: 0;
+  max-width: 350px;
+}
+@media (max-width: 600px) {
+  .planning-container {
+    width: 100%;
+  }
+}
+.daily-agenda {
+  border: solid 1px var(--color-neutral-200);
+}
+.day-header {
+  width: 100%;
+  background: var(--color-neutral-200);
+  color: var(--color-accent-800);
+  padding: 12px;
+}
+.contact-header {
+  background: MidnightBlue;
+  color: white;
+}
+
+.contact-header > i {
+  font-size: 20rem;
+  display: block;
+  text-align: center;
+}
+
+.contact-header > div {
+  text-align: center;
+  font-size: 3rem;
+  margin-top: -2.5rem;
+}
+
+.contact-detail > li {
+  display: flex;
+}
+
+.contact-detail > li > i {
+  width: 2rem;
+  font-size: 1.6rem;
+  display: inline-block;
+  text-align: center;
+  margin-right: 1rem;
+}
+</style>