From 568aa483cced3c3ce8262cf3dfbd3ffc619f91d0 Mon Sep 17 00:00:00 2001
From: AuroreC <chayrouse@datakode.fr>
Date: Thu, 17 Nov 2022 11:58:38 +0100
Subject: [PATCH] Organizations : Map added + Dashboard admin : map added

---
 .../Api/OrganizationsController.php           |   2 +
 ...30_185437_add_lat_lng_to_address_table.php |  33 ++++
 package-lock.json                             |  15 ++
 package.json                                  |   1 +
 public/mix-manifest.json                      |   3 +
 .../AdminView/Components/MaturityCyberMap.vue | 143 +++++++++++++++++-
 .../Organizations/Single/AddressBlock.vue     |  25 ++-
 .../Organizations/Single/PositionBlock.vue    | 103 +++++++++++++
 .../components/Organizations/Single/index.vue |   4 +-
 resources/sass/app.scss                       |   2 +
 10 files changed, 324 insertions(+), 7 deletions(-)
 create mode 100644 database/migrations/2022_10_30_185437_add_lat_lng_to_address_table.php
 create mode 100644 resources/js/components/Organizations/Single/PositionBlock.vue

diff --git a/app/Http/Controllers/Api/OrganizationsController.php b/app/Http/Controllers/Api/OrganizationsController.php
index a1f58fc8..03ff5b72 100644
--- a/app/Http/Controllers/Api/OrganizationsController.php
+++ b/app/Http/Controllers/Api/OrganizationsController.php
@@ -83,6 +83,8 @@ public function save(OrganizationRequest $request, $id = null)
             $address->cp = $data['address']['cp'];
             $address->city = $data['address']['city'];
             $address->codeInsee = $data['address']['codeInsee'];
+            $address->lat = $data['address']['lat'] ?? null;
+            $address->lng = $data['address']['lng'] ?? null;
             $address->save();
 
             $organization->referent_id = $this->UpdateReferent($referent, $data['referent']);
diff --git a/database/migrations/2022_10_30_185437_add_lat_lng_to_address_table.php b/database/migrations/2022_10_30_185437_add_lat_lng_to_address_table.php
new file mode 100644
index 00000000..e2372bcd
--- /dev/null
+++ b/database/migrations/2022_10_30_185437_add_lat_lng_to_address_table.php
@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('address', function (Blueprint $table) {
+            $table->string('lat')->nullable();
+            $table->string('lng')->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('address', function (Blueprint $table) {
+            $table->dropColumn(['lat', 'lng']);
+        });
+    }
+};
diff --git a/package-lock.json b/package-lock.json
index d2691b28..7229bede 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
                 "chartjs-plugin-stacked100": "^1.2.1",
                 "dotenv": "^16.0.1",
                 "leaflet": "^1.9.2",
+                "leaflet.markercluster": "^1.5.3",
                 "vue-chartjs": "^4.1.1",
                 "vue-property-decorator": "^9.1.2",
                 "vue-tables-2": "^2.3.5",
@@ -8303,6 +8304,14 @@
             "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.2.tgz",
             "integrity": "sha512-Kc77HQvWO+y9y2oIs3dn5h5sy2kr3j41ewdqCMEUA4N89lgfUUfOBy7wnnHEstDpefiGFObq12FdopGRMx4J7g=="
         },
+        "node_modules/leaflet.markercluster": {
+            "version": "1.5.3",
+            "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
+            "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
+            "peerDependencies": {
+                "leaflet": "^1.3.1"
+            }
+        },
         "node_modules/levn": {
             "version": "0.4.1",
             "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -19230,6 +19239,12 @@
             "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.2.tgz",
             "integrity": "sha512-Kc77HQvWO+y9y2oIs3dn5h5sy2kr3j41ewdqCMEUA4N89lgfUUfOBy7wnnHEstDpefiGFObq12FdopGRMx4J7g=="
         },
+        "leaflet.markercluster": {
+            "version": "1.5.3",
+            "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
+            "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
+            "requires": {}
+        },
         "levn": {
             "version": "0.4.1",
             "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
diff --git a/package.json b/package.json
index 78330a76..32a01045 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
         "chartjs-plugin-stacked100": "^1.2.1",
         "dotenv": "^16.0.1",
         "leaflet": "^1.9.2",
+        "leaflet.markercluster": "^1.5.3",
         "vue-chartjs": "^4.1.1",
         "vue-property-decorator": "^9.1.2",
         "vue-tables-2": "^2.3.5",
diff --git a/public/mix-manifest.json b/public/mix-manifest.json
index 31ef7126..332f6348 100644
--- a/public/mix-manifest.json
+++ b/public/mix-manifest.json
@@ -10,6 +10,9 @@
     "/images/logoblancetorange.png": "/images/logoblancetorange.png",
     "/images/rectangle.png": "/images/rectangle.png",
     "/images/soluris-logo-white.png": "/images/soluris-logo-white.png",
+    "/images/vendor/leaflet/dist/layers-2x.png": "/images/vendor/leaflet/dist/layers-2x.png",
+    "/images/vendor/leaflet/dist/layers.png": "/images/vendor/leaflet/dist/layers.png",
+    "/images/vendor/leaflet/dist/marker-icon.png": "/images/vendor/leaflet/dist/marker-icon.png",
     "/images/vendor/vuejs-datatable/docs/icons.png": "/images/vendor/vuejs-datatable/docs/icons.png",
     "/images/vendor/vuejs-datatable/docs/icons@2x.png": "/images/vendor/vuejs-datatable/docs/icons@2x.png",
     "/images/vendor/vuejs-datatable/docs/widgets.png": "/images/vendor/vuejs-datatable/docs/widgets.png",
diff --git a/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue b/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue
index b33d0655..fc044ca0 100644
--- a/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue
+++ b/resources/js/components/Dashboard/AdminView/Components/MaturityCyberMap.vue
@@ -1,19 +1,158 @@
 <template>
     <div class="card py-3 px-2">
         <div class="card-title mb-3">Cartographie de la maturité cyber</div>
+
+        <select v-model="measureFilter" class="form-control form-control-sm mb-3">
+            <option value="">Toutes les mesures de sécurité fondamentales</option>
+            <option v-for="m in fundamentalMeasures" :key="m.id" :value="m.id">{{m.short_name}}</option>
+        </select>
+
+        <div class="row">
+            <div class='my-2 col-md-6 text-right' @click="stripetext('green')" :style= "[disabledFilter.includes('green') ? {'text-decoration':'line-through'} : {'text-decoration':''}]">
+                <div class="mr-2 d-inline-block" style="height: 15px; width: 35px; background-color: green;"></div><span class="text-muted small">En règle</span>
+            </div>
+            <div class='my-2 col-md-6 text-left' @click="stripetext('red')" :style= "[disabledFilter.includes('red') ? {'text-decoration':'line-through'} : {'text-decoration':''}]">
+                <div class="mr-2 d-inline-block" style="height: 15px; width: 35px; background-color: red;"></div><span class="text-muted small">En alerte</span>
+            </div>
+        </div>
+
+        <div id="map"></div>
+
     </div>
 </template>
 
 <script>
+import L from 'leaflet';
+
+import 'leaflet.markercluster';
+import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
+
 export default {
     name: 'MaturityCyberMap',
     props : [],
     data () {
         return {
+            seuilAlerte : parseInt(this.$seuilAlerte),
+
+            map : null,
+            markerClusterGroup : null,
+            array_positions : [],
+            measureFilter : "",
+            disabledFilter : [],
+
         }
     },
     computed: {
+        measures () {
+            return this.$store.state.measures.all
+        },
+        evaluations () {
+            return this.$store.state.evaluations.all
+        },
+        fundamentalMeasuresId () {
+            return this.fundamentalMeasures.map(m => m.id)
+        },
+        fundamentalMeasures () {
+            return this.$store.state.measures.all.filter( m => m.fundamental)
+        },
+        organizations () {
+            let orgs = this.$store.state.organizations.all.map( org => {
+                let 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))
+
+
+                const getInsideSeuilAlerte = evaluations.length ? evaluations[0].measure_levels.filter( ml => this.fundamentalMeasuresId.includes(ml.measure_id)).filter( ml => ml.actual_level <= this.seuilAlerte) : []
+                return {...org, evaluations : evaluations, seuilAlert : getInsideSeuilAlerte}
+            })
+
+            return orgs .filter( org => this.disabledFilter.length ? this.disabledFilter.length > 1 ? false : this.disabledFilter.includes('red') ? !org.seuilAlert.length : this.disabledFilter.includes('green') ? org.seuilAlert.length : true : true)
+                        .filter( org => this.measureFilter ? org.seuilAlert.find(a => a.measure_id === this.measureFilter) : true)
+        },
+
+    },
+    mounted () {
+        this.initMap()
+    },
+    watch : {
+        organizations () {
+            this.initMarkers()
+        }
+    },
+    methods : {
+        stripetext(id){
+            if (this.disabledFilter.includes(id)) {
+                this.disabledFilter = this.disabledFilter.filter(f => f !== id)
+            } else {
+                this.disabledFilter.push(id)
+            }
+        },
+        initMap () {
+            this.map = L.map('map') 
+            L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+                // zoom: 5,
+                // center: [46.9, 1.9],
+                maxZoom: 19,
+                attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+            }).addTo(this.map);
+            this.map.setView([46.9, 1.9], 5); // FRANCE BOUNDS
+            this.map.options.maxZoom = 14;
+            this.initMarkers()
+        },
+        initMarkers () {
+            this.markerClusterGroup && this.map.removeLayer(this.markerClusterGroup)
+            this.markerClusterGroup = L.markerClusterGroup({
+                maxClusterRadius: function() { return 30; }
+            });
+
+            this.array_positions = [];
+
+            this.organizations.forEach( org => {
+
+                if (org.evaluations.length) { // Cette carte affiche un point par structure ayant au moins une évaluation
+
+                    let color = org.seuilAlert.length ? "red" : "green"
+
+                    const style = { radius: 5, fillColor: color, fillOpacity: 1, color: color, organization: org} 
+                    const latlng = org.address && org.address.lat && org.address.lng? [parseFloat(org.address.lat), parseFloat(org.address.lng)] : []
 
-    }
+                    if (latlng.length) {
+                        let measureText = org.seuilAlert.map(a => a.measure.short_name)
+                        let alertText = org.seuilAlert.length ? "<br/><i><small>Mesures de sécurité fondamentales en alerte :</small></i> " + measureText.join(', ') : ""
+
+                        this.markerClusterGroup.addLayer(L.circleMarker(latlng, style)
+                            .bindPopup(`
+<b>${org.name}</b> <br> 
+<i><small>Indice de maturité cyber : </small></i>${org.evaluations[0].maturity_cyber} 
+${alertText}`)
+
+                            .on('mouseover', function() {
+                                this.openPopup()})
+                            .on('mouseout', function() {
+                                this.closePopup();
+                            })
+                        )
+                        this.array_positions.push(latlng);
+                    }
+                }
+            })
+            this.map.addLayer(this.markerClusterGroup);
+            this.array_positions.length && this.map.fitBounds(this.array_positions)
+
+        },
+    },
+}
+</script>
+<style scoped>
+#map {
+    height: 400px;
+}
+.vertical-center {
+    position: absolute;
+    top: 50%;
+    transform: translateY(-50%);
+}
+.text-muted {
+    letter-spacing: -0.5px;
 }
-</script>
\ No newline at end of file
+</style>
\ No newline at end of file
diff --git a/resources/js/components/Organizations/Single/AddressBlock.vue b/resources/js/components/Organizations/Single/AddressBlock.vue
index 714c3b8a..1a3aaa71 100644
--- a/resources/js/components/Organizations/Single/AddressBlock.vue
+++ b/resources/js/components/Organizations/Single/AddressBlock.vue
@@ -46,20 +46,24 @@
                     <div v-if="$v.address.city.$error" class="text-danger">Champ obligatoire</div>
                 </div>
                 <div class="form-group">
-                    <label :class="{ 'text-danger': submitted && $v.address.codeInsee.$error }" for="insee">Code
-                        INSEE <i class="text-muted small">(Obligatoire)</i></label> 
+                    <label :class="{ 'text-danger': submitted && $v.address.codeInsee.$error }" for="insee">Code INSEE <i class="text-muted small">(Obligatoire)</i></label> 
                     <input
                         id="insee" v-model="address.codeInsee"
                         type="text"
                         class="form-control form-control-sm" :class="{ 'is-invalid': submitted && $v.address.codeInsee.$error }">
                     <div v-if="$v.address.codeInsee.$error" class="text-danger">Champ obligatoire</div>
                 </div>
+                <div class="form-group">
+                    <PositionBlock :address="address" @addressError="addressError($event)" />
+                    <div v-if="$v.address.lat.$error || $v.address.lng.$error" class="text-danger">Localisation obligatoire</div>
+                </div>
             </div>
         </div>
     </div>
 </template>
 <script>
 import {required, integer} from 'vuelidate/lib/validators'
+import PositionBlock from './PositionBlock.vue'
 
 export default {
     name: 'AddressBlock',
@@ -69,13 +73,18 @@ export default {
             address: this.orgAddress
         }
     },
+    components : {
+        PositionBlock,
+    },
     computed: {},
     validations: {
         address: {
             address: {required},
             cp: {required, integer},
             city: {required},
-            codeInsee: {required}
+            codeInsee: {required},
+            lat: {required},
+            lng: {required},
         }
 
     },
@@ -88,7 +97,15 @@ export default {
             } else {
                 return true
             }
-        }
+        },
+        addressError (message) {
+            console.log('test error')
+            this.$parent.toaster(this, message, 'bg-red')
+        },
+        updateAddressLoc (address) {
+            this.address.lat = address.lat
+            this.address.lng = address.lng
+        },
     }
 }
 </script>
diff --git a/resources/js/components/Organizations/Single/PositionBlock.vue b/resources/js/components/Organizations/Single/PositionBlock.vue
new file mode 100644
index 00000000..4d0950cb
--- /dev/null
+++ b/resources/js/components/Organizations/Single/PositionBlock.vue
@@ -0,0 +1,103 @@
+<template>
+    <div>
+        <button @click.prevent="locateAddress" class="btn btn-primary btn-sm mb-2"><i :class="loading ? 'fa fa-spinner fa-spin' : ''"></i> Localiser l'adresse</button>
+        <div id="map"></div>
+    </div>
+</template>
+<script>
+import L from 'leaflet';
+import axios from 'axios'
+
+export default {
+    name: 'PositionBlock',
+    props: ['address'],
+    data() {
+        return {
+            loading : false,
+            map : null,
+        }
+    },
+    computed: {
+        fullAddress () {
+            return `${this.address.address} ${this.address.cp} ${this.address.city}`
+        }
+    },
+    mounted () {
+        this.initMap()
+        this.address && this.address.lat && this.address.lng && this.setMarker(parseFloat(this.address.lat), parseFloat(this.address.lng))
+    },
+    methods: {
+        initMap () {
+            this.map = L.map('map') 
+
+            L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+                maxZoom: 19,
+                attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+            }).addTo(this.map);
+
+            this.map.setView([46.9, 1.9], 5); // FRANCE BOUNDS
+        },
+        setMarker (lat, lng) {
+            this.marker = L.circleMarker([lat, lng], {draggable: true, radius: 7,fillColor:'red',fillOpacity:0.5,color: 'red'})
+            this.address.lat = lat
+            this.address.lng = lat
+
+            this.marker.on({
+                mousedown: () => this.map.on('mousemove', (e) => {
+                    this.marker.setLatLng(e.latlng); 
+                    this.map.dragging.disable()
+
+                    this.address.lat = e.latlng.lat
+                    this.address.lng = e.latlng.lng
+                    this.$emit('updateAddressLoc', this.address)
+                }), 
+                mouseup: () => {
+                    this.map.dragging.enable()
+                    this.map.off('mousemove')
+                    }
+                }) 
+            this.marker.addTo(this.map)
+
+            const latLngs = [ this.marker.getLatLng() ];
+            const markerBounds = L.latLngBounds(latLngs);
+            this.map.fitBounds(markerBounds);
+        },
+        async locateAddress () {
+            console.log('locateAddress')
+            if (!this.address.address || !this.address.cp || !this.address.city) {
+                    this.$emit('addressError', "L'adresse n'est pas complète")
+                    return
+            } else {
+                this.loading = true
+                await axios.get('https://api-adresse.data.gouv.fr/search/?type=housenumber&autocomplete=1&q=' + this.fullAddress, 
+                    {
+                        transformRequest: (data, headers) => {
+                            delete headers.common['X-CSRF-TOKEN'];
+                        }
+                    }
+                )
+                .then(res => {
+                    this.loading = false
+
+                    if (!res.data.features.length) {
+                        this.$emit('addressError', "L'adresse n'a pas été trouvé")
+                        return;
+                    }  else {
+                        this.enableMap = true
+                        const pointData = res.data.features[0]
+
+                        this.marker && this.map.removeLayer(this.marker)
+
+                        this.setMarker(pointData.geometry.coordinates[1], pointData.geometry.coordinates[0])
+                    }
+                })
+            }
+        }
+    }
+}
+</script>
+<style scoped>
+#map {
+    height: 400px;
+}
+</style>
diff --git a/resources/js/components/Organizations/Single/index.vue b/resources/js/components/Organizations/Single/index.vue
index 74b21ea1..e3a9f377 100644
--- a/resources/js/components/Organizations/Single/index.vue
+++ b/resources/js/components/Organizations/Single/index.vue
@@ -120,7 +120,9 @@ const defaultOrg = {
         moreInfos: null,
         cp: null,
         city: null,
-        codeInsee: null
+        codeInsee: null,
+        lat : null,
+        lng : null,
     },
     referent: {
         id: null,
diff --git a/resources/sass/app.scss b/resources/sass/app.scss
index 5e44f0ec..fa6d34b4 100644
--- a/resources/sass/app.scss
+++ b/resources/sass/app.scss
@@ -9,6 +9,8 @@
 @import '~bootstrap/scss/bootstrap';
 @import '~admin-lte/build/scss/adminlte';
 
+@import '~leaflet/dist/leaflet.css';
+
 
 .bg-grey {
     background-color : #454545!important;
-- 
GitLab