Source

views/SensorsModule.vue

<template lang="pug">
TLayout(
          class="identificationModule"
          :syncWithVuex="false"
          :sidebar="true" 
          :menu="true" 
          :menuCollapsed="menuCollapsed" 
          :menuFullCollapse="true" 
          :menuToggle="true"
          :rightMenu="true"
          :rightMenuBottom="mode==='table_map'"
          )
      template(v-slot:sidebar)
        Sidebar
      template(v-slot:menu)
          SearchWrapper(
              @search-input="onSearchModuleSelectionChange"
              @search-view-change="onSearchModuleViewChange"
              @search-clear="onSearchModuleClearSelection"
          )
              template(v-slot:search-module-filters)
                  .row.p-0.m-0
                    //.col-12.m-0.mt-2
                    //  .filter-title {{$t('identification.filters_puce_title')}}
                    .col-12.p-0.m-0
                      .row.p-0.m-0
                        .col-6.m-0
                          .filter-item
                            label.filter-label {{$t('identification.filters_puce_title')}}
                            b-form-input(class="puceNumberInput" v-model="puceFilterValue" :placeholder="$t('identification.filters_puce_input_placeholder')" size="sm")
                    .col-12.m-0.mt-2
                      .filter-title {{$t('identification.filters_title')}}
                    .col-12.p-0.m-0
                      .row.p-0.m-0
                        .col-6.m-0
                          .filter-item
                            label.filter-label {{$t('identification.filters_is_identified')}}
                            b-form-select(v-model="identifiedFilterValue" :options="filterOptions" size="sm")
                        .col-6.m-0  
                          .filter-item
                            label.filter-label {{$t('identification.filters_is_stopped')}}
                            b-form-select(v-model="stoppedFilterValue" :options="filterOptions" size="sm")
                        .col-6.m-0
                          .filter-item
                            label.filter-label {{$t('identification.filters_is_high_point')}}
                            b-form-select(v-model="highPointFilterValue" :options="filterOptionsHighPoint" size="sm")
                        .col-6.m-0
                          .filter-item
                            label.filter-label {{$t('identification.filters_is_blacklist')}}
                            b-form-select(v-model="blacklistFilterValue" :options="filterOptions" size="sm")
              template(v-slot:search-module-results)
                .row.m-0.p-0(v-show="sortedItems.length===0&&!$store.state.search_module.isSearchInProgress")
                  .col-12
                    span {{$t('identification.no_results')}}
                IdentificationListToolbar.mb-2(v-show="sortedItems.length>0" @map="executeGlobalToolbarAction('map')" 
                  @table="executeGlobalToolbarAction('table')"
                  @markers="executeGlobalToolbarAction('markers')"
                  )
                IdentificationList(v-show="sortedItems.length>0" :items="sortedItems"
                  :highlightedSelectionId="highlightedSelectionId"
                  @toolbar="onToolbarAction"
                  ref="il1"
                )
      template(v-slot:right_menu)
          div.table-layout(v-if="mode==='table'")
            IdentificationTable(:items="detailedItems"
              :mode="mode"
              @mode="switchMode"
              :headerText="tableHeaderText"
              )
          LocationIdentBacsMap(v-if="mode==='table_map'||mode==='list'" ref="map")
      template(v-slot:right_menu_bottom)
          IdentificationTable(v-if="mode==='table_map'" 
            :mode="mode"
            :items="detailedItems"
            :headerText="tableHeaderText"
            @mode="switchMode"
            ref="il2"
            )
</template>
<script>
import Vue from 'vue'
import IdentificationTable from '@c/identification/IdentificationTable.vue'
import LocationIdentBacsMap from '@c/location/LocationIdentBacs/LocationIdentBacsMap.vue'
import IdentificationList from '@c/identification/IdentificationList.vue'
import IdentificationListToolbar from '@c/identification/IdentificationListToolbar.vue'
import Sidebar from '@c/shared/Sidebar/Sidebar.vue'
import TLayout from '@c/shared/TLayout/TLayout.vue'
import SearchWrapper from '@c/shared/SearchModule/SearchWrapper/SearchWrapper.vue'
import { mapGetters } from 'vuex'
import { getIdentificationSearchSelectionLimit } from '@/services/search-service.js'
import moment from 'moment'
import { splitOperation } from '@/utils/promise.js'
import i18n from '@/i18n'
import {
  fetchLeveesSynthesis,
  fetchLeveesDetails,
  createContainerHelpersMixin,
} from '@/services/identification-service.js'

export default {
  name: 'SensorsModule',
  componentType: 'container',
  components: {
    SearchWrapper,
    TLayout,
    IdentificationTable,
    LocationIdentBacsMap,
    IdentificationList,
    IdentificationListToolbar,
    Sidebar,
  },
  mixins: [createContainerHelpersMixin(), Vue.$mixins.userRightsMixin],
  provide() {
    let self = this
    let searchFormTabs = ['disabled']

    if (this.hasFeatureRight('identification_search_vehicle')) {
      searchFormTabs.push({
        label: i18n.t(`common.Véhicule`),
        value: 'vehicle',
      })
    }
    if (this.hasFeatureRight('identification_search_circuit')) {
      searchFormTabs.push({
        label: i18n.t(`common.Circuit`),
        value: 'circuit',
      })
    }

    if (searchFormTabs.length > 1) {
      searchFormTabs.splice(0, 1)
    }

    return {
      /**
       * Provide handler to react to date picker predefined options change and limit date range selection if necessary
       * @function onSearchModuleDatePickerPredefinedSelectionClick
       * @see SMDatePicker.vue Date picker will inject this handler
       */
      onSearchModuleDatePickerPredefinedSelectionClick({
        length,
        limitDateSelectionRangeToNDays,
      }) {
        let selectedElementsIds = self.$store.getters[
          'search_module/getSelectedIdsByType'
        ](self.activeSearchFormTabName)
        let willCurrentDateSelectionExceedSelectionLimit =
          selectedElementsIds.length * length >
          getIdentificationSearchSelectionLimit()
        if (willCurrentDateSelectionExceedSelectionLimit) {
          window.alert(
            `La sélection pour la recherche a été réduite à ${getIdentificationSearchSelectionLimit()} éléments`
          )
          let maxDateRangeLength = Math.round(
            (getIdentificationSearchSelectionLimit() - 1) /
              selectedElementsIds.length -
              1
          )
          limitDateSelectionRangeToNDays(maxDateRangeLength)
        }
      },
      searchModuleCanToggleFreesearch: false,
      searchFormTabs,
    }
  },
  data() {
    let filterOptions = [
      { text: this.$t('identification.filters_value_yes'), value: 1 },
      { text: this.$t('identification.filters_value_no'), value: 0 },
      { text: this.$t('identification.filters_value_tous'), value: '' },
    ]
    let filterOptionsHighPoint = [
      ...filterOptions,
      { text: this.$t('identification.filters_value_unknown'), value: 'nc' },
    ]

    return {
      mode: 'list',
      menuCollapsed: false,

      //Synthesis dataset
      items: [
        /**{
          date,
          dateFormatted
          vehicleId,
          vehicleName: getNestedValue(item, "vehicule_nom"),
          vehicleImmatriculation: getNestedValue(
            item,
            "vehicule_immatriculation"
          ),
          vehicleCategory: getNestedValue(item, "categorie_nom"),
          //Synthesis by circuit
          circuits,
        } */
      ],

      //Levee details dataset (segregated by circuit)
      detailsGroupedByCircuit: [
        /** {
              dateFormatted: subset.dateFormatted,
              date: subset.date,
              vehicleId: subset.vehicleId,
              circuitId: subset.circuitId,
              items: results,
            } */
      ],
      isFetchDetailsInProgress: false,

      //detailedItems (computed) (use can choose to view table data at different levels)
      detailedItemsBy: 'total', //total/date/vehicle/circuit
      detailedItemsByDateValue: null,
      detailedItemsByVehicleIdValue: null,
      detailedItemsByCircuitIdValue: null,

      //Custom search filters
      puceFilterValue: '',
      identifiedFilterValue: '',
      stoppedFilterValue: '',
      highPointFilterValue: '',
      blacklistFilterValue: '',
      filterOptions,
      filterOptionsHighPoint,
    }
  },
  computed: {
    ...mapGetters({
      activeSearchFormTabName: 'search_module/activeSearchFormTabName',
      selection: 'search_module/getSelection',
    }),
    /**
     * Dataset for table mode
     * The user can choose to view levees details data in table mode at different levels (total/date/vehicle/circuit)
     */
    detailedItems() {
      if (this.detailedItemsBy === 'total') {
        return this.detailsGroupedByCircuit
          .map((g) => g.items)
          .reduce((a, v) => a.concat(v), [])
      }
      if (this.detailedItemsBy === 'date') {
        return this.detailsGroupedByCircuit
          .filter((g) => {
            return g.dateFormatted == this.detailedItemsByDateValue
          })
          .map((g) => g.items)
          .reduce((a, v) => a.concat(v), [])
      }
      if (this.detailedItemsBy === 'vehicle') {
        return this.detailsGroupedByCircuit
          .filter((g) => {
            return (
              g.dateFormatted == this.detailedItemsByDateValue &&
              g.vehicleId == this.detailedItemsByVehicleIdValue
            )
          })
          .map((g) => g.items)
          .reduce((a, v) => a.concat(v), [])
      }
      if (this.detailedItemsBy === 'circuit') {
        return this.detailsGroupedByCircuit
          .filter((g) => {
            let areCircuitsSame =
              (g.circuitId === 0 &&
                this.detailedItemsByCircuitIdValue === '') ||
              g.circuitId == this.detailedItemsByCircuitIdValue
            return (
              g.dateFormatted == this.detailedItemsByDateValue &&
              g.vehicleId == this.detailedItemsByVehicleIdValue &&
              areCircuitsSame
            )
          })
          .map((g) => g.items)
          .reduce((a, v) => a.concat(v), [])
      }
      return []
    },
    /**
     * Used to fetch details from a subset (total/vehicle/circuit)
     */
    filteredSynthesisItems() {
      if (this.detailedItemsBy === 'total') {
        return this.items
      }
      if (this.detailedItemsBy === 'date') {
        return this.items.filter(
          (item) => item.dateFormatted === this.detailedItemsByDateValue
        )
      }
      if (this.detailedItemsBy === 'vehicle') {
        return this.items.filter(
          (item) =>
            item.dateFormatted === this.detailedItemsByDateValue &&
            item.vehicleId == this.detailedItemsByVehicleIdValue
        )
      }
      if (this.detailedItemsBy === 'circuit') {
        return this.items.filter(
          (item) =>
            item.dateFormatted === this.detailedItemsByDateValue &&
            item.vehicleId == this.detailedItemsByVehicleIdValue &&
            (!this.detailedItemsByCircuitIdValue ||
              !!item.circuits.find(
                (circ) => circ.circuitId == this.detailedItemsByCircuitIdValue
              ))
        )
      }
      return []
    },
    sortedItems() {
      return [...this.items].sort((a, b) =>
        a.timestamp < b.timestamp ? 1 : -1
      )
    },
    /**
     * We highlight the current selection (date/date-vehicle/date-vehicle-circuit)
     */
    highlightedSelectionId() {
      return `${this.detailedItemsBy}_${this.detailedItemsByDateValue || 'x'}_${
        this.detailedItemsByVehicleIdValue || 'x'
      }_${this.detailedItemsByCircuitIdValue || 'x'}`
    },
    isCurrentSelectionGreaterThanLimit() {
      let dateRanges = this.selection.selectedDateRanges
      let selectedElementsIds = this.$store.getters[
        'search_module/getSelectedIdsByType'
      ](this.activeSearchFormTabName)
      return (
        selectedElementsIds.length * dateRanges.length >
        getIdentificationSearchSelectionLimit()
      )
    },
  },
  watch: {
    /**
     * If table map mode, show markers in the map as soon as data is ready.
     */
    detailedItems() {
      this.prepareMapMarkers()
    },
    items() {
      if (this.items.length === 0) {
        this.$store.dispatch('search_module/setHasResults', false)
      } else {
        if (this.$store.getters['search_module/hasResults']) {
          this.$store.dispatch('search_module/setHasResults', false)
          this.$nextTick(() =>
            this.$store.dispatch('search_module/setHasResults', true)
          )
        } else {
          this.$store.dispatch('search_module/setHasResults', true)
        }
      }
    },
  },
  mounted() {
    this.clearMapMarkers()

    this.$store.state.search_module.isSearchInProgress = false //Reset if search is in progress in a previous module
  },
  destroyed() {
    this.$store.dispatch('simpliciti_map/resetStore', null)
  },
  methods: {
    executeGlobalToolbarAction(name) {
      this.onToolbarAction({
        action: name,
        type: 'total',
      })
    },
    /**
     * There are three possible toolbars actions:
     * map: Switch to table+map
     * table: Siwtch to table
     */
    async onToolbarAction(options) {
      this.detailedItemsBy = options.type
      this.detailedItemsByDateValue = options.dateFormatted
      this.detailedItemsByVehicleIdValue = options.vehicleId
      this.detailedItemsByCircuitIdValue = options.circuitId

      if (['map', 'table'].includes(options.action)) {
        this.switchModeAtLevel(options)
      }
      if (options.action === 'markers') {
        await this.clearMapMarkers()
        this.$loader.show()

        let result = await this.fetchDetailsUsingSynthesisDataset({
          //Also render on the map as soon as data becomes available
          onDataAvailable: () => {
            //Wait "detailedItems" to be computed
            //this.$nextTick(() => {
            //this.prepareMapMarkers();
            //});
          },
        })

        //Use case: If the user click the loupe/markers-icon twice, the data is already cached and prepareMapMarkers is not called because watch is not triggered
        if (result.length === 0) {
          this.prepareMapMarkers()
        }

        console.log('onToolbarAction::markers', {
          result,
        })

        this.$loader.hide()
        //this.$nextTick(() => {
        //this.prepareMapMarkers();
        //});
      }
    },
    /**
     * Will trigger a layout change (state.mode) to table_map or table depending on options.action
     * @param {Object} options.action map/table
     */
    switchModeAtLevel(options = {}) {
      this.$nextTick(() => {
        this.switchMode(
          {
            map: 'table_map',
            table: 'table',
          }[options.action]
        )
      })
    },
    async clearMapMarkers() {
      await this.$store.dispatch('simpliciti_map/setDataset', {
        type: 'identificationBacsMarkers',
        data: [],
      })
    },
    prepareMapMarkers() {
      console.log('prepareMapMarkers', {
        count: this.detailedItems.length,
      })
      if (this.detailedItems.length > 5000) {
        this.$store.dispatch('alert/addAlert', {
          type: 'info',
          title: this.$t('identification.map.render_limit_message_title'),
          text: this.$t('identification.map.render_limit_message_body', {
            length: this.detailedItems.length,
          }),
          //text: `Affichage de 5000 éléments sur les ${this.detailedItems.length} résultats`,
        })
      }

      //Wait for vue to compute detailedItems
      this.$nextTick(() => {
        this.$store.dispatch('simpliciti_map/setDataset', {
          type: 'identificationBacsMarkers',
          data: this.detailedItems.slice(0, 5000),
        })
      })
    },
    /**
     * This will load details dataset for table/map
     *
     * It will use computed 'filteredSynthesisItems' to fetch a subset, optionally (i.g: The user clicks to show markers from a circuit on the map)
     */
    async fetchDetailsUsingSynthesisDataset(options = {}) {
      return new Promise((resolve, reject) => {
        let self = this

        if (this.isFetchDetailsInProgress) {
          this.$log.warn(
            'fetchDetailsUsingSynthesisDataset::skip (in progress)'
          )
          return
        } else {
          this.isFetchDetailsInProgress = true
        }

        console.log('fetchDetailsUsingSynthesisDataset::splitOperation')
        splitOperation({
          sequential: true,
          generateSubsets() {
            let subsets = []
            self.filteredSynthesisItems.forEach((item) => {
              item.circuits.forEach((synthesisItem) => {
                if (
                  !self.detailsGroupedByCircuit.find((grouped) => {
                    return (
                      grouped.dateFormatted == item.dateFormatted &&
                      grouped.vehicleId == item.vehicleId &&
                      grouped.circuitId == synthesisItem.circuitId
                    )
                  })
                ) {
                  //Filter a single circuit (If user click a toolbar action at circuit level)
                  if (
                    //Is filter by circuit mode
                    self.detailedItemsBy === 'circuit' &&
                    //Has a circuit id as filter value ("" = 0)
                    ![undefined, null].includes(
                      self.detailedItemsByCircuitIdValue
                    ) &&
                    //Not the same circuit
                    synthesisItem.circuitId !==
                      self.detailedItemsByCircuitIdValue &&
                    //Not the same circuit
                    !(
                      self.detailedItemsByCircuitIdValue === '' &&
                      synthesisItem.circuitId === 0
                    )
                  ) {
                    return
                  }

                  subsets.push({
                    dateFormatted: item.dateFormatted,
                    date: item.date,
                    vehicleId: item.vehicleId,
                    circuitId: !synthesisItem.circuitId
                      ? 0
                      : synthesisItem.circuitId,
                  })
                }
              })
            })
            console.log(
              'fetchDetailsUsingSynthesisDataset::splitOperation::subsets',
              {
                count: subsets.length,
              }
            )
            return subsets
          },
          async handleSubset(subset) {
            return await fetchLeveesDetails(
              subset.date,
              subset.vehicleId,
              subset.circuitId,
              {
                filters: {
                  puceNumber: self.puceFilterValue,
                  identified: self.identifiedFilterValue,
                  stopped: self.stoppedFilterValue,
                  highPoint: self.highPointFilterValue,
                  blacklisted: self.blacklistFilterValue,
                },
              }
            )
          },
          withSubsetResult(results, subset) {
            self.detailsGroupedByCircuit.push(
              Object.freeze({
                dateFormatted: subset.dateFormatted,
                date: subset.date,
                vehicleId: subset.vehicleId,
                circuitId: subset.circuitId,
                items: results,
              })
            )
            options &&
              options.onDataAvailable &&
              options.onDataAvailable(results)
          },
        })
          .then((r) => resolve(r))
          .finally(() => {
            this.isFetchDetailsInProgress = false
          })
      })
    },

    /**
     * Fetch synthesis dataset
     * - Splits requests by vehicle/date (1-1)
     */
    async performsSearch() {
      this.detailsGroupedByCircuit = []
      this.items = []
      this.clearMapMarkers()
      let dateRanges = this.selection.selectedDateRanges
      //vehiclers or circuits
      let selectedElementsIds = this.$store.getters[
        'search_module/getSelectedIdsByType'
      ](this.activeSearchFormTabName)
      //Validation: Limit vehicles selection
      if (
        selectedElementsIds.length * dateRanges.length >
        getIdentificationSearchSelectionLimit()
      ) {
        this.$alertPopup.showSelectionLimitWarning(
          selectedElementsIds.length * dateRanges.length,
          getIdentificationSearchSelectionLimit()
        )
        return
      }

      let len = dateRanges.length
      if (len === 0) {
        dateRanges = [
          [
            moment().hour(0).minute(0).second(0)._d,
            moment().hour(23).minute(59).second(59)._d,
          ],
        ]
        len = dateRanges.length
      }
      this.$store.state.search_module.isSearchInProgress = true
      this.$loader.show()

      let dateFrom = dateRanges[0][0]
      let dateTo = dateRanges[len - 1][1]
      this.dateFromFormatted = this.$date.formatDatetimeWithSeconds(dateFrom)
      this.dateToFormatted = this.$date.formatDatetimeWithSeconds(dateTo)

      let dates = dateRanges
        .map((range) => range[0])
        .sort((a, b) => (a.getTime() < b.getTime() ? 1 : -1))
      await fetchLeveesSynthesis({
        elementIds: [...selectedElementsIds],
        elementType: this.activeSearchFormTabName,
        dates,
        filters: {
          puceNumber: this.puceFilterValue,
          identified: this.identifiedFilterValue,
          stopped: this.stoppedFilterValue,
          highPoint: this.highPointFilterValue,
          blacklisted: this.blacklistFilterValue,
        },
        handleResults: (results) => {
          this.items = [...this.items, ...results]
          if (
            this.mode !== 'list' &&
            this.items.length > 0 &&
            !this.menuCollapsed
          ) {
            this.menuCollapsed = true
          }
        },
      })

      //Switch to results (even if no results from API, user should see the message indicating no results)
      this.$nextTick(() =>
        this.$store.dispatch('search_module/setHasResults', true)
      )

      //Hide loader in search module search button
      this.$store.state.search_module.isSearchInProgress = false
      //Hide big loader
      this.$loader.hide()
    },
    async onSearchModuleSelectionChange() {
      this.performsSearch()
    },
    onSearchModuleViewChange(view) {
      //React to search module view changes (selection / results)
    },
    onSearchModuleClearSelection() {
      //Clear custom filters
      this.puceFilterValue = ''
      this.identifiedFilterValue = ''
      this.stoppedFilterValue = ''
      this.highPointFilterValue = ''
      this.blacklistFilterValue = ''
    },
    switchMode(mode) {
      this.mode = mode
      console.log('switchMode', {
        mode,
      })
      if (this.mode === 'list') {
        this.$nextTick(() => {
          this.menuCollapsed = false
        })
      } else {
        //Table/Table+Map will prepare details dataset async
        this.fetchDetailsUsingSynthesisDataset()

        this.$nextTick(() => {
          this.menuCollapsed = true
        })
      }
    },
  },
}
</script>
<style lang="scss">
.identificationModule {
  .table-layout {
    height: calc(100% - 20px);
  }
  .filter-label {
    font: normal normal normal 9px/14px Open Sans;
    letter-spacing: 0px;
    color: var(--color-tundora);
    margin: 0px;
  }
  .filter-title {
    font: normal normal normal 11px/14px Open Sans;
    letter-spacing: 0px;
    color: var(--color-tundora);
    margin: 0px;
  }
  .puceNumberInput::placeholder {
    font-size: 0.8rem;
  }
}
</style>