feat: add Territory model for default map boundary, and separate daily report quick action to second row

This commit is contained in:
kdeterme 2026-09-21 10:04:39 +02:00
parent 63c888fdbd
commit e6a09eaf37
8 changed files with 362 additions and 98 deletions

View file

@ -1,4 +1,5 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.gis import admin as gis_admin
from django.urls import path, reverse from django.urls import path, reverse
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.contrib import messages from django.contrib import messages
@ -9,6 +10,7 @@ from datetime import timedelta
from django.db.models import Case, When, IntegerField, Value from django.db.models import Case, When, IntegerField, Value
from .models import ( from .models import (
MapBackground, MapBackground,
Territory,
UserConfig, UserContractAccess, Thematic, Role, UserThematics, UserConfig, UserContractAccess, Thematic, Role, UserThematics,
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess, AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
UserThematicStatusPermission, UserContractStatusPermission, UserThematicStatusPermission, UserContractStatusPermission,
@ -24,6 +26,16 @@ from contracts.models import UserCompanyTeamAccess, CompanyMember, CompanyTeam
from .admin_scheduled_tasks import ScheduledCommandAdmin, CommandExecutionLogAdmin from .admin_scheduled_tasks import ScheduledCommandAdmin, CommandExecutionLogAdmin
@admin.register(Territory)
class TerritoryAdmin(gis_admin.GISModelAdmin):
list_display = ("name_fr", "name_nl", "code", "is_default", "is_active", "updated_at")
list_filter = ("is_default", "is_active")
search_fields = ("name_fr", "name_nl", "code")
list_editable = ("is_default", "is_active")
ordering = ("-is_default", "name_fr")
@admin.register(Thematic) @admin.register(Thematic)
class ThematicAdmin(admin.ModelAdmin): class ThematicAdmin(admin.ModelAdmin):
list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract") list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract")

View file

@ -286,72 +286,113 @@ def organization_info(request):
""" """
Context processor that exposes organization details from settings. Context processor that exposes organization details from settings.
Dynamically adapts map_default_center, map_default_zoom and map_default_extent Dynamically adapts map_default_center, map_default_zoom and map_default_extent
when the authenticated user has an assigned contract associated with a municipality. when a default Territory is defined, or when the authenticated user has an
assigned contract associated with a municipality.
Caches calculated bounds in Django cache to avoid heavy GIS and SQL processing on every page. Caches calculated bounds in Django cache to avoid heavy GIS and SQL processing on every page.
""" """
center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)] center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)]
zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11) zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11)
extent = getattr(settings, 'MAP_DEFAULT_EXTENT', None) extent = getattr(settings, 'MAP_DEFAULT_EXTENT', None)
# 1. Si l'utilisateur est authentifié et a ses bornes en cache, les utiliser directement (0 requête)
user_cached = False
if hasattr(request, 'user') and request.user.is_authenticated: if hasattr(request, 'user') and request.user.is_authenticated:
cache_key = f"user_map_bounds_{request.user.pk}" cache_key = f"user_map_bounds_{request.user.pk}"
cached_bounds = cache.get(cache_key) cached_bounds = cache.get(cache_key)
if cached_bounds is not None: if cached_bounds is not None:
center = cached_bounds.get('center', center) center = cached_bounds.get('center', center)
zoom = cached_bounds.get('zoom', zoom) zoom = cached_bounds.get('zoom', zoom)
extent = cached_bounds.get('extent', extent) extent = cached_bounds.get('extent', extent)
user_cached = True
# 2. Si non présent dans le cache utilisateur et aucun extent dans settings, chercher le Territoire par défaut
if not user_cached and extent is None:
cached_ter_bounds = cache.get("default_territory_bounds")
if cached_ter_bounds is not None:
if cached_ter_bounds:
center = cached_ter_bounds.get('center', center)
zoom = cached_ter_bounds.get('zoom', zoom)
extent = cached_ter_bounds.get('extent', extent)
else: else:
try: try:
user_config = getattr(request.user, 'config', None) from .models import Territory
if user_config: def_ter = Territory.objects.filter(is_active=True, is_default=True, geom__isnull=False).first()
# Récupérer les contrats accessibles ayant des communes associées if def_ter:
contracts_to_check = [] g = def_ter.geom.transform(4326, clone=True) if def_ter.geom.srid != 4326 else def_ter.geom
if user_config.default_contract and user_config.default_contract.municipalities.exists(): min_lon, min_lat, max_lon, max_lat = g.extent
contracts_to_check.append(user_config.default_contract) delta_lon = (max_lon - min_lon) * 0.05
delta_lat = (max_lat - min_lat) * 0.05
center = [round((min_lon + max_lon) / 2.0, 5), round((min_lat + max_lat) / 2.0, 5)]
extent = [
[round(min_lon - delta_lon, 5), round(min_lat - delta_lat, 5)],
[round(max_lon + delta_lon, 5), round(max_lat + delta_lat, 5)]
]
span = max(max_lon - min_lon, max_lat - min_lat)
if span < 0.08:
zoom = 14
elif span < 0.2:
zoom = 13
elif span < 0.5:
zoom = 12
else: else:
contracts_to_check = list(user_config.contracts.prefetch_related('municipalities').filter(municipalities__isnull=False).distinct()) zoom = 11
cache.set("default_territory_bounds", {'center': center, 'zoom': zoom, 'extent': extent}, 86400)
geoms_4326 = [] else:
for c in contracts_to_check: cache.set("default_territory_bounds", {}, 86400)
for m in c.municipalities.exclude(geom=None):
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
geoms_4326.append(g)
if geoms_4326:
min_lon = min(g.extent[0] for g in geoms_4326)
min_lat = min(g.extent[1] for g in geoms_4326)
max_lon = max(g.extent[2] for g in geoms_4326)
max_lat = max(g.extent[3] for g in geoms_4326)
# Léger buffer (5% d'expansion de chaque côté pour aérer le cadre)
delta_lon = (max_lon - min_lon) * 0.05
delta_lat = (max_lat - min_lat) * 0.05
center = [round((min_lon + max_lon) / 2.0, 5), round((min_lat + max_lat) / 2.0, 5)]
extent = [
[round(min_lon - delta_lon, 5), round(min_lat - delta_lat, 5)],
[round(max_lon + delta_lon, 5), round(max_lat + delta_lat, 5)]
]
span = max(max_lon - min_lon, max_lat - min_lat)
if span < 0.08:
zoom = 14
elif span < 0.2:
zoom = 13
elif span < 0.5:
zoom = 12
else:
zoom = 11
cache.set(cache_key, {
'center': center,
'zoom': zoom,
'extent': extent,
}, 86400)
except Exception: except Exception:
pass pass
# 3. Si utilisateur authentifié sans cache, calculer et mettre en cache ses bornes
if not user_cached and hasattr(request, 'user') and request.user.is_authenticated:
cache_key = f"user_map_bounds_{request.user.pk}"
try:
user_config = getattr(request.user, 'config', None)
if user_config:
contracts_to_check = []
if user_config.default_contract and user_config.default_contract.municipalities.exists():
contracts_to_check.append(user_config.default_contract)
else:
contracts_to_check = list(user_config.contracts.prefetch_related('municipalities').filter(municipalities__isnull=False).distinct())
geoms_4326 = []
for c in contracts_to_check:
for m in c.municipalities.exclude(geom=None):
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
geoms_4326.append(g)
if geoms_4326:
min_lon = min(g.extent[0] for g in geoms_4326)
min_lat = min(g.extent[1] for g in geoms_4326)
max_lon = max(g.extent[2] for g in geoms_4326)
max_lat = max(g.extent[3] for g in geoms_4326)
delta_lon = (max_lon - min_lon) * 0.05
delta_lat = (max_lat - min_lat) * 0.05
center = [round((min_lon + max_lon) / 2.0, 5), round((min_lat + max_lat) / 2.0, 5)]
extent = [
[round(min_lon - delta_lon, 5), round(min_lat - delta_lat, 5)],
[round(max_lon + delta_lon, 5), round(max_lat + delta_lat, 5)]
]
span = max(max_lon - min_lon, max_lat - min_lat)
if span < 0.08:
zoom = 14
elif span < 0.2:
zoom = 13
elif span < 0.5:
zoom = 12
else:
zoom = 11
cache.set(cache_key, {
'center': center,
'zoom': zoom,
'extent': extent,
}, 86400)
except Exception:
pass
return { return {
'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'), 'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'),
'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'), 'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'),

View file

@ -0,0 +1,34 @@
# Generated by Django 6.0.7 on 2026-09-21 08:03
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0016_mapbackground'),
]
operations = [
migrations.CreateModel(
name='Territory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
('name_nl', models.CharField(blank=True, max_length=255, verbose_name='Nom (NL)')),
('code', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='Code')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Géométrie')),
('is_default', models.BooleanField(default=False, help_text='Si coché, ce territoire servira de zone de référence par défaut pour les cartes et masques du territoire.', verbose_name='Territoire par défaut')),
('is_active', models.BooleanField(default=True, verbose_name='Actif')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Créé le')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Modifié le')),
],
options={
'verbose_name': 'Territoire',
'verbose_name_plural': 'Territoires',
'ordering': ['-is_default', 'name_fr'],
},
),
]

View file

@ -1390,5 +1390,62 @@ class MapBackground(models.Model):
cache.delete("active_map_backgrounds") cache.delete("active_map_backgrounds")
class Territory(models.Model):
"""
Définit une zone territoriale administrative ou contractuelle (ex: Région de Bruxelles-Capitale).
Permet d'illuminer directement un polygone global sans jointure complexe de communes,
évitant ainsi les artefacts visuels de lignes intérieures.
"""
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=255, blank=True, verbose_name=_("Nom (NL)"))
code = models.CharField(max_length=50, blank=True, null=True, unique=True, verbose_name=_("Code"))
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie"))
is_default = models.BooleanField(
default=False,
verbose_name=_("Territoire par défaut"),
help_text=_("Si coché, ce territoire servira de zone de référence par défaut pour les cartes et masques du territoire.")
)
is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Modifié le"))
@property
def name(self):
lang = get_language()
if lang == 'nl':
return self.name_nl or self.name_fr
return self.name_fr or self.name_nl
objects = LanguageManager()
class Meta:
verbose_name = _("Territoire")
verbose_name_plural = _("Territoires")
ordering = ['-is_default', 'name_fr']
def __str__(self):
code_str = f" [{self.code}]" if self.code else ""
return f"{self.name}{code_str}"
def save(self, *args, **kwargs):
if self.is_default:
Territory.objects.filter(is_default=True).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
self._clear_cache()
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
self._clear_cache()
def _clear_cache(self):
from django.core.cache import cache
cache.delete(f"territory_mask_geojson_territory_{self.id}")
cache.delete(f"territory_mask_geojson_default_{self.id}")
cache.delete("territory_mask_geojson_default")
cache.delete("default_territory_bounds")
# Import des modèles de tâches planifiées # Import des modèles de tâches planifiées
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog

View file

@ -390,6 +390,11 @@ class ScheduledCommandResolveOutputFileTests(TestCase):
mock_attach.assert_called_once_with(fake_path) mock_attach.assert_called_once_with(fake_path)
mock_send.assert_called_once() mock_send.assert_called_once()
from django.test import RequestFactory from django.test import RequestFactory
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
from django.core.cache import cache from django.core.cache import cache
@ -488,3 +493,50 @@ class ContextProcessorsCachingTests(TestCase):
access.delete() access.delete()
self.assertIsNone(cache.get(cache_key)) self.assertIsNone(cache.get(cache_key))
class TerritoryTests(TestCase):
def setUp(self):
from django.contrib.gis.geos import Polygon, MultiPolygon
from common.models import Territory
# Create a simple square polygon in EPSG:3812
poly = Polygon(((650000, 670000), (660000, 670000), (660000, 680000), (650000, 680000), (650000, 670000)))
self.geom = MultiPolygon(poly, srid=3812)
self.territory = Territory.objects.create(
name_fr="Territoire Test",
name_nl="Test Gebied",
code="TEST",
geom=self.geom,
is_default=True,
is_active=True,
)
def test_single_default_territory_enforced(self):
from common.models import Territory
t2 = Territory.objects.create(
name_fr="Territoire 2",
geom=self.geom,
is_default=True,
is_active=True,
)
self.territory.refresh_from_db()
self.assertFalse(self.territory.is_default)
self.assertTrue(t2.is_default)
def test_get_territory_mask_geojson_uses_default_territory(self):
import json
from django.test import RequestFactory
from django.contrib.auth.models import AnonymousUser
from common.views import get_territory_mask_geojson
factory = RequestFactory()
req = factory.get("/common/territory-mask-geojson/")
req.user = AnonymousUser()
res = get_territory_mask_geojson(req)
self.assertEqual(res.status_code, 200)
data = json.loads(res.content.decode())
self.assertEqual(len(data["features"]), 2)
boundary_feature = next(f for f in data["features"] if f["id"] == "territory_boundary")
self.assertEqual(boundary_feature["properties"]["name"], "Territoire Test")
self.assertEqual(boundary_feature["geometry"]["type"], "MultiPolygon")

View file

@ -152,23 +152,86 @@ def get_nearest_address(request):
@require_GET @require_GET
def get_territory_mask_geojson(request): def get_territory_mask_geojson(request):
""" """
import json Renvoie le masque cartographique inversé (calque grisé transparent en dehors du territoire)
Renvoie le masque cartographique inversé (calque grisé transparent en dehors de la commune) et le tracé du contour pour mettre en évidence le territoire à gérer.
et le tracé du contour communal pour mettre en évidence le territoire à gérer. Supporte un modèle Territory par défaut (contour direct sans jointure de communes),
ou une liste de communes (filtrage par contrat/utilisateur).
""" """
import json import json
user = request.user user = request.user
muni_params = request.GET.getlist('municipality') muni_params = request.GET.getlist('municipality')
territory_param = request.GET.get('territory_id') or request.GET.get('territory')
def _build_mask_geojson(target_geom, territory_name, cache_key):
cached = cache.get(cache_key)
if cached is not None:
return JsonResponse(cached)
g = target_geom.transform(4326, clone=True) if target_geom.srid != 4326 else target_geom
try:
num_points = sum(len(ring) for poly in g for ring in poly)
except Exception:
num_points = 0
simp_geom = g.simplify(0.00003, preserve_topology=True) if num_points > 800 else g
world = Polygon.from_bbox((-180, -85, 180, 85))
try:
mask = world.difference(simp_geom)
except Exception:
mask = world.difference(simp_geom.buffer(0))
features = [
{
"type": "Feature",
"id": "territory_mask",
"properties": {
"layer": "mask",
"type": "mask",
"name": territory_name
},
"geometry": json.loads(mask.geojson)
},
{
"type": "Feature",
"id": "territory_boundary",
"properties": {
"layer": "boundary",
"type": "boundary",
"name": territory_name
},
"geometry": json.loads(simp_geom.geojson)
}
]
result = {
"type": "FeatureCollection",
"features": features
}
cache.set(cache_key, result, 86400)
return JsonResponse(result)
from common.models import Municipality, Territory
from contracts.models import Contract
from assets.permissions import get_user_accessible_municipalities
# 1. Si un territoire explicite est demandé par identifiant ou code
if territory_param:
t_filter = Q(code=territory_param)
if str(territory_param).isdigit():
t_filter |= Q(id=int(territory_param))
explicit_territory = Territory.objects.filter(t_filter, is_active=True, geom__isnull=False).first()
if explicit_territory:
return _build_mask_geojson(
explicit_territory.geom,
explicit_territory.name,
f"territory_mask_geojson_territory_{explicit_territory.id}"
)
muni_ids = [] muni_ids = []
if muni_params: if muni_params:
for p in muni_params: for p in muni_params:
if str(p).isdigit(): if str(p).isdigit():
muni_ids.append(int(p)) muni_ids.append(int(p))
from common.models import Municipality
from contracts.models import Contract
from assets.permissions import get_user_accessible_municipalities
if not muni_ids: if not muni_ids:
# Priorité 1 : contrat par défaut ou contrats de l'utilisateur # Priorité 1 : contrat par défaut ou contrats de l'utilisateur
user_config = getattr(user, 'config', None) if user and user.is_authenticated else None user_config = getattr(user, 'config', None) if user and user.is_authenticated else None
@ -179,9 +242,22 @@ def get_territory_mask_geojson(request):
elif Contract.objects.filter(municipalities__isnull=False).count() == 1: elif Contract.objects.filter(municipalities__isnull=False).count() == 1:
muni_ids = list(Contract.objects.filter(municipalities__isnull=False).first().municipalities.values_list('id', flat=True)) muni_ids = list(Contract.objects.filter(municipalities__isnull=False).first().municipalities.values_list('id', flat=True))
elif user and user.is_authenticated: elif user and user.is_authenticated:
muni_ids = list(get_user_accessible_municipalities(user).values_list('id', flat=True)) accessible = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
else: if accessible:
muni_ids = list(Municipality.objects.filter(geom__isnull=False).values_list('id', flat=True)[:1]) muni_ids = accessible
# Priorité 2 : Si aucune restriction communale n'est requise, vérifier s'il existe un Territoire par défaut
if not muni_ids:
default_territory = Territory.objects.filter(is_active=True, is_default=True, geom__isnull=False).first()
if default_territory:
return _build_mask_geojson(
default_territory.geom,
default_territory.name,
f"territory_mask_geojson_default_{default_territory.id}"
)
# Repli : Si aucun Territoire n'est défini, illuminer toutes les communes existantes
muni_ids = list(Municipality.objects.filter(geom__isnull=False).values_list('id', flat=True))
if not muni_ids: if not muni_ids:
return JsonResponse({"type": "FeatureCollection", "features": []}) return JsonResponse({"type": "FeatureCollection", "features": []})
@ -202,7 +278,6 @@ def get_territory_mask_geojson(request):
for m in munis: for m in munis:
names.append(m.name) names.append(m.name)
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
# Légère simplification (~3 mètres) pour optimiser les perfs du navigateur
g_simp = g.simplify(0.00003, preserve_topology=True) g_simp = g.simplify(0.00003, preserve_topology=True)
if combined_geom is None: if combined_geom is None:
combined_geom = g_simp combined_geom = g_simp
@ -212,44 +287,8 @@ def get_territory_mask_geojson(request):
if not combined_geom: if not combined_geom:
return JsonResponse({"type": "FeatureCollection", "features": []}) return JsonResponse({"type": "FeatureCollection", "features": []})
# Boîte englobante mondiale pour le masque inversé return _build_mask_geojson(combined_geom, ", ".join(names), cache_key)
world = Polygon.from_bbox((-180, -85, 180, 85))
try:
mask = world.difference(combined_geom)
except Exception:
mask = world.difference(combined_geom.buffer(0))
territory_name = ", ".join(names)
features = [
{
"type": "Feature",
"id": "territory_mask",
"properties": {
"layer": "mask",
"type": "mask",
"name": territory_name
},
"geometry": json.loads(mask.geojson)
},
{
"type": "Feature",
"id": "territory_boundary",
"properties": {
"layer": "boundary",
"type": "boundary",
"name": territory_name
},
"geometry": json.loads(combined_geom.geojson)
}
]
result = {
"type": "FeatureCollection",
"features": features
}
cache.set(cache_key, result, 86400)
return JsonResponse(result)

View file

@ -26,15 +26,26 @@ function statusFormatter(value) {
const mapContainer = document.getElementById("map"); const mapContainer = document.getElementById("map");
if (mapContainer && typeof maplibregl !== 'undefined') { if (mapContainer && typeof maplibregl !== 'undefined') {
const defaultCenter = (window.MAP_DEFAULT_CENTER && Array.isArray(window.MAP_DEFAULT_CENTER))
? window.MAP_DEFAULT_CENTER
: [4.3572, 50.8477];
const defaultZoom = (typeof window.MAP_DEFAULT_ZOOM === 'number')
? window.MAP_DEFAULT_ZOOM
: 11.5;
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: mapContainer, container: mapContainer,
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [4.568, 50.645], // Court-Saint-Étienne default center: defaultCenter,
zoom: 12.0, zoom: defaultZoom,
minzoom: 8, minzoom: 8,
maxzoom: 20 maxzoom: 20
}); });
if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
map.fitBounds(window.MAP_DEFAULT_EXTENT, { padding: 35, duration: 0 });
}
map.addControl(new maplibregl.NavigationControl(), 'top-left'); map.addControl(new maplibregl.NavigationControl(), 'top-left');
let currentMode = 'heatmap'; // 'heatmap' or 'points' let currentMode = 'heatmap'; // 'heatmap' or 'points'
@ -115,10 +126,26 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
duration: 1000 duration: 1000
}); });
} }
} else if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
map.fitBounds(window.MAP_DEFAULT_EXTENT, {
padding: { top: 35, bottom: 35, left: 35, right: 35 },
duration: 1000
});
} }
} else if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
map.fitBounds(window.MAP_DEFAULT_EXTENT, {
padding: { top: 35, bottom: 35, left: 35, right: 35 },
duration: 1000
});
} }
} catch (e) { } catch (e) {
console.warn("Could not load territory mask:", e); console.warn("Could not load territory mask:", e);
if (window.MAP_DEFAULT_EXTENT && Array.isArray(window.MAP_DEFAULT_EXTENT) && window.MAP_DEFAULT_EXTENT.length === 2) {
map.fitBounds(window.MAP_DEFAULT_EXTENT, {
padding: { top: 35, bottom: 35, left: 35, right: 35 },
duration: 1000
});
}
} }
// 2. Charger les données d'activité isolées par contrat // 2. Charger les données d'activité isolées par contrat

View file

@ -94,7 +94,9 @@
<span class="quick-action-text">{% translate "Nouvelle commande" %}</span> <span class="quick-action-text">{% translate "Nouvelle commande" %}</span>
</a> </a>
{% endif %} {% endif %}
</div>
{% if can_generate_daily_report %} {% if can_generate_daily_report %}
<div class="quick-actions-bar quick-actions-reports-bar mt-2">
<div class="dropdown d-inline-block"> <div class="dropdown d-inline-block">
<button class="quick-action-btn quick-action-report dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <button class="quick-action-btn quick-action-report dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<span class="quick-action-icon"><i class="bi bi-file-earmark-bar-graph"></i></span> <span class="quick-action-icon"><i class="bi bi-file-earmark-bar-graph"></i></span>
@ -118,8 +120,8 @@
</li> </li>
</ul> </ul>
</div> </div>
{% endif %}
</div> </div>
{% endif %}
</div> </div>
</div> </div>