feat: add Territory model for default map boundary, and separate daily report quick action to second row
This commit is contained in:
parent
63c888fdbd
commit
e6a09eaf37
8 changed files with 362 additions and 98 deletions
|
|
@ -1,4 +1,5 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.gis import admin as gis_admin
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.contrib import messages
|
||||
|
|
@ -9,6 +10,7 @@ from datetime import timedelta
|
|||
from django.db.models import Case, When, IntegerField, Value
|
||||
from .models import (
|
||||
MapBackground,
|
||||
Territory,
|
||||
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
|
||||
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
|
||||
UserThematicStatusPermission, UserContractStatusPermission,
|
||||
|
|
@ -24,6 +26,16 @@ from contracts.models import UserCompanyTeamAccess, CompanyMember, CompanyTeam
|
|||
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)
|
||||
class ThematicAdmin(admin.ModelAdmin):
|
||||
list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract")
|
||||
|
|
|
|||
|
|
@ -286,26 +286,68 @@ def organization_info(request):
|
|||
"""
|
||||
Context processor that exposes organization details from settings.
|
||||
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.
|
||||
"""
|
||||
center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)]
|
||||
zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11)
|
||||
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:
|
||||
cache_key = f"user_map_bounds_{request.user.pk}"
|
||||
cached_bounds = cache.get(cache_key)
|
||||
|
||||
if cached_bounds is not None:
|
||||
center = cached_bounds.get('center', center)
|
||||
zoom = cached_bounds.get('zoom', zoom)
|
||||
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:
|
||||
try:
|
||||
from .models import Territory
|
||||
def_ter = Territory.objects.filter(is_active=True, is_default=True, geom__isnull=False).first()
|
||||
if def_ter:
|
||||
g = def_ter.geom.transform(4326, clone=True) if def_ter.geom.srid != 4326 else def_ter.geom
|
||||
min_lon, min_lat, max_lon, max_lat = g.extent
|
||||
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("default_territory_bounds", {'center': center, 'zoom': zoom, 'extent': extent}, 86400)
|
||||
else:
|
||||
cache.set("default_territory_bounds", {}, 86400)
|
||||
except Exception:
|
||||
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:
|
||||
# Récupérer les contrats accessibles ayant des communes associées
|
||||
contracts_to_check = []
|
||||
if user_config.default_contract and user_config.default_contract.municipalities.exists():
|
||||
contracts_to_check.append(user_config.default_contract)
|
||||
|
|
@ -324,7 +366,6 @@ def organization_info(request):
|
|||
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
|
||||
|
||||
|
|
|
|||
34
loko/common/migrations/0017_territory.py
Normal file
34
loko/common/migrations/0017_territory.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1390,5 +1390,62 @@ class MapBackground(models.Model):
|
|||
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
|
||||
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog
|
||||
|
||||
|
|
|
|||
|
|
@ -390,6 +390,11 @@ class ScheduledCommandResolveOutputFileTests(TestCase):
|
|||
|
||||
mock_attach.assert_called_once_with(fake_path)
|
||||
mock_send.assert_called_once()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.cache import cache
|
||||
|
|
@ -488,3 +493,50 @@ class ContextProcessorsCachingTests(TestCase):
|
|||
access.delete()
|
||||
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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -152,23 +152,86 @@ def get_nearest_address(request):
|
|||
@require_GET
|
||||
def get_territory_mask_geojson(request):
|
||||
"""
|
||||
import json
|
||||
Renvoie le masque cartographique inversé (calque grisé transparent en dehors de la commune)
|
||||
et le tracé du contour communal pour mettre en évidence le territoire à gérer.
|
||||
Renvoie le masque cartographique inversé (calque grisé transparent en dehors du territoire)
|
||||
et le tracé du contour 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
|
||||
user = request.user
|
||||
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 = []
|
||||
if muni_params:
|
||||
for p in muni_params:
|
||||
if str(p).isdigit():
|
||||
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:
|
||||
# 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
|
||||
|
|
@ -179,9 +242,22 @@ def get_territory_mask_geojson(request):
|
|||
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))
|
||||
elif user and user.is_authenticated:
|
||||
muni_ids = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
|
||||
else:
|
||||
muni_ids = list(Municipality.objects.filter(geom__isnull=False).values_list('id', flat=True)[:1])
|
||||
accessible = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
|
||||
if accessible:
|
||||
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:
|
||||
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||
|
|
@ -202,7 +278,6 @@ def get_territory_mask_geojson(request):
|
|||
for m in munis:
|
||||
names.append(m.name)
|
||||
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)
|
||||
if combined_geom is None:
|
||||
combined_geom = g_simp
|
||||
|
|
@ -212,44 +287,8 @@ def get_territory_mask_geojson(request):
|
|||
if not combined_geom:
|
||||
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||
|
||||
# Boîte englobante mondiale pour le masque inversé
|
||||
world = Polygon.from_bbox((-180, -85, 180, 85))
|
||||
try:
|
||||
mask = world.difference(combined_geom)
|
||||
except Exception:
|
||||
mask = world.difference(combined_geom.buffer(0))
|
||||
return _build_mask_geojson(combined_geom, ", ".join(names), cache_key)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,15 +26,26 @@ function statusFormatter(value) {
|
|||
const mapContainer = document.getElementById("map");
|
||||
|
||||
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({
|
||||
container: mapContainer,
|
||||
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||
center: [4.568, 50.645], // Court-Saint-Étienne default
|
||||
zoom: 12.0,
|
||||
center: defaultCenter,
|
||||
zoom: defaultZoom,
|
||||
minzoom: 8,
|
||||
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');
|
||||
|
||||
let currentMode = 'heatmap'; // 'heatmap' or 'points'
|
||||
|
|
@ -115,10 +126,26 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
|
|||
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) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -94,7 +94,9 @@
|
|||
<span class="quick-action-text">{% translate "Nouvelle commande" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if can_generate_daily_report %}
|
||||
<div class="quick-actions-bar quick-actions-reports-bar mt-2">
|
||||
<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">
|
||||
<span class="quick-action-icon"><i class="bi bi-file-earmark-bar-graph"></i></span>
|
||||
|
|
@ -118,8 +120,8 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue