git add loko/assets/static/assets/unified_index.js \
loko/assets/static/assets/unified_map_layers.js \
loko/assets/templates/assets/unified_index.html \
loko/assets/tests_management_zones.py \
loko/assets/views/core.py \
loko/assets/views/management_zones.py
git commit -m "feat(assets): integrate management zones into unified assets view
- Add management zones layers and table row support in unified index
- Replace element type radio filters with 3 checkboxes (assets, locations, management zones)
- Add 3rd card in unified add modal for management zone creation
- Include centroid coordinates, area, and unified properties in management zones GeoJSON
- Add comprehensive integration test suite"
This commit is contained in:
parent
b5b0338e09
commit
3e35c98a46
6 changed files with 438 additions and 56 deletions
|
|
@ -19,7 +19,15 @@ var activeCategories = new Set(SELECTED_CATEGORIES || []);
|
|||
var activeModels = new Set();
|
||||
var modelFilterCollapsed = true;
|
||||
var activeConditionalFilters = {};
|
||||
var currentTypeFilter = 'all'; // 'all' | 'locations' | 'assets'
|
||||
var currentTypeFilter = 'all'; // legacy fallback
|
||||
|
||||
function getActiveTypeFilters() {
|
||||
const types = [];
|
||||
if (document.getElementById('type-cb-assets')?.checked) types.push('assets');
|
||||
if (document.getElementById('type-cb-locations')?.checked) types.push('locations');
|
||||
if (document.getElementById('type-cb-management-zones')?.checked) types.push('management_zones');
|
||||
return types;
|
||||
}
|
||||
var searchTerms = [];
|
||||
var searchMode = 'and';
|
||||
var shouldFitSearch = false;
|
||||
|
|
@ -658,9 +666,31 @@ function applyInitialPreferences() {
|
|||
|
||||
// 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;
|
||||
if (Array.isArray(prefs.type_filter)) {
|
||||
const cbA = document.getElementById('type-cb-assets');
|
||||
const cbL = document.getElementById('type-cb-locations');
|
||||
const cbZ = document.getElementById('type-cb-management-zones');
|
||||
if (cbA) cbA.checked = prefs.type_filter.includes('assets');
|
||||
if (cbL) cbL.checked = prefs.type_filter.includes('locations');
|
||||
if (cbZ) cbZ.checked = prefs.type_filter.includes('management_zones');
|
||||
} else if (typeof prefs.type_filter === 'string') {
|
||||
const cbA = document.getElementById('type-cb-assets');
|
||||
const cbL = document.getElementById('type-cb-locations');
|
||||
const cbZ = document.getElementById('type-cb-management-zones');
|
||||
if (prefs.type_filter === 'all') {
|
||||
if (cbA) cbA.checked = true;
|
||||
if (cbL) cbL.checked = true;
|
||||
if (cbZ) cbZ.checked = true;
|
||||
} else if (prefs.type_filter === 'locations') {
|
||||
if (cbA) cbA.checked = false;
|
||||
if (cbL) cbL.checked = true;
|
||||
if (cbZ) cbZ.checked = false;
|
||||
} else if (prefs.type_filter === 'assets') {
|
||||
if (cbA) cbA.checked = true;
|
||||
if (cbL) cbL.checked = false;
|
||||
if (cbZ) cbZ.checked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include no geometry filter
|
||||
|
|
@ -739,7 +769,7 @@ function savePreferences() {
|
|||
models: Array.from(activeModels),
|
||||
conditional_filters: activeConditionalFilters,
|
||||
visible_columns: getVisibleColumnsPreference(),
|
||||
type_filter: currentTypeFilter,
|
||||
type_filter: getActiveTypeFilters(),
|
||||
status_filters: statusFilters,
|
||||
validation_status_filters: validationStatusFilters,
|
||||
include_no_geom: includeNoGeom,
|
||||
|
|
@ -784,9 +814,9 @@ $(document).ready(function () {
|
|||
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';
|
||||
const source = row.is_management_zone ? 'management_zones' : (row.is_location ? 'locations' : 'assets');
|
||||
if (typeof highlightMapFeature === 'function') {
|
||||
highlightMapFeature(map, source, row.id, row.is_location ? locationshighlightState : assetshighlightState);
|
||||
highlightMapFeature(map, source, row.id, row.is_management_zone ? managementZonesHighlightState : (row.is_location ? locationshighlightState : assetshighlightState));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -865,11 +895,13 @@ $(document).ready(function () {
|
|||
renderModelFilters();
|
||||
});
|
||||
|
||||
// ─── Type radio ────────────────────────────────────────────────────────
|
||||
// ─── Type checkboxes ────────────────────────────────────────────────────
|
||||
|
||||
document.querySelectorAll('input[name="unified-type-filter"]').forEach(rb => {
|
||||
rb.addEventListener('change', function () {
|
||||
if (this.checked) currentTypeFilter = this.value;
|
||||
document.querySelectorAll('input[name="filter-element-type"]').forEach(cb => {
|
||||
cb.addEventListener('change', function () {
|
||||
applyFilters();
|
||||
updateFilterBadge();
|
||||
schedulePreferencesSave();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -884,26 +916,45 @@ $(document).ready(function () {
|
|||
}
|
||||
|
||||
// init from locked element type
|
||||
if (PREVENTIVE_ELEMENT_TYPE === 'assets') currentTypeFilter = 'assets';
|
||||
else if (PREVENTIVE_ELEMENT_TYPE === 'locations') currentTypeFilter = 'locations';
|
||||
if (PREVENTIVE_ELEMENT_TYPE === 'assets') {
|
||||
const cbA = document.getElementById('type-cb-assets');
|
||||
const cbL = document.getElementById('type-cb-locations');
|
||||
const cbZ = document.getElementById('type-cb-management-zones');
|
||||
if (cbA) cbA.checked = true;
|
||||
if (cbL) cbL.checked = false;
|
||||
if (cbZ) cbZ.checked = false;
|
||||
} else if (PREVENTIVE_ELEMENT_TYPE === 'locations') {
|
||||
const cbA = document.getElementById('type-cb-assets');
|
||||
const cbL = document.getElementById('type-cb-locations');
|
||||
const cbZ = document.getElementById('type-cb-management-zones');
|
||||
if (cbA) cbA.checked = false;
|
||||
if (cbL) cbL.checked = true;
|
||||
if (cbZ) cbZ.checked = false;
|
||||
}
|
||||
|
||||
// ─── 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 managementZoneThematics = Array.isArray(addConfig.management_zone_thematics) ? addConfig.management_zone_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 managementZoneThematicSelect = document.getElementById('unified-add-management-zone-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'));
|
||||
fillSelectWithOptions(managementZoneThematicSelect, managementZoneThematics, managementZoneThematics.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) {
|
||||
if (managementZoneThematicSelect) managementZoneThematicSelect.disabled = !managementZoneThematics.length;
|
||||
|
||||
const hasAnyAddRights = assetThematics.length > 0 || locationThematics.length > 0 || managementZoneThematics.length > 0;
|
||||
if (chooserEmpty) chooserEmpty.classList.toggle('d-none', hasAnyAddRights);
|
||||
if (addButton && !hasAnyAddRights) {
|
||||
addButton.disabled = true;
|
||||
addButton.title = gettext('Vous n\'avez pas de droits de création');
|
||||
}
|
||||
|
|
@ -962,6 +1013,17 @@ $(document).ready(function () {
|
|||
}
|
||||
});
|
||||
|
||||
document.getElementById('unified-add-management-zone-open')?.addEventListener('click', function () {
|
||||
const thematicCode = managementZoneThematicSelect ? managementZoneThematicSelect.value : '';
|
||||
if (!thematicCode) {
|
||||
setModalAlert('unified-add-chooser-alert', gettext('Sélectionnez une thématique pour créer une zone de gestion.'), 'warning');
|
||||
return;
|
||||
}
|
||||
setModalAlert('unified-add-chooser-alert', '', 'info');
|
||||
const baseUrl = addConfig.management_zone_create_url || '/assets/management-zones/add/';
|
||||
window.location.href = `${baseUrl}?thematic=${encodeURIComponent(thematicCode)}`;
|
||||
});
|
||||
|
||||
document.getElementById('unifiedAddAssetCategory')?.addEventListener('change', function () {
|
||||
const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || '';
|
||||
loadUnifiedAssetModels(thematicCode, this.value);
|
||||
|
|
@ -1175,8 +1237,12 @@ $(document).ready(function () {
|
|||
activeModels.clear();
|
||||
modelFilterCollapsed = true;
|
||||
activeConditionalFilters = {};
|
||||
const allRadio = document.getElementById('type-all');
|
||||
if (allRadio) { allRadio.checked = true; currentTypeFilter = 'all'; }
|
||||
const cbA = document.getElementById('type-cb-assets');
|
||||
const cbL = document.getElementById('type-cb-locations');
|
||||
const cbZ = document.getElementById('type-cb-management-zones');
|
||||
if (cbA) cbA.checked = true;
|
||||
if (cbL) cbL.checked = true;
|
||||
if (cbZ) cbZ.checked = true;
|
||||
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');
|
||||
|
|
@ -1860,11 +1926,13 @@ function findUnifiedRowIndex(rowRef) {
|
|||
const rows = $table.bootstrapTable('getData') || [];
|
||||
|
||||
const isLocation = !!rowRef.is_location;
|
||||
const isManagementZone = !!rowRef.is_management_zone;
|
||||
const matches = (row) => {
|
||||
if (!!row.is_location !== isLocation) return false;
|
||||
if (!!row.is_management_zone !== isManagementZone) return false;
|
||||
if (String(row.id) !== String(rowRef.id)) return false;
|
||||
|
||||
if (!isLocation) {
|
||||
if (!isLocation && !isManagementZone) {
|
||||
if (String(row.thematic || '') !== String(rowRef.thematic || '')) return false;
|
||||
if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false;
|
||||
} else {
|
||||
|
|
@ -1878,7 +1946,7 @@ function findUnifiedRowIndex(rowRef) {
|
|||
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));
|
||||
idx = rows.findIndex(row => !!row.is_location === isLocation && !!row.is_management_zone === isManagementZone && String(row.id) === String(rowRef.id));
|
||||
return idx;
|
||||
}
|
||||
|
||||
|
|
@ -1953,6 +2021,37 @@ function collectClickedAssets(clickEvent) {
|
|||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
function collectClickedManagementZones(clickEvent) {
|
||||
const layers = ['management-zones-fill', 'management-zones-line'].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 = `zone|${p.thematic || ''}|${id}`;
|
||||
if (unique.has(key)) return;
|
||||
unique.set(key, {
|
||||
id: id,
|
||||
code: p.code || '',
|
||||
name: p.name || '',
|
||||
thematic: p.thematic || '',
|
||||
thematic_name: f._thematic_name || p.thematic_name || p.thematic || '',
|
||||
element_type: p.element_type || gettext('Zone de gestion'),
|
||||
category: p.category || p.zone_type_name || '',
|
||||
detail_url: p.detail_url || null,
|
||||
color: p.color || '',
|
||||
feature_id: id,
|
||||
is_management_zone: true,
|
||||
});
|
||||
});
|
||||
|
||||
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 [];
|
||||
|
|
@ -2016,12 +2115,29 @@ function bindAssetPopupActions(popupNode) {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
popupNode.querySelectorAll('.zone-popup-focus-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const rowRef = {
|
||||
id: this.dataset.zoneId,
|
||||
thematic: this.dataset.thematic,
|
||||
is_management_zone: true,
|
||||
};
|
||||
focusRowInUnifiedTable(rowRef);
|
||||
|
||||
if (typeof highlightMapFeature === 'function') {
|
||||
highlightMapFeature(map, 'management_zones', rowRef.id, managementZonesHighlightState);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showAssetClickPopup(clickEvent) {
|
||||
const assets = collectClickedAssets(clickEvent);
|
||||
const locations = collectClickedLocations(clickEvent);
|
||||
if (!assets.length && !locations.length) return;
|
||||
const managementZones = collectClickedManagementZones(clickEvent);
|
||||
if (!assets.length && !locations.length && !managementZones.length) return;
|
||||
|
||||
const maxItems = 8;
|
||||
const visibleAssets = assets.slice(0, maxItems);
|
||||
|
|
@ -2113,10 +2229,56 @@ function showAssetClickPopup(clickEvent) {
|
|||
`
|
||||
: '';
|
||||
|
||||
const visibleZones = managementZones.slice(0, maxItems);
|
||||
const hiddenZonesCount = Math.max(0, managementZones.length - visibleZones.length);
|
||||
|
||||
const zoneItemsHtml = visibleZones.map(z => {
|
||||
const code = escapeHtml(z.code || '-');
|
||||
const type = escapeHtml(z.element_type || gettext('Zone de gestion'));
|
||||
const thematic = escapeHtml(z.thematic_name || z.thematic || '');
|
||||
const cat = z.category ? ` • ${escapeHtml(z.category)}` : '';
|
||||
const dotHtml = z.color ? `<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background-color:${escapeHtml(z.color)}; margin-right:4px; vertical-align:middle; box-shadow:0 0 0 1px rgba(0,0,0,0.15);"></span>` : '';
|
||||
const name = escapeHtml(z.name || '');
|
||||
const detailAnchor = z.detail_url
|
||||
? `<a href="${escapeHtml(z.detail_url)}">${code}</a>`
|
||||
: `<span>${code}</span>`;
|
||||
|
||||
return `
|
||||
<div class="zone-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;">${dotHtml}${type}${cat}${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 zone-popup-focus-btn"
|
||||
data-zone-id="${escapeHtml(z.id)}"
|
||||
data-thematic="${escapeHtml(z.thematic)}"
|
||||
title="${escapeHtml(gettext('Voir dans le tableau'))}"
|
||||
style="transform:translateX(-5px);">
|
||||
<i class="bi bi-table"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const moreZonesHtml = hiddenZonesCount > 0
|
||||
? `<div style="margin-top:0.35rem; font-size:0.75rem; color:#6c757d;">+${hiddenZonesCount} ${escapeHtml(gettext('autre(s) zone(s)'))}</div>`
|
||||
: '';
|
||||
|
||||
const managementZonesSectionHtml = managementZones.length > 0
|
||||
? `
|
||||
<div style="font-weight:700; margin:${(assets.length || locations.length) ? '0.6rem' : '0'} 0 0.35rem 0;">${escapeHtml(gettext('Zones de gestion trouvées'))}: ${managementZones.length}</div>
|
||||
<div>${zoneItemsHtml}</div>
|
||||
${moreZonesHtml}
|
||||
`
|
||||
: '';
|
||||
|
||||
const popupHtml = `
|
||||
<div style="min-width:260px; max-width:320px;">
|
||||
${assetsSectionHtml}
|
||||
${locationsSectionHtml}
|
||||
${managementZonesSectionHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -2307,6 +2469,23 @@ function refreshData() {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Management zones: loaded per active thematic
|
||||
if (cfg.management_zones_geojson_url) {
|
||||
if (!('management_zones' in geojsonCache[code])) {
|
||||
const url = cfg.management_zones_geojson_url + '?thematic=' + encodeURIComponent(code);
|
||||
fetches.push(
|
||||
fetchGeojsonFeatures(url, { signal }, signal)
|
||||
.then(data => {
|
||||
geojsonCache[code].management_zones = data.features || [];
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.name === 'AbortError') throw err;
|
||||
geojsonCache[code].management_zones = [];
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Assets: conditional loading
|
||||
const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code);
|
||||
const prevMode = assetsLoadModes[code] || null;
|
||||
|
|
@ -2382,6 +2561,10 @@ function refreshData() {
|
|||
f._thematic_name = thematicName;
|
||||
allFeatures.push(f);
|
||||
});
|
||||
(cache.management_zones || []).forEach(f => {
|
||||
f._thematic_name = thematicName;
|
||||
allFeatures.push(f);
|
||||
});
|
||||
});
|
||||
renderModelFilters();
|
||||
applyFilters();
|
||||
|
|
@ -2406,19 +2589,27 @@ 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);
|
||||
}
|
||||
const cbAssets = document.getElementById('type-cb-assets');
|
||||
const cbLocations = document.getElementById('type-cb-locations');
|
||||
const cbZones = document.getElementById('type-cb-management-zones');
|
||||
const showAssets = cbAssets ? cbAssets.checked : true;
|
||||
const showLocations = cbLocations ? cbLocations.checked : true;
|
||||
const showManagementZones = cbZones ? cbZones.checked : true;
|
||||
|
||||
features = features.filter(f => {
|
||||
if (!f.properties) return false;
|
||||
if (f.properties.is_management_zone) return showManagementZones;
|
||||
if (f.properties.is_location) return showLocations;
|
||||
return showAssets;
|
||||
});
|
||||
|
||||
// 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;
|
||||
// Locations and management zones are never filtered by geometry checkbox (only assets)
|
||||
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
|
||||
return f.geometry && (f.geometry.coordinates || f.geometry.type);
|
||||
});
|
||||
}
|
||||
|
|
@ -2435,7 +2626,7 @@ function applyFilters() {
|
|||
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;
|
||||
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
|
||||
return selectedValidationStatuses.includes(f.properties && f.properties.validation_status);
|
||||
});
|
||||
}
|
||||
|
|
@ -2443,7 +2634,7 @@ function applyFilters() {
|
|||
// Category filter
|
||||
if (activeCategories.size) {
|
||||
features = features.filter(f => {
|
||||
if (f.properties && f.properties.is_location) return true;
|
||||
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
|
||||
return activeCategories.has(getFeatureCategoryValue(f));
|
||||
});
|
||||
}
|
||||
|
|
@ -2451,7 +2642,7 @@ function applyFilters() {
|
|||
// Model filter
|
||||
if (activeModels.size) {
|
||||
features = features.filter(f => {
|
||||
if (f.properties && f.properties.is_location) return true;
|
||||
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
|
||||
return activeModels.has(getFeatureModelValue(f));
|
||||
});
|
||||
}
|
||||
|
|
@ -2519,6 +2710,7 @@ function loadTableData(features) {
|
|||
length: Number.isFinite(length) ? length : 0,
|
||||
area: Number.isFinite(area) ? area : 0,
|
||||
is_location: p.is_location,
|
||||
is_management_zone: p.is_management_zone || false,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -2539,6 +2731,7 @@ function loadTableData(features) {
|
|||
|
||||
var locationshighlightState = { previousId: null };
|
||||
var assetshighlightState = { previousId: null };
|
||||
var managementZonesHighlightState = { previousId: null };
|
||||
|
||||
|
||||
function isPanelTextSpriteId(missingId) {
|
||||
|
|
@ -2611,15 +2804,20 @@ function rebuildMapSources() {
|
|||
if (typeof map === 'undefined') return;
|
||||
const locSrc = map.getSource('locations');
|
||||
const asstSrc = map.getSource('assets');
|
||||
const zoneSrc = map.getSource('management_zones');
|
||||
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),
|
||||
};
|
||||
mergedManagementZonesGeojson = {
|
||||
type: 'FeatureCollection',
|
||||
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_management_zone),
|
||||
};
|
||||
mergedAssetsGeojson = {
|
||||
type: 'FeatureCollection',
|
||||
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location),
|
||||
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location && !f.properties.is_management_zone),
|
||||
};
|
||||
|
||||
applyUnifiedSignPanelOffsets(mergedAssetsGeojson);
|
||||
|
|
@ -2627,6 +2825,7 @@ function rebuildMapSources() {
|
|||
|
||||
locSrc.setData(mergedLocationsGeojson);
|
||||
asstSrc.setData(mergedAssetsGeojson);
|
||||
if (zoneSrc) zoneSrc.setData(mergedManagementZonesGeojson);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2642,6 +2841,7 @@ maploader.addEventListener('mapLoaded', () => {
|
|||
data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/'
|
||||
});
|
||||
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
map.addSource('management_zones', { 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)
|
||||
|
|
@ -2782,10 +2982,10 @@ maploader.addEventListener('mapLoaded', () => {
|
|||
});
|
||||
|
||||
// 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 => {
|
||||
['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', 'management-zones-fill', 'management-zones-line'].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)) {
|
||||
if (['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'management-zones-fill'].includes(layerId)) {
|
||||
map.on('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; });
|
||||
}
|
||||
|
|
@ -2795,7 +2995,7 @@ maploader.addEventListener('mapLoaded', () => {
|
|||
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']
|
||||
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', 'management-zones-fill', 'management-zones-line']
|
||||
.filter(layerId => map.getLayer(layerId));
|
||||
if (!interactiveLayers.length) {
|
||||
assetClickPopup.remove();
|
||||
|
|
@ -2856,7 +3056,7 @@ function updateFilterBadge() {
|
|||
}, 0);
|
||||
n += thematicBlocksCount;
|
||||
|
||||
if (currentTypeFilter !== 'all') n += 1;
|
||||
if (getActiveTypeFilters().length < 3) 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -384,6 +384,34 @@ const _unified_locations_fill = {
|
|||
},
|
||||
};
|
||||
|
||||
// ─── Layer 1b — management zones fill ─────────────────────────────────────────
|
||||
const _unified_management_zones_fill = {
|
||||
id: 'management-zones-fill',
|
||||
type: 'fill',
|
||||
source: 'management_zones',
|
||||
filter: ['any',
|
||||
['==', ['geometry-type'], 'Polygon'],
|
||||
['==', ['geometry-type'], 'MultiPolygon']
|
||||
],
|
||||
paint: {
|
||||
'fill-color': ['coalesce', ['get', 'color'], '#10b981'],
|
||||
'fill-opacity': 0.25,
|
||||
'fill-antialias': false,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Layer 3b — management zones line ─────────────────────────────────────────
|
||||
const _unified_management_zones_line = {
|
||||
id: 'management-zones-line',
|
||||
type: 'line',
|
||||
source: 'management_zones',
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], '#10b981'],
|
||||
'line-width': 2,
|
||||
'line-dasharray': [3, 2],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ────
|
||||
const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer)
|
||||
? AssetSymbology.getAssetsFillLayer('assets')
|
||||
|
|
@ -1145,10 +1173,12 @@ const UNIFIED_LAYERS = [
|
|||
_unified_territory_mask, // 0. territory mask (outside commune)
|
||||
_unified_territory_outline, // 0b. territory boundary line
|
||||
_unified_locations_fill, // 1. polygon fills (bottom)
|
||||
_unified_management_zones_fill, // 1b. management zones fill
|
||||
_unified_assets_fill, // 2. polygon fills
|
||||
_unified_locations_outline, // 3. lines
|
||||
_unified_assets_line, // 3b. asset lines
|
||||
_unified_assets_polygon_outline, // 3c. polygon asset outlines
|
||||
_unified_management_zones_line, // 3b. management zones line
|
||||
_unified_assets_line, // 3c. asset lines
|
||||
_unified_assets_polygon_outline, // 3d. polygon asset outlines
|
||||
_unified_locations_circle, // 4. circles
|
||||
_unified_locations_radar_outline,
|
||||
_unified_locations_radar,
|
||||
|
|
|
|||
|
|
@ -670,24 +670,24 @@
|
|||
<div class="filter-section-header">
|
||||
<label>{% translate "Type d'élément" %}</label>
|
||||
</div>
|
||||
<div class="segmented-control">
|
||||
<div class="filter-option segmented-option">
|
||||
<input type="radio" name="unified-type-filter" id="type-all" value="all"
|
||||
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
|
||||
{% if preventive_element_type %}disabled{% endif %}>
|
||||
<label for="type-all">{% translate "Assets & localisations" %}</label>
|
||||
<div class="filter-options d-flex gap-3 flex-wrap">
|
||||
<div class="filter-option">
|
||||
<input type="checkbox" name="filter-element-type" id="type-cb-assets" value="assets"
|
||||
{% if preventive_element_type != 'locations' %}checked{% endif %}
|
||||
{% if preventive_element_type == 'locations' %}disabled{% endif %}>
|
||||
<label for="type-cb-assets"><i class="bi bi-layers me-1 text-primary"></i>{% translate "Assets" %}</label>
|
||||
</div>
|
||||
<div class="filter-option segmented-option">
|
||||
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
|
||||
{% if preventive_element_type == 'locations' %}checked{% endif %}
|
||||
{% if preventive_element_type %}disabled{% endif %}>
|
||||
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
|
||||
<div class="filter-option">
|
||||
<input type="checkbox" name="filter-element-type" id="type-cb-locations" value="locations"
|
||||
{% if preventive_element_type != 'assets' %}checked{% endif %}
|
||||
{% if preventive_element_type == 'assets' %}disabled{% endif %}>
|
||||
<label for="type-cb-locations"><i class="bi bi-geo-alt me-1 text-info"></i>{% translate "Localisations" %}</label>
|
||||
</div>
|
||||
<div class="filter-option segmented-option">
|
||||
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
|
||||
{% if preventive_element_type == 'assets' %}checked{% endif %}
|
||||
<div class="filter-option">
|
||||
<input type="checkbox" name="filter-element-type" id="type-cb-management-zones" value="management_zones"
|
||||
{% if not preventive_element_type %}checked{% endif %}
|
||||
{% if preventive_element_type %}disabled{% endif %}>
|
||||
<label for="type-assets">{% translate "Assets uniquement" %}</label>
|
||||
<label for="type-cb-management-zones"><i class="bi bi-diagram-3 me-1 text-success"></i>{% translate "Zones de gestion" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -837,7 +837,7 @@
|
|||
{% translate "Choisissez d'abord le type d'élément, puis la thématique autorisée." %}
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<div class="unified-add-panel h-100">
|
||||
<h6 class="d-flex align-items-center gap-2"><i class="bi bi-layers"></i>{% translate "Asset" %}</h6>
|
||||
<label for="unified-add-asset-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
|
||||
|
|
@ -847,7 +847,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<div class="unified-add-panel h-100">
|
||||
<h6 class="d-flex align-items-center gap-2"><i class="bi bi-geo-alt"></i>{% translate "Localisation" %}</h6>
|
||||
<label for="unified-add-location-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
|
||||
|
|
@ -857,6 +857,16 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="unified-add-panel h-100">
|
||||
<h6 class="d-flex align-items-center gap-2"><i class="bi bi-diagram-3 text-success"></i>{% translate "Zone de gestion" %}</h6>
|
||||
<label for="unified-add-management-zone-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
|
||||
<select id="unified-add-management-zone-thematic" class="form-select mb-3"></select>
|
||||
<button type="button" id="unified-add-management-zone-open" class="btn btn-outline-success w-100">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% translate "Créer une zone" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="unified-add-chooser-empty" class="text-muted small mt-3 d-none">
|
||||
{% translate "Aucune thématique éditable n'est disponible pour la création." %}
|
||||
|
|
|
|||
|
|
@ -486,3 +486,108 @@ class ManagementZonesPermissionAndCRUDTests(TestCase):
|
|||
data = res.json()
|
||||
self.assertEqual(len(data['types']), 1)
|
||||
self.assertEqual(data['types'][0]['code'], 'fauche_eco')
|
||||
|
||||
|
||||
class ManagementZoneUnifiedViewIntegrationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.thematic_nature = Thematic.objects.create(name_fr="Nature", name_nl="Natuur", code="nature")
|
||||
self.thematic_roads = Thematic.objects.create(name_fr="Voirie", name_nl="Wegen", code="roads")
|
||||
|
||||
self.user_editor = User.objects.create_user(username="editor_unified", password="password")
|
||||
self.user_viewer = User.objects.create_user(username="viewer_unified", password="password")
|
||||
|
||||
self.config_editor = UserConfig.objects.create(user=self.user_editor)
|
||||
self.config_viewer = UserConfig.objects.create(user=self.user_viewer)
|
||||
|
||||
UserThematics.objects.create(
|
||||
user_config=self.config_editor,
|
||||
thematic=self.thematic_nature,
|
||||
can_view_assets=True,
|
||||
can_edit_assets=True,
|
||||
can_edit_management_zones=True,
|
||||
)
|
||||
|
||||
UserThematics.objects.create(
|
||||
user_config=self.config_viewer,
|
||||
thematic=self.thematic_nature,
|
||||
can_view_assets=True,
|
||||
can_edit_assets=False,
|
||||
can_edit_management_zones=False,
|
||||
)
|
||||
|
||||
self.zone_type = ManagementZoneType.objects.create(
|
||||
thematic=self.thematic_nature,
|
||||
code="fauche_eco",
|
||||
name_fr="Fauche tardive",
|
||||
name_nl="Laat maaien",
|
||||
default_color="#27ae60",
|
||||
)
|
||||
|
||||
poly = Polygon(((149000, 170000), (149100, 170000), (149100, 170100), (149000, 170100), (149000, 170000)))
|
||||
multi_poly = MultiPolygon([poly], srid=3812)
|
||||
self.zone = ManagementZone.objects.create(
|
||||
thematic=self.thematic_nature,
|
||||
zone_type=self.zone_type,
|
||||
code="ZN-TEST-001",
|
||||
name_fr="Pelouse A",
|
||||
name_nl="Grasveld A",
|
||||
geom=multi_poly,
|
||||
is_active=True,
|
||||
created_by=self.user_editor,
|
||||
)
|
||||
|
||||
def test_build_thematic_config_includes_management_zones_url(self):
|
||||
from assets.views.core import build_thematic_config
|
||||
config = build_thematic_config([self.thematic_nature, self.thematic_roads])
|
||||
self.assertIn("nature", config)
|
||||
self.assertIn("management_zones_geojson_url", config["nature"])
|
||||
self.assertEqual(config["nature"]["management_zones_geojson_url"], reverse("assets:management_zones_geojson"))
|
||||
|
||||
def test_build_unified_add_modal_config_permissions(self):
|
||||
from assets.views.core import build_unified_add_modal_config
|
||||
# Editor can edit management zones in nature
|
||||
editor_config = build_unified_add_modal_config([self.thematic_nature], user=self.user_editor)
|
||||
self.assertIn("management_zone_thematics", editor_config)
|
||||
editor_codes = [t["code"] for t in editor_config["management_zone_thematics"]]
|
||||
self.assertIn("nature", editor_codes)
|
||||
|
||||
# Viewer cannot edit management zones
|
||||
viewer_config = build_unified_add_modal_config([self.thematic_nature], user=self.user_viewer)
|
||||
viewer_codes = [t["code"] for t in viewer_config["management_zone_thematics"]]
|
||||
self.assertNotIn("nature", viewer_codes)
|
||||
|
||||
def test_management_zones_geojson_properties_for_unified_table(self):
|
||||
self.client.login(username="editor_unified", password="password")
|
||||
url = reverse("assets:management_zones_geojson") + f"?thematic={self.thematic_nature.code}"
|
||||
res = self.client.get(url)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
data = res.json()
|
||||
self.assertEqual(len(data["features"]), 1)
|
||||
feat = data["features"][0]
|
||||
props = feat["properties"]
|
||||
|
||||
self.assertTrue(props["is_management_zone"])
|
||||
self.assertFalse(props["is_location"])
|
||||
self.assertEqual(props["asset_type"], "managementzone")
|
||||
self.assertIn(props["element_type"], ["Zone de gestion", "Management zone"])
|
||||
self.assertEqual(props["category"], "Fauche tardive")
|
||||
self.assertEqual(props["status"], "active")
|
||||
self.assertIsNotNone(props["lon"])
|
||||
self.assertIsNotNone(props["lat"])
|
||||
self.assertIsNotNone(props["area"])
|
||||
self.assertEqual(props["detail_url"], reverse("assets:management_zone_detail", kwargs={"pk": self.zone.pk}))
|
||||
|
||||
def test_unified_index_page_contains_management_zone_elements(self):
|
||||
self.client.login(username="editor_unified", password="password")
|
||||
url = reverse("assets:index")
|
||||
res = self.client.get(url)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
# Check filter modal checkboxes
|
||||
self.assertContains(res, 'id="type-cb-assets"')
|
||||
self.assertContains(res, 'id="type-cb-locations"')
|
||||
self.assertContains(res, 'id="type-cb-management-zones"')
|
||||
|
||||
# Check add modal card
|
||||
self.assertContains(res, 'id="unified-add-management-zone-thematic"')
|
||||
self.assertContains(res, 'id="unified-add-management-zone-open"')
|
||||
|
|
|
|||
|
|
@ -470,6 +470,8 @@ ELEMENT_TYPE_LABELS = {
|
|||
# --- Espaces plein air ---
|
||||
'outdoorlocation': _('Espace public plein air'),
|
||||
'outdoorasset': _('Équipement plein air'),
|
||||
# --- Zones de gestion ---
|
||||
'managementzone': _('Zone de gestion'),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -553,6 +555,7 @@ def build_thematic_config(thematics):
|
|||
},
|
||||
}
|
||||
|
||||
management_zones_url = reverse('assets:management_zones_geojson')
|
||||
result = {}
|
||||
for thematic in thematics:
|
||||
code = thematic.code
|
||||
|
|
@ -560,6 +563,7 @@ def build_thematic_config(thematics):
|
|||
entry = dict(_THEMATIC_URLS[code])
|
||||
entry['name'] = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic)
|
||||
entry['icon'] = thematic.icon if hasattr(thematic, 'icon') else ''
|
||||
entry['management_zones_geojson_url'] = management_zones_url
|
||||
result[code] = entry
|
||||
return result
|
||||
|
||||
|
|
@ -936,6 +940,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
|
|||
from assets.permissions import (
|
||||
can_edit_location_for_thematic,
|
||||
get_allowed_actions_for_thematic,
|
||||
can_edit_management_zones,
|
||||
)
|
||||
|
||||
location_type_by_thematic = {
|
||||
|
|
@ -948,6 +953,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
|
|||
|
||||
asset_thematics = []
|
||||
location_thematics = []
|
||||
management_zone_thematics = []
|
||||
|
||||
for thematic in accessible_thematics:
|
||||
thematic_name = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic)
|
||||
|
|
@ -974,9 +980,18 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
|
|||
'location_label': location_meta['label'],
|
||||
})
|
||||
|
||||
if can_edit_management_zones(user, thematic):
|
||||
management_zone_thematics.append({
|
||||
'code': thematic.code,
|
||||
'name': thematic_name,
|
||||
'icon': thematic_icon,
|
||||
})
|
||||
|
||||
return {
|
||||
'asset_thematics': asset_thematics,
|
||||
'location_thematics': location_thematics,
|
||||
'management_zone_thematics': management_zone_thematics,
|
||||
'management_zone_create_url': reverse('assets:management_zone_create'),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ def management_zones_geojson(request):
|
|||
if not thematic:
|
||||
return JsonResponse({'type': 'FeatureCollection', 'features': []})
|
||||
|
||||
can_view, _ = get_user_asset_thematic_access(user, thematic)
|
||||
can_view, _can_edit = get_user_asset_thematic_access(user, thematic)
|
||||
if not can_view:
|
||||
return HttpResponseForbidden("Access denied to this thematic management zones.")
|
||||
|
||||
|
|
@ -77,6 +77,18 @@ def management_zones_geojson(request):
|
|||
if not geom_json:
|
||||
continue
|
||||
|
||||
centroid = zone.geom.centroid.clone() if zone.geom else None
|
||||
if centroid:
|
||||
if centroid.srid and centroid.srid != 4326:
|
||||
centroid.transform(4326)
|
||||
lon = round(centroid.x, 7)
|
||||
lat = round(centroid.y, 7)
|
||||
else:
|
||||
lon = None
|
||||
lat = None
|
||||
area = round(zone.geom.area, 1) if getattr(zone.geom, 'area', None) is not None else None
|
||||
category_name = zone.zone_type.get_name() if zone.zone_type else ""
|
||||
|
||||
features.append({
|
||||
'type': 'Feature',
|
||||
'id': zone.id,
|
||||
|
|
@ -87,12 +99,22 @@ def management_zones_geojson(request):
|
|||
'thematic': zone.thematic.code,
|
||||
'thematic_name': zone.thematic.name,
|
||||
'zone_type_code': zone.zone_type.code if zone.zone_type else None,
|
||||
'zone_type_name': zone.zone_type.get_name() if zone.zone_type else "",
|
||||
'zone_type_name': category_name,
|
||||
'category': category_name,
|
||||
'color': zone.get_color(),
|
||||
'instructions': zone.instructions or "",
|
||||
'description': zone.description or "",
|
||||
'seasonality_notes': zone.seasonality_notes or "",
|
||||
'target_frequency_days': zone.target_frequency_days,
|
||||
'status': 'active' if zone.is_active else 'inactive',
|
||||
'element_type': str(_('Zone de gestion')),
|
||||
'asset_type': 'managementzone',
|
||||
'detail_url': reverse('assets:management_zone_detail', kwargs={'pk': zone.pk}),
|
||||
'lon': lon,
|
||||
'lat': lat,
|
||||
'area': area,
|
||||
'is_location': False,
|
||||
'is_management_zone': True,
|
||||
},
|
||||
'geometry': geom_json
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue