Загрузка данных


<template>
  <div>
    <div class="columns mx-1 mt-1">

      <!-- ─── Карта ─── -->
      <div class="column is-6-tablet is-8-widescreen is-8-fullhd main-map">
        <l-map
          ref="map"
          :max-bounds="$options.MAP.maxBounds"
          :min-zoom="$options.MAP.minZoom"
          :max-zoom="$options.MAP.maxZoom"
          :zoom="$options.MAP.zoom"
          :center="$options.MAP.center"
          :bounds="bounds"
          @ready="onMapReady"
        >
          <l-tile-layer :url="$options.MAP.url" />

          <v-marker-cluster :options="clusterOptions">
            <l-marker
              v-for="object in list"
              :key="`marker_${object.b_id}`"
              :lat-lng="[object.a_lat, object.a_lon]"
            >
              <l-icon :class-name="markerClass(object)">
                <b-icon icon="building" size="is-small" />
              </l-icon>

              <l-popup>
                {{ object.a_name }}
                <br />
                <span
                  v-for="providerId in object.competitors"
                  :key="providerId"
                  class="tag is-primary is-light mr-1"
                >
                  {{ providers[providerId].p_name }}
                </span>
              </l-popup>
            </l-marker>
          </v-marker-cluster>
        </l-map>
      </div>

      <!-- ─── Список адресов ─── -->
      <div class="column">
        <div class="subtitle is-5 has-background-info-light p-2 mb-2">
          Список объектов на карте ({{ visibleAddresses.length }} шт.):
        </div>

        <b-table
          :data="visibleAddresses"
          bordered
          narrowed
          hoverable
          paginated
          :per-page="perPage"
          :current-page="currentPage"
          pagination-size="is-small"
          sticky-header
          height="calc(100vh - 280px)"
          class="smallTable is-size-7 fullHeight"
          :row-class="rowClass"
          detailed
          detail-key="b_id"
          :show-detail-icon="false"
          :opened-detailed="openedDetails"
          @click="toggleRowDetail"
        >
          <!-- № -->
          <b-table-column v-slot="props" field="id" label="№" width="20">
            <div>{{ props.index + 1 }}</div>
            <b-icon
              v-if="!props.row.a_house_number"
              icon="exclamation-circle"
              type="is-danger"
              title="Неверно выбрана геометка у адреса. Сверьте название по оригинальному адресу из АСТУП файла (вторая строчка в столбце с адресом) и найдите актуальный адрес из OpenStreetMap"
              style="width: 12px"
            />
          </b-table-column>

          <!-- Адрес -->
          <b-table-column v-slot="props" field="a_name" label="Адрес" width="150">
            <div class="has-text-weight-semibold" title="Адрес после геокодирования OpenStreetMap" style="cursor: default">
              <span v-if="props.row.a_city">{{ props.row.a_city }},</span>
              {{ props.row.a_name }}
            </div>
            <div
              :class="props.row.a_house_number ? 'has-text-grey' : 'tag is-success'"
              title="Оригинальный адрес из файла АСТУП"
              style="cursor: default"
            >
              {{ props.row.b_eva }}, {{ props.row.b_number }}
            </div>
          </b-table-column>

          <!-- Подъездов / Этажей / Квартир -->
          <b-table-column v-slot="props" field="b_entrance_count" label="Подъездов" width="20">
            {{ props.row.b_entrance_count }}
          </b-table-column>

          <b-table-column v-slot="props" field="b_floor" label="Этажей" width="20">
            {{ props.row.b_floor }}
          </b-table-column>

          <b-table-column v-slot="props" field="b_flat" label="Квартир" width="20">
            {{ props.row.b_flat }}
          </b-table-column>

          <!-- Конкуренты -->
          <b-table-column v-slot="props" field="competitors" label="Конкуренты" width="50" centered>
            <span
              v-for="providerId in props.row.competitors"
              :key="providerId"
              class="tag is-primary is-light mr-1"
            >
              {{ providers[providerId].p_name }}
            </span>
          </b-table-column>

          <!-- Детальная строка -->
          <template slot="detail" slot-scope="props">
            <div class="columns">

              <!-- Информация о здании -->
              <div class="column">
                <div class="box" :class="{ 'has-background-grey-light': props.row.b_deleted }">
                  <b-field>
                    <div class="title is-5" style="width: 100%">
                      {{ props.row.b_eva }}, {{ props.row.b_number }}
                    </div>
                    <div style="width: 30px">
                      <button
                        v-if="!props.row.b_deleted && isOperator"
                        class="button is-success is-outlined is-small is-fullwidth"
                        @click.stop="buildingModal_visible = true"
                      >
                        <b-icon icon="pen" aria-hidden="true" />
                      </button>
                    </div>
                  </b-field>

                  <b-tag v-if="props.row.b_deleted" type="is-danger">
                    Удален {{ new Date(props.row.b_deleted).toLocaleDateString() }}
                  </b-tag>

                  <b-field class="label_30" horizontal label="Адрес OpenStreetMap">
                    <div>
                      <span v-if="props.row.a_city">{{ props.row.a_city }},</span>
                      {{ props.row.a_name }}
                    </div>
                  </b-field>

                  <b-field class="label_30" horizontal label="Примечание">
                    {{ props.row.b_note }}
                  </b-field>
                </div>
              </div>

              <!-- Список конкурентов -->
              <div class="column">
                <div class="box">
                  <div class="media">
                    <div class="media-content">
                      <div class="subtitle is-5">Список конкурентов</div>
                    </div>
                    <div class="media-right">
                      <button
                        v-if="!props.row.b_deleted && isOperator"
                        class="button is-info is-small is-fullwidth"
                        @click="openAddCompetitorModal"
                      >
                        Добавить конкурента
                      </button>
                    </div>
                  </div>

                  <b-table
                    v-if="props.row.competitors"
                    :data="props.row.competitors"
                    :bordered="false"
                    striped
                    narrowed
                    hoverable
                    class="is-size-7"
                  >
                    <template>
                      <b-table-column v-slot="{ row }" field="p_id" label="№" width="10">
                        {{ providers[row].p_id }}
                      </b-table-column>

                      <b-table-column v-slot="{ row }" field="p_name" label="Наименование" width="50">
                        {{ providers[row].p_name }}
                      </b-table-column>

                      <b-table-column v-slot="{ row }" field="edit" label="" width="10">
                        <button
                          class="button is-primary is-small is-outlined"
                          style="height: 24px"
                          disabled
                          title="Редактировать конкурента"
                          @click="openEditCompetitorModal(row)"
                        >
                          <b-icon icon="pen" />
                        </button>
                      </b-table-column>
                    </template>

                    <template slot="empty">
                      <section class="section">
                        <div class="content has-text-grey has-text-centered">
                          <p>Данные не получены</p>
                        </div>
                      </section>
                    </template>
                  </b-table>

                  <div v-else class="subtitle is-6 has-text-weight-bold mt-2">
                    Адреса не добавлены
                  </div>
                </div>
              </div>

            </div>
          </template>

          <template #empty>
            <div class="content has-text-grey has-text-centered">
              <b-icon class="my-icon height-100 is-size-1" icon="blind" size="is-large" />
              <p class="mb-3">Ничего не найдено</p>
            </div>
          </template>
        </b-table>
      </div>

    </div>

    <!-- ─── Модальное окно редактирования здания ─── -->
    <b-modal v-model="buildingModal_visible" has-modal-card width="1000px">
      <Building-modal
        :edit_building="selectedRecord"
        :providers="providers"
        @close-modal="buildingModal_visible = false"
        @update-info="onBuildingUpdated"
      />
    </b-modal>
  </div>
</template>

<script>
import debounce from 'lodash/debounce';
import L from 'leaflet';
import { Icon } from 'leaflet';
import { LMap, LTileLayer, LMarker, LIcon, LPopup } from 'vue2-leaflet';
import Vue2LeafletMarkerCluster from 'vue2-leaflet-markercluster';
import 'leaflet/dist/leaflet.css';

import { wrapWithLoading } from '../../../mixins/wrappers.mixin';
import { showDangerToast } from '../../../mixins/toast.mixin';
import BuildingModal from '../modals/buildingModal.vue';

// Исправляем отображение иконок Leaflet по умолчанию
delete Icon.Default.prototype._getIconUrl;
Icon.Default.mergeOptions({
  iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
  iconUrl: require('leaflet/dist/images/marker-icon.png'),
  shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
});

export default {
  name: 'MapPage',

  components: {
    LMap,
    LTileLayer,
    LMarker,
    LIcon,
    LPopup,
    'v-marker-cluster': Vue2LeafletMarkerCluster,
    BuildingModal,
  },

  mixins: [wrapWithLoading, showDangerToast],

  props: ['list', 'providers', 'isOperator'],

  // ─── Статические настройки карты (не реактивны — вынесены из data) ───────────
  MAP: {
    url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
    center: [53.9, 27.56],
    zoom: 12,
    minZoom: 5,
    maxZoom: 18,
    maxBounds: L.latLngBounds(
      [51.190785980509354, 23.076354200895736],
      [56.245591731745186, 32.878022287814566]
    ),
  },

  data() {
    return {
      // Карта
      mapInstance: null,

      // Настройки кластера
      clusterOptions: {
        spiderfyDistanceMultiplier: 1,
        chunkedLoading: false,
        maxClusterRadius: 70,
        disableClusteringAtZoom: 16,
      },

      // Начальный bbox карты (Минск)
      bounds: L.latLngBounds([53.88, 27.4], [53.93, 27.7]),

      // Адреса, попадающие в текущий viewport карты
      visibleAddresses: [],

      // Таблица
      openedDetails: [],
      perPage: 50,
      currentPage: 1,

      // Модалки
      buildingModal_visible: false,
      organizationModal_visible: false,
      selectedOrganization: null,
    };
  },

  computed: {
    /**
     * Запись, развёрнутая в детальной строке таблицы.
     * Используется как пропс для BuildingModal.
     */
    selectedRecord() {
      if (!this.openedDetails.length) return null;
      return this.list.find((item) => item.b_id === this.openedDetails[0]) ?? null;
    },
  },

  beforeDestroy() {
    if (this.mapInstance) {
      this.mapInstance.off('moveend', this.updateVisibleAddresses);
    }
  },

  methods: {
    // ─── Карта ────────────────────────────────────────────────────────────────

    /** Вызывается когда Leaflet-карта готова. Сохраняем инстанс и вешаем обработчик. */
    onMapReady(map) {
      this.mapInstance = map;
      map.on('moveend', this.updateVisibleAddresses);
      this.updateVisibleAddresses();
    },

    /**
     * Фильтрует `list` по текущим границам карты.
     * Debounce 1000 мс — не вызывается слишком часто при перетаскивании.
     *
     * Оптимизация: используем Object.freeze на результате — Vue не
     * делает массив реактивным глубоко, что ускоряет рендер таблицы.
     */
    updateVisibleAddresses: debounce(function () {
      if (!this.mapInstance) return;

      const bounds = this.mapInstance.getBounds();

      this.visibleAddresses = Object.freeze(
        this.list.filter((item) => bounds.contains(L.latLng(item.a_lat, item.a_lon)))
      );
    }, 1000),

    // ─── Таблица ──────────────────────────────────────────────────────────────

    /** CSS-класс строки таблицы (красный фон если нет номера дома). */
    rowClass(row) {
      return !row.a_house_number ? 'has-background-danger-light' : '';
    },

    /** Открывает / закрывает детальную строку при клике по строке таблицы. */
    toggleRowDetail(row) {
      this.openedDetails = this.openedDetails[0] === row.b_id ? [] : [row.b_id];
    },

    // ─── Маркеры ──────────────────────────────────────────────────────────────

    /** CSS-класс маркера на карте в зависимости от статуса объекта. */
    markerClass(object) {
      if (object.o_free) return 'object-icon border-green';
      if (object.o_sc)   return 'object-icon border-red';
      return 'object-icon border-orange';
    },

    // ─── Модалки / действия ───────────────────────────────────────────────────

    openAddCompetitorModal() {
      this.selectedOrganization = null;
      this.organizationModal_visible = true;
    },

    openEditCompetitorModal(row) {
      this.selectedOrganization = row;
      this.organizationModal_visible = true;
    },

    onBuildingUpdated() {
      this.getList();
      if (this.$refs.shortloglist) {
        this.$refs.shortloglist.getShortLog();
      }
    },
  },
};
</script>

<style scoped lang="scss">
@import '~leaflet/dist/leaflet.css';
@import '~leaflet.markercluster/dist/MarkerCluster.css';
@import '~leaflet.markercluster/dist/MarkerCluster.Default.css';

// ─── Стили карты ────────────────────────────────────────────────────────────
.app-main-page .main-map::v-deep {
  .vue2leaflet-map {
    border-radius: 10px;
    box-shadow: 3px 3px 5px #0000005c, 0 0 3px #0000008c;
    height: calc(100vh - 180px) !important;
    z-index: 1 !important;

    // Скрываем лишний копирайт
    .leaflet-control-attribution.leaflet-control a[href="https://leafletjs.com"] {
      display: none;
    }

    // ─── Иконки объектов ────────────────────────────────────────────────────
    .leaflet-marker-icon.object-icon {
      background-color: white;
      border-style: solid;
      border-width: 2px;
      border-radius: 24px;
      display: flex;
      align-items: center;
      justify-content: center;
      height: 28px !important;
      width: 28px !important;
      padding: 3px;

      // Цвета границ маркеров по статусу объекта
      $marker-colors: (
        'red':       red,
        'green':     #23e635,
        'blue':      #2160e6,
        'yellow':    yellow,
        'violet':    violet,
        'orange':    orange,
        'olive':     olive,
        'turquoise': turquoise,
        'orangeRed': OrangeRed,
      );

      @each $name, $color in $marker-colors {
        &.border-#{$name} {
          border-color: $color;
          &:after { background: $color; }
        }
      }
    }

    .leaflet-marker-icon.addressPopup {
      background-color: white;
      border: 2px solid black;
      height: 10px;
      border-radius: 50%;
    }
  }
}

// ─── Стили таблицы и скроллбаров ────────────────────────────────────────────
.app-map-page::v-deep {
  // Общий миксин для кастомного скроллбара
  @mixin custom-scrollbar {
    ::-webkit-scrollbar       { width: 7px; }
    ::-webkit-scrollbar-track { background: #f1f1f1; }
    ::-webkit-scrollbar-thumb { border-radius: 3px; background: #888; }
    ::-webkit-scrollbar-thumb:hover { background: #555; }
  }

  .smallTable {
    .table-wrapper { overflow-x: hidden; }
    @include custom-scrollbar;
  }

  .smallScrollCntainer {
    .smallScroll {
      overflow-x: hidden;
      max-height: calc(100vh - 180px - 220px) !important;
      overflow-y: scroll;
    }
    @include custom-scrollbar;
  }

  .halfHeight > div.table-wrapper { height: calc(50vh - 140px) !important; }
  .fullHeight > div.table-wrapper { height: calc(100vh - 230px) !important; }

  // Адаптив для узких экранов
  @media screen and (max-width: 1000px) {
    .columns {
      display: block !important;
      .column {
        width: 100% !important;
        .vue2leaflet-map { height: 600px !important; }
        .smallTable .table-wrapper { height: 100%; }
      }
    }
  }

  // v-select стили
  .vs__dropdown-menu { width: auto !important; }
  .v-select input     { height: 20px; }
}
</style>