diff --git a/.env.example b/.env.example index 898e575a2bd8160b2f80a9c099ebbb4f77b9bc5f..2f8f52ea75e466b1dca1b26b4c278202ff5743c1 100644 --- a/.env.example +++ b/.env.example @@ -60,5 +60,6 @@ ADMIN_PASSWORD=secret APP_FAVICON_PATH="logo_madis_2020_favicon_white.png" REFERENTIEL_VERSION="1.3" +SEUIL_ALERTE=1 LOGO_SIDEBAR="logo-soluris.png" FOOTER_LINK=https://www.soluris.fr/ diff --git a/README.md b/README.md index da2054cba05e3f80445282c6c38bcbb432fef676..9e3b2bacd92bbf5153d782e2df95df7c294cb25a 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ ## Installation sur serveur linux - `ADMIN_LASTNAME` : cela sera le nom de l'utilisateur administrateur que nous allons créer par la suite - `APP_FAVICON_PATH` : (_optionnel_) le chemin vers l'image à utiliser comme icone d'onglet (image à placer dans le dossier public/images) - `REFERENTIEL_VERSION` : La version du référentiel à utiliser pour les évaluations +- `SEUIL_ALERTE` : permet la comparaison du niveau des measures fondamentales avec le seuil d'alerte. Sa valeur peut aller de -1 (ne pas prendre en compte) à 3. + Installez les dépendances php avec composer en lançant la commande suivante : `composer install --optimize-autoloader --no-dev` diff --git a/app/Http/Controllers/Api/GraphDataController.php b/app/Http/Controllers/Api/GraphDataController.php index b2a856be138141cedd21920da5d10e429c2f7bef..bfb61675f4bf18d6d3c97a04bcf0a404ff4efde7 100644 --- a/app/Http/Controllers/Api/GraphDataController.php +++ b/app/Http/Controllers/Api/GraphDataController.php @@ -85,6 +85,22 @@ public function attack(int $id): Collection|array|\Illuminate\Support\Collection return $this->repository->getAttackDataForEvaluation($evaluation); } + /** + * Get ActionTerritory graph data. + */ + public function actionterritory(): Collection|array|\Illuminate\Support\Collection + { + return $this->repository->getActionTerritoryGraphData(); + } + + /** + * Get organizations data. + */ + public function organizations(): Collection|array|\Illuminate\Support\Collection + { + return $this->repository->getOrganizationData(); + } + /** * @param $id */ diff --git a/app/Models/Evaluation.php b/app/Models/Evaluation.php index 433a297e0e71195f8cf0a32d82517c5778d0d0a5..95b2e17454d60eb9dccb2272bc8768ffc1e40c74 100644 --- a/app/Models/Evaluation.php +++ b/app/Models/Evaluation.php @@ -35,6 +35,7 @@ * @property string $updated_by * @property float $maturityCyber * @property float $maturity_cyber + * @property string $reference */ class Evaluation extends Model { diff --git a/app/Models/MeasureLevel.php b/app/Models/MeasureLevel.php index ce5cfd3f539c8ec0470294338156a10793ed356e..2150194050c11ee26c09689071f728f9e6cac4cb 100644 --- a/app/Models/MeasureLevel.php +++ b/app/Models/MeasureLevel.php @@ -18,6 +18,7 @@ * @property int|null $actual_level * @property int|null $expected_level * @property DateTime|null $end_date + * @property string|null $manager */ class MeasureLevel extends Model { diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 5152623545c3d1e8a8fa8ed8dbb011cf80b37eca..3aa9ababfeb424d87d2f010faabc3405453200bf 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -7,27 +7,29 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection; /** - * @property int $id - * @property string $name - * @property string $short_name - * @property string $type - * @property string $siren - * @property bool $active - * @property string $website - * @property string $info - * @property Address $address - * @property Contact $referent - * @property int $referent_id - * @property Contact $referent_cyber - * @property Contact $referentCyber - * @property int $referent_cyber_id - * @property Contact $referent_elu - * @property Contact $referentElu - * @property int $referent_elu_id - * @property Evaluation[]|iterable $evaluations - * @property int $territory_id + * @property int $id + * @property string $name + * @property string $short_name + * @property string $type + * @property string $siren + * @property bool $active + * @property string $website + * @property string $info + * @property Address $address + * @property Contact $referent + * @property int $referent_id + * @property Contact $referent_cyber + * @property Contact $referentCyber + * @property int $referent_cyber_id + * @property Contact $referent_elu + * @property Contact $referentElu + * @property int $referent_elu_id + * @property Evaluation[]|Collection<Evaluation> $evaluations + * @property Evaluation[]|Collection<Evaluation> $doneEvaluations + * @property int $territory_id */ class Organization extends Model { @@ -41,7 +43,12 @@ public function users(): HasMany public function evaluations(): HasMany { - return $this->hasMany(Evaluation::class); + return $this->hasMany(Evaluation::class)->orderBy('updated_at', 'desc'); + } + + public function doneEvaluations(): HasMany + { + return $this->evaluations()->where('status', Evaluation::STATUS_DONE); } public function address(): BelongsTo diff --git a/app/Models/Territory.php b/app/Models/Territory.php index 9d7d5b475897c051cf4472df2ef0f5d25ab133bc..1f289cb49ba5ff621a2e9702a5db8dfd6e52845c 100644 --- a/app/Models/Territory.php +++ b/app/Models/Territory.php @@ -5,9 +5,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection; /** - * @property Organization $organization + * @property int $id + * @property string $name + * @property Organization[]|Collection<Organization> $organizations */ class Territory extends Model { diff --git a/app/Repository/GraphDataRepository.php b/app/Repository/GraphDataRepository.php index 5dd4d3244e05a8cc987b1e25b618c40941bd6c03..7efe57dd02d1bab194c9c9655fec313f894114fd 100644 --- a/app/Repository/GraphDataRepository.php +++ b/app/Repository/GraphDataRepository.php @@ -8,7 +8,9 @@ use App\Models\EvaluationMaturityAnswer; use App\Models\Measure; use App\Models\MeasureLevel; +use App\Models\Organization; use App\Models\Scenario; +use App\Models\Territory; class GraphDataRepository { @@ -242,4 +244,162 @@ private function getFutureExpositionPointsForScenario(Scenario $scenario, $measu return floor(100 - ($points / $count)); } + + public function getActionTerritoryGraphData(): array + { + // Pour cq territoire + // trouver les evals des structures + // sort les evals pour recup currentEvaluation & previousEvaluation + // puis plannedMeasures par structure + // puis doneMeasure par structure + // puis count par territoire + + $territories = Territory::with([ + 'organizations', + 'organizations.evaluations', + 'organizations.evaluations.measureLevels', + ])->get(); + + $labels = []; + $plannedMeasures = []; + $doneMeasures = []; + + foreach ($territories as $territory) { + /* + * @var Territory $territory + */ + $labels[] = $territory->name; + + $plannedMeasures[] = $territory->organizations->filter(function (Organization $org) { + return $org->active; + })->reduce(function ($acc, Organization $org) { + $doneEvaluations = $org->doneEvaluations; + if ($doneEvaluations->count() >= 2) { + /** + * @var Evaluation $previousEval + */ + $previousEval = $doneEvaluations[1]; + + // Get the number of planned actions in the previous evaluation + return $acc + $previousEval->measureLevels->filter(function (MeasureLevel $ml) { + return $ml->expected_level && $ml->expected_level > $ml->actual_level; + })->count(); + } + + return $acc; + }, 0); + + $doneMeasures[] = $territory->organizations->filter(function (Organization $org) { + return $org->active; + })->reduce(function ($acc, Organization $org) { + $doneEvaluations = $org->doneEvaluations; + if ($doneEvaluations->count() >= 2) { + /** + * @var Evaluation $currentEval + */ + $currentEval = $doneEvaluations[0]; + /** + * @var Evaluation $previousEval + */ + $previousEval = $doneEvaluations[1]; + + // Get the number of planned actions in the previous evaluation + // that have reached the expected_level in the current evaluation + return $acc + $currentEval->measureLevels->filter(function (MeasureLevel $ml) use ($previousEval) { + $prevML = $previousEval->measureLevels->first(function (MeasureLevel $pml) use ($ml) { + return $ml->measure_id === $pml->measure_id; + }); + + return $prevML && $ml->actual_level >= $prevML->expected_level; + })->count(); + } + + return $acc; + }, 0); + } + + return [ + 'labels' => $labels, + 'data' => [$plannedMeasures, $doneMeasures], + ]; + } + + public function getOrganizationData() + { + $orgs = Organization::where('active', true)->get(); + + $count = $orgs->count(); + + $meanMaturity = $orgs->reduce(function ($acc, Organization $organization) { + /** + * @var Evaluation|null $doneEvaluation + */ + $doneEvaluation = $organization->doneEvaluations->first(); + + if (!$doneEvaluation) { + return $acc; + } + + return $acc + $doneEvaluation->getMaturityCyberAttribute(); + }, 0) / $count; + + $topMeasures = []; + + foreach ($orgs as $org) { + /** + * @var Evaluation|null $currentEvaluation + */ + $currentEvaluation = $org->doneEvaluations->first(); + + if (!$currentEvaluation) { + continue; + } + + foreach ($currentEvaluation->measureLevels as $level) { + if (!$level->expected_level) { + continue; + } + if (array_key_exists($level->measure->short_name, $topMeasures)) { + ++$topMeasures[$level->measure->short_name]['level' . $level->expected_level]; + ++$topMeasures[$level->measure->short_name]['organizations']; + } else { + $topMeasures[$level->measure->short_name] = [ + 'short_name' => $level->measure->short_name, + 'level1' => 1 === $level->expected_level ? 1 : 0, + 'level2' => 2 === $level->expected_level ? 1 : 0, + 'level3' => 3 === $level->expected_level ? 1 : 0, + 'organizations' => 1, + ]; + } + } + } + + + + $topMeasures = collect($topMeasures)->map(function ($topMeasure) use ($count) { + $levelValues = [ + $topMeasure['level1'], + $topMeasure['level2'], + $topMeasure['level3'], + ]; + + $maxValue = max($levelValues); + + $maxLevels = array_keys($levelValues, $maxValue); + + return [ + 'short_name' => $topMeasure['short_name'], + 'organizations' => $topMeasure['organizations'], + 'max_levels' => $maxLevels, + 'max_value' => $maxValue, + 'percentage' => 100 * $maxValue / $count, + ]; + })->values()->sortBy('max_value', SORT_NUMERIC, true)->splice(0, 3); + + return [ + 'maturity' => $meanMaturity, // Nombre de structures évaluées + 'count' => $count, // Indice de maturité moyen des structures actives + 'top_measures' => $topMeasures, // Top 3 des niveaux de mesures planifiées (doit retourner le nom court de la mesure, le niveau et le nombre de structures concernées / ou directement le %) + ]; + } } diff --git a/package-lock.json b/package-lock.json index f31e81d2c8885cda7040a3d771d57fe8456f71c6..0db78ae6aba7c9c9b97dd3b9709a00439cbb7833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "madis-cyber", + "name": "code", "lockfileVersion": 2, "requires": true, "packages": { @@ -9,6 +9,7 @@ "@ckeditor/ckeditor5-vue2": "^3.0.1", "@fortawesome/fontawesome-free": "^5.15.4", "chart.js": "^3.9.1", + "chartjs-plugin-stacked100": "^1.2.1", "dotenv": "^16.0.1", "vue-chartjs": "^4.1.1", "vue-property-decorator": "^9.1.2", @@ -4080,6 +4081,14 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/chartjs-plugin-stacked100": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/chartjs-plugin-stacked100/-/chartjs-plugin-stacked100-1.2.1.tgz", + "integrity": "sha512-6RIvM1gBSXVFTcYs1kkTzPVIzMgJtRO7KNADwHzcVqqQw7xKrIOZfGQ4fXGF8FZtQ26KzjqmNUllPgx4GhlMng==", + "dependencies": { + "chart.js": "^3.8.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -15908,6 +15917,14 @@ "color-name": "^1.0.0" } }, + "chartjs-plugin-stacked100": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/chartjs-plugin-stacked100/-/chartjs-plugin-stacked100-1.2.1.tgz", + "integrity": "sha512-6RIvM1gBSXVFTcYs1kkTzPVIzMgJtRO7KNADwHzcVqqQw7xKrIOZfGQ4fXGF8FZtQ26KzjqmNUllPgx4GhlMng==", + "requires": { + "chart.js": "^3.8.0" + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", diff --git a/package.json b/package.json index 94ddafd937d6ea892ee2c905576d2624646011c7..5c88862722151c2380dddd6f99f7ce2277ef33e5 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@ckeditor/ckeditor5-vue2": "^3.0.1", "@fortawesome/fontawesome-free": "^5.15.4", "chart.js": "^3.9.1", + "chartjs-plugin-stacked100": "^1.2.1", "dotenv": "^16.0.1", "vue-chartjs": "^4.1.1", "vue-property-decorator": "^9.1.2", diff --git a/resources/js/app.js b/resources/js/app.js index 918746fd906f4aafe950818a6a007b0d31273360..743499be3bde59431da2740abf9b03604a0e9330 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -22,6 +22,7 @@ import stores from './stores' require('./bootstrap') Vue.prototype.$reference = window.referentielVersion +Vue.prototype.$seuilAlerte = window.seuilAlerte Vue.prototype.$footerLink = window.footerLink Vue.prototype.$logoSidebar = window.logoSidebar Vue.prototype.$appVersion = window.appVersion diff --git a/resources/js/components/Dashboard/AdminView/Components/ActionTerritory.vue b/resources/js/components/Dashboard/AdminView/Components/ActionTerritory.vue new file mode 100644 index 0000000000000000000000000000000000000000..29b4eb9a3b629b5f1278b30aad9abc73abd9d39f --- /dev/null +++ b/resources/js/components/Dashboard/AdminView/Components/ActionTerritory.vue @@ -0,0 +1,73 @@ +<template> + <div class="card py-3 px-2"> + <div class="card-title mb-3"> Actions par territoire</div> + <Bar v-if="chartData" :chart-data="chartData" :chart-options="options" /> + </div> +</template> + +<script> +import {Bar} from 'vue-chartjs/legacy' +import { Chart as ChartJS, Title, Tooltip, Legend, RadialLinearScale, registerables} from 'chart.js' + +ChartJS.register(Title, Tooltip, Legend, RadialLinearScale, ...registerables) + +/* COUNT AVERAGE ACTION PLANNED & ACTION DONE PER TERRITORY. */ +export default { + name: 'ActionTerritory', + components: { + Bar, + }, + data () { + return { + chartData : {}, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + }, + }, + layout: { + padding: 0, + }, + scales: { + x: { + grid: { + drawBorder: false, + }, + ticks : { + // display: false, + } + }, + y: { + grid: { + drawBorder: false, + }, + ticks : { + // display:false, + } + } + } + }, + } + }, + mounted () { + this.getActivesOrganizations() + }, + methods: { + async getActivesOrganizations () { + await axios.get(`/api/graphs/actionterritory`) + .then( res => { // data : [ [0,0,0], [0,0,0] ] , labels : [] + + const datasets = res.data.data.map( (d, i) => ({ data : d, backgroundColor : i === 1 ? '#00c6c3' : '#ff327b', label : i === 1 ? "Nombre moyen d'actions prévues" : "Nombre moyen d'actions réalisées" })) + + this.chartData = { + labels : res.data.labels, + datasets + } + }) + }, + } +} +</script> \ No newline at end of file diff --git a/resources/js/components/Dashboard/AdminView/Components/Exports.vue b/resources/js/components/Dashboard/AdminView/Components/Exports.vue new file mode 100644 index 0000000000000000000000000000000000000000..51a3e8bc06a2350b869a524449d0475c29620960 --- /dev/null +++ b/resources/js/components/Dashboard/AdminView/Components/Exports.vue @@ -0,0 +1,40 @@ +<template> + <div class="row"> + <div class="col-md-6"> + <div class="card"> + <div class="card-body">Exporter les évaluations</div> + <a href="#"> + <div class="card-footer bg-primary p-1 text-center"> + Exporter <i class="fas fa-arrow-circle-right"></i> + </div> + </a> + </div> + </div> + <div class="col-md-6"> + <div class="card"> + <div class="card-body ">Exporter les structures</div> + + <a href="#"> + <div class="card-footer bg-primary p-1 text-center"> + Exporter <i class="fas fa-arrow-circle-right"></i> + </div> + </a> + + </div> + </div> + </div> +</template> + +<script> +export default { + name: 'ActionTerritory', + props : [], + data () { + return { + } + }, + computed: { + + } +} +</script> \ No newline at end of file diff --git a/resources/js/components/Dashboard/AdminView/Components/MaturityCyberByTerritory.vue b/resources/js/components/Dashboard/AdminView/Components/MaturityCyberByTerritory.vue new file mode 100644 index 0000000000000000000000000000000000000000..d67a0da6a022f2554db532734056ea520a9bc9f7 --- /dev/null +++ b/resources/js/components/Dashboard/AdminView/Components/MaturityCyberByTerritory.vue @@ -0,0 +1,179 @@ +<template> + <div class="card"> + <div class="card flex-row bg-primary mb-0"> + <div class="card-body col-10"> + <div class="card-text"> + <div class="font-weight-bold h4 m-0">{{ getAverageMaturityCyber() }}</div> + Indice de maturité cyber moyen des structures + </div> + </div> + <div class="col-2"> + <i class="fas fa-chart-line" style="font-size: 50px;"></i> + </div> + </div> + <div class="card-body px-5"> + <PolarArea :chart-data="chartData" :chart-options="options" /> + <div class="row"> + <div v-for="t in territories" :key="t.id" class="col-md-4 border-right mb-4"> + <b>{{ t.name }}</b> <br/> + <small>Indice de maturité cyber</small> <br/> + <span v-if="t.organizations.length"> {{ t.current_maturity_cyber }}</span> <span v-else>-</span> + <div class="progress progress-xs"> + <div class="progress-bar bg-success" role="progressbar" :style="{ 'width' : `${t.current_maturity_cyber / 3 * 100}%`}" aria-valuemin="0" aria-valuemax="100"></div> + </div> + + <!-- SHOW % --> + <div v-if="isFinite(getEvolutionMaturityCyber(t.current_maturity_cyber, t.previous_maturity_cyber))"> + <div v-if="getEvolutionMaturityCyber(t.current_maturity_cyber, t.previous_maturity_cyber) === 0" class="mt-1 text-info"> + <i class="fas fa-equals"></i> <small> {{ getEvolutionMaturityCyber(t.current_maturity_cyber, t.previous_maturity_cyber) }} %</small> + </div> + <div v-else-if="getEvolutionMaturityCyber(t.current_maturity_cyber, t.previous_maturity_cyber) > 0" class="mt-1 text-success"> + <i class="fas fa-arrow-up"></i> <small>+ {{ getEvolutionMaturityCyber(t.current_maturity_cyber, t.previous_maturity_cyber) }} %</small> + </div> + <div v-else class="mt-1 text-danger"> + <i class="fas fa-arrow-down"></i> <small> {{ getEvolutionMaturityCyber(t.current_maturity_cyber, t.previous_maturity_cyber) }} %</small> + </div> + + </div> + </div> + </div> + </div> + </div> +</template> + +<script> +/* Pour tous les territoires +Récupérer les structures actives +Pour chaque structures > récupérer la dernière evaluation et l'avant dernière +Pour chaque evaluation > récupérer l'indice de maturité de la derniere et avant derniere eval. + + */ +import { PolarArea } from 'vue-chartjs/legacy' +import { Chart as ChartJS, Title, Tooltip, Legend, RadialLinearScale, registerables} from 'chart.js' + +ChartJS.register(Title, Tooltip, Legend, RadialLinearScale, ...registerables) + +export default { + name: 'MaturityCyberCard', + components: { + PolarArea, + }, + props : [], + data () { + return { + options: { + plugins: { + legend: { + // display: false + position: 'right', + }, + }, + responsive: true, + scales: { + r: { + min: 0, + ticks: { + color: '#000', + backdropColor: 'rgba(255,255,255, 0.5)', + z: 10, + callback: function (value) { if (Number.isInteger(value)) { return value; } }, + }, + pointLabels: { + display: true, + centerPointLabels: true, + font: { + size: 12 + } + } + } + } + }, + } + }, + computed: { + evaluations () { + return this.$store.state.evaluations.all + }, + chartData() { + const labels = this.territories.map(t => t.name) + const data = [1,2,3,4,5]; + + return { + labels, + datasets: [ + { + data, + backgroundColor: [ + 'rgba(255, 99, 132, 0.5)', // rouge + 'rgba(243, 156, 18, 0.5)', // orange + 'rgba(75, 192, 192, 0.5)', // vert + 'rgba(255, 221, 86, 0.5)', // jaune + 'rgba(54, 162, 235, 0.5)' // bleu + ] + } + ] + } + }, + territories () { + // GET EVALUATIONS PER ORGA IN TERRITORY TO REACH MATURITY CYBER INDICE + const territories = this.$store.state.territories.all.map( territory => ( + {...territory, + organizations : territory.organizations + .map( org => ({...org, + evaluations : this.evaluations.filter( e => e.organization_id === org.id) + .filter( e => e.status === 2) + .sort( (a,b) => new Date(b.updated_at) - new Date(a.updated_at)) + })) + .filter(org => org.active) + } + )) + // SET MATURITY CYBER INDICE TO ORG + return territories.map( territory => ( + {...territory, + organizations : territory.organizations.map( org => ({...org, + current_maturity_cyber : org.evaluations[0] && org.evaluations[0].maturity_cyber, + previous_maturity_cyber : org.evaluations[1] && org.evaluations[1].maturity_cyber, + }) + ) + } + // SET ARRAY OF MC INDICE IN TERRITORY THEN CALC AVERAGE MATURITY CYBER + )).map( territory => ({...territory, + current_maturity_cyber : territory.organizations.map(org => org.current_maturity_cyber).filter(mc => mc).reduce((partialSum, a) => partialSum + a, 0) / territory.organizations.length, + previous_maturity_cyber : territory.organizations.map(org => org.previous_maturity_cyber).filter(mc => mc).reduce((partialSum, a) => partialSum + a, 0) / territory.organizations.length, + } + )) + }, + + }, + watch : { + }, + mounted () { + }, + methods : { + getEvolutionMaturityCyber (newMC, oldMC) { + const pourcent = (newMC - oldMC) / oldMC * 100 + return Math.sign(pourcent) * Math.round(Math.abs(pourcent)) || 0 + }, + getAverageMaturityCyber () { + const data = [] + this.territories.forEach( t => t.organizations.forEach(org => { + if (org.active) { + data.push(org.current_maturity_cyber || 0) + } + })) + console.log('data', data) + return (data.reduce((partialSum, a) => partialSum + a, 0) / data.length).toFixed(2) + } + }, +} +</script> +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue b/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue new file mode 100644 index 0000000000000000000000000000000000000000..b33d0655c15234a656b08d5949e9841a5f356f39 --- /dev/null +++ b/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue @@ -0,0 +1,19 @@ +<template> + <div class="card py-3 px-2"> + <div class="card-title mb-3">Cartographie de la maturité cyber</div> + </div> +</template> + +<script> +export default { + name: 'MaturityCyberMap', + props : [], + data () { + return { + } + }, + computed: { + + } +} +</script> \ No newline at end of file diff --git a/resources/js/components/Dashboard/AdminView/Components/MeasurePlanned.vue b/resources/js/components/Dashboard/AdminView/Components/MeasurePlanned.vue new file mode 100644 index 0000000000000000000000000000000000000000..7622d6b67288169cc4016c62cb88afe7545f6f55 --- /dev/null +++ b/resources/js/components/Dashboard/AdminView/Components/MeasurePlanned.vue @@ -0,0 +1,120 @@ +<template> + <div class="card py-3 px-2"> + <div class="card-title mb-3"> Top 3 des niveaux de mesures de sécurité planifiées</div> + + <select v-model="chooseTerritoryId" class="form-control form-control-sm mb-3"> + <option value="">Tous les territoires</option> + <option v-for="t in selectTerritories" :key="t.id" :value="t.id">{{t.name}}</option> + </select> + + <div v-for="(measure, i) in favoriteMeasures" :key="measure.measure_id" class="card flex-row"> + <div class="col-2 bg-primary font-weight-bold"> + <span style="font-size: 50px;">{{ i + 1 }}</span> + </div> + <div class="card-body col-10 py-2"> + <div v-if="measures.length" class="card-text"> + {{ measures.find(m => m.id == measure.measure_id).short_name }} + <div class="small font-weight-bold">Niveau {{ measure.expected_level }}</div> + </div> + <div class="progress progress-xs my-1"> + <div + class="progress-bar" + :class="measure.expected_level == 1 ? 'bg-danger' : measure.expected_level == 2 ? 'bg-info' : measure.expected_level == 3 ? 'bg-success' : 'black'" + role="progressbar" + :style="{width: measure.expected_level == 1 ? '33%' : measure.expected_level == 2 ? '66%' : measure.expected_level == 3 ? '100%' : '0%'}" + aria-valuemin="0" aria-valuemax="100"></div> + </div> + <div v-if="organizationsNumber" class="card-text"> + <!-- if float -> tofixed(2) --> + {{ (measure.count / organizationsNumber * 100) % 1 === 0 ? measure.count / organizationsNumber * 100 : (measure.count / organizationsNumber * 100).toFixed(2) }} % des structures + </div> + </div> + </div> + <div v-if="!favoriteMeasures.length" class="text-center small">Aucune mesure</div> + </div> +</template> + +<script> +/* nombre de niveau de mesures de sécurité mis en Å“uvre /30)*100 */ +export default { + name: 'MeasureCard', + props : [], + data () { + return { + chooseTerritoryId : "", + } + }, + computed: { + measures () { + return this.$store.state.measures.all + }, + evaluations () { + return this.$store.state.evaluations.all + }, + selectTerritories () { + return this.$store.state.territories.all + }, + territories () { + return this.$store.state.territories.all + .filter(t => this.chooseTerritoryId ? t.id === this.chooseTerritoryId : true) + .map( t => ({...t, + organizations : t.organizations + .map( org => ({...org, + evaluations : this.evaluations.filter( e => e.organization_id === org.id) + .filter( e => e.status === 2) + .sort( (a,b) => new Date(b.updated_at) - new Date(a.updated_at)) , + })) + .filter(org => org.active) + }) + ) + }, + organizationsNumber() { + let count = 0 + this.territories && this.territories.forEach( t => { + count = count + t.organizations.length + }) + return count + }, + favoriteMeasures () { + const data = {} // {1 : { 1:1, 2:1, 3:2}, 2 : {...}} to 10 + const topSorted = [] // [ {measure_id : 1, level_expected : 3, count : 0.75}, ... ] + + this.territories.forEach( t => { + t.organizations.forEach( org => { + const currentEvaluation = org.evaluations.length && org.evaluations[0] + currentEvaluation && currentEvaluation.measure_levels.forEach( ml => { + // INIT MEASURE OBJECT + data[ml.measure_id] = data[ml.measure_id] ? data[ml.measure_id] : {} + if (ml.expected_level) { + // ADD 1 TO MEASURE LEVEL EXPECTED + if (!data[ml.measure_id][ml.expected_level]) {data[ml.measure_id][ml.expected_level] = 0} + data[ml.measure_id][ml.expected_level] = data[ml.measure_id][ml.expected_level] + 1 + } + }) + Object.keys(data).forEach( measureId => { + Object.keys(data[measureId]).forEach( expectedLevel => { + + const count = data[measureId][expectedLevel] + const obj = {measure_id : measureId, expected_level : expectedLevel, count } + + topSorted.push(obj) + }) + }) + }) + }) + return topSorted.sort( (a,b) => b.count - a.count).slice(0, 3) + } + + } +} +</script> +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/AdminView/Components/MeasuresInAction.vue b/resources/js/components/Dashboard/AdminView/Components/MeasuresInAction.vue new file mode 100644 index 0000000000000000000000000000000000000000..7ddc78a421977f55e17315fea875fbd82c5bf3f1 --- /dev/null +++ b/resources/js/components/Dashboard/AdminView/Components/MeasuresInAction.vue @@ -0,0 +1,113 @@ +<template> + <div class="card py-3 px-2"> + <div class="card-title mb-3">Mesures de sécurité retenues dans les plans d'actions</div> + <Bar v-if="chartData" :chart-data="chartData" :chart-options="options" /> <!-- :height="180" --> + </div> +</template> + +<script> +import {Bar} from 'vue-chartjs/legacy' +import ChartjsPluginStacked100 from "chartjs-plugin-stacked100"; +import { Chart as ChartJS, Title, Tooltip, Legend, RadialLinearScale, registerables} from 'chart.js' + +ChartJS.register(ChartjsPluginStacked100, Title, Tooltip, Legend, RadialLinearScale, ...registerables) + +export default { + name: 'MeasuresInAction', + components : { + Bar, + }, + data () { + return { + data1 : [], + data2 : [], + data3 : [], + + options: { + indexAxis: 'y', + // responsive: false, + // maintainAspectRatio: false, + plugins: { + stacked100 : { + enable : true, + }, + legend: { + }, + }, + layout: { + padding: 0, + }, + scales: { + y: { + grid: { + drawBorder: false, + }, + stacked: true, + }, + x: { + grid: { + drawBorder: false, + }, + stacked: true, + }, + yAxes: [{ + barThickness: 4, // number (pixels) or 'flex' + maxBarThickness: 5 // number (pixels) + }] + } + }, + } + }, + computed: { + evaluations () { + return this.$store.state.evaluations.all + }, + organizations () { + return this.$store.state.organizations.all + .map( org => ({...org, + evaluations : this.evaluations.filter( e => e.organization_id === org.id) + .filter( e => e.status === 2) + .sort( (a,b) => new Date(b.updated_at) - new Date(a.updated_at)) , + })) + .filter(org => org.active) + }, + chartData() { + // niveaux de mesures planifiés en moyenne dans les plan d’actions des structures actives + // Dans chaque Organisation active > Prendre la dernière evaluation > + // Regarder les measures_levels, mettre la measure id 1 à la première place du tableau correspondant au expected_level de la measure. + const labels = this.$store.state.measures.all.map( m => m.short_name) + + this.organizations.forEach(org => { + const currentEvaluation = org.evaluations[0] + currentEvaluation && currentEvaluation.measure_levels.forEach( ml => { + if (ml.expected_level) { + this[`data${ml.expected_level}`][ml.measure_id -1] = this[`data${ml.expected_level}`][ml.measure_id -1] ? this[`data${ml.expected_level}`][ml.measure_id -1] +1 : 1 + } + }) + }) + + + return { + labels, + datasets: [ + { + label: 'Niveau 1', + backgroundColor: '#ff327b', + data: this.data1 + }, + { + label: 'Niveau 2', + backgroundColor: '#0698ed', + data: this.data2 + }, + { + label: 'Niveau 3', + backgroundColor: '#00c6c3', + data: this.data3 + }, + ] + } + } + } +} +</script> \ No newline at end of file diff --git a/resources/js/components/Dashboard/AdminView/Components/OrganizationsAlert.vue b/resources/js/components/Dashboard/AdminView/Components/OrganizationsAlert.vue new file mode 100644 index 0000000000000000000000000000000000000000..4d9bfbd2c86d680928e847e91b746fd0e876e361 --- /dev/null +++ b/resources/js/components/Dashboard/AdminView/Components/OrganizationsAlert.vue @@ -0,0 +1,130 @@ +<template> + <div class="card"> + <div class="card flex-row bg-primary mb-0"> + <div class="card-body col-10"> + <div class="card-text"> + <div class="font-weight-bold h4 m-0">{{ organizationsAlert.length }}</div> + Structures en alerte dépassement de seuil + </div> + </div> + <div class="col-2"> + <i class="fas fa-exclamation-circle" style="font-size: 50px;"></i> + </div> + </div> + <div class="card-body px-5"> + <Doughnut v-if="chartData" :chart-data="chartData" :chart-options="options" /> + </div> + </div> +</template> + +<script> +/* +GET FUNDAMENTAL MEASURES (value 1) +IF THIS MEASURE LEVEL IS LOWER THAN seuilAlerte > “Seuil d’alerte dépassée = trueâ€. +IF seuilAlerte == -1 > SKIP + */ +import { Doughnut } from 'vue-chartjs/legacy' +import { Chart as ChartJS, Title, Tooltip, Legend, registerables} from 'chart.js' + +ChartJS.register(Title, Tooltip, Legend, ...registerables) + + +export default { + name: 'OrganizationsAlert', + components: { + Doughnut, + }, + props : [], + data () { + return { + seuilAlerte : parseInt(this.$seuilAlerte), + + options: { + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels : { + color : '#6b6b6b', + font: { + size : 16, + } + } + }, + }, + }, + } + }, + computed: { + fundamentalMeasures () { + return this.$store.state.measures.all.filter( m => m.fundamental) + }, + evaluations () { + return this.$store.state.evaluations.all + }, + territories () { + return this.$store.state.territories.all.map( territory => ( + {...territory, + organizations : territory.organizations + .map( org => ({...org, + evaluations : this.evaluations.filter( e => e.organization_id === org.id) + .filter( e => e.status === 2) + .sort( (a,b) => new Date(b.updated_at) - new Date(a.updated_at)) , + })) + .filter(org => org.active) + }) + ) + }, + organizationsAlert () { + const fundamentalMeasuresId = this.fundamentalMeasures.map(m => m.id) + + const organizations = this.territories.map( t => t.organizations) + return [].concat.apply([], organizations) + .filter( org => { + if (org.evaluations.length) { + const getInsideSeuilAlerte = org.evaluations[0].measure_levels + .filter( ml => fundamentalMeasuresId.includes(ml.measure_id)) // CHECK IF MEASURE IS FUNDAMENTAL + .filter( ml => ml.actual_level <= this.seuilAlerte) // THEN CHECK IF IN SEUIL ALERTE + return getInsideSeuilAlerte.length + } + return false + }) + }, + chartData() { + if (this.territories) { + const labels = this.territories.map( t => t.name); + const data = this.territories.map( t => t.organizations.filter( org => this.organizationsAlert.map(o => o.id).includes(org.id)).length); + + return { + labels, + datasets: [ + { + data, + backgroundColor: [ + 'rgb(255, 99, 132)', // rouge + 'rgb(243, 156, 18)', // orange + 'rgb(75, 192, 192)', // vert + 'rgb(255, 221, 86)', // jaune + 'rgb(54, 162, 235)' // bleu + ] + + } + ] + } + } + return {} + }, + } +} +</script> + +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/AdminView/Components/OrganizationsGraph.vue b/resources/js/components/Dashboard/AdminView/Components/OrganizationsGraph.vue new file mode 100644 index 0000000000000000000000000000000000000000..7f82e02dcc000b6d2b537eeab48ed8bf3035e7f1 --- /dev/null +++ b/resources/js/components/Dashboard/AdminView/Components/OrganizationsGraph.vue @@ -0,0 +1,97 @@ +<template> + <div class="card"> + <div class="card flex-row bg-primary mb-0"> + <div class="card-body col-10"> + <div class="card-text"> + <div class="font-weight-bold h4 m-0">{{ activeOrganizations.length }}</div> + Structures actives + </div> + </div> + <div class="col-2"> + <i class="fas fa-home" style="font-size: 50px;"></i> + </div> + </div> + <div class="card-body px-5"> + <Doughnut v-if="chartData" :chart-data="chartData" :chart-options="options" /> + </div> + </div> +</template> + +<script> +import { Doughnut } from 'vue-chartjs/legacy' +import { Chart as ChartJS, Title, Tooltip, Legend, registerables} from 'chart.js' + +ChartJS.register(Title, Tooltip, Legend, ...registerables) + + +export default { + name: 'OrganizationsGraph', + components: { + Doughnut, + }, + props : [], + data () { + return { + options: { + maintainAspectRatio: false, + plugins: { + legend: { + position: 'right', + labels : { + color : '#6b6b6b', + font: { + size : 16, + // weight : 'bold', + } + } + }, + }, + }, + } + }, + computed: { + territories () { + return this.$store.state.territories.all + }, + activeOrganizations () { + const organizations = this.territories.map( t => t.organizations.filter(org => org.active)) + return [].concat.apply([], organizations) + }, + chartData() { + if (this.territories) { + const labels = this.territories.map( t => t.name); + const data = this.territories.map( t => t.organizations.filter(org => org.active).length); + + return { + labels, + datasets: [ + { + data, + backgroundColor: [ + 'rgb(255, 99, 132)', // rouge + 'rgb(243, 156, 18)', // orange + 'rgb(75, 192, 192)', // vert + 'rgb(255, 221, 86)', // jaune + 'rgb(54, 162, 235)' // bleu + ] + + } + ] + } + } + return {} + }, + } +} +</script> + +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/AdminView/index.vue b/resources/js/components/Dashboard/AdminView/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..0ff8113c1ce7e0cf901d846d0f1ac2990f7b3aff --- /dev/null +++ b/resources/js/components/Dashboard/AdminView/index.vue @@ -0,0 +1,62 @@ +<template> + <div class="row"> + <div class="col-md-6"> + <div class="row"> + <div class="col-md-12"> + <OrganizationsGraph /> + </div> + <div class="col-md-12"> + <MaturityCyberMap /> + </div> + <div class="col-md-12"> + <ActionTerritory /> + </div> + <div class="col-md-12"> + <MeasuresInAction /> + </div> + </div> + </div> + <div class="col-md-6"> + <div class="row"> + <div class="col-md-12"> + <OrganizationsAlert /> + </div> + <!-- <div class="col-md-12"> + <Exports /> + </div> --> + <div class="col-md-12"> + <MaturityCyberByTerritory /> + </div> + <div class="col-md-12"> + <MeasurePlanned /> + </div> + </div> + </div> + </div> +</template> + +<script> + +export default { + name: 'AdminView', + components : { + OrganizationsGraph: () => import('./Components/OrganizationsGraph.vue'), + MaturityCyberMap: () => import('./Components/MaturityCyberMap.vue'), + ActionTerritory: () => import('./Components/ActionTerritory.vue'), + MeasuresInAction: () => import('./Components/MeasuresInAction.vue'), + OrganizationsAlert: () => import('./Components/OrganizationsAlert.vue'), + // Exports: () => import('./Components/Exports.vue'), + MaturityCyberByTerritory: () => import('./Components/MaturityCyberByTerritory.vue'), + MeasurePlanned: () => import('./Components/MeasurePlanned.vue'), + }, + data () { + return { + } + }, + computed: { + user () { + return this.$store.state.user + }, + } +} +</script> diff --git a/resources/js/components/Dashboard/ManagerView/Components/ActionPlanTable.vue b/resources/js/components/Dashboard/ManagerView/Components/ActionPlanTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..c73e7f93609b108cc8d52d139b292b9d154a0a4e --- /dev/null +++ b/resources/js/components/Dashboard/ManagerView/Components/ActionPlanTable.vue @@ -0,0 +1,53 @@ +<template> + <div class="card"> + <div class="card-body"> + <h5 class="card-title mb-3">Plan d'action</h5> + <table class="table table-bordered"> + <thead> + <tr> + <th>Actions à réaliser</th> + <th>Date limite de mise en oeuvre</th> + <th>Personne en charge</th> + </tr> + </thead> + <tbody> + <tr v-if="!actions || !actions.length"> + <td colspan="3">Aucune action</td> + </tr> + <tr v-for="item in actions" :key="item.id"> + <td>{{ item.level.actual_label }}</td> + <td>{{ item.end_date }}</td> + <td>{{ item.manager }}</td> + </tr> + </tbody> + </table> + </div> + </div> +</template> + +<script> +import moment from 'moment' + +export default { + name: 'ActionPlanTable', + props : ['currentEvaluation'], + data () { + return { + } + }, + computed: { + measures () { + return this.$store.state.measures.all + }, + actions () { + return this.currentEvaluation && this.currentEvaluation.measure_levels + .filter(level => level.expected_level > level.actual_level) + .map( level => ({...level, + level : this.measures.find(m => m.id === level.measure_id).levels.find(l => l.level === level.expected_level) , + end_date : moment(level.end_date).format('DD/MM/YYYY') + } + )) + } + } +} +</script> \ No newline at end of file diff --git a/resources/js/components/Dashboard/ManagerView/Components/ActionsDoneCard.vue b/resources/js/components/Dashboard/ManagerView/Components/ActionsDoneCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..8dfa00feb844b0184bc1bf855823c6f57454f39f --- /dev/null +++ b/resources/js/components/Dashboard/ManagerView/Components/ActionsDoneCard.vue @@ -0,0 +1,55 @@ +<template> + <div class="card flex-row"> + <div class="col-4 bg-primary"> + <i class="fas fa-bullseye" style="font-size: 50px;"></i> + </div> + <div class="card-body col-8"> + <div class="card-text"> + Actions réalisées <br /> + <div class="small font-weight-bold">{{ doneMeasures ? doneMeasures.length : 0 }} sur {{ plannedMeasures ? plannedMeasures.length : 0 }}</div> + </div> + <div class="progress progress-xs"> + <div class="progress-bar bg-success" role="progressbar" :style="{ 'width' : `${plannedMeasures && doneMeasures ? doneMeasures.length / plannedMeasures.length * 100 : 0}%`}" aria-valuemin="0" aria-valuemax="100"></div> + </div> + </div> + </div> +</template> + +<script> +/* 2 : nombre de niveaux de mesure mises en place qui avait précédemment été planifiés dans l’évalution précédente +4 : nombre de mesures planifiées dans l’évaluation précédente */ +export default { + name: 'ActionsDoneCard', + props : ['currentEvaluation', 'previousEvaluation'], + data () { + return { + } + }, + computed: { + plannedMeasures () { + return this.previousEvaluation && this.previousEvaluation.measure_levels.filter(measure => measure.expected_level > measure.actual_level) + }, + doneMeasures () { + // Compare lastLevel with currentLevel to know if currentLevel if better + if (this.currentEvaluation && this.plannedMeasures) { + return this.currentEvaluation.measure_levels + .filter(currentLevel => { + const lastLevel = this.plannedMeasures.find(ml => ml.measure_id === currentLevel.measure_id) + return lastLevel ? currentLevel.actual_level >= lastLevel.expected_level : false + }) + } + return [] + } + } +} +</script> +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/ManagerView/Components/AverageMaturityCyberCard.vue b/resources/js/components/Dashboard/ManagerView/Components/AverageMaturityCyberCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..77ecda4db79c19255078016818ca9c5496c64b9d --- /dev/null +++ b/resources/js/components/Dashboard/ManagerView/Components/AverageMaturityCyberCard.vue @@ -0,0 +1,37 @@ +<template> + <div class="card flex-row bg-primary"> + <div class="card-body col-8"> + <div class="card-text"> + <div class="font-weight-bold h4 m-0" v-if="organizations">{{ organizations.maturity.toFixed(2) }}</div> + Indice de maturité cyber moyen des structures + </div> + </div> + <div class="col-4"> + <i class="fas fa-chart-line" style="font-size: 50px;"></i> + </div> + </div> +</template> + +<script> + +export default { + name: 'AverageMaturityCyberCard', + props : ['organizations'], + data () { + return { + } + }, + computed: { + } +} +</script> +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/ManagerView/Components/MaturityCyberCard.vue b/resources/js/components/Dashboard/ManagerView/Components/MaturityCyberCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..5bf020eeb79f0b90dfc05cdfca6e08d8637bed66 --- /dev/null +++ b/resources/js/components/Dashboard/ManagerView/Components/MaturityCyberCard.vue @@ -0,0 +1,90 @@ +<template> + <div class="card flex-row"> + <div class="col-4 bg-primary"> + <i class="fas fa-chart-line" style="font-size: 50px;"></i> + </div> + <div class="card-body col-8"> + <div class="card-text"> + Indice de maturité cyber + <div class="small font-weight-bold">{{ averageCurrentMaturityCyber }}</div> + </div> + <div class="progress progress-xs"> + <div v-if="plannedMeasures" class="progress-bar bg-success" role="progressbar" :style="{ 'width' : `${averageCurrentMaturityCyber / 3 * 100}%`}" aria-valuemin="0" aria-valuemax="100"></div> + <div v-else class="progress-bar bg-success" role="progressbar" aria-valuemin="0" aria-valuemax="100"></div> + </div> + <div v-if="isFinite(evolutionMaturityCyber)"> + <div v-if="evolutionMaturityCyber === 0" class="mt-1 text-info"> + <i class="fas fa-equals"></i> <small> {{ evolutionMaturityCyber }} %</small> + </div> + <div v-else-if="evolutionMaturityCyber && evolutionMaturityCyber > 0" class="mt-1 text-success"> + <i class="fas fa-arrow-up"></i> <small>+ {{ evolutionMaturityCyber }} %</small> + </div> + <div v-else-if="evolutionMaturityCyber" class="mt-1 text-danger"> + <i class="fas fa-arrow-down"></i> <small> {{ evolutionMaturityCyber }} %</small> + </div> + </div> + </div> + </div> +</template> + +<script> +import axios from 'axios' + +export default { + name: 'MaturityCyberCard', + props : ['currentEvaluation', 'previousEvaluation'], + data () { + return { + averageCurrentMaturityCyber : 0, + averagePreviousMaturityCyber : 0, + } + }, + computed: { + // TE REMOVE + plannedMeasures () { + return this.previousEvaluation && this.previousEvaluation.measure_levels.filter(measure => measure.expected_level > measure.actual_level) + }, + evolutionMaturityCyber () { + const pourcent = (this.averageCurrentMaturityCyber - this.averagePreviousMaturityCyber) / this.averagePreviousMaturityCyber * 100 + return Math.sign(pourcent) * Math.round(Math.abs(pourcent)) + } + + }, + watch : { + currentEvaluation () { + this.getAverageCurrentMaturityCyber() + }, + previousEvaluation () { + this.getAveragePreviousMaturityCyber() + }, + }, + mounted () { + this.currentEvaluation && this.getAverageCurrentMaturityCyber() + this.previousEvaluation && this.getAveragePreviousMaturityCyber() + }, + methods : { + async getAverageCurrentMaturityCyber () { + await axios.get(`/api/evaluations/${this.currentEvaluation.id}/graphs/maturity`) + .then( res => { + this.averageCurrentMaturityCyber = res.data.data.reduce((a, b) => a + b, 0) / res.data.data.length + }) + }, + async getAveragePreviousMaturityCyber () { + await axios.get(`/api/evaluations/${this.previousEvaluation.id}/graphs/maturity`) + .then( res => { + this.averagePreviousMaturityCyber = res.data.data.reduce((a, b) => a + b, 0) / res.data.data.length + }) + }, + }, +} +</script> +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/ManagerView/Components/MeasureCard.vue b/resources/js/components/Dashboard/ManagerView/Components/MeasureCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..9ae56a857876363d3611fecc77b20c22ead9363c --- /dev/null +++ b/resources/js/components/Dashboard/ManagerView/Components/MeasureCard.vue @@ -0,0 +1,44 @@ +<template> + <div class="card flex-row"> + <div class="col-4 bg-primary"> + <i class="fas fa-shield-alt" style="font-size: 50px;"></i> + </div> + <div class="card-body col-8 py-2"> + <div class="card-text"> + Mise en oeuvre des mesures de sécurité + <div class="small font-weight-bold" v-if="totalMeasuresLevel">{{ Math.round(totalMeasuresLevel / 30 * 100) }} %</div> + <div class="small font-weight-bold" v-else>0 %</div> + </div> + <div class="progress progress-xs"> + <div class="progress-bar bg-success" role="progressbar" :style="{ 'width' : `${totalMeasuresLevel ? totalMeasuresLevel / 30 * 100 : 0}%`}" aria-valuemin="0" aria-valuemax="100"></div> + </div> + </div> + </div> +</template> + +<script> +/* nombre de niveau de mesures de sécurité mis en Å“uvre /30)*100 */ +export default { + name: 'MeasureCard', + props : ['currentEvaluation'], + data () { + return { + } + }, + computed: { + totalMeasuresLevel () { + return this.currentEvaluation && this.currentEvaluation.measure_levels.map(level => level.actual_level).reduce((partialSum, a) => partialSum + a, 0); + } + } +} +</script> +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/ManagerView/Components/MeasurePlanned.vue b/resources/js/components/Dashboard/ManagerView/Components/MeasurePlanned.vue new file mode 100644 index 0000000000000000000000000000000000000000..b9d7cfc48717c18856218bb516e0da19cd88667e --- /dev/null +++ b/resources/js/components/Dashboard/ManagerView/Components/MeasurePlanned.vue @@ -0,0 +1,67 @@ +<template> + <div class="card py-3 px-2" v-if="favoriteMeasures"> + <div class="card-title mb-3"> Top 3 des niveaux de mesures de sécurité planifiées</div> + + <div class="card flex-row" v-for="(ml, i) in favoriteMeasures" :key="i"> + <div class="col-2 bg-primary font-weight-bold"> + <span style="font-size: 50px;">{{ i + 1 }}</span> + </div> + <div class="card-body col-10 py-2"> + <div class="card-text"> + {{ ml.short_name }} + <div class="small font-weight-bold">Niveau {{ ml.max_levels }}</div> + </div> + <div class="progress progress-xs my-1"> + <div class="progress-bar bg-success" role="progressbar" + :style="{width: ml.max_levels == 1 ? '33%' : ml.max_levels == 2 ? '66%' : ml.max_levels == 3 ? '100%' : '0%'}" + :class="ml.max_levels == 1 ? 'bg-danger' : ml.max_levels == 2 ? 'bg-info' : ml.max_levels == 3 ? 'bg-success' : 'black'" + aria-valuemin="0" aria-valuemax="100"> + </div> + </div> + <div class="card-text"> + {{ ml.percentage % 1 === 0 ? ml.percentage : ml.percentage.toFixed(2)}} % des structures + </div> + </div> + </div> + <div v-if="!favoriteMeasures.length" class="text-center small">Aucune mesure</div> + </div> +</template> + +<script> +export default { + name: 'MeasureCard', + props : ['organizations'], + data () { + return { + } + }, + computed: { + // top-measures déjà ordonné par max_value -> pas besoin de sort + favoriteMeasures () { + let favoriteMeasures = [] + this.organizations && this.organizations.top_measures.forEach(ml => { + if (ml.max_levels.length > 1) { + ml.max_levels.forEach( lvl => { + favoriteMeasures.push({...ml, max_levels : lvl}) + }) + } else { + favoriteMeasures.push({...ml, max_levels : ml.max_levels[0]}) + } + }) + + return favoriteMeasures.slice(0, 3) + } + + } +} +</script> +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/ManagerView/Components/OrganizationsCard.vue b/resources/js/components/Dashboard/ManagerView/Components/OrganizationsCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..f49b9e96bb147fadd0a7d2c5ed880335f2664897 --- /dev/null +++ b/resources/js/components/Dashboard/ManagerView/Components/OrganizationsCard.vue @@ -0,0 +1,35 @@ +<template> + <div class="card flex-row bg-primary"> + <div class="card-body col-8"> + <div class="card-text"> + <div class="font-weight-bold h4 m-0" v-if="organizations">{{ organizations.count }}</div> + Structures évaluées + </div> + </div> + <div class="col-4"> + <i class="fas fa-home" style="font-size: 50px;"></i> + </div> + </div> +</template> + +<script> + +export default { + name: 'StructuresEvalueesCard', + props : ['organizations'], + data () { + return { + } + }, +} +</script> +<style scoped> +.card.flex-row { + height: 120px; +} +.bg-primary { + display: flex; + align-items: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/resources/js/components/Dashboard/ManagerView/index.vue b/resources/js/components/Dashboard/ManagerView/index.vue new file mode 100644 index 0000000000000000000000000000000000000000..694f04180b777b3bc466c50fe1384018a2600602 --- /dev/null +++ b/resources/js/components/Dashboard/ManagerView/index.vue @@ -0,0 +1,85 @@ +<template> + <div class="row"> + <div class="col-md-6"> + <div class="row"> + <div class="col-md-6"> + <ActionsDoneCard :current-evaluation="currentEvaluation" :previous-evaluation="previousEvaluation"/> + </div> + <div class="col-md-6"> + <MeasureCard :current-evaluation="currentEvaluation" /> + </div> + <div class="col-md-6"> + <MaturiteCyberCard :current-evaluation="currentEvaluation" :previous-evaluation="previousEvaluation" /> + </div> + <div class="col-md-12"> + <ActionPlanTable :current-evaluation="currentEvaluation" /> + </div> + </div> + </div> + <div class="col-md-6"> + <div class="row"> + <div class="col-md-6"> + <OrganizationsCard :organizations="organizations" /> + </div> + <div class="col-md-6"> + <AverageMaturityCyberCard :organizations="organizations" /> + </div> + <div class="col-md-12"> + <MeasurePlanned :organizations="organizations" /> + </div> + <div class="col-md-12"> + <ActionTerritory /> + </div> + </div> + </div> + </div> +</template> + +<script> + +export default { + name: 'DashboardManager', + components : { + ActionsDoneCard: () => import('./Components/ActionsDoneCard.vue'), + MeasureCard: () => import('./Components/MeasureCard.vue'), + MaturiteCyberCard: () => import('./Components/MaturityCyberCard.vue'), + OrganizationsCard: () => import('./Components/OrganizationsCard.vue'), + AverageMaturityCyberCard: () => import('./Components/AverageMaturityCyberCard.vue'), + ActionPlanTable: () => import('./Components/ActionPlanTable.vue'), + MeasurePlanned: () => import('./Components/MeasurePlanned.vue'), + ActionTerritory: () => import('../AdminView/Components/ActionTerritory.vue'), + }, + data () { + return { + organizations : null + } + }, + computed: { + user () { + return this.$store.state.user + }, + evaluations () { + const evals = this.$store.state.evaluations.all + return evals.filter( e => e.status === 2).sort( (a,b) => new Date(b.updated_at) - new Date(a.updated_at)) + }, + currentEvaluation () { + return this.evaluations[0] + }, + previousEvaluation () { + return this.evaluations[1] + }, + + }, + mounted () { + this.getActivesOrganizations() + }, + methods : { + async getActivesOrganizations () { + await axios.get(`/api/graphs/organizations`) + .then( res => { + this.organizations = res.data + }) + }, + }, +} +</script> diff --git a/resources/js/components/Dashboard/index.vue b/resources/js/components/Dashboard/index.vue index 8db2dea95de7ac0db462c50c43f4756b1ba1b68a..fb89899f7b82a96d5815e0274b55de7ac8788a15 100644 --- a/resources/js/components/Dashboard/index.vue +++ b/resources/js/components/Dashboard/index.vue @@ -1,12 +1,26 @@ <template> - <div class="container"> - En construction + <div class="mb-5 mx-5 mt-0"> + <AdminView v-if="isAdmin()"/> + <ManagerView v-else/> </div> </template> <script> +import { isAdmin } from '../../utils/permissions' +import AdminView from './AdminView' +import ManagerView from './ManagerView' + export default { name: 'Dashboard', + components : { + AdminView, + ManagerView, + }, + data () { + return { + isAdmin, + } + }, computed: { user () { return this.$store.state.user diff --git a/resources/js/components/Evaluations/Single/Components/Results/Measures.vue b/resources/js/components/Evaluations/Single/Components/Results/Measures.vue index 8987a9c53b2e1e1417c02bc0ebdb2fac89ae7fcd..bbf404d93c8a4a5797a84109096c0ec2605e23e7 100644 --- a/resources/js/components/Evaluations/Single/Components/Results/Measures.vue +++ b/resources/js/components/Evaluations/Single/Components/Results/Measures.vue @@ -15,7 +15,6 @@ stroke="7" :is-result="true" :level="measureLevelSelected(item).actual_level" /> - <!-- <p class="my-3">Efficacité</p> --> <button type="button" class="btn btn-primary mt-4" @click="showModal = item.id"> Afficher le détail de la mesure </button> diff --git a/resources/js/components/Evaluations/Single/Components/Step3/AttackGraph.vue b/resources/js/components/Evaluations/Single/Components/Step3/AttackGraph.vue index c509d473ce89526b80262d1d73ec5e5be00c782e..de778378a5a0e9462082d5c09b2eb9bc38a7a71b 100644 --- a/resources/js/components/Evaluations/Single/Components/Step3/AttackGraph.vue +++ b/resources/js/components/Evaluations/Single/Components/Step3/AttackGraph.vue @@ -68,7 +68,7 @@ export default { data, backgroundColor: [ 'rgba(255, 99, 132, 0.5)', // rouge - 'rgba(243, 156, 18, 0.5)', // gris + 'rgba(243, 156, 18, 0.5)', // orange 'rgba(75, 192, 192, 0.5)', // vert 'rgba(255, 221, 86, 0.5)', // jaune 'rgba(54, 162, 235, 0.5)' // bleu diff --git a/resources/sass/app.scss b/resources/sass/app.scss index 4d4afb5b1ffa1aa287c20a016d431e4a116e6845..5e44f0ec9dd23c9b021c0afc343a8027c7a547c2 100644 --- a/resources/sass/app.scss +++ b/resources/sass/app.scss @@ -221,4 +221,6 @@ select.form-control { /* GRAPH */ #attackgraph canvas { max-height:600px; -} \ No newline at end of file +} + +/* DASHBOARD */ diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index df4e9249e0bdc9a5ab8e236684a0a10f4af04d9f..ed5956ee021e51c30f49bcafcea92638e60d9175 100755 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -13,6 +13,7 @@ @push('js') <script> window.referentielVersion = '{{ env('REFERENTIEL_VERSION') }}'; + window.seuilAlerte = '{{ env('SEUIL_ALERTE') }}'; window.footerLink = '{{ env('FOOTER_LINK', 'https://www.soluris.fr/') }}'; window.logoSidebar = '{{ mix('images/' . env('LOGO_SIDEBAR', 'soluris-logo-white.png')) }}'; window.appVersion = ''; diff --git a/routes/api.php b/routes/api.php index 6e068561745856852184a1dc92d2136662b08462..ba7bc5fdb0974453d66ec6b11a1ebd88061a2f49 100644 --- a/routes/api.php +++ b/routes/api.php @@ -68,6 +68,12 @@ Route::get('/{id}/graphs/maturity', [GraphDataController::class, 'maturity'])->name('api.evaluations.graph.maturity'); Route::delete('/{id}', [EvaluationsController::class, 'delete'])->name('api.evaluations.delete'); }); + +Route::prefix('/graphs')->middleware('auth:sanctum')->group(function () { + Route::get('/actionterritory', [GraphDataController::class, 'actionterritory'])->name('api.evaluations.graph.actionterritory'); + Route::get('/organizations', [GraphDataController::class, 'organizations'])->name('api.evaluations.graph.organizations'); +}); + Route::prefix('/dangers')->middleware('auth:sanctum')->group(function () { Route::get('/', [DangersController::class, 'all'])->name('api.dangers.all'); }); diff --git a/tests/Unit/GraphDataRepositoryTest.php b/tests/Unit/GraphDataRepositoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..98d9ecd35a38d651a662e3bd5024fd4fec3e3fa1 --- /dev/null +++ b/tests/Unit/GraphDataRepositoryTest.php @@ -0,0 +1,223 @@ +<?php + +namespace Tests\Unit; + +use App\Models\Evaluation; +use App\Models\Measure; +use App\Models\MeasureLevel; +use App\Models\Organization; +use App\Models\Territory; +use App\Repository\EvaluationRepository; +use App\Repository\GraphDataRepository; +use Carbon\Carbon; +use Tests\TestCase; + +class GraphDataRepositoryTest extends TestCase +{ + public function setUp(): void + { + parent::setUp(); + + $this->artisan('migrate'); + } + + public function testGetActionTerritoryGraphDataWithNoDataShouldReturnZero() + { + $territory = new Territory(); + $territory->name = 'test'; + $territory->save(); + + $organisation = new Organization(); + $organisation->territory_id = $territory->id; + $organisation->name = 'test org'; + $organisation->short_name = 'testorg'; + $organisation->type = 'CCAS'; + $organisation->siren = '123456'; + $organisation->active = true; + $organisation->save(); + + $evalution = new Evaluation(); + $evalution->organization_id = $organisation->id; + $evalution->status = 2; + $evalution->current_step = 6; + $evalution->created_at = Carbon::now()->subDays(10); + $evalution->updated_at = Carbon::now(); + $evalution->updated_by = 'test update'; + $evalution->author = 'test author'; + $evalution->reference = env('REFERENTIEL_VERSION'); + $evalution->save(); + + $evalution = new Evaluation(); + $evalution->organization_id = $organisation->id; + $evalution->status = 2; + $evalution->current_step = 6; + $evalution->created_at = Carbon::now()->subDays(10); + $evalution->updated_at = Carbon::now()->subDays(2); + $evalution->updated_by = 'test update 2'; + $evalution->author = 'test author2'; + $evalution->reference = env('REFERENTIEL_VERSION'); + $evalution->save(); + + $evalRepo = new EvaluationRepository(); + $repo = new GraphDataRepository($evalRepo); + + $data = $repo->getActionTerritoryGraphData(); + + $expected = [ + 'labels' => ['test'], + 'data' => [[0], [0]], + ]; + + $this->assertEquals($expected, $data); + } + + public function testGetActionTerritoryGraphDataWithDataShouldReturnCorrectData() + { + $territory = new Territory(); + $territory->name = 'test'; + $territory->save(); + + $organisation = new Organization(); + $organisation->territory_id = $territory->id; + $organisation->name = 'test org'; + $organisation->short_name = 'testorg'; + $organisation->type = 'CCAS'; + $organisation->siren = '123456'; + $organisation->active = true; + $organisation->save(); + + $measures = Measure::factory(5)->create(); + + $oldevalution = new Evaluation(); + $oldevalution->organization_id = $organisation->id; + $oldevalution->status = 2; + $oldevalution->current_step = 6; + $oldevalution->created_at = Carbon::now()->subDays(10); + $oldevalution->updated_at = Carbon::now()->subDays(2); + $oldevalution->updated_by = 'test update 2'; + $oldevalution->author = 'test author2'; + $oldevalution->reference = env('REFERENTIEL_VERSION'); + $oldevalution->save(); + + foreach ($measures as $measure) { + $ml = new MeasureLevel(); + $ml->measure_id = $measure->id; + $ml->evaluation_id = $oldevalution->id; + $ml->expected_level = 2; + $ml->end_date = Carbon::now()->addMonths(3); + $ml->manager = 'test manager'; + $ml->actual_level = 1; + $ml->save(); + } + + $evalution = new Evaluation(); + $evalution->organization_id = $organisation->id; + $evalution->status = 2; + $evalution->current_step = 6; + $evalution->created_at = Carbon::now()->subDays(10); + $evalution->updated_at = Carbon::now(); + $evalution->updated_by = 'test update'; + $evalution->author = 'test author'; + $evalution->reference = env('REFERENTIEL_VERSION'); + $evalution->save(); + + foreach ($measures as $k => $measure) { + $ml = new MeasureLevel(); + $ml->measure_id = $measure->id; + $ml->evaluation_id = $evalution->id; + $ml->expected_level = 3; + $ml->actual_level = $k % 4; + $ml->end_date = Carbon::now()->addMonths(14); + $ml->manager = 'test manager'; + $ml->save(); + } + + $evalRepo = new EvaluationRepository(); + $repo = new GraphDataRepository($evalRepo); + + $data = $repo->getActionTerritoryGraphData(); + + $expected = [ + 'labels' => ['test'], + 'data' => [[5], [2]], + ]; + + $this->assertEquals($expected, $data); + } + + public function testGetOrganizationDataWithDataShouldReturnCorrectData() + { + $territory = new Territory(); + $territory->name = 'test'; + $territory->save(); + + $organisation = new Organization(); + $organisation->territory_id = $territory->id; + $organisation->name = 'test org'; + $organisation->short_name = 'testorg'; + $organisation->type = 'CCAS'; + $organisation->siren = '123456'; + $organisation->active = true; + $organisation->save(); + + $measures = Measure::factory(5)->create(); + + $oldevalution = new Evaluation(); + $oldevalution->organization_id = $organisation->id; + $oldevalution->status = 2; + $oldevalution->current_step = 6; + $oldevalution->created_at = Carbon::now()->subDays(10); + $oldevalution->updated_at = Carbon::now()->subDays(2); + $oldevalution->updated_by = 'test update 2'; + $oldevalution->author = 'test author2'; + $oldevalution->reference = env('REFERENTIEL_VERSION'); + $oldevalution->save(); + + foreach ($measures as $k => $measure) { + $ml = new MeasureLevel(); + $ml->measure_id = $measure->id; + $ml->evaluation_id = $oldevalution->id; + $ml->expected_level = $k % 4; + $ml->end_date = Carbon::now()->addMonths(3); + $ml->manager = 'test manager'; + $ml->actual_level = 1; + $ml->save(); + } + + $evalution = new Evaluation(); + $evalution->organization_id = $organisation->id; + $evalution->status = 2; + $evalution->current_step = 6; + $evalution->created_at = Carbon::now()->subDays(10); + $evalution->updated_at = Carbon::now(); + $evalution->updated_by = 'test update'; + $evalution->author = 'test author'; + $evalution->reference = env('REFERENTIEL_VERSION'); + $evalution->save(); + + foreach ($measures as $k => $measure) { + $ml = new MeasureLevel(); + $ml->measure_id = $measure->id; + $ml->evaluation_id = $evalution->id; + $ml->expected_level = $k % 4; + $ml->actual_level = $k % 4; + $ml->end_date = Carbon::now()->addMonths(14); + $ml->manager = 'test manager'; + $ml->save(); + } + + $evalRepo = new EvaluationRepository(); + $repo = new GraphDataRepository($evalRepo); + + $data = $repo->getOrganizationData(); + + dd($data); + + $expected = [ + 'labels' => ['test'], + 'data' => [[5], [2]], + ]; + + $this->assertEquals($expected, $data); + } +}