From 62fa1222285f23fa83613896f0bca39389c5ba7c Mon Sep 17 00:00:00 2001
From: Alex ORLUC <alex.orluc@maarch.org>
Date: Fri, 10 Jan 2020 23:36:58 +0100
Subject: [PATCH] FEAT #12765 TIME 1:30 front opinion workflow

---
 .../controllers/ListTemplateController.php    |   4 +-
 src/frontend/app/app.module.ts                |   7 +-
 .../add-avis-model-modal.component.html       |  11 +
 .../add-avis-model-modal.component.scss       |  13 +
 .../add-avis-model-modal.component.ts         |  53 +++
 .../app/avis/avis-workflow.component.html     |  98 +++--
 .../app/avis/avis-workflow.component.scss     | 106 +++++-
 .../app/avis/avis-workflow.component.ts       | 340 +++++++++++++++---
 .../app/process/process.component.html        |  18 +-
 src/frontend/app/process/process.component.ts |  18 +-
 .../app/visa/visa-workflow.component.ts       |   5 -
 src/frontend/lang/lang-en.ts                  |   6 +-
 src/frontend/lang/lang-fr.ts                  |   6 +-
 src/frontend/lang/lang-nl.ts                  |   4 +
 14 files changed, 582 insertions(+), 107 deletions(-)
 create mode 100644 src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.html
 create mode 100644 src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.scss
 create mode 100644 src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.ts

diff --git a/src/app/entity/controllers/ListTemplateController.php b/src/app/entity/controllers/ListTemplateController.php
index 7a3290550ed..729021ce876 100755
--- a/src/app/entity/controllers/ListTemplateController.php
+++ b/src/app/entity/controllers/ListTemplateController.php
@@ -124,7 +124,7 @@ class ListTemplateController
         $check = $check && Validator::arrayType()->notEmpty()->validate($body['items']);
         $check = $check && (Validator::stringType()->notEmpty()->validate($body['title']) || Validator::stringType()->notEmpty()->validate($body['description']));
         if (!$check) {
-            return $response->withStatus(400)->withJson(['errors' => 'Bad Request']);
+            return $response->withStatus(400)->withJson(['errors' => 'Bad allowed types']);
         }
 
         if (!empty($body['entityId'])) {
@@ -603,7 +603,7 @@ class ListTemplateController
         $circuit = $queryParams['circuit'] == 'opinion' ? 'opinionCircuit' : 'visaCircuit';
         $resource = ResModel::getById(['resId' => $args['resId'], 'select' => ['destination']]);
 
-        $where = ['type = ?', 'owner is null or owner = ?'];
+        $where = ['type = ?', '(owner is null or owner = ?)'];
         $data = [$circuit, $GLOBALS['id']];
         if (!empty($resource['destination'])) {
             $entity = EntityModel::getByEntityId(['entityId' => $resource['destination'], 'select' => ['id']]);
diff --git a/src/frontend/app/app.module.ts b/src/frontend/app/app.module.ts
index 44eefa1a128..a8bd7c93cae 100755
--- a/src/frontend/app/app.module.ts
+++ b/src/frontend/app/app.module.ts
@@ -87,6 +87,7 @@ import { ContactsListModalComponent } from './contact/list/modal/contacts-list-m
 import { ContactModalComponent } from './administration/contact/modal/contact-modal.component';
 import { VisaWorkflowModalComponent } from './visa/modal/visa-workflow-modal.component';
 import { AddVisaModelModalComponent } from './visa/addVisaModel/add-visa-model-modal.component';
+import { AddAvisModelModalComponent } from './avis/addAvisModel/add-avis-model-modal.component';
 
 
 @NgModule({
@@ -164,7 +165,8 @@ import { AddVisaModelModalComponent } from './visa/addVisaModel/add-visa-model-m
         FollowedDocumentListComponent,
         FollowedActionListComponent,
         VisaWorkflowModalComponent,
-        AddVisaModelModalComponent
+        AddVisaModelModalComponent,
+        AddAvisModelModalComponent
     ],
     entryComponents: [
         ConfirmModalComponent,
@@ -195,7 +197,8 @@ import { AddVisaModelModalComponent } from './visa/addVisaModel/add-visa-model-m
         ContactsListModalComponent,
         ContactModalComponent,
         VisaWorkflowModalComponent,
-        AddVisaModelModalComponent
+        AddVisaModelModalComponent,
+        AddAvisModelModalComponent
     ],
     providers: [ FiltersListService, FoldersService, ActionsService, PrivilegeService ],
     bootstrap: [ AppComponent ]
diff --git a/src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.html b/src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.html
new file mode 100644
index 00000000000..30d14d0ffb6
--- /dev/null
+++ b/src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.html
@@ -0,0 +1,11 @@
+<h1 mat-dialog-title>Ajouter un modèle</h1>
+<mat-dialog-content class="modal-container">
+    <mat-form-field appearance="outline">
+        <input type="text" matInput [(ngModel)]="template.title" placeholder="Nom du modèle">
+    </mat-form-field>
+</mat-dialog-content>
+<div mat-dialog-actions class="actions">
+    <button mat-raised-button mat-button color="primary" [disabled]="loading"
+        (click)="onSubmit()">{{lang.validate}}</button>
+    <button mat-raised-button mat-button [disabled]="loading" [mat-dialog-close]="">{{lang.cancel}}</button>
+</div>
\ No newline at end of file
diff --git a/src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.scss b/src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.scss
new file mode 100644
index 00000000000..7a15a35b125
--- /dev/null
+++ b/src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.scss
@@ -0,0 +1,13 @@
+@import '../../../css/vars.scss';
+
+.mat-dialog-title {
+    padding: 10px;
+}
+
+.modal-container{
+    height: auto;
+}
+
+.modal-body{
+    min-height: auto;
+}
diff --git a/src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.ts b/src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.ts
new file mode 100644
index 00000000000..6a38ba1da8b
--- /dev/null
+++ b/src/frontend/app/avis/addAvisModel/add-avis-model-modal.component.ts
@@ -0,0 +1,53 @@
+import { Component, Inject } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
+import { LANG } from '../../translate.component';
+import { HttpClient } from '@angular/common/http';
+import { tap, catchError } from 'rxjs/operators';
+import { NotificationService } from '../../notification.service';
+import { of } from 'rxjs';
+
+@Component({
+    templateUrl: 'add-avis-model-modal.component.html',
+    styleUrls: ['add-avis-model-modal.component.scss'],
+})
+export class AddAvisModelModalComponent {
+    lang: any = LANG;
+
+    template: any = {
+        id: 0,
+        type: 'opinionCircuit',
+        title: '',
+        items : []
+    }
+
+    constructor(
+        public http: HttpClient, 
+        @Inject(MAT_DIALOG_DATA) public data: any, 
+        public dialogRef: MatDialogRef<AddAvisModelModalComponent>,
+        private notify: NotificationService) { }
+
+    ngOnInit(): void {
+        this.template.items = this.data.avisWorkflow.map((item: any) => {
+            return {
+                id: item.item_id,
+                type: 'user',
+                mode: 'avis'
+            }
+        }); 
+    }
+
+    onSubmit() {
+        this.http.post(`../../rest/listTemplates`, this.template).pipe(
+            tap((data: any) => {
+                this.template.id = data.id;
+                this.notify.success(this.lang.modelSaved);
+                this.dialogRef.close(this.template);
+            }),
+            catchError((err: any) => {
+                this.notify.handleSoftErrors(err);
+                return of(false);
+            })
+        ).subscribe();
+    }
+
+}
diff --git a/src/frontend/app/avis/avis-workflow.component.html b/src/frontend/app/avis/avis-workflow.component.html
index 8738a66543a..350f7885c7a 100644
--- a/src/frontend/app/avis/avis-workflow.component.html
+++ b/src/frontend/app/avis/avis-workflow.component.html
@@ -1,30 +1,86 @@
+
 <mat-list *ngIf="!loading">
-    <mat-form-field *ngIf="adminMode" appearance="outline" floatLabel="never" [style.fontSize.px]="10">
-        <input class="metaSearch" type="text" matInput placeholder="{{lang.addPerson}}">
+    <mat-form-field appearance="outline" *ngIf="adminMode && !linkedToMaarchParapheur">
+        <input type="text" matInput placeholder="Ajouter des personnes" id="searchAvisSignUserInput"
+            [formControl]="searchAvisSignUser" [matAutocomplete]="autoGroup">
+        <mat-autocomplete #autoGroup="matAutocomplete" (optionSelected)="addItemToWorkflow($event.option.value)" (opened)="initFilterAvisModelList()">
+            <mat-option disabled *ngIf="avisModelListNotLoaded">
+                <div style="display: flex;justify-content: center;">
+                    <mat-spinner diameter="35"></mat-spinner>
+                </div>
+            </mat-option>
+            <mat-optgroup [label]="lang.publicModel" *ngIf="(filteredPublicModels | async)?.length > 0"
+                class="avisSignList">
+                <mat-option *ngFor="let model of filteredPublicModels | async | sortBy : 'label'" [value]="model">
+                    {{model.label}}
+                </mat-option>
+            </mat-optgroup>
+            <mat-optgroup [label]="lang.privateModel" *ngIf="(filteredPrivateModels | async)?.length > 0"
+                class="avisSignList">
+                <mat-option *ngFor="let model of filteredPrivateModels | async | sortBy : 'label'" [value]="model">
+                    <div style="display: flex;align-items: center;">
+                        <div style="flex:1">
+                            {{model.label}}
+                        </div>
+                        <button mat-icon-button color="warn"
+                            (click)="$event.stopPropagation();deletePrivateModel(model)">
+                            <mat-icon class="fa fa-trash" style="margin: 0px;"></mat-icon>
+                        </button>
+                    </div>
+                </mat-option>
+            </mat-optgroup>
+            <mat-optgroup [label]="lang.user | titlecase" *ngIf="(filteredSignAvisUsers | async)?.length > 0"
+                class="avisSignList">
+                <mat-option *ngFor="let user of filteredSignAvisUsers | async | sortBy : 'label'" [value]="user">
+                    {{user.label}} <small>({{user.entity}})</small>
+                </mat-option>
+            </mat-optgroup>
+        </mat-autocomplete>
+        <button mat-icon-button matSuffix *ngIf="avisWorkflow.items.length > 0" color="primary"
+            (click)="$event.stopPropagation();openPromptSaveModel()">
+            <mat-icon class="fa fa-plus"></mat-icon>
+        </button>
     </mat-form-field>
     <div cdkDropList #dataAvailableList="cdkDropList" [cdkDropListData]="avisWorkflow.items" class="cdk-list"
         (cdkDropListDropped)="drop($event)" [cdkDropListDisabled]="!adminMode">
-        <div *ngIf="avisWorkflow.items.length === 0" style="opacity: 0.5;text-align: center;font-size: 10px;padding: 10px;">
+        <div class="emptyContent" *ngIf="avisWorkflow.items.length === 0">
             {{lang.noPerson}}
         </div>
-        <mat-list-item *ngFor="let diffusion of avisWorkflow.items;let i=index" cdkDrag class="columns"
-            [cdkDragDisabled]="!adminMode" [class.notDraggable]="adminMode" [class.notEditable]="!adminMode"
-            [ngStyle]="{'background': diffusion.process_date != null ? 'rgba(0, 128, 0, 0.11)' : ''}">
-            <mat-icon mat-list-icon class="fa fa-user fa-2x" color="primary"></mat-icon>
-            <mat-icon mat-list-icon class="fa fa-hourglass fa-2x" *ngIf="diffusion.process_date == null" style="opacity:0.5;"></mat-icon>
-            <mat-icon mat-list-icon class="fa fa-check fa-2x" *ngIf="diffusion.process_date != null" style="opacity:0.5;"
-                color="accent"></mat-icon>
-            <h4 mat-line style="display: flex;">
-                <span style="flex: 1;">{{diffusion.item_firstname}} {{diffusion.item_lastname}}</span>
-            </h4>
-            <p mat-line style="display: flex;">
-                <span style="opacity:0.5;flex: 1;">{{diffusion.item_entity}}</span>
-                <span *ngIf="diffusion.process_date != null" title='{{diffusion.process_date | date : lang.onRange + " dd/MM/y " + lang.atRange +" HH:mm"}}'
-                    style="flex: 1;text-align: right;font-size: 90%;" color="accent">{{diffusion.process_date
-                    | timeAgo}}</span>
-            </p>
-            <button mat-icon-button *ngIf="adminMode" (click)="deleteItem(i)">
+        <mat-list-item *ngFor="let diffusion of avisWorkflow.items;let i=index" cdkDrag class="columns workflow"
+            [cdkDragDisabled]="!adminMode || !functions.empty(diffusion.process_date)"
+            [class.notDraggable]="!adminMode || !functions.empty(diffusion.process_date)"
+            [class.notEditable]="!adminMode" [class.processed]="diffusion.process_date != null">
+            <mat-icon
+                [ngClass]="{'fa fa-user fa-2x': functions.empty(diffusion.picture),'avatar': !functions.empty(diffusion.picture)}"
+                mat-list-icon color="primary"
+                [style.background-image]="!functions.empty(diffusion.picture) ? 'url('+diffusion.picture+')' : ''">
+            </mat-icon>
+            <ng-container *ngIf="!adminMode || diffusion.process_date != null">
+                <mat-icon mat-list-icon class="fa-2x far"
+                [class.fa-hourglass]="diffusion.process_date == null" [class.fa-thumbs-up]="diffusion.process_date != null" [class.valid]="diffusion.process_date != null"
+                    style="opacity:0.5;"></mat-icon>
+            </ng-container>
+            <div mat-line class="workflowLine">
+                <div class="workflowLineContainer">
+                    <div class="workflowLineLabel">
+                        {{diffusion.labelToDisplay}}
+                    </div>
+                    <div class="workflowLineSubLabel">
+                        {{diffusion.item_entity}}
+                    </div>
+                    <div *ngIf="diffusion.process_date != null" class="workflowLineProcessDate"
+                        title='{{diffusion.process_date | fullDate}}'
+                        color="accent">{{lang.avisSent}} {{diffusion.process_date
+                                                | timeAgo : 'full'}}</div>
+                </div>
+            </div>
+            <button mat-icon-button *ngIf="adminMode && functions.empty(diffusion.process_date)"
+                (click)="deleteItem(i)">
                 <mat-icon class="fa fa-times" color="warn"></mat-icon>
             </button>
         </mat-list-item>
-    </div>
\ No newline at end of file
+    </div>
+</mat-list>
+<div *ngIf="loading" style="display:flex;padding: 10px;">
+    <mat-spinner style="margin:auto;"></mat-spinner>
+</div>
\ No newline at end of file
diff --git a/src/frontend/app/avis/avis-workflow.component.scss b/src/frontend/app/avis/avis-workflow.component.scss
index e228e789956..5c622317589 100644
--- a/src/frontend/app/avis/avis-workflow.component.scss
+++ b/src/frontend/app/avis/avis-workflow.component.scss
@@ -1,12 +1,30 @@
+@import '../../css/vars.scss';
+
+.mat-form-field-appearance-outline {
+    font-size: 11px;
+}
+
+.avisSignList {
+    ::ng-deep.mat-optgroup-label {
+        color: $primary;
+        position: sticky;
+        top: 0px;
+        background: white !important;
+        z-index: 1;
+    }
+}
+
 .cdk-drag-preview {
     box-sizing: border-box;
     border-radius: 4px;
     box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
         0 8px 10px 1px rgba(0, 0, 0, 0.14),
         0 3px 14px 2px rgba(0, 0, 0, 0.12);
-    background: white;
+    background: white !important;
     padding: 10px;
-    .mat-icon {
+
+    .mat-icon,
+    .mat-icon-button {
         display: none;
     }
 }
@@ -37,4 +55,88 @@
 
 .notEditable {
     cursor: initial;
+}
+
+.currentContextButton {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    font-size: 13px;
+    width: 150px;
+    text-align: left;
+}
+
+.currentRoleButton {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    font-size: 13px;
+    width: 120px;
+    text-align: center;
+}
+
+.emptyContent {
+    opacity: 0.5;
+    text-align: center;
+    font-size: 10px;
+    padding: 10px;
+}
+
+.processed {
+    background: rgba(0, 128, 0, 0.11) !important;
+}
+
+.workflow {
+    height: 55px;
+    margin-bottom: 10px;
+    background: rgba(216,216,216,0.1);
+    border-radius: 10px;
+    font-size: 13px;
+}
+
+.workflowLine {
+    display: flex !important;
+    align-items: center;
+
+    &Container {
+        flex: 1;
+        overflow: hidden;
+        text-overflow: ellipsis;
+    }
+
+    &Label {
+        text-overflow: ellipsis;
+        overflow: hidden;
+    }
+
+    &SubLabel {
+        font-size: 80%;
+        opacity: 0.5;
+        flex: 1;
+        text-overflow: ellipsis;
+        overflow: hidden;
+    }
+
+    &ProcessDate {
+        flex: 1;
+        text-align: left;
+        font-size: 80%;
+    }
+
+    .mat-raised-button[disabled] {
+        background: none;
+        color: $primary !important;
+        opacity: 1;
+    }
+}
+
+.avatar {
+    border: solid 3px #F99830;
+    height: 45px !important;
+    width: 45px !important;
+    background-size: cover;
+    background-repeat: no-repeat;
+    background-position: center;
+}
+
+.valid {
+    color: $accent;
 }
\ No newline at end of file
diff --git a/src/frontend/app/avis/avis-workflow.component.ts b/src/frontend/app/avis/avis-workflow.component.ts
index 043b1566a1c..5a8ca69f597 100644
--- a/src/frontend/app/avis/avis-workflow.component.ts
+++ b/src/frontend/app/avis/avis-workflow.component.ts
@@ -1,31 +1,63 @@
-import { Component, Input, OnInit } from '@angular/core';
+import { Component, Input, OnInit, ElementRef, ViewChild } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
 import { LANG } from '../translate.component';
 import { NotificationService } from '../notification.service';
 import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
+import { FunctionsService } from '../../service/functions.service';
+import { tap, exhaustMap, map, startWith, catchError, finalize, filter, debounceTime, switchMap } from 'rxjs/operators';
+import { FormControl } from '@angular/forms';
+import { LatinisePipe } from 'ngx-pipes';
+import { Observable, of } from 'rxjs';
+import { MatDialog } from '@angular/material';
+import { AddAvisModelModalComponent } from './addAvisModel/add-avis-model-modal.component';
+import { ConfirmComponent } from '../../plugins/modal/confirm.component';
+
+declare function $j(selector: any): any;
 
 @Component({
     selector: 'app-avis-workflow',
     templateUrl: 'avis-workflow.component.html',
-    styleUrls: ['avis-workflow.component.scss'],
-    providers: [NotificationService]
+    styleUrls: ['avis-workflow.component.scss']
 })
 export class AvisWorkflowComponent implements OnInit {
 
     lang: any = LANG;
     avisWorkflow: any = {
-        items : []
+        roles: ['sign', 'avis'],
+        items: []
+    };
+    avisWorkflowClone: any = null;
+    avisTemplates: any = {
+        private: [],
+        public: []
     };
-    loading: boolean = true;
+
+    signAvisUsers: any = [];
+    filteredSignAvisUsers: Observable<string[]>;
+    filteredPublicModels: Observable<string[]>;
+    filteredPrivateModels: Observable<string[]>;
+
+    loading: boolean = false;
+    avisModelListNotLoaded: boolean = true;
     data: any;
 
     @Input('injectDatas') injectDatas: any;
     @Input('adminMode') adminMode: boolean;
     @Input('resId') resId: number = null;
 
-    constructor(public http: HttpClient, private notify: NotificationService) { }
+    @ViewChild('searchAvisSignUserInput', { static: true }) searchAvisSignUserInput: ElementRef;
 
-    ngOnInit(): void { 
+    searchAvisSignUser = new FormControl();
+
+    constructor(
+        public http: HttpClient,
+        private notify: NotificationService,
+        public functions: FunctionsService,
+        private latinisePipe: LatinisePipe,
+        public dialog: MatDialog
+    ) { }
+
+    ngOnInit(): void {
         if (this.resId !== null) {
             this.loadWorkflow(this.resId);
         }
@@ -33,70 +65,158 @@ export class AvisWorkflowComponent implements OnInit {
 
     drop(event: CdkDragDrop<string[]>) {
         if (event.previousContainer === event.container) {
-            moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
+            if (this.functions.empty(this.avisWorkflow.items[event.currentIndex].process_date)) {
+                moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
+            } else {
+                this.notify.error(`${this.lang.moveAvisUserErr1} <b>${this.avisWorkflow.items[event.previousIndex].labelToDisplay}</b> ${this.lang.moveAvisUserErr2}.`);
+            }
         }
     }
 
-    loadListModel(entityId: string) {
+    loadListModel(entityId: number) {
         this.loading = true;
 
         this.avisWorkflow.items = [];
 
-
-        // TO DO : ADD ROUTE
-        /*this.http.get("../../rest/???")
+        this.http.get(`../../rest/listTemplates/entities/${entityId}?type=opinionCircuit`)
             .subscribe((data: any) => {
-                this.loading = false;
-            });*/
-
-            this.avisWorkflow.items.push(
-            {
-                "listinstance_id": 20,
-                "sequence": 0,
-                "item_mode": "avis",
-                "item_id": "bbain",
-                "item_type": "user_id",
-                "item_firstname": "Barbara",
-                "item_lastname": "BAIN",
-                "item_entity": "P\u00f4le Jeunesse et Sport",
-                "viewed": 0,
-                "process_date": null,
-                "process_comment": "",
-                "signatory": false,
-                "requested_signature": false
+                if (data.listTemplates[0]) {
+                    this.avisWorkflow.items = data.listTemplates[0].items.map((item: any) => {
+                        return {
+                            ...item,
+                            item_entity: item.descriptionToDisplay,
+                        }
+                    });
+                    this.loading = false;
+                }
             });
+    }
 
-            this.avisWorkflow.items.push(
-            {
-                "listinstance_id": 21,
-                "sequence": 0,
-                "item_mode": "avis",
-                "item_id": "DSG",
-                "item_type": "entity_id",
-                "item_entity": "Secr\u00e9tariat G\u00e9n\u00e9ral",
-                "viewed": 0,
-                "process_date": null,
-                "process_comment": null,
-                "signatory": false,
-                "requested_signature": false
-            }
-        );
+    loadAvisSignUsersList() {
+        return new Promise((resolve, reject) => {
+            this.http.get(`../../rest/autocomplete/users/circuit`).pipe(
+                map((data: any) => {
+                    data = data.map((user: any) => {
+                        return {
+                            id: user.id,
+                            title: `${user.idToDisplay} (${user.otherInfo})`,
+                            label: user.idToDisplay,
+                            entity: user.otherInfo,
+                            type: 'user'
+                        }
+                    });
+                    return data;
+                }),
+                tap((data) => {
+                    this.signAvisUsers = data;
+                    this.filteredSignAvisUsers = this.searchAvisSignUser.valueChanges
+                        .pipe(
+                            startWith(''),
+                            map(value => this._filter(value))
+                        );
+                    resolve(true);
+                }),
+                catchError((err: any) => {
+                    this.notify.handleSoftErrors(err);
+                    return of(false);
+                })
+            ).subscribe();
+        });
+    }
+
+    loadAvisModelListByResource() {
+        return new Promise((resolve, reject) => {
+            this.http.get(`../../rest/resources/${this.resId}/availableCircuits?circuit=opinion`).pipe(
+                tap((data: any) => {
+                    this.avisTemplates.public = data.circuits.filter((item: any) => !item.private).map((item: any) => {
+                        return {
+                            id: item.id,
+                            title: item.title,
+                            label: item.title,
+                            type: 'entity'
+                        }
+                    });
+
+                    this.avisTemplates.private = data.circuits.filter((item: any) => item.private).map((item: any) => {
+                        return {
+                            id: item.id,
+                            title: item.title,
+                            label: item.title,
+                            type: 'entity'
+                        }
+                    });
+                    this.filteredPublicModels = this.searchAvisSignUser.valueChanges
+                        .pipe(
+                            startWith(''),
+                            map(value => this._filterPublicModel(value))
+                        );
+                    this.filteredPrivateModels = this.searchAvisSignUser.valueChanges
+                        .pipe(
+                            startWith(''),
+                            map(value => this._filterPrivateModel(value))
+                        );
+                    resolve(true);
+                }),
+            ).subscribe();
+        });
+    }
+
+    async initFilterAvisModelList() {
+        if (this.avisModelListNotLoaded) {
+            await this.loadAvisSignUsersList();
+
+            await this.loadAvisModelListByResource();
+
+            this.searchAvisSignUser.reset();
+
+            this.avisModelListNotLoaded = false;
+        }
+    }
+
+    private _filter(value: string): string[] {
+        if (typeof value === 'string') {
+            const filterValue = this.latinisePipe.transform(value.toLowerCase());
+            return this.signAvisUsers.filter((option: any) => this.latinisePipe.transform(option['title'].toLowerCase()).includes(filterValue));
+        } else {
+            return this.signAvisUsers;
+        }
+    }
+
+    private _filterPrivateModel(value: string): string[] {
+        if (typeof value === 'string') {
+            const filterValue = this.latinisePipe.transform(value.toLowerCase());
+            return this.avisTemplates.private.filter((option: any) => this.latinisePipe.transform(option['title'].toLowerCase()).includes(filterValue));
+        } else {
+            return this.avisTemplates.private;
+        }
+    }
 
-        this.loading = false;
+    private _filterPublicModel(value: string): string[] {
+        if (typeof value === 'string') {
+            const filterValue = this.latinisePipe.transform(value.toLowerCase());
+            return this.avisTemplates.public.filter((option: any) => this.latinisePipe.transform(option['title'].toLowerCase()).includes(filterValue));
+        } else {
+            return this.avisTemplates.public;
+        }
     }
 
     loadWorkflow(resId: number) {
         this.loading = true;
         this.avisWorkflow.items = [];
         this.http.get("../../rest/resources/" + resId + "/opinionCircuit")
-         .subscribe((data: any) => {
-            data.forEach((element:any) => {
-                this.avisWorkflow.items.push(element);
+            .subscribe((data: any) => {
+                data.forEach((element: any) => {
+                    this.avisWorkflow.items.push(
+                        {
+                            ...element,
+                            difflist_type: 'AVIS_CIRCUIT'
+                        });
+                });
+                this.avisWorkflowClone = JSON.parse(JSON.stringify(this.avisWorkflow.items))
+                this.loading = false;
+            }, (err: any) => {
+                this.notify.handleErrors(err);
             });
-            this.loading = false;
-        }, (err: any) => {
-            this.notify.handleErrors(err);
-        });
     }
 
     deleteItem(index: number) {
@@ -106,4 +226,118 @@ export class AvisWorkflowComponent implements OnInit {
     getAvisCount() {
         return this.avisWorkflow.items.length;
     }
+
+    changeRole(i: number) {
+        this.avisWorkflow.items[i].requested_signature = !this.avisWorkflow.items[i].requested_signature;
+    }
+
+    getWorkflow() {
+        return this.avisWorkflow.items;
+    }
+
+    saveAvisWorkflow() {
+        this.http.put(`../../rest/listinstances`, [{ resId: this.resId, listInstances: this.avisWorkflow.items }]).pipe(
+            tap((data: any) => {
+                this.avisWorkflowClone = JSON.parse(JSON.stringify(this.avisWorkflow.items));
+                this.notify.success(this.lang.avisWorkflowUpdated);
+            }),
+            catchError((err: any) => {
+                this.notify.handleSoftErrors(err);
+                return of(false);
+            })
+        ).subscribe();
+    }
+
+    addItemToWorkflow(item: any) {
+        if (item.type === 'user') {
+            this.avisWorkflow.items.push({
+                item_id: item.id,
+                item_type: 'user',
+                item_entity: item.entity,
+                labelToDisplay: item.label,
+                externalId: !this.functions.empty(item.externalId) ? item.externalId : null,
+                difflist_type: 'AVIS_CIRCUIT',
+                signatory: false,
+                requested_signature: false
+            });
+            this.searchAvisSignUser.reset();
+        } else if (item.type === 'entity') {
+            this.http.get(`../../rest/listTemplates/${item.id}`).pipe(
+                tap((data: any) => {
+                    this.avisWorkflow.items = this.avisWorkflow.items.concat(
+                        data.listTemplate.items.map((itemTemplate: any) => {
+                            return {
+                                item_id: itemTemplate.item_id,
+                                item_type: 'user',
+                                labelToDisplay: itemTemplate.idToDisplay,
+                                item_entity: itemTemplate.descriptionToDisplay,
+                                difflist_type: 'AVIS_CIRCUIT',
+                                signatory: false,
+                                requested_signature: false
+                            }
+                        })
+                    );
+                    this.searchAvisSignUser.reset();
+                })
+            ).subscribe();
+        }
+    }
+
+    openPromptSaveModel() {
+        const dialogRef = this.dialog.open(AddAvisModelModalComponent, { data: { avisWorkflow: this.avisWorkflow.items } });
+
+        dialogRef.afterClosed().pipe(
+            filter((data: string) => !this.functions.empty(data)),
+
+            tap((data: any) => {
+                this.avisTemplates.private.push({
+                    id: data.id,
+                    title: data.title,
+                    label: data.title,
+                    type: 'entity'
+                });
+            }),
+            catchError((err: any) => {
+                this.notify.handleSoftErrors(err);
+                return of(false);
+            })
+        ).subscribe();
+    }
+
+    deletePrivateModel(model: any) {
+        const dialogRef = this.dialog.open(ConfirmComponent, { autoFocus: false, disableClose: true, data: { title: this.lang.delete, msg: this.lang.confirmAction } });
+
+        dialogRef.afterClosed().pipe(
+            filter((data: string) => data === 'ok'),
+            exhaustMap(() => this.http.delete(`../../rest/listTemplates/${model.id}`)),
+            tap(() => {
+                this.avisTemplates.private = this.avisTemplates.private.filter((template: any) => template.id !== model.id);
+                this.searchAvisSignUser.reset();
+                this.notify.success(this.lang.modelDeleted);
+            }),
+            catchError((err: any) => {
+                this.notify.handleErrors(err);
+                return of(false);
+            })
+        ).subscribe();
+    }
+
+    getMaarchParapheurUserAvatar(externalId: string, key: number) {
+        if (!this.functions.empty(externalId)) {
+            this.http.get("../../rest/maarchParapheur/user/" + externalId + "/picture")
+                .subscribe((data: any) => {
+                    this.avisWorkflow.items[key].picture = data.picture;
+                }, (err: any) => {
+                    this.notify.handleErrors(err);
+                });
+        }
+    }
+
+    isModified() {
+        if (this.loading || JSON.stringify(this.avisWorkflow.items) === JSON.stringify(this.avisWorkflowClone)) {
+            return false;
+        } else {
+            return true;
+        }
+    }
 }
diff --git a/src/frontend/app/process/process.component.html b/src/frontend/app/process/process.component.html
index e522ca380f2..b5ec5b245d3 100644
--- a/src/frontend/app/process/process.component.html
+++ b/src/frontend/app/process/process.component.html
@@ -79,7 +79,7 @@
                 <app-visa-workflow *ngIf="currentTool === 'visa' && !loading" #appVisaWorkflow
                     [resId]="currentResourceInformations.resId" [adminMode]="privilegeService.hasCurrentUserPrivilege('config_visa_workflow')"></app-visa-workflow>
                 <app-avis-workflow *ngIf="currentTool === 'avis' && !loading" #appAvisWorkflow
-                    [resId]="currentResourceInformations.resId"></app-avis-workflow>
+                    [resId]="currentResourceInformations.resId" [adminMode]="privilegeService.hasCurrentUserPrivilege('config_avis_workflow')"></app-avis-workflow>
                 <app-attachments-list *ngIf="currentTool === 'attachments' && !loading" #appAttachmentsList
                     [resId]="currentResourceInformations.resId" [target]="'process'"
                     (reloadBadgeAttachments)="refreshBadge($event,'attachments')">
@@ -89,19 +89,9 @@
                     [mode]="'process'" [canEdit]="canEditData" [hideDiffusionList]="true"
                     (loadingFormEndEvent)="triggerProcessAction()"></app-indexing-form>
                 <div style="position: sticky;bottom: 0px;text-align:right;">
-                    <button mat-fab
-                        *ngIf="indexingForm !== undefined && indexingForm.isResourceModified() && currentTool === 'info'"
-                        (click)="confirmModification()" color="accent" [title]="lang.saveModifications">
-                        <mat-icon style="height:auto;font-size:20px;" class="fas fa-check"></mat-icon>
-                    </button>
-                    <button mat-fab [title]="lang.saveModifications"
-                        *ngIf="appDiffusionsList !== undefined && appDiffusionsList.isModified() && currentTool === 'diffusionList'"
-                        (click)="saveListinstance()" color="accent">
-                        <mat-icon style="height:auto;font-size:20px;" class="fas fa-check"></mat-icon>
-                    </button>
                     <button mat-fab [title]="lang.saveModifications"
-                        *ngIf="appVisaWorkflow !== undefined && appVisaWorkflow.isModified() && currentTool === 'visa'"
-                        (click)="saveVisaWorkflow()" color="accent">
+                        *ngIf="isToolModified()"
+                        (click)="saveTool()" color="accent">
                         <mat-icon style="height:auto;font-size:20px;" class="fas fa-check"></mat-icon>
                     </button>
                 </div>
@@ -258,7 +248,7 @@
             [resId]="currentResourceInformations.resId" >
         </app-visa-workflow>
         <app-avis-workflow *ngIf="modal.id === 'avis' && !loading" #appAvisWorkflow
-            [resId]="currentResourceInformations.resId">
+            [resId]="currentResourceInformations.resId" [adminMode]="privilegeService.hasCurrentUserPrivilege('config_avis_workflow')">
         </app-avis-workflow>
         <app-attachments-list *ngIf="modal.id === 'attachments'  && !loading" #appAttachmentsList
             [resId]="currentResourceInformations.resId" (reloadBadgeAttachments)="refreshBadge($event,'attachments')">
diff --git a/src/frontend/app/process/process.component.ts b/src/frontend/app/process/process.component.ts
index 64319f6a440..01474838c9e 100755
--- a/src/frontend/app/process/process.component.ts
+++ b/src/frontend/app/process/process.component.ts
@@ -23,6 +23,7 @@ import { DiffusionsListComponent } from '../diffusions/diffusions-list.component
 import { ContactService } from '../../service/contact.service';
 import { VisaWorkflowComponent } from '../visa/visa-workflow.component';
 import { PrivilegeService } from '../../service/privileges.service';
+import { AvisWorkflowComponent } from '../avis/avis-workflow.component';
 
 
 
@@ -140,6 +141,7 @@ export class ProcessComponent implements OnInit {
     @ViewChild('indexingForm', { static: false }) indexingForm: IndexingFormComponent;
     @ViewChild('appDiffusionsList', { static: false }) appDiffusionsList: DiffusionsListComponent;
     @ViewChild('appVisaWorkflow', { static: false }) appVisaWorkflow: VisaWorkflowComponent;
+    @ViewChild('appAvisWorkflow', { static: false }) appAvisWorkflow: AvisWorkflowComponent;
     senderLightInfo: any = { 'displayName': null, 'fillingRate': null };
     hasContact: boolean = false;
 
@@ -494,11 +496,13 @@ export class ProcessComponent implements OnInit {
     }
 
     isToolModified() {
-        if (this.currentTool === 'info' && this.indexingForm.isResourceModified()) {
+        if (this.currentTool === 'info' && this.indexingForm !== undefined && this.indexingForm.isResourceModified()) {
             return true;
-        } else if (this.currentTool === 'diffusionList' && this.appDiffusionsList.isModified()) {
+        } else if (this.currentTool === 'diffusionList' && this.appDiffusionsList !== undefined && this.appDiffusionsList.isModified()) {
             return true;
-        } else if (this.currentTool === 'visa' && this.appVisaWorkflow.isModified()) {
+        } else if (this.currentTool === 'visa' && this.appVisaWorkflow !== undefined && this.appVisaWorkflow.isModified()) {
+            return true;
+        } else if (this.currentTool === 'avis' && this.appAvisWorkflow !== undefined && this.appAvisWorkflow.isModified()) {
             return true;
         } else {
             return false;
@@ -514,12 +518,14 @@ export class ProcessComponent implements OnInit {
     }
 
     saveTool() {
-        if (this.currentTool === 'info') {
+        if (this.currentTool === 'info' && this.indexingForm !== undefined) {
             this.indexingForm.saveData(this.currentUserId, this.currentGroupId, this.currentBasketId);
-        } else if (this.currentTool === 'diffusionList') {
+        } else if (this.currentTool === 'diffusionList' && this.appDiffusionsList !== undefined) {
             this.appDiffusionsList.saveListinstance();
-        } else if (this.currentTool === 'visa') {
+        } else if (this.currentTool === 'visa' && this.appVisaWorkflow !== undefined) {
             this.appVisaWorkflow.saveVisaWorkflow();
+        } else if (this.currentTool === 'avis' && this.appAvisWorkflow !== undefined) {
+            this.appAvisWorkflow.saveAvisWorkflow();
         }
     }
 
diff --git a/src/frontend/app/visa/visa-workflow.component.ts b/src/frontend/app/visa/visa-workflow.component.ts
index df8b0631ad4..537bc4630e4 100644
--- a/src/frontend/app/visa/visa-workflow.component.ts
+++ b/src/frontend/app/visa/visa-workflow.component.ts
@@ -397,11 +397,6 @@ export class VisaWorkflowComponent implements OnInit {
         ).subscribe();
     }
 
-    addItem(userRest: any) {
-        
-
-    }
-
     getMaarchParapheurUserAvatar(externalId: string, key: number) {
         if (!this.functions.empty(externalId)) {
             this.http.get("../../rest/maarchParapheur/user/" + externalId + "/picture")
diff --git a/src/frontend/lang/lang-en.ts b/src/frontend/lang/lang-en.ts
index c2801276fac..9dfa6774736 100755
--- a/src/frontend/lang/lang-en.ts
+++ b/src/frontend/lang/lang-en.ts
@@ -1392,5 +1392,9 @@ export const LANG_EN = {
     "publicModel" : "Public model",
     "privateModel" : "Private model",
     "moveVisaUserErr1" : "You cannot move", 
-    "moveVisaUserErr2" : "with users who have already approved / signed", 
+    "moveVisaUserErr2" : "with users who have already approved / signed",
+    "moveAvisUserErr1" : "You cannot move", 
+    "moveAvisUserErr2" : "with users who have already given an opinion", 
+    "avisWorkflowUpdated" : "Opinion workflow updated",
+    "avisSent" : "Opinion given",
 };
diff --git a/src/frontend/lang/lang-fr.ts b/src/frontend/lang/lang-fr.ts
index 00b1ee8b950..21270cfcd60 100755
--- a/src/frontend/lang/lang-fr.ts
+++ b/src/frontend/lang/lang-fr.ts
@@ -1420,7 +1420,7 @@ export const LANG_FR = {
     "closeEditor" : "Fermer l'éditeur",
     "errorOnlyoffice1" : "Impossible de lancer onlyoffice, vous utilisez une adresse locale",
     "errorOnlyoffice2" : "Impossible de lancer onlyoffice. Veuillez vérifier la disponibilité du serveur",
-    "externalVisaWorkflow" : "Circuit de visa Maarch parapheur",
+    "externalVisaWorkflow" : "Circuit de visa Maarch Parapheur",
     "IdMaarch2Gec" : "Identifiant MAARCH2GEC",
     "indexingFile" : "Indexation",
     "signUserRequired" : "Un signataire mininum est requis",
@@ -1433,4 +1433,8 @@ export const LANG_FR = {
     "privateModel" : "Modèle privé", 
     "moveVisaUserErr1" : "Vous ne pouvez pas déplacer", 
     "moveVisaUserErr2" : "avec des personnes ayant déja visé / signé", 
+    "moveAvisUserErr1" : "Vous ne pouvez pas déplacer", 
+    "moveAvisUserErr2" : "avec des personnes ayant déja donné un avis", 
+    "avisWorkflowUpdated" : "Circuit d'avis modifié",
+    "avisSent" : "Avis donné",
 };
diff --git a/src/frontend/lang/lang-nl.ts b/src/frontend/lang/lang-nl.ts
index 47bb4296555..4408bf5d486 100755
--- a/src/frontend/lang/lang-nl.ts
+++ b/src/frontend/lang/lang-nl.ts
@@ -1416,4 +1416,8 @@ export const LANG_NL = {
     "privateModel" : "Private model", //_TO_TRANSLATE
     "moveVisaUserErr1" : "You cannot move", //_TO_TRANSLATE
     "moveVisaUserErr2" : "with users who have already approved / signed", //_TO_TRANSLATE
+    "moveAvisUserErr1" : "You cannot move", //_TO_TRANSLATE
+    "moveAvisUserErr2" : "with users who have already given an opinion", //_TO_TRANSLATE
+    "avisWorkflowUpdated" : "Opinion workflow updated", //_TO_TRANSLATE
+    "avisSent" : "Opinion given", //_TO_TRANSLATE
 };
-- 
GitLab