Pārlūkot izejas kodu

implement Explanability of Hard score

tripeur 4 gadi atpakaļ
vecāks
revīzija
8140c7a251

+ 1 - 1
package-lock.json

@@ -6945,7 +6945,7 @@
       "dev": true
     },
     "jc-timeline": {
-      "version": "git+http://gitlab.jaquin.fr/clovis/jc-timeline.git#28da6f3ee43b04c67ffbae54b0cbb7f12675a662",
+      "version": "git+http://gitlab.jaquin.fr/clovis/jc-timeline.git#af9d55204ef266ceeb274c92c0006d33748e5351",
       "from": "git+http://gitlab.jaquin.fr/clovis/jc-timeline.git",
       "requires": {
         "dayjs": "^1.10.4",

+ 1 - 1
package.json

@@ -6,7 +6,7 @@
     "serve": "vue-cli-service serve",
     "build": "vue-cli-service build",
     "lint": "vue-cli-service lint",
-    "deploy":"Copy-Item -Path C:\\Users\\Clovis\\Desktop\\code\\javascript\\bdlg-scheduler\\dist\\* -Destination C:\\Users\\Clovis\\Desktop\\code\\java\\bdlg.planner\\src\\main\\resources\\static -PassThru -Force"
+    "deploy": "Copy-Item -Path C:\\Users\\Clovis\\Desktop\\code\\javascript\\bdlg-scheduler\\dist\\* -Destination C:\\Users\\Clovis\\Desktop\\code\\java\\bdlg.planner\\src\\main\\resources\\static -PassThru -Force"
   },
   "dependencies": {
     "dayjs": "^1.10.4",

+ 88 - 10
src/App.vue

@@ -30,17 +30,28 @@
         <div class="spinner"><span class="material-icons"> sync </span></div>
       </div>
     </div>
+    <div v-if="showExplanation" class="modal" @click="showExplanation = false">
+      <div class="modal-content" v-html="explanation" @click="(e) => e.stopPropagation()"></div>
+    </div>
   </div>
 </template>
 <script lang="ts">
 import { defineComponent } from "vue";
-import toast from "./utils/Toast";
+import toast, { Toast } from "./utils/Toast";
 import "@/assets/css/tabs.css";
+import ConstraintTranslation from "@/assets/ConstraintTranslation";
 import Evenement from "./models/Evenement";
 import Benevole from "./models/Benevole";
 import Creneau from "./models/Creneau";
 import Competence from "./models/Competence";
-import { SolverInput, SolverOutput } from "./models/SolverInput";
+import {
+  AssignementPair,
+  SolverInput,
+  SolverOutput,
+  JustificationObject,
+  ScoreExplanation,
+  HardConstraint,
+} from "./models/SolverInput";
 import Ressource, { IRessource, RessourceJSON } from "jc-timeline/lib/Ressource";
 import { MutationTypes } from "./store/Mutations";
 import dayjs from "dayjs";
@@ -48,10 +59,16 @@ import { StateJSON } from "./store/State";
 const keyofEvent: Array<keyof Evenement> = ["name", "uuid", "start", "end"];
 export default defineComponent({
   data() {
-    return { optimisationInProgress: false };
+    return {
+      optimisationInProgress: false,
+      explanation: "",
+      showExplanation: false,
+      lastToast: null as null | Toast,
+    };
   },
   mounted() {
     const previousState = window.localStorage.getItem("activeState");
+    toast({ html: "coucou" });
     if (previousState) {
       this.importJsonState(JSON.parse(previousState), false);
     }
@@ -63,6 +80,12 @@ export default defineComponent({
     localSave() {
       window.localStorage.setItem("activeState", JSON.stringify(this.$store.getters.getJSONState));
     },
+    clearToast() {
+      if (this.lastToast) {
+        this.lastToast.dismiss();
+        this.lastToast = null;
+      }
+    },
     exportStateToJson(): void {
       const obj: StateJSON = this.$store.getters.getJSONState;
       const mimeType = "data:text/json;charset=utf-8";
@@ -83,7 +106,9 @@ export default defineComponent({
       const constraints = this.$store.state.competenceList.map((c) => c.toSkill());
       const getAssignementPair = (c: Creneau) =>
         c.benevoleIdList.map((id) => {
-          return { volonteerId: id, slotId: c.id };
+          const obj: AssignementPair = { volonteerId: id, slotId: c.id };
+          if (c.fixedAttendee) obj.isFixed = true;
+          return obj;
         });
       const assignements = slots.flatMap(getAssignementPair);
       const mealAssignements = meals.flatMap(getAssignementPair);
@@ -110,7 +135,7 @@ export default defineComponent({
           this.optimisationInProgress = false;
         });
     },
-    updatePlanningWithNewPairing(data: SolverOutput) {
+    updatePlanningWithNewPairing(data: SolverOutput): void {
       this.optimisationInProgress = false;
       // Display message from the solver
       toast({
@@ -118,10 +143,9 @@ export default defineComponent({
         classes: "success",
         displayLength: 10000,
       });
-      data.message
-        .split("\n")
-        .filter((s) => s != "")
-        .forEach((s) => toast({ html: s, classes: "warning", displayLength: 10000 }));
+      if (data.message) toast({ html: data.message, classes: "warning", displayLength: 10000 });
+      this.displayExplanation(data.explanation);
+
       // Remove previous timeslot assignement
       this.$store.commit(MutationTypes.clearBenevole2Creneau, undefined);
       // Add new timeslot assignement
@@ -132,6 +156,49 @@ export default defineComponent({
         });
       }
     },
+    displayExplanation(explanation: string) {
+      // Display score explanation if any
+      const scoreExplanation: ScoreExplanation = JSON.parse(explanation);
+      let scoreExplanationHTML = "";
+      let k: HardConstraint;
+      for (k in scoreExplanation) {
+        // Title of the section
+        scoreExplanationHTML += `<h4>${ConstraintTranslation[k]}</h4>`;
+        // Content of the section splitted by group
+        scoreExplanationHTML += scoreExplanation[k].map((list) => {
+          const pairs = list
+            .map((o) => {
+              if (typeof o === "string" || o instanceof String) {
+                return o;
+              } else {
+                const parseObj = o as JustificationObject;
+                const slot = this.$store.getters.getCreneauById(parseObj.slotId);
+                const benevole = this.$store.getters.getBenevoleById(parseObj.volonteerId);
+                return `Bénevole :\t${benevole?.shortame}<br>Créneau:\t${slot?.title} ${slot?.horaire}`;
+              }
+            })
+            .join("<br>Et<br>");
+          if (pairs != "") {
+            return `<p>${pairs}</p>`;
+          }
+          return "";
+        });
+        // End of the section
+        scoreExplanationHTML += "<hr>";
+      }
+      this.explanation = scoreExplanationHTML;
+      this.clearToast();
+      this.lastToast = toast({
+        html: `<span>Ouvrir l'explication du score</span><i class="material-icons">close<i>`,
+        displayLength: Infinity,
+        classes: "action",
+      });
+      this.lastToast.element.firstElementChild?.addEventListener(
+        "click",
+        () => (this.showExplanation = true)
+      );
+      this.lastToast.element.lastElementChild?.addEventListener("click", this.clearToast);
+    },
     importJsonState(obj: StateJSON, preserve = false) {
       // Remove previous content and load the main event title
       if (preserve == false) {
@@ -196,4 +263,15 @@ export default defineComponent({
 });
 </script>
 
-<style></style>
+<style>
+.toast.action > span {
+  cursor: pointer;
+  color: var(--color-primary-600);
+}
+.toast.action > i.material-icons {
+  cursor: pointer;
+  margin-left: 16px;
+  margin-right: -8px;
+  font-size: 18px;
+}
+</style>

+ 8 - 0
src/assets/ConstraintTranslation.ts

@@ -0,0 +1,8 @@
+export default {
+  "Meal not initialized": "Certains bénévoles n'ont pas de créneau de repas",
+  "Timeslot not initialized": "Certains créneaux n'ont pas de bénévoles",
+  "Volonteer conflict": "Certains bénévoles ont plusieurs créneaux en parallèles",
+  "Volonteer Meal conflict": "Certains bénévoles ont un créneau pendant leur repas",
+  "Competence conflict": "Certains bénévoles n'ont pas les compétences requises",
+  "Meal max attendee": "Certains repas ont plus de bénévoles inscrits que leur limite maximum",
+};

+ 8 - 5
src/components/EditeurCreneau.vue

@@ -112,6 +112,11 @@
           :modelValue="creneau.isMeal"
           @input="checkboxListener($event, 'isMeal')"
         />
+        <checkbox
+          label="Bénévole fixe"
+          :modelValue="creneau.fixedAttendee"
+          @input="checkboxListener($event, 'fixedAttendee')"
+        />
       </div>
       <styled-input
         label="Pénibilité"
@@ -207,7 +212,7 @@ export default defineComponent({
     concatStart(): string {
       return (
         this.jour +
-        "T" +
+        " " +
         this.heure
           .split(":")
           .map((c) => ("0" + c).slice(-2))
@@ -218,7 +223,7 @@ export default defineComponent({
       return /\d{4}\/\d{1,2}\/\d{1,2}/.test(this.jour) && dayjs(this.concatStart).isValid();
     },
     startDate(): Date {
-      return new Date(this.concatStart);
+      return dayjs(this.concatStart, "YYYY/MM/DD HH:mm").toDate();
     },
     endDate(): Date {
       return dayjs(this.startDate).add(this.duration, "m").toDate();
@@ -317,8 +322,6 @@ export default defineComponent({
 
 <style scoped>
 .checkbox {
-  align-items: end;
-  display: flex;
-  padding: 8px;
+  padding: 4px;
 }
 </style>

+ 8 - 1
src/models/Creneau.ts

@@ -11,6 +11,7 @@ export interface ICreneau {
   competencesIdList: Array<number>;
   description: string;
   isMeal: boolean;
+  fixedAttendee: boolean;
 }
 export type CreneauJSON = Omit<ICreneau, "event"> & {
   event: EventJSON;
@@ -47,6 +48,7 @@ class Creneau implements ICreneau {
   competencesIdList: number[];
   description: string;
   isMeal: boolean;
+  fixedAttendee: boolean;
 
   constructor(obj: ICreneau) {
     if (!(typeof obj.event.id == "string") || obj.event.id == "") {
@@ -67,6 +69,7 @@ class Creneau implements ICreneau {
     this._benevoleIdList = "benevoleIdList" in obj ? obj.benevoleIdList : [];
     this.competencesIdList = "competencesIdList" in obj ? obj.competencesIdList : [];
     this.isMeal = "isMeal" in obj ? obj.isMeal : false;
+    this.fixedAttendee = "fixedAttendee" in obj ? obj.fixedAttendee : false;
     this.updateEventContent();
   }
   get id(): string {
@@ -107,10 +110,13 @@ class Creneau implements ICreneau {
       dayjs(this.event.end).format("HH:mm")
     );
   }
+  toString() {
+    return `Créneau[titre:${this.title},\tHoraire:${this.horaire}]`;
+  }
   updateEventContent(): void {
     const missingbenevole = Math.max(this.minAttendee - this.benevoleIdList.length, 0);
     const spanClass = this.benevoleIdList.length == 0 ? "red" : missingbenevole > 0 ? "orange" : "";
-    this.event.content = `<div class="bubble ${spanClass}">${missingbenevole}</div>`;
+    this.event.content = `<div class="bubble ${spanClass}">${this.benevoleIdList.length} / ${this.minAttendee}</div>`;
   }
   toJSON(): CreneauJSON {
     const e = this.event.toJSON();
@@ -124,6 +130,7 @@ class Creneau implements ICreneau {
       competencesIdList: this.competencesIdList,
       description: this.description,
       isMeal: this.isMeal,
+      fixedAttendee: this.fixedAttendee,
     };
   }
   toMealSlot(): MealSlot {

+ 17 - 0
src/models/SolverInput.ts

@@ -8,6 +8,7 @@ export type Skill = {
 export type AssignementPair = {
   slotId: string;
   volonteerId: number;
+  isFixed?: boolean;
 };
 export type TimeslotRaw = {
   id: string;
@@ -40,4 +41,20 @@ export type SolverOutput = {
   assignements: Array<AssignementPair>;
   message: string;
   score: string;
+  explanation: string;
+};
+export type JustificationObject = {
+  type: "Assignement" | "MealAssignement";
+  slotId: string;
+  volonteerId: number;
+};
+export type HardConstraint =
+  | "Meal not initialized"
+  | "Timeslot not initialized"
+  | "Volonteer conflict"
+  | "Volonteer Meal conflict"
+  | "Competence conflict"
+  | "Meal max attendee";
+export type ScoreExplanation = {
+  [constraintName in HardConstraint]: Array<Array<string | JustificationObject>>;
 };

+ 1 - 1
src/utils/Toast.ts

@@ -16,7 +16,7 @@ const defaultOptions: ToastOptions = {
   classes: "",
   activationPercent: 0.7,
 };
-class Toast {
+export class Toast {
   static _container: HTMLElement | null = null;
   static _toasts: Array<Toast> = [];
   element: HTMLElement;

+ 59 - 59
src/utils/toast.css

@@ -1,87 +1,87 @@
 #toast-container {
-    display:block;
-    position:fixed;
-    z-index:10000
+  display: block;
+  position: fixed;
+  z-index: 10000;
 }
 @media only screen and (max-width: 600px) {
-    #toast-container {
-    min-width:100%;
-    bottom:0%
-    }
+  #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%
-    }
+  #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-container {
+    top: 75px;
+    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
+  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);
+.toast::after {
+  content: "";
+  position: absolute;
+  bottom: 0px;
+  left: 0px;
+  width: var(--completion);
+  height: 3px;
+  background-color: rgb(160, 140, 179);
 }
 
-.toast.error{
+.toast.error {
   color: #282e3a;
   background-color: #f9ccd4;
 }
-.toast.error::after{
+.toast.error::after {
   background-color: #b60022;
 }
 
-.toast.warning{
+.toast.warning {
   color: #282e3a;
   background-color: #fbca32;
 }
-.toast.warning::after{
+.toast.warning::after {
   background-color: #fabd00;
 }
 
-.toast.success{
+.toast.success {
   color: white;
   background-color: #08875b;
 }
-.toast.success::after{
+.toast.success::after {
   background-color: #097350;
-}
+}

+ 1 - 1
src/views/Home.vue

@@ -85,7 +85,7 @@ export default defineComponent({
       }
     },
     end(val: Dayjs, oldval) {
-      if (oldval && !val.isSame(this.evenement.endingDate)) {
+      if (oldval && !val.isSame(this.evenement.endingDate, "minutes")) {
         this.$store.commit(MutationTypes.editEvenement, {
           field: "end",
           value: val.subtract(1, "h").endOf("h").toDate(),

+ 17 - 10
src/views/Planning.vue

@@ -7,7 +7,9 @@
           <button class="btn icon small primary">
             <i class="material-icons">upload_file</i>
           </button>
-          <button class="btn icon small primary"><i class="material-icons">save</i></button>
+          <button class="btn icon small primary">
+            <i class="material-icons">save</i>
+          </button>
           <button
             class="btn small secondary"
             @click="createCreneau"
@@ -31,8 +33,8 @@
         :slotduration="slotduration"
         :legendspan="legendspan"
         :slotwidth="slotwidth"
-        :start="start"
-        :end="end"
+        :start="start.toDate().toISOString()"
+        :end="end.toDate().toISOString()"
         @event-change="eventChangeHandler"
         @item-selected="selectionChangeHandler"
         @reorder-ressource="ressourceChangeHandler"
@@ -87,8 +89,6 @@ export default defineComponent({
   },
   data() {
     return {
-      startDate: "",
-      endDate: "",
       currentCreneau: undefined as Creneau | undefined,
       currentCreneauGroup: undefined as Ressource | undefined,
       zoomLevel: 14,
@@ -127,6 +127,7 @@ export default defineComponent({
             competencesIdList: [],
             description: "",
             isMeal: false,
+            fixedAttendee: false,
           });
           this.$store.commit(MutationTypes.addCreneau, creneau);
 
@@ -142,7 +143,10 @@ export default defineComponent({
       }
     },
     createRessource(): void {
-      const ressource = new Ressource({ id: uuidv4(), title: "Nouvelle ligne" });
+      const ressource = new Ressource({
+        id: uuidv4(),
+        title: "Nouvelle ligne",
+      });
       this.$store.commit(MutationTypes.addCreneauGroup, ressource);
       this.timeline.clearSelectedItems();
       this.timeline.addRessource(ressource).selected = true;
@@ -300,23 +304,26 @@ export default defineComponent({
   },
   watch: {},
   mounted() {
+    this.timeline.setAttribute("start", this.start.toISOString());
+    this.timeline.setAttribute("end", this.end.toISOString());
     this.timeline.addRessources(this.creneauGroupList);
     this.timeline.addEvents(this.eventList);
     this.timeline.setLegendUnitFormat("d", "dddd D MMMM");
     this.timeline.customStyle = `.bubble{
-    height: 19px;
-    width: 19px;
+    padding: 2px 4px;
     display: flex;
     justify-content: center;
     align-items: center;
     position: absolute;
     right: -5px;
     bottom: -5px;
-    border-radius: 50%;
+    border-radius: 4px;
     font-size: 12px;
     font-weight: bold;
-    z-index:10;
+    z-index:1;
     background: #08875b;
+    transform: translateX(50%);
+    white-space: nowrap;
 }
 .bubble.red{
     background: #e4002b;