浏览代码

first commit

tripeur 4 年之前
父节点
当前提交
92040ca5c7

文件差异内容过多而无法显示
+ 695 - 14
package-lock.json


+ 22 - 2
package.json

@@ -8,11 +8,19 @@
     "lint": "vue-cli-service lint"
   },
   "dependencies": {
+    "dayjs": "^1.10.4",
+    "jc-timeline": "git+http://gitlab.jaquin.fr/clovis/jc-timeline.git",
+    "postcss": "^8.2.13",
+    "uuid": "^8.3.2",
     "vue": "^3.0.0",
     "vue-class-component": "^8.0.0-0",
-    "vue-router": "^4.0.0-0"
+    "vue-router": "^4.0.0-0",
+    "vuex": "^4.0.0"
   },
   "devDependencies": {
+    "@types/lodash": "^4.14.168",
+    "@types/materialize-css": "^1.0.9",
+    "@types/uuid": "^8.3.0",
     "@typescript-eslint/eslint-plugin": "^4.18.0",
     "@typescript-eslint/parser": "^4.18.0",
     "@vue/cli-plugin-eslint": "~4.5.0",
@@ -25,7 +33,11 @@
     "eslint": "^6.7.2",
     "eslint-plugin-prettier": "^3.3.1",
     "eslint-plugin-vue": "^7.0.0",
+    "node-sass": "^5.0.0",
     "prettier": "^2.2.1",
+    "sass": "^1.32.8",
+    "sass-loader": "^10.1.1",
+    "tslib": "^2.2.0",
     "typescript": "~4.1.5"
   },
   "eslintConfig": {
@@ -43,7 +55,15 @@
     "parserOptions": {
       "ecmaVersion": 2020
     },
-    "rules": {}
+    "rules": {
+      "prettier/prettier": [
+        "error",
+        {
+          "endOfLine": "auto",
+          "printWidth": 100
+        }
+      ]
+    }
   },
   "browserslist": [
     "> 1%",

+ 28 - 26
src/App.vue

@@ -1,30 +1,32 @@
 <template>
-  <div id="nav">
-    <router-link to="/">Home</router-link> |
-    <router-link to="/about">About</router-link>
+  <div class="header">
+    <div class="logo"></div>
+    <h1 class="appname">BDLG planner</h1>
+    <nav class="tabs floating">
+      <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>
+    </nav>
+  </div>
+  <div class="container">
+    <router-view />
   </div>
-  <router-view />
 </template>
+<script lang="ts">
+import { defineComponent } from "vue";
+import toast from "./utils/Toast";
+import "@/assets/css/tabs.css";
+export default defineComponent({
+  mounted() {
+    const a = () => toast({ html: "test", inDuration: 500, outDuration: 500, displayLength: 2000 });
+    a();
+  },
+});
+</script>
 
-<style>
-#app {
-  font-family: Avenir, Helvetica, Arial, sans-serif;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-  text-align: center;
-  color: #2c3e50;
-}
-
-#nav {
-  padding: 30px;
-}
-
-#nav a {
-  font-weight: bold;
-  color: #2c3e50;
-}
-
-#nav a.router-link-exact-active {
-  color: #42b983;
-}
-</style>
+<style></style>

文件差异内容过多而无法显示
+ 0 - 0
src/assets/bdlg.min.svg


+ 240 - 0
src/assets/css/button.css

@@ -0,0 +1,240 @@
+.btn {
+  color: inherit;
+  border: 0;
+  cursor: pointer;
+  margin: 0;
+  display: -webkit-inline-box;
+  display: inline-flex;
+  outline: 0;
+  padding: calc(0.5rem - 1px) 1rem;
+  font-size: 0.875rem;
+  min-width: 2rem;
+  box-sizing: border-box;
+  min-height: 1.5rem;
+  text-align: center;
+  -webkit-box-align: center;
+          align-items: center;
+  font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
+  font-weight: 700;
+  line-height: 1.5rem;
+  -webkit-user-select: none;
+     -moz-user-select: none;
+      -ms-user-select: none;
+          user-select: none;
+  white-space: nowrap;
+  border-radius: 3px;
+  text-transform: none;
+  vertical-align: middle;
+  -moz-appearance: none;
+  -webkit-box-pack: center;
+          justify-content: center;
+  text-decoration: none;
+  background-color: transparent;
+  -webkit-appearance: none;
+  -webkit-tap-highlight-color: transparent;
+}
+.btn:disabled {
+  opacity: 0.5;
+  pointer-events: none;
+}
+.btn svg, .btn .material-icons {
+  fill: currentColor;
+  width: 1rem;
+  height: 1rem;
+  font-size: 1rem;
+  margin-left: calc(-0.125rem);
+  margin-right: 0.5rem;
+}
+.btn.xsmall {
+  padding: 2px 0.5rem;
+  font-size: 0.875rem;
+  min-width: 1rem;
+  line-height: 1rem;
+}
+.btn.small {
+  padding: calc(0.125rem + 1px) .7rem;
+  font-size: 0.875rem;
+  min-width: 2rem;
+  line-height: 1.5rem;
+}
+.btn.large {
+  padding: calc(0.75rem - 1px) 1.5rem;
+  font-size: 1rem;
+  min-width: 3rem;
+  line-height: 1.5rem;
+}
+.btn.large svg, .btn.large .material-icons {
+  width: 1.5rem;
+  height: 1.5rem;
+  font-size: 1.5rem;
+}
+.btn.xlarge {
+  padding: 0.75rem 2rem;
+  font-size: 1.125rem;
+  min-width: 4rem;
+  line-height: 1.875rem;
+}
+.btn.xlarge svg, .btn.xlarge .material-icons {
+  width: 1.5rem;
+  height: 1.5rem;
+  font-size: 1.5rem;
+}
+.btn.ghost {
+  color: var(--color-neutral-100);
+  border: 2px solid transparent;
+  background-color: rgba(0, 0, 0, 0);
+}
+.btn.ghost:hover {
+  background-color: #f0f4f6;
+}
+.btn.ghost:active {
+  background-color: #e2e9ed;
+}
+.btn.ghost::-moz-focus-inner {
+  border-style: none;
+}
+.btn.ghost:focus {
+  border: 2px solid var(--color-neutral-200);
+  outline: none;
+}
+.btn.primary {
+  color: #ffffff;
+  border: 1px solid transparent;
+  position: relative;
+  background-color: var(--color-neutral-100);
+}
+.btn.primary:hover {
+  background-color: var(--color-neutral-200);
+}
+.btn.primary:active {
+  background-color:var(--color-neutral-200);
+}
+.btn.primary::-moz-focus-inner {
+  border-style: none;
+}
+.btn.primary:focus {
+  outline: none;
+}
+.btn.primary:focus:after {
+  top: -4px;
+  left: -4px;
+  right: -4px;
+  border: 2px solid var(--color-neutral-200);
+  bottom: -4px;
+  content: "";
+  position: absolute;
+  border-radius: 6px;
+}
+.btn.secondary {
+  color: var(--color-neutral-100);
+  border: 1px solid transparent;
+  position: relative;
+  border-color: var(--color-neutral-100);
+  background-color: transparent;
+}
+.btn.secondary:hover {
+  background-color: #f0f4f6;
+}
+.btn.secondary:active {
+  background-color: #e2e9ed;
+}
+.btn.secondary::-moz-focus-inner {
+  border-style: none;
+}
+.btn.secondary:focus {
+  outline: none;
+}
+.btn.secondary:focus:after {
+  top: -4px;
+  left: -4px;
+  right: -4px;
+  border: 2px solid var(--color-neutral-200);
+  bottom: -4px;
+  content: "";
+  position: absolute;
+  border-radius: 6px;
+}
+.btn.success {
+  color: #ffffff;
+  border: 1px solid transparent;
+  position: relative;
+  background-color: #08875b;
+}
+.btn.success:hover {
+  background-color: #097350;
+}
+.btn.success:active {
+  background-color: #07593e;
+}
+.btn.success::-moz-focus-inner {
+  border-style: none;
+}
+.btn.success:focus {
+  outline: none;
+}
+.btn.success:focus:after {
+  top: -4px;
+  left: -4px;
+  right: -4px;
+  border: 2px solid #255fcc;
+  bottom: -4px;
+  content: "";
+  position: absolute;
+  border-radius: 6px;
+}
+.btn.warning {
+  color: #282e3a;
+  border: 1px solid transparent;
+  position: relative;
+  background-color: #fbca32;
+}
+.btn.warning:hover {
+  background-color: #fabd00;
+}
+.btn.warning:active {
+  background-color: #d6a100;
+}
+.btn.warning::-moz-focus-inner {
+  border-style: none;
+}
+.btn.warning:focus {
+  outline: none;
+}
+.btn.warning:focus:after {
+  top: -4px;
+  left: -4px;
+  right: -4px;
+  border: 2px solid #255fcc;
+  bottom: -4px;
+  content: "";
+  position: absolute;
+  border-radius: 6px;
+}
+.btn.error {
+  color: #ffffff;
+  border: 1px solid transparent;
+  position: relative;
+  background-color: #e4002b;
+}
+.btn.error:hover {
+  background-color: #b60022;
+}
+.btn.error:active {
+  background-color: #880019;
+}
+.btn.error::-moz-focus-inner {
+  border-style: none;
+}
+.btn.error:focus {
+  outline: none;
+}
+.btn.error:focus:after {
+  top: -4px;
+  left: -4px;
+  right: -4px;
+  border: 2px solid #255fcc;
+  bottom: -4px;
+  content: "";
+  position: absolute;
+  border-radius: 6px;
+}

+ 168 - 0
src/assets/css/main.css

@@ -0,0 +1,168 @@
+
+*,
+::before,
+::after {
+  box-sizing: border-box;
+}
+:root{
+    --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-accent-100:hsl(179, 52%, 15%);
+    --color-accent-200:hsl(179, 52%, 25%);
+    --color-accent-400:hsl(172, 58%, 40%);
+    --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-950:hsl(172, 96%, 98%);
+
+    --color-neutral-100:hsl(37, 5%, 15%);
+    --color-neutral-200:hsl(37, 5%, 25%);
+    --color-neutral-300:hsl(37, 5%, 35%);
+    --color-neutral-400:hsl(37, 5%, 45%);
+    --color-neutral-600:hsl(37, 5%, 60%);
+}
+
+body{
+    color: #282e3a;
+    margin: 0;
+    font-size: 1rem;
+    font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
+    background-color: #ffffff;
+}
+.header {
+    --bg-color:var(--color-neutral-100);
+    --color:var(--color-accent-600);
+    top: 0;
+    left: auto;
+    right: 0;
+    width: 100%;
+    display: -webkit-box;
+    display: flex;
+    padding: 0;
+    padding-right: 0px;
+    z-index: 1200;
+    position: -webkit-sticky;
+    position: sticky;
+    background-color: var(--bg-color);
+    color: var(--color);;
+    box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.2);
+    box-sizing: border-box;
+    min-height: 3.5rem;
+    overflow-x: hidden;
+    -webkit-box-align: center;
+    align-items: center;
+    flex-shrink: 0;
+    font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
+    padding-right: 1rem;
+  }
+  .header > :not(:first-child) {
+    margin-right: 0.5rem;
+}
+  .logo {
+    width: 32px;
+    height: 32px;
+    margin: 0 1rem 0 1rem;
+    flex-shrink: 0;
+    background: url(../bdlg.min.svg);
+    background-size: contain;
+    background-repeat: no-repeat;
+  }
+  .appname {
+    -webkit-box-flex: 1;
+    flex: 1;
+    color: inherit;
+    font-size: 1rem;
+    text-align: left;
+    border-left: 1px solid var(--color);;
+    font-weight: 400;
+    line-height: 1.5rem;
+    white-space: nowrap;
+    padding-left: 1rem;
+    padding-right: 1rem;
+  }
+  
+  @font-face {
+    font-family: 'Material Icons';
+    font-style: normal;
+    font-weight: 400;
+    src: url(https://fonts.gstatic.com/s/materialicons/v85/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2) format('woff2');
+  }
+  
+  .material-icons {
+    font-family: 'Material Icons';
+    font-weight: normal;
+    font-style: normal;
+    font-size: 24px;
+    line-height: 1;
+    letter-spacing: normal;
+    text-transform: none;
+    display: inline-block;
+    white-space: nowrap;
+    word-wrap: normal;
+    direction: ltr;
+    font-feature-settings: 'liga';
+    -moz-font-feature-settings: 'liga';
+    -moz-osx-font-smoothing: grayscale;
+  }  
+
+  
+.formcontrol {
+  all: initial;
+  display: block;
+  box-sizing: border-box;
+  font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
+}
+.formcontrol:not(:last-child) {
+  margin-bottom: 1rem;
+}
+.formcontrol-control {
+  width: 100%;
+}
+.formcontrol-label {
+  color: var(--color-neutral-200);
+  display: -webkit-box;
+  display: flex;
+  font-size: 0.9rem;
+  font-family: inherit;
+  font-weight: 500;
+  line-height: 1.5rem;
+  margin-bottom: 0.2rem;
+}
+.formcontrol-label > button {
+  margin-left: 0.25rem;
+}
+.formcontrol-optional {
+  -webkit-box-flex: 1;
+  flex: 1;
+  color: #647592;
+  font-size: 0.75rem;
+  margin-top: 0.25rem;
+  text-align: right;
+  line-height: 1.125rem;
+  margin-left: 0.25rem;
+}
+.formcontrol-help {
+  color: #647592;
+  display: -webkit-box;
+  display: flex;
+  font-size: 0.75rem;
+  margin-top: 0.5rem;
+  font-family: inherit;
+  line-height: 1.125rem;
+}
+.formcontrol-helplink {
+  -webkit-box-flex: 1;
+  flex: 1;
+  margin-top: calc(-0.25rem);
+  text-align: right;
+}
+.formcontrol--error .formcontrol-help {
+  color: #e4002b;
+}
+.formcontrol--success .formcontrol-help {
+  color: #08875b;
+}

+ 126 - 0
src/assets/css/multiple-select.css

@@ -0,0 +1,126 @@
+.input-control {
+display: inline-block;
+position: relative;
+}
+.select-multiple {
+display: block;
+width: 100%;
+position: relative;
+height: 2.5rem;
+}
+.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;
+  background-color: white;
+  }
+.select-multiple.select-multiple--expanded > .dropdown-options {
+  display: block;
+}
+.select-multiple.select-multiple--expanded > .dropdown-options > div {
+  line-height: 2rem;
+  font-size: .9rem;;
+  cursor: pointer;
+  -webkit-transition: 0.1s ease-in-out;
+  transition: 0.1s ease-in-out;
+  display: flex;
+  align-items: center;
+}
+
+.dropdown-options > div.select--checked {
+  font-weight: bold;
+  color: var(--color-neutral-100);
+  background-color: #f8f9fb;
+}
+.dropdown-options > div.select--active {
+background-color: #e0e8ec;
+}
+.dropdown-options > div:hover {
+background-color: #f0f4f6;  
+}
+.dropdown-options  > div:before {
+content: " ";
+background-color: white;
+background-size: 18px;
+height: 18px;
+width: 0px;
+border-radius: 4px;
+display: inline-block;
+margin: 8px;
+}
+.dropdown-options.pickable > div:before{
+width: 18px;
+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==");
+}
+.select-multiple > .select-multiple-value {
+all: initial;
+display: block;
+position:relative;
+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;
+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;
+padding-right: 2rem;
+-webkit-transition-property: box-shadow, border;
+transition-property: box-shadow, border;
+}
+.select-multiple.select-multiple--expanded > .select-multiple-value::after,
+.select-multiple > .select-multiple-value::after{
+  content: "expand_less";
+  font-family: 'Material Icons';
+  font-weight: normal;
+  font-style: normal;
+  font-size: 1.5rem;
+  line-height: 1;
+  letter-spacing: normal;
+  text-transform: none;
+  display: inline-block;
+  color: var(--color-accent-400);
+  position: absolute;
+  top:4px;
+  right:4px;
+}
+.select-multiple > .select-multiple-value::after{
+content: "expand_more";
+}
+.select-multiple > .select-multiple-value:hover {
+cursor: pointer;
+background-color: var(--color-accent-850);
+}
+
+.select-multiple-input {
+border: 0;
+background: transparent;
+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);
+border-color: transparent;
+background-color: var(--color-accent-950);
+}
+.select-multiple-input:focus,
+.select-multiple-input:focus-visible {
+outline: 0;
+}

+ 122 - 0
src/assets/css/tabs.css

@@ -0,0 +1,122 @@
+.tabs {
+    margin: 0;
+    display: inline-block;
+    padding: 0;
+    box-shadow: 0px 2px 0px 0px var(--color, var(--color-primary-200));
+    box-sizing: border-box;
+    font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
+    white-space: nowrap;
+    margin-bottom: 1px;
+    list-style-type: none;
+  }
+  .tabs.floating {
+    align-self: stretch;
+    box-shadow: none;
+    margin-bottom: 0;
+  }
+  .tab {
+    color: var(--color,var(--color-primary-200));
+    border: 2px solid transparent;
+    cursor: pointer;
+    height: 100%;
+    margin: 0;
+    display: -webkit-inline-box;
+    display: inline-flex;
+    outline: 0;
+    padding: calc(0.5rem - 1px) 1rem;
+    font-size: 0.875rem;
+    min-width: 2rem;
+    box-sizing: border-box;
+    min-height: 1.5rem;
+    text-align: center;
+    -webkit-box-align: center;
+            align-items: center;
+    font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
+    font-weight: 500;
+    line-height: 1.5rem;
+    -webkit-user-select: none;
+       -moz-user-select: none;
+        -ms-user-select: none;
+            user-select: none;
+    white-space: nowrap;
+    border-radius: 3px;
+    text-transform: none;
+    vertical-align: middle;
+    -moz-appearance: none;
+    -webkit-box-pack: center;
+            justify-content: center;
+    text-decoration: none;
+    background-color: rgba(0, 0, 0, 0);
+    -webkit-appearance: none;
+    border-bottom-left-radius: 0;
+    border-bottom-right-radius: 0;
+    -webkit-tap-highlight-color: transparent;
+  }
+  .tab:disabled {
+    opacity: 0.5;
+    pointer-events: none;
+  }
+  .tab svg, .tab .material-icons {
+    fill: currentColor;
+    width: 1rem;
+    height: 1rem;
+    font-size: 1rem;
+    margin-left: calc(-0.125rem);
+    margin-right: 0.5rem;
+  }
+  .tab:hover {
+    color:var(--bg-color,white);
+    background-color: var(--color,var(--color-primary-200));
+  }
+  .tab:active {
+    background-color: #e2e9ed;
+  }
+  .tab::-moz-focus-inner {
+    border-style: none;
+  }
+  .tab:focus {
+    border: 2px solid var(--color,--color-primary-200);
+    outline: none;
+  }
+  .tab.large {
+    padding: calc(0.75rem - 1px) 1.5rem;
+    font-size: 1rem;
+    min-width: 3rem;
+    line-height: 1.5rem;
+  }
+  .tab.large svg, .tab.large .material-icons {
+    width: 1.5rem;
+    height: 1.5rem;
+    font-size: 1.5rem;
+  }
+  .tab.xlarge {
+    padding: 0.75rem 2rem;
+    font-size: 1.125rem;
+    min-width: 4rem;
+    line-height: 1.875rem;
+  }
+  .tab.xlarge svg, .tab.xlarge .material-icons {
+    width: 1.5rem;
+    height: 1.5rem;
+    font-size: 1.5rem;
+  }
+  .tab.selected {
+    position: relative;
+    font-weight: 700;
+  }
+  .tab.selected:after {
+    left: 1rem;
+    right: 1rem;
+    border: 3px solid var(--color,var(--color-primary-200));
+    bottom: -2px;
+    content: "";
+    position: absolute;
+    border-radius: 3px 3px 0 0;
+  }
+  .tab.selected:hover:after{
+    border: 3px solid var(--bg-color,white);
+  }
+  .tab.disabled {
+    opacity: 0.5;
+    pointer-events: none;
+  }

+ 163 - 0
src/components/AutoCompleteInput.vue

@@ -0,0 +1,163 @@
+<template>
+  <div class="formcontrol">
+    <label class="formcontrol-label" for="" v-if="optional || label"
+      >{{ label }}
+      <div class="formcontrol-optional" v-if="optional">Optional</div></label
+    >
+    <div
+      class="select-multiple"
+      :class="{ 'select-multiple--expanded': showAutocomplete }"
+      @click="openDropDown"
+    >
+      <div class="select-multiple-value">
+        <input
+          ref="input"
+          class="select-multiple-input"
+          :placeholder="placeholder"
+          :size="Math.max(value.length, placeholder.length)"
+          v-model="value"
+          @keyup="onKeyup"
+        />
+      </div>
+      <div class="dropdown-options" tabindex="0">
+        <div
+          v-for="(v, i) in displayedAutocomplete"
+          :class="{ 'select--active': i == activeIndex }"
+          @click="toggle(v, $event)"
+          :key="v.id"
+        >
+          <span v-html="highlight(v.name)"></span>
+        </div>
+        <div v-if="filtredAutocomplete.length > displayedAutocomplete.length">...</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import AutocompleteValues from "@/models/AutocompleteOptions";
+import { defineComponent, PropType } from "vue";
+import toast from "@/utils/Toast";
+export default defineComponent({
+  props: {
+    id: String,
+    label: String,
+    modelValue: {
+      type: String,
+    },
+    autocompleteList: {
+      type: Array as PropType<Array<AutocompleteValues>>,
+      default: () => [],
+    },
+    placeholder: { type: String, default: () => "" },
+    optional: Boolean,
+    limit: {
+      type: Number,
+      default: () => Infinity,
+    },
+    strictAutocomplete: {
+      type: Boolean,
+      default: () => false,
+    },
+  },
+  data: function () {
+    return {
+      value: "",
+      activeIndex: -1,
+      expanded: false,
+    };
+  },
+  watch: {
+    modelValue(v) {
+      this.value = this.autocompleteList.find((o) => o.id == v)?.name || "";
+    },
+    value(value) {
+      if (!this.strictAutocomplete) this.$emit("update:modelValue", value);
+    },
+  },
+  computed: {
+    filtredAutocomplete(): Array<AutocompleteValues> {
+      const lower = this.value.toLowerCase();
+      return this.autocompleteList.filter((o) => o.name.toLowerCase().includes(lower));
+    },
+    displayedAutocomplete(): Array<AutocompleteValues> {
+      return this.filtredAutocomplete.slice(0, this.limit);
+    },
+    showAutocomplete(): boolean {
+      return this.expanded && this.filtredAutocomplete.length > 0;
+    },
+    input(): HTMLInputElement {
+      return this.$refs["input"] as HTMLInputElement;
+    },
+  },
+  methods: {
+    openDropDown: function (e: MouseEvent) {
+      this.expanded = true;
+      this.input.focus();
+      window.addEventListener("click", this.closeDropDown);
+      e.stopPropagation();
+    },
+    closeDropDown: function (e: MouseEvent) {
+      if (!this.$el.contains(e.target)) {
+        this.expanded = false;
+        window.removeEventListener("click", this.closeDropDown);
+        e.stopPropagation();
+      }
+    },
+    toggle(v: AutocompleteValues, e: MouseEvent) {
+      e.stopPropagation();
+      this.expanded = false;
+      this.value = v.name;
+      this.$emit("update:modelValue", v.id);
+    },
+    onKeyup: function (e: KeyboardEvent): void {
+      if (e.key === "Enter") {
+        if (this.activeIndex > -1 && this.activeIndex < this.displayedAutocomplete.length) {
+          const v = this.displayedAutocomplete[this.activeIndex];
+          this.value = v.name;
+          this.$emit("update:modelValue", v.id);
+          this.expanded = false;
+        } else {
+          if (this.value === "") {
+            this.$emit("update:modelValue", "");
+            this.expanded = false;
+          } else if (this.strictAutocomplete) {
+            toast({
+              html: "Vous ne pouvez ajouter que des valeurs prédéfinies",
+              classes: "red",
+            });
+          }
+        }
+      }
+      if (e.key === "ArrowUp") {
+        this.activeIndex--;
+      }
+      if (e.key === "ArrowDown") {
+        this.activeIndex++;
+      }
+      if (this.activeIndex < -1) {
+        this.activeIndex = this.displayedAutocomplete.length - 1;
+      }
+      if (this.activeIndex >= this.displayedAutocomplete.length) {
+        this.activeIndex = 0;
+      }
+    },
+    highlight: function (txt: string) {
+      if (this.value) {
+        const reg = new RegExp(this.value, "ig");
+        const highlightedText = txt.match(reg);
+        if (highlightedText) {
+          const plaintext = txt.split(reg);
+          return (
+            highlightedText.map((t, i) => plaintext[i] + `<b class="highlight">${t}</b>`).join("") +
+            plaintext.pop()
+          );
+        }
+      }
+      return txt;
+    },
+  },
+});
+</script>
+
+<style src="../assets/css/multiple-select.css"></style>

+ 226 - 0
src/components/EditeurCreneau.vue

@@ -0,0 +1,226 @@
+<template>
+  <div>
+    <h3 class="center-align">Modifier un créneau</h3>
+    <div class="row center-align">
+      <a class="btn-small" v-on:click="emitCreationOrder"
+        ><i class="material-icons right">create</i>Nouveau</a
+      >
+      <a
+        class="btn-small"
+        :class="{ disabled: creneau === undefined }"
+        v-on:click="emitDuplicateOrder"
+        ><i class="material-icons right">content_copy</i>Dupliquer</a
+      >
+      <a
+        class="btn-small red"
+        :class="{ disabled: creneau === null }"
+        v-on:click="emitDeleteOrder"
+        style="margin-top: 4px"
+        ><i class="material-icons right">delete_forever</i>Supprimer</a
+      >
+    </div>
+    <div class="center-align" v-if="creneau === undefined">Veuillez selectioner un creneau.</div>
+    <form v-else>
+      <div class="input-field col s12">
+        <input
+          id="last_name"
+          type="text"
+          class="validate"
+          value="creneau.title"
+          @input="inputListener('title')"
+        />
+        <label for="last_name">Titre</label>
+      </div>
+
+      <div class="input-field col s6">
+        <input id="creneauDate" type="text" class="datepicker" v-model.lazy="jour" />
+        <label for="creneauDate">Date</label>
+      </div>
+      <div class="input-field col s3">
+        <input id="creneauHeure" type="text" class="timepicker" v-model.lazy="heure" />
+        <label for="creneauHeure">Heure</label>
+      </div>
+      <div class="input-field col s3">
+        <input id="creneauDuree" type="number" class="validate" v-model="duree" />
+        <label for="creneauDuree">Duree (min)</label>
+      </div>
+      <div class="input-field col s6">
+        <input
+          id="penibility"
+          type="number"
+          class="validate"
+          :value="creneau.penibility"
+          @input="inputListener('penibility')"
+        />
+        <label for="penibility">Pénébilité</label>
+      </div>
+      <div class="input-field col s3">
+        <input disabled id="disabled" type="text" class="validate" v-model="endHour" />
+        <label for="disabled">Heure fin</label>
+      </div>
+      <div class="input-field col s6">
+        <input
+          id="minAttendee"
+          type="number"
+          class="validate"
+          :value="creneau.minAttendee"
+          @input="inputListener('minAttendee')"
+        />
+        <label for="minAttendee">Bénévole minimum</label>
+      </div>
+      <div class="input-field col s6">
+        <input
+          id="maxAttendee"
+          type="number"
+          class="validate"
+          :value="creneau.maxAttendee"
+          @input="inputListener('maxAttendee')"
+        />
+        <label for="maxAttendee">Bénévole maximum (opt)</label>
+      </div>
+      <div class="input-field col s12">
+        <textarea
+          id="description"
+          type="text"
+          class="materialize-textarea"
+          v-model="creneau.description"
+        ></textarea>
+        <label for="description">Description</label>
+      </div>
+      <div class="col s12">
+        <chips-input
+          title="Compétences & préférences associées"
+          id="compétence_selection"
+          place-holder="Choisir une condition"
+          secondary-placeholder="+ compétence"
+          :autocomplete-list="autocompleteCompetencesList"
+          :strict-autocomplete="true"
+          :value="creneau.competencesIdList"
+          @input="inputListener('competencesIdList')"
+        ></chips-input>
+      </div>
+      <div class="col s12">
+        <chips-input
+          title="Bénévoles"
+          id="bénevole_selection"
+          place-holder="Choisir un bénévole"
+          secondary-placeholder="+ bénévole"
+          :autocomplete-list="autocompleteBenevolesList"
+          :strict-autocomplete="true"
+          :value="creneau.benevoleIdList"
+          @input="inputListener('benevoleIdList')"
+        ></chips-input>
+      </div>
+    </form>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import Creneau from "@/models/Creneau";
+import { MutationTypes } from "@/store/Mutations";
+import AutocompleteOptions from "@/models/AutocompleteOptions";
+
+export default defineComponent({
+  name: "EditeurCreneau",
+  props: {
+    creneauId: {
+      type: String,
+    },
+  },
+  data: function () {
+    return {
+      jour: "",
+      heure: "",
+      duree: "",
+    };
+  },
+  watch: {
+    creneauId() {
+      this.updateForm();
+    },
+    jour: function () {
+      this.updateDates();
+    },
+    heure: function () {
+      this.updateDates();
+    },
+    duree: function () {
+      this.updateDates();
+    },
+  },
+  computed: {
+    duration(): number {
+      return parseFloat(this.duree) ?? 0;
+    },
+    formStartTime(): number {
+      const input = this.jour + "T" + this.heure;
+      return new Date(input).getTime();
+    },
+    endStartTime(): number {
+      return this.formStartTime + this.duration * 60 * 1000;
+    },
+    endHour(): string {
+      return new Date(this.endStartTime).toLocaleTimeString().substring(0, 5);
+    },
+    validDuree(): boolean {
+      return isNaN(parseInt(this.duree));
+    },
+    creneau(): Creneau | undefined {
+      return this.$store.getters.getCreneauById(this.creneauId || "");
+    },
+    autocompleteBenevolesList(): Array<AutocompleteOptions> {
+      return this.$store.state.benevoleList.map((benevole) => {
+        return { id: benevole.id + "", name: benevole.fullname, img: "benevole" };
+      });
+    },
+    autocompleteCompetencesList(): Array<AutocompleteOptions> {
+      return this.$store.state.competenceList.map((competence) => {
+        return { id: competence.id + "", name: competence.fullname };
+      });
+    },
+  },
+  methods: {
+    updateDates: function (): void {
+      if (this.creneauId) {
+        this.updateCreneau("start", new Date(this.formStartTime));
+        this.updateCreneau("end", new Date(this.endStartTime));
+      }
+    },
+    updateCreneau<K extends keyof Creneau>(field: K, value: Creneau[K]) {
+      if (this.creneauId)
+        this.$store.commit(MutationTypes.editCreneau, {
+          id: this.creneauId,
+          field: field,
+          value: value,
+        });
+    },
+    inputListener(field: keyof Creneau): (e: InputEvent) => void {
+      return (e: InputEvent) => {
+        this.updateCreneau(field, (e.target as HTMLInputElement).value);
+      };
+    },
+    updateForm: function () {
+      if (this.creneau) {
+        const startDate = this.creneau.event.start;
+        this.jour = startDate.toISOString().substr(0, 10);
+        this.heure = startDate.toTimeString().substr(0, 5);
+        this.duree = Math.round(
+          (this.creneau.event.end.getTime() - this.creneau.event.start.getTime()) / 1000 / 60
+        ).toString();
+      }
+    },
+    emitDuplicateOrder: function () {
+      this.$emit("duplicate", this.creneau);
+    },
+    emitCreationOrder: function () {
+      this.$emit("create");
+    },
+    emitDeleteOrder: function () {
+      this.$emit("delete", this.creneau);
+    },
+  },
+});
+</script>
+
+<style scoped></style>

+ 139 - 0
src/components/EditeurCreneauGroup.vue

@@ -0,0 +1,139 @@
+<template>
+  <div>
+    <h3 class="center-align">Modifier une ligne</h3>
+    <div class="button-holder center-align">
+      <button class="btn small primary" v-on:click="emitCreationOrder">
+        <i class="material-icons right">create</i>Créer 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
+      </button>
+    </div>
+
+    <form v-if="creneauGroup">
+      <styled-input
+        label="Identifiant"
+        id="row_id"
+        type="text"
+        class="validate"
+        :modelValue="creneauGroup.id"
+        :disabled="true"
+      >
+      </styled-input>
+      <styled-input
+        label="Nom de la ligne"
+        id="groupName"
+        type="text"
+        class="validate"
+        placeholder="Entrer un nom"
+        :required="true"
+        :modelValue="creneauGroup.title"
+        @input="updateLabel"
+      >
+      </styled-input>
+      <auto-complete-input
+        id="selectParent"
+        label="Parent"
+        icon="low_priority"
+        :autocomplete-list="potentialParent"
+        :validateInput="(s) => s.length - 1"
+        v-model="parentId"
+        :strictAutocomplete="true"
+        placeholder="Selectionner un parent"
+      >
+      </auto-complete-input>
+    </form>
+    <div class="center-align" v-else>Veuillez selectioner un ligne de creneau.</div>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import { Ressource } from "jc-timeline/lib/Ressource";
+import { MutationTypes } from "@/store/Mutations";
+import AutoCompleteInput from "./AutoCompleteInput.vue";
+import styledInput from "./input.vue";
+import AutocompleteOptions from "@/models/AutocompleteOptions";
+
+export default defineComponent({
+  name: "EditeurCreneauGroup",
+  components: { AutoCompleteInput, styledInput },
+  data: () => {
+    return {
+      parentId: "",
+    };
+  },
+  props: {
+    creneauGroupId: String,
+  },
+  watch: {
+    creneauGroupId(): void {
+      if (this.creneauGroup) {
+        this.parentId = this.creneauGroup.parentId;
+      }
+    },
+    parentId(id: string) {
+      const parent = this.$store.getters.getCreneauGroupById(id);
+      this.updateCreneau("parent", parent);
+    },
+  },
+  computed: {
+    creneauGroup(): Ressource | undefined {
+      if (this.creneauGroupId) return this.$store.getters.getCreneauGroupById(this.creneauGroupId);
+      return undefined;
+    },
+    potentialParent(): Array<AutocompleteOptions> {
+      if (this.creneauGroup) {
+        const ressource = this.creneauGroup;
+        return this.$store.state.creneauGroupList
+          .filter((o) => !o.descendantOf(ressource) && o !== ressource)
+          .map((o) => {
+            return { id: o.id, name: o.title };
+          });
+      }
+      return [];
+    },
+  },
+  methods: {
+    updateCreneau<K extends keyof Ressource>(field: K, value: Ressource[K]) {
+      if (this.creneauGroupId)
+        this.$emit("edit", {
+          id: this.creneauGroupId,
+          field: field,
+          value: value,
+        });
+    },
+    updateLabel(e: InputEvent): void {
+      const value = (e.target as HTMLInputElement).value;
+      this.updateCreneau("title", value);
+    },
+    emitCreationOrder(): void {
+      this.$emit("create");
+    },
+    emitDeleteOrder(): void {
+      this.$emit("delete");
+    },
+  },
+});
+</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>

+ 0 - 126
src/components/HelloWorld.vue

@@ -1,126 +0,0 @@
-<template>
-  <div class="hello">
-    <h1>{{ msg }}</h1>
-    <p>
-      For a guide and recipes on how to configure / customize this project,<br />
-      check out the
-      <a href="https://cli.vuejs.org" target="_blank" rel="noopener"
-        >vue-cli documentation</a
-      >.
-    </p>
-    <h3>Installed CLI Plugins</h3>
-    <ul>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router"
-          target="_blank"
-          rel="noopener"
-          >router</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
-          target="_blank"
-          rel="noopener"
-          >eslint</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
-          target="_blank"
-          rel="noopener"
-          >typescript</a
-        >
-      </li>
-    </ul>
-    <h3>Essential Links</h3>
-    <ul>
-      <li>
-        <a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
-      </li>
-      <li>
-        <a href="https://forum.vuejs.org" target="_blank" rel="noopener"
-          >Forum</a
-        >
-      </li>
-      <li>
-        <a href="https://chat.vuejs.org" target="_blank" rel="noopener"
-          >Community Chat</a
-        >
-      </li>
-      <li>
-        <a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
-          >Twitter</a
-        >
-      </li>
-      <li>
-        <a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
-      </li>
-    </ul>
-    <h3>Ecosystem</h3>
-    <ul>
-      <li>
-        <a href="https://router.vuejs.org" target="_blank" rel="noopener"
-          >vue-router</a
-        >
-      </li>
-      <li>
-        <a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/vue-devtools#vue-devtools"
-          target="_blank"
-          rel="noopener"
-          >vue-devtools</a
-        >
-      </li>
-      <li>
-        <a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
-          >vue-loader</a
-        >
-      </li>
-      <li>
-        <a
-          href="https://github.com/vuejs/awesome-vue"
-          target="_blank"
-          rel="noopener"
-          >awesome-vue</a
-        >
-      </li>
-    </ul>
-  </div>
-</template>
-
-<script lang="ts">
-import { Options, Vue } from "vue-class-component";
-
-@Options({
-  props: {
-    msg: String,
-  },
-})
-export default class HelloWorld extends Vue {
-  msg!: string;
-}
-</script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped>
-h3 {
-  margin: 40px 0 0;
-}
-ul {
-  list-style-type: none;
-  padding: 0;
-}
-li {
-  display: inline-block;
-  margin: 0 10px;
-}
-a {
-  color: #42b983;
-}
-</style>

+ 206 - 0
src/components/SelectChipInput.vue

@@ -0,0 +1,206 @@
+<template>
+  <div 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>
+      <div v-if="checkedItems.length > maxItemDisplayed" class="ds-chip">
+        <div class="ds-chip-label">+{{ checkedItems.length - maxItemDisplayed }}</div>
+      </div>
+      <input
+        class="select-multiple-input"
+        ref="input"
+        :placeholder="placeholder"
+        v-model="inputValue"
+        :size="Math.max(inputValue.length, placeholder.length)"
+        @keyup="keyUp"
+      />
+    </div>
+    <div class="dropdown-options">
+      <div
+        v-for="(item, index) in filteredItems"
+        :class="{
+          'select--checked': item.isChecked,
+          'select--active': index == activeIndex,
+        }"
+        :key="item.id"
+        @click="toggle(item.id, $event)"
+        v-html="highlight(item.text)"
+      ></div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from "vue";
+interface selectItem {
+  id: number;
+  isChecked: boolean;
+  value: any;
+  text: string;
+}
+interface selectOption {
+  value: any;
+  text: string;
+}
+export default defineComponent({
+  name: "selectChip",
+  props: {
+    // Values selected by the component
+    valueModel: {
+      type: Array as PropType<Array<string>>,
+      default: () => [],
+    },
+    /*
+     * Potential values that could be selected by the user
+     * type Array<String> | Array<Option>
+     * Option {
+     *   value:Any,
+     *   text:String ,
+     * }
+     */
+    options: {
+      type: Array as PropType<Array<string | selectOption>>,
+      default: () => [],
+    },
+    placeholder: {
+      type: String,
+      default: "Choose among the list",
+    },
+    // Number of selected item displayed before displaying an ellipsis appear
+    maxItemDisplayed: {
+      type: Number,
+      default: Infinity,
+    },
+  },
+  data() {
+    return {
+      items: [] as Array<selectItem>,
+      expanded: false,
+      inputValue: "",
+      activeIndex: -1,
+    };
+  },
+  watch: {
+    valueModel: function () {
+      this.updateItems();
+    },
+  },
+  emit: ["update:valueModel"],
+  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.input.focus();
+      e.stopPropagation();
+    },
+    updateItems(): void {
+      this.items = this.options.map((o: selectOption | string, idx: number) => {
+        let itemValue: any, text: string;
+        if (typeof o == "object") {
+          itemValue = "value" in o ? o.value : o;
+          text = "text" in o ? o.text : itemValue;
+        } else {
+          itemValue = o;
+          text = o;
+        }
+        return {
+          id: idx,
+          isChecked: this.valueModel.includes(itemValue),
+          value: itemValue,
+          text: text,
+        };
+      });
+    },
+    openDropDown: function (e: MouseEvent) {
+      this.expanded = true;
+      this.input.focus();
+      window.addEventListener("click", this.closeDropDown);
+      e.stopPropagation();
+    },
+    closeDropDown: function (e: MouseEvent) {
+      if (!this.$el.contains(e.target)) {
+        this.expanded = false;
+        window.removeEventListener("click", this.closeDropDown);
+        e.stopPropagation();
+      }
+    },
+    highlight(txt: string): string {
+      if (this.inputValue) {
+        let reg = new RegExp(this.inputValue, "ig");
+        let boldTxt = txt.match(reg);
+        if (boldTxt !== null) {
+          const plain = txt.split(reg);
+          let output = "";
+          for (let i = 0; i < boldTxt.length; i++) {
+            output += plain[i] + "<b>" + boldTxt[i] + "</b>";
+          }
+          return output + plain.pop();
+        }
+      }
+      return txt;
+    },
+    keyUp(e: KeyboardEvent) {
+      if (e.key == "Enter") {
+        if (this.selectedItem) this.selectedItem.isChecked = !this.selectedItem.isChecked;
+      }
+      if (e.key == "ArrowUp") {
+        this.activeIndex--;
+      }
+      if (e.key == "ArrowDown") {
+        this.activeIndex++;
+      }
+      this.boundActiveIndex();
+      /*
+      if(e.key=="Backspace" && this.inputValue==""){
+        // Remove the last entry
+        const lastItem  = this.checkedItems[this.checkedItems.length-1]
+        if (lastItem){
+          lastItem.isChecked = false
+        }
+      }
+      */
+    },
+    boundActiveIndex() {
+      if (this.activeIndex < 0) {
+        this.activeIndex = this.filteredItems.length;
+      }
+      if (this.activeIndex > this.filteredItems.length) {
+        this.activeIndex = 0;
+      }
+    },
+  },
+  computed: {
+    checkedItems(): Array<selectItem> {
+      return this.items.filter((i) => i.isChecked);
+    },
+    displayedItems(): Array<selectItem> {
+      return this.checkedItems.filter((i, idx) => idx < this.maxItemDisplayed);
+    },
+    input(): HTMLInputElement {
+      return this.$refs["input"] as HTMLInputElement;
+    },
+    filteredItems(): Array<selectItem> {
+      if (this.inputValue) {
+        const low = this.inputValue.toLowerCase();
+        const output = this.items.filter((o) => o.text.toLowerCase().includes(low));
+        return output;
+      }
+      return this.items;
+    },
+    selectedItem(): selectItem | undefined {
+      return this.filteredItems[this.activeIndex];
+    },
+  },
+  beforeMount: function () {
+    this.updateItems();
+  },
+});
+</script>
+
+<style src="../assets/css/multiple-select.css"></style>

+ 197 - 0
src/components/date-picker.vue

@@ -0,0 +1,197 @@
+<template>
+  <div class="ds-formcontrol ds-date-input-control">
+    <div class="ds-formcontrol-label">{{ title }}</div>
+    <input
+      class="ds-date-input browser-default"
+      type="text"
+      placeholder="yyyy/mm/dd"
+      v-model="textValue"
+      @click="openPicker"
+      @focus="openPicker"
+    />
+    <div
+      class="ds-datepicker-container"
+      :class="[{ show: isPickerOpen }, position]"
+    >
+      <div class="ds-datepicker">
+        <div class="ds-datepicker-prev" @click="prevMonth" tabindex="-1"></div>
+        <div class="ds-datepicker-current">{{ pickerTitle }}</div>
+        <div class="ds-datepicker-next" @click="nextMonth" tabindex="-1"></div>
+        <div class="ds-datepicker-weekday">
+          <div v-for="(d, i) in weekDays" :key="i">{{ d }}</div>
+        </div>
+        <div
+          v-for="(d, i) in currentDays"
+          @click="selectDay(i)"
+          tabindex="-1"
+          :key="i"
+          :class="{
+            'ds-disable': d.isBefore(min) || d.isAfter(max),
+            'ds-out': !d.isSame(currentMonth, 'month'),
+            'ds-selected': d.isSame(valueObject, 'date'),
+            'ds-today': d.isSame(today, 'date'),
+          }"
+        >
+          {{ d.format("D") }}
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import dayjs, { Dayjs } from "dayjs";
+// eslint-disable-next-line
+const dateValidator = (d: any) => dayjs(d).isValid();
+export default defineComponent({
+  name: "DatePicker",
+  emit: ["input"],
+  props: {
+    title: {
+      type: String,
+      required: false,
+      default: () => "Select date",
+    },
+    value: {
+      type: [Number, Object, String, Date],
+      required: false,
+      default: () => "",
+    },
+    min: {
+      type: [Number, Object, String, Date],
+      required: false,
+      default: () => {
+        return dayjs().year(0);
+      },
+      validator: dateValidator,
+    },
+    max: {
+      type: [Number, Object, String, Date],
+      required: false,
+      default: () => {
+        return dayjs().year(9999);
+      },
+      validator: dateValidator,
+    },
+    lang: {
+      type: String,
+      required: false,
+      default: "en",
+    },
+    position: {
+      type: String,
+      required: false,
+      default: "",
+    },
+  },
+  data: function () {
+    const now = dayjs().startOf("day");
+    return {
+      today: now,
+      currentMonth: now.startOf("month"),
+      isPickerOpen: false,
+      valueObject: null as Dayjs | null,
+      textValue: "",
+      weekDays: "SMTWTFS".split(""),
+      textRegex: /^(?<year>\d{4})\/(?<month>(?:0[1-9])|(?:1[0-2]))\/(?<day>(?:0[1-9])|(?:[12][0-9])|(?:3[01]))$/,
+    };
+  },
+  computed: {
+    pickerTitle: function (): string {
+      return this.currentMonth.format("MMMM YYYY");
+    },
+    currentDays: function (): Array<Dayjs> {
+      const firstDay = this.currentMonth.startOf("month").startOf("week");
+      const output = [];
+      for (let i = 0; i < 7 * 6; i++) {
+        output.push(firstDay.add(i, "day"));
+      }
+      return output;
+    },
+  },
+  watch: {
+    value: function (val) {
+      if (["object", "string", "number"].includes(typeof val)) {
+        this.valueObject = dayjs(val);
+      }
+    },
+    textValue: function (val, oldVal) {
+      const l = val.length;
+      if (l > 10 || !/^[0-9/]*$/.test(val)) {
+        this.textValue = val === "Invalid Date" ? "" : val.slice(0, 10);
+      }
+      if (l < oldVal.length) {
+        this.textValue =
+          oldVal.charAt(oldVal.length - 1) === "/" ? val.slice(0, -1) : val;
+      } else if ([4, 7].includes(l)) {
+        this.textValue = val + "/";
+      }
+      if (/^\d{4}/.test(val)) {
+        this.currentMonth = this.currentMonth.year(parseInt(val.slice(0, 4)));
+      }
+      const re = /\/(0?[0-9]|(?:1[0-2]))\//.exec(val);
+      if (re) {
+        this.currentMonth = this.currentMonth.month(parseInt(re[1]) - 1);
+      }
+      if (this.textRegex.test(val)) {
+        this.valueObject = dayjs(val, "YYYY/M/D");
+      }
+    },
+    valueObject: function (v) {
+      if (v) {
+        this.textValue = v.format("YYYY/MM/DD");
+        this.$emit("input", this.textValue);
+      } else {
+        this.textValue = "";
+      }
+    },
+  },
+  methods: {
+    prevMonth: function (): void {
+      this.currentMonth = this.currentMonth.add(-1, "month");
+    },
+    nextMonth: function (): void {
+      this.currentMonth = this.currentMonth.add(1, "month");
+    },
+    selectDay: function (i: number): void {
+      const d = this.currentDays[i];
+      if (d.isAfter(this.min) && d.isBefore(this.max)) {
+        this.valueObject = dayjs(d);
+      }
+    },
+    openPicker: function () {
+      if (!this.isPickerOpen) {
+        this.isPickerOpen = true;
+        window.addEventListener("focusin", this.loseFocusListener);
+        window.addEventListener("click", this.loseFocusListener);
+      }
+    },
+    loseFocusListener: function (e: Event) {
+      if (
+        !this.$el.contains(e.target) ||
+        (e.target as HTMLElement).classList.contains("ds-date-input-title")
+      ) {
+        window.removeEventListener("focusin", this.loseFocusListener);
+        window.removeEventListener("click", this.loseFocusListener);
+        this.isPickerOpen = false;
+        const d = dayjs(this.textValue, "YYYY/MM/DD");
+        if (d.isValid()) {
+          this.valueObject = d;
+          this.textValue = d.format("YYYY/MM/DD");
+        } else {
+          this.$emit("input", "");
+        }
+      }
+    },
+  },
+  mounted: function () {
+    if (this.value) {
+      if (["object", "string", "number"].includes(typeof this.value)) {
+        this.valueObject = dayjs(this.value);
+      }
+    }
+  },
+});
+</script>
+<style src="../ds-date-picker/datepicker.css"></style>

+ 250 - 0
src/components/input.vue

@@ -0,0 +1,250 @@
+<template>
+  <div class="formcontrol" :class="controlClass">
+    <label class="formcontrol-label" v-if="optional || label">
+      {{ label }}
+      <div class="formcontrol-optional" v-if="optional">Optional</div>
+    </label>
+    <div class="formcontrol-control">
+      <input
+        class="input"
+        :type="type"
+        :placeholder="placeholder"
+        v-model="value"
+        :required="required"
+        :disabled="disabled"
+      />
+    </div>
+    <div class="formcontrol-help" v-if="helpText || helpLinkHref">
+      {{ helpText }}}
+      <div class="formcontrol-helplink">
+        <a
+          class="ds-link ds-link--underlined ds-link--xsmall"
+          :href="helpLinkHref"
+          @click="onHelpLinkClick"
+        >
+          {{ helpLinkLabel }}
+        </a>
+      </div>
+    </div>
+  </div>
+</template>
+<script lang="ts">
+import { defineComponent, PropType } from "vue";
+export default defineComponent({
+  name: "dsInput",
+  data() {
+    return {
+      value: "",
+    };
+  },
+  props: {
+    id: String,
+    label: String,
+    modelValue: String,
+    placeholder: { type: String, default: "" },
+    helpText: String,
+    helpLinkHref: String,
+    helpLinkLabel: {
+      type: String,
+      default: () => "Help link",
+    },
+    onHelpLinkClick: { type: Function as PropType<(e: MouseEvent) => void> },
+    required: Boolean,
+    type: {
+      type: String,
+      default: () => "text",
+    },
+    optional: Boolean,
+    validateInput: {
+      type: Function as PropType<(e: string) => number>,
+      default: () => 0,
+    },
+    disabled: { type: Boolean, default: false },
+  },
+  emits: ["update:modelValue"],
+  computed: {
+    inputScore: function (): number {
+      return this.validateInput(this.value);
+    },
+    controlClass: function (): string {
+      if (this.inputScore < 0) {
+        return "formcontrol--error";
+      }
+      if (this.inputScore > 0) {
+        return "formcontrol--success";
+      }
+      return "";
+    },
+    inputClass: function (): string {
+      if (this.inputScore < 0) {
+        return "input--invalid";
+      }
+      if (this.inputScore > 0) {
+        return "input--valid";
+      }
+      return "";
+    },
+  },
+  watch: {
+    modelValue: function (val: string): void {
+      this.value = val;
+    },
+    value(new_val, old_val) {
+      if (new_val != old_val) this.$emit("update:modelValue", new_val);
+    },
+  },
+  methods: {},
+  mounted(): void {
+    this.value = this.modelValue || "";
+  },
+});
+</script>
+<style scoped>
+.input {
+  all: initial;
+  color: var(--color-neutral-100);
+  width: 100%;
+  border: none;
+  height: 2rem;
+  margin: 0;
+  outline: 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-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;
+  background-color: var(--color-accent-950);
+  -webkit-transition-property: box-shadow, border;
+  transition-property: box-shadow, border;
+}
+.input::-webkit-input-placeholder {
+  color: #505d74;
+}
+.input:-ms-input-placeholder {
+  color: #505d74;
+}
+.input::-ms-input-placeholder {
+  color: #505d74;
+}
+.input::placeholder {
+  color: #505d74;
+}
+.input:hover {
+  background-color: var(--color-accent-850);
+}
+.input:focus {
+  box-shadow: 0 0 0 2px var(--color-accent-800);
+  border-color: transparent;
+  background-color: var(--color-accent-950);
+}
+.input:disabled {
+  color: #c1c7d3 !important;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  border-color: transparent;
+  pointer-events: none;
+}
+.input:disabled::-webkit-input-placeholder {
+  color: #c1c7d3;
+}
+.input:disabled:-ms-input-placeholder {
+  color: #c1c7d3;
+}
+.input:disabled::-ms-input-placeholder {
+  color: #c1c7d3;
+}
+.input:disabled::placeholder {
+  color: #c1c7d3;
+}
+.input--small {
+  height: 2rem;
+  font-size: 0.875rem;
+  line-height: 1.125rem;
+  padding-top: 0.25rem;
+  padding-bottom: 0.25rem;
+}
+.input--adorned-start {
+  padding-left: 2rem;
+}
+.input--adorned-end {
+  padding-right: 2rem;
+}
+.input--valid {
+  box-shadow: 0 -1px 0 0 #08875b inset !important;
+}
+.input--valid:focus {
+  box-shadow: 0 0 0 2px #08875b !important;
+}
+.input--invalid {
+  box-shadow: 0 -1px 0 0 #e93255 inset !important;
+}
+.input--invalid:focus {
+  box-shadow: 0 0 0 2px #e93255 !important;
+}
+
+.ds-link {
+  all: initial;
+  color: #063b9e;
+  border: 2px solid transparent;
+  cursor: pointer;
+  display: -webkit-inline-box;
+  display: inline-flex;
+  outline: 0;
+  padding: calc(0.125rem + 1px) 0.5rem;
+  position: relative;
+  font-size: 1rem;
+  box-sizing: border-box;
+  font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
+  font-weight: 400;
+  line-height: 1rem;
+  text-decoration: none;
+}
+.ds-link:hover {
+  color: #002466;
+  font-weight: 500;
+}
+.ds-link:focus {
+  border: 2px solid #255fcc;
+  outline: none;
+  text-decoration: underline;
+}
+.ds-link:active {
+  color: #00205b;
+}
+.ds-link:visited {
+  color: #b746a6;
+}
+.ds-link::-moz-focus-inner {
+  border-style: none;
+}
+.ds-link--xsmall {
+  padding: calc(0.125rem + 1px) 0.25rem;
+  font-size: 0.75rem;
+  line-height: 1rem;
+}
+.ds-link--small {
+  padding: calc(0.125rem + 1px) 0.5rem;
+  font-size: 0.875rem;
+  line-height: 1rem;
+}
+.ds-link--large {
+  padding: calc(0.25rem - 1px) 0.5rem;
+  font-size: 1.125rem;
+  line-height: 1.5rem;
+}
+.ds-link--underlined {
+  text-decoration: underline;
+}
+.ds-link--disabled {
+  color: #a5c0f2 !important;
+  pointer-events: none;
+}
+</style>

+ 4 - 1
src/main.ts

@@ -1,5 +1,8 @@
 import { createApp } from "vue";
 import App from "./App.vue";
 import router from "./router";
+import { store } from "./store/Store";
+import "@/assets/css/main.css";
+import "@/assets/css/button.css";
 
-createApp(App).use(router).mount("#app");
+createApp(App).use(router).use(store).mount("#app");

+ 5 - 0
src/models/AutocompleteOptions.ts

@@ -0,0 +1,5 @@
+export default interface AutocompleteValues {
+  id: string;
+  name: string;
+  img?: string;
+}

+ 138 - 0
src/models/Benevole.ts

@@ -0,0 +1,138 @@
+interface IBenevole {
+  id: number;
+  name: string;
+  surname?: string;
+  phone?: string;
+  email?: string;
+  comment?: string;
+  competenceIdList?: number[];
+  creneauIdList?: string[];
+}
+
+export default class Benevole {
+  static maxId = 0;
+  id: number;
+  name: string;
+  surname: string;
+  phone: string;
+  email: string;
+  comment: string;
+  competenceIdList: Array<number>;
+  creneauIdList: Array<string>;
+  constructor(
+    id: number,
+    name: string,
+    surname: string,
+    phone: string,
+    email: string,
+    comment: string,
+    competenceIdList: number[],
+    creneauIdList: string[]
+  ) {
+    // Change the current max id
+    if (!isNaN(id)) Benevole.maxId = id > Benevole.maxId ? id : Benevole.maxId;
+    this.id = id;
+    this.name = name;
+    this.surname = surname;
+    this.phone = phone;
+    this.email = email;
+    this.comment = comment;
+    this.competenceIdList = competenceIdList;
+    this.creneauIdList = creneauIdList;
+  }
+  get formatPhone(): string {
+    const cleaned = ("" + this.phone).replace(/\D/g, "");
+
+    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;
+  }
+  get shortame(): string {
+    return this.name + " (" + this.fanfare + ")";
+  }
+  get fanfare(): string {
+    const defaultValue = "Exte";
+    if (this.competenceIdList.length == 0) {
+      return defaultValue;
+    } else {
+      const output = this.fanfareList.join(",");
+      return output == "" ? defaultValue : output;
+    }
+  }
+  get imgPlaceholder(): string {
+    return (
+      "holder.js/32x32?theme=social&text=" +
+      this.name.charAt(0).toUpperCase() +
+      this.surname.charAt(0).toUpperCase()
+    );
+  }
+  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)) {
+      this.maxId += 1;
+      id = this.maxId;
+    } else {
+      id = obj.id;
+      this.maxId = obj.id > this.maxId ? obj.id : this.maxId;
+    }
+    return new Benevole(
+      id,
+      obj.name,
+      obj.surname ? obj.surname : "",
+      obj.phone ? obj.phone : "",
+      obj.email ? obj.email : "",
+      obj.comment ? obj.comment : "",
+      obj.competenceIdList ? obj.competenceIdList : [],
+      obj.creneauIdList ? obj.creneauIdList : []
+    );
+  }
+
+  toPlainObject() {
+    return {
+      id: this.id,
+      name: this.name,
+      surname: this.surname,
+      phone: this.phone,
+      email: this.email,
+      comment: this.comment,
+      competenceIdList: this.competenceIdList,
+      creneauIdList: this.creneauIdList,
+    };
+  }
+}

+ 66 - 0
src/models/Competence.ts

@@ -0,0 +1,66 @@
+interface ICompetence {
+  id: number;
+  name: string;
+  description: string;
+  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) {
+    // Change the current max id
+    if (!isNaN(id)) Competence.maxId = id > Competence.maxId ? id : Competence.maxId;
+
+    this.id = id;
+    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>";
+  }
+  get renderTeachable(): string {
+    const icon = this.isTeachable ? "check_box" : "check_box_outline_blank";
+    return '<i class="material-icons">' + icon + "</i>";
+  }
+  get overflowDescription(): string {
+    return this.description;
+  }
+  get fullname(): string {
+    return this.name + (this.isPreference ? " (P)" : " (C)");
+  }
+  static fromObject(obj: ICompetence): Competence {
+    let id: number;
+    if (isNaN(obj.id)) {
+      this.maxId += 1;
+      id = this.maxId;
+    } else {
+      id = obj.id;
+      this.maxId = obj.id > this.maxId ? obj.id : this.maxId;
+    }
+
+    return new Competence(
+      id,
+      obj.name,
+      obj.description ? obj.description : "",
+      obj.isPreference ? obj.isPreference : false
+    );
+  }
+
+  toPlainObject(): ICompetence {
+    return {
+      id: this.id,
+      name: this.name,
+      description: this.description,
+      isPreference: this.isPreference,
+      isTeachable: this.isTeachable,
+    };
+  }
+}

+ 93 - 0
src/models/Creneau.ts

@@ -0,0 +1,93 @@
+import dayjs from "dayjs";
+import { isString } from "lodash";
+import { Event } from "jc-timeline";
+
+export interface ICreneau {
+  event: Event;
+  penibility: number;
+  minAttendee: number;
+  maxAttendee: number;
+  benevoleIdList: Array<number>;
+  competencesIdList: Array<number>;
+  description: string;
+}
+class Creneau implements ICreneau {
+  event: Event;
+  penibility: number;
+  minAttendee: number;
+  maxAttendee: number;
+  benevoleIdList: number[];
+  competencesIdList: number[];
+  description: string;
+
+  constructor(obj: ICreneau) {
+    if (!isString(obj.event.id) || obj.event.id == "") {
+      throw new TypeError(
+        "missing argument 0 (event.id:String) when calling function Creneau.constructor"
+      );
+    }
+    if (!isString(obj.event.ressourceId) || obj.event.ressourceId == "") {
+      throw new TypeError(
+        "missing argument 0 (event.ressourceId:String) when calling function Creneau.constructor"
+      );
+    }
+    this.event = obj.event;
+    this.description = "description" in obj ? obj.description : "";
+    this.penibility = "penibility" in obj ? obj.penibility : 12;
+    this.minAttendee = "minAttendee" in obj ? obj.minAttendee : 0;
+    this.maxAttendee = "maxAttendee" in obj ? obj.maxAttendee : 0;
+    this.benevoleIdList = "benevoleIdList" in obj ? obj.benevoleIdList : [];
+    this.competencesIdList = "competencesIdList" in obj ? obj.competencesIdList : [];
+  }
+  get id(): string {
+    return this.event.id;
+  }
+  get ressourceId(): string {
+    return this.event.ressourceId;
+  }
+  set ressourceId(value: string) {
+    this.event.ressourceId = value;
+  }
+  get title(): string {
+    return this.event.title;
+  }
+  set title(value: string) {
+    this.event.title = value;
+  }
+  set start(value: Date) {
+    this.event.start = value;
+  }
+  set end(value: Date) {
+    this.event.end = value;
+  }
+  get durationMs(): number {
+    return this.event.end.getTime() - this.event.start.getTime();
+  }
+  get durationMin(): number {
+    return this.durationMs / 1000 / 60;
+  }
+  get durationH(): number {
+    return this.durationMin / 60;
+  }
+  get horaire(): string {
+    return (
+      "De " +
+      dayjs(this.event.start).format("HH:mm") +
+      " à " +
+      dayjs(this.event.end).format("HH:mm")
+    );
+  }
+  toPlainObject(): ICreneau {
+    return {
+      event: this.event,
+      penibility: this.penibility,
+      minAttendee: this.minAttendee,
+      maxAttendee: this.maxAttendee,
+      benevoleIdList: this.benevoleIdList,
+      competencesIdList: this.competencesIdList,
+      description: this.description,
+    };
+  }
+}
+
+export default Creneau;

+ 4 - 7
src/router/index.ts

@@ -1,5 +1,6 @@
 import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
 import Home from "../views/Home.vue";
+import Planning from "@/views/Planning.vue";
 
 const routes: Array<RouteRecordRaw> = [
   {
@@ -8,13 +9,9 @@ const routes: Array<RouteRecordRaw> = [
     component: Home,
   },
   {
-    path: "/about",
-    name: "About",
-    // route level code-splitting
-    // this generates a separate chunk (about.[hash].js) for this route
-    // which is lazy-loaded when the route is visited.
-    component: () =>
-      import(/* webpackChunkName: "about" */ "../views/About.vue"),
+    path: "/evenement",
+    name: "Planning",
+    component: Planning,
   },
 ];
 

+ 25 - 0
src/store/Actions.ts

@@ -0,0 +1,25 @@
+import { ActionTree, ActionContext } from "vuex";
+import { State } from "./state";
+import { Mutations, MutationTypes } from "./Mutations";
+import Creneau, { ICreneau } from "@/models/Creneau";
+
+export enum ActionTypes {
+  NEW_CRENEAU = "NEW_CRENEAU",
+}
+
+type AugmentedActionContext = {
+  commit<K extends keyof Mutations>(
+    key: K,
+    payload: Parameters<Mutations[K]>[1]
+  ): ReturnType<Mutations[K]>;
+} & Omit<ActionContext<State, State>, "commit">;
+
+export interface Actions {
+  [ActionTypes.NEW_CRENEAU]({ commit }: AugmentedActionContext, payload: ICreneau): void;
+}
+
+export const actions: ActionTree<State, State> & Actions = {
+  [ActionTypes.NEW_CRENEAU]({ commit }, payload) {
+    commit(MutationTypes.addCreneau, new Creneau(payload));
+  },
+};

+ 28 - 0
src/store/Getters.ts

@@ -0,0 +1,28 @@
+import Benevole from "@/models/Benevole";
+import Competence from "@/models/Competence";
+import Creneau from "@/models/Creneau";
+import { Ressource } from "jc-timeline";
+import { GetterTree } from "vuex";
+import { State } 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;
+};
+
+export const getters: GetterTree<State, State> & Getters = {
+  getCreneauById: (state) => (id: string) => {
+    return state.creneauList.find((o) => o.id == id);
+  },
+  getCreneauGroupById: (state) => (id: string) => {
+    return state.creneauGroupList.find((o) => o.id == id);
+  },
+  getCompetenceById: (state) => (id: number) => {
+    return state.competenceList.find((o) => o.id == id);
+  },
+  getBenevoleById: (state) => (id: number) => {
+    return state.benevoleList.find((o) => o.id == id);
+  },
+};

+ 92 - 0
src/store/Mutations.ts

@@ -0,0 +1,92 @@
+import Creneau from "@/models/Creneau";
+import { Ressource } from "jc-timeline";
+import { MutationTree } from "vuex";
+import { State } from "./state";
+
+export enum MutationTypes {
+  addCreneau = "addCreneau",
+  removeCreneau = "removeCreneau",
+  editCreneau = "editCreneau",
+  addBenevole2Creneau = "addBenevole2Creneau",
+  removeBenevole2Creneau = "removeBenevole2Creneau",
+
+  addCreneauGroup = "addCreneauGroup",
+  removeCreneauGroup = "removeCreneauGroup",
+  editCreneauGroup = "editCreneauGroup",
+  reorderCreneauGroup = "reorderCreneauGroup",
+}
+interface CreneauPairing {
+  creneauId: string;
+  benevoleId: number;
+}
+
+export type Mutations<S = State> = {
+  [MutationTypes.addCreneau](state: S, payload: Creneau): void;
+  [MutationTypes.removeCreneau](state: S, payload: Creneau): void;
+  [MutationTypes.editCreneau]<K extends keyof Creneau>(
+    state: S,
+    payload: { id: string; field: K; value: Creneau[K] }
+  ): boolean;
+
+  [MutationTypes.addCreneauGroup](state: S, payload: Ressource): void;
+  [MutationTypes.removeCreneauGroup](state: S, payload: Ressource): void;
+  [MutationTypes.editCreneauGroup]<K extends keyof Ressource>(
+    state: S,
+    payload: { id: string; field: K; value: Ressource[K] }
+  ): boolean;
+  [MutationTypes.reorderCreneauGroup](state: S, payload: Array<Ressource>): boolean;
+
+  [MutationTypes.addBenevole2Creneau](state: S, payload: CreneauPairing): void;
+  [MutationTypes.removeBenevole2Creneau](state: S, payload: CreneauPairing): void;
+};
+export const mutations: MutationTree<State> & Mutations = {
+  [MutationTypes.addCreneau](state, creneau) {
+    state.creneauList = [...state.creneauList, creneau];
+  },
+  [MutationTypes.removeCreneau](state, creneau) {
+    state.creneauList = state.creneauList.filter((c) => c.id !== creneau.id);
+  },
+  [MutationTypes.editCreneau](state, payload) {
+    const el = state.creneauList.find((o) => o.id == payload.id);
+    if (el) {
+      el[payload.field] = payload.value;
+      return true;
+    }
+    return false;
+  },
+  [MutationTypes.reorderCreneauGroup](state, payload) {
+    state.creneauGroupList = payload;
+    return true;
+  },
+  [MutationTypes.addCreneauGroup](state, payload) {
+    state.creneauGroupList = [...state.creneauGroupList, payload];
+  },
+  [MutationTypes.removeCreneauGroup](state, payload) {
+    state.creneauGroupList = state.creneauGroupList.filter((c) => c.id !== payload.id);
+  },
+  [MutationTypes.editCreneauGroup](state, payload) {
+    const el = state.creneauGroupList.find((o) => o.id == payload.id);
+    if (el) {
+      el[payload.field] = payload.value;
+      return true;
+    }
+    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);
+    }
+  },
+  [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);
+  },
+};

+ 13 - 0
src/store/State.ts

@@ -0,0 +1,13 @@
+import { Ressource } from "jc-timeline";
+import Benevole from "../models/Benevole";
+import Competence from "../models/Competence";
+import Creneau from "../models/Creneau";
+
+export const state = {
+  eventId: "" as string,
+  creneauList: [] as Array<Creneau>,
+  creneauGroupList: [] as Array<Ressource>,
+  competenceList: [] as Array<Competence>,
+  benevoleList: [] as Array<Benevole>,
+};
+export type State = typeof state;

+ 30 - 0
src/store/Store.ts

@@ -0,0 +1,30 @@
+import { createStore, Store as VuexStore, CommitOptions, DispatchOptions } from "vuex";
+import { State, state } from "./State";
+import { Getters, getters } from "./Getters";
+import { Mutations, mutations } from "./Mutations";
+import { Actions, actions } from "./Actions";
+
+export const store = createStore({
+  state,
+  getters,
+  mutations,
+  actions,
+});
+
+export type Store = Omit<VuexStore<State>, "getters" | "commit" | "dispatch"> & {
+  commit<K extends keyof Mutations, P extends Parameters<Mutations[K]>[1]>(
+    key: K,
+    payload: P,
+    options?: CommitOptions
+  ): ReturnType<Mutations[K]>;
+} & {
+  dispatch<K extends keyof Actions>(
+    key: K,
+    payload: Parameters<Actions[K]>[1],
+    options?: DispatchOptions
+  ): ReturnType<Actions[K]>;
+} & {
+  getters: {
+    [K in keyof Getters]: ReturnType<Getters[K]>;
+  };
+};

+ 8 - 0
src/typing/vuex.d.ts

@@ -0,0 +1,8 @@
+import { Store } from "@/store/Store";
+import { ComponentCustomProperties } from "vue";
+
+declare module "@vue/runtime-core" {
+  interface ComponentCustomProperties {
+    $store: Store;
+  }
+}

+ 155 - 0
src/utils/Toast.ts

@@ -0,0 +1,155 @@
+import "./toast.css";
+interface ToastOptions {
+  html: string;
+  displayLength: number;
+  inDuration: number;
+  outDuration: number;
+  classes: string;
+  completeCallback: (toast: Toast) => void;
+  activationPercent: number;
+}
+const defaultOptions: ToastOptions = {
+  html: "",
+  displayLength: 4000,
+  inDuration: 300,
+  outDuration: 375,
+  classes: "",
+  completeCallback: (toast: Toast) => {
+    toast.update();
+  },
+  activationPercent: 0.7,
+};
+class Toast {
+  static _container: HTMLElement | null = null;
+  static _toasts: Array<Toast> = [];
+  element: HTMLElement;
+  options: ToastOptions;
+  isMoving: boolean;
+  startTime: number;
+  startingPosX: number;
+
+  get activationDistance() {
+    return this.element.offsetWidth * this.options.activationPercent;
+  }
+  constructor(options: Partial<ToastOptions>) {
+    this.options = { ...defaultOptions, ...options };
+    if (Toast._container == null) {
+      Toast._createContainer();
+    }
+
+    this.startTime = Date.now();
+    this.startingPosX = 0;
+    this.isMoving = false;
+
+    this.element = document.createElement("div");
+    this.element.className = "toast " + this.options.classes;
+    this.element.innerHTML = this.options.html;
+
+    const onDragStart = (e: MouseEvent | TouchEvent) => {
+      e.stopPropagation();
+      this.startingPosX = this._getposX(e);
+      this.element.style.transition = "all 0s";
+      this.isMoving = true;
+      document.addEventListener("touchmove", onDragMove);
+      document.addEventListener("touchend", onDragEnd);
+      document.addEventListener("mousemove", onDragMove);
+      document.addEventListener("mouseup", onDragEnd);
+    };
+    const onDragMove = (e: MouseEvent | TouchEvent) => {
+      e.stopPropagation();
+      const totalDeltaX = this._getposX(e) - this.startingPosX;
+      this.element.style.transform = `translateX(${totalDeltaX}px)`;
+      this.element.style.opacity = "" + (1 - Math.abs(totalDeltaX / this.activationDistance));
+    };
+    const onDragEnd = (e: MouseEvent | TouchEvent) => {
+      const totalDeltaX = this.startingPosX - this._getposX(e);
+      this.isMoving = false;
+
+      // Remove toast
+      if (Math.abs(totalDeltaX) > this.activationDistance) {
+        this.dismiss();
+
+        // Animate toast back to original position
+      } else {
+        this.element.style.transition = "transform .2s, opacity .2s";
+        this.element.style.transform = "";
+        this.element.style.opacity = "";
+      }
+
+      document.removeEventListener("touchmove", onDragMove);
+      document.removeEventListener("touchend", onDragEnd);
+      document.removeEventListener("mousemove", onDragMove);
+      document.removeEventListener("mouseup", onDragEnd);
+    };
+
+    this.element.addEventListener("touchstart", onDragStart);
+    this.element.addEventListener("mousedown", onDragStart);
+
+    this.element.style.top = `30px`;
+    this.element.style.opacity = `0`;
+    this.element.style.transition = `top ${this.options.inDuration}ms, opacity ${this.options.inDuration}ms`;
+
+    if (this.options.displayLength < Infinity) {
+      this.update();
+    }
+    Toast._container?.appendChild(this.element);
+    setTimeout(() => {
+      this.element.style.top = `0px`;
+      this.element.style.opacity = `1`;
+    }, 10);
+    Toast._toasts.push(this);
+  }
+  update() {
+    const ellapsed = Date.now() - this.startTime;
+    if (this.options && ellapsed < this.options.displayLength) {
+      this.element.style.setProperty(
+        "--completion",
+        Math.round((ellapsed / this.options.displayLength) * 100) + "%"
+      );
+      setTimeout(() => this.update(), 200);
+    } else {
+      this.dismiss();
+    }
+  }
+  _getposX(e: MouseEvent | TouchEvent) {
+    if (e instanceof MouseEvent) {
+      return e.clientX;
+    } else {
+      return e.touches[0].clientX;
+    }
+  }
+  dismiss() {
+    this.element.style.transition = `all ${this.options.outDuration}ms`;
+    this.element.style.marginTop = `-${this.element.offsetHeight}px`;
+    this.element.style.opacity = "0";
+    setTimeout(() => {
+      Toast._container?.removeChild(this.element);
+      Toast._toasts = Toast._toasts.filter((e) => e !== this);
+      if (Toast._toasts.length === 0) {
+        Toast._removeContainer();
+      }
+      this.options.completeCallback(this);
+    }, this.options.outDuration);
+  }
+  static dismissAll() {
+    for (const toastIndex in Toast._toasts) {
+      Toast._toasts[toastIndex].dismiss();
+    }
+  }
+  static _createContainer() {
+    const container = document.createElement("div");
+    container.setAttribute("id", "toast-container");
+    document.body.appendChild(container);
+    Toast._container = container;
+  }
+  static _removeContainer() {
+    if (Toast._container) {
+      document.body.removeChild(Toast._container);
+      Toast._container = null;
+    }
+  }
+}
+
+export default function (options: Partial<ToastOptions>): Toast {
+  return new Toast(options);
+}

+ 63 - 0
src/utils/toast.css

@@ -0,0 +1,63 @@
+#toast-container {
+    display:block;
+    position:fixed;
+    z-index:10000
+}
+@media only screen and (max-width: 600px) {
+    #toast-container {
+    min-width:100%;
+    bottom:0%
+    }
+}
+@media only screen and (min-width: 601px) and (max-width: 992px) {
+#toast-container {
+    left:5%;
+    bottom:7%;
+    max-width:90%
+    }
+}
+@media only screen and (min-width: 993px) {
+    #toast-container {
+    top:10%;
+    right:7%;
+    max-width:86%
+    }
+}
+.toast {
+    position: relative;
+    border-radius:2px;
+    width:auto;
+    max-width:100%;
+    height:auto;
+    min-height:48px;
+    margin-top:10px;
+    padding:10px 25px;
+    position:relative;
+    font-size:1.1rem;
+    font-weight:300;
+    line-height:1.5em;
+    color:#fff;
+    background-color:#323232;
+    display:-webkit-box;
+    display:-webkit-flex;
+    display:-ms-flexbox;
+    display:flex;
+    -webkit-box-align:center;
+    -webkit-align-items:center;
+    -ms-flex-align:center;
+    align-items:center;
+    -webkit-box-pack:justify;
+    -webkit-justify-content:space-between;
+    -ms-flex-pack:justify;
+    justify-content:space-between;
+    cursor:default
+}
+.toast::after{
+    content:'';
+    position: absolute;
+    bottom:0px;
+    left:0px;
+    width: var(--completion);
+    height:3px;
+    background-color: rgb(160, 140, 179);
+}

+ 0 - 5
src/views/About.vue

@@ -1,5 +0,0 @@
-<template>
-  <div class="about">
-    <h1>This is an about page</h1>
-  </div>
-</template>

+ 11 - 0
src/views/BenevoleManager.vue

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

+ 15 - 0
src/views/CompetenceManage.vue

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

+ 1 - 5
src/views/Home.vue

@@ -1,18 +1,14 @@
 <template>
   <div class="home">
     <img alt="Vue logo" src="../assets/logo.png" />
-    <HelloWorld msg="Welcome to Your Vue.js + TypeScript App" />
   </div>
 </template>
 
 <script lang="ts">
 import { Options, Vue } from "vue-class-component";
-import HelloWorld from "@/components/HelloWorld.vue"; // @ is an alias to /src
 
 @Options({
-  components: {
-    HelloWorld,
-  },
+  components: {},
 })
 export default class Home extends Vue {}
 </script>

+ 201 - 0
src/views/Planning.vue

@@ -0,0 +1,201 @@
+<template>
+  <div class="container">
+    <div class="timeline">
+      <div class="timelime-control">
+        <h1>Planning global de l'événement</h1>
+        <button class="btn primary"><i class="material-icons">upload_file</i></button>
+        <button class="btn primary"><i class="material-icons">save</i></button>
+      </div>
+      <jc-timeline
+        ref="timeline"
+        @event-change="eventChangeHandler"
+        @item-selected="selectionChangeHandler"
+        @reorder-ressource="ressourceChangeHandler"
+      ></jc-timeline>
+    </div>
+
+    <editeur-creneau
+      v-if="currentCreneau"
+      class="editor"
+      :creneauId="currentCreneau.id"
+    ></editeur-creneau>
+    <editeur-ligne
+      v-else
+      :creneauGroupId="currentCreneauGroupId"
+      class="editor"
+      @create="createRessource"
+      @delete="deleteRessource"
+      @edit="updateCreneauGroup"
+    ></editeur-ligne>
+  </div>
+</template>
+
+<script lang="ts">
+import { v4 as uuidv4 } from "uuid";
+import { defineComponent } from "vue";
+import Timeline from "jc-timeline/lib/Timeline";
+import { Event as jcEvent } from "jc-timeline/lib/Event";
+import "jc-timeline";
+import EditeurCreneau from "@/components/EditeurCreneau.vue";
+import EditeurLigne from "@/components/EditeurCreneauGroup.vue";
+import { Ressource } from "jc-timeline";
+
+import Creneau from "@/models/Creneau";
+import { MutationTypes } from "@/store/Mutations";
+import Selectable from "node_modules/jc-timeline/lib/utils/selectable";
+
+type changePayload = {
+  items: Array<jcEvent>;
+};
+
+export default defineComponent({
+  name: "Planning",
+  components: {
+    EditeurCreneau,
+    EditeurLigne,
+  },
+  data() {
+    return {
+      startDate: "",
+      endDate: "",
+      currentCreneau: undefined as Creneau | undefined,
+      currentCreneauGroup: undefined as Ressource | undefined,
+    };
+  },
+  methods: {
+    createRessource(): void {
+      const ressource = new Ressource({ id: uuidv4(), title: "Nouvelle ligne" });
+      this.$store.commit(MutationTypes.addCreneauGroup, ressource);
+      this.timeline.addRessource(ressource);
+      this.currentCreneauGroup = ressource;
+    },
+    deleteRessource(): void {
+      if (this.currentCreneauGroup) {
+        const contentRemoved = this.timeline.removeRessourceById(this.currentCreneauGroup.id);
+        contentRemoved.ressources.map((r) =>
+          this.$store.commit(MutationTypes.removeCreneauGroup, r)
+        );
+        contentRemoved.items.map((e) => {
+          const crenenau = this.$store.getters.getCreneauById(e.id);
+          if (crenenau) this.$store.commit(MutationTypes.removeCreneau, crenenau);
+        });
+      }
+    },
+    selectionChangeHandler(ev: CustomEvent) {
+      const elts = ev.detail.items as Array<Selectable>;
+      if (elts.length == 1) {
+        const item = elts[0];
+        if (item instanceof Ressource) {
+          this.currentCreneauGroup = item;
+          this.currentCreneau = undefined;
+        }
+        if (item instanceof jcEvent) {
+          this.currentCreneau = this.$store.getters.getCreneauById(item.id);
+          this.currentCreneauGroup = undefined;
+        }
+      }
+      if (elts.length == 0) {
+        this.currentCreneau = undefined;
+        this.currentCreneauGroup = undefined;
+      }
+    },
+    eventChangeHandler(ev: CustomEvent<changePayload>) {
+      const jcEvents = ev.detail.items;
+      for (let index = 0; index < jcEvents.length; index++) {
+        const element = jcEvents[index];
+        this.$store.commit(MutationTypes.editCreneau, {
+          id: element.id,
+          field: "start",
+          value: element.start,
+        });
+        this.$store.commit(MutationTypes.editCreneau, {
+          id: element.id,
+          field: "end",
+          value: element.end,
+        });
+        this.$store.commit(MutationTypes.editCreneau, {
+          id: element.id,
+          field: "ressourceId",
+          value: element.ressourceId,
+        });
+      }
+    },
+    ressourceChangeHandler(ev: CustomEvent<{ ressources: Array<Ressource> }>) {
+      this.$store.commit(MutationTypes.reorderCreneauGroup, ev.detail.ressources);
+    },
+    updateCreneauGroup<K extends keyof Ressource>(payload: {
+      id: string;
+      field: K;
+      value: Ressource[K];
+    }) {
+      this.$store.commit(MutationTypes.editCreneauGroup, payload);
+      const newRessource = this.$store.getters.getCreneauGroupById(payload.id);
+      if (newRessource) {
+        if (payload.field == "parent") {
+          const content = this.timeline.removeRessourceById(payload.id);
+          this.timeline.addRessources(
+            content.ressources.map((o) => (o.id == payload.id ? newRessource : o))
+          );
+          this.timeline.addRessources(content.items);
+        } else {
+          this.timeline.updateLegend();
+        }
+      }
+    },
+    updateCreneau<K extends keyof Creneau>(payload: { id: string; field: K; value: Creneau[K] }) {
+      this.$store.commit(MutationTypes.editCreneau, payload);
+      this.timeline.updateEventById(payload.id);
+    },
+  },
+  computed: {
+    timeline(): Timeline {
+      return this.$refs["timeline"] as Timeline;
+    },
+    currentCreneauGroupId(): string {
+      return this.currentCreneauGroup ? this.currentCreneauGroup.id : "";
+    },
+    creneauList(): Array<Creneau> {
+      return this.$store.state.creneauList;
+    },
+    eventList(): Array<jcEvent> {
+      return this.creneauList.map((o) => o.event);
+    },
+    creneauGroupList(): Array<Ressource> {
+      return this.$store.state.creneauGroupList;
+    },
+  },
+  watch: {
+    creneauGroupList() {
+      this.timeline.requestUpdate();
+    },
+    creneauList() {
+      this.timeline.requestUpdate();
+    },
+  },
+  mounted() {
+    this.timeline.addRessources(this.creneauGroupList);
+    this.timeline.addEvents(this.eventList);
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.container {
+  display: flex;
+}
+.timeline {
+  margin: 0px 16px;
+}
+jc-timeline {
+  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%;
+}
+</style>

+ 15 - 0
src/views/PlanningPersonnel.vue

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

部分文件因为文件数量过多而无法显示