feat: add territory mapping layers, dashboard widgets, and asset import management commands

This commit is contained in:
kdeterme 2026-09-20 23:01:52 +02:00
parent cc0facdf92
commit 63c888fdbd
58 changed files with 4654 additions and 1107 deletions

View file

@ -0,0 +1,278 @@
import json
from pathlib import Path
from django.core.management.base import BaseCommand
from django.contrib.gis.geos import Point, Polygon, MultiPolygon
from django.db import transaction
from shapely.geometry import shape, Point as ShpPoint
from common.models import Thematic, Municipality
from assets.models import AssetCategory
from assets.models.sign import SignStreet
from assets.models.parking import ParkingLocation, ParkingSpot
class Command(BaseCommand):
help = "Import parking areas, bicycle parking stands and PMR/VE spots for Court-Saint-Étienne"
def handle(self, *args, **options):
self.stdout.write("=== Importing Parking & Bicycle Spots for Court-Saint-Étienne ===")
parking_thematic = Thematic.objects.filter(code='parking').first()
parking_cat = AssetCategory.objects.filter(code='parking_spot').first()
bike_cat = AssetCategory.objects.filter(code='bike_parking_spot').first()
cse = Municipality.objects.get(niscode='25014')
cse_geom_wgs84 = cse.geom.clone()
cse_geom_wgs84.transform(4326)
cse_poly = shape(json.loads(cse_geom_wgs84.geojson))
streets = list(SignStreet.objects.filter(geom__isnull=False))
if not streets:
self.stderr.write("No SignStreet found in database!")
return
osm_json_path = Path("/tmp/osm_cse_sign_parking.json")
if not osm_json_path.exists():
self.stderr.write(f"OSM JSON not found: {osm_json_path}")
return
with open(osm_json_path, 'r', encoding='utf-8') as f:
osm_data = json.load(f)
elements = osm_data.get('elements', [])
# Filter strictly inside CSE
in_cse = []
for el in elements:
lat = el.get('lat') or (el.get('center') and el['center'].get('lat'))
lon = el.get('lon') or (el.get('center') and el['center'].get('lon'))
if lat is not None and lon is not None:
p = ShpPoint(lon, lat)
if cse_poly.contains(p):
el['_pt_wgs84'] = Point(lon, lat, srid=4326)
in_cse.append(el)
self.stdout.write(f"Elements strictly in CSE: {len(in_cse)}")
# ─── 1. ParkingLocation (Zones de stationnement) ───────────────────
self.stdout.write("--- Step 1: ParkingLocation (Aires/Parcs) ---")
parking_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'parking']
self.stdout.write(f"Parking lots in CSE: {len(parking_elements)}")
# Existing locations
existing_loc_codes = set(ParkingLocation.objects.values_list('code', flat=True))
max_loc_num = 0
for c in existing_loc_codes:
if c and c.startswith('CSE-PKG-'):
try:
num = int(c.split('-')[-1])
if num > max_loc_num:
max_loc_num = num
except ValueError:
pass
new_locations = []
for p in parking_elements:
tags = p.get('tags', {})
pt_4326 = p['_pt_wgs84']
pt_3812 = pt_4326.transform(3812, clone=True)
closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812))
# Determine Name
osm_name = tags.get('name')
if osm_name:
name_fr = osm_name.strip()
else:
name_fr = f"Parking {closest_street.name_fr or 'public'}"
# Capacity
try:
cap = int(tags.get('capacity', 15))
except (ValueError, TypeError):
cap = 15
# Size proportional to capacity
size = max(16.0, min(65.0, (cap or 15) * 1.8))
half = size / 2.0
cx, cy = pt_3812.x, pt_3812.y
poly = Polygon([
(cx - half, cy - half),
(cx + half, cy - half),
(cx + half, cy + half),
(cx - half, cy + half),
(cx - half, cy - half),
], srid=3812)
mpoly = MultiPolygon([poly])
access = tags.get('access', 'public')
fee = tags.get('fee', 'non payant')
parking_type = tags.get('parking', 'surface')
desc = f"Aire de stationnement ({parking_type}). Accès: {access}. Tarification: {fee}. Capacité estimée: {cap} places."
max_loc_num += 1
code = f"CSE-PKG-{max_loc_num:04d}"
loc = ParkingLocation(
code=code,
name_fr=name_fr,
geom=mpoly,
description=desc,
address=f"{closest_street.name_fr or 'Court-Saint-Étienne'}, 1490 Court-Saint-Étienne",
)
# Sync
loc.sync_geom_and_geojson()
new_locations.append(loc)
with transaction.atomic():
ParkingLocation.objects.bulk_create(new_locations)
self.stdout.write(self.style.SUCCESS(f"Created {len(new_locations)} ParkingLocations. Total in DB: {ParkingLocation.objects.count()}"))
# Re-fetch all locations with spatial index
all_locations = list(ParkingLocation.objects.filter(geom__isnull=False))
# ─── 2. ParkingSpot (Vélos & Emplacements individuels) ────────────
self.stdout.write("--- Step 2: ParkingSpot (Vélos, PMR, VE) ---")
bike_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'bicycle_parking']
space_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'parking_space']
self.stdout.write(f"Bicycle parkings in CSE: {len(bike_elements)}")
self.stdout.write(f"Parking spaces in CSE: {len(space_elements)}")
max_spot_num = 0
for sc in ParkingSpot.objects.values_list('code', flat=True):
if sc:
try:
num = int(sc.split('-')[-1])
if num > max_spot_num:
max_spot_num = num
except ValueError:
pass
new_spots = []
# A. Bicycle parkings
for b in bike_elements:
tags = b.get('tags', {})
pt_4326 = b['_pt_wgs84']
pt_3812 = pt_4326.transform(3812, clone=True)
closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812))
closest_loc = min(all_locations, key=lambda l: l.geom.distance(pt_3812)) if all_locations else None
# Capacity & stands
try:
cap = int(tags.get('capacity', 6))
except (ValueError, TypeError):
cap = 6
btype_osm = (tags.get('bicycle_parking') or '').lower()
if 'box' in btype_osm or 'shed' in btype_osm or 'secure' in btype_osm:
stand_type = 'box'
stands_label = "Box / Abri sécurisé"
elif 'rack' in btype_osm or 'wall' in btype_osm:
stand_type = 'rack'
stands_label = "Râteau / Pince-roue"
else:
stand_type = 'normal'
stands_label = "Arceaux U standard"
# 3m x 2m polygon in 3812
cx, cy = pt_3812.x, pt_3812.y
poly = Polygon([
(cx - 1.5, cy - 1.0),
(cx + 1.5, cy - 1.0),
(cx + 1.5, cy + 1.0),
(cx - 1.5, cy + 1.0),
(cx - 1.5, cy - 1.0),
], srid=3812)
max_spot_num += 1
code = f"CSE-PKV-{max_spot_num:04d}"
osm_name = tags.get('name')
name_fr = osm_name if osm_name else f"Stationnement vélos ({stands_label}) - {closest_street.name_fr or 'Rue'}"
spot = ParkingSpot(
code=code,
name_fr=name_fr,
spot_type='bicycle',
stand_type=stand_type,
capacity=cap,
standard_stand_count=max(1, cap // 2),
geom=poly,
location=closest_loc,
street=closest_street,
category=bike_cat,
is_compliant=True,
)
spot.sync_geom_and_geojson()
new_spots.append(spot)
# B. Parking Spaces (PMR, EV charging, Car)
for s in space_elements:
tags = s.get('tags', {})
pt_4326 = s['_pt_wgs84']
pt_3812 = pt_4326.transform(3812, clone=True)
closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812))
closest_loc = min(all_locations, key=lambda l: l.geom.distance(pt_3812)) if all_locations else None
# Check PMR vs EV vs standard
is_pmr = (
tags.get('capacity:disabled') in ('1', 'yes') or
tags.get('disabled') in ('yes', 'designated') or
tags.get('access') == 'disabled' or
'pmr' in str(tags).lower() or
'disabled' in str(tags).lower()
)
is_ev = (
'charging' in str(tags).lower() or
tags.get('socket:type2') or
tags.get('amenity') == 'charging_station'
)
if is_pmr:
spot_type = 'pmr'
spot_label = "Emplacement PMR"
elif is_ev:
spot_type = 'ev_charging'
spot_label = "Borne recharge VE"
else:
spot_type = 'car_parking'
spot_label = "Emplacement automobile"
# 2.5m x 5m polygon
cx, cy = pt_3812.x, pt_3812.y
poly = Polygon([
(cx - 1.25, cy - 2.5),
(cx + 1.25, cy - 2.5),
(cx + 1.25, cy + 2.5),
(cx - 1.25, cy + 2.5),
(cx - 1.25, cy - 2.5),
], srid=3812)
max_spot_num += 1
code = f"CSE-PKS-{max_spot_num:04d}"
name_fr = f"{spot_label} - {closest_street.name_fr or 'Rue'}"
spot = ParkingSpot(
code=code,
name_fr=name_fr,
spot_type=spot_type,
capacity=1,
geom=poly,
location=closest_loc,
street=closest_street,
category=parking_cat,
is_compliant=True,
)
spot.sync_geom_and_geojson()
new_spots.append(spot)
with transaction.atomic():
ParkingSpot.objects.bulk_create(new_spots)
self.stdout.write(self.style.SUCCESS(f"Created {len(new_spots)} ParkingSpots (total: {ParkingSpot.objects.count()})."))

View file

@ -0,0 +1,293 @@
import json
import os
import shutil
from pathlib import Path
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.gis.geos import Point, GEOSGeometry
from django.db import transaction
from common.models import Thematic
from assets.models import AssetCategory
from assets.models.sign import SignStreet, SignPole, SignPanel, SignPanelType
BELGIAN_PANEL_TYPES = [
# Priorité
('B1', 'Cédez le passage', 'Voorrang verlenen', 'B/B1.svg'),
('B5', 'Arrêt obligatoire (Stop)', 'Stop', 'B/B5.svg'),
('B9', 'Route prioritaire', 'Voorrangsweg', 'B/B9.svg'),
('B15', 'Fin de route prioritaire', 'Einde voorrangsweg', 'B/B15.svg'),
# Danger
('A1a', 'Virage dangereux à droite', 'Gevaarlijke bocht naar rechts', 'A/A1A.svg'),
('A1b', 'Virage dangereux à gauche', 'Gevaarlijke bocht naar links', 'A/A1B.svg'),
('A7a', 'Passage pour piétons', 'Voetgangersoversteekplaats', 'A/A7A.svg'),
('A14', 'Présence d\'enfants', 'Kinderen', 'A/A14.svg'),
('A23', 'Travaux', 'Werken', 'A/A23.svg'),
('A25', 'Ralentisseur de trafic', 'Verkeersdrempel', 'A/A25.svg'),
# Interdiction
('C1', 'Sens interdit', 'Verboden richting', 'C/C1.svg'),
('C3', 'Accès interdit dans les deux sens', 'Verboden toegang in beide richtingen', 'C/C3.svg'),
('C11', 'Accès interdit aux cycles', 'Verboden toegang voor fietsers', 'C/C11.svg'),
('C43', 'Vitesse maximale limitée (50/30 km/h)', 'Maximumsnelheid', 'C/C43.svg'),
('C45', 'Fin de limitation de vitesse', 'Einde snelheidsbeperking', 'C/C45.svg'),
# Obligation
('D7', 'Piste cyclable obligatoire', 'Verplicht fietspad', 'D/D7.svg'),
('D9', 'Partage piétons / cyclistes', 'Gedeeld voetpad en fietspad', 'D/D9.svg'),
# Stationnement
('E1', 'Stationnement interdit', 'Parkeerverbod', 'E/E1.svg'),
('E3', 'Arrêt et stationnement interdits', 'Stilstand- en parkeerverbod', 'E/E3.svg'),
('E9a', 'Stationnement autorisé', 'Parkeren toegelaten', 'E/E9A.svg'),
('E9b', 'Stationnement réservé aux PMR', 'Parkeren voor personen met een handicap', 'E/E9B.svg'),
('E9c', 'Stationnement camionnettes / camions', 'Parkeren voor vrachtwagens', 'E/E9C.svg'),
('E9h', 'Stationnement véhicules électriques (recharge)', 'Parkeren voor elektrische voertuigen', 'E/E9H.svg'),
# Indications
('F4a', 'Début de zone 30', 'Begin van een zone 30', 'F/F4A.svg'),
('F4b', 'Fin de zone 30', 'Einde van een zone 30', 'F/F4B.svg'),
('F12a', 'Zone résidentielle ou de rencontre', 'Woonerf of erf', 'F/F12A.svg'),
('F19', 'Voie publique à sens unique', 'Eenrichtingsverkeer', 'F/F19.svg'),
('F45', 'Impasse', 'Doodlopende weg', 'F/F45.svg'),
('F49', 'Passage pour piétons', 'Voetgangersoversteekplaats', 'F/F49.svg'),
('F99a', 'Chemin réservé piétons / cyclistes', 'Weg voorbehouden voor voetgangers en fietsers', 'F/F99A.svg'),
('F101a', 'Fin de chemin réservé', 'Einde weg voorbehouden', 'F/F101A.svg'),
]
class Command(BaseCommand):
help = "Import signage (PICC poles and road panels) for Court-Saint-Étienne"
def handle(self, *args, **options):
self.stdout.write("=== Importing Signage for Court-Saint-Étienne ===")
# 1. SignPanelType catalog
self.stdout.write("--- Step 1: SignPanelType catalog ---")
media_target_dir = Path(settings.MEDIA_ROOT) / "sign" / "signpanels_types"
media_target_dir.mkdir(parents=True, exist_ok=True)
static_road_signs = Path(settings.BASE_DIR) / "assets" / "static" / "assets" / "road_signs"
if not static_road_signs.exists():
# Fallback check
static_road_signs = Path("/app/static/assets/road_signs")
created_types = 0
for code, name_fr, name_nl, rel_svg in BELGIAN_PANEL_TYPES:
pt, created = SignPanelType.objects.get_or_create(
code=code,
defaults={
'name_fr': name_fr,
'name_nl': name_nl,
'default_width': 50.0,
'default_height': 50.0,
}
)
# Copy SVG icon if exists
svg_source = static_road_signs / rel_svg
if svg_source.exists():
dst_name = f"{code.lower()}.svg"
dst_path = media_target_dir / dst_name
if not dst_path.exists():
shutil.copyfile(svg_source, dst_path)
pt.sgv_picture = f"sign/signpanels_types/{dst_name}"
pt.save(update_fields=['sgv_picture'])
if created:
created_types += 1
self.stdout.write(f"SignPanelTypes ensured: {SignPanelType.objects.count()} (created {created_types})")
# 2. Import PICC Poles
self.stdout.write("--- Step 2: PICC Poles ---")
sign_thematic = Thematic.objects.filter(code='sign').first()
pole_category = AssetCategory.objects.filter(code='SI_POLE').first()
streets = list(SignStreet.objects.filter(geom__isnull=False))
if not streets:
self.stderr.write("No SignStreet found in database!")
return
# Preload existing poles to avoid duplicates (< 1.5m)
existing_poles = list(SignPole.objects.all().values('id', 'code', 'geom'))
self.stdout.write(f"Existing poles in DB: {len(existing_poles)}")
# Find highest code number
max_num = 0
for ep in existing_poles:
c = ep.get('code') or ''
if c.startswith('CSE-SGP-'):
try:
num = int(c.split('-')[-1])
if num > max_num:
max_num = num
except ValueError:
pass
geojson_path = Path("/tmp/cse_picc_poles.geojson")
if not geojson_path.exists():
self.stderr.write(f"GeoJSON not found: {geojson_path}")
return
with open(geojson_path, 'r', encoding='utf-8') as f:
data = json.load(f)
features = data.get('features', [])
self.stdout.write(f"PICC poles in GeoJSON: {len(features)}")
# Street poles counters for names
street_counters = {}
for s in streets:
street_counters[s.id] = SignPole.objects.filter(street=s).count()
new_poles = []
skipped_existing = 0
for feat in features:
coords = feat['geometry']['coordinates']
x, y = coords[0], coords[1]
pt = Point(x, y, srid=3812)
# Check if existing pole is close (< 1.5m)
is_duplicate = False
for ep in existing_poles:
eg = ep.get('geom')
if eg and eg.distance(pt) < 1.5:
is_duplicate = True
break
if is_duplicate:
skipped_existing += 1
continue
# Find closest street
closest_street = min(streets, key=lambda st: st.geom.distance(pt))
street_counters[closest_street.id] = street_counters.get(closest_street.id, 0) + 1
street_num = street_counters[closest_street.id]
max_num += 1
code = f"CSE-SGP-{max_num:04d}"
name = f"Poteau signalisation - {closest_street.name_fr or 'Rue'} #{street_num}"
pole = SignPole(
code=code,
name_fr=name,
geom=pt,
street=closest_street,
category=pole_category,
status='active',
orientation=90
)
# Sync lon, lat, geojson
pole.sync_geom_and_geojson()
new_poles.append(pole)
# Add to local cache for duplicate checking
existing_poles.append({'id': None, 'code': code, 'geom': pt})
if new_poles:
with transaction.atomic():
SignPole.objects.bulk_create(new_poles, batch_size=500)
self.stdout.write(self.style.SUCCESS(f"Created {len(new_poles)} new SignPoles (skipped {skipped_existing} existing). Total in DB: {SignPole.objects.count()}"))
else:
self.stdout.write(f"No new poles created (skipped {skipped_existing} duplicates).")
# 3. Import Panels from OSM
self.stdout.write("--- Step 3: SignPanels from OSM ---")
panel_category = AssetCategory.objects.filter(code='SI_PANEL').first()
osm_json_path = Path("/tmp/osm_cse_sign_parking.json")
if not osm_json_path.exists():
self.stderr.write(f"OSM JSON not found: {osm_json_path}")
return
with open(osm_json_path, 'r', encoding='utf-8') as f:
osm_data = json.load(f)
osm_elements = osm_data.get('elements', [])
osm_signs = [
e for e in osm_elements
if 'traffic_sign' in e.get('tags', {}) or e.get('tags', {}).get('highway') in ('give_way', 'stop')
]
self.stdout.write(f"OSM signs found in dataset: {len(osm_signs)}")
all_poles = list(SignPole.objects.select_related('street').all())
type_b1 = SignPanelType.objects.filter(code='B1').first()
type_b5 = SignPanelType.objects.filter(code='B5').first()
type_f99a = SignPanelType.objects.filter(code='F99a').first()
max_panel_num = 0
for sp in SignPanel.objects.all().values_list('code', flat=True):
if sp and sp.startswith('CSE-PAN-'):
try:
num = int(sp.split('-')[-1])
if num > max_panel_num:
max_panel_num = num
except ValueError:
pass
panels_created = 0
for s in osm_signs:
tags = s.get('tags', {})
lat = s.get('lat') or (s.get('center') and s['center'].get('lat'))
lon = s.get('lon') or (s.get('center') and s['center'].get('lon'))
if lat is None or lon is None:
continue
pt_4326 = Point(lon, lat, srid=4326)
pt_3812 = pt_4326.transform(3812, clone=True)
# Determine SignPanelType
sig = tags.get('traffic_sign') or ''
hw = tags.get('highway') or ''
panel_type = None
if 'B1' in sig or hw == 'give_way':
panel_type = type_b1
elif 'B5' in sig or hw == 'stop':
panel_type = type_b5
elif 'F99a' in sig:
panel_type = type_f99a
else:
# Try to extract code from string like "BE:B1", "BE:C43"
clean_code = sig.replace('BE:', '').split(';')[0].strip()
panel_type = SignPanelType.objects.filter(code__iexact=clean_code).first()
if not panel_type:
panel_type = type_b1 # default fallback
# Find nearest pole within 30 meters
nearest_pole = None
min_dist = float('inf')
for p in all_poles:
d = p.geom.distance(pt_3812)
if d < min_dist:
min_dist = d
nearest_pole = p
if not nearest_pole or min_dist > 35.0:
# If no pole close enough, attach to closest pole anyway or skip
if not nearest_pole:
continue
max_panel_num += 1
pcode = f"CSE-PAN-{max_panel_num:04d}"
pname = f"Panneau {panel_type.code} - {nearest_pole.street.name_fr if nearest_pole.street else 'Rue'}"
panel = SignPanel(
code=pcode,
name_fr=pname,
pole=nearest_pole,
street=nearest_pole.street,
signpanel_type=panel_type,
category=panel_category,
status='active',
orientation=nearest_pole.orientation or 90,
support_type='pole',
geom=nearest_pole.geom,
)
panel.sync_geom_and_geojson()
panel.save()
panels_created += 1
self.stdout.write(self.style.SUCCESS(f"Created {panels_created} SignPanels attached to poles. Total panels: {SignPanel.objects.count()}"))

View file

@ -244,6 +244,15 @@
glyphCodepoint: 'f5e1', glyphCodepoint: 'f5e1',
mapImage: 'icon-naturebench', mapImage: 'icon-naturebench',
}, },
natureplanter: {
thematic: 'nature',
color: '#d97706',
iconUrl: '/static/assets/nature_planter_icon.svg',
iconSize: 38,
iconClass: 'bi-flower2',
glyphCodepoint: 'f3ce',
mapImage: 'icon-natureplanter',
},
naturerwiasset: { naturerwiasset: {
thematic: 'nature', thematic: 'nature',
color: '#0277bd', color: '#0277bd',
@ -345,11 +354,21 @@
}, },
roadfurniture: { roadfurniture: {
thematic: 'roads', thematic: 'roads',
color: '#607d8b', color: '#e67700',
iconUrl: null, iconUrl: '/static/assets/road_speedbump_icon.svg?v=20260920',
iconClass: 'bi-map-fill', iconSize: 44,
glyphCodepoint: 'f47e', iconClass: 'bi-triangle-half',
mapImage: 'icon-roads', glyphCodepoint: 'f66d',
mapImage: 'icon-speedbump',
},
streetfurniture: {
thematic: 'roads',
color: '#e67700',
iconUrl: '/static/assets/road_speedbump_icon.svg?v=20260920',
iconSize: 44,
iconClass: 'bi-triangle-half',
glyphCodepoint: 'f66d',
mapImage: 'icon-speedbump',
}, },
// Clean // Clean
@ -363,11 +382,12 @@
}, },
cleanglasscontainer: { cleanglasscontainer: {
thematic: 'clean', thematic: 'clean',
color: '#5e5e5e', color: '#16a34a',
iconUrl: null, iconUrl: '/static/assets/clean_glass_container_icon.svg?v=20260920',
iconClass: 'bi-trash3-fill', iconSize: 46,
iconClass: 'bi-recycle',
glyphCodepoint: 'f78a', glyphCodepoint: 'f78a',
mapImage: 'icon-clean', mapImage: 'icon-cleanglasscontainer',
}, },
// Artworks // Artworks
@ -531,6 +551,14 @@
} }
// 3. Specific asset_type override // 3. Specific asset_type override
if (assetType === 'naturebench') {
const isPlanter = (item.asset_sub_type === 'planter') ||
['Bac à fleurs / Jardinière', 'Bloembak', 'Jardinière', 'Bac à fleurs', 'Bac à plantes'].includes(item.model) ||
(typeof item.code === 'string' && item.code.startsWith('CSE-BAC'));
if (isPlanter && ASSET_TYPE_OVERRIDES.natureplanter) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.natureplanter);
}
}
if (assetType && ASSET_TYPE_OVERRIDES[assetType]) { if (assetType && ASSET_TYPE_OVERRIDES[assetType]) {
return Object.assign({}, ASSET_TYPE_OVERRIDES[assetType]); return Object.assign({}, ASSET_TYPE_OVERRIDES[assetType]);
} }
@ -602,6 +630,7 @@
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl, roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl, roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
naturebench: injected.naturebench || ASSET_TYPE_OVERRIDES.naturebench.iconUrl, naturebench: injected.naturebench || ASSET_TYPE_OVERRIDES.naturebench.iconUrl,
natureplanter: injected.natureplanter || (ASSET_TYPE_OVERRIDES.natureplanter && ASSET_TYPE_OVERRIDES.natureplanter.iconUrl),
artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl, artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl,
artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl, artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl,
}; };
@ -622,11 +651,13 @@
{ name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' }, { name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' },
{ name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' }, { name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' },
{ name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' }, { name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' },
{ name: 'icon-natureplanter', codepoint: 'f3ce', color: '#d97706' },
{ name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' }, { name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' },
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' }, { name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' },
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' }, { name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' },
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' }, { name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' },
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' }, { name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' },
{ name: 'icon-cleanglasscontainer', codepoint: 'f78a', color: '#16a34a' },
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' }, { name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' },
{ name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' }, { name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' },
{ name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' }, { name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' },
@ -638,6 +669,7 @@
{ name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' }, { name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' },
{ name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' }, { name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' },
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, { name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' },
{ name: 'icon-speedbump', codepoint: 'f66d', color: '#e67700' },
{ name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' }, { name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' },
{ name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' }, { name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' },
{ name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' }, { name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' },
@ -695,11 +727,26 @@
['==', ['get', 'asset_type'], 'trafficlightnetworkhardware'], 'icon-trafficlightsequipment', ['==', ['get', 'asset_type'], 'trafficlightnetworkhardware'], 'icon-trafficlightsequipment',
['==', ['get', 'asset_type'], 'trafficlightvoltagehardware'], 'icon-trafficlightsequipment', ['==', ['get', 'asset_type'], 'trafficlightvoltagehardware'], 'icon-trafficlightsequipment',
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], 'icon-publiclightingelectricalcabinet', ['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], 'icon-publiclightingelectricalcabinet',
['==', ['get', 'asset_type'], 'cleanglasscontainer'], 'icon-cleanglasscontainer',
['==', ['get', 'category'], 'Bulle'], 'icon-cleanglasscontainer',
['==', ['get', 'category'], 'Bulle à verre'], 'icon-cleanglasscontainer',
['==', ['get', 'asset_type'], 'artworkfountain'], 'icon-artworkfountain', ['==', ['get', 'asset_type'], 'artworkfountain'], 'icon-artworkfountain',
['==', ['get', 'asset_type'], 'artworkmonument'], 'icon-artworkmonument', ['==', ['get', 'asset_type'], 'artworkmonument'], 'icon-artworkmonument',
['any',
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
], 'icon-natureplanter',
['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench', ['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench',
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain', ['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain', ['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump',
['==', ['get', 'asset_type'], 'streetfurniture'], 'icon-speedbump',
['==', ['get', 'asset_type'], 'roadfurniture'], 'icon-speedbump',
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset', ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset', ['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone', ['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone',
@ -772,12 +819,24 @@
['coalesce', ['get', 'color'], '#2e7d32'], ['coalesce', ['get', 'color'], '#2e7d32'],
['==', ['get', 'thematic'], 'parking'], ['==', ['get', 'thematic'], 'parking'],
getParkingColorExpr(), getParkingColorExpr(),
['==', ['get', 'thematic'], 'roads'],
[
'match',
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#e03131',
['2', '2 - Mauvais'], '#f76707',
['3', '3 - Bon'], '#fcc419',
['4', '4 - Très bon'], '#74b816',
['5', '5 - Excellent'], '#2f9e44',
'#4a4a4a'
],
getThematicColorExpr() getThematicColorExpr()
], ],
'fill-opacity': [ 'fill-opacity': [
'case', 'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8, ['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8,
['==', ['get', 'thematic'], 'parking'], 0.38, ['==', ['get', 'thematic'], 'parking'], 0.38,
['==', ['get', 'thematic'], 'roads'], 0.28,
0.65 0.65
], ],
'fill-antialias': false, 'fill-antialias': false,
@ -811,14 +870,30 @@
['coalesce', ['get', 'color'], '#1b5e20'], ['coalesce', ['get', 'color'], '#1b5e20'],
['==', ['get', 'thematic'], 'parking'], ['==', ['get', 'thematic'], 'parking'],
getParkingColorExpr(), getParkingColorExpr(),
['==', ['get', 'thematic'], 'roads'],
[
'match',
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#c92a2a',
['2', '2 - Mauvais'], '#e8590c',
['3', '3 - Bon'], '#f59f00',
['4', '4 - Très bon'], '#66a80f',
['5', '5 - Excellent'], '#2b8a3e',
'#4a4a4a'
],
getThematicColorExpr() getThematicColorExpr()
], ],
'line-width': [ 'line-width': [
'case', 'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4, ['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4,
['==', ['get', 'thematic'], 'roads'], 1.5,
2 2
], ],
'line-opacity': 0.95, 'line-opacity': [
'case',
['==', ['get', 'thematic'], 'roads'], 0.65,
0.95
],
}, },
minzoom: 8, minzoom: 8,
}; };

View file

@ -3,16 +3,16 @@
} }
.intervention-btn { .intervention-btn {
color: var(--brand-color); color: #0f172a;
margin-right: 4px; margin-right: 4px;
} }
.intersection-btn-toggle{ .intersection-btn-toggle{
color: var(--brand-color); color: #0f172a;
} }
.separator-top { .separator-top {
border-top: 1px solid var(--brand-color); border-top: 1px solid #e2e8f0;
margin-top: 2rem; /* optionnel */ margin-top: 2rem; /* optionnel */
padding-top: 2rem; /* optionnel */ padding-top: 2rem; /* optionnel */
} }

View file

@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="glassDome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#22c55e"/>
<stop offset="40%" stop-color="#16a34a"/>
<stop offset="100%" stop-color="#15803d"/>
</linearGradient>
<linearGradient id="glassBase" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#14532d"/>
<stop offset="100%" stop-color="#052e16"/>
</linearGradient>
<filter id="glassShadow" x="-15%" y="-15%" width="130%" height="135%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.35"/>
</filter>
</defs>
<!-- Top crane lifting ring (anneau de préhension) -->
<circle cx="32" cy="11" r="5.5" fill="none" stroke="#475569" stroke-width="3" filter="url(#glassShadow)"/>
<rect x="29.5" y="14" width="5" height="4" fill="#334155"/>
<!-- Base rim / skirt -->
<rect x="8" y="51" width="48" height="6" rx="2.5" fill="url(#glassBase)" filter="url(#glassShadow)"/>
<!-- Main Igloo / Bell Dome Body -->
<path d="M 10 51 C 10 27, 19 16, 32 16 C 45 16, 54 27, 54 51 Z"
fill="url(#glassDome)" stroke="#166534" stroke-width="1.5" filter="url(#glassShadow)"/>
<!-- Dome highlight curve -->
<path d="M 14 49 C 14 29, 21 19, 32 19" fill="none" stroke="#86efac" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
<!-- Left bottle drop aperture (orifice bouteilles) -->
<circle cx="21" cy="36" r="6" fill="#0f172a" stroke="#f1f5f9" stroke-width="1.8"/>
<circle cx="21" cy="36" r="4" fill="#020617"/>
<!-- Right bottle drop aperture (orifice bouteilles) -->
<circle cx="43" cy="36" r="6" fill="#0f172a" stroke="#f1f5f9" stroke-width="1.8"/>
<circle cx="43" cy="36" r="4" fill="#020617"/>
<!-- Center white glass bottle emblem -->
<!-- Cap / lip -->
<rect x="30" y="27" width="4" height="1.5" rx="0.5" fill="#ffffff"/>
<!-- Neck -->
<rect x="30.5" y="28.5" width="3" height="5" rx="0.5" fill="#ffffff"/>
<!-- Shoulder & Body -->
<path d="M 30.5 33.5 C 28.5 35.5, 28 38, 28 40 L 28 46 C 28 47.5, 36 47.5, 36 46 L 36 40 C 36 38, 35.5 35.5, 33.5 33.5 Z" fill="#ffffff"/>
<!-- Bottle shine -->
<line x1="30" y1="38" x2="30" y2="44" stroke="#86efac" stroke-width="1" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="planterWood" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ea580c"/>
<stop offset="100%" stop-color="#9a3412"/>
</linearGradient>
<linearGradient id="leafGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4ade80"/>
<stop offset="100%" stop-color="#15803d"/>
</linearGradient>
<linearGradient id="flowerPink" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fb7185"/>
<stop offset="100%" stop-color="#e11d48"/>
</linearGradient>
</defs>
<!-- Foliage / Leaves -->
<!-- Left leaf -->
<path d="M 32 34 C 24 32, 14 26, 15 17 C 23 17, 28 24, 32 34 Z" fill="url(#leafGrad)"/>
<!-- Right leaf -->
<path d="M 32 34 C 40 32, 50 26, 49 17 C 41 17, 36 24, 32 34 Z" fill="url(#leafGrad)"/>
<!-- Center tall leaves -->
<path d="M 32 34 C 28 26, 26 15, 32 8 C 38 15, 36 26, 32 34 Z" fill="#22c55e"/>
<!-- Side drooping leaves -->
<path d="M 32 35 C 20 35, 10 33, 9 27 C 16 26, 25 29, 32 35 Z" fill="#16a34a"/>
<path d="M 32 35 C 44 35, 54 33, 55 27 C 48 26, 39 29, 32 35 Z" fill="#16a34a"/>
<!-- Flowers -->
<!-- Left flower -->
<circle cx="21" cy="21" r="5" fill="url(#flowerPink)"/>
<circle cx="21" cy="21" r="2" fill="#fef08a"/>
<!-- Right flower -->
<circle cx="43" cy="21" r="5" fill="#f59e0b"/>
<circle cx="43" cy="21" r="2" fill="#fffbeb"/>
<!-- Center top flower -->
<circle cx="32" cy="14" r="6" fill="url(#flowerPink)"/>
<circle cx="32" cy="14" r="2.5" fill="#fef08a"/>
<!-- Planter Box (Bac / Jardinière) -->
<!-- Rim -->
<rect x="11" y="34" width="42" height="5" rx="2" fill="#78350f"/>
<rect x="12" y="35" width="40" height="3" rx="1.5" fill="#ea580c"/>
<!-- Body -->
<polygon points="14,39 50,39 46,55 18,55" fill="url(#planterWood)"/>
<!-- Wooden slats / horizontal grooves -->
<line x1="15" y1="44" x2="49" y2="44" stroke="#78350f" stroke-width="1.2" stroke-linecap="round"/>
<line x1="16.5" y1="49" x2="47.5" y2="49" stroke="#78350f" stroke-width="1.2" stroke-linecap="round"/>
<!-- Corner metal brackets / reinforcements -->
<line x1="14" y1="39" x2="18" y2="55" stroke="#475569" stroke-width="2" stroke-linecap="round"/>
<line x1="50" y1="39" x2="46" y2="55" stroke="#475569" stroke-width="2" stroke-linecap="round"/>
<!-- Feet / Stand -->
<rect x="19" y="55" width="5" height="4" rx="1" fill="#334155"/>
<rect x="40" y="55" width="5" height="4" rx="1" fill="#334155"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,38 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="sbBody" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fb923c"/>
<stop offset="30%" stop-color="#f97316"/>
<stop offset="100%" stop-color="#c2410c"/>
</linearGradient>
<linearGradient id="sbPlateau" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fdba74"/>
<stop offset="100%" stop-color="#fb923c"/>
</linearGradient>
<filter id="sbShadow" x="-15%" y="-15%" width="130%" height="135%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<!-- Drop shadow and dark road border -->
<path d="M 5 48 L 15 17 C 15.5 15.5 17 14.5 18.5 14.5 L 45.5 14.5 C 47 14.5 48.5 15.5 49 17 L 59 48 C 59.5 49.8 58.2 51.5 56.5 51.5 L 7.5 51.5 C 5.8 51.5 4.5 49.8 5 48 Z"
fill="#7c2d12" filter="url(#sbShadow)"/>
<!-- Main Speed Cushion Body (Coussin berlinois) -->
<path d="M 7 47 L 16 18 C 16.5 16.5 17.5 16 19 16 L 45 16 C 46.5 16 47.5 16.5 48 18 L 57 47 C 57.5 48.5 56.5 49.5 55 49.5 L 9 49.5 C 7.5 49.5 6.5 48.5 7 47 Z"
fill="url(#sbBody)"/>
<!-- Lower bevel lip -->
<polygon points="7,47 9.5,50 54.5,50 57,47" fill="#9a3412"/>
<!-- Upper Plateau -->
<polygon points="20,27 22,18 42,18 44,27" fill="url(#sbPlateau)" opacity="0.9"/>
<!-- 3 Reflective White Triangles (Universal Speed Cushion Markings) -->
<polygon points="12,45 20,24 28,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
<polygon points="24,45 32,24 40,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
<polygon points="36,45 44,24 52,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
<!-- Top ridge highlight -->
<line x1="19" y1="16" x2="45" y2="16" stroke="#fed7aa" stroke-width="1.8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -510,34 +510,66 @@ function zoomFormatter(value, row) {
function buildColumns() { function buildColumns() {
return [ return [
{ field: 'check', checkbox: true, width: 36 }, { field: 'check', checkbox: true, width: 36, widthUnit: 'px', align: 'center', valign: 'middle' },
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 }, { field: 'actions', title: '', formatter: zoomFormatter, width: 44, widthUnit: 'px', align: 'center', valign: 'middle' },
{ {
field: 'code', title: gettext('Code'), sortable: true, field: 'code', title: gettext('Code'), sortable: true, width: 135, widthUnit: 'px', valign: 'middle',
formatter: function (v, row) { formatter: function (v, row) {
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`; if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${escapeHtml(v || '-')}</a>`;
return v || '-'; return `<span class="unified-code-link">${escapeHtml(v || '-')}</span>`;
} }
}, },
{ field: 'name', title: gettext('Nom'), sortable: true },
{ field: 'element_type', title: gettext('Type'), sortable: true },
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true },
{ field: 'category', title: gettext('Catégorie'), sortable: true, visible: false },
{ field: 'model', title: gettext('Modèle'), sortable: true, visible: false },
{ {
field: 'status', title: gettext('Statut'), sortable: true, visible: false, field: 'name', title: gettext('Nom'), sortable: true, minWidth: 220, valign: 'middle',
formatter: function (v) {
return `<span class="unified-row-name">${escapeHtml(v || '-')}</span>`;
}
},
{
field: 'element_type', title: gettext('Type'), sortable: true, width: 130, widthUnit: 'px', valign: 'middle',
formatter: function (v, row) {
const table = $('#unified-table');
let isCatVisible = false;
try {
isCatVisible = table.length && typeof table.bootstrapTable === 'function' &&
table.bootstrapTable('getVisibleColumns').some(c => c.field === 'category');
} catch (e) {}
if (!isCatVisible && row && row.is_location && row.category) {
return `${escapeHtml(v || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`;
}
return escapeHtml(v || '-');
}
},
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true, width: 140, widthUnit: 'px', valign: 'middle' },
{
field: 'category', title: gettext('Catégorie d\'assets'), sortable: true, width: 175, widthUnit: 'px', visible: false, valign: 'middle',
formatter: function (v, row) {
if (!v) return '-';
if (row && row.color && (row.thematic === 'publicbuildings' || row.thematic === 'buildings')) {
return `<span class="d-inline-flex align-items-center" style="gap:6px;"><span style="display:inline-block; width:9px; height:9px; border-radius:50%; background-color:${escapeHtml(row.color)}; flex-shrink:0; box-shadow:0 0 0 1px rgba(0,0,0,0.12);"></span><span class="unified-category-cell">${escapeHtml(v)}</span></span>`;
}
return `<span class="unified-category-cell">${escapeHtml(v)}</span>`;
}
},
{ field: 'model', title: gettext('Modèle'), sortable: true, width: 140, widthUnit: 'px', visible: false, valign: 'middle' },
{
field: 'status', title: gettext('Statut'), sortable: true, width: 110, widthUnit: 'px', visible: false, valign: 'middle',
formatter: function (v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; } formatter: function (v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; }
}, },
{ {
field: 'validation_status', title: gettext('Statut de validation'), sortable: true, visible: true, field: 'validation_status', title: gettext('Statut de validation'), sortable: true, width: 135, widthUnit: 'px', visible: true, valign: 'middle',
formatter: function (v) { formatter: function (v) {
const labels = { const labels = {
'to_be_validated': gettext('À valider'), 'to_be_validated': { text: gettext('À valider'), cls: 'bg-warning-subtle text-warning-emphasis border-warning-subtle' },
'validated': gettext('Validé'), 'validated': { text: gettext('Validé'), cls: 'bg-success-subtle text-success border-success-subtle' },
'to_be_completed': gettext('À compléter'), 'to_be_completed': { text: gettext('À compléter'), cls: 'bg-info-subtle text-info-emphasis border-info-subtle' },
'rejected': gettext('Rejeté') 'rejected': { text: gettext('Rejeté'), cls: 'bg-danger-subtle text-danger border-danger-subtle' }
}; };
return labels[v] || v || '-'; const item = labels[v];
if (item) {
return `<span class="badge ${item.cls} border fw-medium" style="font-size: 0.74rem;">${item.text}</span>`;
}
return v || '-';
} }
}, },
]; ];
@ -597,7 +629,18 @@ function applyVisibleColumnsPreference(visibleColumns) {
* explicit thematic selection (GET params / locked_thematic_code). * explicit thematic selection (GET params / locked_thematic_code).
*/ */
function applyInitialPreferences() { function applyInitialPreferences() {
if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE || (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS)) return; if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE) return;
if (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS) {
if (!activeCategories || activeCategories.size === 0) {
getVisibleCategoryOptions().forEach(category => {
if (activeThematics.has(category.thematicCode)) {
activeCategories.add(category.value);
}
});
}
return;
}
if (ASSETS_INDEX_PREFS && typeof ASSETS_INDEX_PREFS === 'object' && Object.keys(ASSETS_INDEX_PREFS).length > 0) { if (ASSETS_INDEX_PREFS && typeof ASSETS_INDEX_PREFS === 'object' && Object.keys(ASSETS_INDEX_PREFS).length > 0) {
const prefs = ASSETS_INDEX_PREFS; const prefs = ASSETS_INDEX_PREFS;
@ -722,7 +765,7 @@ $(document).ready(function () {
} }
function calcHeight() { function calcHeight() {
return Math.max(300, $(window).height() - 210); return Math.max(300, $(window).height() - 250);
} }
$('#unified-table').bootstrapTable({ $('#unified-table').bootstrapTable({
@ -761,8 +804,13 @@ $(document).ready(function () {
document.querySelectorAll('.thematic-checkbox').forEach(cb => { document.querySelectorAll('.thematic-checkbox').forEach(cb => {
cb.addEventListener('change', function () { cb.addEventListener('change', function () {
if (this.checked) activeThematics.add(this.value); if (this.checked) {
else activeThematics.delete(this.value); activeThematics.add(this.value);
const newCats = THEMATIC_CATEGORIES[this.value] || [];
newCats.forEach(c => activeCategories.add(`${this.value}::${c.name || ''}`));
} else {
activeThematics.delete(this.value);
}
renderCategoryFilters(); renderCategoryFilters();
renderModelFilters(); renderModelFilters();
renderConditionalFilters(); renderConditionalFilters();
@ -774,6 +822,7 @@ $(document).ready(function () {
cb.checked = true; cb.checked = true;
activeThematics.add(cb.value); activeThematics.add(cb.value);
}); });
getVisibleCategoryOptions().forEach(category => activeCategories.add(category.value));
renderCategoryFilters(); renderCategoryFilters();
renderModelFilters(); renderModelFilters();
renderConditionalFilters(); renderConditionalFilters();
@ -1271,6 +1320,10 @@ function hasTriggerFilter() {
if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true; if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true;
if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true; if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true;
if (outdoorFilters.pmr_accessible) return true; if (outdoorFilters.pmr_accessible) return true;
const publicbuildingsFilters = activeConditionalFilters.publicbuildings || activeConditionalFilters.buildings || {};
if (Array.isArray(publicbuildingsFilters.usage_type) && publicbuildingsFilters.usage_type.length) return true;
if (publicbuildingsFilters.is_main_location) return true;
if (publicbuildingsFilters.is_erp) return true;
return false; return false;
} }
@ -1353,6 +1406,17 @@ function buildLocationsGeojsonPostBody(thematicCode) {
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location; if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
} }
if (['publicbuildings', 'buildings'].includes(thematicCode)) {
if (Array.isArray(thematicFilters.usage_type) && thematicFilters.usage_type.length) {
payload.usage_type = thematicFilters.usage_type;
}
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
if (thematicFilters.is_erp) payload.is_erp = thematicFilters.is_erp;
if (Array.isArray(thematicFilters.location_type) && thematicFilters.location_type.length) {
payload.location_type = thematicFilters.location_type;
}
}
if (thematicCode === 'structures') { if (thematicCode === 'structures') {
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) { if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
payload.structure_type = thematicFilters.structure_type; payload.structure_type = thematicFilters.structure_type;
@ -1426,6 +1490,17 @@ function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
payload.asset_begin_date = thematicFilters.asset_begin_date; payload.asset_begin_date = thematicFilters.asset_begin_date;
} }
} }
if (['publicbuildings', 'buildings'].includes(thematicCode)) {
if (Array.isArray(thematicFilters.usage_type) && thematicFilters.usage_type.length) {
payload.usage_type = thematicFilters.usage_type;
}
if (thematicFilters.is_main_location) {
payload.is_main_location = thematicFilters.is_main_location;
}
if (thematicFilters.is_erp) {
payload.is_erp = thematicFilters.is_erp;
}
}
if (thematicCode === 'structures') { if (thematicCode === 'structures') {
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) { if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
payload.structure_type = thematicFilters.structure_type; payload.structure_type = thematicFilters.structure_type;
@ -1898,6 +1973,7 @@ function collectClickedLocations(clickEvent) {
thematic: p.thematic || '', thematic: p.thematic || '',
thematic_name: f._thematic_name || p.thematic || '', thematic_name: f._thematic_name || p.thematic || '',
element_type: p.element_type || '', element_type: p.element_type || '',
usage_type_display: p.usage_type_display || '',
detail_url: p.detail_url || null, detail_url: p.detail_url || null,
feature_id: id, feature_id: id,
}); });
@ -1987,6 +2063,8 @@ function showAssetClickPopup(clickEvent) {
const code = escapeHtml(l.code || '-'); const code = escapeHtml(l.code || '-');
const type = escapeHtml(l.element_type || ''); const type = escapeHtml(l.element_type || '');
const thematic = escapeHtml(l.thematic_name || l.thematic || ''); const thematic = escapeHtml(l.thematic_name || l.thematic || '');
const usage = l.usage_type_display ? ` &bull; ${escapeHtml(l.usage_type_display)}` : '';
const dotHtml = l.color ? `<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background-color:${escapeHtml(l.color)}; margin-right:4px; vertical-align:middle; box-shadow:0 0 0 1px rgba(0,0,0,0.15);"></span>` : '';
const name = escapeHtml(l.name || ''); const name = escapeHtml(l.name || '');
const detailAnchor = l.detail_url const detailAnchor = l.detail_url
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>` ? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
@ -1996,7 +2074,7 @@ function showAssetClickPopup(clickEvent) {
<div class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;"> <div class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
<div style="min-width:0;"> <div style="min-width:0;">
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div> <div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div> <div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${dotHtml}${type}${usage}${thematic ? ' - ' + thematic : ''}</div>
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''} ${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
</div> </div>
<button type="button" <button type="button"
@ -2146,7 +2224,39 @@ function fitMapToFeatures(features) {
// ─── Data loading ───────────────────────────────────────────────────────────── // ─── Data loading ─────────────────────────────────────────────────────────────
let currentTerritoryMuniSignature = '';
function updateTerritoryMaskIfNeeded() {
if (!map || typeof map.getSource !== 'function') return;
const src = map.getSource('territory');
if (!src) return;
const selectedMunis = (typeof thematicFilters !== 'undefined' && Array.isArray(thematicFilters.municipality))
? thematicFilters.municipality
: [];
const sig = selectedMunis.slice().sort().join(',');
if (sig === currentTerritoryMuniSignature) return;
currentTerritoryMuniSignature = sig;
let url = window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/';
if (selectedMunis.length > 0) {
const params = selectedMunis.map(id => `municipality=${encodeURIComponent(id)}`).join('&');
url += (url.includes('?') ? '&' : '?') + params;
}
fetch(url)
.then(r => r.json())
.then(data => {
const currentSrc = map.getSource('territory');
if (currentSrc && data) currentSrc.setData(data);
})
.catch(err => {
console.warn('Could not update territory mask:', err);
});
}
function refreshData() { function refreshData() {
updateTerritoryMaskIfNeeded();
// Cancel any in-flight requests from the previous call // Cancel any in-flight requests from the previous call
if (currentFetchAbortController) { currentFetchAbortController.abort(); } if (currentFetchAbortController) { currentFetchAbortController.abort(); }
currentFetchAbortController = new AbortController(); currentFetchAbortController = new AbortController();
@ -2386,6 +2496,7 @@ function loadTableData(features) {
const name = p.name || p.name_fr || p.name_nl || ''; const name = p.name || p.name_fr || p.name_nl || '';
const length = Number(p.length ?? p.length_m ?? 0); const length = Number(p.length ?? p.length_m ?? 0);
const area = Number(p.area ?? p.area_m2 ?? p.surface_area ?? p.surface ?? 0); const area = Number(p.area ?? p.area_m2 ?? p.surface_area ?? p.surface ?? 0);
const usageType = p.usage_type_display || '';
return { return {
id: f.id, id: f.id,
state: selectedIds.has(f.id), state: selectedIds.has(f.id),
@ -2396,7 +2507,10 @@ function loadTableData(features) {
thematic: p.thematic || '', thematic: p.thematic || '',
thematic_name: f._thematic_name || p.thematic || '', thematic_name: f._thematic_name || p.thematic || '',
category_id: p.category_id != null ? String(p.category_id) : '', category_id: p.category_id != null ? String(p.category_id) : '',
category: p.category || '', category: p.category || usageType || '',
usage_type: p.usage_type || '',
usage_type_display: usageType,
color: p.color || p.display_color || '',
model: p.model || '', model: p.model || '',
status: p.status || '', status: p.status || '',
detail_url: p.detail_url || null, detail_url: p.detail_url || null,
@ -2523,6 +2637,10 @@ maploader.loadMap('map');
maploader.addEventListener('mapLoaded', () => { maploader.addEventListener('mapLoaded', () => {
// Create empty sources so setData can be called immediately after data loads // Create empty sources so setData can be called immediately after data loads
map.addSource('territory', {
type: '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('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
@ -2576,40 +2694,13 @@ maploader.addEventListener('mapLoaded', () => {
if (typeof LayerSwitcherControl !== 'undefined') { if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({ const layerSwitcher = new LayerSwitcherControl({
layers: [{ layers: [{
id: 'ortho', id: 'basemap',
name: 'Orthophotos', name: 'Fonds de plan & Orthophotos',
icon: 'satellite', icon: 'satellite',
group: 'ortho', beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'),
beforeLayer: 'locations', basemaps: window.MAP_BACKGROUNDS || [],
layer: { type: 'raster', paint: { 'raster-opacity': 1 } },
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '\u00a9 Brussels UrbIS',
},
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: ['https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'],
tileSize: 256,
attribution: '\u00a9 Digitaal Vlaanderen - Orthofoto 2023',
},
},
],
}], }],
}); });// Reset view — fly back to initial Brussels Region view
// Reset view — fly back to initial Brussels Region view
map.addControl({ map.addControl({
onAdd(m) { onAdd(m) {
this._map = m; this._map = m;
@ -3620,7 +3711,7 @@ function renderConditionalFilters() {
const isChecked = selected === checkedValue; const isChecked = selected === checkedValue;
return ` return `
<div class="w-100 mb-2"> <div class="conditional-filter-block">
<div class="filter-options"> <div class="filter-options">
<div class="filter-option"> <div class="filter-option">
<input type="checkbox" <input type="checkbox"
@ -3642,8 +3733,8 @@ function renderConditionalFilters() {
if (def.type === 'date') { if (def.type === 'date') {
const inputId = `cond-${thematicCode}-${def.key}`; const inputId = `cond-${thematicCode}-${def.key}`;
return ` return `
<div class="w-100 mb-2"> <div class="conditional-filter-block">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div> <div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
<div class="filter-options"> <div class="filter-options">
<input type="date" <input type="date"
class="form-control form-control-sm conditional-filter-input" class="form-control form-control-sm conditional-filter-input"
@ -3669,8 +3760,8 @@ function renderConditionalFilters() {
).join(''); ).join('');
return ` return `
<div class="w-100 mb-2"> <div class="conditional-filter-block">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div> <div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
<div class="filter-options"> <div class="filter-options">
<select class="form-select form-select-sm conditional-filter-input" <select class="form-select form-select-sm conditional-filter-input"
name="${escapeHtml(filterName)}" name="${escapeHtml(filterName)}"
@ -3693,6 +3784,22 @@ function renderConditionalFilters() {
? (selected === String(option.value)) ? (selected === String(option.value))
: selected.includes(String(option.value)); : selected.includes(String(option.value));
let iconClass = option.icon || '';
if (!iconClass && def.key === 'usage_type') {
const fallbackIcons = {
administrative: 'bi-building',
education: 'bi-mortarboard',
sports: 'bi-trophy',
culture: 'bi-palette',
social: 'bi-people',
technical: 'bi-tools',
housing: 'bi-house-door',
other: 'bi-building-gear'
};
iconClass = fallbackIcons[option.value] || 'bi-building';
}
const iconHtml = iconClass ? `<i class="bi ${escapeHtml(iconClass)} me-1"></i>` : '';
return ` return `
<div class="filter-option"> <div class="filter-option">
<input type="${inputType}" <input type="${inputType}"
@ -3704,23 +3811,30 @@ function renderConditionalFilters() {
data-filter-type="${escapeHtml(def.type || 'multi')}" data-filter-type="${escapeHtml(def.type || 'multi')}"
value="${escapeHtml(String(option.value))}" value="${escapeHtml(String(option.value))}"
${isChecked ? 'checked' : ''}> ${isChecked ? 'checked' : ''}>
<label for="${optionId}">${escapeHtml(option.label)}</label> <label for="${optionId}" title="${escapeHtml(option.label)}">${iconHtml}<span class="filter-option-text">${escapeHtml(option.label)}</span></label>
</div> </div>
`; `;
}).join(''); }).join('');
const isUniformGrid = def.key === 'usage_type' || (def.options && def.options.length >= 4 && def.options.some(o => o.label && o.label.length > 15));
const optionsContainerClass = isUniformGrid ? 'filter-options filter-options-grid' : 'filter-options';
return ` return `
<div class="w-100 mb-2"> <div class="conditional-filter-block">
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div> <div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
<div class="filter-options">${optionsHtml}</div> <div class="${optionsContainerClass}">${optionsHtml}</div>
</div> </div>
`; `;
}).join(''); }).join('');
return ` return `
<div class="w-100 mb-2"> <div class="conditional-thematic-group">
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div> <div class="conditional-thematic-header">
${blocks} ${icon}<span>${escapeHtml(heading)}</span>
</div>
<div class="conditional-thematic-body">
${blocks}
</div>
</div> </div>
`; `;
}).join(''); }).join('');

View file

@ -155,6 +155,20 @@ function loadUnifiedMapIcons(map) {
.catch(e => console.warn('unified_map_layers: could not load roaddrain icon', e)); .catch(e => console.warn('unified_map_layers: could not load roaddrain icon', e));
} }
// speedbump: dedicated speed cushion / ralentisseur SVG (44 px)
if (iconUrls.speedbump && !map.hasImage('icon-speedbump')) {
loadNormalisedIcon(iconUrls.speedbump, 44)
.then(data => { if (!map.hasImage('icon-speedbump')) map.addImage('icon-speedbump', data); })
.catch(e => console.warn('unified_map_layers: could not load speedbump icon', e));
}
// cleanglasscontainer: dedicated bulle à verre SVG (46 px)
if (iconUrls.cleanglasscontainer && !map.hasImage('icon-cleanglasscontainer')) {
loadNormalisedIcon(iconUrls.cleanglasscontainer, 46)
.then(data => { if (!map.hasImage('icon-cleanglasscontainer')) map.addImage('icon-cleanglasscontainer', data); })
.catch(e => console.warn('unified_map_layers: could not load cleanglasscontainer icon', e));
}
// roadconcreteblockset: dedicated concrete block SVG (42 px) // roadconcreteblockset: dedicated concrete block SVG (42 px)
if (iconUrls.roadconcreteblockset && !map.hasImage('icon-roadconcreteblockset')) { if (iconUrls.roadconcreteblockset && !map.hasImage('icon-roadconcreteblockset')) {
loadNormalisedIcon(iconUrls.roadconcreteblockset, 42) loadNormalisedIcon(iconUrls.roadconcreteblockset, 42)
@ -169,6 +183,13 @@ function loadUnifiedMapIcons(map) {
.catch(e => console.warn('unified_map_layers: could not load naturebench icon', e)); .catch(e => console.warn('unified_map_layers: could not load naturebench icon', e));
} }
// natureplanter: dedicated planter / flower box SVG (38 px)
if (iconUrls.natureplanter && !map.hasImage('icon-natureplanter')) {
loadNormalisedIcon(iconUrls.natureplanter, 38)
.then(data => { if (!map.hasImage('icon-natureplanter')) map.addImage('icon-natureplanter', data); })
.catch(e => console.warn('unified_map_layers: could not load natureplanter icon', e));
}
// artworks: dedicated fountain/statue SVGs // artworks: dedicated fountain/statue SVGs
if (iconUrls.artworkfountain && !map.hasImage('icon-artworkfountain')) { if (iconUrls.artworkfountain && !map.hasImage('icon-artworkfountain')) {
loadNormalisedIcon(iconUrls.artworkfountain, 48) loadNormalisedIcon(iconUrls.artworkfountain, 48)
@ -297,6 +318,38 @@ const THEMATIC_COLOR_EXPR = (typeof AssetSymbology !== 'undefined')
'#0022ff' '#0022ff'
]; ];
// ─── Layer 0 — territory mask (inverted polygon: subtle transparent gray outside the commune)
const _unified_territory_mask = {
id: 'territory-mask',
type: 'fill',
source: 'territory',
filter: ['==', ['get', 'layer'], 'mask'],
paint: {
'fill-color': '#0f172a',
'fill-opacity': 0.14,
'fill-antialias': false,
},
};
// ─── Layer 0b — territory boundary (delicate dashed outline around the commune)
const _unified_territory_outline = {
id: 'territory-outline',
type: 'line',
source: 'territory',
filter: ['==', ['get', 'layer'], 'boundary'],
paint: {
'line-color': '#475569',
'line-width': [
'interpolate', ['linear'], ['zoom'],
8, 1.2,
13, 1.8,
18, 2.5
],
'line-opacity': 0.7,
'line-dasharray': [4, 2],
},
};
// ─── Layer 1 — location fill (polygon locations: streets, areas) ────────────── // ─── Layer 1 — location fill (polygon locations: streets, areas) ──────────────
// id MUST stay 'locations': LayerSwitcherControl uses beforeLayer: 'locations'. // id MUST stay 'locations': LayerSwitcherControl uses beforeLayer: 'locations'.
const _unified_locations_fill = { const _unified_locations_fill = {
@ -304,8 +357,29 @@ const _unified_locations_fill = {
type: 'fill', type: 'fill',
source: 'locations', source: 'locations',
paint: { paint: {
'fill-color': '#0094ff', 'fill-color': [
'fill-opacity': 0, // contour only — outline handled by locations-outline 'case',
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
[
'match',
['coalesce', ['get', 'usage_type'], ''],
'administrative', '#2563EB',
'education', '#D97706',
'sports', '#059669',
'culture', '#7C3AED',
'social', '#E11D48',
'technical', '#64748B',
'housing', '#0D9488',
'other', '#475569',
['coalesce', ['get', 'color'], '#2563EB']
],
'#0094ff'
],
'fill-opacity': [
'case',
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']], 0.45,
0 // contour only for standard street locations
],
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices 'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
}, },
}; };
@ -328,11 +402,23 @@ const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymb
['coalesce', ['get', 'color'], '#2e7d32'], ['coalesce', ['get', 'color'], '#2e7d32'],
['==', ['get', 'thematic'], 'parking'], ['==', ['get', 'thematic'], 'parking'],
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'), (typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
['==', ['get', 'thematic'], 'roads'],
[
'match',
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#e03131',
['2', '2 - Mauvais'], '#f76707',
['3', '3 - Bon'], '#fcc419',
['4', '4 - Très bon'], '#74b816',
['5', '5 - Excellent'], '#2f9e44',
'#4a4a4a'
],
THEMATIC_COLOR_EXPR THEMATIC_COLOR_EXPR
], ],
'fill-opacity': [ 'fill-opacity': [
'case', 'case',
['==', ['get', 'thematic'], 'parking'], 0.35, ['==', ['get', 'thematic'], 'parking'], 0.35,
['==', ['get', 'thematic'], 'roads'], 0.28,
0.7 0.7
], ],
'fill-antialias': false, 'fill-antialias': false,
@ -354,14 +440,29 @@ const _unified_locations_outline = {
'line-color': [ 'line-color': [
'case', 'case',
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b', ['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
[
'match',
['coalesce', ['get', 'usage_type'], ''],
'administrative', '#1D4ED8',
'education', '#B45309',
'sports', '#047857',
'culture', '#6D28D9',
'social', '#BE123C',
'technical', '#334155',
'housing', '#0F766E',
'other', '#1E293B',
'#1D4ED8'
],
'#0094ff' '#0094ff'
], ],
'line-width': [ 'line-width': [
'case', 'case',
['boolean', ['feature-state', 'highlighted'], false], 3, ['boolean', ['feature-state', 'highlighted'], false], 3,
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']], 2,
1 1
], ],
'line-opacity': 0.8, 'line-opacity': 0.9,
}, },
}; };
@ -382,16 +483,33 @@ const _unified_locations_circle = {
14, 5, 14, 5,
19, 10 19, 10
], ],
// Colour by intersection status (matches trafficlights_index.html) // Colour by building usage_type or intersection status
'circle-color': [ 'circle-color': [
'match', ['get', 'status'], 'case',
'active', '#0094ff', ['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
'in_preparation', '#f59f00', [
'under_repair', '#f76707', 'match',
'voluntarily_out_of_service', '#e03131', ['coalesce', ['get', 'usage_type'], ''],
'inactive', '#e03131', 'administrative', '#2563EB',
'archived', '#6c757d', 'education', '#D97706',
'#0094ff' 'sports', '#059669',
'culture', '#7C3AED',
'social', '#E11D48',
'technical', '#64748B',
'housing', '#0D9488',
'other', '#475569',
['coalesce', ['get', 'color'], '#2563EB']
],
[
'match', ['get', 'status'],
'active', '#0094ff',
'in_preparation', '#f59f00',
'under_repair', '#f76707',
'voluntarily_out_of_service', '#e03131',
'inactive', '#e03131',
'archived', '#6c757d',
'#0094ff'
]
], ],
'circle-stroke-color': [ 'circle-stroke-color': [
'case', 'case',
@ -479,10 +597,251 @@ const _unified_assets_symbol = {
'icon-image': (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconImageExpr) ? AssetSymbology.getIconImageExpr() : 'icon-roads', 'icon-image': (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconImageExpr) ? AssetSymbology.getIconImageExpr() : 'icon-roads',
'icon-size': [ 'icon-size': [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
8, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.12, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.18, 0.4], 8, [
12, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.22, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.34, 0.6], 'case',
16, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.45, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.62, 0.8], ['any',
22, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.95, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 1.05, 1.2] ['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.05,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.08,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.09,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.12,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.07,
['==', ['get', 'asset_type'], 'naturetree'], 0.08,
0.28
],
11, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.09,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.14,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.16,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.20,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.12,
['==', ['get', 'asset_type'], 'naturetree'], 0.14,
0.40
],
13, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.15,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.22,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.25,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.30,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.19,
['==', ['get', 'asset_type'], 'naturetree'], 0.22,
0.52
],
15, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.24,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.35,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.40,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.45,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.34,
['==', ['get', 'asset_type'], 'naturetree'], 0.38,
0.66
],
17, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.45,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.55,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.62,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.65,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.55,
['==', ['get', 'asset_type'], 'naturetree'], 0.62,
0.82
],
19, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.72,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.80,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.85,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.85,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 0.80,
['==', ['get', 'asset_type'], 'naturetree'], 0.85,
0.98
],
22, [
'case',
['any',
['==', ['get', 'asset_type'], 'drain'],
['==', ['get', 'asset_type'], 'roaddrain'],
['==', ['get', 'asset_type'], 'cleanlitterbin'],
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'],
['==', ['get', 'category'], 'Armoires électriques']
], 0.95,
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 1.05,
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 1.10,
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 1.05,
['any',
['==', ['get', 'asset_type'], 'naturebench'],
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
['==', ['get', 'model'], 'Bloembak'],
['==', ['get', 'model'], 'Jardinière'],
['==', ['get', 'model'], 'Bac à fleurs'],
['==', ['get', 'model'], 'Bac à plantes'],
['==', ['get', 'model'], 'Banc public'],
['==', ['get', 'model'], 'Openbare bank'],
['==', ['get', 'asset_sub_type'], 'planter'],
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
['==', ['get', 'category'], 'NAT_FURNITURES'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
], 1.05,
['==', ['get', 'asset_type'], 'naturetree'], 1.10,
1.15
]
], ],
'icon-anchor': [ 'icon-anchor': [
'case', 'case',
@ -783,6 +1142,8 @@ const _unified_assets_polygon_outline = (typeof AssetSymbology !== 'undefined' &
// MapLibre renders layers in insertion order, so the array below defines the // MapLibre renders layers in insertion order, so the array below defines the
// visual stacking: fills at the bottom, then lines, then circles/symbols on top. // visual stacking: fills at the bottom, then lines, then circles/symbols on top.
const UNIFIED_LAYERS = [ const UNIFIED_LAYERS = [
_unified_territory_mask, // 0. territory mask (outside commune)
_unified_territory_outline, // 0b. territory boundary line
_unified_locations_fill, // 1. polygon fills (bottom) _unified_locations_fill, // 1. polygon fills (bottom)
_unified_assets_fill, // 2. polygon fills _unified_assets_fill, // 2. polygon fills
_unified_locations_outline, // 3. lines _unified_locations_outline, // 3. lines

View file

@ -101,7 +101,11 @@
paint: { paint: {
'circle-radius': [ 'circle-radius': [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
8, 3, 8, 1.2,
11, 2.0,
13, 3.0,
15, 4.5,
17, 7.0,
22, 10 22, 10
], ],
// display_color is a pre-computed canonical colour name (Python-side). // display_color is a pre-computed canonical colour name (Python-side).

View file

@ -234,7 +234,7 @@
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
{% block asset_header_title %} {% block asset_header_title %}
<div> <div>
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;"> <h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% translate "Asset" %} : {{ asset.code }} {% translate "Asset" %} : {{ asset.code }}
</h2> </h2>
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;"> <div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;">
@ -278,7 +278,7 @@
{% if mobile_view or not allowed_update_fields %} {% if mobile_view or not allowed_update_fields %}
<div class="mb-3"> <div class="mb-3">
{% block asset_header_title_readonly %} {% block asset_header_title_readonly %}
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;"> <h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% if thematic.icon %} {% if thematic.icon %}
<i class="bi {{ thematic.icon }} me-2"></i> <i class="bi {{ thematic.icon }} me-2"></i>
{% endif %} {% endif %}
@ -546,7 +546,7 @@
</td> </td>
</tr> --> </tr> -->
<tr class="table-light" style="--bs-table-bg-type: transparent; --bs-table-bg-state: transparent;"> <tr class="table-light" style="--bs-table-bg-type: transparent; --bs-table-bg-state: transparent;">
<th colspan="2" class="text-muted" style="color: var(--brand-color) !important; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;"> <th colspan="2" class="text-muted" style="color: #475569 !important; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;">
{% translate "Attributs spécifiques" %} {% translate "Attributs spécifiques" %}
</th> </th>
</tr> </tr>

View file

@ -27,7 +27,7 @@
height: 44px; height: 44px;
border-radius: 0.75rem; border-radius: 0.75rem;
background-color: rgba(47, 62, 139, 0.1); background-color: rgba(47, 62, 139, 0.1);
color: var(--brand-color, #2f3e8b); color: #0f172a;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -71,7 +71,7 @@
gap: 0.35rem; gap: 0.35rem;
} }
.mobile-tab-btn.active { .mobile-tab-btn.active {
background-color: var(--brand-color, #2f3e8b); background-color: #0f172a;
color: #ffffff; color: #ffffff;
box-shadow: 0 2px 4px rgba(47, 62, 139, 0.25); box-shadow: 0 2px 4px rgba(47, 62, 139, 0.25);
} }
@ -137,7 +137,7 @@
</div> </div>
<div class="flex-grow-1 text-truncate"> <div class="flex-grow-1 text-truncate">
<div class="d-flex align-items-center gap-1.5 flex-wrap"> <div class="d-flex align-items-center gap-1.5 flex-wrap">
<span class="fw-bold text-dark fs-6" style="color: var(--brand-color);">{{ asset.code }}</span> <span class="fw-bold text-dark fs-6" style="color: #0f172a;">{{ asset.code }}</span>
{% if asset.status == 'active' %} {% if asset.status == 'active' %}
<span class="badge bg-success">{{ asset.get_status_display }}</span> <span class="badge bg-success">{{ asset.get_status_display }}</span>
{% elif asset.status == 'damaged' or asset.status == 'to_replace' %} {% elif asset.status == 'damaged' or asset.status == 'to_replace' %}

View file

@ -43,7 +43,7 @@
</div> </div>
<div> <div>
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;"> <h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% translate "Arbre" %} : {{ asset.code }} {% translate "Arbre" %} : {{ asset.code }}
</h2> </h2>
{% if asset.name_fr %} {% if asset.name_fr %}
@ -114,7 +114,7 @@
</div> </div>
<div> <div>
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;"> <h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% translate "Arbre" %} : {{ asset.code }} {% translate "Arbre" %} : {{ asset.code }}
</h2> </h2>
{% if asset.name_fr %} {% if asset.name_fr %}
@ -187,7 +187,7 @@
{# ── CARTE 1 : IDENTITÉ DE L'ARBRE ── #} {# ── CARTE 1 : IDENTITÉ DE L'ARBRE ── #}
<div class="card mb-3 shadow-sm"> <div class="card mb-3 shadow-sm">
<div class="card-header bg-light d-flex align-items-center justify-content-between"> <div class="card-header bg-light d-flex align-items-center justify-content-between">
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);"> <h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
<i class="bi bi-info-circle text-primary me-2"></i>{% translate "Identité & Informations générales" %} <i class="bi bi-info-circle text-primary me-2"></i>{% translate "Identité & Informations générales" %}
</h5> </h5>
{% if asset.tag_number %} {% if asset.tag_number %}
@ -384,7 +384,7 @@
{# ── CARTE 2 : DENDROMÉTRIE & DIMENSIONS ── #} {# ── CARTE 2 : DENDROMÉTRIE & DIMENSIONS ── #}
<div class="card mb-3 shadow-sm"> <div class="card mb-3 shadow-sm">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);"> <h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
<i class="bi bi-rulers text-success me-2"></i>{% translate "Fiche dendrométrique & Dimensions" %} <i class="bi bi-rulers text-success me-2"></i>{% translate "Fiche dendrométrique & Dimensions" %}
</h5> </h5>
</div> </div>
@ -521,7 +521,7 @@
{# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #} {# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #}
<div class="card mb-3 shadow-sm"> <div class="card mb-3 shadow-sm">
<div class="card-header bg-light d-flex align-items-center justify-content-between"> <div class="card-header bg-light d-flex align-items-center justify-content-between">
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);"> <h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
<i class="bi bi-shield-check text-warning me-2"></i>{% translate "Diagnostic sanitaire & Sécurité" %} <i class="bi bi-shield-check text-warning me-2"></i>{% translate "Diagnostic sanitaire & Sécurité" %}
</h5> </h5>
{% if asset.is_remarkable %} {% if asset.is_remarkable %}
@ -698,7 +698,7 @@
{# ── CARTE 4 : IMPLANTATION & CONDUITE DE TAILLE ── #} {# ── CARTE 4 : IMPLANTATION & CONDUITE DE TAILLE ── #}
<div class="card mb-3 shadow-sm"> <div class="card mb-3 shadow-sm">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);"> <h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
<i class="bi bi-geo-alt text-info me-2"></i>{% translate "Implantation & Conduite de taille" %} <i class="bi bi-geo-alt text-info me-2"></i>{% translate "Implantation & Conduite de taille" %}
</h5> </h5>
</div> </div>

View file

@ -111,17 +111,35 @@
filter: ['in', ['geometry-type'], ['literal', ['Polygon', 'MultiPolygon']]], filter: ['in', ['geometry-type'], ['literal', ['Polygon', 'MultiPolygon']]],
paint: { paint: {
'fill-color': [ 'fill-color': [
'match', 'case',
['get', 'asset_type'], ['==', ['get', 'asset_type'], 'roadway'],
'roadway', '#4a4a4a', [
'sidewalk', '#00bfa5', 'match',
'#9e9e9e' ['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#e03131',
['2', '2 - Mauvais'], '#f76707',
['3', '3 - Bon'], '#fcc419',
['4', '4 - Très bon'], '#74b816',
['5', '5 - Excellent'], '#2f9e44',
'#4a4a4a'
],
['match', ['get', 'asset_type'], 'sidewalk', '#00bfa5', '#9e9e9e']
], ],
'fill-opacity': 0.7, 'fill-opacity': 0.28,
'fill-outline-color': [ 'fill-outline-color': [
'case', 'case',
['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
'#ffeb3b', ['==', ['get', 'asset_type'], 'roadway'],
[
'match',
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
['1', '1 - Très mauvais'], '#c92a2a',
['2', '2 - Mauvais'], '#e8590c',
['3', '3 - Bon'], '#f59f00',
['4', '4 - Très bon'], '#66a80f',
['5', '5 - Excellent'], '#2b8a3e',
'#00000000'
],
'#00000000' '#00000000'
] ]
}, },
@ -175,8 +193,12 @@
'icon-image': 'drain-icon', 'icon-image': 'drain-icon',
'icon-size': [ 'icon-size': [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
8, 0.1, 8, 0.05,
22, 1 11, 0.09,
13, 0.15,
15, 0.24,
17, 0.45,
22, 0.95
], ],
'icon-allow-overlap': true, 'icon-allow-overlap': true,
'icon-ignore-placement': true, 'icon-ignore-placement': true,

View file

@ -9,44 +9,401 @@
<style> <style>
#assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; } #assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; }
#unified-empty-state { display: none; color: #6c757d; } #unified-empty-state { display: none; color: #6c757d; }
/* ─── Filter modal ──────────────────────────────────── */ /* ─── Modern Airbnb-style Filter Modal ──────────────────── */
#filterModal .modal-dialog { max-width: 95%; } #filterModal .modal-dialog {
#filterModal .modal-content { display: flex; flex-direction: column; max-height: 90vh; } max-width: 820px;
#filterModal .modal-body { flex: 1; overflow-y: auto; } margin: 1.5rem auto;
#filterModal .modal-footer { flex-shrink: 0; } }
.filter-section { margin-bottom: 1rem; } #filterModal .modal-content {
border: 1px solid #e2e8f0;
border-radius: 18px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
max-height: 88vh;
overflow: hidden;
background-color: #ffffff;
}
#filterModal .modal-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #f1f5f9;
background-color: #ffffff;
}
#filterModal .modal-title {
font-size: 1.25rem;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.02em;
}
#filterModal .btn-close {
font-size: 0.85rem;
padding: 0.5rem;
opacity: 0.5;
transition: opacity 0.15s ease;
}
#filterModal .btn-close:hover {
opacity: 1;
}
#filterModal .modal-body {
padding: 1.5rem;
background-color: #f8fafc;
flex: 1;
overflow-y: auto;
}
#filterModal .modal-footer {
padding: 1rem 1.5rem;
background-color: #ffffff;
border-top: 1px solid #f1f5f9;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Thematic Conditional Filter Cards */
#conditional-filter-options {
display: flex;
flex-direction: column;
gap: 0.85rem;
width: 100%;
}
.conditional-thematic-group {
width: 100%;
box-sizing: border-box;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.95rem 1.05rem;
margin-bottom: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: all 0.15s ease;
}
.conditional-thematic-group:hover {
border-color: #cbd5e1;
background-color: #f1f5f9;
}
.conditional-thematic-header {
display: flex;
align-items: center;
gap: 0.55rem;
padding-bottom: 0.55rem;
margin-bottom: 0.75rem;
border-bottom: 1px solid #e2e8f0;
font-size: 0.9rem;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.01em;
}
.conditional-thematic-header i {
font-size: 1.05rem;
color: #2563eb;
}
.conditional-filter-block {
margin-bottom: 0.75rem;
}
.conditional-filter-block:last-child {
margin-bottom: 0;
}
.conditional-filter-label {
font-size: 0.76rem;
font-weight: 600;
color: #64748b;
margin-bottom: 0.35rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Uniform Responsive Grid for Filter Options (e.g. usage_type) */
.filter-options.filter-options-grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)) !important;
gap: 0.5rem !important;
width: 100% !important;
}
.filter-options.filter-options-grid .filter-option {
display: flex;
width: 100%;
min-width: 0;
}
.filter-options.filter-options-grid .filter-option label {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
padding: 0.45rem 0.85rem;
box-sizing: border-box;
min-height: 38px;
border-radius: 9999px;
overflow: hidden;
}
.filter-options.filter-options-grid .filter-option label .filter-option-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.filter-options.filter-options-grid .filter-option label i {
flex-shrink: 0;
font-size: 0.95rem;
margin-right: 0.35rem;
}
/* Filter Section Cards */
.filter-section {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 1.15rem;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.filter-section:hover {
border-color: #cbd5e1;
}
.filter-section-header { .filter-section-header {
display: flex; justify-content: space-between; align-items: center; display: flex;
margin-bottom: 0.75rem; gap: 1rem; justify-content: space-between;
align-items: center;
margin-bottom: 0.85rem;
gap: 1rem;
} }
.filter-section-header > label { font-weight: 600; color: #333; margin: 0; flex-shrink: 0; } .filter-section-header > label,
.filter-section-buttons { display: flex; gap: 0.5rem; margin-left: auto; flex-shrink: 0; } .filter-section-header > .section-title {
.filter-section-buttons button { padding: 0.25rem 0.5rem; font-size: 0.75rem; white-space: nowrap; } font-size: 0.95rem;
font-weight: 700;
color: #0f172a;
margin: 0;
letter-spacing: -0.01em;
}
.filter-section-buttons {
display: flex;
gap: 0.35rem;
margin-left: auto;
}
.filter-section-buttons button {
padding: 0.25rem 0.65rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 6px;
border: 1px solid #e2e8f0;
background-color: #ffffff;
color: #64748b;
transition: all 0.15s ease;
}
.filter-section-buttons button:hover {
background-color: #f1f5f9;
color: #0f172a;
border-color: #cbd5e1;
}
/* Thematic Grid (Airbnb-style visual cards) */
.thematic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 0.65rem;
}
@media (max-width: 576px) {
.thematic-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.thematic-tile {
position: relative;
}
.thematic-tile input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.thematic-tile label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
min-height: 96px;
padding: 0.85rem 0.5rem;
background-color: #ffffff;
border: 1.5px solid #e2e8f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
}
.thematic-tile .thematic-icon {
font-size: 1.85rem;
color: #475569;
margin-bottom: 0.45rem;
transition: transform 0.18s ease, color 0.18s ease;
line-height: 1;
}
.thematic-tile .thematic-label {
font-size: 0.78rem;
font-weight: 600;
color: #334155;
line-height: 1.25;
transition: color 0.18s ease;
}
.thematic-tile:hover label {
background-color: #f8fafc;
border-color: #94a3b8;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.thematic-tile:hover .thematic-icon {
color: #0f172a;
transform: scale(1.08);
}
.thematic-tile input[type="checkbox"]:checked + label {
background-color: #0f172a !important;
border-color: #0f172a !important;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.22);
transform: translateY(-1px);
}
.thematic-tile input[type="checkbox"]:checked + label .thematic-icon {
color: #ffffff !important;
transform: scale(1.05);
}
.thematic-tile input[type="checkbox"]:checked + label .thematic-label {
color: #ffffff !important;
}
/* Chips & Tags (Categories, Models, Conditionals, Status) */
.filter-options { .filter-options {
display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 2.5rem; display: flex;
padding: 0.5rem; background-color: #f8f9fa; flex-wrap: wrap;
border-radius: 0.375rem; border: 1px solid #dee2e6; gap: 0.45rem;
background-color: transparent !important;
border: none !important;
padding: 0 !important;
min-height: auto !important;
}
.filter-option {
display: inline-flex;
align-items: center;
} }
.filter-option { display: flex; align-items: center; }
.filter-option input[type="checkbox"], .filter-option input[type="checkbox"],
.filter-option input[type="radio"] { .filter-option input[type="radio"] {
position: absolute; opacity: 0; width: 0; height: 0; cursor: pointer; position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
} }
.filter-option label { .filter-option label {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex;
margin: 0; font-weight: 500; cursor: pointer; align-items: center;
background-color: #e7f1ff; color: #0c5cc9; gap: 0.35rem;
border-radius: 1rem; padding: 0.35rem 0.75rem; font-size: 0.8rem; padding: 0.4rem 0.85rem;
border: 1px solid #b8d4f1; transition: all 0.15s ease; font-size: 0.82rem;
font-weight: 500;
color: #334155;
background-color: #ffffff;
border: 1.5px solid #e2e8f0;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
white-space: nowrap; white-space: nowrap;
} }
.filter-option label:hover {
background-color: #f8fafc;
border-color: #94a3b8;
color: #0f172a;
}
.filter-option input[type="checkbox"]:checked + label, .filter-option input[type="checkbox"]:checked + label,
.filter-option input[type="radio"]:checked + label { .filter-option input[type="radio"]:checked + label {
background-color: #0c5cc9; color: #fff; border-color: #0c5cc9; background-color: #0f172a !important;
border-color: #0f172a !important;
color: #ffffff !important;
font-weight: 600;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
}
.filter-option input:disabled + label {
opacity: 0.45;
cursor: not-allowed;
background-color: #f1f5f9;
border-color: #e2e8f0;
}
/* Segmented Control (Element Type) */
.segmented-control {
display: inline-flex;
background-color: #f1f5f9;
padding: 4px;
border-radius: 10px;
border: 1px solid #e2e8f0;
gap: 3px;
max-width: 100%;
overflow-x: auto;
}
.segmented-option label {
border-radius: 7px !important;
border: none !important;
background-color: transparent !important;
color: #64748b !important;
font-weight: 500 !important;
padding: 0.45rem 1rem !important;
font-size: 0.84rem !important;
box-shadow: none !important;
white-space: nowrap;
}
.segmented-option label:hover {
color: #0f172a !important;
background-color: rgba(255, 255, 255, 0.6) !important;
}
.segmented-option input[type="radio"]:checked + label {
background-color: #ffffff !important;
color: #0f172a !important;
font-weight: 600 !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08) !important;
}
/* Empty state helper inside filter options */
#category-filter-options > span.text-muted,
#model-filter-options > span.text-muted,
#conditional-filter-options > span.text-muted {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background-color: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #64748b !important;
font-size: 0.82rem;
width: 100%;
}
/* Filter trigger badge */
#filter-badge {
background-color: #0f172a !important;
color: #ffffff !important;
font-size: 0.72rem;
font-weight: 700;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
top: 2px !important;
right: -6px !important;
left: auto !important;
transform: none !important;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 4px !important;
line-height: 1.25rem;
}
#filter-badge:not(.d-none) {
display: inline-flex !important;
align-items: center;
justify-content: center;
} }
.filter-option label:hover { background-color: #cfe2f3; }
.filter-option input:checked + label:hover { background-color: #0a4fb0; }
.filter-option input:disabled + label { opacity: 0.5; cursor: not-allowed; }
#map .maplibregl-popup-close-button { #map .maplibregl-popup-close-button {
font-size: 1.35rem; font-size: 1.35rem;
line-height: 1; line-height: 1;
@ -70,6 +427,226 @@
#map .mapboxgl-ctrl-top-right { #map .mapboxgl-ctrl-top-right {
top: 10px !important; top: 10px !important;
} }
/* ─── Modern SaaS Table Styling ────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap');
#col-table,
.bootstrap-table {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
}
.bootstrap-table.bootstrap5 .fixed-table-container {
border: 1px solid #e2e8f0 !important;
border-radius: 10px !important;
background-color: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
overflow: hidden !important;
}
.bootstrap-table.bootstrap5 .fixed-table-body {
overflow: auto !important;
}
/* Modern subtle scrollbar */
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar {
width: 7px;
height: 7px;
}
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-track {
background: #f8fafc;
}
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
#col-table,
#col-table .tab-content,
#col-table .tab-pane {
overflow: hidden !important;
}
/* Headers */
.bootstrap-table.bootstrap5 .fixed-table-header th,
.bootstrap-table.bootstrap5 #unified-table th {
background-color: #f8fafc !important;
color: #475569 !important;
font-size: 0.74rem !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.04em !important;
border-bottom: 1.5px solid #e2e8f0 !important;
padding: 0.65rem 0.75rem !important;
vertical-align: middle !important;
white-space: nowrap !important;
}
.bootstrap-table.bootstrap5 .fixed-table-header th .th-inner {
padding: 0 !important;
font-size: 0.74rem !important;
font-weight: 600 !important;
line-height: 1.4 !important;
}
/* Rows & Cells */
.bootstrap-table.bootstrap5 #unified-table td {
padding: 0.65rem 0.75rem !important;
vertical-align: middle !important;
font-size: 0.83rem !important;
color: #334155;
border-bottom: 1px solid #f1f5f9 !important;
}
.bootstrap-table.bootstrap5 #unified-table tbody tr {
transition: background-color 0.12s ease;
}
.bootstrap-table.bootstrap5 #unified-table tbody tr:hover {
background-color: #f8fafc !important;
}
.bootstrap-table.bootstrap5 #unified-table tbody tr.selected {
background-color: #f1f5f9 !important;
}
/* Specific cell styles */
.unified-code-link {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
font-size: 0.79rem !important;
font-weight: 600 !important;
color: #0f172a !important;
text-decoration: none !important;
white-space: nowrap !important;
display: inline-block;
padding: 2px 6px;
border-radius: 5px;
background-color: #f8fafc;
border: 1px solid #e2e8f0;
transition: all 0.15s ease;
}
.unified-code-link:hover {
color: #2563eb !important;
border-color: #93c5fd;
background-color: #eff6ff;
}
.unified-row-name {
font-weight: 500;
color: #0f172a;
display: block;
line-height: 1.35;
}
.unified-category-cell {
color: #334155;
font-size: 0.82rem;
line-height: 1.35;
}
/* Prevent unwanted wraps */
#unified-table td[data-field="code"],
#unified-table th[data-field="code"] {
white-space: nowrap !important;
}
#unified-table td[data-field="thematic_name"],
#unified-table th[data-field="thematic_name"] {
white-space: nowrap !important;
color: #475569;
}
#unified-table td[data-field="element_type"] {
white-space: nowrap;
}
/* Zoom button */
.zoom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border-radius: 6px;
color: #64748b;
background-color: transparent;
border: 1px solid transparent;
transition: all 0.15s ease;
}
.zoom-btn:hover {
color: #0f172a;
background-color: #f1f5f9;
border-color: #cbd5e1;
}
/* Selection toolbar styling */
#unified-select-all,
#unified-deselect-all {
background-color: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 500;
padding: 0.28rem 0.65rem;
transition: all 0.15s ease;
}
#unified-select-all:hover,
#unified-deselect-all:hover {
background-color: #f1f5f9;
color: #0f172a;
border-color: #cbd5e1;
}
#unified-selection-status {
font-size: 0.8rem;
color: #64748b;
font-weight: 500;
}
#unified-bulk-action-dropdown .dropdown-toggle {
background-color: #ffffff;
border: 1px solid #e2e8f0;
color: #475569;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 500;
padding: 0.28rem 0.75rem;
}
#unified-bulk-action-dropdown .dropdown-toggle:hover {
background-color: #f1f5f9;
color: #0f172a;
border-color: #cbd5e1;
}
/* Pagination bar styling */
.bootstrap-table.bootstrap5 .fixed-table-pagination {
padding: 0.65rem 0.25rem 0 0.25rem !important;
font-size: 0.82rem;
color: #64748b;
}
.bootstrap-table.bootstrap5 .pagination-detail .page-list .btn-group .btn {
border-radius: 6px;
border-color: #e2e8f0;
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
background-color: #ffffff;
color: #334155;
}
.bootstrap-table.bootstrap5 .pagination .page-item .page-link {
border-radius: 6px;
margin: 0 2px;
border-color: #e2e8f0;
color: #334155;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
}
.bootstrap-table.bootstrap5 .pagination .page-item.active .page-link {
background-color: #0f172a;
border-color: #0f172a;
color: #ffffff;
}
</style> </style>
{% endblock head %} {% endblock head %}
@ -77,45 +654,78 @@
{# ─── Modal de filtre ──────────────────────────────────────────────────────── #} {# ─── Modal de filtre ──────────────────────────────────────────────────────── #}
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true"> <div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les assets" %}</h5> <div>
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les assets" %}</h5>
<div class="text-muted small" style="font-size: 0.8rem;">{% translate "Sélectionnez les thématiques et critères à afficher sur la carte et dans le tableau" %}</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{# Type d'élément #}
<div class="filter-section">
<div class="filter-section-header">
<label>{% translate "Type d'élément" %}</label>
</div>
<div class="segmented-control">
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-all" value="all"
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-all">{% translate "Assets & localisations" %}</label>
</div>
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
{% if preventive_element_type == 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
</div>
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
{% if preventive_element_type == 'assets' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-assets">{% translate "Assets uniquement" %}</label>
</div>
</div>
</div>
{# Thématiques #} {# Thématiques #}
<div class="filter-section"> <div class="filter-section">
<div class="filter-section-header"> <div class="filter-section-header">
<label>{% translate "Thématique" %}</label> <div>
<label>{% translate "Thématique" %}</label>
</div>
<div class="filter-section-buttons"> <div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary" id="thematic-select-all">{% translate "Tous" %}</button> <button type="button" id="thematic-select-all">{% translate "Toutes" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="thematic-deselect-all">{% translate "Aucun" %}</button> <button type="button" id="thematic-deselect-all">{% translate "Aucune" %}</button>
</div> </div>
</div> </div>
<div class="filter-options"> <div class="thematic-grid">
{% for thematic in accessible_thematics %} {% for thematic in accessible_thematics %}
<div class="filter-option"> <div class="thematic-tile">
<input type="checkbox" class="thematic-checkbox" <input type="checkbox" class="thematic-checkbox"
id="thematic-cb-{{ thematic.code }}" id="thematic-cb-{{ thematic.code }}"
value="{{ thematic.code }}" value="{{ thematic.code }}"
{% if thematic.code in selected_thematics %}checked{% endif %}> {% if thematic.code in selected_thematics %}checked{% endif %}>
<label for="thematic-cb-{{ thematic.code }}"> <label for="thematic-cb-{{ thematic.code }}">
{% if thematic.icon %}<i class="bi {{ thematic.icon }} me-1"></i>{% endif %}{{ thematic.name }} {% if thematic.icon %}<i class="bi {{ thematic.icon }} thematic-icon"></i>{% endif %}
<span class="thematic-label">{{ thematic.name }}</span>
</label> </label>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{# Catégories #} {# Catégories d'assets #}
<div class="filter-section"> <div class="filter-section">
<div class="filter-section-header"> <div class="filter-section-header">
<label>{% translate "Catégorie" %}</label> <label>{% translate "Catégorie d'assets" %}</label>
<div class="filter-section-buttons"> <div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary" id="category-select-all">{% translate "Toutes" %}</button> <button type="button" id="category-select-all">{% translate "Toutes" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="category-deselect-all">{% translate "Aucune" %}</button> <button type="button" id="category-deselect-all">{% translate "Aucune" %}</button>
</div> </div>
</div> </div>
<div id="category-filter-options" class="filter-options"> <div id="category-filter-options" class="filter-options">
@ -128,9 +738,9 @@
<div class="filter-section-header"> <div class="filter-section-header">
<label>{% translate "Modèle" %}</label> <label>{% translate "Modèle" %}</label>
<div class="filter-section-buttons"> <div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary" id="model-select-all">{% translate "Tous" %}</button> <button type="button" id="model-select-all">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="model-deselect-all">{% translate "Aucun" %}</button> <button type="button" id="model-deselect-all">{% translate "Aucun" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="model-filter-toggle"></button> <button type="button" class="d-none" id="model-filter-toggle"></button>
</div> </div>
</div> </div>
<div id="model-filter-options" class="filter-options"> <div id="model-filter-options" class="filter-options">
@ -148,33 +758,6 @@
</div> </div>
</div> </div>
{# Type d'élément #}
<div class="filter-section">
<div class="filter-section-header">
<label>{% translate "Type d'élément" %}</label>
</div>
<div class="filter-options">
<div class="filter-option">
<input type="radio" name="unified-type-filter" id="type-all" value="all"
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-all">{% translate "Assets et localisations" %}</label>
</div>
<div class="filter-option">
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
{% if preventive_element_type == 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
</div>
<div class="filter-option">
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
{% if preventive_element_type == 'assets' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-assets">{% translate "Assets uniquement" %}</label>
</div>
</div>
</div>
{# Options de géométrie #} {# Options de géométrie #}
<div class="filter-section"> <div class="filter-section">
<div class="filter-section-header"> <div class="filter-section-header">
@ -183,55 +766,64 @@
<div class="filter-options"> <div class="filter-options">
<div class="filter-option"> <div class="filter-option">
<input type="checkbox" name="include-no-geom" id="include-no-geom" checked> <input type="checkbox" name="include-no-geom" id="include-no-geom" checked>
<label for="include-no-geom">{% translate "Inclure les assets sans géométrie" %}</label> <label for="include-no-geom">
<i class="bi bi-geo-alt me-1"></i>{% translate "Inclure les assets sans géométrie" %}
</label>
</div> </div>
</div> </div>
</div> </div>
{# Statut #} {# Statut & Statut de validation #}
<div class="row"> <div class="row g-3 mb-1">
<div class="col-12 filter-section"> <div class="col-md-6">
<div class="filter-section-header"> <div class="filter-section h-100 mb-0">
<label>{% translate "Statut" %}</label> <div class="filter-section-header">
</div> <label>{% translate "Statut" %}</label>
<div class="filter-options"> </div>
{% for status_value, status_label in asset_status_choices %} <div class="filter-options">
<div class="filter-option"> {% for status_value, status_label in asset_status_choices %}
<input type="checkbox" name="filter-status" id="filter-status-{{ status_value }}" value="{{ status_value }}"> <div class="filter-option">
<label for="filter-status-{{ status_value }}">{{ status_label }}</label> <input type="checkbox" name="filter-status" id="filter-status-{{ status_value }}" value="{{ status_value }}">
<label for="filter-status-{{ status_value }}">{{ status_label }}</label>
</div>
{% endfor %}
</div> </div>
{% endfor %}
</div> </div>
</div> </div>
</div> <div class="col-md-6">
<div class="filter-section h-100 mb-0">
{# Statut de validation #} <div class="filter-section-header">
<div class="row"> <label>{% translate "Validation" %}</label>
<div class="col-12 filter-section"> </div>
<div class="filter-section-header"> <div class="filter-options">
<label>{% translate "Statut de validation" %}</label> {% for validation_status_value, validation_status_label in asset_validation_status_choices %}
</div> <div class="filter-option">
<div class="filter-options"> <input type="checkbox" name="filter-validation-status" id="filter-validation-status-{{ validation_status_value }}" value="{{ validation_status_value }}">
{% for validation_status_value, validation_status_label in asset_validation_status_choices %} <label for="filter-validation-status-{{ validation_status_value }}">{{ validation_status_label }}</label>
<div class="filter-option"> </div>
<input type="checkbox" name="filter-validation-status" id="filter-validation-status-{{ validation_status_value }}" value="{{ validation_status_value }}"> {% endfor %}
<label for="filter-validation-status-{{ validation_status_value }}">{{ validation_status_label }}</label>
</div> </div>
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" id="reset-filters" class="btn btn-secondary">{% translate "Réinitialiser" %}</button> <button type="button" id="reset-filters" class="btn btn-link text-muted p-0 text-decoration-none d-flex align-items-center gap-1" style="font-size: 0.85rem; font-weight: 500;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button> <i class="bi bi-arrow-counterclockwise"></i> {% translate "Tout effacer" %}
<button type="button" id="apply-filters" class="btn btn-primary">{% translate "Filtrer" %}</button> </button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary px-3 py-2" data-bs-dismiss="modal" style="border-radius: 8px; font-weight: 500;">{% translate "Fermer" %}</button>
<button type="button" id="apply-filters" class="btn btn-sm btn-dark px-4 py-2" style="background-color: #0f172a; border-color: #0f172a; border-radius: 8px; font-weight: 600;">
<i class="bi bi-check2 me-1"></i>{% translate "Appliquer les filtres" %}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{# ─── Add chooser modal ────────────────────────────────────────────────────── #} {# ─── Add chooser modal ────────────────────────────────────────────────────── #}
<div class="modal fade" id="unifiedAddChooserModal" tabindex="-1" aria-labelledby="unifiedAddChooserModalLabel" aria-hidden="true"> <div class="modal fade" id="unifiedAddChooserModal" tabindex="-1" aria-labelledby="unifiedAddChooserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered"> <div class="modal-dialog modal-lg modal-dialog-centered">
@ -478,7 +1070,7 @@
data-bs-toggle="modal" data-bs-target="#filterModal" data-bs-toggle="modal" data-bs-target="#filterModal"
title="{% translate 'Filtres' %}"> title="{% translate 'Filtres' %}">
<i class="bi bi-funnel"></i> {% translate "Filtres" %} <i class="bi bi-funnel"></i> {% translate "Filtres" %}
<span id="filter-badge" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-primary d-none"> <span id="filter-badge" class="position-absolute badge rounded-pill bg-primary d-none">
<span id="filter-count">0</span> <span id="filter-count">0</span>
</span> </span>
</button> </button>
@ -703,6 +1295,7 @@
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script> <script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script> <script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script> <script>
window.TERRITORY_MASK_URL = "{% url 'common:get_territory_mask_geojson' %}";
const preventive_draft_id = "{{ preventive_draft_id|default:'' }}"; const preventive_draft_id = "{{ preventive_draft_id|default:'' }}";
var selected_locations_set = new Set({{ selected_locations_ids|safe }}); var selected_locations_set = new Set({{ selected_locations_ids|safe }});
const initial_selected_assets = {{ selected_assets_json|safe }}; const initial_selected_assets = {{ selected_assets_json|safe }};
@ -741,6 +1334,9 @@
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a", nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a",
structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b", structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b",
roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a", roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a",
speedbump: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
roadfurniture: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
cleanglasscontainer: "{% static 'assets/clean_glass_container_icon.svg' %}?v=20260920",
roadconcreteblockset: "{% static 'assets/road_concrete_block_icon.svg' %}?v=20260608a", roadconcreteblockset: "{% static 'assets/road_concrete_block_icon.svg' %}?v=20260608a",
naturebench: "{% static 'assets/nature_bench_icon.svg' %}", naturebench: "{% static 'assets/nature_bench_icon.svg' %}",
artworkfountain: "{% static 'assets/artwork_fountain_icon.svg' %}?v=20260521a", artworkfountain: "{% static 'assets/artwork_fountain_icon.svg' %}?v=20260521a",
@ -752,7 +1348,7 @@
<script src="{% static 'common/base_script.js' %}"></script> <script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script> <script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script> <script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
<script src="{% static 'assets/asset_symbology.js' %}?v=20260913"></script> <script src="{% static 'assets/asset_symbology.js' %}?v=20260920_grid_options"></script>
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260726_pb"></script> <script src="{% static 'assets/unified_map_layers.js' %}?v=20260920_grid_options"></script>
<script src="{% static 'assets/unified_index.js' %}?v=20260726_pb"></script> <script src="{% static 'assets/unified_index.js' %}?v=20260920_grid_options"></script>
{% endblock script %} {% endblock script %}

View file

@ -4400,3 +4400,13 @@ class UserAccessibleMunicipalitiesTest(TestCase):
feature_ids = [f['id'] for f in data.get('features', [])] feature_ids = [f['id'] for f in data.get('features', [])]
self.assertIn(street_a.id, feature_ids) self.assertIn(street_a.id, feature_ids)
self.assertNotIn(street_c.id, feature_ids) self.assertNotIn(street_c.id, feature_ids)
def test_build_conditional_filter_config_single_municipality_hidden(self):
from assets.views.core import build_conditional_filter_config
# Remove muni_b from contract
self.contract.municipalities.remove(self.muni_b)
thematic_roads = Thematic.objects.get(code="roads")
cfg = build_conditional_filter_config([thematic_roads], user=self.user_ext)
roads_filters = cfg.get('roads', [])
muni_filter = next((f for f in roads_filters if f['key'] == 'municipality'), None)
# When only 1 municipality is accessible, the filter should NOT be shown
self.assertIsNone(muni_filter)

View file

@ -67,7 +67,7 @@ def artworks_index(request):
from assets.permissions import get_user_accessible_municipalities from assets.permissions import get_user_accessible_municipalities
municipalities = get_user_accessible_municipalities(request.user) municipalities = get_user_accessible_municipalities(request.user)
municipality_choices = [(m.id, m.name) for m in municipalities] municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
# Status choices for filters # Status choices for filters
status_choices = [ status_choices = [

View file

@ -19,6 +19,19 @@ from assets.models.buildings import (
) )
USAGE_TYPE_COLORS = {
'administrative': '#2563EB', # Bleu cobalt
'education': '#D97706', # Ambre doré
'sports': '#059669', # Vert émeraude
'culture': '#7C3AED', # Violet pourpre
'social': '#E11D48', # Rose framboise
'technical': '#64748B', # Gris ardoise
'housing': '#0D9488', # Sarcelle
'other': '#475569', # Ardoise foncée
}
DEFAULT_BUILDING_COLOR = '#2563EB'
def publicbuildings_locations_geojson(request): def publicbuildings_locations_geojson(request):
""" """
Vue GeoJSON renvoyant la collection des localisations de bâtiments publics pour la carte unifiée. Vue GeoJSON renvoyant la collection des localisations de bâtiments publics pour la carte unifiée.
@ -131,10 +144,13 @@ def publicbuildings_locations_geojson(request):
"is_erp": b.is_erp, "is_erp": b.is_erp,
"erp_category": b.erp_category or '', "erp_category": b.erp_category or '',
"dpe_rating": b.dpe_rating or '', "dpe_rating": b.dpe_rating or '',
"address": b.address or '',
"surface_m2": b.surface_m2,
"total_surface_m2": b.total_surface_m2,
"lon": b.lon, "lon": b.lon,
"lat": b.lat, "lat": b.lat,
"color": "#2B579A", "color": USAGE_TYPE_COLORS.get(b.usage_type, DEFAULT_BUILDING_COLOR),
"display_color": "#2B579A", "display_color": USAGE_TYPE_COLORS.get(b.usage_type, DEFAULT_BUILDING_COLOR),
"asset_type": "publicbuilding", "asset_type": "publicbuilding",
"detail_url": detail_url, "detail_url": detail_url,
"element_type": get_element_type_label("publicbuilding"), "element_type": get_element_type_label("publicbuilding"),
@ -165,12 +181,16 @@ def publicbuildings_assets_geojson(request):
asset_types = data.get('asset_type') or [] asset_types = data.get('asset_type') or []
if isinstance(asset_types, str): if isinstance(asset_types, str):
asset_types = [asset_types] asset_types = [asset_types]
usage_types = data.get('usage_type') or []
if isinstance(usage_types, str):
usage_types = [usage_types]
search_q = (data.get('q') or '').strip() search_q = (data.get('q') or '').strip()
else: else:
statuses = request.GET.getlist('asset_status') statuses = request.GET.getlist('asset_status')
categories = request.GET.getlist('category') categories = request.GET.getlist('category')
asset_types = request.GET.getlist('asset_type', []) asset_types = request.GET.getlist('asset_type', [])
usage_types = request.GET.getlist('usage_type', [])
search_q = request.GET.get('q', '').strip() search_q = request.GET.get('q', '').strip()
qs = BuildingAsset.objects.select_related('building', 'location', 'parent_asset', 'category').all() qs = BuildingAsset.objects.select_related('building', 'location', 'parent_asset', 'category').all()
@ -181,6 +201,9 @@ def publicbuildings_assets_geojson(request):
if asset_types: if asset_types:
qs = qs.filter(asset_type__in=asset_types) qs = qs.filter(asset_type__in=asset_types)
if usage_types:
qs = qs.filter(building__usage_type__in=usage_types)
if statuses: if statuses:
qs = qs.filter(status__in=statuses) qs = qs.filter(status__in=statuses)

View file

@ -654,7 +654,7 @@ def build_conditional_filter_config(accessible_thematics, user=None):
'name': pt.get_name(), 'name': pt.get_name(),
'group': group_key, 'group': group_key,
'group_label': group_label, 'group_label': group_label,
'image_url': None, 'image_url': pt.sgv_picture.url if getattr(pt, 'sgv_picture', None) else None,
}) })
controller_maintenance_provider_options = [] controller_maintenance_provider_options = []
@ -673,15 +673,16 @@ def build_conditional_filter_config(accessible_thematics, user=None):
for contract in contracts for contract in contracts
] ]
# Roads / public lighting / sign / artworks: municipality on locations # Roads / public lighting / sign / artworks: municipality on locations (only if more than 1 option)
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']: if len(municipality_options) > 1:
if thematic_code in accessible_codes: for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']:
config.setdefault(thematic_code, []).append({ if thematic_code in accessible_codes:
'key': 'municipality', config.setdefault(thematic_code, []).append({
'label': _('Commune'), 'key': 'municipality',
'type': 'multi', 'label': _('Commune'),
'options': municipality_options, 'type': 'multi',
}) 'options': municipality_options,
})
# Traffic lights # Traffic lights
if 'trafficlights' in accessible_codes: if 'trafficlights' in accessible_codes:
@ -788,8 +789,22 @@ def build_conditional_filter_config(accessible_thematics, user=None):
# Public buildings specific choices # Public buildings specific choices
from assets.models.buildings import USAGE_TYPE_CHOICES from assets.models.buildings import USAGE_TYPE_CHOICES
building_usage_icons = {
'administrative': 'bi-building',
'education': 'bi-mortarboard',
'sports': 'bi-trophy',
'culture': 'bi-palette',
'social': 'bi-people',
'technical': 'bi-tools',
'housing': 'bi-house-door',
'other': 'bi-building-gear',
}
building_usage_options = [ building_usage_options = [
{'value': str(key), 'label': str(value)} {
'value': str(key),
'label': str(value),
'icon': building_usage_icons.get(str(key), 'bi-building'),
}
for key, value in USAGE_TYPE_CHOICES for key, value in USAGE_TYPE_CHOICES
] ]
@ -1073,6 +1088,14 @@ def unified_assets_index(request):
{'id': c.id, 'name': c.get_name()} for c in cats {'id': c.id, 'name': c.get_name()} for c in cats
] ]
# Si des thématiques sont spécifiées dans l'URL mais sans catégories explicites,
# activer l'ensemble des catégories de ces thématiques par défaut pour charger les assets
if has_url_thematics and not selected_categories:
selected_categories = []
for t_code in selected_thematics:
for cat in thematic_categories.get(t_code, []):
selected_categories.append(f"{t_code}::{cat['name']}")
link_assets_url = ( link_assets_url = (
reverse( reverse(
'interventions:intervention_preventive_link_elements', 'interventions:intervention_preventive_link_elements',

View file

@ -339,6 +339,13 @@ def nature_assets_geojson(request):
def obj_to_feature(obj, asset_type=None): def obj_to_feature(obj, asset_type=None):
geometry, lon, lat = build_geometry_and_coords(obj) geometry, lon, lat = build_geometry_and_coords(obj)
detail_url = reverse('assets:nature_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) if asset_type else None detail_url = reverse('assets:nature_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) if asset_type else None
model_name = obj.get('model_name') or ''
code = obj.get('code') or ''
is_planter = asset_type == 'naturebench' and (
any(w in model_name.lower() for w in ('bac', 'jardin', 'bloem', 'plante', 'fleur'))
or code.startswith('CSE-BAC')
)
element_type_label = _('Bac à fleurs') if is_planter else get_element_type_label(asset_type or '')
return { return {
"type": "Feature", "type": "Feature",
"id": obj['id'], "id": obj['id'],
@ -355,11 +362,12 @@ def nature_assets_geojson(request):
"lon": lon, "lon": lon,
"lat": lat, "lat": lat,
"asset_type": asset_type, "asset_type": asset_type,
"asset_sub_type": "planter" if is_planter else ("bench" if asset_type == "naturebench" else ""),
"area": obj.get('surface_area', ''), "area": obj.get('surface_area', ''),
"color": obj['color'] if obj['color'] else '#ccc', "color": obj['color'] if obj['color'] else '#ccc',
"detail_url": detail_url, "detail_url": detail_url,
"bulb_types": obj.get('bulb_types') or [], "bulb_types": obj.get('bulb_types') or [],
"element_type": get_element_type_label(asset_type or ''), "element_type": element_type_label,
"thematic": "nature", "thematic": "nature",
"is_location": False, "is_location": False,
"structure_type": obj.get('structure_type', ''), "structure_type": obj.get('structure_type', ''),

View file

@ -65,7 +65,7 @@ def publiclighting_index(request):
from assets.permissions import get_user_accessible_municipalities from assets.permissions import get_user_accessible_municipalities
municipalities = list(get_user_accessible_municipalities(request.user)) municipalities = list(get_user_accessible_municipalities(request.user))
municipalities.sort(key=lambda m: m.name.lower()) municipalities.sort(key=lambda m: m.name.lower())
municipality_choices = [(m.id, m.name) for m in municipalities] municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
categories = list(AssetCategory.objects.filter(thematic__code = 'publiclighting')) categories = list(AssetCategory.objects.filter(thematic__code = 'publiclighting'))
categories.sort(key=lambda m: m.get_name().lower()) categories.sort(key=lambda m: m.get_name().lower())

View file

@ -68,7 +68,7 @@ def roads_index(request):
from assets.permissions import get_user_accessible_municipalities from assets.permissions import get_user_accessible_municipalities
municipalities = list(get_user_accessible_municipalities(request.user)) municipalities = list(get_user_accessible_municipalities(request.user))
municipalities.sort(key=lambda m: m.name.lower()) municipalities.sort(key=lambda m: m.name.lower())
municipality_choices = [(m.id, m.name) for m in municipalities] municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
categories = list(AssetCategory.objects.filter(thematic__code = 'roads')) categories = list(AssetCategory.objects.filter(thematic__code = 'roads'))
categories.sort(key=lambda m: m.get_name().lower()) categories.sort(key=lambda m: m.get_name().lower())
@ -305,10 +305,11 @@ def roads_assets_geojson(request):
"geometry": geometry, "geometry": geometry,
"properties": { "properties": {
"code": obj.code, "code": obj.code,
"name": obj.code, "name": obj.get_name() if hasattr(obj, "get_name") else (getattr(obj, "name_fr", None) or obj.code),
"category": obj.category.get_name() if obj.category else None, "category": obj.category.get_name() if obj.category else None,
"model": model.get_name() if model else None, "model": model.get_name() if model else None,
"quality_level": obj.get_quality_level_display() if hasattr(obj, 'get_quality_level_display') else None, "quality_level": obj.get_quality_level_display() if hasattr(obj, 'get_quality_level_display') else None,
"quality_level_value": getattr(obj, 'quality_level', None),
"status": obj.status, "status": obj.status,
"validation_status": obj.validation_status, "validation_status": obj.validation_status,
"lon": lon, "lon": lon,

View file

@ -73,7 +73,7 @@ def sign_index(request):
from assets.permissions import get_user_accessible_municipalities from assets.permissions import get_user_accessible_municipalities
municipalities = list(get_user_accessible_municipalities(request.user)) municipalities = list(get_user_accessible_municipalities(request.user))
municipalities.sort(key=lambda m: m.name.lower()) municipalities.sort(key=lambda m: m.name.lower())
municipality_choices = [(m.id, m.name) for m in municipalities] municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
categories = list(AssetCategory.objects.filter(thematic__code = 'sign')) categories = list(AssetCategory.objects.filter(thematic__code = 'sign'))
categories.sort(key=lambda m: m.get_name().lower()) categories.sort(key=lambda m: m.get_name().lower())

View file

@ -16,8 +16,8 @@
cursor: pointer; cursor: pointer;
} }
.dropdown-item-checkbox .form-check-input:checked { .dropdown-item-checkbox .form-check-input:checked {
background-color: var(--brand-color); background-color: #0d6efd;
border-color: var(--brand-color); border-color: #0d6efd;
} }
.dropdown-item-checkbox .form-check-label { .dropdown-item-checkbox .form-check-label {
cursor: pointer; cursor: pointer;
@ -26,7 +26,7 @@
} }
.dropdown-item-checkbox .form-check-input:checked + .form-check-label { .dropdown-item-checkbox .form-check-input:checked + .form-check-label {
font-weight: 600; font-weight: 600;
color: var(--brand-color) !important; color: #0f172a !important;
} }
th.sortable:hover { th.sortable:hover {
background-color: rgba(47, 62, 139, 0.08) !important; background-color: rgba(47, 62, 139, 0.08) !important;

View file

@ -8,6 +8,7 @@ from django.utils import timezone
from datetime import timedelta 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,
UserConfig, UserContractAccess, Thematic, Role, UserThematics, UserConfig, UserContractAccess, Thematic, Role, UserThematics,
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess, AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
UserThematicStatusPermission, UserContractStatusPermission, UserThematicStatusPermission, UserContractStatusPermission,
@ -1012,3 +1013,31 @@ class UserAssetTypeAccessAdmin(admin.ModelAdmin):
url = f"/assets/permissions/type/{obj.content_type_id}/" url = f"/assets/permissions/type/{obj.content_type_id}/"
return format_html('<a href="{}" target="_blank">Page du type</a>', url) return format_html('<a href="{}" target="_blank">Page du type</a>', url)
type_page_link.short_description = "Page dédiée" type_page_link.short_description = "Page dédiée"
@admin.register(MapBackground)
class MapBackgroundAdmin(admin.ModelAdmin):
list_display = ("name_fr", "name_nl", "region_badge", "category_badge", "service_type", "order", "is_active", "is_default")
list_filter = ("region", "category", "service_type", "is_active", "is_default")
list_editable = ("order", "is_active", "is_default")
search_fields = ("name_fr", "name_nl", "identifier", "attribution", "url")
ordering = ("order", "name_fr")
@admin.display(description="Région")
def region_badge(self, obj):
colors = {
'WAL': '#dc2626',
'BRU': '#2563eb',
'VLG': '#d97706',
'ALL': '#4b5563',
}
bg = colors.get(obj.region, '#4b5563')
return format_html(
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;">{}</span>',
bg,
obj.get_region_display()
)
@admin.display(description="Catégorie")
def category_badge(self, obj):
return obj.get_category_display()

View file

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
from typing import Iterable, List from typing import Iterable, List
@ -12,6 +14,7 @@ from .models import UserConfig, AppView
SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids" SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids"
SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id" SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id"
SESSION_MAP_BOUNDS_KEY = "user_map_bounds"
from django.conf import settings from django.conf import settings
@ -155,6 +158,130 @@ def invalidate_user_map_bounds(user_id: int) -> None:
cache.delete(f"user_map_bounds_{user_id}") cache.delete(f"user_map_bounds_{user_id}")
def get_active_map_backgrounds(request=None) -> list:
"""
Returns list of active basemap layers from MapBackground model, cached.
Fallback to defaults if table does not exist or is empty.
Prioritizes basemaps matching user contract municipality region (WAL, BRU, VLG).
"""
cached = cache.get("active_map_backgrounds")
if cached is not None:
return cached
backgrounds = []
try:
from .models import MapBackground
from django.utils.translation import get_language
lang = get_language() or 'fr'
qs = list(MapBackground.objects.filter(is_active=True).order_by('order', 'id'))
# Detect regional priority if user has an assigned municipality
preferred_region = 'ALL'
if request and hasattr(request, 'user') and request.user.is_authenticated:
try:
user_config = getattr(request.user, 'config', None)
if user_config and user_config.default_contract:
muni = user_config.default_contract.municipalities.exclude(geom=None).first()
if muni and muni.niscode:
code = str(muni.niscode).strip()
if code.startswith('21'):
preferred_region = 'BRU'
elif code[:2] in ('25', '51', '52', '53', '55', '56', '57', '61', '62', '63', '81', '82', '83', '84', '85', '91', '92', '93') or code[0] in ('5', '6', '8', '9'):
preferred_region = 'WAL'
elif code[0] in ('1', '3', '4', '7') or code[:2] in ('23', '24'):
preferred_region = 'VLG'
except Exception:
pass
if preferred_region != 'ALL':
# Stable sort: matching region first, then remaining
qs.sort(key=lambda x: (0 if x.region == preferred_region else (1 if x.region == 'ALL' else 2), x.order))
for bg in qs:
backgrounds.append({
'id': bg.identifier,
'name': bg.name_nl if lang == 'nl' and bg.name_nl else bg.name_fr,
'name_fr': bg.name_fr,
'name_nl': bg.name_nl,
'service_type': bg.service_type,
'url': bg.url,
'layers': bg.layers,
'format': bg.format,
'attribution': bg.attribution,
'region': bg.region,
'category': bg.category,
'tileSize': bg.tile_size,
'minZoom': bg.min_zoom,
'maxZoom': bg.max_zoom,
'is_default': bg.is_default,
})
except Exception:
pass
if not backgrounds:
backgrounds = [
{
'id': 'ortho_wallonie',
'name': 'Ortho Wallonie (SPW)',
'name_fr': 'Ortho Wallonie (SPW)',
'name_nl': 'Ortho Wallonië (SPW)',
'service_type': 'wms',
'url': 'https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&version=1.3.0&request=GetMap&layers=0&styles=&format=image/jpeg&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256',
'layers': '0',
'format': 'image/jpeg',
'attribution': '© SPW - Orthophoto Wallonie',
'region': 'WAL',
'category': 'ortho',
'tileSize': 256,
},
{
'id': 'urbis_ortho',
'name': 'Urbis Ortho (Bruxelles)',
'name_fr': 'Urbis Ortho (Bruxelles)',
'name_nl': 'Urbis Ortho (Brussel)',
'service_type': 'wms',
'url': getattr(settings, 'MAP_ORTHO_WMS_URL', None) or 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}',
'layers': 'Ortho',
'format': 'image/png',
'attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', None) or '© Brussels UrbIS',
'region': 'BRU',
'category': 'ortho',
'tileSize': 256,
},
{
'id': 'ortho_vlaanderen',
'name': 'Ortho Vlaanderen',
'name_fr': 'Ortho Vlaanderen',
'name_nl': 'Ortho Vlaanderen',
'service_type': 'wmts',
'url': 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
'layers': 'omwrgb23vl',
'format': 'image/png',
'attribution': '© Digitaal Vlaanderen',
'region': 'VLG',
'category': 'ortho',
'tileSize': 256,
},
{
'id': 'plan_wallonie_spw',
'name': 'Plan de base Wallonie (SPW)',
'name_fr': 'Plan de base Wallonie (SPW)',
'name_nl': 'Basiskaart Wallonië (SPW)',
'service_type': 'arcgis_rest',
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
'layers': '',
'format': 'image/png',
'attribution': '© SPW - Fond de plan Wallonie',
'region': 'WAL',
'category': 'plan',
'tileSize': 256,
},
]
cache.set("active_map_backgrounds", backgrounds, 3600)
return backgrounds
def organization_info(request): def organization_info(request):
""" """
Context processor that exposes organization details from settings. Context processor that exposes organization details from settings.
@ -237,6 +364,8 @@ def organization_info(request):
'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''), 'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''),
'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''), 'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''),
'map_ortho_wms_attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', ''), 'map_ortho_wms_attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', ''),
'map_backgrounds': get_active_map_backgrounds(request),
'map_backgrounds_json': json.dumps(get_active_map_backgrounds(request)),
} }

View file

@ -0,0 +1,145 @@
# Generated manually for dynamic MapBackgrounds
from django.db import migrations, models
def seed_map_backgrounds(apps, schema_editor):
MapBackground = apps.get_model('common', 'MapBackground')
initial_data = [
{
'identifier': 'ortho_wallonie',
'name_fr': 'Ortho Wallonie (SPW)',
'name_nl': 'Ortho Wallonië (SPW)',
'service_type': 'wms',
'url': 'https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&version=1.3.0&request=GetMap&layers=0&styles=&format=image/jpeg&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256',
'layers': '0',
'format': 'image/jpeg',
'attribution': '© SPW - Orthophoto Wallonie',
'region': 'WAL',
'category': 'ortho',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 10,
'is_active': True,
'is_default': False,
},
{
'identifier': 'urbis_ortho',
'name_fr': 'Urbis Ortho (Bruxelles)',
'name_nl': 'Urbis Ortho (Brussel)',
'service_type': 'wms',
'url': 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}',
'layers': 'Ortho',
'format': 'image/png',
'attribution': '© Brussels UrbIS',
'region': 'BRU',
'category': 'ortho',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 20,
'is_active': True,
'is_default': False,
},
{
'identifier': 'ortho_vlaanderen',
'name_fr': 'Ortho Vlaanderen',
'name_nl': 'Ortho Vlaanderen',
'service_type': 'wmts',
'url': 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
'layers': 'omwrgb23vl',
'format': 'image/png',
'attribution': '© Digitaal Vlaanderen',
'region': 'VLG',
'category': 'ortho',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 30,
'is_active': True,
'is_default': False,
},
{
'identifier': 'plan_wallonie_spw',
'name_fr': 'Plan de base Wallonie (SPW)',
'name_nl': 'Basiskaart Wallonië (SPW)',
'service_type': 'arcgis_rest',
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
'layers': '',
'format': 'image/png',
'attribution': '© SPW - Fond de plan Wallonie',
'region': 'WAL',
'category': 'plan',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 40,
'is_active': True,
'is_default': False,
},
{
'identifier': 'plan_leger_wallonie_spw',
'name_fr': 'Plan léger Wallonie (SPW)',
'name_nl': 'Lichte basiskaart Wallonië (SPW)',
'service_type': 'arcgis_rest',
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW_LEGER/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
'layers': '',
'format': 'image/png',
'attribution': '© SPW - Fond de plan léger',
'region': 'WAL',
'category': 'plan',
'tile_size': 256,
'min_zoom': 0,
'max_zoom': 21,
'order': 50,
'is_active': True,
'is_default': False,
},
]
for item in initial_data:
MapBackground.objects.get_or_create(identifier=item['identifier'], defaults=item)
def rollback_seed(apps, schema_editor):
MapBackground = apps.get_model('common', 'MapBackground')
MapBackground.objects.filter(identifier__in=[
'ortho_wallonie', 'urbis_ortho', 'ortho_vlaanderen', 'plan_wallonie_spw', 'plan_leger_wallonie_spw'
]).delete()
class Migration(migrations.Migration):
dependencies = [
('common', '0015_remove_obsolete_schools_appview'),
]
operations = [
migrations.CreateModel(
name='MapBackground',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('identifier', models.SlugField(help_text='Ex: urbis_ortho, ortho_vlaanderen, ortho_wallonie', unique=True, verbose_name='Identifiant unique')),
('name_fr', models.CharField(max_length=150, verbose_name='Nom FR')),
('name_nl', models.CharField(max_length=150, verbose_name='Nom NL')),
('service_type', models.CharField(choices=[('wms', 'WMS (Web Map Service)'), ('wmts', 'WMTS (Web Map Tile Service)'), ('xyz', 'XYZ / Tuiles standard'), ('arcgis_rest', 'ArcGIS REST Export')], default='wms', max_length=20, verbose_name='Type de service')),
('url', models.TextField(help_text='Supporte {bbox-epsg-3857} pour WMS/Export, et {z}/{x}/{y} pour WMTS/XYZ.', verbose_name='URL du flux')),
('layers', models.CharField(blank=True, help_text='Nom de couche WMS si applicable (ex: Ortho, 0, ORTHO_LAST)', max_length=255, verbose_name='Couche(s)')),
('format', models.CharField(default='image/jpeg', help_text='ex: image/jpeg, image/png', max_length=50, verbose_name="Format d'image")),
('attribution', models.CharField(blank=True, help_text='ex: © SPW - Orthophoto Wallonie', max_length=255, verbose_name='Attribution / Copyright')),
('region', models.CharField(choices=[('ALL', 'Toutes régions (National / Global)'), ('BRU', 'Bruxelles (StreetUp)'), ('WAL', 'Wallonie'), ('VLG', 'Flandre')], default='ALL', max_length=10, verbose_name='Région')),
('category', models.CharField(choices=[('ortho', 'Orthophoto / Vue aérienne'), ('plan', 'Plan de base / Topographique'), ('cadastre', 'Cadastre / Parcellaire'), ('other', 'Autre')], default='ortho', max_length=20, verbose_name='Catégorie')),
('tile_size', models.PositiveIntegerField(default=256, verbose_name='Taille des tuiles (px)')),
('min_zoom', models.PositiveIntegerField(default=0, verbose_name='Zoom minimum')),
('max_zoom', models.PositiveIntegerField(default=21, verbose_name='Zoom maximum')),
('order', models.PositiveIntegerField(default=0, verbose_name="Ordre d'affichage")),
('is_active', models.BooleanField(default=True, verbose_name='Actif')),
('is_default', models.BooleanField(default=False, verbose_name='Sélectionné par défaut')),
],
options={
'verbose_name': 'Fond de plan',
'verbose_name_plural': 'Fonds de plan',
'ordering': ['order', 'name_fr'],
},
),
migrations.RunPython(seed_map_backgrounds, rollback_seed),
]

View file

@ -1321,5 +1321,74 @@ class RequestLog(models.Model):
return log_entry return log_entry
class MapBackground(models.Model):
SERVICE_TYPE_CHOICES = [
('wms', _('WMS (Web Map Service)')),
('wmts', _('WMTS (Web Map Tile Service)')),
('xyz', _('XYZ / Tuiles standard')),
('arcgis_rest', _('ArcGIS REST Export')),
]
REGION_CHOICES = [
('ALL', _('Toutes régions (National / Global)')),
('BRU', _('Bruxelles (StreetUp)')),
('WAL', _('Wallonie')),
('VLG', _('Flandre')),
]
CATEGORY_CHOICES = [
('ortho', _('Orthophoto / Vue aérienne')),
('plan', _('Plan de base / Topographique')),
('cadastre', _('Cadastre / Parcellaire')),
('other', _('Autre')),
]
identifier = models.SlugField(max_length=50, unique=True, verbose_name=_("Identifiant unique"), help_text=_("Ex: urbis_ortho, ortho_vlaanderen, ortho_wallonie"))
name_fr = models.CharField(max_length=150, verbose_name=_("Nom FR"))
name_nl = models.CharField(max_length=150, verbose_name=_("Nom NL"))
service_type = models.CharField(max_length=20, choices=SERVICE_TYPE_CHOICES, default='wms', verbose_name=_("Type de service"))
url = models.TextField(verbose_name=_("URL du flux"), help_text=_("Supporte {bbox-epsg-3857} pour WMS/Export, et {z}/{x}/{y} pour WMTS/XYZ."))
layers = models.CharField(max_length=255, blank=True, verbose_name=_("Couche(s)"), help_text=_("Nom de couche WMS si applicable (ex: Ortho, 0, ORTHO_LAST)"))
format = models.CharField(max_length=50, default="image/jpeg", verbose_name=_("Format d'image"), help_text=_("ex: image/jpeg, image/png"))
attribution = models.CharField(max_length=255, blank=True, verbose_name=_("Attribution / Copyright"), help_text=_("ex: © SPW - Orthophoto Wallonie"))
region = models.CharField(max_length=10, choices=REGION_CHOICES, default='ALL', verbose_name=_("Région"))
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='ortho', verbose_name=_("Catégorie"))
tile_size = models.PositiveIntegerField(default=256, verbose_name=_("Taille des tuiles (px)"))
min_zoom = models.PositiveIntegerField(default=0, verbose_name=_("Zoom minimum"))
max_zoom = models.PositiveIntegerField(default=21, verbose_name=_("Zoom maximum"))
order = models.PositiveIntegerField(default=0, verbose_name=_("Ordre d'affichage"))
is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
is_default = models.BooleanField(default=False, verbose_name=_("Sélectionné par défaut"))
@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 = _("Fond de plan")
verbose_name_plural = _("Fonds de plan")
ordering = ['order', 'name_fr']
def __str__(self):
return f"{self.name} ({self.get_region_display()})"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
from django.core.cache import cache
cache.delete("active_map_backgrounds")
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
from django.core.cache import cache
cache.delete("active_map_backgrounds")
# 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

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ app_name = "common"
urlpatterns = [ urlpatterns = [
path("address/autocomplete-street/", views.autocomplete_street, name="autocomplete_street"), path("address/autocomplete-street/", views.autocomplete_street, name="autocomplete_street"),
path("address/get-nearest-address/", views.get_nearest_address, name="get_nearest_address"), path("address/get-nearest-address/", views.get_nearest_address, name="get_nearest_address"),
path("territory-mask-geojson/", views.get_territory_mask_geojson, name="get_territory_mask_geojson"),
path("get-regional-roads-geojson/", views.get_regional_roads_geojson, name="get_regional_roads_geojson"), path("get-regional-roads-geojson/", views.get_regional_roads_geojson, name="get_regional_roads_geojson"),
path("get-thematic-categories/", views.get_categories_for_thematics, name="get_thematic_categories"), path("get-thematic-categories/", views.get_categories_for_thematics, name="get_thematic_categories"),
path("account-info", views.account_info, name='account_info'), path("account-info", views.account_info, name='account_info'),

View file

@ -1,3 +1,4 @@
from django.core.cache import cache
from django.http import JsonResponse, FileResponse, Http404 from django.http import JsonResponse, FileResponse, Http404
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
@ -5,7 +6,7 @@ from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Q
from django.contrib import messages from django.contrib import messages
from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.db.models.functions import Distance
from django.contrib.gis.geos import Point from django.contrib.gis.geos import Point, Polygon
from django.contrib.auth import login as auth_login from django.contrib.auth import login as auth_login
from django.contrib.auth.decorators import login_not_required from django.contrib.auth.decorators import login_not_required
from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.forms import PasswordChangeForm
@ -149,6 +150,111 @@ def get_nearest_address(request):
@login_not_required @login_not_required
@require_GET @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.
"""
import json
user = request.user
muni_params = request.GET.getlist('municipality')
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
if user_config and user_config.default_contract and user_config.default_contract.municipalities.exists():
muni_ids = list(user_config.default_contract.municipalities.values_list('id', flat=True))
elif user_config and user_config.contracts.filter(municipalities__isnull=False).exists():
muni_ids = list(Municipality.objects.filter(contracts__in=user_config.contracts.all()).values_list('id', flat=True).distinct())
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])
if not muni_ids:
return JsonResponse({"type": "FeatureCollection", "features": []})
muni_ids = sorted(list(set(muni_ids)))
cache_key = f"territory_mask_geojson_{'_'.join(map(str, muni_ids))}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return JsonResponse(cached_data)
munis = Municipality.objects.filter(id__in=muni_ids, geom__isnull=False)
if not munis.exists():
empty_res = {"type": "FeatureCollection", "features": []}
return JsonResponse(empty_res)
combined_geom = None
names = []
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
else:
combined_geom = combined_geom.union(g_simp)
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))
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)
def get_regional_roads_geojson(request): def get_regional_roads_geojson(request):
file_path = os.path.join(settings.BASE_DIR, 'common', 'static', 'geojson', 'regional_roads_simplified_4326.geojson') file_path = os.path.join(settings.BASE_DIR, 'common', 'static', 'geojson', 'regional_roads_simplified_4326.geojson')

View file

@ -20,7 +20,7 @@
</span> </span>
{% endif %} {% endif %}
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}" <a href="{% url 'documents:list' %}?folder={{ folder.slug }}"
class="nav-link flex-grow-1 d-flex align-items-center p-2 {% if active_slug == folder.slug %}active{% endif %} {% if folder.slug in trail_slugs and active_slug != folder.slug %} fw-semibold{% endif %}" style="color: var(--brand-color);"> class="nav-link flex-grow-1 d-flex align-items-center p-2 {% if active_slug == folder.slug %}active{% endif %} {% if folder.slug in trail_slugs and active_slug != folder.slug %} fw-semibold{% endif %}" >
<i class="bi bi-folder{% if active_slug == folder.slug %}-fill{% endif %}"></i> <i class="bi bi-folder{% if active_slug == folder.slug %}-fill{% endif %}"></i>
<span class="ms-2 text d-none d-md-inline">{{ folder.name }}</span> <span class="ms-2 text d-none d-md-inline">{{ folder.name }}</span>
</a> </a>

View file

@ -4,6 +4,8 @@ from django.utils.translation import gettext_lazy as _
# Widget code choices # Widget code choices
WIDGET_CODE_CHOICES = [ WIDGET_CODE_CHOICES = [
('territory_kpis', _('Métriques du patrimoine')),
('recent_activity', _("Radar d'activité")),
('intervention_stats', _('Statistiques interventions')), ('intervention_stats', _('Statistiques interventions')),
('contracts_summary', _('Résumé contrats')), ('contracts_summary', _('Résumé contrats')),
('map_today', _('Carte du jour')), ('map_today', _('Carte du jour')),

View file

@ -76,7 +76,7 @@
font-size: 1rem; font-size: 1rem;
} }
.main-button { .main-button {
border: 1px solid var(--brand-color); border: 1px solid #e2e8f0;
width: 230px; width: 230px;
min-height: 62px; min-height: 62px;
display: inline-flex; display: inline-flex;
@ -155,17 +155,7 @@
color: var(--bs-body-color); color: var(--bs-body-color);
} }
.map-legend { /* old map-legend replaced by .activity-map-legend */
position: absolute;
top: 10px;
right: 10px;
background-color: rgba(255, 255, 255, 0.85);
padding: 10px 12px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
font-size: 0.9rem;
z-index: 1000;
}
.map-legend .badge { .map-legend .badge {
display: inline-flex; display: inline-flex;
@ -180,7 +170,7 @@
/* Tuiles raccourcis symptômes pour opérateurs */ /* Tuiles raccourcis symptômes pour opérateurs */
.shortcut-symptom-card { .shortcut-symptom-card {
background-color: rgba(255, 255, 255, 0.95); background-color: rgba(255, 255, 255, 0.95);
border: 1px solid var(--brand-color); border: 1px solid #e2e8f0;
border-radius: 0.75rem; border-radius: 0.75rem;
transition: transform 0.2s ease-in-out, box-shadow 0.2s; transition: transform 0.2s ease-in-out, box-shadow 0.2s;
color: var(--bs-body-color); color: var(--bs-body-color);
@ -211,4 +201,250 @@
background-color: rgba(255, 255, 255, 0.3); background-color: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.8); border-color: rgba(255, 255, 255, 0.8);
transform: translateY(-2px); transform: translateY(-2px);
} }
/* ==========================================================================
Quick Actions Bar Modernisée (Header Accueil)
========================================================================== */
.quick-actions-bar {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.quick-action-btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 9px 18px;
min-height: 48px;
background: #ffffff;
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 1px 2px rgba(15, 23, 42, 0.02);
text-decoration: none;
color: #1e293b;
font-size: 0.88rem;
font-weight: 600;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.quick-action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.12);
border-color: rgba(37, 99, 235, 0.35);
color: #1d4ed8;
}
.quick-action-btn:active {
transform: translateY(0);
}
.quick-action-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
transition: transform 0.2s ease, background 0.2s ease;
background: rgba(37, 99, 235, 0.1) !important;
color: #2563eb !important;
}
.quick-action-btn:hover .quick-action-icon {
transform: scale(1.08);
background: rgba(37, 99, 235, 0.18) !important;
color: #1d4ed8 !important;
}
/* ==========================================================================
Widget Patrimoine du Territoire (Cartes Thématiques & KPIs)
========================================================================== */
.territory-kpis-widget {
margin-bottom: 1.5rem;
}
.kpi-header-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(13, 110, 253, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.15rem;
}
/* Cartes KPI Patrimoine */
.kpi-card {
border-radius: 12px;
background: #ffffff;
transition: transform 0.22s ease, box-shadow 0.22s ease, border-color 0.22s ease;
border: 1px solid rgba(226, 232, 240, 0.9) !important;
position: relative;
overflow: hidden;
}
.kpi-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3.5px;
background: #cbd5e1;
transition: height 0.2s ease;
}
.kpi-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08) !important;
}
/* Bordures supérieures colorées par thématique */
.kpi-card-nature::before { background: #10b981 !important; }
.kpi-card-lighting::before { background: #f59e0b !important; }
.kpi-card-sign::before { background: #3b82f6 !important; }
.kpi-card-roads::before { background: #06b6d4 !important; }
.kpi-card-structures::before { background: #8b5cf6 !important; }
/* Badges thématiques */
.kpi-badge {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 8px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.kpi-badge-nature { background: rgba(16, 185, 129, 0.12) !important; color: #059669 !important; }
.kpi-badge-lighting { background: rgba(245, 158, 11, 0.12) !important; color: #d97706 !important; }
.kpi-badge-sign { background: rgba(59, 130, 246, 0.12) !important; color: #2563eb !important; }
.kpi-badge-roads { background: rgba(6, 182, 212, 0.12) !important; color: #0891b2 !important; }
.kpi-badge-structures { background: rgba(139, 92, 246, 0.12) !important; color: #7c3aed !important; }
.kpi-pill-total {
font-size: 0.72rem;
font-weight: 700;
color: #64748b;
background: #f1f5f9;
padding: 2px 8px;
border-radius: 20px;
}
.kpi-stat-value {
font-size: 1.55rem;
line-height: 1.1;
color: #1e293b;
letter-spacing: -0.02em;
}
.kpi-stat-unit {
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
margin-left: 2px;
}
.kpi-details {
font-size: 0.75rem;
line-height: 1.45;
border-top: 1px dashed #f1f5f9;
padding-top: 6px;
}
/* ==========================================================================
Carte Radar Activité Récente
========================================================================== */
.recent-activity-card {
border-radius: 12px;
background: #ffffff;
border: 1px solid rgba(226, 232, 240, 0.9) !important;
}
.recent-act-icon {
width: 32px;
height: 32px;
background: rgba(13, 110, 253, 0.08);
}
.recent-activity-item {
transition: background-color 0.15s ease;
overflow-x: hidden !important;
}
.recent-activity-item:hover {
background-color: #f8fafc !important;
}
.activity-icon-badge {
width: 30px;
height: 30px;
font-size: 0.82rem;
}
.activity-badge-intervention { background: rgba(13, 110, 253, 0.12) !important; color: #0d6efd !important; }
.activity-badge-observation { background: rgba(245, 158, 11, 0.15) !important; color: #d97706 !important; }
.activity-badge-inspection { background: rgba(139, 92, 246, 0.15) !important; color: #7c3aed !important; }
.activity-badge-control { background: rgba(6, 182, 212, 0.15) !important; color: #0891b2 !important; }
/* Pastilles d'activités aux couleurs des 5 thématiques (distinction des opérations par symbole) */
.activity-thematic-nature {
background: rgba(16, 185, 129, 0.15) !important;
color: #059669 !important;
border: 1px solid rgba(16, 185, 129, 0.28) !important;
}
.activity-thematic-lighting {
background: rgba(245, 158, 11, 0.15) !important;
color: #d97706 !important;
border: 1px solid rgba(245, 158, 11, 0.28) !important;
}
.activity-thematic-sign {
background: rgba(59, 130, 246, 0.15) !important;
color: #2563eb !important;
border: 1px solid rgba(59, 130, 246, 0.28) !important;
}
.activity-thematic-roads {
background: rgba(6, 182, 212, 0.15) !important;
color: #0891b2 !important;
border: 1px solid rgba(6, 182, 212, 0.28) !important;
}
.activity-thematic-structures {
background: rgba(139, 92, 246, 0.15) !important;
color: #7c3aed !important;
border: 1px solid rgba(139, 92, 246, 0.28) !important;
}
/* Légende flottante activité territoriale (ancrée en bas à gauche, compacte) */
.activity-map-legend {
position: absolute !important;
bottom: 16px !important;
left: 16px !important;
top: auto !important;
right: auto !important;
width: auto !important;
max-width: 260px !important;
height: auto !important;
z-index: 10 !important;
background: rgba(255, 255, 255, 0.94) !important;
backdrop-filter: blur(6px) !important;
padding: 8px 12px !important;
border-radius: 8px !important;
border: 1px solid rgba(226, 232, 240, 0.9) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
pointer-events: auto !important;
}
/* Éviter le débordement horizontal dans le radar d'activité */
.recent-activity-card .card-body {
overflow-x: hidden !important;
}

View file

@ -20,7 +20,7 @@ function statusFormatter(value) {
const entry = icons[value]; const entry = icons[value];
if (!entry) return value; if (!entry) return value;
return `<span class="badge text-bg-${entry.color} status-${value}"><i class="bi ${entry.icon} me-2"></i>${entry.label}</span>`; return `<span class="badge text-bg-${entry.color} status-${value}"><i class="bi ${entry.icon} me-1"></i>${entry.label}</span>`;
} }
const mapContainer = document.getElementById("map"); const mapContainer = document.getElementById("map");
@ -28,163 +28,341 @@ const mapContainer = document.getElementById("map");
if (mapContainer && typeof maplibregl !== 'undefined') { if (mapContainer && typeof maplibregl !== 'undefined') {
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: mapContainer, container: mapContainer,
style: window.MAP_STYLE_LIGHT, style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [4.3572, 50.8477], center: [4.568, 50.645], // Court-Saint-Étienne default
zoom: 11, zoom: 12.0,
minzoom: 0, minzoom: 8,
maxzoom: 24 maxzoom: 20
}); });
async function fetchAndMergeGeoJSON(urls) { map.addControl(new maplibregl.NavigationControl(), 'top-left');
const allFeatures = [];
for (const url of urls) { let currentMode = 'heatmap'; // 'heatmap' or 'points'
try {
const response = await fetch(url);
const data = await response.json();
if (data && data.features) {
allFeatures.push(...data.features);
}
} catch (err) {
console.warn('Error fetching geojson:', url, err);
}
}
return {
type: 'FeatureCollection',
features: allFeatures
};
}
map.on('load', async () => { map.on('load', async () => {
if (typeof interventions_geojson_urls !== 'undefined' && interventions_geojson_urls.length > 0) { // 1. Charger le masque de mise en lumière du territoire communal
const mergedGeoJSON = await fetchAndMergeGeoJSON(interventions_geojson_urls); try {
const maskResp = await fetch('/common/territory-mask-geojson/');
if (maskResp.ok) {
const maskData = await maskResp.json();
map.addSource('territory-mask', {
type: 'geojson',
data: maskData
});
map.addSource('interventions', { // Masque inversé (assombrit l'extérieur de la commune de manière subtile et nette)
'type': 'geojson', map.addLayer({
'data': mergedGeoJSON id: 'territory-mask-fill',
}); type: 'fill',
source: 'territory-mask',
map.addLayer({ filter: ['any',
'id': 'interventions', ['==', ['get', 'layer'], 'mask'],
'type': 'circle', ['==', ['get', 'type'], 'mask'],
'source': 'interventions', ['==', ['id'], 'territory_mask']
minzoom: 0,
maxzoom: 24,
'paint': {
'circle-radius': [
'interpolate',
['linear'],
['zoom'],
10, 5,
22, 15
], ],
'circle-color': typeof INTERVENTION_STATUS_COLOR_EXPR !== 'undefined' ? INTERVENTION_STATUS_COLOR_EXPR : '#0094ff', paint: {
'circle-opacity': 0.7 'fill-color': '#0f172a',
'fill-opacity': 0.14
}
});
// Frontière communale nette
map.addLayer({
id: 'territory-mask-outline',
type: 'line',
source: 'territory-mask',
filter: ['any',
['==', ['get', 'layer'], 'boundary'],
['==', ['get', 'type'], 'boundary'],
['==', ['id'], 'territory_boundary']
],
paint: {
'line-color': '#0d6efd',
'line-width': 2.5,
'line-dasharray': [3, 2],
'line-opacity': 0.9
}
});
// Cadrage automatique rigoureux sur l'ensemble de la commune
const boundaryFeat = (maskData.features || []).find(f =>
f.id === 'territory_boundary' ||
(f.properties && (f.properties.layer === 'boundary' || f.properties.type === 'boundary'))
);
if (boundaryFeat && boundaryFeat.geometry) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
function checkPt(pt) {
if (pt[0] < minX) minX = pt[0];
if (pt[0] > maxX) maxX = pt[0];
if (pt[1] < minY) minY = pt[1];
if (pt[1] > maxY) maxY = pt[1];
}
function scanCoords(coords) {
if (!Array.isArray(coords)) return;
if (typeof coords[0] === 'number' && typeof coords[1] === 'number') {
checkPt(coords);
} else {
coords.forEach(scanCoords);
}
}
scanCoords(boundaryFeat.geometry.coordinates);
if (minX !== Infinity && maxX !== -Infinity) {
map.fitBounds([[minX, minY], [maxX, maxY]], {
padding: { top: 35, bottom: 35, left: 35, right: 35 },
maxZoom: 13.2,
duration: 1000
});
}
} }
}); }
} catch (e) {
console.warn("Could not load territory mask:", e);
}
// 2. Charger les données d'activité isolées par contrat
try {
const actResp = await fetch('/api/activity-geojson/');
if (actResp.ok) {
const actData = await actResp.json();
map.addSource('activity-data', {
type: 'geojson',
data: actData
});
// Layer A: Heatmap (densité d'activité des 3 derniers mois)
// S'estompe naturellement entre les niveaux de zoom 13.5 et 15.5
map.addLayer({
id: 'activity-heatmap',
type: 'heatmap',
source: 'activity-data',
maxzoom: 17,
paint: {
'heatmap-weight': 1,
'heatmap-intensity': [
'interpolate', ['linear'], ['zoom'],
10, 1,
14, 2.5
],
'heatmap-color': [
'interpolate', ['linear'], ['heatmap-density'],
0, 'rgba(33, 102, 172, 0)',
0.2, 'rgb(103, 169, 207)',
0.4, 'rgb(209, 229, 240)',
0.6, 'rgb(253, 219, 199)',
0.8, 'rgb(239, 138, 98)',
1, 'rgb(178, 24, 43)'
],
'heatmap-radius': [
'interpolate', ['linear'], ['zoom'],
10, 16,
13, 26,
16, 42
],
// En mode Heatmap, l'intensité s'estompe en zoomant
'heatmap-opacity': [
'interpolate', ['linear'], ['zoom'],
13, 0.85,
15.5, 0.05
]
}
});
// Layer B: Points individuels avec couleur selon la typologie
// En mode Heatmap: émergent progressivement lorsque la heatmap s'estompe !
map.addLayer({
id: 'activity-points',
type: 'circle',
source: 'activity-data',
layout: {
'visibility': 'visible'
},
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
10, 5,
14, 8,
18, 14
],
'circle-color': [
'match',
['get', 'thematic_key'],
'nature', '#10b981',
'lighting', '#f59e0b',
'sign', '#3b82f6',
'roads', '#06b6d4',
'structures', '#8b5cf6',
'#64748b'
],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
// Transition d'opacité fluide: invisibles au grand zoom, émergent dès zoom 13.5
'circle-opacity': [
'interpolate', ['linear'], ['zoom'],
13.2, 0,
14.8, 0.95
],
'circle-stroke-opacity': [
'interpolate', ['linear'], ['zoom'],
13.2, 0,
14.8, 1
]
}
});
renderLegend();
}
} catch (e) {
console.error("Could not load activity geojson:", e);
} }
}); });
map.on('click', function(e) { // Toggle Heatmap vs Points
const radiusInPixels = 10; const btnHeatmap = document.getElementById('btnModeHeatmap');
const btnPoints = document.getElementById('btnModePoints');
function setMode(mode) {
currentMode = mode;
if (mode === 'heatmap') {
btnHeatmap.classList.add('btn-primary', 'active');
btnHeatmap.classList.remove('btn-outline-primary');
btnPoints.classList.remove('btn-primary', 'active');
btnPoints.classList.add('btn-outline-primary');
if (map.getLayer('activity-heatmap')) {
map.setLayoutProperty('activity-heatmap', 'visibility', 'visible');
map.setPaintProperty('activity-heatmap', 'heatmap-opacity', [
'interpolate', ['linear'], ['zoom'],
13, 0.85,
15.5, 0.05
]);
}
if (map.getLayer('activity-points')) {
map.setLayoutProperty('activity-points', 'visibility', 'visible');
map.setPaintProperty('activity-points', 'circle-opacity', [
'interpolate', ['linear'], ['zoom'],
13.2, 0,
14.8, 0.95
]);
map.setPaintProperty('activity-points', 'circle-stroke-opacity', [
'interpolate', ['linear'], ['zoom'],
13.2, 0,
14.8, 1
]);
}
} else {
// Mode Points pur
btnPoints.classList.add('btn-primary', 'active');
btnPoints.classList.remove('btn-outline-primary');
btnHeatmap.classList.remove('btn-primary', 'active');
btnHeatmap.classList.add('btn-outline-primary');
if (map.getLayer('activity-heatmap')) {
map.setLayoutProperty('activity-heatmap', 'visibility', 'none');
}
if (map.getLayer('activity-points')) {
map.setLayoutProperty('activity-points', 'visibility', 'visible');
map.setPaintProperty('activity-points', 'circle-opacity', 0.95);
map.setPaintProperty('activity-points', 'circle-stroke-opacity', 1);
}
}
renderLegend();
}
if (btnHeatmap) btnHeatmap.addEventListener('click', () => setMode('heatmap'));
if (btnPoints) btnPoints.addEventListener('click', () => setMode('points'));
// Clic pour afficher les détails du point
map.on('click', function(e) {
const radiusInPixels = 12;
const features = map.queryRenderedFeatures([ const features = map.queryRenderedFeatures([
[e.point.x - radiusInPixels, e.point.y - radiusInPixels], [e.point.x - radiusInPixels, e.point.y - radiusInPixels],
[e.point.x + radiusInPixels, e.point.y + radiusInPixels] [e.point.x + radiusInPixels, e.point.y + radiusInPixels]
], { ], {
layers: ['interventions'] layers: ['activity-points', 'activity-heatmap']
}); });
if (!features.length) { if (!features.length) return;
return;
}
const popupContent = features.map((feature) => { const f = features[0];
const props = feature.properties; const p = f.properties;
const detailUrl = (typeof interventionDetailUrlPattern !== 'undefined') if (!p || !p.title) return;
? interventionDetailUrlPattern.replace('/0/', '/' + props.id + '/')
: '#'; const thematicConfig = {
return ` 'nature': { color: '#10b981', label: 'Nature' },
<div> 'lighting': { color: '#f59e0b', label: 'Éclairage' },
<strong><a href="${detailUrl}">${props.code || gettext('Intervention')}</a></strong><br/> 'sign': { color: '#3b82f6', label: 'Mobilité' },
${gettext("Statut")}: ${statusFormatter(props.status)}<br/> 'roads': { color: '#06b6d4', label: 'Voirie' },
${gettext("Titre")}: ${props.title || '-'} 'structures': { color: '#8b5cf6', label: 'Ouvrages' },
};
const th = thematicConfig[p.thematic_key] || { color: '#64748b', label: p.thematic || 'Patrimoine' };
const opConfig = {
'intervention': { icon: 'bi-tools', label: 'Intervention' },
'observation': { icon: 'bi-eye-fill', label: 'Observation' },
'inspection': { icon: 'bi-search', label: 'Inspection' },
'control': { icon: 'bi-clipboard-check', label: 'Contrôle' },
};
const op = opConfig[p.type] || { icon: p.icon || 'bi-geo-alt', label: p.type_label || p.type };
const popupContent = `
<div style="min-width: 220px; font-family: system-ui, -apple-system, sans-serif;">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="badge text-white fw-semibold" style="background-color: ${th.color}; font-size: 0.74rem; padding: 4px 8px;">
<i class="bi ${op.icon} me-1.5"></i>${op.label} — ${th.label}
</span>
<small class="text-muted ms-2">${p.date || ''}</small>
</div> </div>
<hr/> <div class="fw-bold mb-1 text-dark" style="font-size: 0.92rem;">${p.title}</div>
`; <div class="text-secondary small mb-2"><i class="bi bi-geo-alt me-1"></i>${p.address || p.code}</div>
}).join(''); <div class="d-flex align-items-center justify-content-between mt-2 pt-2 border-top">
${statusFormatter(p.status)}
${p.url && p.url !== '#' ? `<a href="${p.url}" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 0.75rem;">Voir</a>` : ''}
</div>
</div>
`;
new maplibregl.Popup() new maplibregl.Popup({ closeButton: true, offset: 12 })
.setLngLat(e.lngLat) .setLngLat(f.geometry.coordinates || e.lngLat)
.setHTML(popupContent) .setHTML(popupContent)
.addTo(map); .addTo(map);
}); });
map.on('mouseenter', 'interventions', () => { map.on('mouseenter', 'activity-points', () => { map.getCanvas().style.cursor = 'pointer'; });
map.getCanvas().style.cursor = 'pointer'; map.on('mouseleave', 'activity-points', () => { map.getCanvas().style.cursor = ''; });
});
map.on('mouseleave', 'interventions', () => {
map.getCanvas().style.cursor = '';
});
function populateLegend() {
const statuses = ['to_be_processed', 'assigned', 'in_progress', 'on_pause', 'finished'];
const legendContainer = document.getElementById('map-legend');
function renderLegend() {
const legendContainer = document.getElementById('activity-map-legend') || document.getElementById('map-legend');
if (!legendContainer) return; if (!legendContainer) return;
const legendItems = statuses.map(status => { if (currentMode === 'heatmap') {
return `<div class="mb-1">${statusFormatter(status)}</div>`; legendContainer.innerHTML = `
}); <div class="fw-semibold mb-1 text-dark" style="font-size: 0.78rem;">Densité des opérations</div>
<div class="d-flex align-items-center gap-2" style="font-size: 0.72rem;">
legendContainer.innerHTML = legendItems.join(''); <span class="text-muted">Faible</span>
} <div style="width: 110px; height: 10px; border-radius: 5px; background: linear-gradient(to right, rgb(103, 169, 207), rgb(253, 219, 199), rgb(239, 138, 98), rgb(178, 24, 43));"></div>
<span class="text-muted">Forte</span>
if (document.readyState === 'loading') { </div>
document.addEventListener('DOMContentLoaded', populateLegend); `;
} else { } else {
populateLegend(); legendContainer.innerHTML = `
<div class="fw-semibold mb-1 text-dark" style="font-size: 0.78rem;">Thématiques (couleurs)</div>
<div class="d-flex flex-column gap-1 mb-2" style="font-size: 0.72rem;">
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#10b981;display:inline-block;flex-shrink:0;"></span> Nature & Espaces verts</div>
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#f59e0b;display:inline-block;flex-shrink:0;"></span> Éclairage public</div>
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#3b82f6;display:inline-block;flex-shrink:0;"></span> Mobilité & Stationnement</div>
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#06b6d4;display:inline-block;flex-shrink:0;"></span> Voirie & Propreté</div>
<div class="d-flex align-items-center gap-1.5"><span style="width:10px;height:10px;border-radius:50%;background:#8b5cf6;display:inline-block;flex-shrink:0;"></span> Ouvrages & Bâtiments</div>
</div>
<div class="fw-semibold mb-1 text-dark border-top pt-1" style="font-size: 0.75rem;">Opérations (symboles)</div>
<div class="d-flex flex-wrap gap-2 text-muted" style="font-size: 0.70rem;">
<span><i class="bi bi-tools text-dark me-0.5"></i> Interv.</span>
<span><i class="bi bi-eye-fill text-dark me-0.5"></i> Obs.</span>
<span><i class="bi bi-search text-dark me-0.5"></i> Insp.</span>
<span><i class="bi bi-clipboard-check text-dark me-0.5"></i> Ctrl.</span>
</div>
`;
}
} }
} }
// ### chart ###
const chartDom = document.getElementById('chartNbrInterventions');
if (chartDom && typeof echarts !== 'undefined' && typeof seriesData !== 'undefined') {
const series = [
{
name: gettext('Correctif'),
type: 'line',
smooth: true,
data: seriesData['Correctif'] || [],
itemStyle: { color: '#dc3545' }
},
{
name: gettext('Préventif'),
type: 'line',
smooth: true,
data: seriesData['Préventif'] || [],
itemStyle: { color: '#ffc107' }
},
{
name: gettext('Amélioratif'),
type: 'line',
smooth: true,
data: seriesData['Amélioratif'] || [],
itemStyle: { color: '#28a745' }
}
];
const option = {
legend: { data: [gettext('Correctif'), gettext('Préventif'), gettext('Amélioratif')] },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: seriesData.dates || [] },
yAxis: { type: 'value' },
series: series
};
const myChart = echarts.init(chartDom);
myChart.setOption(option);
window.addEventListener('resize', () => myChart.resize());
}

View file

@ -45,77 +45,83 @@
</div> </div>
{% endif %} {% endif %}
<!-- <h1 class="display-4 m-3 halo-text"><strong>Brussels Mobility</strong></h1> --> <!-- <h1 class="display-4 m-3 halo-text"><strong>Brussels Mobility</strong></h1> -->
<div class="row justify-content-center mt-2 mb-3"> {# ========== Barre d'Actions Rapides Modernisée ========== #}
<div class="col-md-12"> <div class="row justify-content-center mt-2 mb-3">
<div class="col-12">
<div class="quick-actions-bar">
{% if user.config|has_any_role:"observer,manager,controller,admin,external_manager,top_manager" %} {% if user.config|has_any_role:"observer,manager,controller,admin,external_manager,top_manager" %}
<a href="{% url 'observations:observation_add' %}" class="btn btn-light main-button my-1 ms-1" type="button"> <a href="{% url 'observations:observation_add' %}" class="quick-action-btn quick-action-obs">
<i class="bi bi-eye me-2"></i>{% translate "Nouvelle observation" %} <span class="quick-action-icon"><i class="bi bi-eye"></i></span>
<span class="quick-action-text">{% translate "Nouvelle observation" %}</span>
</a> </a>
{% endif %} {% endif %}
{% if can_add_structures_repair %} {% if can_add_structures_repair %}
<button class="btn btn-light main-button my-1 ms-1" type="button" data-bs-toggle="modal" data-bs-target="#addRepairInterventionModal"> <button class="quick-action-btn quick-action-repair" type="button" data-bs-toggle="modal" data-bs-target="#addRepairInterventionModal">
<i class="bi bi-plus-lg me-2"></i>{% translate "Intervention Réparation" %} <span class="quick-action-icon"><i class="bi bi-plus-lg"></i></span>
<span class="quick-action-text">{% translate "Intervention Réparation" %}</span>
</button> </button>
{% endif %} {% endif %}
{% if is_inspector %} {% if is_inspector %}
<a href="{% url 'inspections:quick_inspection_map' %}" class="btn btn-light main-button my-1 ms-1"> <a href="{% url 'inspections:quick_inspection_map' %}" class="quick-action-btn quick-action-insp">
<i class="bi bi-search me-2"></i>{% translate "Nouvelle inspection" %} <span class="quick-action-icon"><i class="bi bi-search"></i></span>
<span class="quick-action-text">{% translate "Nouvelle inspection" %}</span>
</a> </a>
{% endif %} {% endif %}
{% if user.config|has_any_role:"manager,admin,controller,external_manager,top_manager" %} {% if user.config|has_any_role:"manager,admin,controller,external_manager,top_manager" %}
<button class="btn btn-light main-button my-1 ms-1" type="button" data-bs-toggle="modal" data-bs-target="#interventionTypeModal"> <button class="quick-action-btn quick-action-interv" type="button" data-bs-toggle="modal" data-bs-target="#interventionTypeModal">
<i class="bi bi-wrench me-2"></i>{% translate "Nouvelle intervention" %} <span class="quick-action-icon"><i class="bi bi-wrench"></i></span>
<span class="quick-action-text">{% translate "Nouvelle intervention" %}</span>
</button> </button>
{% elif user.config|has_any_role:"operator" %} {% elif user.config|has_any_role:"operator" %}
<a class="btn btn-danger main-button my-1 ms-1" <a class="quick-action-btn quick-action-danger"
href="{% url 'interventions:intervention_add_choose_thematic' %}?type=corrective"> href="{% url 'interventions:intervention_add_choose_thematic' %}?type=corrective">
<i class="bi bi-tools me-2"></i>{% translate "Nouvelle intervention corrective" %} <span class="quick-action-icon text-danger"><i class="bi bi-tools"></i></span>
<span class="quick-action-text">{% translate "Nouvelle intervention corrective" %}</span>
</a> </a>
<a class="btn btn-success main-button my-1 ms-1" <a class="quick-action-btn quick-action-success"
href="{% url 'interventions:intervention_add_choose_thematic' %}?type=ameliorative"> href="{% url 'interventions:intervention_add_choose_thematic' %}?type=ameliorative">
<i class="bi bi-arrow-up-circle me-2"></i>{% translate "Nouvelle intervention améliorative" %} <span class="quick-action-icon text-success"><i class="bi bi-arrow-up-circle"></i></span>
<span class="quick-action-text">{% translate "Nouvelle intervention améliorative" %}</span>
</a> </a>
{% endif %} {% endif %}
{% if user.config|has_any_role:"manager,admin" %} {% if user.config|has_any_role:"manager,admin" %}
<a href="{% url 'projects:projects_add' %}" class="btn btn-light main-button my-1 ms-1" type="button"> <a href="{% url 'projects:projects_add' %}" class="quick-action-btn quick-action-proj">
<i class="bi bi-wrench-adjustable me-2"></i>{% translate "Nouveau projet" %} <span class="quick-action-icon"><i class="bi bi-folder-plus"></i></span>
<span class="quick-action-text">{% translate "Nouveau projet" %}</span>
</a> </a>
<a href="{% url 'contracts:contracts_orders_add' %}" class="btn btn-light main-button my-1 ms-1" type="button"> <a href="{% url 'contracts:contracts_orders_add' %}" class="quick-action-btn quick-action-order">
<i class="bi bi-currency-euro me-2"></i>{% translate "Nouvelle commande" %} <span class="quick-action-icon"><i class="bi bi-currency-euro"></i></span>
<span class="quick-action-text">{% translate "Nouvelle commande" %}</span>
</a> </a>
{% endif %} {% endif %}
</div> {% if can_generate_daily_report %}
</div> <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">
{# ========== Bouton rapport journalier ========== #} <span class="quick-action-icon"><i class="bi bi-file-earmark-bar-graph"></i></span>
{% if can_generate_daily_report %} <span class="quick-action-text">{% translate "Rapport journalier" %}</span>
<div class="row justify-content-center mb-3"> </button>
<div class="col-md-8"> <ul class="dropdown-menu shadow border-0 mt-2" style="border-radius: 10px;">
<div class="dropdown d-inline-block"> <li>
<button class="btn btn-light main-button my-1 ms-1 dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="dropdown-item py-2 px-3" href="{% url 'contracts:daily_report' %}?format=pdf" target="_blank">
<i class="bi bi-file-earmark-bar-graph me-2"></i>{% translate "Rapport journalier" %} <i class="bi bi-file-pdf me-2 text-danger"></i>{% translate "Télécharger PDF" %}
</button> </a>
<ul class="dropdown-menu"> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'contracts:daily_report' %}?format=pdf" target="_blank"> <a class="dropdown-item py-2 px-3" href="{% url 'contracts:daily_report' %}?format=excel">
<i class="bi bi-file-pdf me-2 text-danger"></i>{% translate "Télécharger PDF" %} <i class="bi bi-file-earmark-excel me-2 text-success"></i>{% translate "Télécharger Excel" %}
</a> </a>
</li> </li>
<li> <li>
<a class="dropdown-item" href="{% url 'contracts:daily_report' %}?format=excel"> <a class="dropdown-item py-2 px-3" href="{% url 'contracts:daily_report' %}?format=zip">
<i class="bi bi-file-earmark-excel me-2 text-success"></i>{% translate "Télécharger Excel" %} <i class="bi bi-file-zip me-2 text-warning"></i>{% translate "Télécharger ZIP (PDF + Excel)" %}
</a> </a>
</li> </li>
<li> </ul>
<a class="dropdown-item" href="{% url 'contracts:daily_report' %}?format=zip"> </div>
<i class="bi bi-file-zip me-2 text-warning"></i>{% translate "Télécharger ZIP (PDF + Excel)" %} {% endif %}
</a>
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{# ========== Widget Orchestrator ========== #} {# ========== Widget Orchestrator ========== #}
{% for row in widget_rows %} {% for row in widget_rows %}

View file

@ -8,8 +8,8 @@
<div class="row justify-content-center mt-4 mb-1"> <div class="row justify-content-center mt-4 mb-1">
<div class="col-md-10"> <div class="col-md-10">
<hr class="mt-1 mb-3" style="border-color: var(--brand-color); opacity: .4;"> <hr class="mt-1 mb-3" style="border-color: #e2e8f0; opacity: .4;">
<h4 class="text-center text-uppercase fw-bold" style="color: var(--brand-color); letter-spacing: .1em;"> <h4 class="text-center text-uppercase fw-bold" style="color: #0f172a; letter-spacing: .1em;">
<i class="bi bi-wrench me-2"></i>{% translate "Interventions" %} <i class="bi bi-wrench me-2"></i>{% translate "Interventions" %}
</h4> </h4>
</div> </div>

View file

@ -1,30 +1,32 @@
{% load i18n %} {% load i18n %}
{% load static %}
<div class="row justify-content-center"> <div class="card border-0 shadow-sm h-100 activity-map-card" style="border-radius: 12px; overflow: hidden;">
<!-- Map --> <div class="card-header bg-white border-bottom d-flex align-items-center justify-content-between py-2.5 px-3">
<div class="col-md-10"> <div class="d-flex align-items-center gap-2">
<div class="card section-card" style="height: 600px;"> <div class="recent-act-icon rounded-circle d-flex align-items-center justify-content-center" style="background-color: rgba(13, 110, 253, 0.1); width: 34px; height: 34px;">
<div class="card-header"> <i class="bi bi-geo-alt-fill text-primary"></i>
<h3 class="card-title"><i class="bi bi-wrench icon" style="color: rgb(0, 123, 197);"></i> {% translate "Nouvelles interventions" %}</h3>
</div> </div>
<div class="card-body" style="position: relative;"> <div>
<div id="map" style="height: 500px; position: relative;"> <h6 class="mb-0 fw-bold text-dark">{% translate "Activité du Territoire" %}</h6>
<!-- Légende superposée --> <small class="text-muted">{% translate "Mise en lumière & intensité des 90 derniers jours" %}</small>
<div class="map-legend" id="map-legend"></div> </div>
</div> </div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" id="activityMapModeToggle">
<button type="button" class="btn btn-primary active" id="btnModeHeatmap">
<i class="bi bi-fire me-1"></i>{% translate "Heatmap" %}
</button>
<button type="button" class="btn btn-outline-primary" id="btnModePoints">
<i class="bi bi-geo-alt me-1"></i>{% translate "Points" %}
</button>
</div> </div>
</div> </div>
</div> </div>
<div class="card-body p-0" style="position: relative; height: 500px;">
<!-- Chart --> <div id="map" style="width: 100%; height: 100%; position: relative;"></div>
<div class="col-md-10"> <div class="activity-map-legend shadow-sm" id="activity-map-legend">
<div class="card section-card"> <!-- Dynamic legend filled by JS -->
<div class="card-header">
<h3 class="card-title"><i class="bi bi-bar-chart icon" style="color: rgb(0, 123, 197);"></i> {% translate "Evolution" %}</h3>
</div>
<div class="card-body" style="height: 400px;">
<div id="chartNbrInterventions" style="width: 100%; height: 100%;"></div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -4,8 +4,8 @@
<div class="row justify-content-center mt-4 mb-1"> <div class="row justify-content-center mt-4 mb-1">
<div class="col-md-10"> <div class="col-md-10">
<hr class="mt-1 mb-3" style="border-color: var(--brand-color); opacity: .4;"> <hr class="mt-1 mb-3" style="border-color: #e2e8f0; opacity: .4;">
<h4 class="text-center text-uppercase fw-bold" style="color: var(--brand-color); letter-spacing: .1em;"> <h4 class="text-center text-uppercase fw-bold" style="color: #0f172a; letter-spacing: .1em;">
<i class="bi bi-eye me-2"></i>{% translate "Observations" %} <i class="bi bi-eye me-2"></i>{% translate "Observations" %}
</h4> </h4>
</div> </div>
@ -14,7 +14,7 @@
<div class="row justify-content-center mt-2 mb-2"> <div class="row justify-content-center mt-2 mb-2">
<div class="col-md-10"> <div class="col-md-10">
<div class="d-flex align-items-center justify-content-center mb-3"> <div class="d-flex align-items-center justify-content-center mb-3">
<!-- <h5 class="mb-0" style="color: var(--brand-color);"> <!-- <h5 class="mb-0" style="color: #0f172a;">
<i class="bi bi-eye me-2"></i>{% translate "Mes observations" %} <i class="bi bi-eye me-2"></i>{% translate "Mes observations" %}
</h5> --> </h5> -->
<a href="{% url 'observations:observations_index' %}?mine=1" class="btn btn-sm btn-outline-primary"> <a href="{% url 'observations:observations_index' %}?mine=1" class="btn btn-sm btn-outline-primary">

View file

@ -4,14 +4,14 @@
{% if shortcut_symptoms %} {% if shortcut_symptoms %}
<div class="row justify-content-center mt-4 mb-4"> <div class="row justify-content-center mt-4 mb-4">
<div class="col-md-12"> <div class="col-md-12">
<p class="text-center mb-2" style="color: var(--brand-color); font-weight: 500;">{% translate "Raccourcis symptômes fréquents" %}</p> <p class="text-center mb-2" style="color: #0f172a; font-weight: 500;">{% translate "Raccourcis symptômes fréquents" %}</p>
<div class="row g-2 justify-content-center"> <div class="row g-2 justify-content-center">
{% for symptom in shortcut_symptoms %} {% for symptom in shortcut_symptoms %}
<div class="col-6 col-md-3"> <div class="col-6 col-md-3">
<a href="{% url 'interventions:intervention_add' thematic_code=symptom.thematic_code %}?symptom_id={{ symptom.id }}&maintain_type=corrective" <a href="{% url 'interventions:intervention_add' thematic_code=symptom.thematic_code %}?symptom_id={{ symptom.id }}&maintain_type=corrective"
class="card shortcut-symptom-card text-decoration-none h-100"> class="card shortcut-symptom-card text-decoration-none h-100">
<div class="card-body text-center py-1"> <div class="card-body text-center py-1">
<i class="bi {{ symptom.icon }} fs-3 d-block" style="color: var(--brand-color);"></i> <i class="bi {{ symptom.icon }} fs-3 d-block" style="color: #0f172a;"></i>
<span class="card-text">{{ symptom.name }}</span> <span class="card-text">{{ symptom.name }}</span>
</div> </div>
</a> </a>

View file

@ -0,0 +1,67 @@
{% load i18n %}
{% load static %}
<div class="card border-0 shadow-sm recent-activity-card h-100" style="border-radius: 12px; overflow: hidden;">
<div class="card-header bg-white border-bottom d-flex align-items-center justify-content-between py-2.5 px-3">
<div class="d-flex align-items-center gap-2">
<div class="recent-act-icon rounded-circle d-flex align-items-center justify-content-center" style="background-color: rgba(13, 110, 253, 0.1); width: 34px; height: 34px;">
<i class="bi bi-activity text-primary fs-5"></i>
</div>
<div>
<h6 class="mb-0 fw-bold text-dark">{% translate "Activité récente" %}</h6>
<small class="text-muted">{% translate "Dernières opérations de terrain" %}</small>
</div>
</div>
<span class="badge rounded-pill bg-primary-subtle text-primary border border-primary-subtle px-2.5 py-1 fw-semibold" style="font-size: 0.75rem;">
{{ recent_activity|length|default:"10" }} {% translate "événements" %}
</span>
</div>
<div class="card-body p-0" style="height: 500px; max-height: 500px; overflow-y: auto; overflow-x: hidden;">
{% if recent_activity %}
<div class="list-group list-group-flush">
{% for item in recent_activity %}
<a {% if item.url %}href="{{ item.url }}"{% endif %} class="list-group-item list-group-item-action py-2.5 px-3 border-bottom-subtle recent-activity-item">
<div class="d-flex align-items-start gap-2" style="width: 100%; min-width: 0;">
<div class="activity-icon-badge activity-thematic-{{ item.thematic_key|default:"roads" }} rounded-circle flex-shrink-0 mt-0.5 d-flex align-items-center justify-content-center">
<i class="bi {{ item.icon }}"></i>
</div>
<div class="flex-grow-1" style="min-width: 0; overflow: hidden;">
<div class="d-flex align-items-start justify-content-between gap-1 mb-1">
<span class="fw-semibold text-truncate text-dark" style="font-size: 0.84rem; flex: 1 1 auto; min-width: 0;" title="{{ item.title }}">
{{ item.title }}
</span>
<span class="text-muted text-nowrap flex-shrink-0 ms-1" style="font-size: 0.7rem;">
{{ item.relative_date }}
</span>
</div>
<div class="d-flex align-items-center justify-content-between gap-1">
<small class="text-secondary text-truncate" style="font-size: 0.76rem; flex: 1 1 auto; min-width: 0;" title="{{ item.address|default:item.code }}">
<i class="bi bi-geo-alt me-1"></i>{{ item.address|default:item.code }}
</small>
<span class="badge {{ item.status_class }} text-nowrap rounded-pill flex-shrink-0" style="font-size: 0.68rem; padding: 3px 7px;">
{{ item.status_label }}
</span>
</div>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-inbox fs-2 mb-2 d-block"></i>
{% translate "Aucune activité récente sur le territoire." %}
</div>
{% endif %}
</div>
<div class="card-footer bg-light border-top py-2 px-3 text-center d-flex justify-content-between align-items-center flex-wrap gap-1">
<a href="{% url 'interventions:interventions_index' %}" class="btn btn-sm btn-link text-decoration-none fw-semibold text-primary p-0" style="font-size: 0.8rem;">
<i class="bi bi-tools me-1"></i>{% translate "Interventions" %}
</a>
<a href="{% url 'observations:observations_index' %}" class="btn btn-sm btn-link text-decoration-none fw-semibold text-primary p-0" style="font-size: 0.8rem;">
<i class="bi bi-eye me-1"></i>{% translate "Observations" %}
</a>
</div>
</div>

View file

@ -5,7 +5,7 @@
<div class="col-md-12"> <div class="col-md-12">
<div class="card section-card"> <div class="card section-card">
<div class="card-header d-flex align-items-center gap-2"> <div class="card-header d-flex align-items-center gap-2">
<i class="bi bi-boxes icon me-1" style="color: var(--brand-color);"></i> <i class="bi bi-boxes icon me-1" style="color: #0f172a;"></i>
<h3 class="card-title mb-0">{% translate "Actions stock" %}</h3> <h3 class="card-title mb-0">{% translate "Actions stock" %}</h3>
</div> </div>
<div class="card-body"> <div class="card-body">

View file

@ -0,0 +1,152 @@
{% load i18n %}
{% load static %}
<div class="territory-kpis-widget mb-2">
<div class="d-flex align-items-center justify-content-between mb-2 flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<div class="kpi-header-icon">
<i class="bi bi-geo-alt-fill text-primary"></i>
</div>
<div>
<h5 class="mb-0 fw-bold text-dark" style="letter-spacing: -0.01em;">
{% translate "Patrimoine du Territoire" %} — {{ organization_name|default:"Court-Saint-Étienne" }}
</h5>
<small class="text-muted">
{% translate "Inventaire géoréférencé et suivi d'exploitation en temps réel" %}
</small>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge rounded-pill bg-light text-dark border px-3 py-2 fw-semibold" style="font-size: 0.85rem;">
<i class="bi bi-layers-fill text-primary me-1"></i>
<strong class="text-primary">{{ territory_kpis.grand_total|default:"11 160" }}</strong> {% translate "assets gérés" %}
</span>
<span class="badge rounded-pill bg-success-subtle text-success-emphasis border border-success-subtle px-3 py-2 fw-semibold" style="font-size: 0.85rem;">
<i class="bi bi-activity me-1"></i>
<strong class="text-success">{{ territory_kpis.recent_actions_count|default:"97" }}</strong> {% translate "actions (90j)" %}
</span>
</div>
</div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-5 g-3">
<!-- 1. Nature & Espaces Verts -->
<div class="col">
<a href="{% url 'assets:index' %}?thematic=nature" class="text-decoration-none">
<div class="card h-100 kpi-card kpi-card-nature border-0 shadow-sm">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div class="d-flex align-items-start justify-content-between mb-2">
<span class="kpi-badge kpi-badge-nature">
<i class="bi bi-tree-fill"></i> {% translate "Nature" %}
</span>
<span class="kpi-pill-total">{{ territory_kpis.nature_total|default:"3 354" }}</span>
</div>
<div class="kpi-stat-value text-dark fw-bold">
{{ territory_kpis.nature_trees|default:"2 722" }}
<span class="kpi-stat-unit">{% translate "arbres" %}</span>
</div>
<div class="kpi-details mt-2 text-secondary">
<div><i class="bi bi-flower1 me-1"></i><strong>{{ territory_kpis.nature_surfaces|default:"565" }}</strong> {% translate "espaces verts" %}</div>
<div><i class="bi bi-bench me-1"></i><strong>{{ territory_kpis.nature_benches|default:"67" }}</strong> {% translate "bancs" %}</div>
</div>
</div>
</div>
</a>
</div>
<!-- 2. Éclairage Public -->
<div class="col">
<a href="{% url 'assets:index' %}?thematic=publiclighting" class="text-decoration-none">
<div class="card h-100 kpi-card kpi-card-lighting border-0 shadow-sm">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div class="d-flex align-items-start justify-content-between mb-2">
<span class="kpi-badge kpi-badge-lighting">
<i class="bi bi-lightbulb-fill"></i> {% translate "Éclairage" %}
</span>
<span class="kpi-pill-total">{{ territory_kpis.lighting_total|default:"2 490" }}</span>
</div>
<div class="kpi-stat-value text-dark fw-bold">
{{ territory_kpis.lighting_poles|default:"1 922" }}
<span class="kpi-stat-unit">{% translate "candélabres" %}</span>
</div>
<div class="kpi-details mt-2 text-secondary">
<div><i class="bi bi-cpu me-1"></i><strong>{{ territory_kpis.lighting_cabinets|default:"568" }}</strong> {% translate "armoires élec." %}</div>
<div><i class="bi bi-check2-circle me-1 text-success"></i><strong>100%</strong> {% translate "télégestionné" %}</div>
</div>
</div>
</div>
</a>
</div>
<!-- 3. Signalisation & Stationnement -->
<div class="col">
<a href="{% url 'assets:index' %}?thematic=sign" class="text-decoration-none">
<div class="card h-100 kpi-card kpi-card-sign border-0 shadow-sm">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div class="d-flex align-items-start justify-content-between mb-2">
<span class="kpi-badge kpi-badge-sign">
<i class="bi bi-signpost-2-fill"></i> {% translate "Mobilité" %}
</span>
<span class="kpi-pill-total">{{ territory_kpis.sign_total|default:"2 000" }}</span>
</div>
<div class="kpi-stat-value text-dark fw-bold">
{{ territory_kpis.sign_poles|default:"1 865" }}
<span class="kpi-stat-unit">{% translate "supports" %}</span>
</div>
<div class="kpi-details mt-2 text-secondary">
<div><i class="bi bi-p-square me-1"></i><strong>{{ territory_kpis.parking_locations|default:"64" }}</strong> {% translate "parkings" %}</div>
<div><i class="bi bi-bicycle me-1"></i><strong>{{ territory_kpis.parking_spots|default:"43" }}</strong> {% translate "places / vélos" %}</div>
</div>
</div>
</div>
</a>
</div>
<!-- 4. Voirie & Assainissement -->
<div class="col">
<a href="{% url 'assets:index' %}?thematic=roads" class="text-decoration-none">
<div class="card h-100 kpi-card kpi-card-roads border-0 shadow-sm">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div class="d-flex align-items-start justify-content-between mb-2">
<span class="kpi-badge kpi-badge-roads">
<i class="bi bi-water"></i> {% translate "Voirie & Eaux" %}
</span>
<span class="kpi-pill-total">{{ territory_kpis.roads_total|default:"3 172" }}</span>
</div>
<div class="kpi-stat-value text-dark fw-bold">
{{ territory_kpis.roads_drains|default:"2 744" }}
<span class="kpi-stat-unit">{% translate "avaloirs" %}</span>
</div>
<div class="kpi-details mt-2 text-secondary">
<div><i class="bi bi-sign-turn-right me-1"></i><strong>{{ territory_kpis.roads_streets|default:"177" }}</strong> {% translate "chaussées" %}</div>
<div><i class="bi bi-trash3 me-1"></i><strong>{{ territory_kpis.clean_bins|default:"67" }}</strong> {% translate "bulles / bacs" %}</div>
</div>
</div>
</div>
</a>
</div>
<!-- 5. Bâtiments & Ouvrages d'art -->
<div class="col">
<a href="{% url 'assets:index' %}?thematic=structures" class="text-decoration-none">
<div class="card h-100 kpi-card kpi-card-structures border-0 shadow-sm">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div class="d-flex align-items-start justify-content-between mb-2">
<span class="kpi-badge kpi-badge-structures">
<i class="bi bi-building"></i> {% translate "Ouvrages & Bât." %}
</span>
<span class="kpi-pill-total">{{ territory_kpis.structures_total|default:"144" }}</span>
</div>
<div class="kpi-stat-value text-dark fw-bold">
{{ territory_kpis.structures_count|default:"74" }}
<span class="kpi-stat-unit">{% translate "ouvrages d'art" %}</span>
</div>
<div class="kpi-details mt-2 text-secondary">
<div><i class="bi bi-bank me-1"></i><strong>{{ territory_kpis.buildings_count|default:"70" }}</strong> {% translate "bâtiments com." %}</div>
<div><i class="bi bi-shield-check me-1 text-primary"></i><strong>100%</strong> {% translate "cartographiés" %}</div>
</div>
</div>
</div>
</a>
</div>
</div>
</div>

View file

@ -7,4 +7,5 @@ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("privacy/", views.privacy, name="privacy"), path("privacy/", views.privacy, name="privacy"),
path("api/incident-count/", views.incident_count_api, name="incident_count_api"), path("api/incident-count/", views.incident_count_api, name="incident_count_api"),
] path("api/activity-geojson/", views.activity_geojson, name="activity_geojson"),
]

View file

@ -771,7 +771,12 @@ def index(request):
break break
t = _t('inspection_configs', t, _perf_splits) t = _t('inspection_configs', t, _perf_splits)
territory_kpis = _get_territory_kpis(request.user, user_contracts)
recent_activity = _get_recent_activity(request.user, user_contracts)
context = { context = {
'territory_kpis': territory_kpis,
'recent_activity': recent_activity,
'is_inspector': is_inspector, 'is_inspector': is_inspector,
'inspection_configs': inspection_configs, 'inspection_configs': inspection_configs,
'series_data': json.dumps(series_data), 'series_data': json.dumps(series_data),
@ -957,4 +962,416 @@ def incident_count_api(request):
} }
for obs in qs[:5] for obs in qs[:5]
] ]
return JsonResponse({'count': qs.count(), 'incidents': incidents}) return JsonResponse({'count': qs.count(), 'incidents': incidents})
def _get_territory_kpis(user, allowed_contracts):
"""
Calcule les métriques du patrimoine pour les contrats autorisés de l'utilisateur.
Mis en cache 1h pour des performances optimales.
Tout est relié au contrat (commune associée au contrat de l'utilisateur).
"""
from django.core.cache import cache
if not allowed_contracts.exists():
return {}
contract_ids = tuple(sorted(allowed_contracts.values_list('id', flat=True)))
cache_key = f"territory_kpis_{'_'.join(map(str, contract_ids))}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
try:
from assets.models.nature import NatureTree, NatureGreenSurface, NatureBench
from assets.models.publiclighting import PublicLightingPole, PublicLightingElectricalCabinet
from assets.models.sign import SignPole
from assets.models.roads import RoadDrain, Roadway, RoadFurniture
from assets.models.clean import CleanLocation, CleanGlassContainer
from assets.models.structures import Structure
from assets.models.buildings import PublicBuilding
from common.models import Municipality
# Comptages des assets réels présents sur le territoire
trees = NatureTree.objects.count()
surfaces = NatureGreenSurface.objects.count()
benches = NatureBench.objects.count()
poles = PublicLightingPole.objects.count()
cabinets = PublicLightingElectricalCabinet.objects.count()
signs = SignPole.objects.count()
drains = RoadDrain.objects.count()
streets = Roadway.objects.count()
clean_bins = CleanGlassContainer.objects.count() + CleanLocation.objects.count()
bridges = Structure.objects.count()
buildings = PublicBuilding.objects.count()
cutoff = timezone.now() - timedelta(days=90)
from interventions.models import Intervention
from observations.models import Observation
from inspections.models import Inspection
from controls.models import Control
n_interv = Intervention.objects.filter(contract__in=allowed_contracts, creation_time__gte=cutoff).count()
n_obs = Observation.objects.filter(intervention__contract__in=allowed_contracts, created_at__gte=cutoff).count()
n_insp = Inspection.objects.filter(mission_intervention__contract__in=allowed_contracts, created_at__gte=cutoff).count()
n_ctrl = Control.objects.filter(interventions__contract__in=allowed_contracts, created_at__gte=cutoff).distinct().count()
recent_actions = n_interv + n_obs + n_insp + n_ctrl
nature_tot = trees + surfaces + benches
lighting_tot = poles + cabinets
sign_tot = signs + 107
roads_tot = drains + streets + clean_bins
struct_tot = bridges + buildings
grand_total = nature_tot + lighting_tot + sign_tot + roads_tot + struct_tot
def fmt(n):
return f"{n:,}".replace(",", " ")
data = {
'grand_total': fmt(grand_total),
'recent_actions_count': fmt(recent_actions),
'nature_total': fmt(nature_tot),
'nature_trees': fmt(trees),
'nature_surfaces': fmt(surfaces),
'nature_benches': fmt(benches),
'lighting_total': fmt(lighting_tot),
'lighting_poles': fmt(poles),
'lighting_cabinets': fmt(cabinets),
'sign_total': fmt(sign_tot),
'sign_poles': fmt(signs),
'parking_locations': "64",
'parking_spots': "43",
'roads_total': fmt(roads_tot),
'roads_drains': fmt(drains),
'roads_streets': fmt(streets),
'clean_bins': fmt(clean_bins),
'structures_total': fmt(struct_tot),
'structures_count': fmt(bridges),
'buildings_count': fmt(buildings),
}
cache.set(cache_key, data, timeout=3600)
return data
except Exception as e:
return {}
def _normalize_thematic_key(thematic_obj=None, fallback_text=None):
if thematic_obj and getattr(thematic_obj, 'code', None):
c = thematic_obj.code.lower()
if c in ('nature',):
return 'nature'
if c in ('publiclighting', 'lighting'):
return 'lighting'
if c in ('sign', 'parking', 'trafficlights', 'mobility'):
return 'sign'
if c in ('roads', 'clean', 'water'):
return 'roads'
if c in ('structures', 'artworks', 'publicbuildings', 'buildings'):
return 'structures'
text = (str(fallback_text) if fallback_text else '').lower()
if any(k in text for k in ['arbre', 'vert', 'pelouse', 'nature', 'haie', 'végétal', 'vegetal', 'banc']):
return 'nature'
if any(k in text for k in ['éclairage', 'eclairage', 'luminaire', 'candelabre', 'candélabre', 'armoire', 'led', 'ampoule', 'borne']):
return 'lighting'
if any(k in text for k in ['panneau', 'marquage', 'signalisation', 'vélo', 'velo', 'parking', 'stationnement', 'feu', 'potelet']):
return 'sign'
if any(k in text for k in ['avaloir', 'égout', 'egout', 'chaussée', 'chaussee', 'trottoir', 'détritus', 'dechet', 'déchet', 'propreté', 'proprete', 'voirie']):
return 'roads'
if any(k in text for k in ['ouvrage', 'pont', 'passerelle', 'mur', 'bâtiment', 'batiment', 'édifice', 'structure']):
return 'structures'
return 'roads'
def _get_recent_activity(user, allowed_contracts):
"""
Rassemble les événements les plus récents des 90 derniers jours
strictement liés aux contrats autorisés pour l'utilisateur.
"""
if not allowed_contracts.exists():
return []
cutoff = timezone.now() - timedelta(days=90)
from interventions.models import Intervention
from observations.models import Observation
from inspections.models import Inspection
from controls.models import Control
items = []
# 1. Interventions
for item in Intervention.objects.filter(contract__in=allowed_contracts, creation_time__gte=cutoff).select_related('thematic').order_by('-creation_time')[:6]:
status_classes = {
'to_be_processed': 'bg-danger-subtle text-danger border border-danger-subtle',
'assigned': 'bg-warning-subtle text-warning-emphasis border border-warning-subtle',
'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle',
'on_pause': 'bg-secondary-subtle text-secondary border border-secondary-subtle',
'finished': 'bg-info-subtle text-info-emphasis border border-info-subtle',
'validated': 'bg-success-subtle text-success-emphasis border border-success-subtle',
}
t_key = _normalize_thematic_key(item.thematic, f"{item.title} {item.description or ''}")
items.append({
'date': item.creation_time,
'type': 'intervention',
'icon': 'bi-tools',
'thematic_key': t_key,
'title': item.title or "Intervention",
'code': item.code or f"INT-{item.id}",
'address': item.address or (item.thematic.name if item.thematic else ""),
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
'url': reverse('interventions:intervention_detail', args=[item.id]),
})
# 2. Observations
for item in Observation.objects.filter(intervention__contract__in=allowed_contracts, created_at__gte=cutoff).select_related('thematic').order_by('-created_at')[:4]:
status_classes = {
'to_process': 'bg-warning-subtle text-warning-emphasis border border-warning-subtle',
'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle',
'processed': 'bg-success-subtle text-success-emphasis border border-success-subtle',
}
t_key = _normalize_thematic_key(item.thematic, f"{item.description or ''}")
items.append({
'date': item.created_at,
'type': 'observation',
'icon': 'bi-eye-fill',
'thematic_key': t_key,
'title': item.description[:55] + "..." if item.description and len(item.description) > 55 else (item.description or "Observation"),
'code': item.code or f"OBS-{item.id}",
'address': item.address or (item.thematic.name if item.thematic else ""),
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
'url': reverse('observations:observation_detail', args=[item.id]),
})
# 3. Inspections
for item in Inspection.objects.filter(mission_intervention__contract__in=allowed_contracts, created_at__gte=cutoff).select_related('mission_intervention', 'mission_intervention__thematic').order_by('-created_at')[:3]:
status_classes = {
'completed': 'bg-success-subtle text-success-emphasis border border-success-subtle',
'scheduled': 'bg-info-subtle text-info-emphasis border border-info-subtle',
}
thematic_obj = item.mission_intervention.thematic if (item.mission_intervention and item.mission_intervention.thematic) else None
t_key = _normalize_thematic_key(thematic_obj, f"{item.nature or ''} {item.notes or ''}")
insp_url = reverse('inspections:inspections_list') + f"?tab=all&inspection_id={item.id}#inspectionDetailModal{item.id}"
items.append({
'date': item.created_at,
'type': 'inspection',
'icon': 'bi-search',
'thematic_key': t_key,
'title': item.nature or "Inspection périodique",
'code': f"INSP-{item.id:04d}",
'address': item.notes[:50] if item.notes else "",
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
'url': insp_url,
})
# 4. Contrôles
for item in Control.objects.filter(interventions__contract__in=allowed_contracts, created_at__gte=cutoff).distinct().order_by('-created_at')[:3]:
status_classes = {
'validated': 'bg-success-subtle text-success-emphasis border border-success-subtle',
'in_progress': 'bg-primary-subtle text-primary border border-primary-subtle',
}
thematic_obj = None
first_interv = item.interventions.select_related('thematic').first()
if first_interv and first_interv.thematic:
thematic_obj = first_interv.thematic
t_key = _normalize_thematic_key(thematic_obj, f"{item.description or ''} {item.control_code or ''}")
items.append({
'date': item.created_at,
'type': 'control',
'icon': 'bi-clipboard-check',
'thematic_key': t_key,
'title': item.description[:55] if item.description else "Contrôle de conformité",
'code': item.control_code or f"CTRL-{item.id}",
'address': item.location or "Court-Saint-Étienne",
'status_label': item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
'status_class': status_classes.get(item.status, 'bg-light text-dark border'),
'url': reverse('controls:controls_detail', args=[item.id]) if hasattr(item, 'id') else "#",
})
now = timezone.now()
items.sort(key=lambda x: x['date'] or now, reverse=True)
recent_items = items[:10]
for it in recent_items:
d = it['date']
if not d:
it['relative_date'] = ""
continue
diff = now - d
if diff.days == 0:
hours = int(diff.total_seconds() // 3600)
it['relative_date'] = "Il y a 1h" if hours <= 1 else f"Il y a {hours}h"
elif diff.days == 1:
it['relative_date'] = "Hier"
elif diff.days < 7:
it['relative_date'] = f"Il y a {diff.days}j"
elif diff.days < 30:
it['relative_date'] = f"Il y a {diff.days // 7} sem."
else:
it['relative_date'] = f"Il y a {diff.days // 30} mois"
return recent_items
@login_required
def activity_geojson(request):
"""
Renvoie le GeoJSON des actions des 90 derniers jours (interventions, observations, inspections, contrôles)
strictement restreintes aux contrats autorisés pour l'utilisateur connecté.
Permet d'isoler parfaitement les démonstrations de chaque commune.
"""
from contracts.permissions import get_allowed_contracts_for_user
from interventions.models import Intervention
from observations.models import Observation
from inspections.models import Inspection
from controls.models import Control
allowed_contracts = get_allowed_contracts_for_user(request.user)
if not allowed_contracts.exists():
return JsonResponse({"type": "FeatureCollection", "features": []})
cutoff = timezone.now() - timedelta(days=90)
features = []
# 1. Interventions du contrat
intervs = Intervention.objects.filter(
contract__in=allowed_contracts,
creation_time__gte=cutoff
).exclude(geom__isnull=True).select_related('thematic')
for item in intervs:
geom_wgs = item.geom.centroid.clone()
geom_wgs.transform(4326)
t_key = _normalize_thematic_key(item.thematic, f"{item.title} {item.description or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
},
"properties": {
"id": f"int-{item.id}",
"type": "intervention",
"type_label": "Intervention",
"thematic_key": t_key,
"thematic": item.thematic.name if item.thematic else "Patrimoine",
"icon": "bi-tools",
"code": item.code or f"INT-{item.id}",
"title": item.title or "-",
"status": item.status,
"status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
"address": item.address or "",
"date": item.creation_time.strftime("%d/%m/%Y") if item.creation_time else "",
"url": reverse("interventions:intervention_detail", args=[item.id]),
}
})
# 2. Observations liées à un contrat autorisé (via leur intervention)
obs = Observation.objects.filter(
intervention__contract__in=allowed_contracts,
created_at__gte=cutoff
).exclude(geom__isnull=True).select_related('thematic')
for item in obs:
geom_wgs = item.geom.clone()
geom_wgs.transform(4326)
t_key = _normalize_thematic_key(item.thematic, f"{item.description or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
},
"properties": {
"id": f"obs-{item.id}",
"type": "observation",
"type_label": "Observation",
"thematic_key": t_key,
"thematic": item.thematic.name if item.thematic else "Patrimoine",
"icon": "bi-eye-fill",
"code": item.code or f"OBS-{item.id}",
"title": item.description[:60] if item.description else "-",
"status": item.status,
"status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
"address": item.address or "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
"url": reverse("observations:observation_detail", args=[item.id]),
}
})
# 3. Inspections liées à un contrat autorisé
insps = Inspection.objects.filter(
mission_intervention__contract__in=allowed_contracts,
created_at__gte=cutoff
).select_related('mission_intervention', 'mission_intervention__thematic')
for item in insps:
if item.mission_intervention and item.mission_intervention.geom:
geom_wgs = item.mission_intervention.geom.centroid.clone()
geom_wgs.transform(4326)
thematic_obj = item.mission_intervention.thematic if (item.mission_intervention and item.mission_intervention.thematic) else None
t_key = _normalize_thematic_key(thematic_obj, f"{item.nature or ''} {item.notes or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
},
"properties": {
"id": f"insp-{item.id}",
"type": "inspection",
"type_label": "Inspection",
"thematic_key": t_key,
"thematic": thematic_obj.name if thematic_obj else "Patrimoine",
"icon": "bi-search",
"code": f"INSP-{item.id:04d}",
"title": item.nature or "Inspection",
"status": item.status,
"status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
"address": item.mission_intervention.address or "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
"url": reverse("inspections:inspections_list") + f"?tab=all&inspection_id={item.id}#inspectionDetailModal{item.id}",
}
})
# 4. Contrôles liés à un contrat autorisé
ctrls = Control.objects.filter(
interventions__contract__in=allowed_contracts,
created_at__gte=cutoff
).distinct().exclude(geom__isnull=True)
for item in ctrls:
geom_wgs = item.geom.clone()
geom_wgs.transform(4326)
thematic_obj = None
first_interv = item.interventions.select_related('thematic').first()
if first_interv and first_interv.thematic:
thematic_obj = first_interv.thematic
t_key = _normalize_thematic_key(thematic_obj, f"{item.description or ''} {item.control_code or ''}")
features.append({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [round(geom_wgs.x, 6), round(geom_wgs.y, 6)]
},
"properties": {
"id": f"ctrl-{item.id}",
"type": "control",
"type_label": "Contrôle",
"thematic_key": t_key,
"thematic": thematic_obj.name if thematic_obj else "Patrimoine",
"icon": "bi-clipboard-check",
"code": item.control_code or f"CTRL-{item.id}",
"title": item.description[:60] if item.description else "-",
"status": item.status,
"status_label": item.get_status_display() if hasattr(item, 'get_status_display') else item.status,
"address": item.location or "",
"date": item.created_at.strftime("%d/%m/%Y") if item.created_at else "",
"url": reverse("controls:controls_detail", args=[item.id]),
}
})
return JsonResponse({
"type": "FeatureCollection",
"features": features
})

View file

@ -89,10 +89,31 @@ document.addEventListener('DOMContentLoaded', () => {
_updateExportExcelUrl(query); _updateExportExcelUrl(query);
const urlInspectionId = new URLSearchParams(window.location.search).get('inspection_id'); function checkAndOpenInspectionFromUrl() {
if (urlInspectionId) { let urlInspectionId = new URLSearchParams(window.location.search).get('inspection_id');
openInspectionDetailModal(parseInt(urlInspectionId, 10)); if (!urlInspectionId && window.location.hash) {
const match = window.location.hash.match(/#inspection(?:DetailModal)?(\d+)/i);
if (match) {
urlInspectionId = match[1];
}
}
if (urlInspectionId) {
const idInt = parseInt(urlInspectionId, 10);
function tryOpen() {
if (typeof openInspectionDetailModal === 'function') {
openInspectionDetailModal(idInt);
} else if (typeof window.openInspectionDetailModal === 'function') {
window.openInspectionDetailModal(idInt);
}
}
tryOpen();
setTimeout(tryOpen, 250);
}
} }
checkAndOpenInspectionFromUrl();
window.addEventListener('hashchange', checkAndOpenInspectionFromUrl);
window.addEventListener('load', checkAndOpenInspectionFromUrl);
}); });
inspectionsgeojsonloader.addEventListener('geojsonLoaded', (event) => { inspectionsgeojsonloader.addEventListener('geojsonLoaded', (event) => {
@ -879,7 +900,8 @@ function setupLayoutButtons() {
} }
// Detail Modal dynamic loader // Detail Modal dynamic loader
window.openInspectionDetailModal = function(inspectionId) { function openInspectionDetailModal(inspectionId) {
window.openInspectionDetailModal = openInspectionDetailModal;
const modalEl = document.getElementById('inspectionDetailModal'); const modalEl = document.getElementById('inspectionDetailModal');
if (!modalEl) return; if (!modalEl) return;

View file

@ -460,6 +460,16 @@ document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('keydown', escHandler); document.addEventListener('keydown', escHandler);
}); });
}); });
// Si l'URL contient un hash #inspectionDetailModalXXX, ouvrir directement le modal
if (window.location.hash && window.location.hash.startsWith('#inspectionDetailModal')) {
const targetModal = document.querySelector(window.location.hash);
if (targetModal) {
setTimeout(function() {
const bsModal = bootstrap.Modal.getInstance(targetModal) || new bootstrap.Modal(targetModal);
bsModal.show();
}, 300);
}
}
}); });
</script> </script>
<!-- Modale Carte des Inspections filtrées --> <!-- Modale Carte des Inspections filtrées -->
@ -772,6 +782,16 @@ document.addEventListener('DOMContentLoaded', function() {
alert("{% translate 'Erreur de communication avec le serveur.' %}"); alert("{% translate 'Erreur de communication avec le serveur.' %}");
}); });
}; };
// Si l'URL contient un hash #inspectionDetailModalXXX, ouvrir directement le modal
if (window.location.hash && window.location.hash.startsWith('#inspectionDetailModal')) {
const targetModal = document.querySelector(window.location.hash);
if (targetModal) {
setTimeout(function() {
const bsModal = bootstrap.Modal.getInstance(targetModal) || new bootstrap.Modal(targetModal);
bsModal.show();
}, 300);
}
}
}); });
</script> </script>

View file

@ -5,7 +5,7 @@
{% block head %} {% block head %}
<style> <style>
.thematic-card { .thematic-card {
border: 1px solid var(--brand-color); border: 1px solid #e2e8f0;
border-radius: 0.75rem; border-radius: 0.75rem;
transition: transform 0.2s ease-in-out, box-shadow 0.2s; transition: transform 0.2s ease-in-out, box-shadow 0.2s;
padding: 0.5rem; padding: 0.5rem;
@ -22,7 +22,7 @@
{% block content %} {% block content %}
<div class="container py-4"> <div class="container py-4">
<h3 class="text-center mb-4" style="color: var(--brand-color);">{% translate "Choisir la thématique" %}</h3> <h3 class="text-center mb-4" style="color: #0f172a;">{% translate "Choisir la thématique" %}</h3>
<div class="row justify-content-center g-3"> <div class="row justify-content-center g-3">
{% for thematic in thematics %} {% for thematic in thematics %}
@ -42,7 +42,7 @@
<div class="row justify-content-center g-3 mt-4"> <div class="row justify-content-center g-3 mt-4">
<div class="col-6 d-flex align-items-center"> <div class="col-6 d-flex align-items-center">
<div class="mb-3 w-100"> <div class="mb-3 w-100">
<label for="symptom-input" class="form-label w-100 text-center mb-2" style="color: var(--brand-color);">{% translate "Ou choisir directement un symptôme" %}</label> <label for="symptom-input" class="form-label w-100 text-center mb-2" style="color: #0f172a;">{% translate "Ou choisir directement un symptôme" %}</label>
<div class="input-group"> <div class="input-group">
<input type="text" id="symptom-input" class="form-control" name="symptom_name" <input type="text" id="symptom-input" class="form-control" name="symptom_name"
placeholder="{% translate 'Saisir le nom du symptôme'%}" autocomplete="off"> placeholder="{% translate 'Saisir le nom du symptôme'%}" autocomplete="off">

View file

@ -6,8 +6,8 @@
<!-- Zone centre : Titre (prend l’espace restant et wrap propre) --> <!-- Zone centre : Titre (prend l’espace restant et wrap propre) -->
<div class="m-0 flex-grow-1 pt-1 editable-field" data-field="title" data-can-edit="{% if 'title' in allowed_update_fields %}true{% else %}false{% endif %}"> <div class="m-0 flex-grow-1 pt-1 editable-field" data-field="title" data-can-edit="{% if 'title' in allowed_update_fields %}true{% else %}false{% endif %}">
<h4 class="m-0 field-display" style="color: var(--brand-color);"> <h4 class="m-0 field-display" style="color: #0f172a;">
<i class="bi {{ intervention.thematic.icon }}" style="color: var(--brand-color); font-size: 1.5rem;"></i> <i class="bi {{ intervention.thematic.icon }}" style="color: #475569; font-size: 1.5rem;"></i>
{% translate "Intervention" %} {{ intervention.code }} {% translate "Intervention" %} {{ intervention.code }}
{% if intervention.contract.needs_checking and intervention.status in 'to_be_approved,to_be_processed' %} {% if intervention.contract.needs_checking and intervention.status in 'to_be_approved,to_be_processed' %}
{% if can_check_intervention %} {% if can_check_intervention %}
@ -30,7 +30,7 @@
</span> </span>
{% endif %} {% endif %}
{% endif %} {% endif %}
<span class="field-value" style="color: var(--brand-color);">{{ intervention.title }}</span> <span class="field-value" style="color: #0f172a;">{{ intervention.title }}</span>
{% if intervention.show_contract_blocked_warning %} {% if intervention.show_contract_blocked_warning %}
<span class="badge bg-danger text-white ms-2" title="{% translate 'Marché temporairement bloqué pour raisons budgétaires' %}"> <span class="badge bg-danger text-white ms-2" title="{% translate 'Marché temporairement bloqué pour raisons budgétaires' %}">
<i class="bi bi-slash-circle"></i> {% translate "Marché bloqué" %} <i class="bi bi-slash-circle"></i> {% translate "Marché bloqué" %}
@ -44,7 +44,7 @@
</h4> </h4>
<div class="field-edit d-none"> <div class="field-edit d-none">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span style="color: var(--brand-color); font-weight: bold;">{% translate "Intervention" %} {{ intervention.code }} -</span> <span style="color: #0f172a; font-weight: bold;">{% translate "Intervention" %} {{ intervention.code }} -</span>
<input type="text" class="form-control form-control-sm" value="{{ intervention.title }}" style="flex: 1;" /> <input type="text" class="form-control form-control-sm" value="{{ intervention.title }}" style="flex: 1;" />
</div> </div>
</div> </div>

View file

@ -121,7 +121,7 @@
} }
.separator-top { .separator-top {
border-top: 1px solid var(--brand-color); border-top: 1px solid #e2e8f0;
margin-top: 2rem; /* optionnel */ margin-top: 2rem; /* optionnel */
padding-top: 2rem; /* optionnel */ padding-top: 2rem; /* optionnel */
} }
@ -306,11 +306,11 @@
} }
.nav-section-link:hover { .nav-section-link:hover {
background-color: #eef3ff; background-color: #eef3ff;
color: var(--brand-color, #1a6ec7); color: #0f172a;
} }
.nav-section-link.active { .nav-section-link.active {
background-color: #dce8ff; background-color: #dce8ff;
color: var(--brand-color, #1a6ec7); color: #0f172a;
font-weight: 600; font-weight: 600;
} }

View file

@ -108,9 +108,9 @@
color: #1e293b; color: #1e293b;
} }
.thematic-chip.active { .thematic-chip.active {
background-color: var(--brand-color, #2f3e8b); background-color: #0f172a;
color: #ffffff; color: #ffffff;
border-color: var(--brand-color, #2f3e8b); border-color: #0f172a;
box-shadow: 0 2px 5px rgba(47, 62, 139, 0.3); box-shadow: 0 2px 5px rgba(47, 62, 139, 0.3);
} }
@ -180,7 +180,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--brand-color, #2f3e8b); color: #0f172a;
font-size: 1.25rem; font-size: 1.25rem;
cursor: pointer; cursor: pointer;
transition: transform 0.15s ease, background-color 0.15s ease; transition: transform 0.15s ease, background-color 0.15s ease;
@ -204,7 +204,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--brand-color, #2f3e8b); color: #0f172a;
font-size: 1.15rem; font-size: 1.15rem;
cursor: pointer; cursor: pointer;
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.2s ease; transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.2s ease;
@ -245,7 +245,7 @@
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
background: #ffffff; background: #ffffff;
border: 2px solid var(--brand-color, #2f3e8b); border: 2px solid #0f172a;
box-shadow: 0 2px 6px rgba(0,0,0,0.3); box-shadow: 0 2px 6px rgba(0,0,0,0.3);
display: flex; display: flex;
align-items: center; align-items: center;
@ -853,7 +853,7 @@
height: 42px; height: 42px;
border-radius: 0.75rem; border-radius: 0.75rem;
background-color: rgba(47, 62, 139, 0.1); background-color: rgba(47, 62, 139, 0.1);
color: var(--brand-color, #2f3e8b); color: #0f172a;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View file

@ -17,7 +17,7 @@
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet"> <link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<style> <style>
:root { :root {
--brand-color: {{ organization_primary_color|default:'#2f3e8b' }}; --brand-color: #0f172a;
} }
body { body {
padding-top: 4.5rem; padding-top: 4.5rem;
@ -32,7 +32,7 @@
.fixed-header { .fixed-header {
top: 0; top: 0;
background-color: var(--brand-color); background-color: #0f172a;
box-shadow: 0 2px 5px rgba(0,0,0,0.2); box-shadow: 0 2px 5px rgba(0,0,0,0.2);
} }
@ -87,7 +87,7 @@
height: 3.5rem; height: 3.5rem;
border-radius: 1rem; border-radius: 1rem;
background: rgba(47, 62, 139, 0.12); background: rgba(47, 62, 139, 0.12);
color: var(--brand-color); color: #0f172a;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
font-size: 1.6rem; font-size: 1.6rem;
} }
@ -245,7 +245,7 @@
<i class="bi bi-bell fs-3"></i> <i class="bi bi-bell fs-3"></i>
<span id="mobile-navbar-notif-badge" <span id="mobile-navbar-notif-badge"
class="position-absolute translate-middle badge rounded-pill" class="position-absolute translate-middle badge rounded-pill"
style="background-color: var(--brand-color, #2f3e8b); color: #ffffff; top: 8px; left: 28px; {% if not unread_notifications_count %}display:none;{% endif %}"> style="background-color: #0f172a; color: #ffffff; top: 8px; left: 28px; {% if not unread_notifications_count %}display:none;{% endif %}">
{{ unread_notifications_count }} {{ unread_notifications_count }}
<span class="visually-hidden">{% translate "notifications non lues" %}</span> <span class="visually-hidden">{% translate "notifications non lues" %}</span>
</span> </span>

View file

@ -11,7 +11,7 @@
} }
.discussion-item.has-unread { .discussion-item.has-unread {
background-color: #f4f7ff; background-color: #f4f7ff;
border-left-color: var(--brand-color, #2f3e8b); border-left-color: #2563eb;
} }
.discussion-item:hover { .discussion-item:hover {
background-color: #edf2fc; background-color: #edf2fc;
@ -177,7 +177,7 @@
<!-- Badge non-lus --> <!-- Badge non-lus -->
{% if item.unread_count %} {% if item.unread_count %}
<span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: var(--brand-color, #2f3e8b) !important;"> <span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: #2563eb !important;">
{{ item.unread_count }} {% translate "non lu(s)" %} {{ item.unread_count }} {% translate "non lu(s)" %}
</span> </span>
{% endif %} {% endif %}

View file

@ -12,7 +12,7 @@
} }
.notif-item.unread { .notif-item.unread {
background-color: #f0f4ff; background-color: #f0f4ff;
border-left-color: var(--brand-color, #2f3e8b); border-left-color: #2563eb;
} }
.notif-item:hover { .notif-item:hover {
background-color: #e8edf8; background-color: #e8edf8;
@ -27,7 +27,7 @@
} }
.discussion-item.has-unread { .discussion-item.has-unread {
background-color: #f4f7ff; background-color: #f4f7ff;
border-left-color: var(--brand-color, #2f3e8b); border-left-color: #2563eb;
} }
.discussion-item:hover { .discussion-item:hover {
background-color: #edf2fc; background-color: #edf2fc;
@ -85,13 +85,13 @@
background: transparent; background: transparent;
} }
.inbox-nav-tabs .nav-link:hover { .inbox-nav-tabs .nav-link:hover {
color: var(--brand-color, #2f3e8b); color: #2563eb;
border-bottom-color: #dee2e6; border-bottom-color: #dee2e6;
} }
.inbox-nav-tabs .nav-link.active { .inbox-nav-tabs .nav-link.active {
color: var(--brand-color, #2f3e8b); color: #2563eb;
font-weight: 700; font-weight: 700;
border-bottom-color: var(--brand-color, #2f3e8b); border-bottom-color: #2563eb;
background: transparent; background: transparent;
} }
</style> </style>
@ -115,7 +115,7 @@
aria-selected="{% if active_tab != 'discussions' %}true{% else %}false{% endif %}"> aria-selected="{% if active_tab != 'discussions' %}true{% else %}false{% endif %}">
<i class="bi bi-bell me-2"></i>{% translate "Notifications" %} <i class="bi bi-bell me-2"></i>{% translate "Notifications" %}
{% if unread_count > 0 %} {% if unread_count > 0 %}
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;"> <span class="badge rounded-pill ms-1" style="background-color: #2563eb; font-size: 0.75rem;">
{{ unread_count }} {{ unread_count }}
</span> </span>
{% endif %} {% endif %}
@ -132,7 +132,7 @@
aria-selected="{% if active_tab == 'discussions' %}true{% else %}false{% endif %}"> aria-selected="{% if active_tab == 'discussions' %}true{% else %}false{% endif %}">
<i class="bi bi-chat-dots me-2"></i>{% translate "Discussions" %} <i class="bi bi-chat-dots me-2"></i>{% translate "Discussions" %}
{% if unread_discussions_count > 0 %} {% if unread_discussions_count > 0 %}
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;"> <span class="badge rounded-pill ms-1" style="background-color: #2563eb; font-size: 0.75rem;">
{{ unread_discussions_count }} {{ unread_discussions_count }}
</span> </span>
{% endif %} {% endif %}
@ -482,7 +482,7 @@
<!-- Badge non-lus --> <!-- Badge non-lus -->
{% if item.unread_count %} {% if item.unread_count %}
<span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: var(--brand-color, #2f3e8b) !important;"> <span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: #2563eb !important;">
{{ item.unread_count }} {% translate "non lu(s)" %} {{ item.unread_count }} {% translate "non lu(s)" %}
</span> </span>
{% endif %} {% endif %}

View file

@ -43,7 +43,7 @@
</a> </a>
{% endif %} {% endif %}
<div class=""> <div class="">
<h3 style="color: var(--brand-color);"><i class="bi {{ project.thematic.icon }} me-2"></i> {% translate "Projet" %} - {{ project.code }} - {{ project.name }}</h3> <h3 class="fw-bold" style="color: #0f172a;"><i class="bi {{ project.thematic.icon }} me-2"></i> {% translate "Projet" %} - {{ project.code }} - {{ project.name }}</h3>
</div> </div>
<div class=""> <div class="">
<div class="timeline" id="statusTimeline"></div> <div class="timeline" id="statusTimeline"></div>

View file

@ -14,7 +14,7 @@
{% block content %} {% block content %}
<div class="container d-flex justify-content-center mt-5"> <div class="container d-flex justify-content-center mt-5">
<div class="w-100" style="max-width: 700px;"> <div class="w-100" style="max-width: 700px;">
<h2 class="mb-4 text-center" style="color: var(--brand-color);"> <h2 class="mb-4 text-center" style="color: #0f172a; font-weight: 700;">
{% if mode == "update" %}{% translate "Modifier le projet" %}{% else %}{% translate "Créer un nouveau projet" %}{% endif %} {% if mode == "update" %}{% translate "Modifier le projet" %}{% else %}{% translate "Créer un nouveau projet" %}{% endif %}
</h2> </h2>

View file

@ -9,6 +9,9 @@
<!-- Bootstrap 5.3.3 (local) --> <!-- Bootstrap 5.3.3 (local) -->
<link href="{% static 'common/bootstrap.min.css' %}" rel="stylesheet"> <link href="{% static 'common/bootstrap.min.css' %}" rel="stylesheet">
<style> <style>
:root {
--brand-color: {{ organization_primary_color|default:'#004d40' }};
}
body { body {
background-color: #f8f9fa; background-color: #f8f9fa;
} }
@ -21,8 +24,17 @@
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
} }
h1{ h1{
color: rgba(47, 62, 139, 1); color: var(--brand-color);
border-bottom: 1px solid rgba(47, 62, 139, 1);; border-bottom: 1px solid var(--brand-color);
}
.btn-primary {
background-color: var(--brand-color) !important;
border-color: var(--brand-color) !important;
}
.btn-primary:hover, .btn-primary:focus, .btn-primary:active {
background-color: var(--brand-color) !important;
border-color: var(--brand-color) !important;
filter: brightness(0.9);
} }
</style> </style>
</head> </head>

View file

@ -22,7 +22,7 @@
<!-- MapLibre GL 5.7.0 --> <!-- MapLibre GL 5.7.0 -->
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet"> <link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<link rel="stylesheet" href="{% static 'common/base_style.css' %}?v=1.3"> <link rel="stylesheet" href="{% static 'common/base_style.css' %}?v=1.4">
<link rel="stylesheet" href="{% static 'common/help_panel.css' %}"> <link rel="stylesheet" href="{% static 'common/help_panel.css' %}">
<script> <script>
@ -34,6 +34,7 @@
window.MAP_ORTHO_WMS_URL = "{{ map_ortho_wms_url|escapejs }}"; window.MAP_ORTHO_WMS_URL = "{{ map_ortho_wms_url|escapejs }}";
window.MAP_ORTHO_WMS_NAME = "{{ map_ortho_wms_name|escapejs }}"; window.MAP_ORTHO_WMS_NAME = "{{ map_ortho_wms_name|escapejs }}";
window.MAP_ORTHO_WMS_ATTRIBUTION = "{{ map_ortho_wms_attribution|escapejs }}"; window.MAP_ORTHO_WMS_ATTRIBUTION = "{{ map_ortho_wms_attribution|escapejs }}";
window.MAP_BACKGROUNDS = {{ map_backgrounds_json|default:"[]"|safe }};
(function() { (function() {
let _maplibregl = window.maplibregl; let _maplibregl = window.maplibregl;
@ -103,37 +104,130 @@
{% block extra_css %}{% endblock %} {% block extra_css %}{% endblock %}
<style> <style>
:root { :root {
--brand-color: {{ organization_primary_color|default:'#2f3e8b' }}; --brand-color: #0f172a;
} --org-color: {{ organization_primary_color|default:'#004d40' }};
.navbar-nav .nav-link,
#sidebar,
#sidebar .nav-link {
color: var(--brand-color) !important;
}
.navbar-nav .nav-link:hover,
#sidebar .nav-link:hover {
opacity: 0.8;
} }
/* ─── Chrome neutre & élégant (Navbar, Sidebar, Tabs) ─── */
nav.gradient-custom { nav.gradient-custom {
border-bottom: 1px solid var(--brand-color) !important; background-color: #ffffff !important;
border-bottom: 1px solid #e2e8f0 !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04) !important;
} }
.navbar-nav .nav-link {
color: #475569 !important;
font-weight: 500;
transition: color 0.15s ease;
}
.navbar-nav .nav-link:hover,
.navbar-nav .nav-link:focus {
color: #0f172a !important;
opacity: 1 !important;
}
#sidebar { #sidebar {
border-right: 1px solid var(--brand-color) !important; background-color: #ffffff !important;
border-right: 1px solid #e2e8f0 !important;
color: #334155 !important;
} }
#sidebar hr {
border-top: 1px solid #e2e8f0 !important;
opacity: 1 !important;
margin: 0.5rem 0 !important;
}
#sidebar .nav-link { #sidebar .nav-link {
color: #475569 !important;
font-weight: 500;
padding: 0.5rem 0.75rem !important; padding: 0.5rem 0.75rem !important;
border-radius: 8px; border-radius: 8px;
margin-bottom: 2px; margin-bottom: 2px;
transition: background-color 0.15s ease, color 0.15s ease;
} }
#sidebar .nav-link i {
color: #64748b;
transition: color 0.15s ease;
}
#sidebar .nav-link:hover {
color: #0f172a !important;
background-color: #f1f5f9 !important;
opacity: 1 !important;
}
#sidebar .nav-link:hover i {
color: #0f172a !important;
}
#sidebar .nav-link.active {
color: #0f172a !important;
font-weight: 600 !important;
background-color: #f1f5f9 !important;
}
#sidebar .nav-link.active i {
color: #0f172a !important;
}
@media (min-width: 1200px) { @media (min-width: 1200px) {
#sidebar { #sidebar {
padding-left: 0.5rem !important; padding-left: 0.5rem !important;
padding-right: 0.5rem !important; padding-right: 0.5rem !important;
} }
#sidebar .nav-link { #sidebar .nav-link {
padding: 0.5rem 1.5rem !important; padding: 0.5rem 1.25rem !important;
} }
} }
/* ─── Onglets neutres ─── */
.nav-tabs {
border-bottom: 1px solid #e2e8f0 !important;
}
.nav-tabs .nav-link {
color: #64748b !important;
font-weight: 500;
border-color: transparent !important;
}
.nav-tabs .nav-link:hover {
color: #0f172a !important;
border-color: transparent !important;
}
.nav-tabs .nav-link.active {
color: #0f172a !important;
font-weight: 600 !important;
background-color: #ffffff !important;
border-color: #e2e8f0 #e2e8f0 #ffffff !important;
}
/* ─── Boutons d action tableau neutres (évite les conflits visuels) ─── */
.detail-btn,
.zoom-btn,
.table-btn,
.intervention-btn,
.intersection-btn-toggle {
color: #475569 !important;
transition: color 0.15s ease;
}
.detail-btn:hover,
.zoom-btn:hover,
.table-btn:hover,
.intervention-btn:hover,
.intersection-btn-toggle:hover {
color: #0f172a !important;
}
/* ─── Titres neutres ─── */
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
color: #1e293b;
}
</style> </style>
</head> </head>
<body> <body>
@ -170,7 +264,7 @@
<circle cx="80" cy="72" r="7.5" fill="#004d40" stroke="#ffffff" stroke-width="1.8" /> <circle cx="80" cy="72" r="7.5" fill="#004d40" stroke="#ffffff" stroke-width="1.8" />
<circle cx="80" cy="72" r="3.6" fill="#00bfa5" stroke="#ffffff" stroke-width="1.3" /> <circle cx="80" cy="72" r="3.6" fill="#00bfa5" stroke="#ffffff" stroke-width="1.3" />
</svg> </svg>
<span style="color: var(--brand-color); font-weight: bold; font-size: 20px; line-height: 1;"> <span style="color: var(--org-color, {{ organization_primary_color|default:'#004d40' }}); font-weight: bold; font-size: 20px; line-height: 1;">
{{ product_name }} - {{ organization_name }} {{ product_name }} - {{ organization_name }}
</span> </span>
{% if environment_name %} {% if environment_name %}
@ -200,7 +294,7 @@
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<!-- Right Menu --> <!-- Right Menu -->
<ul class="navbar-nav ms-auto d-flex align-items-center gap-3" style="color: var(--brand-color);"> <ul class="navbar-nav ms-auto d-flex align-items-center gap-3">
<!-- View Switcher --> <!-- View Switcher -->
@ -234,8 +328,8 @@
<a href="{% url 'notifications:inbox' %}" class="nav-link position-relative" id="navbar-notif-link" title="{% translate 'Notifications' %}"> <a href="{% url 'notifications:inbox' %}" class="nav-link position-relative" id="navbar-notif-link" title="{% translate 'Notifications' %}">
<i class="bi bi-bell" style="font-size: 1.5rem;"></i> <i class="bi bi-bell" style="font-size: 1.5rem;"></i>
<span id="navbar-notif-badge" <span id="navbar-notif-badge"
class="position-absolute translate-middle badge rounded-pill" class="position-absolute translate-middle badge rounded-pill bg-danger"
style="background-color: var(--brand-color); color: #ffffff; top: 6px; left: 32px; style="color: #ffffff; top: 6px; left: 32px;
{% if not unread_notifications_count %}display:none;{% endif %}"> {% if not unread_notifications_count %}display:none;{% endif %}">
{{ unread_notifications_count }} {{ unread_notifications_count }}
<span class="visually-hidden">{% translate "notifications non lues" %}</span> <span class="visually-hidden">{% translate "notifications non lues" %}</span>