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:
kdeterme 2026-10-05 16:00:12 +02:00
parent b5b0338e09
commit 3e35c98a46
6 changed files with 438 additions and 56 deletions

View file

@ -19,7 +19,15 @@ var activeCategories = new Set(SELECTED_CATEGORIES || []);
var activeModels = new Set(); var activeModels = new Set();
var modelFilterCollapsed = true; var modelFilterCollapsed = true;
var activeConditionalFilters = {}; 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 searchTerms = [];
var searchMode = 'and'; var searchMode = 'and';
var shouldFitSearch = false; var shouldFitSearch = false;
@ -658,9 +666,31 @@ function applyInitialPreferences() {
// Type filter // Type filter
if (prefs.type_filter) { if (prefs.type_filter) {
currentTypeFilter = prefs.type_filter; if (Array.isArray(prefs.type_filter)) {
const rb = document.querySelector(`input[name="unified-type-filter"][value="${prefs.type_filter}"]`); const cbA = document.getElementById('type-cb-assets');
if (rb) rb.checked = true; 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 // Include no geometry filter
@ -739,7 +769,7 @@ function savePreferences() {
models: Array.from(activeModels), models: Array.from(activeModels),
conditional_filters: activeConditionalFilters, conditional_filters: activeConditionalFilters,
visible_columns: getVisibleColumnsPreference(), visible_columns: getVisibleColumnsPreference(),
type_filter: currentTypeFilter, type_filter: getActiveTypeFilters(),
status_filters: statusFilters, status_filters: statusFilters,
validation_status_filters: validationStatusFilters, validation_status_filters: validationStatusFilters,
include_no_geom: includeNoGeom, 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(); }, onUncheckAll: function (rows) { rows.forEach(r => { r.state = false; selectedIds.delete(r.id); }); updateSelectionStatus(); },
onClickRow: function (row) { onClickRow: function (row) {
if (!row.lon || !row.lat) return; 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') { 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(); renderModelFilters();
}); });
// ─── Type radio ──────────────────────────────────────────────────────── // ─── Type checkboxes ────────────────────────────────────────────────────
document.querySelectorAll('input[name="unified-type-filter"]').forEach(rb => { document.querySelectorAll('input[name="filter-element-type"]').forEach(cb => {
rb.addEventListener('change', function () { cb.addEventListener('change', function () {
if (this.checked) currentTypeFilter = this.value; applyFilters();
updateFilterBadge();
schedulePreferencesSave();
}); });
}); });
@ -884,26 +916,45 @@ $(document).ready(function () {
} }
// init from locked element type // init from locked element type
if (PREVENTIVE_ELEMENT_TYPE === 'assets') currentTypeFilter = 'assets'; if (PREVENTIVE_ELEMENT_TYPE === 'assets') {
else if (PREVENTIVE_ELEMENT_TYPE === 'locations') currentTypeFilter = '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 = 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 ───────────────────────────────────────────────────── // ─── Add actions ─────────────────────────────────────────────────────
const addConfig = getUnifiedAddModalConfig(); const addConfig = getUnifiedAddModalConfig();
const assetThematics = Array.isArray(addConfig.asset_thematics) ? addConfig.asset_thematics : []; const assetThematics = Array.isArray(addConfig.asset_thematics) ? addConfig.asset_thematics : [];
const locationThematics = Array.isArray(addConfig.location_thematics) ? addConfig.location_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 addButton = document.getElementById('btn-add-asset');
const assetThematicSelect = document.getElementById('unified-add-asset-thematic'); const assetThematicSelect = document.getElementById('unified-add-asset-thematic');
const locationThematicSelect = document.getElementById('unified-add-location-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'); 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(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(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 (assetThematicSelect) assetThematicSelect.disabled = !assetThematics.length;
if (locationThematicSelect) locationThematicSelect.disabled = !locationThematics.length; if (locationThematicSelect) locationThematicSelect.disabled = !locationThematics.length;
if (chooserEmpty) chooserEmpty.classList.toggle('d-none', assetThematics.length > 0 || locationThematics.length > 0); if (managementZoneThematicSelect) managementZoneThematicSelect.disabled = !managementZoneThematics.length;
if (addButton && !assetThematics.length && !locationThematics.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.disabled = true;
addButton.title = gettext('Vous n\'avez pas de droits de création'); 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 () { document.getElementById('unifiedAddAssetCategory')?.addEventListener('change', function () {
const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || ''; const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || '';
loadUnifiedAssetModels(thematicCode, this.value); loadUnifiedAssetModels(thematicCode, this.value);
@ -1175,8 +1237,12 @@ $(document).ready(function () {
activeModels.clear(); activeModels.clear();
modelFilterCollapsed = true; modelFilterCollapsed = true;
activeConditionalFilters = {}; activeConditionalFilters = {};
const allRadio = document.getElementById('type-all'); const cbA = document.getElementById('type-cb-assets');
if (allRadio) { allRadio.checked = true; currentTypeFilter = 'all'; } 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-status"]').forEach(cb => { cb.checked = false; });
document.querySelectorAll('[name="filter-validation-status"]').forEach(cb => { cb.checked = false; }); document.querySelectorAll('[name="filter-validation-status"]').forEach(cb => { cb.checked = false; });
const includeNoGeomCheckbox = document.getElementById('include-no-geom'); const includeNoGeomCheckbox = document.getElementById('include-no-geom');
@ -1860,11 +1926,13 @@ function findUnifiedRowIndex(rowRef) {
const rows = $table.bootstrapTable('getData') || []; const rows = $table.bootstrapTable('getData') || [];
const isLocation = !!rowRef.is_location; const isLocation = !!rowRef.is_location;
const isManagementZone = !!rowRef.is_management_zone;
const matches = (row) => { const matches = (row) => {
if (!!row.is_location !== isLocation) return false; 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 (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.thematic || '') !== String(rowRef.thematic || '')) return false;
if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false; if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false;
} else { } else {
@ -1878,7 +1946,7 @@ function findUnifiedRowIndex(rowRef) {
if (idx !== -1) return idx; if (idx !== -1) return idx;
// Fallback in case some rows do not expose all discriminators. // 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; return idx;
} }
@ -1953,6 +2021,37 @@ function collectClickedAssets(clickEvent) {
return Array.from(unique.values()); 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) { function collectClickedLocations(clickEvent) {
const layers = ['locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].filter(layerId => map.getLayer(layerId)); const layers = ['locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].filter(layerId => map.getLayer(layerId));
if (!layers.length) return []; 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) { function showAssetClickPopup(clickEvent) {
const assets = collectClickedAssets(clickEvent); const assets = collectClickedAssets(clickEvent);
const locations = collectClickedLocations(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 maxItems = 8;
const visibleAssets = assets.slice(0, maxItems); 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 = ` const popupHtml = `
<div style="min-width:260px; max-width:320px;"> <div style="min-width:260px; max-width:320px;">
${assetsSectionHtml} ${assetsSectionHtml}
${locationsSectionHtml} ${locationsSectionHtml}
${managementZonesSectionHtml}
</div> </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 // ── Assets: conditional loading
const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code); const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code);
const prevMode = assetsLoadModes[code] || null; const prevMode = assetsLoadModes[code] || null;
@ -2382,6 +2561,10 @@ function refreshData() {
f._thematic_name = thematicName; f._thematic_name = thematicName;
allFeatures.push(f); allFeatures.push(f);
}); });
(cache.management_zones || []).forEach(f => {
f._thematic_name = thematicName;
allFeatures.push(f);
});
}); });
renderModelFilters(); renderModelFilters();
applyFilters(); applyFilters();
@ -2406,19 +2589,27 @@ function applyFilters() {
let features = allFeatures.slice(); let features = allFeatures.slice();
// Type filter // Type filter
if (currentTypeFilter === 'locations') { const cbAssets = document.getElementById('type-cb-assets');
features = features.filter(f => f.properties && f.properties.is_location); const cbLocations = document.getElementById('type-cb-locations');
} else if (currentTypeFilter === 'assets') { const cbZones = document.getElementById('type-cb-management-zones');
features = features.filter(f => f.properties && !f.properties.is_location); 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 // Include no geom filter
const includeNoGeomCheckbox = document.getElementById('include-no-geom'); const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true; const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
if (!includeNoGeom) { if (!includeNoGeom) {
features = features.filter(f => { features = features.filter(f => {
// Locations are never filtered by geometry checkbox (only assets) // Locations and management zones are never filtered by geometry checkbox (only assets)
if (f.properties && f.properties.is_location) return true; if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
return f.geometry && (f.geometry.coordinates || f.geometry.type); 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); const selectedValidationStatuses = Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value);
if (selectedValidationStatuses.length) { if (selectedValidationStatuses.length) {
features = features.filter(f => { 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); return selectedValidationStatuses.includes(f.properties && f.properties.validation_status);
}); });
} }
@ -2443,7 +2634,7 @@ function applyFilters() {
// Category filter // Category filter
if (activeCategories.size) { if (activeCategories.size) {
features = features.filter(f => { 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)); return activeCategories.has(getFeatureCategoryValue(f));
}); });
} }
@ -2451,7 +2642,7 @@ function applyFilters() {
// Model filter // Model filter
if (activeModels.size) { if (activeModels.size) {
features = features.filter(f => { 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)); return activeModels.has(getFeatureModelValue(f));
}); });
} }
@ -2519,6 +2710,7 @@ function loadTableData(features) {
length: Number.isFinite(length) ? length : 0, length: Number.isFinite(length) ? length : 0,
area: Number.isFinite(area) ? area : 0, area: Number.isFinite(area) ? area : 0,
is_location: p.is_location, 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 locationshighlightState = { previousId: null };
var assetshighlightState = { previousId: null }; var assetshighlightState = { previousId: null };
var managementZonesHighlightState = { previousId: null };
function isPanelTextSpriteId(missingId) { function isPanelTextSpriteId(missingId) {
@ -2611,15 +2804,20 @@ function rebuildMapSources() {
if (typeof map === 'undefined') return; if (typeof map === 'undefined') return;
const locSrc = map.getSource('locations'); const locSrc = map.getSource('locations');
const asstSrc = map.getSource('assets'); const asstSrc = map.getSource('assets');
const zoneSrc = map.getSource('management_zones');
if (!locSrc || !asstSrc) return; // sources not yet added (before mapLoaded) if (!locSrc || !asstSrc) return; // sources not yet added (before mapLoaded)
mergedLocationsGeojson = { mergedLocationsGeojson = {
type: 'FeatureCollection', 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),
}; };
mergedManagementZonesGeojson = {
type: 'FeatureCollection',
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_management_zone),
};
mergedAssetsGeojson = { mergedAssetsGeojson = {
type: 'FeatureCollection', 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); applyUnifiedSignPanelOffsets(mergedAssetsGeojson);
@ -2627,6 +2825,7 @@ function rebuildMapSources() {
locSrc.setData(mergedLocationsGeojson); locSrc.setData(mergedLocationsGeojson);
asstSrc.setData(mergedAssetsGeojson); asstSrc.setData(mergedAssetsGeojson);
if (zoneSrc) zoneSrc.setData(mergedManagementZonesGeojson);
} }
@ -2642,6 +2841,7 @@ maploader.addEventListener('mapLoaded', () => {
data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/' data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/'
}); });
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); 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: [] } }); map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
// Add layers in render order: fills → lines → circles/symbols (see UNIFIED_LAYERS) // 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. // 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; if (!map.getLayer(layerId)) return;
map.on('click', layerId, showAssetClickPopup); 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('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; }); map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; });
} }
@ -2795,7 +2995,7 @@ maploader.addEventListener('mapLoaded', () => {
map.on('click', function (e) { map.on('click', function (e) {
if (!assetClickPopup || !assetClickPopup.isOpen()) return; 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)); .filter(layerId => map.getLayer(layerId));
if (!interactiveLayers.length) { if (!interactiveLayers.length) {
assetClickPopup.remove(); assetClickPopup.remove();
@ -2856,7 +3056,7 @@ function updateFilterBadge() {
}, 0); }, 0);
n += thematicBlocksCount; 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-status"]:checked').length > 0) n += 1;
if (document.querySelectorAll('[name="filter-validation-status"]:checked').length > 0) n += 1; if (document.querySelectorAll('[name="filter-validation-status"]:checked').length > 0) n += 1;

View file

@ -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) ──── // ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ────
const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer) const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer)
? AssetSymbology.getAssetsFillLayer('assets') ? AssetSymbology.getAssetsFillLayer('assets')
@ -1145,10 +1173,12 @@ const UNIFIED_LAYERS = [
_unified_territory_mask, // 0. territory mask (outside commune) _unified_territory_mask, // 0. territory mask (outside commune)
_unified_territory_outline, // 0b. territory boundary line _unified_territory_outline, // 0b. territory boundary line
_unified_locations_fill, // 1. polygon fills (bottom) _unified_locations_fill, // 1. polygon fills (bottom)
_unified_management_zones_fill, // 1b. management zones fill
_unified_assets_fill, // 2. polygon fills _unified_assets_fill, // 2. polygon fills
_unified_locations_outline, // 3. lines _unified_locations_outline, // 3. lines
_unified_assets_line, // 3b. asset lines _unified_management_zones_line, // 3b. management zones line
_unified_assets_polygon_outline, // 3c. polygon asset outlines _unified_assets_line, // 3c. asset lines
_unified_assets_polygon_outline, // 3d. polygon asset outlines
_unified_locations_circle, // 4. circles _unified_locations_circle, // 4. circles
_unified_locations_radar_outline, _unified_locations_radar_outline,
_unified_locations_radar, _unified_locations_radar,

View file

@ -670,24 +670,24 @@
<div class="filter-section-header"> <div class="filter-section-header">
<label>{% translate "Type d'élément" %}</label> <label>{% translate "Type d'élément" %}</label>
</div> </div>
<div class="segmented-control"> <div class="filter-options d-flex gap-3 flex-wrap">
<div class="filter-option segmented-option"> <div class="filter-option">
<input type="radio" name="unified-type-filter" id="type-all" value="all" <input type="checkbox" name="filter-element-type" id="type-cb-assets" value="assets"
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %} {% if preventive_element_type != 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}> {% if preventive_element_type == 'locations' %}disabled{% endif %}>
<label for="type-all">{% translate "Assets & localisations" %}</label> <label for="type-cb-assets"><i class="bi bi-layers me-1 text-primary"></i>{% translate "Assets" %}</label>
</div> </div>
<div class="filter-option segmented-option"> <div class="filter-option">
<input type="radio" name="unified-type-filter" id="type-locations" value="locations" <input type="checkbox" name="filter-element-type" id="type-cb-locations" value="locations"
{% if preventive_element_type == 'locations' %}checked{% endif %} {% if preventive_element_type != 'assets' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}> {% if preventive_element_type == 'assets' %}disabled{% endif %}>
<label for="type-locations">{% translate "Localisations uniquement" %}</label> <label for="type-cb-locations"><i class="bi bi-geo-alt me-1 text-info"></i>{% translate "Localisations" %}</label>
</div> </div>
<div class="filter-option segmented-option"> <div class="filter-option">
<input type="radio" name="unified-type-filter" id="type-assets" value="assets" <input type="checkbox" name="filter-element-type" id="type-cb-management-zones" value="management_zones"
{% if preventive_element_type == 'assets' %}checked{% endif %} {% if not preventive_element_type %}checked{% endif %}
{% if preventive_element_type %}disabled{% 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> </div>
</div> </div>
@ -837,7 +837,7 @@
{% translate "Choisissez d'abord le type d'élément, puis la thématique autorisée." %} {% translate "Choisissez d'abord le type d'élément, puis la thématique autorisée." %}
</div> </div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-4">
<div class="unified-add-panel h-100"> <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> <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> <label for="unified-add-asset-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
@ -847,7 +847,7 @@
</button> </button>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<div class="unified-add-panel h-100"> <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> <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> <label for="unified-add-location-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
@ -857,6 +857,16 @@
</button> </button>
</div> </div>
</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>
<div id="unified-add-chooser-empty" class="text-muted small mt-3 d-none"> <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." %} {% translate "Aucune thématique éditable n'est disponible pour la création." %}

View file

@ -486,3 +486,108 @@ class ManagementZonesPermissionAndCRUDTests(TestCase):
data = res.json() data = res.json()
self.assertEqual(len(data['types']), 1) self.assertEqual(len(data['types']), 1)
self.assertEqual(data['types'][0]['code'], 'fauche_eco') 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"')

View file

@ -470,6 +470,8 @@ ELEMENT_TYPE_LABELS = {
# --- Espaces plein air --- # --- Espaces plein air ---
'outdoorlocation': _('Espace public plein air'), 'outdoorlocation': _('Espace public plein air'),
'outdoorasset': _('Équipement 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 = {} result = {}
for thematic in thematics: for thematic in thematics:
code = thematic.code code = thematic.code
@ -560,6 +563,7 @@ def build_thematic_config(thematics):
entry = dict(_THEMATIC_URLS[code]) entry = dict(_THEMATIC_URLS[code])
entry['name'] = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic) entry['name'] = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic)
entry['icon'] = thematic.icon if hasattr(thematic, 'icon') else '' entry['icon'] = thematic.icon if hasattr(thematic, 'icon') else ''
entry['management_zones_geojson_url'] = management_zones_url
result[code] = entry result[code] = entry
return result return result
@ -936,6 +940,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
from assets.permissions import ( from assets.permissions import (
can_edit_location_for_thematic, can_edit_location_for_thematic,
get_allowed_actions_for_thematic, get_allowed_actions_for_thematic,
can_edit_management_zones,
) )
location_type_by_thematic = { location_type_by_thematic = {
@ -948,6 +953,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
asset_thematics = [] asset_thematics = []
location_thematics = [] location_thematics = []
management_zone_thematics = []
for thematic in accessible_thematics: for thematic in accessible_thematics:
thematic_name = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic) 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'], '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 { return {
'asset_thematics': asset_thematics, 'asset_thematics': asset_thematics,
'location_thematics': location_thematics, 'location_thematics': location_thematics,
'management_zone_thematics': management_zone_thematics,
'management_zone_create_url': reverse('assets:management_zone_create'),
} }

View file

@ -39,7 +39,7 @@ def management_zones_geojson(request):
if not thematic: if not thematic:
return JsonResponse({'type': 'FeatureCollection', 'features': []}) 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: if not can_view:
return HttpResponseForbidden("Access denied to this thematic management zones.") return HttpResponseForbidden("Access denied to this thematic management zones.")
@ -77,6 +77,18 @@ def management_zones_geojson(request):
if not geom_json: if not geom_json:
continue 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({ features.append({
'type': 'Feature', 'type': 'Feature',
'id': zone.id, 'id': zone.id,
@ -87,12 +99,22 @@ def management_zones_geojson(request):
'thematic': zone.thematic.code, 'thematic': zone.thematic.code,
'thematic_name': zone.thematic.name, 'thematic_name': zone.thematic.name,
'zone_type_code': zone.zone_type.code if zone.zone_type else None, '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(), 'color': zone.get_color(),
'instructions': zone.instructions or "", 'instructions': zone.instructions or "",
'description': zone.description or "", 'description': zone.description or "",
'seasonality_notes': zone.seasonality_notes or "", 'seasonality_notes': zone.seasonality_notes or "",
'target_frequency_days': zone.target_frequency_days, '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 'geometry': geom_json
}) })