3953 lines
162 KiB
JavaScript
3953 lines
162 KiB
JavaScript
/**
|
|
* unified_index.js
|
|
* Handles the unified assets+locations index page.
|
|
* Expects the following globals injected by the template:
|
|
* THEMATIC_CONFIG, CONDITIONAL_FILTER_CONFIG, THEMATIC_CATEGORIES, SELECTED_THEMATICS,
|
|
* PREVENTIVE_DRAFT_ID, LOCKED_THEMATIC_CODE, PREVENTIVE_ELEMENT_TYPE,
|
|
* SELECTED_ASSETS, SELECTED_LOCATION_IDS, LINK_ASSETS_URL,
|
|
* ASSETS_INDEX_PREFS, DEFAULT_THEMATIC_CODE, SAVE_ASSETS_PREFS_URL
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// ─── State ────────────────────────────────────────────────────────────────────
|
|
|
|
var allFeatures = []; // merged features from all loaded thematics
|
|
var filteredFeatures = []; // after type+search+status+category filters
|
|
var activeThematics = new Set(SELECTED_THEMATICS || []);
|
|
var activeCategories = new Set(SELECTED_CATEGORIES || []);
|
|
var activeModels = new Set();
|
|
var modelFilterCollapsed = true;
|
|
var activeConditionalFilters = {};
|
|
var currentTypeFilter = 'all'; // 'all' | 'locations' | 'assets'
|
|
var searchTerms = [];
|
|
var searchMode = 'and';
|
|
var shouldFitSearch = false;
|
|
var searchIsGlobal = false;
|
|
var selectedIds = new Set(); // ids of checked rows
|
|
var loadingCount = 0;
|
|
|
|
var categoryLabelByThematicAndId = {};
|
|
var categoryValueByThematicAndLabel = {};
|
|
var modelsByCategoryKey = {};
|
|
var pendingModelFetches = {};
|
|
|
|
// GeoJSON caches per thematic { thematic_code: { locations: [], assets: [] } }
|
|
var geojsonCache = {};
|
|
var locationsFetchSignatures = {};
|
|
var assetsFetchSignatures = {};
|
|
|
|
// Assets: thematics that always load all assets (small datasets)
|
|
const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters', 'parking']);
|
|
|
|
const MIN_ASSETS_ZOOM = 14; // zoom level at which bbox-based asset loading triggers
|
|
var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox'
|
|
|
|
// MapLibre sources
|
|
var mergedLocationsGeojson = { type: 'FeatureCollection', features: [] };
|
|
var mergedAssetsGeojson = { type: 'FeatureCollection', features: [] };
|
|
var currentFetchAbortController = null;
|
|
var assetClickPopup = null;
|
|
var zoomFocusMarker = null;
|
|
var loadWarningVisible = false;
|
|
var unifiedAddAssetThematic = null;
|
|
var unifiedAddLocationThematic = null;
|
|
var unifiedLocationPickerMap = null;
|
|
var unifiedLocationPickerMarker = null;
|
|
var unifiedLocationPickerControl = null;
|
|
var panelTextSpriteMap = null;
|
|
var unifiedPanelOffsetRefreshTimer = null;
|
|
|
|
function shouldUseTrafficlightsLocationCreateFlow(thematic) {
|
|
return !!(thematic && thematic.code === 'trafficlights' && thematic.location_type);
|
|
}
|
|
|
|
function getUnifiedAddModalConfig() {
|
|
if (typeof ADD_MODAL_CONFIG !== 'object' || ADD_MODAL_CONFIG === null) {
|
|
return { asset_thematics: [], location_thematics: [] };
|
|
}
|
|
return ADD_MODAL_CONFIG;
|
|
}
|
|
|
|
function setModalAlert(alertId, message, variant) {
|
|
const alert = document.getElementById(alertId);
|
|
if (!alert) return;
|
|
if (!message) {
|
|
alert.classList.add('d-none');
|
|
alert.textContent = '';
|
|
return;
|
|
}
|
|
alert.className = `alert alert-${variant || 'info'}`;
|
|
alert.innerHTML = message;
|
|
alert.classList.remove('d-none');
|
|
}
|
|
|
|
function fillSelectWithOptions(select, items, placeholder) {
|
|
if (!select) return;
|
|
select.innerHTML = '';
|
|
if (placeholder) {
|
|
const option = document.createElement('option');
|
|
option.value = '';
|
|
option.textContent = placeholder;
|
|
select.appendChild(option);
|
|
}
|
|
(items || []).forEach(item => {
|
|
const option = document.createElement('option');
|
|
option.value = item.code;
|
|
option.textContent = item.name;
|
|
if (item.icon) {
|
|
option.dataset.icon = item.icon;
|
|
}
|
|
if (item.location_type) {
|
|
option.dataset.locationType = item.location_type;
|
|
}
|
|
if (item.location_label) {
|
|
option.dataset.locationLabel = item.location_label;
|
|
}
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
|
|
async function loadUnifiedAssetCategories(thematicCode) {
|
|
const categorySelect = document.getElementById('unifiedAddAssetCategory');
|
|
const modelSelect = document.getElementById('unifiedAddAssetModel');
|
|
if (!categorySelect || !modelSelect || !thematicCode) return;
|
|
|
|
categorySelect.disabled = true;
|
|
modelSelect.disabled = true;
|
|
modelSelect.innerHTML = `<option value="">${gettext('Sélectionner d\'abord une catégorie...')}</option>`;
|
|
categorySelect.innerHTML = `<option value="">${gettext('Chargement...')}</option>`;
|
|
|
|
try {
|
|
const response = await fetch(`/assets/${thematicCode}/categories/`);
|
|
const data = await response.json();
|
|
const categories = Array.isArray(data.categories) ? data.categories : [];
|
|
categorySelect.innerHTML = `<option value="">${gettext('Sélectionner une catégorie...')}</option>`;
|
|
categories.forEach(category => {
|
|
const option = document.createElement('option');
|
|
option.value = category.id;
|
|
option.textContent = category.name || category.code || category.id;
|
|
categorySelect.appendChild(option);
|
|
});
|
|
categorySelect.disabled = false;
|
|
if (!categories.length) {
|
|
categorySelect.innerHTML = `<option value="">${gettext('Aucune catégorie disponible')}</option>`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading asset categories:', error);
|
|
categorySelect.innerHTML = `<option value="">${gettext('Erreur de chargement')}</option>`;
|
|
}
|
|
}
|
|
|
|
function getUnifiedAssetLocationConfig(thematicCode) {
|
|
const thematicConfig = THEMATIC_CONFIG && THEMATIC_CONFIG[thematicCode] ? THEMATIC_CONFIG[thematicCode] : null;
|
|
if (!thematicConfig || !thematicConfig.locations_geojson_url) {
|
|
return null;
|
|
}
|
|
return thematicConfig;
|
|
}
|
|
|
|
async function loadUnifiedAssetLocations(thematicCode) {
|
|
const group = document.getElementById('unifiedAddAssetLocationGroup');
|
|
const locationSelect = document.getElementById('unifiedAddAssetLocation');
|
|
const locationHelp = document.getElementById('unifiedAddAssetLocationHelp');
|
|
const submitBtn = document.getElementById('unifiedAddAssetSubmit');
|
|
const locationConfig = getUnifiedAssetLocationConfig(thematicCode);
|
|
|
|
if (!group || !locationSelect) return;
|
|
|
|
if (!locationConfig) {
|
|
group.classList.add('d-none');
|
|
locationSelect.innerHTML = '<option value=""></option>';
|
|
locationSelect.disabled = true;
|
|
locationSelect.required = false;
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
if (locationHelp) locationHelp.textContent = '';
|
|
return;
|
|
}
|
|
|
|
group.classList.remove('d-none');
|
|
locationSelect.disabled = true;
|
|
locationSelect.required = true;
|
|
locationSelect.innerHTML = `<option value="">${gettext('Chargement...')}</option>`;
|
|
if (locationHelp) {
|
|
locationHelp.textContent = gettext('Sélectionnez la localisation liée à ce nouvel asset.');
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(locationConfig.locations_geojson_url);
|
|
const data = await response.json();
|
|
const features = Array.isArray(data.features) ? data.features : [];
|
|
locationSelect.innerHTML = `<option value="">${gettext('Sélectionner une localisation...')}</option>`;
|
|
|
|
features.forEach(feature => {
|
|
const props = feature.properties || {};
|
|
const locationId = feature.id ?? props.id ?? props.object_id ?? '';
|
|
if (locationId === '') return;
|
|
const code = props.code || '';
|
|
const name = props.name || props.name_fr || props.name_nl || '';
|
|
const label = [code, name].filter(Boolean).join(' - ') || String(locationId);
|
|
const option = document.createElement('option');
|
|
option.value = locationId;
|
|
option.textContent = label;
|
|
locationSelect.appendChild(option);
|
|
});
|
|
|
|
if (features.length > 0) {
|
|
locationSelect.disabled = false;
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
} else {
|
|
locationSelect.innerHTML = `<option value="">${gettext('Aucune localisation disponible')}</option>`;
|
|
locationSelect.disabled = true;
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
if (locationHelp) {
|
|
locationHelp.textContent = gettext('Aucune localisation disponible pour créer cet asset.');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading asset locations:', error);
|
|
locationSelect.innerHTML = `<option value="">${gettext('Erreur de chargement')}</option>`;
|
|
locationSelect.disabled = true;
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
if (locationHelp) {
|
|
locationHelp.textContent = gettext('Impossible de charger les localisations pour cette thématique.');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadUnifiedAssetModels(thematicCode, categoryId) {
|
|
const modelSelect = document.getElementById('unifiedAddAssetModel');
|
|
if (!modelSelect || !thematicCode) return;
|
|
|
|
modelSelect.disabled = true;
|
|
if (!categoryId) {
|
|
modelSelect.innerHTML = `<option value="">${gettext('Sélectionner d\'abord une catégorie...')}</option>`;
|
|
return;
|
|
}
|
|
|
|
modelSelect.innerHTML = `<option value="">${gettext('Chargement...')}</option>`;
|
|
|
|
try {
|
|
const response = await fetch(`/assets/${thematicCode}/category/${categoryId}/models/`);
|
|
const data = await response.json();
|
|
const models = Array.isArray(data.models) ? data.models : [];
|
|
modelSelect.innerHTML = `<option value="">${gettext('Sélectionner un modèle...')}</option>`;
|
|
models.forEach(model => {
|
|
const option = document.createElement('option');
|
|
option.value = model.id;
|
|
option.textContent = model.name || model.code || model.id;
|
|
if (model.model_type) {
|
|
option.dataset.modelType = model.model_type;
|
|
}
|
|
modelSelect.appendChild(option);
|
|
});
|
|
if (models.length) {
|
|
modelSelect.disabled = false;
|
|
} else {
|
|
modelSelect.innerHTML = `<option value="">${gettext('Aucun modèle disponible')}</option>`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading asset models:', error);
|
|
modelSelect.innerHTML = `<option value="">${gettext('Erreur de chargement')}</option>`;
|
|
}
|
|
}
|
|
|
|
function resetUnifiedAssetForm() {
|
|
const form = document.getElementById('unifiedAddAssetForm');
|
|
const categorySelect = document.getElementById('unifiedAddAssetCategory');
|
|
const modelSelect = document.getElementById('unifiedAddAssetModel');
|
|
const submitBtn = document.getElementById('unifiedAddAssetSubmit');
|
|
if (form) form.reset();
|
|
if (categorySelect) {
|
|
categorySelect.innerHTML = `<option value="">${gettext('Sélectionner une catégorie...')}</option>`;
|
|
categorySelect.disabled = true;
|
|
}
|
|
if (modelSelect) {
|
|
modelSelect.innerHTML = `<option value="">${gettext('Sélectionner d\'abord une catégorie...')}</option>`;
|
|
modelSelect.disabled = true;
|
|
}
|
|
const locationGroup = document.getElementById('unifiedAddAssetLocationGroup');
|
|
const locationSelect = document.getElementById('unifiedAddAssetLocation');
|
|
const locationHelp = document.getElementById('unifiedAddAssetLocationHelp');
|
|
const codeGroup = document.getElementById('unifiedAddAssetCodeGroup');
|
|
const codeInput = document.getElementById('unifiedAddAssetCode');
|
|
const codeHelp = document.getElementById('unifiedAddAssetCodeHelp');
|
|
if (locationGroup) locationGroup.classList.add('d-none');
|
|
if (locationSelect) {
|
|
locationSelect.innerHTML = '<option value=""></option>';
|
|
locationSelect.disabled = true;
|
|
locationSelect.required = false;
|
|
}
|
|
if (locationHelp) locationHelp.textContent = '';
|
|
if (codeGroup) codeGroup.classList.remove('d-none');
|
|
if (codeInput) codeInput.required = true;
|
|
if (codeHelp) {
|
|
codeHelp.textContent = '';
|
|
codeHelp.classList.add('d-none');
|
|
}
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
setModalAlert('unifiedAddAssetAlert', '', 'info');
|
|
}
|
|
|
|
async function openUnifiedAssetModal(thematicCode) {
|
|
const config = getUnifiedAddModalConfig();
|
|
const thematic = (config.asset_thematics || []).find(item => item.code === thematicCode);
|
|
if (!thematic) return;
|
|
|
|
unifiedAddAssetThematic = thematic;
|
|
const modalLabel = document.getElementById('unifiedAddAssetModalLabel');
|
|
if (modalLabel) modalLabel.textContent = `${gettext('Créer un asset')} - ${thematic.name}`;
|
|
resetUnifiedAssetForm();
|
|
const thematicInput = document.getElementById('unified-add-asset-thematic-code');
|
|
if (thematicInput) thematicInput.value = thematic.code;
|
|
await loadUnifiedAssetCategories(thematic.code);
|
|
|
|
const codeGroup = document.getElementById('unifiedAddAssetCodeGroup');
|
|
const codeInput = document.getElementById('unifiedAddAssetCode');
|
|
const codeHelp = document.getElementById('unifiedAddAssetCodeHelp');
|
|
|
|
if (shouldUseTrafficlightsLocationCreateFlow(thematic)) {
|
|
await loadUnifiedAssetLocations(thematic.code);
|
|
if (codeGroup) codeGroup.classList.add('d-none');
|
|
if (codeInput) codeInput.required = false;
|
|
if (codeHelp) {
|
|
codeHelp.textContent = gettext('Le code est généré automatiquement.');
|
|
codeHelp.classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
const modalEl = document.getElementById('unifiedAddAssetModal');
|
|
if (modalEl) {
|
|
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
|
}
|
|
}
|
|
|
|
function initUnifiedLocationPickerMap() {
|
|
const mapDiv = document.getElementById('locPickerMap');
|
|
if (!mapDiv || typeof maplibregl === 'undefined') return;
|
|
|
|
if (unifiedLocationPickerMap) {
|
|
unifiedLocationPickerMap.remove();
|
|
unifiedLocationPickerMap = null;
|
|
unifiedLocationPickerMarker = null;
|
|
unifiedLocationPickerControl = null;
|
|
}
|
|
|
|
const lonInput = document.getElementById('id_lon');
|
|
const latInput = document.getElementById('id_lat');
|
|
|
|
let center = [4.3572, 50.8477];
|
|
let zoom = 11;
|
|
if (lonInput && latInput && lonInput.value && latInput.value) {
|
|
center = [parseFloat(lonInput.value), parseFloat(latInput.value)];
|
|
zoom = 17;
|
|
}
|
|
|
|
unifiedLocationPickerMap = new maplibregl.Map({
|
|
container: mapDiv,
|
|
style: window.MAP_STYLE_LIGHT,
|
|
center,
|
|
zoom,
|
|
minZoom: 9,
|
|
maxZoom: 24,
|
|
cooperativeGestures: true,
|
|
});
|
|
|
|
const setPoint = (lng, lat, fly = false) => {
|
|
if (lonInput) lonInput.value = String(lng);
|
|
if (latInput) latInput.value = String(lat);
|
|
|
|
if (!unifiedLocationPickerMarker) {
|
|
unifiedLocationPickerMarker = new maplibregl.Marker({ draggable: true })
|
|
.setLngLat([lng, lat])
|
|
.addTo(unifiedLocationPickerMap);
|
|
unifiedLocationPickerMarker.on('dragend', () => {
|
|
const ll = unifiedLocationPickerMarker.getLngLat();
|
|
if (lonInput) lonInput.value = String(ll.lng);
|
|
if (latInput) latInput.value = String(ll.lat);
|
|
});
|
|
} else {
|
|
unifiedLocationPickerMarker.setLngLat([lng, lat]);
|
|
}
|
|
|
|
if (fly) {
|
|
unifiedLocationPickerMap.flyTo({ center: [lng, lat], zoom: 17, duration: 600 });
|
|
}
|
|
};
|
|
|
|
if (typeof AddressSearchControl !== 'undefined' && typeof AUTOCOMPLETE_STREET_URL !== 'undefined') {
|
|
unifiedLocationPickerControl = new AddressSearchControl({
|
|
autocompleteUrl: AUTOCOMPLETE_STREET_URL,
|
|
lonInputId: 'id_lon',
|
|
latInputId: 'id_lat',
|
|
});
|
|
unifiedLocationPickerMap.addControl(unifiedLocationPickerControl, 'top-left');
|
|
|
|
unifiedLocationPickerMap.on('load', () => {
|
|
if (lonInput && latInput && lonInput.value && latInput.value && unifiedLocationPickerControl) {
|
|
unifiedLocationPickerControl.setMarker(parseFloat(lonInput.value), parseFloat(latInput.value), false);
|
|
}
|
|
unifiedLocationPickerMap.resize();
|
|
});
|
|
|
|
unifiedLocationPickerMap.on('click', (e) => {
|
|
if (unifiedLocationPickerControl) {
|
|
unifiedLocationPickerControl.setMarker(e.lngLat.lng, e.lngLat.lat);
|
|
}
|
|
});
|
|
} else {
|
|
unifiedLocationPickerMap.on('load', () => {
|
|
if (lonInput && latInput && lonInput.value && latInput.value) {
|
|
setPoint(parseFloat(lonInput.value), parseFloat(latInput.value), false);
|
|
}
|
|
unifiedLocationPickerMap.resize();
|
|
});
|
|
unifiedLocationPickerMap.on('click', (e) => {
|
|
setPoint(e.lngLat.lng, e.lngLat.lat);
|
|
});
|
|
}
|
|
|
|
document.getElementById('locPickerGeoBtn')?.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (!navigator.geolocation) return;
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
const lng = pos.coords.longitude;
|
|
const lat = pos.coords.latitude;
|
|
if (unifiedLocationPickerControl) {
|
|
unifiedLocationPickerControl.setMarker(lng, lat);
|
|
} else {
|
|
setPoint(lng, lat, true);
|
|
}
|
|
},
|
|
() => { },
|
|
{ enableHighAccuracy: true, timeout: 10000 }
|
|
);
|
|
});
|
|
|
|
document.getElementById('locPickerClearBtn')?.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (lonInput) lonInput.value = '';
|
|
if (latInput) latInput.value = '';
|
|
if (unifiedLocationPickerControl) {
|
|
unifiedLocationPickerControl.clearMarker();
|
|
}
|
|
if (unifiedLocationPickerMarker) {
|
|
unifiedLocationPickerMarker.remove();
|
|
unifiedLocationPickerMarker = null;
|
|
}
|
|
unifiedLocationPickerMap.flyTo({ center: [4.3572, 50.8477], zoom: 11, duration: 600 });
|
|
});
|
|
}
|
|
|
|
async function loadUnifiedLocationForm(thematicCode, locationType) {
|
|
const fieldsContainer = document.getElementById('unifiedAddLocationFields');
|
|
const metaContainer = document.getElementById('unifiedAddLocationMeta');
|
|
if (!fieldsContainer || !metaContainer || !thematicCode || !locationType) return;
|
|
|
|
fieldsContainer.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></div>';
|
|
|
|
try {
|
|
const response = await fetch(`/assets/locations/${thematicCode}/${locationType}/form/`);
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(data.error || gettext('Erreur lors du chargement du formulaire.'));
|
|
}
|
|
fieldsContainer.innerHTML = data.html || '';
|
|
} catch (error) {
|
|
console.error('Error loading location form:', error);
|
|
fieldsContainer.innerHTML = `<div class="alert alert-danger mb-0">${gettext('Erreur lors du chargement du formulaire.')}</div>`;
|
|
}
|
|
}
|
|
|
|
function resetUnifiedLocationForm() {
|
|
const form = document.getElementById('unifiedAddLocationForm');
|
|
if (form) form.reset();
|
|
document.getElementById('unifiedAddLocationFields').innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></div>';
|
|
setModalAlert('unifiedAddLocationAlert', '', 'info');
|
|
}
|
|
|
|
async function openUnifiedLocationModal(thematicCode) {
|
|
const config = getUnifiedAddModalConfig();
|
|
const thematic = (config.location_thematics || []).find(item => item.code === thematicCode);
|
|
if (!thematic) return;
|
|
|
|
unifiedAddLocationThematic = thematic;
|
|
const modalLabel = document.getElementById('unifiedAddLocationModalLabel');
|
|
if (modalLabel) modalLabel.textContent = `${gettext('Créer une localisation')} - ${thematic.name}`;
|
|
const modalMeta = document.getElementById('unifiedAddLocationMeta');
|
|
if (modalMeta) modalMeta.textContent = `${gettext('Type')}: ${thematic.location_label}`;
|
|
resetUnifiedLocationForm();
|
|
const thematicCodeInput = document.getElementById('unified-add-location-thematic-code');
|
|
if (thematicCodeInput) thematicCodeInput.value = thematic.code;
|
|
const locationTypeInput = document.getElementById('unified-add-location-type');
|
|
if (locationTypeInput) locationTypeInput.value = thematic.location_type;
|
|
await loadUnifiedLocationForm(thematic.code, thematic.location_type);
|
|
|
|
const modalEl = document.getElementById('unifiedAddLocationModal');
|
|
if (!modalEl) return;
|
|
const onShown = () => {
|
|
initUnifiedLocationPickerMap();
|
|
bootstrap.Modal.getOrCreateInstance(modalEl).handleUpdate();
|
|
modalEl.removeEventListener('shown.bs.modal', onShown);
|
|
};
|
|
modalEl.addEventListener('shown.bs.modal', onShown);
|
|
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
|
}
|
|
|
|
// ─── Bootstrap-table columns ──────────────────────────────────────────────────
|
|
|
|
function zoomFormatter(value, row) {
|
|
if (!row.lon) return '';
|
|
return `<button class="btn btn-sm btn-light zoom-btn"
|
|
data-lon="${row.lon}"
|
|
data-lat="${row.lat}"
|
|
data-bs-toggle="tooltip"
|
|
title="${gettext('Zoom')}">
|
|
<i class="bi bi-geo-alt"></i>
|
|
</button>`;
|
|
}
|
|
|
|
function buildColumns() {
|
|
return [
|
|
{ field: 'check', checkbox: true, width: 36 },
|
|
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 },
|
|
{
|
|
field: 'code', title: gettext('Code'), sortable: true,
|
|
formatter: function (v, row) {
|
|
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`;
|
|
return v || '-';
|
|
}
|
|
},
|
|
{ field: 'name', title: gettext('Nom'), sortable: true },
|
|
{ field: 'element_type', title: gettext('Type'), sortable: true },
|
|
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true },
|
|
{ field: 'category', title: gettext('Catégorie'), sortable: true, visible: false },
|
|
{ field: 'model', title: gettext('Modèle'), sortable: true, visible: false },
|
|
{
|
|
field: 'status', title: gettext('Statut'), sortable: true, visible: false,
|
|
formatter: function (v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; }
|
|
},
|
|
{
|
|
field: 'validation_status', title: gettext('Statut de validation'), sortable: true, visible: true,
|
|
formatter: function (v) {
|
|
const labels = {
|
|
'to_be_validated': gettext('À valider'),
|
|
'validated': gettext('Validé'),
|
|
'to_be_completed': gettext('À compléter'),
|
|
'rejected': gettext('Rejeté')
|
|
};
|
|
return labels[v] || v || '-';
|
|
}
|
|
},
|
|
];
|
|
}
|
|
|
|
const UNIFIED_PREFERENCE_COLUMN_FIELDS = [
|
|
'code',
|
|
'name',
|
|
'element_type',
|
|
'thematic_name',
|
|
'category',
|
|
'model',
|
|
'status',
|
|
'validation_status',
|
|
];
|
|
|
|
var isApplyingSavedColumnVisibility = false;
|
|
|
|
function getVisibleColumnsPreference() {
|
|
const table = $('#unified-table');
|
|
if (table.length && typeof table.bootstrapTable === 'function') {
|
|
const visibleColumns = table.bootstrapTable('getVisibleColumns') || [];
|
|
return visibleColumns
|
|
.map(col => col.field)
|
|
.filter(field => UNIFIED_PREFERENCE_COLUMN_FIELDS.includes(field));
|
|
}
|
|
|
|
return buildColumns()
|
|
.filter(col => UNIFIED_PREFERENCE_COLUMN_FIELDS.includes(col.field) && col.visible !== false)
|
|
.map(col => col.field);
|
|
}
|
|
|
|
function applyVisibleColumnsPreference(visibleColumns) {
|
|
if (!Array.isArray(visibleColumns)) return;
|
|
|
|
const table = $('#unified-table');
|
|
if (!table.length || typeof table.bootstrapTable !== 'function') return;
|
|
|
|
const wanted = new Set(visibleColumns.map(String));
|
|
isApplyingSavedColumnVisibility = true;
|
|
UNIFIED_PREFERENCE_COLUMN_FIELDS.forEach(field => {
|
|
if (wanted.has(field)) {
|
|
table.bootstrapTable('showColumn', field);
|
|
} else {
|
|
table.bootstrapTable('hideColumn', field);
|
|
}
|
|
});
|
|
isApplyingSavedColumnVisibility = false;
|
|
}
|
|
|
|
// ─── Preferences: save / restore ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Applies saved preferences (or the user's default thematic on first visit).
|
|
* Called inside document.ready BEFORE the initial refreshData() / showEmptyState().
|
|
* Skipped entirely in preventive-intervention mode or when the URL provides
|
|
* explicit thematic selection (GET params / locked_thematic_code).
|
|
*/
|
|
function applyInitialPreferences() {
|
|
if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE || (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS)) return;
|
|
|
|
if (ASSETS_INDEX_PREFS && typeof ASSETS_INDEX_PREFS === 'object' && Object.keys(ASSETS_INDEX_PREFS).length > 0) {
|
|
const prefs = ASSETS_INDEX_PREFS;
|
|
|
|
// Thematics — only restore codes that are still accessible
|
|
if (Array.isArray(prefs.thematics) && prefs.thematics.length > 0) {
|
|
const valid = prefs.thematics.filter(code => code in THEMATIC_CONFIG);
|
|
if (valid.length > 0) {
|
|
activeThematics = new Set(valid);
|
|
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
|
|
cb.checked = activeThematics.has(cb.value);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Type filter
|
|
if (prefs.type_filter) {
|
|
currentTypeFilter = prefs.type_filter;
|
|
const rb = document.querySelector(`input[name="unified-type-filter"][value="${prefs.type_filter}"]`);
|
|
if (rb) rb.checked = true;
|
|
}
|
|
|
|
// Include no geometry filter
|
|
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
|
|
if (includeNoGeomCheckbox && prefs.hasOwnProperty('include_no_geom')) {
|
|
includeNoGeomCheckbox.checked = prefs.include_no_geom;
|
|
}
|
|
|
|
// Status filters
|
|
if (Array.isArray(prefs.status_filters) && prefs.status_filters.length > 0) {
|
|
prefs.status_filters.forEach(status => {
|
|
const cb = document.querySelector(`[name="filter-status"][value="${status}"]`);
|
|
if (cb) cb.checked = true;
|
|
});
|
|
}
|
|
|
|
// Validation status filters
|
|
if (Array.isArray(prefs.validation_status_filters) && prefs.validation_status_filters.length > 0) {
|
|
prefs.validation_status_filters.forEach(status => {
|
|
const cb = document.querySelector(`[name="filter-validation-status"][value="${status}"]`);
|
|
if (cb) cb.checked = true;
|
|
});
|
|
}
|
|
|
|
if (Array.isArray(prefs.categories) && prefs.categories.length > 0) {
|
|
activeCategories = new Set(prefs.categories);
|
|
}
|
|
|
|
if (Array.isArray(prefs.models) && prefs.models.length > 0) {
|
|
activeModels = new Set(prefs.models);
|
|
}
|
|
|
|
if (prefs.conditional_filters && typeof prefs.conditional_filters === 'object') {
|
|
activeConditionalFilters = prefs.conditional_filters;
|
|
}
|
|
|
|
if (Array.isArray(prefs.visible_columns)) {
|
|
applyVisibleColumnsPreference(prefs.visible_columns);
|
|
}
|
|
|
|
// Map position — stored for use inside the mapLoaded handler
|
|
if (prefs.map_center && prefs.map_zoom) {
|
|
window._restoredMapCenter = prefs.map_center;
|
|
window._restoredMapZoom = prefs.map_zoom;
|
|
}
|
|
} else if (DEFAULT_THEMATIC_CODE && DEFAULT_THEMATIC_CODE in THEMATIC_CONFIG) {
|
|
// First visit: pre-select the user's default thematic
|
|
activeThematics = new Set([DEFAULT_THEMATIC_CODE]);
|
|
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
|
|
cb.checked = cb.value === DEFAULT_THEMATIC_CODE;
|
|
});
|
|
}
|
|
}
|
|
|
|
var _prefsSaveTimer = null;
|
|
|
|
function schedulePreferencesSave() {
|
|
if (typeof SAVE_ASSETS_PREFS_URL === 'undefined') return;
|
|
clearTimeout(_prefsSaveTimer);
|
|
_prefsSaveTimer = setTimeout(savePreferences, 1000);
|
|
}
|
|
|
|
function savePreferences() {
|
|
if (typeof SAVE_ASSETS_PREFS_URL === 'undefined') return;
|
|
const statusFilters = Array.from(
|
|
document.querySelectorAll('[name="filter-status"]:checked')
|
|
).map(cb => cb.value);
|
|
const validationStatusFilters = Array.from(
|
|
document.querySelectorAll('[name="filter-validation-status"]:checked')
|
|
).map(cb => cb.value);
|
|
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
|
|
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
|
|
const prefs = {
|
|
thematics: Array.from(activeThematics),
|
|
categories: Array.from(activeCategories),
|
|
models: Array.from(activeModels),
|
|
conditional_filters: activeConditionalFilters,
|
|
visible_columns: getVisibleColumnsPreference(),
|
|
type_filter: currentTypeFilter,
|
|
status_filters: statusFilters,
|
|
validation_status_filters: validationStatusFilters,
|
|
include_no_geom: includeNoGeom,
|
|
map_center: (typeof map !== 'undefined' && map.getCenter) ? [map.getCenter().lng, map.getCenter().lat] : null,
|
|
map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null,
|
|
};
|
|
fetch(SAVE_ASSETS_PREFS_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
|
|
body: JSON.stringify(prefs),
|
|
keepalive: true,
|
|
}).catch(() => { });
|
|
}
|
|
|
|
// ─── Table init ───────────────────────────────────────────────────────────────
|
|
|
|
$(document).ready(function () {
|
|
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
|
|
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
|
tooltipTriggerList.forEach(function (el) {
|
|
new bootstrap.Tooltip(el);
|
|
});
|
|
}
|
|
|
|
function calcHeight() {
|
|
return Math.max(300, $(window).height() - 210);
|
|
}
|
|
|
|
$('#unified-table').bootstrapTable({
|
|
columns: buildColumns(),
|
|
height: calcHeight(),
|
|
search: false,
|
|
pagination: true,
|
|
pageSize: 100,
|
|
clickToSelect: true,
|
|
ignoreClickToSelectOn: function (element) {
|
|
return $(element).closest('.zoom-btn, .unified-code-link').length > 0;
|
|
},
|
|
onCheck: function (row) { row.state = true; selectedIds.add(row.id); updateSelectionStatus(); },
|
|
onUncheck: function (row) { row.state = false; selectedIds.delete(row.id); updateSelectionStatus(); },
|
|
onCheckAll: function (rows) { rows.forEach(r => { r.state = true; selectedIds.add(r.id); }); updateSelectionStatus(); },
|
|
onUncheckAll: function (rows) { rows.forEach(r => { r.state = false; selectedIds.delete(r.id); }); updateSelectionStatus(); },
|
|
onClickRow: function (row) {
|
|
if (!row.lon || !row.lat) return;
|
|
const source = row.is_location ? 'locations' : 'assets';
|
|
if (typeof highlightMapFeature === 'function') {
|
|
highlightMapFeature(map, source, row.id, row.is_location ? locationshighlightState : assetshighlightState);
|
|
}
|
|
},
|
|
});
|
|
|
|
$(window).on('resize', function () {
|
|
$('#unified-table').bootstrapTable('resetView', { height: calcHeight() });
|
|
});
|
|
|
|
$('#unified-table').on('column-switch.bs.table', function () {
|
|
if (isApplyingSavedColumnVisibility) return;
|
|
schedulePreferencesSave();
|
|
});
|
|
|
|
// ─── Thematic checkboxes ────────────────────────────────────────────────
|
|
|
|
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
|
|
cb.addEventListener('change', function () {
|
|
if (this.checked) activeThematics.add(this.value);
|
|
else activeThematics.delete(this.value);
|
|
renderCategoryFilters();
|
|
renderModelFilters();
|
|
renderConditionalFilters();
|
|
});
|
|
});
|
|
|
|
document.getElementById('thematic-select-all').addEventListener('click', function () {
|
|
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
|
|
cb.checked = true;
|
|
activeThematics.add(cb.value);
|
|
});
|
|
renderCategoryFilters();
|
|
renderModelFilters();
|
|
renderConditionalFilters();
|
|
});
|
|
|
|
document.getElementById('thematic-deselect-all').addEventListener('click', function () {
|
|
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
|
|
cb.checked = false;
|
|
});
|
|
activeThematics.clear();
|
|
renderCategoryFilters();
|
|
renderModelFilters();
|
|
renderConditionalFilters();
|
|
});
|
|
|
|
document.getElementById('category-select-all').addEventListener('click', function () {
|
|
getVisibleCategoryOptions().forEach(category => activeCategories.add(category.value));
|
|
renderCategoryFilters();
|
|
renderModelFilters();
|
|
});
|
|
|
|
document.getElementById('category-deselect-all').addEventListener('click', function () {
|
|
activeCategories.clear();
|
|
renderCategoryFilters();
|
|
renderModelFilters();
|
|
});
|
|
|
|
document.getElementById('model-select-all').addEventListener('click', function () {
|
|
getVisibleModelOptions().forEach(model => activeModels.add(model.value));
|
|
renderModelFilters();
|
|
});
|
|
|
|
document.getElementById('model-deselect-all').addEventListener('click', function () {
|
|
activeModels.clear();
|
|
renderModelFilters();
|
|
});
|
|
|
|
document.getElementById('model-filter-toggle').addEventListener('click', function () {
|
|
modelFilterCollapsed = !modelFilterCollapsed;
|
|
renderModelFilters();
|
|
});
|
|
|
|
// ─── Type radio ────────────────────────────────────────────────────────
|
|
|
|
document.querySelectorAll('input[name="unified-type-filter"]').forEach(rb => {
|
|
rb.addEventListener('change', function () {
|
|
if (this.checked) currentTypeFilter = this.value;
|
|
});
|
|
});
|
|
|
|
// ─── include-no-geom checkbox ──────────────────────────────────────────
|
|
|
|
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
|
|
if (includeNoGeomCheckbox) {
|
|
includeNoGeomCheckbox.addEventListener('change', function () {
|
|
refreshData();
|
|
schedulePreferencesSave();
|
|
});
|
|
}
|
|
|
|
// init from locked element type
|
|
if (PREVENTIVE_ELEMENT_TYPE === 'assets') currentTypeFilter = 'assets';
|
|
else if (PREVENTIVE_ELEMENT_TYPE === 'locations') currentTypeFilter = 'locations';
|
|
|
|
// ─── Add actions ─────────────────────────────────────────────────────
|
|
|
|
const addConfig = getUnifiedAddModalConfig();
|
|
const assetThematics = Array.isArray(addConfig.asset_thematics) ? addConfig.asset_thematics : [];
|
|
const locationThematics = Array.isArray(addConfig.location_thematics) ? addConfig.location_thematics : [];
|
|
const addButton = document.getElementById('btn-add-asset');
|
|
const assetThematicSelect = document.getElementById('unified-add-asset-thematic');
|
|
const locationThematicSelect = document.getElementById('unified-add-location-thematic');
|
|
const chooserEmpty = document.getElementById('unified-add-chooser-empty');
|
|
|
|
fillSelectWithOptions(assetThematicSelect, assetThematics, assetThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
|
|
fillSelectWithOptions(locationThematicSelect, locationThematics, locationThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
|
|
|
|
if (assetThematicSelect) assetThematicSelect.disabled = !assetThematics.length;
|
|
if (locationThematicSelect) locationThematicSelect.disabled = !locationThematics.length;
|
|
if (chooserEmpty) chooserEmpty.classList.toggle('d-none', assetThematics.length > 0 || locationThematics.length > 0);
|
|
if (addButton && !assetThematics.length && !locationThematics.length) {
|
|
addButton.disabled = true;
|
|
addButton.title = gettext('Vous n\'avez pas de droits de création');
|
|
}
|
|
|
|
addButton?.addEventListener('click', function () {
|
|
bootstrap.Modal.getOrCreateInstance(document.getElementById('unifiedAddChooserModal')).show();
|
|
});
|
|
|
|
assetThematicSelect?.addEventListener('change', function () {
|
|
unifiedAddAssetThematic = assetThematics.find(item => item.code === this.value) || null;
|
|
});
|
|
|
|
locationThematicSelect?.addEventListener('change', function () {
|
|
unifiedAddLocationThematic = locationThematics.find(item => item.code === this.value) || null;
|
|
});
|
|
|
|
document.getElementById('unified-add-asset-open')?.addEventListener('click', function () {
|
|
const thematicCode = assetThematicSelect ? assetThematicSelect.value : '';
|
|
if (!thematicCode) {
|
|
setModalAlert('unified-add-chooser-alert', gettext('Sélectionnez une thématique pour créer un asset.'), 'warning');
|
|
return;
|
|
}
|
|
setModalAlert('unified-add-chooser-alert', '', 'info');
|
|
const chooserEl = document.getElementById('unifiedAddChooserModal');
|
|
const chooser = bootstrap.Modal.getInstance(chooserEl);
|
|
if (chooser) {
|
|
const onHidden = () => {
|
|
openUnifiedAssetModal(thematicCode);
|
|
chooserEl.removeEventListener('hidden.bs.modal', onHidden);
|
|
};
|
|
chooserEl.addEventListener('hidden.bs.modal', onHidden);
|
|
chooser.hide();
|
|
} else {
|
|
openUnifiedAssetModal(thematicCode);
|
|
}
|
|
});
|
|
|
|
document.getElementById('unified-add-location-open')?.addEventListener('click', function () {
|
|
const thematicCode = locationThematicSelect ? locationThematicSelect.value : '';
|
|
if (!thematicCode) {
|
|
setModalAlert('unified-add-chooser-alert', gettext('Sélectionnez une thématique pour créer une localisation.'), 'warning');
|
|
return;
|
|
}
|
|
setModalAlert('unified-add-chooser-alert', '', 'info');
|
|
const chooserEl = document.getElementById('unifiedAddChooserModal');
|
|
const chooser = bootstrap.Modal.getInstance(chooserEl);
|
|
if (chooser) {
|
|
const onHidden = () => {
|
|
openUnifiedLocationModal(thematicCode);
|
|
chooserEl.removeEventListener('hidden.bs.modal', onHidden);
|
|
};
|
|
chooserEl.addEventListener('hidden.bs.modal', onHidden);
|
|
chooser.hide();
|
|
} else {
|
|
openUnifiedLocationModal(thematicCode);
|
|
}
|
|
});
|
|
|
|
document.getElementById('unifiedAddAssetCategory')?.addEventListener('change', function () {
|
|
const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || '';
|
|
loadUnifiedAssetModels(thematicCode, this.value);
|
|
});
|
|
|
|
document.getElementById('unifiedAddAssetForm')?.addEventListener('submit', async function (e) {
|
|
e.preventDefault();
|
|
const locationGroup = document.getElementById('unifiedAddAssetLocationGroup');
|
|
const locationSelect = document.getElementById('unifiedAddAssetLocation');
|
|
if (shouldUseTrafficlightsLocationCreateFlow(unifiedAddAssetThematic) && locationGroup && !locationGroup.classList.contains('d-none') && locationSelect && !locationSelect.value) {
|
|
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(gettext('Sélectionnez une localisation avant de créer cet asset.'))}`, 'warning');
|
|
return;
|
|
}
|
|
|
|
const submitBtn = document.getElementById('unifiedAddAssetSubmit');
|
|
const originalText = submitBtn ? submitBtn.innerHTML : '';
|
|
if (submitBtn) {
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${gettext('Création...')}`;
|
|
}
|
|
|
|
setModalAlert('unifiedAddAssetAlert', '', 'info');
|
|
|
|
const formData = new FormData(this);
|
|
const data = {};
|
|
formData.forEach((value, key) => {
|
|
data[key] = value;
|
|
});
|
|
|
|
if (!data.location_id) {
|
|
delete data.location_id;
|
|
}
|
|
|
|
let createUrl = '/assets/api/create-asset/';
|
|
let payload = data;
|
|
|
|
if (shouldUseTrafficlightsLocationCreateFlow(unifiedAddAssetThematic)) {
|
|
const selectedModelOption = document.getElementById('unifiedAddAssetModel')?.selectedOptions?.[0];
|
|
const modelType = selectedModelOption?.dataset?.modelType || '';
|
|
const assetType = modelType.replace(/model$/i, '');
|
|
if (!assetType) {
|
|
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(gettext('Type d\'asset introuvable pour ce modèle.'))}`, 'danger');
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = originalText;
|
|
}
|
|
return;
|
|
}
|
|
createUrl = `/assets/${data.thematic_code}/${unifiedAddAssetThematic.location_type}/${data.location_id}/create-asset/`;
|
|
payload = {
|
|
asset_type: assetType,
|
|
model_id: data.model_id || null,
|
|
category_id: data.category_id || null,
|
|
data: {
|
|
name_fr: data.name_fr || '',
|
|
name_nl: data.name_nl || '',
|
|
},
|
|
};
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(createUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
let result = null;
|
|
try {
|
|
result = await response.json();
|
|
} catch (e) {
|
|
result = null;
|
|
}
|
|
|
|
if (response.ok && result && result.success) {
|
|
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-check-circle me-1"></i>${escapeHtml(result.message || gettext('Asset créé.'))}`, 'success');
|
|
setTimeout(() => {
|
|
bootstrap.Modal.getInstance(document.getElementById('unifiedAddAssetModal'))?.hide();
|
|
const fallbackUrl = (result.asset_id && payload.asset_type)
|
|
? `/assets/${data.thematic_code}/${payload.asset_type}/${result.asset_id}/`
|
|
: '';
|
|
const redirectUrl = result.redirect_url || fallbackUrl;
|
|
if (redirectUrl) window.location.href = redirectUrl;
|
|
else window.location.reload();
|
|
}, 800);
|
|
} else {
|
|
const errorMsg = (result && (result.error || result.message))
|
|
|| (response.status ? `${gettext('Erreur serveur')} (${response.status})` : gettext('Erreur lors de la création.'));
|
|
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(errorMsg)}`, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating asset:', error);
|
|
setModalAlert('unifiedAddAssetAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(error.message || gettext('Erreur de connexion.'))}`, 'danger');
|
|
} finally {
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = originalText;
|
|
}
|
|
}
|
|
});
|
|
|
|
document.getElementById('unifiedAddLocationForm')?.addEventListener('submit', async function (e) {
|
|
e.preventDefault();
|
|
const thematicCode = document.getElementById('unified-add-location-thematic-code')?.value || '';
|
|
const locationType = document.getElementById('unified-add-location-type')?.value || '';
|
|
if (!thematicCode || !locationType) return;
|
|
|
|
const submitBtn = document.getElementById('unifiedAddLocationSubmit');
|
|
const originalText = submitBtn ? submitBtn.innerHTML : '';
|
|
if (submitBtn) {
|
|
submitBtn.disabled = true;
|
|
submitBtn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${gettext('Création...')}`;
|
|
}
|
|
|
|
setModalAlert('unifiedAddLocationAlert', '', 'info');
|
|
|
|
const formData = new FormData(this);
|
|
try {
|
|
const response = await fetch(`/assets/locations/${thematicCode}/${locationType}/create/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRFToken': getCsrfToken(),
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: formData,
|
|
});
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setModalAlert('unifiedAddLocationAlert', `<i class="bi bi-check-circle me-1"></i>${escapeHtml(result.label || gettext('Localisation créée.'))}`, 'success');
|
|
setTimeout(() => {
|
|
bootstrap.Modal.getInstance(document.getElementById('unifiedAddLocationModal'))?.hide();
|
|
window.location.reload();
|
|
}, 800);
|
|
} else {
|
|
const errors = result.errors ? Object.values(result.errors).flat().join(' ') : result.error;
|
|
setModalAlert('unifiedAddLocationAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(errors || gettext('Erreur lors de la création.'))}`, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating location:', error);
|
|
setModalAlert('unifiedAddLocationAlert', `<i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(gettext('Erreur de connexion.'))}`, 'danger');
|
|
} finally {
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.innerHTML = originalText;
|
|
}
|
|
}
|
|
});
|
|
|
|
document.getElementById('unifiedAddAssetModal')?.addEventListener('hidden.bs.modal', resetUnifiedAssetForm);
|
|
document.getElementById('unifiedAddLocationModal')?.addEventListener('hidden.bs.modal', function () {
|
|
if (unifiedLocationPickerMap) {
|
|
unifiedLocationPickerMap.remove();
|
|
unifiedLocationPickerMap = null;
|
|
unifiedLocationPickerMarker = null;
|
|
unifiedLocationPickerControl = null;
|
|
}
|
|
resetUnifiedLocationForm();
|
|
});
|
|
|
|
// ─── Search ────────────────────────────────────────────────────────────
|
|
|
|
function doSearch() {
|
|
const raw = document.getElementById('assets-search-input').value.trim();
|
|
searchTerms = raw ? raw.toLowerCase().split(/\s+/) : [];
|
|
const clearBtn = document.getElementById('assets-search-clear');
|
|
if (clearBtn) clearBtn.style.visibility = raw ? 'visible' : 'hidden';
|
|
if (searchTerms.length > 0) {
|
|
shouldFitSearch = true;
|
|
searchIsGlobal = true;
|
|
} else {
|
|
searchIsGlobal = false;
|
|
}
|
|
refreshData();
|
|
}
|
|
|
|
document.getElementById('assets-search-btn').addEventListener('click', doSearch);
|
|
document.getElementById('assets-search-input').addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter') doSearch();
|
|
});
|
|
document.getElementById('assets-search-clear').addEventListener('click', function () {
|
|
document.getElementById('assets-search-input').value = '';
|
|
doSearch();
|
|
});
|
|
document.querySelectorAll('input[name="assets-search-mode"]').forEach(rb => {
|
|
rb.addEventListener('change', function () {
|
|
searchMode = this.value;
|
|
if (searchTerms.length) {
|
|
shouldFitSearch = true;
|
|
searchIsGlobal = true;
|
|
refreshData();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Filters ───────────────────────────────────────────────────────────
|
|
|
|
document.getElementById('apply-filters').addEventListener('click', function () {
|
|
refreshData();
|
|
updateFilterBadge();
|
|
schedulePreferencesSave();
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('filterModal'));
|
|
if (modal) modal.hide();
|
|
});
|
|
|
|
document.getElementById('reset-filters').addEventListener('click', function () {
|
|
document.querySelectorAll('.thematic-checkbox').forEach(cb => { cb.checked = false; });
|
|
activeThematics.clear();
|
|
activeCategories.clear();
|
|
activeModels.clear();
|
|
modelFilterCollapsed = true;
|
|
activeConditionalFilters = {};
|
|
const allRadio = document.getElementById('type-all');
|
|
if (allRadio) { allRadio.checked = true; currentTypeFilter = 'all'; }
|
|
document.querySelectorAll('[name="filter-status"]').forEach(cb => { cb.checked = false; });
|
|
document.querySelectorAll('[name="filter-validation-status"]').forEach(cb => { cb.checked = false; });
|
|
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
|
|
if (includeNoGeomCheckbox) includeNoGeomCheckbox.checked = true;
|
|
renderCategoryFilters();
|
|
renderModelFilters();
|
|
renderConditionalFilters();
|
|
refreshData();
|
|
updateFilterBadge();
|
|
schedulePreferencesSave();
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('filterModal'));
|
|
if (modal) modal.hide();
|
|
});
|
|
|
|
// ─── Select all / deselect all ─────────────────────────────────────────
|
|
|
|
function selectAllTableRows() {
|
|
const allRows = $('#unified-table').bootstrapTable('getData') || [];
|
|
allRows.forEach(r => {
|
|
selectedIds.add(r.id);
|
|
r.state = true;
|
|
});
|
|
$('#unified-table').bootstrapTable('checkAll');
|
|
updateSelectionStatus();
|
|
}
|
|
|
|
function deselectAllTableRows() {
|
|
const allRows = $('#unified-table').bootstrapTable('getData') || [];
|
|
allRows.forEach(r => {
|
|
r.state = false;
|
|
});
|
|
selectedIds.clear();
|
|
$('#unified-table').bootstrapTable('uncheckAll');
|
|
updateSelectionStatus();
|
|
}
|
|
|
|
document.getElementById('unified-select-all').addEventListener('click', selectAllTableRows);
|
|
document.getElementById('unified-deselect-all').addEventListener('click', deselectAllTableRows);
|
|
|
|
bindUnifiedAssetsExport();
|
|
bindUnifiedBulkActions();
|
|
|
|
// ─── Layout buttons ────────────────────────────────────────────────────
|
|
|
|
document.getElementById('layout-btn-table').addEventListener('click', function () {
|
|
document.getElementById('col-table').className = 'col-12';
|
|
document.getElementById('col-map').classList.add('d-none');
|
|
setLayoutActive('table');
|
|
});
|
|
document.getElementById('layout-btn-both').addEventListener('click', function () {
|
|
document.getElementById('col-table').className = 'col-md-6';
|
|
const colMap = document.getElementById('col-map');
|
|
colMap.className = 'col-md-6';
|
|
colMap.style.cssText = 'position: relative; margin-top: 50px;';
|
|
setLayoutActive('both');
|
|
});
|
|
document.getElementById('layout-btn-map').addEventListener('click', function () {
|
|
document.getElementById('col-table').classList.add('d-none');
|
|
const colMap = document.getElementById('col-map');
|
|
colMap.className = 'col-12';
|
|
colMap.style.cssText = 'position: relative; margin-top: 50px;';
|
|
setLayoutActive('map');
|
|
});
|
|
|
|
function setLayoutActive(mode) {
|
|
['table', 'both', 'map'].forEach(m => {
|
|
const btn = document.getElementById(`layout-btn-${m}`);
|
|
btn.classList.toggle('text-primary', m === mode);
|
|
});
|
|
if (typeof map !== 'undefined' && map.resize) map.resize();
|
|
$('#unified-table').bootstrapTable('resetView', { height: calcHeight() });
|
|
}
|
|
|
|
// ─── Init ──────────────────────────────────────────────────────────────
|
|
|
|
applyInitialPreferences();
|
|
buildCategoryLookup();
|
|
renderCategoryFilters();
|
|
renderModelFilters();
|
|
renderConditionalFilters();
|
|
updateFilterBadge();
|
|
|
|
if (activeThematics.size > 0) {
|
|
refreshData();
|
|
} else {
|
|
showEmptyState();
|
|
}
|
|
|
|
// Pre-select items from intervention draft
|
|
if (SELECTED_LOCATION_IDS && SELECTED_LOCATION_IDS.length) {
|
|
SELECTED_LOCATION_IDS.forEach(id => selectedIds.add(id));
|
|
}
|
|
if (SELECTED_ASSETS && SELECTED_ASSETS.length) {
|
|
SELECTED_ASSETS.forEach(a => selectedIds.add(a.id || a));
|
|
}
|
|
|
|
// Zoom button: fly to location then refresh data (moveend from flyTo is
|
|
// programmatic and would be filtered out by the standard moveend handler)
|
|
$('#unified-table').on('click', '.zoom-btn', function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const lon = parseFloat($(this).data('lon'));
|
|
const lat = parseFloat($(this).data('lat'));
|
|
if (!isNaN(lon) && !isNaN(lat)) {
|
|
showZoomFocusMarker(lon, lat);
|
|
searchIsGlobal = false;
|
|
map.flyTo({ center: [lon, lat], zoom: 19, essential: true });
|
|
map.once('moveend', function () {
|
|
refreshData();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Asset loading helpers ─────────────────────────────────────────────────────
|
|
|
|
function hasTriggerFilter() {
|
|
if (activeCategories.size > 0) return true;
|
|
if (activeModels.size > 0) return true;
|
|
const publiclightingFilters = activeConditionalFilters.publiclighting || {};
|
|
if (Array.isArray(publiclightingFilters.municipality) && publiclightingFilters.municipality.length) return true;
|
|
const trafficlightsFilters = activeConditionalFilters.trafficlights || {};
|
|
if (Array.isArray(trafficlightsFilters.status) && trafficlightsFilters.status.length) return true;
|
|
if (Array.isArray(trafficlightsFilters.voltage) && trafficlightsFilters.voltage.length) return true;
|
|
if (trafficlightsFilters.connected) return true;
|
|
if (trafficlightsFilters.controller_maintenance_provider) return true;
|
|
if (trafficlightsFilters.asset_begin_date) return true;
|
|
const structuresFilters = activeConditionalFilters.structures || {};
|
|
if (Array.isArray(structuresFilters.structure_type) && structuresFilters.structure_type.length) return true;
|
|
if (structuresFilters.is_main_location) return true;
|
|
const natureFilters = activeConditionalFilters.nature || {};
|
|
if (natureFilters.location_type) return true;
|
|
if (natureFilters.highway) return true;
|
|
const roadsFilters = activeConditionalFilters.roads || {};
|
|
if (Array.isArray(roadsFilters.municipality) && roadsFilters.municipality.length) return true;
|
|
if (Array.isArray(roadsFilters.quality_level) && roadsFilters.quality_level.length) return true;
|
|
const signFilters = activeConditionalFilters.sign || {};
|
|
if (Array.isArray(signFilters.municipality) && signFilters.municipality.length) return true;
|
|
if (Array.isArray(signFilters.panel_type) && signFilters.panel_type.length) return true;
|
|
const outdoorFilters = activeConditionalFilters.outdoorfacilities || activeConditionalFilters.outdoor || {};
|
|
if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true;
|
|
if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true;
|
|
if (outdoorFilters.pmr_accessible) return true;
|
|
return false;
|
|
}
|
|
|
|
function getMapBbox() {
|
|
if (typeof map === 'undefined' || !map.getBounds) return null;
|
|
const b = map.getBounds();
|
|
const sw = b.getSouthWest(), ne = b.getNorthEast();
|
|
return {
|
|
type: 'Polygon',
|
|
coordinates: [[
|
|
[sw.lng, sw.lat], [ne.lng, sw.lat],
|
|
[ne.lng, ne.lat], [sw.lng, ne.lat],
|
|
[sw.lng, sw.lat],
|
|
]],
|
|
};
|
|
}
|
|
|
|
function getSelectedStatusFilters() {
|
|
return Array.from(document.querySelectorAll('[name="filter-status"]:checked')).map(cb => cb.value);
|
|
}
|
|
|
|
function getSelectedValidationStatusFilters() {
|
|
return Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value);
|
|
}
|
|
|
|
function getActiveCategoryIdsForThematic(thematicCode) {
|
|
const categories = THEMATIC_CATEGORIES[thematicCode] || [];
|
|
const ids = [];
|
|
|
|
categories.forEach(category => {
|
|
const categoryValue = `${thematicCode}::${category.name || ''}`;
|
|
if (activeCategories.has(categoryValue)) {
|
|
ids.push(String(category.id));
|
|
}
|
|
});
|
|
|
|
return ids;
|
|
}
|
|
|
|
function getActiveModelIdsForThematic(thematicCode) {
|
|
const ids = [];
|
|
|
|
getVisibleModelOptions().forEach(model => {
|
|
if (model.thematicCode !== thematicCode) return;
|
|
if (!activeModels.has(model.value)) return;
|
|
if (model.modelId == null) return;
|
|
const parsed = Number(model.modelId);
|
|
if (!Number.isFinite(parsed)) return;
|
|
ids.push(parsed);
|
|
});
|
|
|
|
return Array.from(new Set(ids));
|
|
}
|
|
|
|
function buildLocationsGeojsonPostBody(thematicCode) {
|
|
const payload = {};
|
|
const thematicFilters = getConditionalFiltersForThematic(thematicCode);
|
|
|
|
if (thematicCode === 'trafficlights') {
|
|
if (Array.isArray(thematicFilters.status) && thematicFilters.status.length) {
|
|
payload.status = thematicFilters.status;
|
|
}
|
|
if (Array.isArray(thematicFilters.voltage) && thematicFilters.voltage.length) {
|
|
payload.voltage = thematicFilters.voltage;
|
|
}
|
|
if (thematicFilters.connected) payload.connected = thematicFilters.connected;
|
|
if (thematicFilters.asset_begin_date) payload.begin_date = thematicFilters.asset_begin_date;
|
|
if (thematicFilters.controller_maintenance_provider) {
|
|
payload.controller_maintenance_provider = thematicFilters.controller_maintenance_provider;
|
|
}
|
|
}
|
|
|
|
if (['roads', 'publiclighting', 'sign', 'artworks'].includes(thematicCode)) {
|
|
if (Array.isArray(thematicFilters.municipality) && thematicFilters.municipality.length) {
|
|
payload.municipality = thematicFilters.municipality;
|
|
}
|
|
}
|
|
|
|
if (['structures', 'its', 'controlcenters'].includes(thematicCode)) {
|
|
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
|
|
}
|
|
|
|
if (thematicCode === 'structures') {
|
|
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
|
payload.structure_type = thematicFilters.structure_type;
|
|
}
|
|
}
|
|
|
|
if (thematicCode === 'nature') {
|
|
if (thematicFilters.location_type) payload.location_type = thematicFilters.location_type;
|
|
if (thematicFilters.highway) payload.highway = thematicFilters.highway;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
|
|
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
|
|
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
|
|
|
|
const payload = {
|
|
include_no_geom: includeNoGeom,
|
|
};
|
|
if (bbox) {
|
|
payload.filter_geom = { type: 'Feature', geometry: bbox };
|
|
}
|
|
|
|
const assetsSearchInput = document.getElementById('assets-search-input');
|
|
const rawSearch = assetsSearchInput ? assetsSearchInput.value.trim() : '';
|
|
if (rawSearch) {
|
|
payload.q = rawSearch;
|
|
payload.search_mode = searchMode;
|
|
}
|
|
|
|
if (!includeNonGeoFilters) return payload;
|
|
|
|
const modelIds = getActiveModelIdsForThematic(thematicCode);
|
|
if (modelIds.length) payload.model = modelIds;
|
|
else {
|
|
const categoryIds = getActiveCategoryIdsForThematic(thematicCode);
|
|
if (categoryIds.length) payload.category = categoryIds;
|
|
}
|
|
|
|
const statusFilters = getSelectedStatusFilters();
|
|
if (statusFilters.length) payload.asset_status = statusFilters;
|
|
|
|
const validationStatusFilters = getSelectedValidationStatusFilters();
|
|
if (validationStatusFilters.length) payload.asset_validation_status = validationStatusFilters;
|
|
|
|
const thematicFilters = getConditionalFiltersForThematic(thematicCode);
|
|
if (thematicCode === 'roads') {
|
|
if (Array.isArray(thematicFilters.municipality) && thematicFilters.municipality.length) {
|
|
payload.municipality = thematicFilters.municipality;
|
|
}
|
|
if (Array.isArray(thematicFilters.quality_level) && thematicFilters.quality_level.length) {
|
|
payload.quality_level = thematicFilters.quality_level;
|
|
}
|
|
}
|
|
if (thematicCode === 'trafficlights') {
|
|
if (Array.isArray(thematicFilters.status) && thematicFilters.status.length) {
|
|
payload.status = thematicFilters.status;
|
|
}
|
|
if (Array.isArray(thematicFilters.voltage) && thematicFilters.voltage.length) {
|
|
payload.voltage = thematicFilters.voltage;
|
|
}
|
|
if (thematicFilters.connected) {
|
|
payload.connected = thematicFilters.connected;
|
|
}
|
|
if (thematicFilters.controller_maintenance_provider) {
|
|
payload.controller_maintenance_provider = thematicFilters.controller_maintenance_provider;
|
|
}
|
|
if (thematicFilters.asset_begin_date) {
|
|
payload.asset_begin_date = thematicFilters.asset_begin_date;
|
|
}
|
|
}
|
|
if (thematicCode === 'structures') {
|
|
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
|
payload.structure_type = thematicFilters.structure_type;
|
|
}
|
|
if (thematicFilters.is_main_location) {
|
|
payload.is_main_location = thematicFilters.is_main_location;
|
|
}
|
|
}
|
|
if (thematicCode === 'publiclighting') {
|
|
if (Array.isArray(thematicFilters.municipality) && thematicFilters.municipality.length) {
|
|
payload.municipality = thematicFilters.municipality;
|
|
}
|
|
}
|
|
if (thematicCode === 'nature') {
|
|
if (thematicFilters.location_type) {
|
|
payload.location_type = thematicFilters.location_type;
|
|
}
|
|
if (thematicFilters.highway) {
|
|
payload.highway = thematicFilters.highway;
|
|
}
|
|
}
|
|
if (thematicCode === 'sign') {
|
|
if (Array.isArray(thematicFilters.municipality) && thematicFilters.municipality.length) {
|
|
payload.municipality = thematicFilters.municipality;
|
|
}
|
|
if (Array.isArray(thematicFilters.panel_type) && thematicFilters.panel_type.length) {
|
|
payload.panel_type = thematicFilters.panel_type;
|
|
}
|
|
}
|
|
|
|
const searchInput = document.getElementById('search');
|
|
const searchQuery = searchInput ? (searchInput.value || '').trim() : '';
|
|
if (searchQuery) payload.q = searchQuery;
|
|
|
|
return payload;
|
|
}
|
|
|
|
function getCsrfToken() {
|
|
const m = document.cookie.match(/csrftoken=([^;]+)/);
|
|
return m ? m[1] : '';
|
|
}
|
|
|
|
function getSelectedAssetKeysForExport() {
|
|
const rows = $('#unified-table').bootstrapTable('getData') || [];
|
|
const keys = [];
|
|
const seen = new Set();
|
|
|
|
rows.forEach(row => {
|
|
if (!row || row.is_location) return;
|
|
if (selectedIds.size > 0 && !selectedIds.has(row.id)) return;
|
|
|
|
const modelName = String(row.asset_type || '').toLowerCase().trim();
|
|
const objectId = Number(row.id);
|
|
if (!modelName || !Number.isFinite(objectId)) return;
|
|
|
|
const key = `${modelName}:${objectId}`;
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
keys.push(key);
|
|
});
|
|
|
|
return keys;
|
|
}
|
|
|
|
function bindUnifiedAssetsExport() {
|
|
const exportConfirmBtn = document.getElementById('unified-confirm-export-btn');
|
|
const exportError = document.getElementById('unified-export-error');
|
|
const exportModalEl = document.getElementById('unifiedExportModal');
|
|
if (!exportConfirmBtn || !exportModalEl) return;
|
|
|
|
exportModalEl.addEventListener('show.bs.modal', function () {
|
|
if (!exportError) return;
|
|
exportError.classList.add('d-none');
|
|
exportError.textContent = '';
|
|
|
|
const currentSelectedKeys = getSelectedAssetKeysForExport();
|
|
if (!currentSelectedKeys.length) {
|
|
selectAllTableRows();
|
|
}
|
|
});
|
|
|
|
exportConfirmBtn.addEventListener('click', function () {
|
|
let ids = getSelectedAssetKeysForExport();
|
|
if (!ids.length) {
|
|
selectAllTableRows();
|
|
ids = getSelectedAssetKeysForExport();
|
|
}
|
|
|
|
if (!ids.length) {
|
|
if (exportError) {
|
|
exportError.textContent = gettext('Veuillez sélectionner au moins un asset.');
|
|
exportError.classList.remove('d-none');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (exportError) {
|
|
exportError.classList.add('d-none');
|
|
exportError.textContent = '';
|
|
}
|
|
|
|
const format = document.querySelector('input[name="unified-export-format"]:checked')?.value || 'csv';
|
|
|
|
const form = document.createElement('form');
|
|
form.method = 'POST';
|
|
form.action = exportAssetsUrl;
|
|
form.style.display = 'none';
|
|
|
|
const csrfInput = document.createElement('input');
|
|
csrfInput.type = 'hidden';
|
|
csrfInput.name = 'csrfmiddlewaretoken';
|
|
csrfInput.value = getCsrfToken();
|
|
form.appendChild(csrfInput);
|
|
|
|
const formatInput = document.createElement('input');
|
|
formatInput.type = 'hidden';
|
|
formatInput.name = 'format';
|
|
formatInput.value = format;
|
|
form.appendChild(formatInput);
|
|
|
|
const idsInput = document.createElement('input');
|
|
idsInput.type = 'hidden';
|
|
idsInput.name = 'ids';
|
|
idsInput.value = JSON.stringify(ids);
|
|
form.appendChild(idsInput);
|
|
|
|
document.body.appendChild(form);
|
|
|
|
bootstrap.Modal.getOrCreateInstance(exportModalEl).hide();
|
|
|
|
form.submit();
|
|
setTimeout(() => {
|
|
if (form.parentNode) form.parentNode.removeChild(form);
|
|
}, 1000);
|
|
});
|
|
}
|
|
|
|
function buildBulkSelectedAssetsPayload() {
|
|
const rows = $('#unified-table').bootstrapTable('getData') || [];
|
|
const byId = new Map(rows.filter(r => !r.is_location).map(r => [String(r.id), r]));
|
|
const payload = [];
|
|
|
|
Array.from(selectedIds).forEach(rawId => {
|
|
const id = String(rawId);
|
|
const row = byId.get(id);
|
|
if (!row || !row.asset_type) return;
|
|
payload.push({ model: String(row.asset_type).toLowerCase().trim(), object_id: Number(id) });
|
|
});
|
|
|
|
return payload;
|
|
}
|
|
|
|
function bindUnifiedBulkActions() {
|
|
// 1. Bulk Status
|
|
const bulkStatusConfirmBtn = document.getElementById('bulk-status-confirm-btn');
|
|
const bulkStatusError = document.getElementById('bulk-status-error');
|
|
const bulkStatusModalEl = document.getElementById('bulkStatusModal');
|
|
|
|
if (bulkStatusConfirmBtn && bulkStatusModalEl) {
|
|
bulkStatusModalEl.addEventListener('show.bs.modal', function () {
|
|
if (bulkStatusError) {
|
|
bulkStatusError.classList.add('d-none');
|
|
bulkStatusError.textContent = '';
|
|
}
|
|
});
|
|
|
|
bulkStatusConfirmBtn.addEventListener('click', function () {
|
|
const assets = buildBulkSelectedAssetsPayload();
|
|
if (!assets.length) {
|
|
if (bulkStatusError) {
|
|
bulkStatusError.textContent = gettext('Veuillez sélectionner au moins un asset.');
|
|
bulkStatusError.classList.remove('d-none');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const newValue = document.getElementById('bulk-status-select').value;
|
|
|
|
fetch(bulkEditAssetsUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': getCsrfToken()
|
|
},
|
|
body: JSON.stringify({
|
|
assets: assets,
|
|
field_name: 'status',
|
|
new_value: newValue
|
|
})
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
bootstrap.Modal.getOrCreateInstance(bulkStatusModalEl).hide();
|
|
selectedIds.clear();
|
|
updateSelectionStatus();
|
|
refreshData();
|
|
} else {
|
|
if (bulkStatusError) {
|
|
bulkStatusError.textContent = data.error || gettext('Une erreur est survenue.');
|
|
bulkStatusError.classList.remove('d-none');
|
|
}
|
|
}
|
|
})
|
|
.catch(err => {
|
|
if (bulkStatusError) {
|
|
bulkStatusError.textContent = gettext('Une erreur est survenue.');
|
|
bulkStatusError.classList.remove('d-none');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// 2. Bulk Validation Status
|
|
const bulkValConfirmBtn = document.getElementById('bulk-validation-status-confirm-btn');
|
|
const bulkValError = document.getElementById('bulk-validation-status-error');
|
|
const bulkValModalEl = document.getElementById('bulkValidationStatusModal');
|
|
|
|
if (bulkValConfirmBtn && bulkValModalEl) {
|
|
bulkValModalEl.addEventListener('show.bs.modal', function () {
|
|
if (bulkValError) {
|
|
bulkValError.classList.add('d-none');
|
|
bulkValError.textContent = '';
|
|
}
|
|
});
|
|
|
|
bulkValConfirmBtn.addEventListener('click', function () {
|
|
const assets = buildBulkSelectedAssetsPayload();
|
|
if (!assets.length) {
|
|
if (bulkValError) {
|
|
bulkValError.textContent = gettext('Veuillez sélectionner au moins un asset.');
|
|
bulkValError.classList.remove('d-none');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const newValue = document.getElementById('bulk-validation-status-select').value;
|
|
|
|
fetch(bulkEditAssetsUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': getCsrfToken()
|
|
},
|
|
body: JSON.stringify({
|
|
assets: assets,
|
|
field_name: 'validation_status',
|
|
new_value: newValue
|
|
})
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
bootstrap.Modal.getOrCreateInstance(bulkValModalEl).hide();
|
|
selectedIds.clear();
|
|
updateSelectionStatus();
|
|
refreshData();
|
|
} else {
|
|
if (bulkValError) {
|
|
bulkValError.textContent = data.error || gettext('Une erreur est survenue.');
|
|
bulkValError.classList.remove('d-none');
|
|
}
|
|
}
|
|
})
|
|
.catch(err => {
|
|
if (bulkValError) {
|
|
bulkValError.textContent = gettext('Une erreur est survenue.');
|
|
bulkValError.classList.remove('d-none');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function showLoadWarning(message) {
|
|
const warning = document.getElementById('unified-load-warning');
|
|
if (!warning) return;
|
|
warning.textContent = message;
|
|
warning.classList.remove('d-none');
|
|
loadWarningVisible = true;
|
|
}
|
|
|
|
function hideLoadWarning() {
|
|
const warning = document.getElementById('unified-load-warning');
|
|
if (!warning) return;
|
|
warning.textContent = '';
|
|
warning.classList.add('d-none');
|
|
loadWarningVisible = false;
|
|
}
|
|
|
|
function handleLoadTooLarge() {
|
|
showLoadWarning(gettext("Trop de données à afficher. Ajoutez des filtres ou réduisez le périmètre pour charger le tableau et la carte."));
|
|
}
|
|
|
|
async function fetchGeojsonFeatures(url, options, signal) {
|
|
const response = await fetch(url, options);
|
|
const contentType = response.headers.get('content-type') || '';
|
|
const bodyText = await response.text().catch(() => '');
|
|
|
|
if (response.status === 413 || /Content Too Large|Trop d'objets à afficher|too large/i.test(bodyText)) {
|
|
const error = new Error('Too Large');
|
|
error.code = 'TOO_LARGE';
|
|
throw error;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const error = new Error(`HTTP ${response.status}`);
|
|
error.status = response.status;
|
|
error.contentType = contentType;
|
|
error.bodyText = bodyText;
|
|
throw error;
|
|
}
|
|
|
|
if (bodyText.trim() === '') return { type: 'FeatureCollection', features: [] };
|
|
|
|
if (contentType.includes('json') || bodyText.trim().startsWith('{') || bodyText.trim().startsWith('[')) {
|
|
return JSON.parse(bodyText);
|
|
}
|
|
|
|
return { type: 'FeatureCollection', features: [] };
|
|
}
|
|
|
|
function showZoomFocusMarker(lon, lat) {
|
|
if (typeof map === 'undefined') return;
|
|
if (!Number.isFinite(lon) || !Number.isFinite(lat) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
|
return;
|
|
}
|
|
|
|
if (zoomFocusMarker) {
|
|
zoomFocusMarker.setLngLat([lon, lat]);
|
|
return;
|
|
}
|
|
|
|
zoomFocusMarker = new maplibregl.Marker({ color: '#dc3545' })
|
|
.setLngLat([lon, lat])
|
|
.addTo(map);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function escapeCssSelectorValue(value) {
|
|
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
return CSS.escape(value);
|
|
}
|
|
return String(value).replace(/(["\\#.:\[\]\(\)=,>+~*^$|!])/g, '\\$1');
|
|
}
|
|
|
|
function findUnifiedRowIndex(rowRef) {
|
|
const $table = $('#unified-table');
|
|
const rows = $table.bootstrapTable('getData') || [];
|
|
|
|
const isLocation = !!rowRef.is_location;
|
|
const matches = (row) => {
|
|
if (!!row.is_location !== isLocation) return false;
|
|
if (String(row.id) !== String(rowRef.id)) return false;
|
|
|
|
if (!isLocation) {
|
|
if (String(row.thematic || '') !== String(rowRef.thematic || '')) return false;
|
|
if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false;
|
|
} else {
|
|
if (rowRef.thematic && String(row.thematic || '') !== String(rowRef.thematic || '')) return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
let idx = rows.findIndex(matches);
|
|
if (idx !== -1) return idx;
|
|
|
|
// Fallback in case some rows do not expose all discriminators.
|
|
idx = rows.findIndex(row => !!row.is_location === isLocation && String(row.id) === String(rowRef.id));
|
|
return idx;
|
|
}
|
|
|
|
function focusRowInUnifiedTable(rowRef) {
|
|
const bothBtn = document.getElementById('layout-btn-both');
|
|
if (bothBtn && document.getElementById('col-table').classList.contains('d-none')) {
|
|
bothBtn.click();
|
|
}
|
|
|
|
const $table = $('#unified-table');
|
|
if (!$table.length) return;
|
|
|
|
const rowIndex = findUnifiedRowIndex(rowRef);
|
|
if (rowIndex === -1) {
|
|
console.warn('Row not found in unified table', rowRef);
|
|
return;
|
|
}
|
|
|
|
const options = $table.bootstrapTable('getOptions') || {};
|
|
const pageSize = options.pageSize || 100;
|
|
const pageNumber = Math.floor(rowIndex / pageSize) + 1;
|
|
$table.bootstrapTable('selectPage', pageNumber);
|
|
|
|
setTimeout(() => {
|
|
$table.find('tbody td').removeClass('rowhighlight');
|
|
const $row = $table.find(`tbody tr[data-index="${rowIndex}"]`);
|
|
if ($row.length) {
|
|
$row.find('td').addClass('rowhighlight');
|
|
$row[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function collectClickedAssets(clickEvent) {
|
|
const layers = [
|
|
'assets',
|
|
'assets-fill',
|
|
'assets-polygon-outline',
|
|
'assets-line',
|
|
'assets-point',
|
|
'sign-panels-circles',
|
|
'sign-panels-symbols',
|
|
'sign-poles-orientation',
|
|
'sign-poles',
|
|
'sign-poles-vertical',
|
|
].filter(layerId => map.getLayer(layerId));
|
|
|
|
if (!layers.length) return [];
|
|
|
|
const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
|
|
const unique = new Map();
|
|
|
|
rendered.forEach(f => {
|
|
const p = f.properties || {};
|
|
const id = f.id ?? p.id;
|
|
if (id == null) return;
|
|
const key = `${p.thematic || ''}|${p.asset_type || ''}|${id}`;
|
|
if (unique.has(key)) return;
|
|
unique.set(key, {
|
|
id: id,
|
|
code: p.code || '',
|
|
name: p.name || p.name_fr || p.name_nl || '',
|
|
thematic: p.thematic || '',
|
|
thematic_name: f._thematic_name || p.thematic || '',
|
|
asset_type: p.asset_type || '',
|
|
element_type: p.element_type || '',
|
|
detail_url: p.detail_url || null,
|
|
feature_id: id,
|
|
});
|
|
});
|
|
|
|
return Array.from(unique.values());
|
|
}
|
|
|
|
function collectClickedLocations(clickEvent) {
|
|
const layers = ['locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].filter(layerId => map.getLayer(layerId));
|
|
if (!layers.length) return [];
|
|
|
|
const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
|
|
const unique = new Map();
|
|
|
|
rendered.forEach(f => {
|
|
const p = f.properties || {};
|
|
const id = f.id ?? p.id;
|
|
if (id == null) return;
|
|
const key = `${p.thematic || ''}|${id}`;
|
|
if (unique.has(key)) return;
|
|
unique.set(key, {
|
|
id: id,
|
|
code: p.code || '',
|
|
name: p.name || p.name_fr || p.name_nl || '',
|
|
thematic: p.thematic || '',
|
|
thematic_name: f._thematic_name || p.thematic || '',
|
|
element_type: p.element_type || '',
|
|
detail_url: p.detail_url || null,
|
|
feature_id: id,
|
|
});
|
|
});
|
|
|
|
return Array.from(unique.values());
|
|
}
|
|
|
|
function bindAssetPopupActions(popupNode) {
|
|
if (!popupNode) return;
|
|
popupNode.querySelectorAll('.asset-popup-focus-btn').forEach(btn => {
|
|
btn.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
const rowRef = {
|
|
id: this.dataset.assetId,
|
|
thematic: this.dataset.thematic,
|
|
asset_type: this.dataset.assetType,
|
|
is_location: false,
|
|
};
|
|
focusRowInUnifiedTable(rowRef);
|
|
|
|
if (typeof highlightMapFeature === 'function') {
|
|
highlightMapFeature(map, 'assets', rowRef.id, assetshighlightState);
|
|
}
|
|
});
|
|
});
|
|
|
|
popupNode.querySelectorAll('.location-popup-focus-btn').forEach(btn => {
|
|
btn.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
const rowRef = {
|
|
id: this.dataset.locationId,
|
|
thematic: this.dataset.thematic,
|
|
is_location: true,
|
|
};
|
|
focusRowInUnifiedTable(rowRef);
|
|
|
|
if (typeof highlightMapFeature === 'function') {
|
|
highlightMapFeature(map, 'locations', rowRef.id, locationshighlightState);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function showAssetClickPopup(clickEvent) {
|
|
const assets = collectClickedAssets(clickEvent);
|
|
const locations = collectClickedLocations(clickEvent);
|
|
if (!assets.length && !locations.length) return;
|
|
|
|
const maxItems = 8;
|
|
const visibleAssets = assets.slice(0, maxItems);
|
|
const hiddenCount = Math.max(0, assets.length - visibleAssets.length);
|
|
|
|
const itemsHtml = visibleAssets.map(a => {
|
|
const code = escapeHtml(a.code || '-');
|
|
const type = escapeHtml(a.element_type || a.asset_type || '');
|
|
const thematic = escapeHtml(a.thematic_name || a.thematic || '');
|
|
const name = escapeHtml(a.name || '');
|
|
const detailAnchor = a.detail_url
|
|
? `<a href="${escapeHtml(a.detail_url)}">${code}</a>`
|
|
: `<span>${code}</span>`;
|
|
|
|
return `
|
|
<div class="asset-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
|
|
<div style="min-width:0;">
|
|
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
|
|
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
|
|
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
|
|
</div>
|
|
<button type="button"
|
|
class="btn btn-sm btn-light asset-popup-focus-btn"
|
|
data-asset-id="${escapeHtml(a.id)}"
|
|
data-thematic="${escapeHtml(a.thematic)}"
|
|
data-asset-type="${escapeHtml(a.asset_type)}"
|
|
title="${escapeHtml(gettext('Voir dans le tableau'))}"
|
|
style="transform:translateX(-5px);">
|
|
<i class="bi bi-table"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const visibleLocations = locations.slice(0, maxItems);
|
|
const hiddenLocationsCount = Math.max(0, locations.length - visibleLocations.length);
|
|
|
|
const locationItemsHtml = visibleLocations.map(l => {
|
|
const code = escapeHtml(l.code || '-');
|
|
const type = escapeHtml(l.element_type || '');
|
|
const thematic = escapeHtml(l.thematic_name || l.thematic || '');
|
|
const name = escapeHtml(l.name || '');
|
|
const detailAnchor = l.detail_url
|
|
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
|
|
: `<span>${code}</span>`;
|
|
|
|
return `
|
|
<div class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
|
|
<div style="min-width:0;">
|
|
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
|
|
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
|
|
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
|
|
</div>
|
|
<button type="button"
|
|
class="btn btn-sm btn-light location-popup-focus-btn"
|
|
data-location-id="${escapeHtml(l.id)}"
|
|
data-thematic="${escapeHtml(l.thematic)}"
|
|
title="${escapeHtml(gettext('Voir dans le tableau'))}"
|
|
style="transform:translateX(-5px);">
|
|
<i class="bi bi-table"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const moreHtml = hiddenCount > 0
|
|
? `<div style="margin-top:0.35rem; font-size:0.75rem; color:#6c757d;">+${hiddenCount} ${escapeHtml(gettext('autre(s) asset(s)'))}</div>`
|
|
: '';
|
|
|
|
const moreLocationsHtml = hiddenLocationsCount > 0
|
|
? `<div style="margin-top:0.35rem; font-size:0.75rem; color:#6c757d;">+${hiddenLocationsCount} ${escapeHtml(gettext('autre(s) localisation(s)'))}</div>`
|
|
: '';
|
|
|
|
const assetsSectionHtml = assets.length > 0
|
|
? `
|
|
<div style="font-weight:700; margin-bottom:0.35rem;">${escapeHtml(gettext('Assets trouvés'))}: ${assets.length}</div>
|
|
<div>${itemsHtml}</div>
|
|
${moreHtml}
|
|
`
|
|
: '';
|
|
|
|
const locationsSectionHtml = locations.length > 0
|
|
? `
|
|
<div style="font-weight:700; margin:${assets.length ? '0.6rem' : '0'} 0 0.35rem 0;">${escapeHtml(gettext('Localisations trouvées'))}: ${locations.length}</div>
|
|
<div>${locationItemsHtml}</div>
|
|
${moreLocationsHtml}
|
|
`
|
|
: '';
|
|
|
|
const popupHtml = `
|
|
<div style="min-width:260px; max-width:320px;">
|
|
${assetsSectionHtml}
|
|
${locationsSectionHtml}
|
|
</div>
|
|
`;
|
|
|
|
if (!assetClickPopup) {
|
|
// Keep control of close behavior so consecutive clicks on features always reopen/update the popup.
|
|
assetClickPopup = new maplibregl.Popup({ closeButton: true, closeOnClick: false });
|
|
}
|
|
|
|
assetClickPopup
|
|
.setLngLat(clickEvent.lngLat)
|
|
.setHTML(popupHtml)
|
|
.addTo(map);
|
|
|
|
const popupNode = assetClickPopup.getElement();
|
|
bindAssetPopupActions(popupNode);
|
|
}
|
|
|
|
function updateAssetsHint() {
|
|
const hint = document.getElementById('assets-hint');
|
|
if (!hint) return;
|
|
const noThematic = activeThematics.size === 0;
|
|
const hasConditional = Array.from(activeThematics).some(
|
|
code => !ALWAYS_LOAD_ASSETS_THEMATICS.has(code)
|
|
);
|
|
const filterActive = hasTriggerFilter();
|
|
const bboxActive = typeof map !== 'undefined' && map.getZoom && map.getZoom() >= MIN_ASSETS_ZOOM;
|
|
const showHint = noThematic || (hasConditional && !filterActive && !bboxActive);
|
|
hint.style.visibility = showHint ? 'visible' : 'hidden';
|
|
}
|
|
|
|
function getViewportFeatures(features) {
|
|
if (typeof map === 'undefined' || !map.getBounds) return features;
|
|
if (searchTerms && searchTerms.length > 0 && searchIsGlobal) return features;
|
|
const bounds = map.getBounds();
|
|
return features.filter(f => {
|
|
const geom = f.geometry;
|
|
if (!geom || !geom.coordinates) {
|
|
// Keep non-geolocated assets in the table; they cannot be spatially clipped.
|
|
return !!(f.properties && !f.properties.is_location);
|
|
}
|
|
let lng, lat;
|
|
try {
|
|
if (geom.type === 'Point') {
|
|
[lng, lat] = geom.coordinates;
|
|
} else if (geom.type === 'LineString' || geom.type === 'MultiPoint') {
|
|
[lng, lat] = geom.coordinates[0];
|
|
} else if (geom.type === 'Polygon' || geom.type === 'MultiLineString') {
|
|
[lng, lat] = geom.coordinates[0][0];
|
|
} else if (geom.type === 'MultiPolygon') {
|
|
[lng, lat] = geom.coordinates[0][0][0];
|
|
} else {
|
|
return true;
|
|
}
|
|
if (!Number.isFinite(lng) || !Number.isFinite(lat) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
|
return false;
|
|
}
|
|
return bounds.contains([lng, lat]);
|
|
} catch (e) {
|
|
console.warn('getViewportFeatures: Invalid coordinate or geometry structure', f, e);
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function fitMapToFeatures(features) {
|
|
if (typeof map === 'undefined' || !map.fitBounds || !features || features.length === 0) return;
|
|
|
|
let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity;
|
|
let hasCoords = false;
|
|
|
|
features.forEach(f => {
|
|
const geom = f.geometry;
|
|
if (!geom || !geom.coordinates) return;
|
|
|
|
function processCoords(coords) {
|
|
if (!Array.isArray(coords)) return;
|
|
if (typeof coords[0] === 'number') {
|
|
const lng = coords[0];
|
|
const lat = coords[1];
|
|
if (Number.isFinite(lng) && Number.isFinite(lat) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
|
|
if (lng < minLng) minLng = lng;
|
|
if (lat < minLat) minLat = lat;
|
|
if (lng > maxLng) maxLng = lng;
|
|
if (lat > maxLat) maxLat = lat;
|
|
hasCoords = true;
|
|
}
|
|
} else {
|
|
coords.forEach(processCoords);
|
|
}
|
|
}
|
|
processCoords(geom.coordinates);
|
|
});
|
|
|
|
if (hasCoords) {
|
|
map.fitBounds([
|
|
[minLng, minLat],
|
|
[maxLng, maxLat]
|
|
], {
|
|
padding: 80,
|
|
maxZoom: 17,
|
|
duration: 1000
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Data loading ─────────────────────────────────────────────────────────────
|
|
|
|
function refreshData() {
|
|
// Cancel any in-flight requests from the previous call
|
|
if (currentFetchAbortController) { currentFetchAbortController.abort(); }
|
|
currentFetchAbortController = new AbortController();
|
|
const signal = currentFetchAbortController.signal;
|
|
hideLoadWarning();
|
|
|
|
const thematics = Array.from(activeThematics);
|
|
if (thematics.length === 0) {
|
|
allFeatures = [];
|
|
applyFilters();
|
|
return;
|
|
}
|
|
|
|
const filterActive = hasTriggerFilter();
|
|
const mapZoom = (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : 0;
|
|
const bboxActive = mapZoom >= MIN_ASSETS_ZOOM;
|
|
|
|
const fetches = [];
|
|
thematics.forEach(code => {
|
|
const cfg = THEMATIC_CONFIG[code];
|
|
if (!cfg) return;
|
|
if (!geojsonCache[code]) geojsonCache[code] = {};
|
|
|
|
// ── Locations: fetch once per thematic conditional-filter signature
|
|
const locationPayload = buildLocationsGeojsonPostBody(code);
|
|
const locationSignature = JSON.stringify(locationPayload);
|
|
if (cfg.locations_geojson_url && (!('locations' in geojsonCache[code]) || locationsFetchSignatures[code] !== locationSignature)) {
|
|
fetches.push(
|
|
fetchGeojsonFeatures(cfg.locations_geojson_url + '?unified=true', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify(locationPayload),
|
|
signal,
|
|
}, signal)
|
|
.then(data => {
|
|
geojsonCache[code].locations = data.features || [];
|
|
locationsFetchSignatures[code] = locationSignature;
|
|
})
|
|
.catch(err => {
|
|
if (err.name === 'AbortError') throw err;
|
|
if (err.code === 'TOO_LARGE') handleLoadTooLarge();
|
|
geojsonCache[code].locations = [];
|
|
locationsFetchSignatures[code] = locationSignature;
|
|
})
|
|
);
|
|
}
|
|
|
|
// ── Assets: conditional loading
|
|
const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code);
|
|
const prevMode = assetsLoadModes[code] || null;
|
|
|
|
if (alwaysLoad) {
|
|
// artworks / controlcenters: fetch once, cache forever
|
|
if (!('assets' in geojsonCache[code])) {
|
|
const url = cfg.assets_geojson_url + '?unified=true';
|
|
fetches.push(
|
|
fetchGeojsonFeatures(url, { signal }, signal)
|
|
.then(data => { geojsonCache[code].assets = data.features || []; })
|
|
.catch(err => {
|
|
if (err.name === 'AbortError') throw err;
|
|
if (err.code === 'TOO_LARGE') handleLoadTooLarge();
|
|
geojsonCache[code].assets = [];
|
|
})
|
|
);
|
|
assetsLoadModes[code] = 'always';
|
|
}
|
|
} else if (bboxActive || filterActive || (searchTerms && searchTerms.length > 0)) {
|
|
const useBbox = !searchIsGlobal;
|
|
const bbox = useBbox ? getMapBbox() : null;
|
|
const targetMode = useBbox ? 'bbox' : 'global';
|
|
|
|
const assetPayload = buildAssetsGeojsonPostBody(code, bbox, filterActive);
|
|
const assetSignature = JSON.stringify(assetPayload);
|
|
|
|
if (assetsLoadModes[code] !== targetMode || assetsFetchSignatures[code] !== assetSignature) {
|
|
if (!useBbox || bbox) {
|
|
fetches.push(
|
|
fetchGeojsonFeatures(cfg.assets_geojson_url + '?unified=true', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': getCsrfToken(),
|
|
},
|
|
body: JSON.stringify(assetPayload),
|
|
signal,
|
|
})
|
|
.then(data => {
|
|
geojsonCache[code].assets = data.features || [];
|
|
assetsFetchSignatures[code] = assetSignature;
|
|
})
|
|
.catch(err => {
|
|
if (err.name === 'AbortError') throw err;
|
|
if (err.code === 'TOO_LARGE') handleLoadTooLarge();
|
|
geojsonCache[code].assets = [];
|
|
assetsFetchSignatures[code] = assetSignature;
|
|
})
|
|
);
|
|
assetsLoadModes[code] = targetMode;
|
|
}
|
|
}
|
|
} else {
|
|
// No condition met (search cleared, zoom out, etc): clear assets
|
|
geojsonCache[code].assets = [];
|
|
assetsLoadModes[code] = null;
|
|
assetsFetchSignatures[code] = null;
|
|
}
|
|
});
|
|
|
|
function afterFetch() {
|
|
allFeatures = [];
|
|
thematics.forEach(code => {
|
|
const cfg = THEMATIC_CONFIG[code];
|
|
const cache = geojsonCache[code] || {};
|
|
const thematicName = cfg ? cfg.name : code;
|
|
(cache.locations || []).forEach(f => {
|
|
f._thematic_name = thematicName;
|
|
allFeatures.push(f);
|
|
});
|
|
(cache.assets || []).forEach(f => {
|
|
f._thematic_name = thematicName;
|
|
allFeatures.push(f);
|
|
});
|
|
});
|
|
renderModelFilters();
|
|
applyFilters();
|
|
if (loadWarningVisible && allFeatures.length === 0) {
|
|
showEmptyState();
|
|
}
|
|
}
|
|
|
|
if (fetches.length > 0) {
|
|
showSpinner();
|
|
Promise.all(fetches)
|
|
.then(afterFetch)
|
|
.catch(err => { if (err.name !== 'AbortError') afterFetch(); });
|
|
} else {
|
|
afterFetch();
|
|
}
|
|
}
|
|
|
|
// ─── Filtering ────────────────────────────────────────────────────────────────
|
|
|
|
function applyFilters() {
|
|
let features = allFeatures.slice();
|
|
|
|
// Type filter
|
|
if (currentTypeFilter === 'locations') {
|
|
features = features.filter(f => f.properties && f.properties.is_location);
|
|
} else if (currentTypeFilter === 'assets') {
|
|
features = features.filter(f => f.properties && !f.properties.is_location);
|
|
}
|
|
|
|
// Include no geom filter
|
|
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
|
|
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
|
|
if (!includeNoGeom) {
|
|
features = features.filter(f => {
|
|
// Locations are never filtered by geometry checkbox (only assets)
|
|
if (f.properties && f.properties.is_location) return true;
|
|
return f.geometry && (f.geometry.coordinates || f.geometry.type);
|
|
});
|
|
}
|
|
|
|
// Status filter
|
|
const selectedStatuses = Array.from(document.querySelectorAll('[name="filter-status"]:checked')).map(cb => cb.value);
|
|
if (selectedStatuses.length) {
|
|
features = features.filter(f => {
|
|
return selectedStatuses.includes(f.properties && f.properties.status);
|
|
});
|
|
}
|
|
|
|
// Validation status filter
|
|
const selectedValidationStatuses = Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value);
|
|
if (selectedValidationStatuses.length) {
|
|
features = features.filter(f => {
|
|
if (f.properties && f.properties.is_location) return true;
|
|
return selectedValidationStatuses.includes(f.properties && f.properties.validation_status);
|
|
});
|
|
}
|
|
|
|
// Category filter
|
|
if (activeCategories.size) {
|
|
features = features.filter(f => {
|
|
if (f.properties && f.properties.is_location) return true;
|
|
return activeCategories.has(getFeatureCategoryValue(f));
|
|
});
|
|
}
|
|
|
|
// Model filter
|
|
if (activeModels.size) {
|
|
features = features.filter(f => {
|
|
if (f.properties && f.properties.is_location) return true;
|
|
return activeModels.has(getFeatureModelValue(f));
|
|
});
|
|
}
|
|
|
|
// Text search
|
|
if (searchTerms.length) {
|
|
features = features.filter(f => {
|
|
const p = f.properties || {};
|
|
const haystack = [p.code, p.name, p.name_fr, p.name_nl, p.element_type, p.thematic]
|
|
.filter(Boolean).join(' ').toLowerCase();
|
|
if (searchMode === 'or') return searchTerms.some(t => haystack.includes(t));
|
|
return searchTerms.every(t => haystack.includes(t));
|
|
});
|
|
}
|
|
|
|
filteredFeatures = features;
|
|
loadTableData(getViewportFeatures(features));
|
|
rebuildMapSources();
|
|
updateAssetsHint();
|
|
|
|
if (shouldFitSearch) {
|
|
shouldFitSearch = false;
|
|
fitMapToFeatures(features);
|
|
}
|
|
}
|
|
|
|
// ─── Table data ───────────────────────────────────────────────────────────────
|
|
|
|
function loadTableData(features) {
|
|
hideSpinner();
|
|
|
|
if (features.length === 0 && allFeatures.length === 0) {
|
|
$('#unified-spinner').hide();
|
|
$('#unified-table').bootstrapTable('load', []);
|
|
showEmptyState();
|
|
return;
|
|
}
|
|
hideEmptyState();
|
|
|
|
const rows = features.map(f => {
|
|
const p = f.properties || {};
|
|
const name = p.name || p.name_fr || p.name_nl || '';
|
|
const length = Number(p.length ?? p.length_m ?? 0);
|
|
const area = Number(p.area ?? p.area_m2 ?? p.surface_area ?? p.surface ?? 0);
|
|
return {
|
|
id: f.id,
|
|
state: selectedIds.has(f.id),
|
|
code: p.code || '',
|
|
name: name,
|
|
asset_type: p.asset_type || '',
|
|
element_type: p.element_type || '',
|
|
thematic: p.thematic || '',
|
|
thematic_name: f._thematic_name || p.thematic || '',
|
|
category_id: p.category_id != null ? String(p.category_id) : '',
|
|
category: p.category || '',
|
|
model: p.model || '',
|
|
status: p.status || '',
|
|
detail_url: p.detail_url || null,
|
|
lon: p.lon,
|
|
lat: p.lat,
|
|
length: Number.isFinite(length) ? length : 0,
|
|
area: Number.isFinite(area) ? area : 0,
|
|
is_location: p.is_location,
|
|
};
|
|
});
|
|
|
|
$('#unified-spinner').hide();
|
|
$('#unified-table').bootstrapTable('load', rows);
|
|
|
|
// Restore checked state
|
|
if (selectedIds.size) {
|
|
rows.forEach((row, idx) => {
|
|
if (selectedIds.has(row.id)) {
|
|
$('#unified-table').bootstrapTable('check', idx);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Map integration ──────────────────────────────────────────────────────────
|
|
|
|
var locationshighlightState = { previousId: null };
|
|
var assetshighlightState = { previousId: null };
|
|
|
|
|
|
function isPanelTextSpriteId(missingId) {
|
|
if (!missingId) return false;
|
|
return missingId.startsWith('panel-text-f34a-') ||
|
|
missingId.startsWith('panel-text-type') ||
|
|
missingId.startsWith('panel-text-fxx-') ||
|
|
missingId.startsWith('panel-text-f33c-') ||
|
|
missingId.startsWith('panel-text-xc-') ||
|
|
missingId.startsWith('panel-text-typexivf-') ||
|
|
missingId.startsWith('panel-text-icr-') ||
|
|
missingId.startsWith('panel-text-zone-');
|
|
}
|
|
|
|
function applyUnifiedSignPanelOffsets(featureCollection) {
|
|
if (!featureCollection || !Array.isArray(featureCollection.features)) return;
|
|
if (!window.SignPanelsShared || typeof window.SignPanelsShared.calculatePanelOffsets !== 'function') return;
|
|
window.SignPanelsShared.calculatePanelOffsets(featureCollection);
|
|
}
|
|
|
|
function scheduleUnifiedSignPanelOffsetRefresh() {
|
|
if (unifiedPanelOffsetRefreshTimer) {
|
|
clearTimeout(unifiedPanelOffsetRefreshTimer);
|
|
}
|
|
|
|
unifiedPanelOffsetRefreshTimer = setTimeout(function () {
|
|
unifiedPanelOffsetRefreshTimer = null;
|
|
if (!window.SignPanelsShared || typeof window.SignPanelsShared.refreshPanelOffsetsForSource !== 'function') {
|
|
return;
|
|
}
|
|
window.SignPanelsShared.refreshPanelOffsetsForSource(map, 'assets');
|
|
}, 40);
|
|
}
|
|
|
|
function enqueueUnifiedSignPanelTextSprites(features) {
|
|
if (!Array.isArray(features)) return;
|
|
if (typeof enqueuePanelTextSprites !== 'function') return;
|
|
|
|
try {
|
|
enqueuePanelTextSprites(features);
|
|
} catch (err) {
|
|
console.warn('Error enqueueing panel text sprites:', err);
|
|
}
|
|
}
|
|
|
|
function hasValidCoordinates(f) {
|
|
if (!f || !f.geometry) return false;
|
|
const geom = f.geometry;
|
|
if (!geom.coordinates) return false;
|
|
try {
|
|
if (geom.type === 'Point') {
|
|
return Number.isFinite(geom.coordinates[0]) && Number.isFinite(geom.coordinates[1]);
|
|
}
|
|
if (geom.type === 'LineString' || geom.type === 'MultiPoint') {
|
|
return Array.isArray(geom.coordinates) && geom.coordinates.length > 0 && Array.isArray(geom.coordinates[0]) && Number.isFinite(geom.coordinates[0][0]);
|
|
}
|
|
if (geom.type === 'Polygon' || geom.type === 'MultiLineString') {
|
|
return Array.isArray(geom.coordinates) && geom.coordinates.length > 0 && Array.isArray(geom.coordinates[0]) && geom.coordinates[0].length > 0 && Number.isFinite(geom.coordinates[0][0][0]);
|
|
}
|
|
if (geom.type === 'MultiPolygon') {
|
|
return Array.isArray(geom.coordinates) && geom.coordinates.length > 0 && Array.isArray(geom.coordinates[0]) && geom.coordinates[0].length > 0 && geom.coordinates[0][0].length > 0 && Number.isFinite(geom.coordinates[0][0][0][0]);
|
|
}
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function rebuildMapSources() {
|
|
if (typeof map === 'undefined') return;
|
|
const locSrc = map.getSource('locations');
|
|
const asstSrc = map.getSource('assets');
|
|
if (!locSrc || !asstSrc) return; // sources not yet added (before mapLoaded)
|
|
|
|
mergedLocationsGeojson = {
|
|
type: 'FeatureCollection',
|
|
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_location),
|
|
};
|
|
mergedAssetsGeojson = {
|
|
type: 'FeatureCollection',
|
|
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location),
|
|
};
|
|
|
|
applyUnifiedSignPanelOffsets(mergedAssetsGeojson);
|
|
enqueueUnifiedSignPanelTextSprites(mergedAssetsGeojson.features);
|
|
|
|
locSrc.setData(mergedLocationsGeojson);
|
|
asstSrc.setData(mergedAssetsGeojson);
|
|
}
|
|
|
|
|
|
// ─── Map layers & initialization ──────────────────────────────────────────────
|
|
// Layer definitions and icon loading are in unified_map_layers.js (loaded first).
|
|
|
|
maploader.loadMap('map');
|
|
|
|
maploader.addEventListener('mapLoaded', () => {
|
|
// Create empty sources so setData can be called immediately after data loads
|
|
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
|
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
|
|
|
// Add layers in render order: fills → lines → circles/symbols (see UNIFIED_LAYERS)
|
|
UNIFIED_LAYERS.forEach(layer => {
|
|
if (!map.getLayer(layer.id)) map.addLayer(layer);
|
|
});
|
|
|
|
// Load per-thematic Bootstrap Icons glyphs as SDF sprites
|
|
loadUnifiedMapIcons(map);
|
|
|
|
panelTextSpriteMap = map;
|
|
|
|
if (window.SignPanelsShared && typeof window.SignPanelsShared.loadRoadSignImages === 'function') {
|
|
window.SignPanelsShared.loadRoadSignImages(map);
|
|
}
|
|
|
|
map.on('styleimagemissing', function (e) {
|
|
const missingId = e && e.id;
|
|
if (!missingId) return;
|
|
|
|
if (isPanelTextSpriteId(missingId)) {
|
|
const hasDefinition = (typeof ensurePanelTextDefinitionFromMap === 'function')
|
|
? ensurePanelTextDefinitionFromMap(map, missingId)
|
|
: false;
|
|
if (!hasDefinition) return;
|
|
|
|
if (typeof ensurePanelTextSprite === 'function') {
|
|
ensurePanelTextSprite(map, missingId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (window.SignPanelsShared && typeof window.SignPanelsShared.handleRoadSignStyleImageMissing === 'function') {
|
|
window.SignPanelsShared.handleRoadSignStyleImageMissing({
|
|
map,
|
|
event: e,
|
|
onImageUpdated: scheduleUnifiedSignPanelOffsetRefresh,
|
|
});
|
|
}
|
|
});
|
|
|
|
window.addEventListener('panelTextSpriteRendered', scheduleUnifiedSignPanelOffsetRefresh);
|
|
|
|
// Push any data already loaded before the map was ready
|
|
rebuildMapSources();
|
|
|
|
// ── Map controls ──────────────────────────────────────────────────────────
|
|
|
|
// Layer switcher — Urbis & Vlaanderen orthos (top-right, topmost)
|
|
if (typeof LayerSwitcherControl !== 'undefined') {
|
|
const layerSwitcher = new LayerSwitcherControl({
|
|
layers: [{
|
|
id: 'ortho',
|
|
name: 'Orthophotos',
|
|
icon: 'satellite',
|
|
group: 'ortho',
|
|
beforeLayer: 'locations',
|
|
layer: { type: 'raster', paint: { 'raster-opacity': 1 } },
|
|
sublayers: [
|
|
{
|
|
id: 'urbis',
|
|
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
|
|
default: true,
|
|
source: {
|
|
type: 'raster',
|
|
tiles: [
|
|
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
|
|
],
|
|
tileSize: 256,
|
|
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '\u00a9 Brussels UrbIS',
|
|
},
|
|
},
|
|
{
|
|
id: 'vlaanderen',
|
|
name: 'Vlaanderen 2023',
|
|
source: {
|
|
type: 'raster',
|
|
tiles: ['https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'],
|
|
tileSize: 256,
|
|
attribution: '\u00a9 Digitaal Vlaanderen - Orthofoto 2023',
|
|
},
|
|
},
|
|
],
|
|
}],
|
|
});
|
|
// Reset view — fly back to initial Brussels Region view
|
|
map.addControl({
|
|
onAdd(m) {
|
|
this._map = m;
|
|
this._container = document.createElement('div');
|
|
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.title = gettext('Vue initiale (Bruxelles)');
|
|
btn.style.cssText = 'display:flex;align-items:center;justify-content:center;';
|
|
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" viewBox="0 0 16 16"><path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293zM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207L8 2z"/></svg>';
|
|
btn.addEventListener('click', () => {
|
|
searchIsGlobal = false;
|
|
m.flyTo({ center: [4.3572, 50.8477], zoom: 11, duration: 1000 });
|
|
m.once('moveend', function () { refreshData(); });
|
|
});
|
|
this._container.appendChild(btn);
|
|
return this._container;
|
|
},
|
|
onRemove() {
|
|
this._container.parentNode?.removeChild(this._container);
|
|
this._map = undefined;
|
|
}
|
|
}, 'top-right');
|
|
|
|
map.addControl(layerSwitcher, 'top-right');
|
|
}
|
|
|
|
// Navigation (zoom +/-)
|
|
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
|
|
|
// Address search (custom autocomplete)
|
|
if (typeof AddressSearchControl !== 'undefined' && typeof AUTOCOMPLETE_STREET_URL !== 'undefined') {
|
|
map.addControl(new AddressSearchControl({ autocompleteUrl: AUTOCOMPLETE_STREET_URL }), 'top-left');
|
|
}
|
|
|
|
// Restore saved map position (set by applyInitialPreferences)
|
|
if (window._restoredMapCenter && window._restoredMapZoom) {
|
|
map.jumpTo({ center: window._restoredMapCenter, zoom: window._restoredMapZoom });
|
|
delete window._restoredMapCenter;
|
|
delete window._restoredMapZoom;
|
|
// Re-run with the correct zoom/bbox now that the map is at its saved position
|
|
refreshData();
|
|
}
|
|
|
|
let isBoxZooming = false;
|
|
map.on('boxzoomend', function () {
|
|
isBoxZooming = true;
|
|
});
|
|
|
|
let moveEndTimer = null;
|
|
map.on('moveend', function (e) {
|
|
// Ignore moveend not triggered by a real user gesture (e.g. from map.resize()
|
|
// called by MapLibre's internal ResizeObserver after a layout change).
|
|
// User-initiated events (pan, zoom, keyboard) always carry a native browser
|
|
// Event as originalEvent; programmatic / resize-triggered ones do not.
|
|
const isUserGesture = e && e.originalEvent && typeof e.originalEvent.type === 'string';
|
|
const isBoxZoomMove = isBoxZooming;
|
|
if (!isUserGesture && !isBoxZoomMove) return;
|
|
|
|
searchIsGlobal = false;
|
|
isBoxZooming = false;
|
|
|
|
// Compute immediately (moveend fires after the gesture, zoom is already final)
|
|
const zoom = map.getZoom();
|
|
const anyZoomRelevant = Array.from(activeThematics).some(
|
|
code => !ALWAYS_LOAD_ASSETS_THEMATICS.has(code) &&
|
|
(zoom >= MIN_ASSETS_ZOOM || assetsLoadModes[code] === 'bbox')
|
|
);
|
|
|
|
// Show spinner right away for instant visual feedback — before the debounce delay
|
|
if (anyZoomRelevant) showSpinner();
|
|
|
|
clearTimeout(moveEndTimer);
|
|
moveEndTimer = setTimeout(() => {
|
|
if (anyZoomRelevant) refreshData();
|
|
else applyFilters(); // update table to current viewport without re-fetching
|
|
schedulePreferencesSave();
|
|
}, 200);
|
|
});
|
|
|
|
// Click interaction on map elements: show popup when assets are found at click position.
|
|
['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].forEach(layerId => {
|
|
if (!map.getLayer(layerId)) return;
|
|
map.on('click', layerId, showAssetClickPopup);
|
|
if (['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical'].includes(layerId)) {
|
|
map.on('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; });
|
|
map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; });
|
|
}
|
|
});
|
|
|
|
// Close popup only when clicking outside interactive layers.
|
|
map.on('click', function (e) {
|
|
if (!assetClickPopup || !assetClickPopup.isOpen()) return;
|
|
|
|
const interactiveLayers = ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline']
|
|
.filter(layerId => map.getLayer(layerId));
|
|
if (!interactiveLayers.length) {
|
|
assetClickPopup.remove();
|
|
return;
|
|
}
|
|
|
|
const hits = map.queryRenderedFeatures(e.point, { layers: interactiveLayers });
|
|
if (hits.length === 0) assetClickPopup.remove();
|
|
});
|
|
});
|
|
|
|
// ─── UI helpers ───────────────────────────────────────────────────────────────
|
|
|
|
function showSpinner() {
|
|
const spinner = document.getElementById('unified-spinner');
|
|
// Position the overlay to start at the table body (below toolbar + thead)
|
|
// so headers and the columns button remain visible.
|
|
const tableBody = spinner.parentElement.querySelector('.fixed-table-body');
|
|
spinner.style.top = tableBody ? ((tableBody.offsetTop + 50) + 'px') : '0';
|
|
spinner.style.display = 'flex';
|
|
}
|
|
|
|
function hideSpinner() {
|
|
const spinner = document.getElementById('unified-spinner');
|
|
spinner.style.display = 'none';
|
|
spinner.style.top = '0'; // reset for next show
|
|
}
|
|
|
|
function showEmptyState() {
|
|
hideSpinner();
|
|
document.getElementById('unified-empty-state').style.display = '';
|
|
updateAssetsHint();
|
|
}
|
|
|
|
function hideEmptyState() {
|
|
document.getElementById('unified-empty-state').style.display = 'none';
|
|
}
|
|
|
|
function updateFilterBadge() {
|
|
const badge = document.getElementById('filter-badge');
|
|
const countEl = document.getElementById('filter-count');
|
|
if (!badge) return;
|
|
const hasFilterValue = (value) => {
|
|
if (Array.isArray(value)) return value.length > 0;
|
|
return value !== null && value !== undefined && value !== '' && value !== false;
|
|
};
|
|
|
|
let n = 0;
|
|
if (activeThematics.size > 0) n += 1;
|
|
if (activeCategories.size > 0) n += 1;
|
|
if (activeModels.size > 0) n += 1;
|
|
|
|
const thematicBlocksCount = Object.values(activeConditionalFilters).reduce((acc, filterMap) => {
|
|
if (!filterMap || typeof filterMap !== 'object') return acc;
|
|
return acc + Object.values(filterMap).reduce((subtotal, value) => {
|
|
return subtotal + (hasFilterValue(value) ? 1 : 0);
|
|
}, 0);
|
|
}, 0);
|
|
n += thematicBlocksCount;
|
|
|
|
if (currentTypeFilter !== 'all') n += 1;
|
|
if (document.querySelectorAll('[name="filter-status"]:checked').length > 0) n += 1;
|
|
if (document.querySelectorAll('[name="filter-validation-status"]:checked').length > 0) n += 1;
|
|
|
|
if (countEl) countEl.textContent = n;
|
|
badge.classList.toggle('d-none', n === 0);
|
|
}
|
|
|
|
function updateSelectionStatus() {
|
|
const el = document.getElementById('unified-selection-status');
|
|
if (el) el.textContent = `${selectedIds.size} ${gettext('sélectionné(s)')}`;
|
|
// Update hidden form for preventive
|
|
const hiddenInput = document.getElementById('preventive-selected-ids');
|
|
if (hiddenInput) hiddenInput.value = Array.from(selectedIds).join(',');
|
|
const hiddenLocationsInput = document.getElementById('preventive-selected-locations-ids');
|
|
if (hiddenLocationsInput) hiddenLocationsInput.value = Array.from(selectedIds).join(',');
|
|
}
|
|
|
|
function buildPreventiveSelectedAssetsPayload() {
|
|
const rows = $('#unified-table').bootstrapTable('getData') || [];
|
|
const byId = new Map(rows.filter(r => !r.is_location).map(r => [String(r.id), r]));
|
|
const payload = [];
|
|
|
|
Array.from(selectedIds).forEach(rawId => {
|
|
const id = String(rawId);
|
|
const row = byId.get(id);
|
|
if (!row || !row.asset_type) return;
|
|
payload.push({ model: String(row.asset_type).toLowerCase().trim(), object_id: Number(id) });
|
|
});
|
|
|
|
return payload;
|
|
}
|
|
|
|
function bindPreventiveSelectionSubmit() {
|
|
const form = document.getElementById('preventive-select-form');
|
|
if (!form) return;
|
|
|
|
form.addEventListener('submit', function () {
|
|
const selectedCsv = Array.from(selectedIds).join(',');
|
|
const selectedIdsInput = document.getElementById('preventive-selected-ids');
|
|
const selectedLocationsInput = document.getElementById('preventive-selected-locations-ids');
|
|
const selectedAssetsInput = document.getElementById('preventive-selected-assets');
|
|
|
|
if (selectedIdsInput) selectedIdsInput.value = selectedCsv;
|
|
if (selectedLocationsInput) selectedLocationsInput.value = selectedCsv;
|
|
|
|
if (selectedAssetsInput) {
|
|
const assetsPayload = (PREVENTIVE_ELEMENT_TYPE === 'assets')
|
|
? buildPreventiveSelectedAssetsPayload()
|
|
: [];
|
|
selectedAssetsInput.value = JSON.stringify(assetsPayload);
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildCategoryLookup() {
|
|
categoryLabelByThematicAndId = {};
|
|
categoryValueByThematicAndLabel = {};
|
|
|
|
Object.entries(THEMATIC_CATEGORIES || {}).forEach(([thematicCode, categories]) => {
|
|
categoryLabelByThematicAndId[thematicCode] = {};
|
|
categoryValueByThematicAndLabel[thematicCode] = {};
|
|
|
|
(categories || []).forEach(category => {
|
|
const categoryId = String(category.id);
|
|
const categoryLabel = category.name || '';
|
|
const categoryValue = `${thematicCode}::${categoryLabel}`;
|
|
categoryLabelByThematicAndId[thematicCode][categoryId] = categoryLabel;
|
|
categoryValueByThematicAndLabel[thematicCode][categoryLabel] = categoryValue;
|
|
});
|
|
});
|
|
}
|
|
|
|
function getFeatureCategoryValue(feature) {
|
|
const p = feature.properties || {};
|
|
const thematicCode = p.thematic || feature._thematic_code || '';
|
|
const categoryLabel = p.category || '';
|
|
const categoryId = p.category_id != null ? String(p.category_id) : '';
|
|
|
|
if (thematicCode && categoryLabel && categoryValueByThematicAndLabel[thematicCode] && categoryValueByThematicAndLabel[thematicCode][categoryLabel]) {
|
|
return categoryValueByThematicAndLabel[thematicCode][categoryLabel];
|
|
}
|
|
|
|
if (thematicCode && categoryId && categoryLabelByThematicAndId[thematicCode] && categoryLabelByThematicAndId[thematicCode][categoryId]) {
|
|
return `${thematicCode}::${categoryLabelByThematicAndId[thematicCode][categoryId]}`;
|
|
}
|
|
|
|
if (thematicCode && categoryLabel) {
|
|
return `${thematicCode}::${categoryLabel}`;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function getFeatureModelValue(feature) {
|
|
const p = feature.properties || {};
|
|
const thematicCode = p.thematic || feature._thematic_code || '';
|
|
const modelLabel = p.model || '';
|
|
if (!modelLabel) return '';
|
|
|
|
const categoryValue = getFeatureCategoryValue(feature);
|
|
if (categoryValue) {
|
|
const sepIndex = categoryValue.indexOf('::');
|
|
const categoryLabel = sepIndex >= 0 ? categoryValue.slice(sepIndex + 2) : '';
|
|
|
|
const categoryOption = getVisibleCategoryOptions().find(option => (
|
|
option.thematicCode === thematicCode &&
|
|
option.label === categoryLabel
|
|
));
|
|
|
|
if (categoryOption) {
|
|
const cacheKey = `${categoryOption.thematicCode}::${categoryOption.categoryId}`;
|
|
const models = modelsByCategoryKey[cacheKey] || [];
|
|
const matchedModel = models.find(model => model.label === modelLabel);
|
|
if (matchedModel && matchedModel.modelId != null) {
|
|
return `${categoryValue}::${String(matchedModel.modelId)}`;
|
|
}
|
|
}
|
|
|
|
// Fallback kept for robustness when model catalogs are not loaded yet.
|
|
return `${categoryValue}::${modelLabel}`;
|
|
}
|
|
|
|
if (thematicCode) {
|
|
return `${thematicCode}::::${modelLabel}`;
|
|
}
|
|
|
|
return `::::${modelLabel}`;
|
|
}
|
|
|
|
function getConditionalFilterValue(thematicCode, filterKey, filterType) {
|
|
const thematicFilters = activeConditionalFilters[thematicCode] || {};
|
|
const raw = thematicFilters[filterKey];
|
|
|
|
if (filterType === 'multi') {
|
|
return Array.isArray(raw) ? raw : [];
|
|
}
|
|
return typeof raw === 'string' ? raw : '';
|
|
}
|
|
|
|
function setConditionalFilterValue(thematicCode, filterKey, filterType, value) {
|
|
if (!activeConditionalFilters[thematicCode]) activeConditionalFilters[thematicCode] = {};
|
|
|
|
if (filterType === 'multi') {
|
|
activeConditionalFilters[thematicCode][filterKey] = value.filter(Boolean);
|
|
if (!activeConditionalFilters[thematicCode][filterKey].length) {
|
|
delete activeConditionalFilters[thematicCode][filterKey];
|
|
}
|
|
} else if (value) {
|
|
activeConditionalFilters[thematicCode][filterKey] = value;
|
|
} else {
|
|
delete activeConditionalFilters[thematicCode][filterKey];
|
|
}
|
|
|
|
if (Object.keys(activeConditionalFilters[thematicCode]).length === 0) {
|
|
delete activeConditionalFilters[thematicCode];
|
|
}
|
|
}
|
|
|
|
function pruneConditionalFiltersForActiveThematics() {
|
|
Object.keys(activeConditionalFilters).forEach(thematicCode => {
|
|
if (!activeThematics.has(thematicCode)) {
|
|
delete activeConditionalFilters[thematicCode];
|
|
}
|
|
});
|
|
}
|
|
|
|
function getConditionalFiltersForThematic(thematicCode) {
|
|
return activeConditionalFilters[thematicCode] || {};
|
|
}
|
|
|
|
function getVisibleCategoryOptions() {
|
|
const visible = [];
|
|
Array.from(activeThematics).forEach(thematicCode => {
|
|
const categories = THEMATIC_CATEGORIES[thematicCode] || [];
|
|
categories.forEach(category => {
|
|
visible.push({
|
|
thematicCode,
|
|
thematicName: (THEMATIC_CONFIG[thematicCode] && THEMATIC_CONFIG[thematicCode].name) || thematicCode,
|
|
categoryId: String(category.id),
|
|
label: category.name || '',
|
|
value: `${thematicCode}::${category.name || ''}`,
|
|
icon: (THEMATIC_CONFIG[thematicCode] && THEMATIC_CONFIG[thematicCode].icon) || '',
|
|
});
|
|
});
|
|
});
|
|
return visible;
|
|
}
|
|
|
|
function getSelectedCategoryOptions() {
|
|
return getVisibleCategoryOptions().filter(category => activeCategories.has(category.value));
|
|
}
|
|
|
|
function buildAssetModelsUrl(thematicCode, categoryId) {
|
|
if (!ASSET_MODELS_BY_CATEGORY_URL_TEMPLATE) return '';
|
|
return ASSET_MODELS_BY_CATEGORY_URL_TEMPLATE
|
|
.replace('THEMATIC_CODE', encodeURIComponent(thematicCode))
|
|
.replace('/0/models/', `/${encodeURIComponent(String(categoryId))}/models/`);
|
|
}
|
|
|
|
function ensureModelsLoadedForSelectedCategories() {
|
|
const selectedCategories = getSelectedCategoryOptions();
|
|
|
|
selectedCategories.forEach(category => {
|
|
const cacheKey = `${category.thematicCode}::${category.categoryId}`;
|
|
if (modelsByCategoryKey[cacheKey] || pendingModelFetches[cacheKey]) return;
|
|
|
|
const url = buildAssetModelsUrl(category.thematicCode, category.categoryId);
|
|
if (!url) {
|
|
modelsByCategoryKey[cacheKey] = [];
|
|
return;
|
|
}
|
|
|
|
pendingModelFetches[cacheKey] = fetch(url)
|
|
.then(response => {
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
const byLabel = new Map();
|
|
(data.models || []).forEach(model => {
|
|
const label = String(model.name || model.code || '').trim();
|
|
if (!label) return;
|
|
|
|
const modelId = model.id != null ? String(model.id) : '';
|
|
if (!modelId) return;
|
|
|
|
const modelValue = `${category.value}::${modelId}`;
|
|
if (!byLabel.has(modelValue)) {
|
|
byLabel.set(modelValue, {
|
|
thematicCode: category.thematicCode,
|
|
thematicName: category.thematicName,
|
|
categoryLabel: category.label || gettext('Sans catégorie'),
|
|
label,
|
|
modelId,
|
|
value: modelValue,
|
|
});
|
|
}
|
|
});
|
|
modelsByCategoryKey[cacheKey] = Array.from(byLabel.values());
|
|
})
|
|
.catch(() => {
|
|
modelsByCategoryKey[cacheKey] = [];
|
|
})
|
|
.finally(() => {
|
|
delete pendingModelFetches[cacheKey];
|
|
renderModelFilters();
|
|
});
|
|
});
|
|
}
|
|
|
|
function getVisibleModelOptions() {
|
|
const modelsByValue = new Map();
|
|
const selectedCategories = getSelectedCategoryOptions();
|
|
|
|
selectedCategories.forEach(category => {
|
|
const cacheKey = `${category.thematicCode}::${category.categoryId}`;
|
|
(modelsByCategoryKey[cacheKey] || []).forEach(model => {
|
|
if (!modelsByValue.has(model.value)) {
|
|
modelsByValue.set(model.value, model);
|
|
}
|
|
});
|
|
});
|
|
|
|
return Array.from(modelsByValue.values()).sort((a, b) => {
|
|
const thematicCmp = a.thematicName.localeCompare(b.thematicName, 'fr');
|
|
if (thematicCmp !== 0) return thematicCmp;
|
|
const categoryCmp = (a.categoryLabel || '').localeCompare((b.categoryLabel || ''), 'fr');
|
|
if (categoryCmp !== 0) return categoryCmp;
|
|
return a.label.localeCompare(b.label, 'fr');
|
|
});
|
|
}
|
|
|
|
function getVisibleConditionalFilterDefinitions() {
|
|
const defs = [];
|
|
Array.from(activeThematics).forEach(thematicCode => {
|
|
const thematicDefs = CONDITIONAL_FILTER_CONFIG[thematicCode] || [];
|
|
thematicDefs.forEach(def => {
|
|
defs.push({
|
|
thematicCode,
|
|
thematicName: (THEMATIC_CONFIG[thematicCode] && THEMATIC_CONFIG[thematicCode].name) || thematicCode,
|
|
icon: (THEMATIC_CONFIG[thematicCode] && THEMATIC_CONFIG[thematicCode].icon) || '',
|
|
...def,
|
|
});
|
|
});
|
|
});
|
|
return defs;
|
|
}
|
|
|
|
function normalizeSignPanelTypeOption(option) {
|
|
const value = option && option.value != null ? String(option.value) : '';
|
|
const code = String((option && (option.code || option.label)) || '').trim();
|
|
const name = String((option && option.name) || '').trim();
|
|
const imageUrl = String((option && option.image_url) || '').trim();
|
|
|
|
let group = String((option && option.group) || '').trim();
|
|
let groupLabel = String((option && option.group_label) || '').trim();
|
|
if (!group) {
|
|
const firstChar = code ? code[0].toUpperCase() : '';
|
|
group = /^[A-Za-z]$/.test(firstChar) ? firstChar : 'Autre';
|
|
}
|
|
if (!groupLabel) {
|
|
groupLabel = group;
|
|
}
|
|
|
|
return { value, code, name, imageUrl, group, groupLabel };
|
|
}
|
|
|
|
function buildSignPanelTypeGroups(options) {
|
|
const grouped = new Map();
|
|
const groupLabels = new Map();
|
|
|
|
(options || []).map(normalizeSignPanelTypeOption).forEach(option => {
|
|
if (!option.value || !option.code) return;
|
|
if (!grouped.has(option.group)) {
|
|
grouped.set(option.group, []);
|
|
groupLabels.set(option.group, option.groupLabel);
|
|
}
|
|
grouped.get(option.group).push(option);
|
|
});
|
|
|
|
const definedOrder = ['danger', 'priority', 'prohibition', 'obligation', 'parking', 'indication', 'zone', 'additional', 'other'];
|
|
const groupKeys = [];
|
|
|
|
definedOrder.forEach(key => {
|
|
if (grouped.has(key)) {
|
|
groupKeys.push(key);
|
|
}
|
|
});
|
|
|
|
Array.from(grouped.keys()).forEach(key => {
|
|
if (!definedOrder.includes(key)) {
|
|
groupKeys.push(key);
|
|
}
|
|
});
|
|
|
|
return { grouped, groupLabels, groupKeys };
|
|
}
|
|
|
|
function normalizePanelTypeCodeForImage(code) {
|
|
let normalized = String(code || '').trim();
|
|
if (!normalized) return '';
|
|
|
|
const upper = normalized.toUpperCase();
|
|
if (upper === 'XA') {
|
|
normalized = 'GXA';
|
|
} else if (upper === 'XB') {
|
|
normalized = 'GXB';
|
|
} else if (upper === 'XD') {
|
|
normalized = 'GXD';
|
|
} else if (upper.startsWith('Z') && upper.length > 1) {
|
|
normalized = normalized.slice(1);
|
|
}
|
|
|
|
return normalized.replace(/["']/g, '').trim();
|
|
}
|
|
|
|
function buildPanelTypeImageCandidates(code) {
|
|
const normalizedCode = normalizePanelTypeCodeForImage(code);
|
|
if (!normalizedCode) return [];
|
|
|
|
const upperCode = normalizedCode.toUpperCase();
|
|
const titleCode = upperCode.charAt(0) + upperCode.slice(1).toLowerCase();
|
|
const lowerCode = upperCode.toLowerCase();
|
|
const uniqueCodes = Array.from(new Set([upperCode, titleCode, lowerCode]));
|
|
const candidates = [];
|
|
|
|
uniqueCodes.forEach(item => {
|
|
candidates.push(`/static/assets/road_signs/2025/${encodeURIComponent(item)}.svg`);
|
|
});
|
|
uniqueCodes.forEach(item => {
|
|
candidates.push(`/static/assets/road_signs/2025/${encodeURIComponent(item)}.png`);
|
|
});
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function appendPanelTypeImage(container, code, imageUrl, options = {}) {
|
|
if (!container) return;
|
|
|
|
const imageHeight = options.height || '40px';
|
|
const imageWidth = options.width || '40px';
|
|
const objectFit = options.objectFit || 'contain';
|
|
|
|
function createImage(src) {
|
|
const img = document.createElement('img');
|
|
img.src = src;
|
|
img.alt = code || '';
|
|
img.style.maxHeight = '100%';
|
|
img.style.maxWidth = '100%';
|
|
img.style.height = imageHeight;
|
|
img.style.width = imageWidth;
|
|
img.style.objectFit = objectFit;
|
|
return img;
|
|
}
|
|
|
|
const candidates = buildPanelTypeImageCandidates(code);
|
|
let candidateIndex = 0;
|
|
|
|
function tryFallbackCandidate() {
|
|
const src = candidates[candidateIndex++];
|
|
if (!src) {
|
|
container.textContent = code || '';
|
|
return;
|
|
}
|
|
|
|
const fallbackImg = createImage(src);
|
|
fallbackImg.onerror = tryFallbackCandidate;
|
|
container.innerHTML = '';
|
|
container.appendChild(fallbackImg);
|
|
}
|
|
|
|
if (imageUrl) {
|
|
const img = createImage(imageUrl);
|
|
img.onerror = tryFallbackCandidate;
|
|
container.innerHTML = '';
|
|
container.appendChild(img);
|
|
return;
|
|
}
|
|
|
|
tryFallbackCandidate();
|
|
}
|
|
|
|
function renderSignPanelTypeConditionalFilter(thematicCode, def, selectedValues) {
|
|
const filterName = `${thematicCode}::${def.key}`;
|
|
const selectId = `cond-${thematicCode}-${def.key}-select`;
|
|
const selectedSet = new Set((selectedValues || []).map(value => String(value)));
|
|
const { grouped, groupLabels, groupKeys } = buildSignPanelTypeGroups(def.options || []);
|
|
|
|
const hiddenOptionsHtml = Array.from(grouped.values()).flat().map(option => {
|
|
const isSelected = selectedSet.has(option.value);
|
|
return `
|
|
<option value="${escapeHtml(option.value)}"
|
|
data-code="${escapeHtml(option.code)}"
|
|
data-name="${escapeHtml(option.name)}"
|
|
data-group="${escapeHtml(option.group)}"
|
|
data-image-url="${escapeHtml(option.imageUrl)}"
|
|
${isSelected ? 'selected' : ''}>
|
|
${escapeHtml(option.code)}
|
|
</option>
|
|
`;
|
|
}).join('');
|
|
|
|
const styles = `
|
|
<style>
|
|
#accordion-sign-panel-types .accordion-item {
|
|
margin-bottom: 0.5rem;
|
|
border: 1px solid rgba(0,0,0,.125) !important;
|
|
border-radius: 8px !important;
|
|
overflow: hidden;
|
|
transition: all 0.2s ease-in-out;
|
|
}
|
|
#accordion-sign-panel-types .accordion-item:hover {
|
|
border-color: #0d6efd !important;
|
|
box-shadow: 0 4px 12px rgba(47, 62, 139, 0.06);
|
|
}
|
|
#accordion-sign-panel-types .accordion-button:not(.collapsed) {
|
|
background-color: rgba(13, 110, 253, 0.05) !important;
|
|
color: #0d6efd !important;
|
|
border-bottom: 1px solid rgba(13, 110, 253, 0.1) !important;
|
|
}
|
|
#accordion-sign-panel-types .accordion-button {
|
|
transition: all 0.2s ease-in-out;
|
|
}
|
|
|
|
#accordion-sign-panel-types .panel-item {
|
|
transition: all 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
|
|
border: 1px solid #dee2e6 !important;
|
|
}
|
|
#accordion-sign-panel-types .panel-item:hover {
|
|
transform: translateY(-3px);
|
|
box-shadow: 0 4px 8px rgba(13, 110, 253, 0.15);
|
|
border-color: #0d6efd !important;
|
|
}
|
|
#accordion-sign-panel-types .panel-item.bg-primary {
|
|
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.15) !important;
|
|
}
|
|
</style>
|
|
`;
|
|
|
|
const accordionId = `accordion-sign-panel-types`;
|
|
const accordionItemsHtml = groupKeys.map((group, index) => {
|
|
const groupLabel = groupLabels.get(group) || group;
|
|
const options = grouped.get(group) || [];
|
|
|
|
const itemsHtml = options.map(option => {
|
|
const title = option.name ? `${option.code} - ${option.name}` : option.code;
|
|
return `
|
|
<div class="panel-item text-center p-1 border rounded bg-white"
|
|
data-id="${escapeHtml(option.value)}"
|
|
data-code="${escapeHtml(option.code)}"
|
|
data-image-url="${escapeHtml(option.imageUrl)}"
|
|
style="cursor: pointer; width: 70px;"
|
|
title="${escapeHtml(title)}">
|
|
<div class="panel-img-placeholder mb-1"
|
|
style="height: 50px; display: flex; align-items: center; justify-content: center;"></div>
|
|
<div class="small text-truncate fw-bold" style="font-size: 0.7rem;">
|
|
${escapeHtml(option.code)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const collapseId = `collapse-sign-${group}`;
|
|
const headingId = `heading-sign-${group}`;
|
|
|
|
return `
|
|
<div class="accordion-item border-0 border-bottom">
|
|
<h2 class="accordion-header" id="${headingId}">
|
|
<button class="accordion-button collapsed py-2 px-3 fw-bold bg-white text-dark shadow-none"
|
|
type="button"
|
|
data-bs-toggle="collapse"
|
|
data-bs-target="#${collapseId}"
|
|
aria-expanded="false"
|
|
aria-controls="${collapseId}"
|
|
data-group="${escapeHtml(group)}">
|
|
${escapeHtml(groupLabel)}
|
|
</button>
|
|
</h2>
|
|
<div id="${collapseId}" class="accordion-collapse collapse" aria-labelledby="${headingId}" data-bs-parent="#${accordionId}">
|
|
<div class="accordion-body p-2 bg-light border-top">
|
|
<div class="d-flex flex-wrap gap-2 justify-content-center">
|
|
${itemsHtml}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
${styles}
|
|
<div class="w-100 mb-2 js-sign-panel-type-picker"
|
|
data-thematic="${escapeHtml(thematicCode)}"
|
|
data-filter-key="${escapeHtml(def.key)}">
|
|
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
|
|
|
<select name="${escapeHtml(filterName)}"
|
|
id="${selectId}"
|
|
class="d-none js-sign-panel-type-select"
|
|
data-thematic="${escapeHtml(thematicCode)}"
|
|
data-filter-key="${escapeHtml(def.key)}"
|
|
multiple>
|
|
${hiddenOptionsHtml}
|
|
</select>
|
|
|
|
${groupKeys.length ? `
|
|
<div class="accordion mb-3 shadow-sm border rounded" id="${accordionId}" style="max-height: 500px; overflow-y: auto;">
|
|
${accordionItemsHtml}
|
|
</div>
|
|
|
|
<div class="border rounded p-2 bg-light js-sign-selected-list"
|
|
style="max-height: 150px; overflow-y: auto;">
|
|
<div class="text-muted small fst-italic empty-message text-center">
|
|
${escapeHtml(gettext('Aucun type sélectionné'))}
|
|
</div>
|
|
</div>
|
|
` : `
|
|
<div class="text-muted small">${escapeHtml(gettext('Aucun type disponible.'))}</div>
|
|
`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function initSignPanelTypePickers() {
|
|
document.querySelectorAll('.js-sign-panel-type-picker').forEach(picker => {
|
|
const panelTypeSelect = picker.querySelector('.js-sign-panel-type-select');
|
|
if (!panelTypeSelect || panelTypeSelect.dataset.bound === '1') return;
|
|
|
|
panelTypeSelect.dataset.bound = '1';
|
|
|
|
const thematicCode = panelTypeSelect.dataset.thematic || picker.dataset.thematic || '';
|
|
const filterKey = panelTypeSelect.dataset.filterKey || picker.dataset.filterKey || '';
|
|
if (!thematicCode || !filterKey) return;
|
|
|
|
const accordionButtons = picker.querySelectorAll('.accordion-button');
|
|
const selectedList = picker.querySelector('.js-sign-selected-list');
|
|
const emptyMessage = selectedList ? selectedList.querySelector('.empty-message') : null;
|
|
|
|
function getSelectedValues() {
|
|
return Array.from(panelTypeSelect.options)
|
|
.filter(option => option.selected)
|
|
.map(option => String(option.value));
|
|
}
|
|
|
|
function syncFilterState() {
|
|
setConditionalFilterValue(thematicCode, filterKey, 'multi', getSelectedValues());
|
|
}
|
|
|
|
function updateSelectedList() {
|
|
const selectedOptions = Array.from(panelTypeSelect.options).filter(option => option.selected);
|
|
|
|
if (selectedList) {
|
|
Array.from(selectedList.querySelectorAll('.selected-panel-item')).forEach(item => item.remove());
|
|
|
|
if (emptyMessage) {
|
|
emptyMessage.style.display = selectedOptions.length ? 'none' : 'block';
|
|
}
|
|
|
|
selectedOptions.forEach(option => {
|
|
const id = String(option.value);
|
|
const code = String(option.dataset.code || option.text || id).trim();
|
|
const imageUrl = String(option.dataset.imageUrl || '').trim();
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'selected-panel-item d-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1';
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'd-flex align-items-center';
|
|
|
|
const thumb = document.createElement('div');
|
|
thumb.className = 'd-flex align-items-center justify-content-center me-2';
|
|
thumb.style.width = '28px';
|
|
thumb.style.height = '28px';
|
|
appendPanelTypeImage(thumb, code, imageUrl, { width: '28px', height: '28px' });
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'small fw-bold ms-1';
|
|
label.textContent = code;
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button';
|
|
removeBtn.className = 'btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type';
|
|
removeBtn.dataset.id = id;
|
|
removeBtn.innerHTML = '<i class="bi bi-x"></i>';
|
|
|
|
content.appendChild(thumb);
|
|
content.appendChild(label);
|
|
item.appendChild(content);
|
|
item.appendChild(removeBtn);
|
|
selectedList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
picker.querySelectorAll('.panel-item').forEach(item => {
|
|
const id = item.dataset.id;
|
|
const option = Array.from(panelTypeSelect.options).find(opt => opt.value === id);
|
|
const isSelected = !!(option && option.selected);
|
|
|
|
item.classList.toggle('bg-primary', isSelected);
|
|
item.classList.toggle('text-white', isSelected);
|
|
item.classList.toggle('bg-white', !isSelected);
|
|
});
|
|
|
|
syncFilterState();
|
|
}
|
|
|
|
accordionButtons.forEach(btn => {
|
|
btn.addEventListener('click', function () {
|
|
const group = this.dataset.group;
|
|
const collapseDiv = picker.querySelector(`#collapse-sign-${group}`);
|
|
if (collapseDiv) {
|
|
collapseDiv.querySelectorAll('.panel-item').forEach(item => {
|
|
const placeholder = item.querySelector('.panel-img-placeholder');
|
|
if (!placeholder || placeholder.children.length > 0) return;
|
|
|
|
const imageUrl = item.dataset.imageUrl || '';
|
|
const code = item.dataset.code || '';
|
|
appendPanelTypeImage(placeholder, code, imageUrl);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
picker.addEventListener('click', function (event) {
|
|
const panelItem = event.target.closest('.panel-item');
|
|
if (panelItem && picker.contains(panelItem)) {
|
|
const id = panelItem.dataset.id;
|
|
const option = Array.from(panelTypeSelect.options).find(opt => opt.value === id);
|
|
if (option) {
|
|
option.selected = !option.selected;
|
|
updateSelectedList();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const removeBtn = event.target.closest('.remove-panel-type');
|
|
if (removeBtn && picker.contains(removeBtn)) {
|
|
const id = removeBtn.dataset.id;
|
|
const option = Array.from(panelTypeSelect.options).find(opt => opt.value === id);
|
|
if (option) {
|
|
option.selected = false;
|
|
updateSelectedList();
|
|
}
|
|
}
|
|
});
|
|
|
|
updateSelectedList();
|
|
});
|
|
}
|
|
|
|
function renderConditionalFilters() {
|
|
const container = document.getElementById('conditional-filter-options');
|
|
if (!container) return;
|
|
|
|
pruneConditionalFiltersForActiveThematics();
|
|
|
|
const defs = getVisibleConditionalFilterDefinitions();
|
|
if (!defs.length) {
|
|
container.innerHTML = `<span class="text-muted small">${gettext("Sélectionnez d'abord une ou plusieurs thématiques.")}</span>`;
|
|
return;
|
|
}
|
|
|
|
const grouped = new Map();
|
|
defs.forEach(def => {
|
|
if (!grouped.has(def.thematicCode)) grouped.set(def.thematicCode, []);
|
|
grouped.get(def.thematicCode).push(def);
|
|
});
|
|
|
|
container.innerHTML = Array.from(grouped.entries()).map(([thematicCode, thematicDefs]) => {
|
|
const thematic = THEMATIC_CONFIG[thematicCode] || {};
|
|
const heading = thematic.name || thematicCode;
|
|
const icon = thematic.icon ? `<i class="bi ${escapeHtml(thematic.icon)} me-1"></i>` : '';
|
|
|
|
const blocks = thematicDefs.map(def => {
|
|
const filterName = `${thematicCode}::${def.key}`;
|
|
const selected = getConditionalFilterValue(thematicCode, def.key, def.type);
|
|
|
|
if (thematicCode === 'sign' && def.key === 'panel_type' && def.type === 'multi') {
|
|
return renderSignPanelTypeConditionalFilter(thematicCode, def, selected);
|
|
}
|
|
|
|
if (def.type === 'checkbox') {
|
|
const inputId = `cond-${thematicCode}-${def.key}`;
|
|
const checkedValue = String(def.value || '1');
|
|
const isChecked = selected === checkedValue;
|
|
|
|
return `
|
|
<div class="w-100 mb-2">
|
|
<div class="filter-options">
|
|
<div class="filter-option">
|
|
<input type="checkbox"
|
|
class="conditional-filter-input"
|
|
name="${escapeHtml(filterName)}"
|
|
id="${inputId}"
|
|
data-thematic="${escapeHtml(thematicCode)}"
|
|
data-filter-key="${escapeHtml(def.key)}"
|
|
data-filter-type="checkbox"
|
|
value="${escapeHtml(checkedValue)}"
|
|
${isChecked ? 'checked' : ''}>
|
|
<label for="${inputId}">${escapeHtml(def.label || def.key)}</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (def.type === 'date') {
|
|
const inputId = `cond-${thematicCode}-${def.key}`;
|
|
return `
|
|
<div class="w-100 mb-2">
|
|
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
|
<div class="filter-options">
|
|
<input type="date"
|
|
class="form-control form-control-sm conditional-filter-input"
|
|
name="${escapeHtml(filterName)}"
|
|
id="${inputId}"
|
|
data-thematic="${escapeHtml(thematicCode)}"
|
|
data-filter-key="${escapeHtml(def.key)}"
|
|
data-filter-type="date"
|
|
value="${escapeHtml(selected)}"
|
|
style="max-width: 220px;">
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (def.type === 'select') {
|
|
const inputId = `cond-${thematicCode}-${def.key}`;
|
|
const optionsHtml = [`<option value="">${escapeHtml(gettext('Tous'))}</option>`].concat(
|
|
(def.options || []).map(option => {
|
|
const isSelected = selected === String(option.value);
|
|
return `<option value="${escapeHtml(String(option.value))}" ${isSelected ? 'selected' : ''}>${escapeHtml(option.label)}</option>`;
|
|
})
|
|
).join('');
|
|
|
|
return `
|
|
<div class="w-100 mb-2">
|
|
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
|
<div class="filter-options">
|
|
<select class="form-select form-select-sm conditional-filter-input"
|
|
name="${escapeHtml(filterName)}"
|
|
id="${inputId}"
|
|
data-thematic="${escapeHtml(thematicCode)}"
|
|
data-filter-key="${escapeHtml(def.key)}"
|
|
data-filter-type="select"
|
|
style="max-width: 420px;">
|
|
${optionsHtml}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const optionsHtml = (def.options || []).map((option, idx) => {
|
|
const optionId = `cond-${thematicCode}-${def.key}-${idx}`;
|
|
const inputType = def.type === 'single' ? 'radio' : 'checkbox';
|
|
const isChecked = def.type === 'single'
|
|
? (selected === String(option.value))
|
|
: selected.includes(String(option.value));
|
|
|
|
return `
|
|
<div class="filter-option">
|
|
<input type="${inputType}"
|
|
class="conditional-filter-input"
|
|
name="${escapeHtml(filterName)}"
|
|
id="${optionId}"
|
|
data-thematic="${escapeHtml(thematicCode)}"
|
|
data-filter-key="${escapeHtml(def.key)}"
|
|
data-filter-type="${escapeHtml(def.type || 'multi')}"
|
|
value="${escapeHtml(String(option.value))}"
|
|
${isChecked ? 'checked' : ''}>
|
|
<label for="${optionId}">${escapeHtml(option.label)}</label>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="w-100 mb-2">
|
|
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
|
<div class="filter-options">${optionsHtml}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="w-100 mb-2">
|
|
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div>
|
|
${blocks}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
initSignPanelTypePickers();
|
|
|
|
document.querySelectorAll('.conditional-filter-input').forEach(input => {
|
|
input.addEventListener('change', function () {
|
|
const thematicCode = this.dataset.thematic;
|
|
const filterKey = this.dataset.filterKey;
|
|
const filterType = this.dataset.filterType || 'multi';
|
|
|
|
if (filterType === 'single') {
|
|
const checked = document.querySelector(`input[name="${escapeCssSelectorValue(`${thematicCode}::${filterKey}`)}"]:checked`);
|
|
setConditionalFilterValue(thematicCode, filterKey, 'single', checked ? checked.value : '');
|
|
} else if (filterType === 'checkbox') {
|
|
setConditionalFilterValue(thematicCode, filterKey, 'single', this.checked ? this.value : '');
|
|
} else if (filterType === 'date' || filterType === 'select') {
|
|
setConditionalFilterValue(thematicCode, filterKey, 'single', this.value || '');
|
|
} else {
|
|
const selected = Array.from(document.querySelectorAll(
|
|
`input[name="${escapeCssSelectorValue(`${thematicCode}::${filterKey}`)}"]:checked`
|
|
)).map(el => el.value);
|
|
setConditionalFilterValue(thematicCode, filterKey, 'multi', selected);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
bindPreventiveSelectionSubmit();
|
|
|
|
function renderCategoryFilters() {
|
|
const container = document.getElementById('category-filter-options');
|
|
if (!container) return;
|
|
|
|
const visibleCategories = getVisibleCategoryOptions();
|
|
const visibleValues = new Set(visibleCategories.map(item => item.value));
|
|
activeCategories = new Set(Array.from(activeCategories).filter(value => visibleValues.has(value)));
|
|
|
|
if (!visibleCategories.length) {
|
|
container.innerHTML = `<span class="text-muted small">${gettext("Sélectionnez d'abord une ou plusieurs thématiques.")}</span>`;
|
|
return;
|
|
}
|
|
|
|
const grouped = new Map();
|
|
visibleCategories.forEach(item => {
|
|
if (!grouped.has(item.thematicCode)) grouped.set(item.thematicCode, []);
|
|
grouped.get(item.thematicCode).push(item);
|
|
});
|
|
|
|
container.innerHTML = Array.from(grouped.entries()).map(([thematicCode, categories]) => {
|
|
const thematic = THEMATIC_CONFIG[thematicCode] || {};
|
|
const heading = thematic.name || thematicCode;
|
|
const icon = thematic.icon ? `<i class="bi ${escapeHtml(thematic.icon)} me-1"></i>` : '';
|
|
const items = categories.map(category => {
|
|
const checkboxId = `category-cb-${thematicCode}-${category.categoryId}`;
|
|
const checked = activeCategories.has(category.value) ? 'checked' : '';
|
|
return `
|
|
<div class="filter-option">
|
|
<input type="checkbox" class="category-checkbox"
|
|
id="${checkboxId}"
|
|
value="${escapeHtml(category.value)}"
|
|
${checked}>
|
|
<label for="${checkboxId}">${escapeHtml(category.label)}</label>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="w-100 mb-2">
|
|
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div>
|
|
<div class="filter-options">${items}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
document.querySelectorAll('.category-checkbox').forEach(cb => {
|
|
cb.addEventListener('change', function () {
|
|
if (this.checked) activeCategories.add(this.value);
|
|
else activeCategories.delete(this.value);
|
|
renderModelFilters();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderModelFilters() {
|
|
const container = document.getElementById('model-filter-options');
|
|
const toggleBtn = document.getElementById('model-filter-toggle');
|
|
if (!container || !toggleBtn) return;
|
|
|
|
const modelSection = document.getElementById('model-filter-section');
|
|
const sectionButtons = modelSection ? modelSection.querySelector('.filter-section-buttons') : null;
|
|
if (sectionButtons && sectionButtons.firstElementChild !== toggleBtn) {
|
|
sectionButtons.insertBefore(toggleBtn, sectionButtons.firstChild);
|
|
}
|
|
|
|
if (activeThematics.size === 0) {
|
|
container.style.display = '';
|
|
container.innerHTML = `<span class="text-muted small">${gettext("Sélectionnez d'abord une ou plusieurs thématiques.")}</span>`;
|
|
toggleBtn.classList.add('d-none');
|
|
return;
|
|
}
|
|
|
|
if (activeCategories.size === 0) {
|
|
container.style.display = '';
|
|
container.innerHTML = `<span class="text-muted small">${gettext("Sélectionnez d'abord une ou plusieurs catégories.")}</span>`;
|
|
toggleBtn.classList.add('d-none');
|
|
activeModels.clear();
|
|
return;
|
|
}
|
|
|
|
ensureModelsLoadedForSelectedCategories();
|
|
|
|
const selectedCategories = getSelectedCategoryOptions();
|
|
const hasPendingFetch = selectedCategories.some(category => {
|
|
const cacheKey = `${category.thematicCode}::${category.categoryId}`;
|
|
return !!pendingModelFetches[cacheKey];
|
|
});
|
|
|
|
if (hasPendingFetch) {
|
|
container.style.display = '';
|
|
container.innerHTML = `<span class="text-muted small">${gettext('Chargement des modèles...')}</span>`;
|
|
toggleBtn.classList.add('d-none');
|
|
return;
|
|
}
|
|
|
|
const visibleModels = getVisibleModelOptions();
|
|
const visibleValues = new Set(visibleModels.map(item => item.value));
|
|
activeModels = new Set(Array.from(activeModels).filter(value => visibleValues.has(value)));
|
|
|
|
if (!visibleModels.length) {
|
|
container.style.display = '';
|
|
container.innerHTML = `<span class="text-muted small">${gettext("Aucun modèle disponible pour les catégories sélectionnées.")}</span>`;
|
|
toggleBtn.classList.add('d-none');
|
|
return;
|
|
}
|
|
|
|
const shouldCollapse = visibleModels.length > 10;
|
|
if (!shouldCollapse) modelFilterCollapsed = false;
|
|
|
|
toggleBtn.classList.toggle('d-none', !shouldCollapse);
|
|
if (shouldCollapse) {
|
|
toggleBtn.textContent = modelFilterCollapsed
|
|
? gettext('Afficher les modèles')
|
|
: gettext('Replier les modèles');
|
|
}
|
|
|
|
if (shouldCollapse && modelFilterCollapsed) {
|
|
container.style.display = '';
|
|
container.innerHTML = `<span class="text-muted small">${gettext('Bloc replié. Cliquez sur "Afficher les modèles" pour voir les options.')}</span>`;
|
|
} else {
|
|
container.style.display = '';
|
|
|
|
const groupedByThematic = new Map();
|
|
visibleModels.forEach(item => {
|
|
if (!groupedByThematic.has(item.thematicCode)) groupedByThematic.set(item.thematicCode, []);
|
|
groupedByThematic.get(item.thematicCode).push(item);
|
|
});
|
|
|
|
container.innerHTML = Array.from(groupedByThematic.entries()).map(([thematicCode, models]) => {
|
|
const thematic = THEMATIC_CONFIG[thematicCode] || {};
|
|
const heading = thematic.name || thematicCode;
|
|
const icon = thematic.icon ? `<i class="bi ${escapeHtml(thematic.icon)} me-1"></i>` : '';
|
|
|
|
const groupedByCategory = new Map();
|
|
models.forEach(model => {
|
|
const categoryKey = model.categoryLabel || gettext('Sans catégorie');
|
|
if (!groupedByCategory.has(categoryKey)) groupedByCategory.set(categoryKey, []);
|
|
groupedByCategory.get(categoryKey).push(model);
|
|
});
|
|
|
|
const categoryBlocks = Array.from(groupedByCategory.entries()).map(([categoryLabel, categoryModels]) => {
|
|
const items = categoryModels.map(model => {
|
|
const checkboxId = `model-cb-${String(model.value).replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
|
const checked = activeModels.has(model.value) ? 'checked' : '';
|
|
return `
|
|
<div class="filter-option">
|
|
<input type="checkbox" class="model-checkbox"
|
|
id="${checkboxId}"
|
|
value="${escapeHtml(model.value)}"
|
|
${checked}>
|
|
<label for="${checkboxId}">${escapeHtml(model.label)}</label>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="w-100 mb-2">
|
|
<div class="small fw-semibold text-muted mb-1">${escapeHtml(categoryLabel)}</div>
|
|
<div class="filter-options">${items}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="w-100 mb-2">
|
|
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div>
|
|
${categoryBlocks}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
document.querySelectorAll('.model-checkbox').forEach(cb => {
|
|
cb.addEventListener('change', function () {
|
|
if (this.checked) activeModels.add(this.value);
|
|
else activeModels.delete(this.value);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// Totaux
|
|
function getAssetsForTotals() {
|
|
const $table = $('#unified-table');
|
|
if (!$table.length || typeof $table.bootstrapTable !== 'function') return [];
|
|
|
|
const rows = $table.bootstrapTable('getData', {
|
|
useCurrentPage: false,
|
|
includeHiddenRows: false,
|
|
}) || [];
|
|
|
|
const assetRows = rows.filter(row => !row.is_location);
|
|
|
|
if (!selectedIds || selectedIds.size === 0) return assetRows;
|
|
|
|
const selectedAssetRows = assetRows.filter(row => (
|
|
selectedIds.has(row.id) || selectedIds.has(String(row.id))
|
|
));
|
|
return selectedAssetRows.length ? selectedAssetRows : assetRows;
|
|
}
|
|
|
|
function groupByCategoryAndModel(assets) {
|
|
const byCat = {};
|
|
for (const a of assets) {
|
|
const cat = a.category || gettext('Sans catégorie');
|
|
const model = a.model || '—';
|
|
const lengthValue = Number(a.length ?? a.length_m ?? 0);
|
|
const areaValue = Number(a.area ?? a.area_m2 ?? a.surface_area ?? a.surface ?? 0);
|
|
byCat[cat] ??= {};
|
|
byCat[cat][model] ??= { count: 0, length_m: 0, area_m2: 0 };
|
|
byCat[cat][model].count += 1;
|
|
byCat[cat][model].length_m += Number.isFinite(lengthValue) ? lengthValue : 0;
|
|
byCat[cat][model].area_m2 += Number.isFinite(areaValue) ? areaValue : 0;
|
|
}
|
|
return byCat;
|
|
}
|
|
|
|
function fmtInt(n) {
|
|
return (Math.round(n) || '-').toLocaleString();
|
|
}
|
|
|
|
function renderTotals() {
|
|
const contentEl = document.getElementById('totalsContent');
|
|
if (!contentEl) return;
|
|
|
|
const assets = getAssetsForTotals();
|
|
const grouped = groupByCategoryAndModel(assets);
|
|
const hasSelection = selectedIds && selectedIds.size > 0;
|
|
|
|
const headerCount = interpolate(
|
|
ngettext('%(count)s élément pris en compte', '%(count)s éléments pris en compte', assets.length),
|
|
{ count: assets.length },
|
|
true
|
|
);
|
|
|
|
const selectionSuffix = hasSelection ? gettext('(sélection)') : gettext('(table)');
|
|
let html = `<div class="mb-3 text-muted">${headerCount} ${selectionSuffix}</div>`;
|
|
|
|
const categories = Object.keys(grouped).sort((a, b) => a.localeCompare(b, 'fr'));
|
|
if (!categories.length) {
|
|
contentEl.innerHTML = `<div class="text-center text-muted">${gettext('Aucune donnée disponible')}</div>`;
|
|
return;
|
|
}
|
|
|
|
for (const cat of categories) {
|
|
const models = grouped[cat];
|
|
const modelNames = Object.keys(models).sort((a, b) => a.localeCompare(b, 'fr'));
|
|
|
|
const bodyRows = modelNames.map(m => {
|
|
const g = models[m];
|
|
return `
|
|
<tr>
|
|
<td>${m}</td>
|
|
<td class="text-end">${fmtInt(g.count)}</td>
|
|
<td class="text-end">${fmtInt(g.length_m)}</td>
|
|
<td class="text-end">${fmtInt(g.area_m2)}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
const catTotals = modelNames.reduce((acc, m) => {
|
|
acc.count += models[m].count;
|
|
acc.length_m += models[m].length_m;
|
|
acc.area_m2 += models[m].area_m2;
|
|
return acc;
|
|
}, { count: 0, length_m: 0, area_m2: 0 });
|
|
|
|
html += `
|
|
<div class="mb-3 pb-2 border-bottom">
|
|
<h6 class="mb-2 d-flex align-items-center gap-2">
|
|
<span class="badge text-bg-secondary">${cat}</span>
|
|
<small class="text-muted">(${fmtInt(catTotals.count)} ${gettext('au total')})</small>
|
|
</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm align-middle">
|
|
<thead>
|
|
<tr>
|
|
<th>${gettext('Modèle')}</th>
|
|
<th class="text-end">${gettext('Nombre')}</th>
|
|
<th class="text-end">${gettext('Longueur (m)')}</th>
|
|
<th class="text-end">${gettext('Surface (m²)')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${bodyRows || `<tr><td colspan="4" class="text-center text-muted">${gettext('Aucun modèle')}</td></tr>`}</tbody>
|
|
<tfoot>
|
|
<tr class="fw-semibold">
|
|
<td>${gettext('Total catégorie')}</td>
|
|
<td class="text-end">${fmtInt(catTotals.count)}</td>
|
|
<td class="text-end">${fmtInt(catTotals.length_m)}</td>
|
|
<td class="text-end">${fmtInt(catTotals.area_m2)}</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
contentEl.innerHTML = html;
|
|
}
|
|
|
|
function initTotalsOverlay() {
|
|
const btnTotals = document.getElementById('totals_button');
|
|
const overlay = document.getElementById('totalsOverlay');
|
|
const closeBtn = document.getElementById('totalsClose');
|
|
if (!btnTotals || !overlay || !closeBtn) return;
|
|
|
|
btnTotals.addEventListener('click', function () {
|
|
renderTotals();
|
|
overlay.classList.add('show');
|
|
setTimeout(() => closeBtn.focus(), 0);
|
|
});
|
|
|
|
function hideOverlay() {
|
|
overlay.classList.remove('show');
|
|
}
|
|
|
|
closeBtn.addEventListener('click', hideOverlay);
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape' && overlay.classList.contains('show')) {
|
|
hideOverlay();
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', initTotalsOverlay);
|