Compare commits

...

2 commits

29 changed files with 1033 additions and 23 deletions

View file

@ -79,6 +79,21 @@ const draw = new MapboxDraw({
map.on('load', () => { map.on('load', () => {
map.addControl(draw); map.addControl(draw);
// Contrôle sélecteur de couches (LayerSwitcherControl)
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'basemap',
name: (typeof gettext === 'function') ? gettext('Fonds de plan & Orthophotos') : 'Fonds de plan & Orthophotos',
icon: 'satellite',
beforeLayer: 'locations',
basemaps: window.MAP_BACKGROUNDS || [],
}],
});
map.addControl(layerSwitcher, 'top-right');
window.layerSwitcher = layerSwitcher;
}
map.boxZoom.enable(); map.boxZoom.enable();
// Initial state // Initial state

View file

@ -364,6 +364,8 @@
<script src="{% url 'javascript-catalog' %}"></script> <script src="{% url 'javascript-catalog' %}"></script>
<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 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'contracts/js/contracts_map_overlay.js' %}"></script>
<script src="{% static 'assets/assets_index.js' %}"></script> <script src="{% static 'assets/assets_index.js' %}"></script>
{% block extra_script %} {% block extra_script %}
{% endblock extra_script %} {% endblock extra_script %}

View file

@ -221,5 +221,28 @@
{% endblock const_script %} {% endblock const_script %}
{% block extra_script %} {% block extra_script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
function setupNatureContractsOverlay() {
if (typeof initContractsMapOverlay === 'function' && window.map) {
initContractsMapOverlay({
map: window.map,
layerSwitcher: window.layerSwitcher,
thematic: 'nature',
geojsonUrl: "{% url 'contracts:contracts_geojson' %}",
});
}
}
if (window.map && window.map.loaded && window.map.loaded()) {
setupNatureContractsOverlay();
} else if (window.maploader) {
window.maploader.addEventListener('mapLoaded', () => {
setTimeout(setupNatureContractsOverlay, 100);
});
} else if (window.map) {
window.map.on('load', setupNatureContractsOverlay);
}
});
</script>
{% endblock extra_script %} {% endblock extra_script %}

View file

@ -2,6 +2,8 @@ import io
from django import forms from django import forms
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.gis import admin as gis_admin
from django.utils.translation import gettext_lazy as _
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import render from django.shortcuts import render
from django.urls import path, reverse from django.urls import path, reverse
@ -136,8 +138,8 @@ class CompanyAdmin(admin.ModelAdmin):
list_display = ('name', 'contact_email', 'phone_number') list_display = ('name', 'contact_email', 'phone_number')
@admin.register(Contract) @admin.register(Contract)
class ContractAdmin(admin.ModelAdmin): class ContractAdmin(gis_admin.GISModelAdmin):
list_display = ('contract_number', 'description', 'company', 'municipalities_list', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'thematics_list') list_display = ('contract_number', 'description', 'company', 'municipalities_list', 'has_geom', 'color', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'thematics_list')
search_fields = ('contract_number', 'company__name', 'description', 'contact_phone') search_fields = ('contract_number', 'company__name', 'description', 'contact_phone')
list_filter = ('is_blocked', 'is_active', 'municipalities', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'send_daily_summary') list_filter = ('is_blocked', 'is_active', 'municipalities', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'send_daily_summary')
filter_horizontal = ('thematics', 'municipalities') filter_horizontal = ('thematics', 'municipalities')
@ -155,6 +157,12 @@ class ContractAdmin(admin.ModelAdmin):
(None, { (None, {
'fields': ('company', 'municipalities', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'), 'fields': ('company', 'municipalities', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'),
}), }),
('Emprise géographique (Lot)', {
'fields': ('geom', 'color'),
'description': (
"Emprise géographique du contrat / lot (Multi-polygone) et couleur d'affichage sur les cartes."
),
}),
('Blocage budgétaire / Suspension', { ('Blocage budgétaire / Suspension', {
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'), 'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
'description': ( 'description': (
@ -175,6 +183,11 @@ class ContractAdmin(admin.ModelAdmin):
}), }),
) )
def has_geom(self, obj):
return bool(obj.geom)
has_geom.boolean = True
has_geom.short_description = _("Zone définie")
class Media: class Media:
css = { css = {
'all': ('contracts/css/contract_admin.css',) 'all': ('contracts/css/contract_admin.css',)
@ -285,8 +298,7 @@ class CompanyTeamMemberInline(admin.TabularInline):
verbose_name_plural = "Membres" verbose_name_plural = "Membres"
from django.contrib.gis import admin as gis_admin
from django.utils.translation import gettext_lazy as _
class CompanyTeamAdminForm(forms.ModelForm): class CompanyTeamAdminForm(forms.ModelForm):

View file

@ -2383,3 +2383,19 @@ msgstr "If enabled, a control team is automatically assigned to interventions fo
msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge." msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge."
msgstr "Maintenance types handled by this team (corrective, preventive, ameliorative). If none is selected, all types are supported." msgstr "Maintenance types handled by this team (corrective, preventive, ameliorative). If none is selected, all types are supported."
#: contracts/models.py:415
msgid "Zone géographique / Emprise du lot"
msgstr "Geographic zone / Lot scope"
#: contracts/models.py:416
msgid "Emprise géographique multi-polygone pour le lot/contrat."
msgstr "Multi-polygon geographic footprint for the lot/contract."
#: contracts/models.py:423
msgid "Couleur cartographique"
msgstr "Map color"
#: contracts/models.py:424
msgid "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)."
msgstr "Display color of the lot on the map (hexadecimal code, e.g. #28a745)."

View file

@ -2433,3 +2433,19 @@ msgstr "Si activé, une équipe de contrôle est automatiquement assignée aux i
msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge." msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge."
msgstr "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge." msgstr "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge."
#: contracts/models.py:415
msgid "Zone géographique / Emprise du lot"
msgstr "Zone géographique / Emprise du lot"
#: contracts/models.py:416
msgid "Emprise géographique multi-polygone pour le lot/contrat."
msgstr "Emprise géographique multi-polygone pour le lot/contrat."
#: contracts/models.py:423
msgid "Couleur cartographique"
msgstr "Couleur cartographique"
#: contracts/models.py:424
msgid "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)."
msgstr "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)."

View file

@ -2389,3 +2389,19 @@ msgstr "Indien geactiveerd wordt automatisch een controleteam toegewezen aan de
msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge." msgid "Types de maintenance pris en charge par cette équipe (corrective, préventive, améliorative). Si aucun n'est sélectionné, tous les types sont pris en charge."
msgstr "Onderhoudstypes ondersteund door dit team (correctief, preventief, verbeterend). Indien niets is geselecteerd, worden alle types ondersteund." msgstr "Onderhoudstypes ondersteund door dit team (correctief, preventief, verbeterend). Indien niets is geselecteerd, worden alle types ondersteund."
#: contracts/models.py:415
msgid "Zone géographique / Emprise du lot"
msgstr "Geografische zone / Omvang van het perceel"
#: contracts/models.py:416
msgid "Emprise géographique multi-polygone pour le lot/contrat."
msgstr "Geografische multi-polygoondekking voor het perceel/contract."
#: contracts/models.py:423
msgid "Couleur cartographique"
msgstr "Kaartkleur"
#: contracts/models.py:424
msgid "Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745)."
msgstr "Weergavekleur van het perceel op de kaart (hexadecimale code, bijv. #28a745)."

View file

@ -0,0 +1,24 @@
# Generated by Django 6.0.7 on 2026-10-05 12:08
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contracts', '0013_companyteam_maintain_types_and_more'),
]
operations = [
migrations.AddField(
model_name='contract',
name='color',
field=models.CharField(blank=True, default='#28a745', help_text="Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745).", max_length=20, null=True, verbose_name='Couleur cartographique'),
),
migrations.AddField(
model_name='contract',
name='geom',
field=django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, help_text='Emprise géographique multi-polygone pour le lot/contrat.', null=True, srid=3812, verbose_name='Zone géographique / Emprise du lot'),
),
]

View file

@ -408,6 +408,33 @@ class Contract(models.Model):
def municipality(self): def municipality(self):
"""Retourne la première commune associée (compatibilité ascendante).""" """Retourne la première commune associée (compatibilité ascendante)."""
return self.municipalities.first() return self.municipalities.first()
geom = gis_models.MultiPolygonField(
srid=3812,
null=True,
blank=True,
verbose_name=_("Zone géographique / Emprise du lot"),
help_text=_("Emprise géographique multi-polygone pour le lot/contrat.")
)
color = models.CharField(
max_length=20,
default="#28a745",
blank=True,
null=True,
verbose_name=_("Couleur cartographique"),
help_text=_("Couleur d'affichage du lot sur la carte (code hexadécimal, ex: #28a745).")
)
@property
def geojson_geometry(self):
"""Retourne la géométrie GeoJSON sous forme de dictionnaire ou None."""
if self.geom:
import json
try:
return json.loads(self.geom.geojson)
except Exception:
return None
return None
is_active = models.BooleanField( is_active = models.BooleanField(
default=True, default=True,
verbose_name=_("Actif"), verbose_name=_("Actif"),

View file

@ -1,9 +1,10 @@
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.db.models import Q
from functools import wraps from functools import wraps
from common.models import UserContractAccess, UserContractAccess, UserConfig, UserThematics from common.models import UserContractAccess, UserConfig, UserThematics
from contracts.models import Contract, ContractOrder, ContractOrderDocument, ClaimDeclarationAttachment from contracts.models import Contract, ContractOrder, ContractOrderDocument, ClaimDeclarationAttachment
@ -11,57 +12,80 @@ from contracts.models import Contract, ContractOrder, ContractOrderDocument, Cla
ALLOWED_ROLES_FOR_CONTRACT_ACCESS = {"admin", "manager", "external_manager", "controller", "top_manager"} ALLOWED_ROLES_FOR_CONTRACT_ACCESS = {"admin", "manager", "external_manager", "controller", "top_manager"}
def get_allowed_contracts_for_user(user): def get_allowed_contracts_for_user(user, for_assets=False):
""" """
Retourne une queryset des contrats accessibles à un utilisateur Retourne une queryset des contrats accessibles à un utilisateur.
s'il a le droit de voir les interventions ET un rôle autorisé. Si for_assets=True, vérifie l'accès aux assets (can_view_assets=True ou rôle operator/admin).
Sinon, vérifie l'accès aux interventions (can_view_interventions=True).
""" """
if getattr(user, 'is_superuser', False):
return Contract.objects.all()
try: try:
config = user.config config = user.config
except AttributeError: except (AttributeError, UserConfig.DoesNotExist):
return Contract.objects.none() return Contract.objects.none()
# Vérifie les rôles # Vérifie les rôles
user_roles = set(config.roles.values_list("name", flat=True)) user_roles = set(config.roles.values_list("name", flat=True))
if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS): allowed_roles = ALLOWED_ROLES_FOR_CONTRACT_ACCESS
if for_assets:
allowed_roles = allowed_roles | {"operator"}
if not user_roles.intersection(allowed_roles):
return Contract.objects.none() return Contract.objects.none()
if "admin" in user_roles or "top_manager" in user_roles: if "admin" in user_roles or "top_manager" in user_roles:
return Contract.objects.all() return Contract.objects.all()
# Filtre les UserContractAccess valides # Filtre les UserContractAccess valides
if for_assets:
access_filter = Q(can_view_assets=True) | Q(can_view_interventions=True)
else:
access_filter = Q(can_view_interventions=True)
allowed_contract_ids = UserContractAccess.objects.filter( allowed_contract_ids = UserContractAccess.objects.filter(
user_config=config, user_config=config
can_view_interventions=True ).filter(access_filter).values_list("contract_id", flat=True)
).values_list("contract_id", flat=True)
return Contract.objects.filter(id__in=allowed_contract_ids) return Contract.objects.filter(id__in=allowed_contract_ids)
def user_has_access_to_contract(user, contract_id): def user_has_access_to_contract(user, contract_id, for_assets=False):
""" """
Vérifie si un utilisateur peut accéder à un contrat donné : Vérifie si un utilisateur peut accéder à un contrat donné :
- A un rôle autorisé - A un rôle autorisé (ou superuser/admin)
- A un accès UserContractAccess avec can_view_interventions=True - A un accès UserContractAccess valide (can_view_assets si for_assets sinon can_view_interventions)
""" """
if getattr(user, 'is_superuser', False):
return True
try: try:
config = user.config config = user.config
except AttributeError: except (AttributeError, UserConfig.DoesNotExist):
return False return False
user_roles = set(config.roles.values_list("name", flat=True)) user_roles = set(config.roles.values_list("name", flat=True))
if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS): allowed_roles = ALLOWED_ROLES_FOR_CONTRACT_ACCESS
if for_assets:
allowed_roles = allowed_roles | {"operator"}
if not user_roles.intersection(allowed_roles):
return False return False
if "admin" in user_roles or "top_manager" in user_roles: if "admin" in user_roles or "top_manager" in user_roles:
return True return True
if for_assets:
access_filter = Q(can_view_assets=True) | Q(can_view_interventions=True)
else:
access_filter = Q(can_view_interventions=True)
return UserContractAccess.objects.filter( return UserContractAccess.objects.filter(
user_config=config, user_config=config,
contract_id=contract_id, contract_id=contract_id
can_view_interventions=True ).filter(access_filter).exists()
).exists()
def user_can_view_contract_prices(user, contract_id): def user_can_view_contract_prices(user, contract_id):

View file

@ -0,0 +1,362 @@
/**
* Contracts Map Overlay & Layer Switcher Integration
*
* Permet d'afficher en surimpression les périmètres des lots/contrats
* sur la carte MapLibre (par-dessus les sections de rues et les assets)
* et ajoute une section dédiée "Périmètres contractuels" dans le LayerSwitcherControl.
*/
(function (window) {
'use strict';
/**
* Calcule la bounding box [[minX, minY], [maxX, maxY]] d'une géométrie GeoJSON.
*/
function getGeometryBBox(geometry) {
if (!geometry || !geometry.coordinates) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
function traverse(coords) {
if (typeof coords[0] === 'number') {
const [x, y] = coords;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
} else if (Array.isArray(coords)) {
for (let i = 0; i < coords.length; i++) {
traverse(coords[i]);
}
}
}
traverse(geometry.coordinates);
if (minX === Infinity || minY === Infinity) return null;
return [[minX, minY], [maxX, maxY]];
}
/**
* Initialise l'overlay des contrats sur la carte MapLibre.
*
* @param {Object} options
* @param {Object} options.map - Instance de maplibregl.Map
* @param {Object} [options.layerSwitcher] - Instance de LayerSwitcherControl
* @param {string} [options.thematic='nature'] - Code de la thématique
* @param {string} options.geojsonUrl - URL vers l'endpoint contracts_geojson
* @param {string} [options.beforeLayerId] - ID de la couche avant laquelle insérer l'overlay (optionnel)
*/
async function initContractsMapOverlay(options = {}) {
const map = options.map;
if (!map) {
console.warn('[ContractsMapOverlay] Map instance missing.');
return;
}
if (!map.loaded()) {
map.once('load', () => initContractsMapOverlay(options));
return;
}
const thematic = options.thematic || 'nature';
const geojsonUrl = options.geojsonUrl || '/contracts/api/geojson/';
const layerSwitcher = options.layerSwitcher || window.layerSwitcher;
const beforeLayerId = options.beforeLayerId || null;
const _t = (key, fallback) => (typeof gettext === 'function' ? gettext(key) : (fallback || key));
try {
const url = new URL(geojsonUrl, window.location.origin);
url.searchParams.set('thematic', thematic);
const response = await fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!response.ok) {
console.warn('[ContractsMapOverlay] Failed to fetch contracts geojson:', response.status);
return;
}
const geojson = await response.json();
if (!geojson || geojson.type !== 'FeatureCollection') {
console.warn('[ContractsMapOverlay] Invalid GeoJSON received.');
return;
}
const features = geojson.features || [];
// 1. Ajouter la source et les couches à MapLibre
const sourceId = 'contracts-source';
const fillLayerId = 'contracts-fill';
const lineLayerId = 'contracts-line';
if (map.getSource(sourceId)) {
map.getSource(sourceId).setData(geojson);
} else {
map.addSource(sourceId, {
type: 'geojson',
data: geojson
});
}
// Couche de remplissage translucide (en surimpression)
if (!map.getLayer(fillLayerId)) {
const fillLayerDef = {
id: fillLayerId,
type: 'fill',
source: sourceId,
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#28a745'],
'fill-opacity': 0.18
}
};
if (beforeLayerId && map.getLayer(beforeLayerId)) {
map.addLayer(fillLayerDef, beforeLayerId);
} else {
map.addLayer(fillLayerDef);
}
}
// Couche de contour net pour délimiter les lots
if (!map.getLayer(lineLayerId)) {
const lineLayerDef = {
id: lineLayerId,
type: 'line',
source: sourceId,
paint: {
'line-color': ['coalesce', ['get', 'color'], '#28a745'],
'line-width': 2.5,
'line-opacity': 0.85
}
};
if (beforeLayerId && map.getLayer(beforeLayerId)) {
map.addLayer(lineLayerDef, beforeLayerId);
} else {
map.addLayer(lineLayerDef);
}
}
// 2. Gestion des popups et du curseur sur clic
const contractPopup = new maplibregl.Popup({
closeButton: true,
closeOnClick: true,
maxWidth: '320px'
});
map.on('click', fillLayerId, (e) => {
// Si un asset est sous le clic, prioriser l'interaction asset
const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id));
if (assetLayerIds.length > 0) {
const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds });
if (rendered && rendered.length > 0) {
return;
}
}
if (!e.features || !e.features[0]) return;
const props = e.features[0].properties || {};
let datesHtml = '';
if (props.start_date || props.end_date) {
datesHtml = `<div style="color: #64748b; font-size: 11px; margin-bottom: 4px;">
<i class="bi bi-calendar-range me-1"></i>${props.start_date || ''} &rarr; ${props.end_date || ''}
</div>`;
}
let descHtml = '';
if (props.description) {
descHtml = `<div style="color: #334155; font-size: 11px; margin-top: 4px; border-top: 1px solid #f1f5f9; padding-top: 4px; max-height: 80px; overflow-y: auto;">
${props.description}
</div>`;
}
let detailLinkHtml = '';
if (props.detail_url) {
detailLinkHtml = `<div style="margin-top: 6px; text-align: right;">
<a href="${props.detail_url}" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 11px;" target="_blank">
<i class="bi bi-box-arrow-up-right me-1"></i>${_t('Détails du contrat', 'Détails du contrat')}
</a>
</div>`;
}
const html = `
<div style="font-family: inherit; font-size: 12px; padding: 2px;">
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
<span style="display: inline-block; width: 12px; height: 12px; border-radius: 3px; background-color: ${props.color || '#28a745'}; border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;"></span>
<strong style="font-size: 13px; color: #1e293b;">${props.contract_number}</strong>
</div>
${props.company_name ? `<div style="color: #475569; font-weight: 500; margin-bottom: 4px;"><i class="bi bi-building me-1"></i>${props.company_name}</div>` : ''}
${datesHtml}
${descHtml}
${detailLinkHtml}
</div>
`;
contractPopup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
map.on('mousemove', fillLayerId, (e) => {
const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id));
if (assetLayerIds.length > 0) {
const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds });
if (rendered && rendered.length > 0) {
return;
}
}
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', fillLayerId, () => {
map.getCanvas().style.cursor = '';
});
// 3. Intégration dans le LayerSwitcherControl
if (layerSwitcher && typeof layerSwitcher.addCustomSection === 'function') {
layerSwitcher.addCustomSection({
id: 'contract-perimeters',
title: `<span class="d-flex align-items-center gap-1"><i class="bi bi-briefcase me-1"></i>${_t('Périmètres contractuels', 'Périmètres contractuels')}</span>`,
render: (container, switcher) => {
renderContractsSection(container, switcher, features, map, fillLayerId, lineLayerId, _t);
}
});
// Activer l'indicateur visuel sur l'icône du layer switcher si des lots sont présents
if (features.length > 0) {
layerSwitcher.setOverlayActive(true);
}
}
} catch (err) {
console.error('[ContractsMapOverlay] Error initializing overlay:', err);
}
}
/**
* Rendu HTML et interactions de la section "Périmètres contractuels" dans le LayerSwitcherControl.
*/
function renderContractsSection(container, switcher, features, map, fillLayerId, lineLayerId, _t) {
if (!features || features.length === 0) {
container.innerHTML = `
<div class="text-muted small px-2 py-1 fst-italic">
${_t('Aucun périmètre contractuel défini pour cette thématique.', 'Aucun périmètre contractuel défini pour cette thématique.')}
</div>
`;
return;
}
const countText = `${features.length} ${_t('lots', 'lots')}`;
// En-tête avec toggle général
const headerEl = document.createElement('div');
headerEl.className = 'layer-switcher-subtitle';
headerEl.innerHTML = `
<label class="d-flex align-items-center gap-2 mb-0" style="cursor: pointer;">
<input type="checkbox" id="switch-contracts-master" class="form-check-input mt-0" checked>
<span>${_t('Afficher les lots', 'Afficher les lots')}</span>
</label>
<span class="badge bg-light text-secondary border" style="font-size: 9.5px;">${countText}</span>
`;
container.appendChild(headerEl);
// Liste des lots individuels
const listEl = document.createElement('div');
listEl.className = 'layer-switcher-contracts-list';
listEl.style.display = 'flex';
listEl.style.flexDirection = 'column';
listEl.style.gap = '3px';
listEl.style.maxHeight = '240px';
listEl.style.overflowY = 'auto';
listEl.style.paddingRight = '2px';
features.forEach((feat) => {
const props = feat.properties || {};
const color = props.color || '#28a745';
const contractId = feat.id;
const card = document.createElement('div');
card.className = 'layer-switcher-card contract-lot-card';
card.style.padding = '6px 8px';
card.style.marginBottom = '2px';
card.dataset.contractId = contractId;
card.innerHTML = `
<div class="d-flex align-items-center justify-content-between gap-2">
<label class="d-flex align-items-center gap-2 mb-0 flex-grow-1" style="cursor: pointer; overflow: hidden;">
<input type="checkbox" class="contract-lot-checkbox form-check-input mt-0" data-contract-id="${contractId}" checked>
<span class="contract-color-dot" style="width: 12px; height: 12px; border-radius: 3px; background-color: ${color}; border: 1px solid rgba(0,0,0,0.25); flex-shrink: 0;"></span>
<span class="contract-lot-number text-truncate" style="font-size: 11.5px; font-weight: 600; color: #1e293b;" title="${props.contract_number}">
${props.contract_number}
</span>
</label>
<button type="button" class="btn btn-sm btn-link p-0 text-muted contract-zoom-btn" title="${_t('Centrer sur ce lot', 'Centrer sur ce lot')}" data-contract-id="${contractId}" style="line-height: 1; flex-shrink: 0;">
<i class="bi bi-crosshair" style="font-size: 13px;"></i>
</button>
</div>
${props.company_name ? `<div class="text-muted text-truncate ps-4" style="font-size: 10px; margin-top: 1px;" title="${props.company_name}"><i class="bi bi-building me-1"></i>${props.company_name}</div>` : ''}
`;
// Bouton zoom sur le lot
const zoomBtn = card.querySelector('.contract-zoom-btn');
zoomBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
const bbox = getGeometryBBox(feat.geometry);
if (bbox) {
map.fitBounds(bbox, { padding: 40, maxZoom: 16, duration: 800 });
}
});
listEl.appendChild(card);
});
container.appendChild(listEl);
const masterCheckbox = headerEl.querySelector('#switch-contracts-master');
function updateLayersVisibility() {
const checkedCheckboxes = container.querySelectorAll('.contract-lot-checkbox:checked');
const activeIds = Array.from(checkedCheckboxes).map(cb => Number(cb.dataset.contractId));
if (activeIds.length === 0) {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'none');
masterCheckbox.checked = false;
masterCheckbox.indeterminate = false;
switcher.setOverlayActive(false);
} else {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'visible');
const filter = ['in', ['get', 'id'], ['literal', activeIds]];
if (map.getLayer(fillLayerId)) map.setFilter(fillLayerId, filter);
if (map.getLayer(lineLayerId)) map.setFilter(lineLayerId, filter);
masterCheckbox.checked = activeIds.length === features.length;
masterCheckbox.indeterminate = activeIds.length > 0 && activeIds.length < features.length;
switcher.setOverlayActive(true);
}
}
// Toggles individuels
container.querySelectorAll('.contract-lot-checkbox').forEach((cb) => {
cb.addEventListener('change', (e) => {
e.stopPropagation();
updateLayersVisibility();
});
});
// Toggle master
masterCheckbox.addEventListener('change', (e) => {
e.stopPropagation();
const checked = masterCheckbox.checked;
container.querySelectorAll('.contract-lot-checkbox').forEach(cb => {
cb.checked = checked;
});
updateLayersVisibility();
});
}
// Exposer l'initialiseur global
window.initContractsMapOverlay = initContractsMapOverlay;
})(window);

View file

@ -4,7 +4,7 @@ from django.utils import timezone
from django.urls import reverse from django.urls import reverse
import json import json
from contracts.models import Company, CompanyTeam, CompanyMember, CompanyMemberHistory from contracts.models import Company, CompanyTeam, CompanyMember, CompanyMemberHistory, Contract
from common.models import UserConfig, Role from common.models import UserConfig, Role
User = get_user_model() User = get_user_model()
@ -460,3 +460,266 @@ class ContractOrderDetailControllerDisplayTests(TestCase):
self.assertNotContains(response, 'data-field="assigned_controller"') self.assertNotContains(response, 'data-field="assigned_controller"')
self.assertNotContains(response, 'Contrôleur assigné') self.assertNotContains(response, 'Contrôleur assigné')
class ContractGeometryTests(TestCase):
def setUp(self):
self.company = Company.objects.create(name="ETA Environnement")
def test_contract_with_donut_geometry_and_color(self):
"""Test that Contract accepts a MultiPolygon with interior ring (donut hole) and color."""
from django.contrib.gis.geos import Polygon, MultiPolygon, Point
from datetime import date
outer_ring = ((0, 0), (100, 0), (100, 100), (0, 100), (0, 0))
hole = ((20, 20), (40, 20), (40, 40), (20, 40), (20, 20))
poly_with_hole = Polygon(outer_ring, hole)
multi_poly = MultiPolygon([poly_with_hole], srid=3812)
contract = Contract.objects.create(
company=self.company,
contract_number="CTR-ETA-LOT1",
start_date=date(2026, 1, 1),
end_date=date(2028, 12, 31),
geom=multi_poly,
color="#28a745",
)
self.assertEqual(contract.color, "#28a745")
self.assertIsNotNone(contract.geom)
self.assertEqual(contract.geom.srid, 3812)
self.assertEqual(contract.geom.geom_type, "MultiPolygon")
# GeoJSON property
geojson = contract.geojson_geometry
self.assertIsNotNone(geojson)
self.assertEqual(geojson["type"], "MultiPolygon")
self.assertEqual(len(geojson["coordinates"]), 1)
self.assertEqual(len(geojson["coordinates"][0]), 2)
# Spatial queries with hole
pt_inside = Point(10, 10, srid=3812)
pt_in_hole = Point(30, 30, srid=3812)
self.assertTrue(Contract.objects.filter(pk=contract.pk, geom__intersects=pt_inside).exists())
self.assertFalse(Contract.objects.filter(pk=contract.pk, geom__intersects=pt_in_hole).exists())
def test_contract_admin_has_geom(self):
"""Test ContractAdmin has_geom method."""
from django.contrib.admin.sites import AdminSite
from contracts.admin import ContractAdmin
from datetime import date
admin_instance = ContractAdmin(Contract, AdminSite())
contract_without_geom = Contract.objects.create(
company=self.company,
contract_number="CTR-NOGEOM",
start_date=date(2026, 1, 1),
end_date=date(2028, 12, 31),
)
self.assertFalse(admin_instance.has_geom(contract_without_geom))
class ContractGeometryTests(TestCase):
def setUp(self):
self.company = Company.objects.create(name="ETA Environnement")
def test_contract_with_donut_geometry_and_color(self):
"""Test that Contract accepts a MultiPolygon with interior ring (donut hole) and color."""
from django.contrib.gis.geos import Polygon, MultiPolygon, Point
from datetime import date
# Polygon with an outer ring and an inner ring (hole)
# Outer ring: (0,0) to (100, 100)
# Inner hole: (20,20) to (40, 40)
outer_ring = ((0, 0), (100, 0), (100, 100), (0, 100), (0, 0))
hole = ((20, 20), (40, 20), (40, 40), (20, 40), (20, 20))
poly_with_hole = Polygon(outer_ring, hole)
multi_poly = MultiPolygon([poly_with_hole], srid=3812)
contract = Contract.objects.create(
company=self.company,
contract_number="CTR-ETA-LOT1",
start_date=date(2026, 1, 1),
end_date=date(2028, 12, 31),
geom=multi_poly,
color="#28a745",
)
self.assertEqual(contract.color, "#28a745")
self.assertIsNotNone(contract.geom)
self.assertEqual(contract.geom.srid, 3812)
self.assertEqual(contract.geom.geom_type, "MultiPolygon")
# GeoJSON property
geojson = contract.geojson_geometry
self.assertIsNotNone(geojson)
self.assertEqual(geojson["type"], "MultiPolygon")
# 1 polygon in multipolygon, having 2 rings (outer + hole)
self.assertEqual(len(geojson["coordinates"]), 1)
self.assertEqual(len(geojson["coordinates"][0]), 2)
# Spatial queries with hole
# Point inside outer ring but outside hole -> intersects
pt_inside = Point(10, 10, srid=3812)
# Point inside the hole -> does NOT intersect
pt_in_hole = Point(30, 30, srid=3812)
self.assertTrue(Contract.objects.filter(pk=contract.pk, geom__intersects=pt_inside).exists())
self.assertFalse(Contract.objects.filter(pk=contract.pk, geom__intersects=pt_in_hole).exists())
def test_contract_admin_has_geom(self):
"""Test ContractAdmin has_geom method."""
from django.contrib.admin.sites import AdminSite
from contracts.admin import ContractAdmin
from datetime import date
admin_instance = ContractAdmin(Contract, AdminSite())
contract_without_geom = Contract.objects.create(
company=self.company,
contract_number="CTR-NOGEOM",
start_date=date(2026, 1, 1),
end_date=date(2028, 12, 31),
)
self.assertFalse(admin_instance.has_geom(contract_without_geom))
class ContractGeojsonEndpointTests(TestCase):
def setUp(self):
from django.contrib.auth import get_user_model
from common.models import Thematic, Role, UserConfig, UserThematics, UserContractAccess
from django.contrib.gis.geos import Polygon, MultiPolygon
from datetime import date
User = get_user_model()
self.user = User.objects.create_user(username="test_nature_user", password="password")
self.role_manager = Role.objects.create(name="manager")
self.config = UserConfig.objects.create(user=self.user)
self.config.roles.add(self.role_manager)
self.thematic_nature = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur")
self.thematic_roads = Thematic.objects.create(code="roads", name_fr="Voiries", name_nl="Wegen")
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic_nature,
can_view_assets=True,
can_view_interventions=True
)
self.company = Company.objects.create(name="ETA Vert")
poly = Polygon(((0, 0), (10, 0), (10, 10), (0, 10), (0, 0)))
self.multi_poly = MultiPolygon([poly], srid=3812)
# Nature contract with geom
self.contract_nature_with_geom = Contract.objects.create(
company=self.company,
contract_number="CTR-NAT-01",
start_date=date(2026, 1, 1),
end_date=date(2028, 12, 31),
geom=self.multi_poly,
color="#198754",
description="Lot 1 entretien espaces verts",
)
self.contract_nature_with_geom.thematics.add(self.thematic_nature)
# Nature contract without geom
self.contract_nature_no_geom = Contract.objects.create(
company=self.company,
contract_number="CTR-NAT-NOGEOM",
start_date=date(2026, 1, 1),
end_date=date(2028, 12, 31),
)
self.contract_nature_no_geom.thematics.add(self.thematic_nature)
# Roads contract with geom
self.contract_roads_with_geom = Contract.objects.create(
company=self.company,
contract_number="CTR-ROADS-01",
start_date=date(2026, 1, 1),
end_date=date(2028, 12, 31),
geom=self.multi_poly,
color="#0d6efd",
)
self.contract_roads_with_geom.thematics.add(self.thematic_roads)
# Grant access to nature contracts
UserContractAccess.objects.create(
user_config=self.config,
contract=self.contract_nature_with_geom,
can_view_assets=True,
can_view_interventions=True
)
UserContractAccess.objects.create(
user_config=self.config,
contract=self.contract_nature_no_geom,
can_view_assets=True,
can_view_interventions=True
)
def test_anonymous_access_forbidden(self):
url = reverse("contracts:contracts_geojson")
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
def test_authenticated_nature_contracts_geojson(self):
self.client.login(username="test_nature_user", password="password")
url = reverse("contracts:contracts_geojson") + "?thematic=nature"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["type"], "FeatureCollection")
# Only 1 feature should be returned (nature with geom)
self.assertEqual(len(data["features"]), 1)
feat = data["features"][0]
self.assertEqual(feat["id"], self.contract_nature_with_geom.id)
self.assertEqual(feat["properties"]["contract_number"], "CTR-NAT-01")
self.assertEqual(feat["properties"]["company_name"], "ETA Vert")
self.assertEqual(feat["properties"]["color"], "#198754")
self.assertEqual(feat["properties"]["description"], "Lot 1 entretien espaces verts")
self.assertEqual(feat["geometry"]["type"], "MultiPolygon")
def test_superuser_access_all_contracts(self):
from django.contrib.auth import get_user_model
User = get_user_model()
admin = User.objects.create_superuser(username="admin_user", password="password", email="admin@example.com")
self.client.login(username="admin_user", password="password")
url = reverse("contracts:contracts_geojson") + "?thematic=nature"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data["features"]), 1)
# For roads:
url_roads = reverse("contracts:contracts_geojson") + "?thematic=roads"
response_roads = self.client.get(url_roads)
self.assertEqual(response_roads.status_code, 200)
data_roads = response_roads.json()
self.assertEqual(len(data_roads["features"]), 1)
self.assertEqual(data_roads["features"][0]["properties"]["contract_number"], "CTR-ROADS-01")
def test_user_without_contract_access_gets_empty(self):
from django.contrib.auth import get_user_model
from common.models import Role, UserConfig, UserThematics
User = get_user_model()
other_user = User.objects.create_user(username="other_user", password="password")
config = UserConfig.objects.create(user=other_user)
role = Role.objects.get(name="manager")
config.roles.add(role)
UserThematics.objects.create(
user_config=config,
thematic=self.thematic_nature,
can_view_assets=True
)
self.client.login(username="other_user", password="password")
url = reverse("contracts:contracts_geojson") + "?thematic=nature"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data["features"]), 0)

View file

@ -19,6 +19,7 @@ urlpatterns = [
path('teams/planning/absence/', views_teams.team_planning_toggle_absence_ajax, name='team_planning_toggle_absence_ajax'), path('teams/planning/absence/', views_teams.team_planning_toggle_absence_ajax, name='team_planning_toggle_absence_ajax'),
path('teams/planning/duplicate/', views_teams.team_planning_duplicate_ajax, name='team_planning_duplicate_ajax'), path('teams/planning/duplicate/', views_teams.team_planning_duplicate_ajax, name='team_planning_duplicate_ajax'),
path("", views.contracts_index, name="contracts_index"), path("", views.contracts_index, name="contracts_index"),
path("api/geojson/", views.contracts_geojson, name="contracts_geojson"),
path('<int:contract_id>/', views.contracts_detail, name='contracts_detail'), path('<int:contract_id>/', views.contracts_detail, name='contracts_detail'),
path('orders/available/', views.available_orders, name='available_orders'), path('orders/available/', views.available_orders, name='available_orders'),
path('orders/<int:order_id>/', views.contracts_orders_detail, name='contracts_orders_detail'), path('orders/<int:order_id>/', views.contracts_orders_detail, name='contracts_orders_detail'),

View file

@ -10,6 +10,7 @@ from django.utils.translation import get_language, gettext as _, ngettext
from django.db.models import F, Q from django.db.models import F, Q
from django.db.models.functions import Cast from django.db.models.functions import Cast
from django.db.models import CharField from django.db.models import CharField
from django.contrib.gis.db.models.functions import AsGeoJSON, Transform
import json import json
import os import os
@ -1251,3 +1252,82 @@ def order_direct_post_validate(request, order_id, direct_post_id):
}, },
}) })
@login_required
@require_GET
def contracts_geojson(request):
"""
Retourne la liste des contrats sous forme de GeoJSON FeatureCollection
filtrée selon la thématique demandée (ex: nature) et les permissions de l'utilisateur.
Seuls les contrats disposant d'une géométrie (geom) sont inclus.
"""
thematic_code = request.GET.get('thematic', 'nature').strip()
# 1. Vérification de l'accès à la thématique si l'utilisateur n'est pas superuser / admin
if not request.user.is_superuser:
user_config = getattr(request.user, 'config', None)
if not user_config:
return JsonResponse({"type": "FeatureCollection", "features": []})
user_roles = set(user_config.roles.values_list("name", flat=True))
if not user_roles.intersection({"admin", "top_manager"}):
has_thematic_access = UserThematics.objects.filter(
user_config=user_config,
thematic__code=thematic_code
).filter(
Q(can_view_assets=True) | Q(can_view_interventions=True)
).exists()
if not has_thematic_access:
return JsonResponse({"type": "FeatureCollection", "features": []})
# 2. Récupération des contrats autorisés pour l'utilisateur
allowed_contracts = get_allowed_contracts_for_user(request.user, for_assets=True)
# 3. Filtrage par thématique et géométrie non nulle
contracts_qs = allowed_contracts.filter(
thematics__code=thematic_code,
geom__isnull=False
).select_related('company').prefetch_related('thematics').distinct()
# 4. Annotation PostGIS pour transformation rapide en GeoJSON EPSG:4326
contracts_qs = contracts_qs.annotate(
geom_geojson=AsGeoJSON(Transform('geom', 4326))
).order_by('contract_number')
features = []
for contract in contracts_qs:
if not contract.geom_geojson:
continue
try:
geom_data = json.loads(contract.geom_geojson)
except (json.JSONDecodeError, TypeError):
continue
detail_url = ""
try:
detail_url = reverse('contracts:contracts_detail', kwargs={'contract_id': contract.id})
except Exception:
detail_url = ""
features.append({
"type": "Feature",
"id": contract.id,
"geometry": geom_data,
"properties": {
"id": contract.id,
"contract_number": contract.contract_number,
"company_name": contract.company.name if contract.company else "",
"color": contract.color or "#28a745",
"description": contract.description or "",
"start_date": contract.start_date.isoformat() if contract.start_date else "",
"end_date": contract.end_date.isoformat() if contract.end_date else "",
"detail_url": detail_url,
}
})
return JsonResponse({
"type": "FeatureCollection",
"features": features
})

Binary file not shown.

View file

@ -1281,3 +1281,25 @@ msgstr "Reduce map"
#~ msgid "(aucune sous-catégorie)" #~ msgid "(aucune sous-catégorie)"
#~ msgstr "(no subcategory)" #~ msgstr "(no subcategory)"
msgid "Périmètres contractuels"
msgstr "Contractual perimeters"
msgid "Afficher les lots"
msgstr "Show lots"
msgid "Aucun périmètre contractuel défini pour cette thématique."
msgstr "No contractual perimeter defined for this thematic."
msgid "Centrer sur ce lot"
msgstr "Center on this lot"
msgid "Détails du contrat"
msgstr "Contract details"
msgid "lots"
msgstr "lots"
msgid "Fonds de plan & Orthophotos"
msgstr "Basemaps & Orthophotos"

View file

@ -173,3 +173,24 @@ msgstr "None"
msgid "(aucune sous-catégorie)" msgid "(aucune sous-catégorie)"
msgstr "(no subcategory)" msgstr "(no subcategory)"
msgid "Périmètres contractuels"
msgstr "Contractual perimeters"
msgid "Afficher les lots"
msgstr "Show lots"
msgid "Aucun périmètre contractuel défini pour cette thématique."
msgstr "No contractual perimeter defined for this thematic."
msgid "Centrer sur ce lot"
msgstr "Center on this lot"
msgid "Détails du contrat"
msgstr "Contract details"
msgid "lots"
msgstr "lots"
msgid "Fonds de plan & Orthophotos"
msgstr "Basemaps & Orthophotos"

Binary file not shown.

View file

@ -1313,3 +1313,25 @@ msgstr "Réduire la carte"
#~ msgid "(aucune sous-catégorie)" #~ msgid "(aucune sous-catégorie)"
#~ msgstr "(aucune sous-catégorie)" #~ msgstr "(aucune sous-catégorie)"
msgid "Périmètres contractuels"
msgstr "Périmètres contractuels"
msgid "Afficher les lots"
msgstr "Afficher les lots"
msgid "Aucun périmètre contractuel défini pour cette thématique."
msgstr "Aucun périmètre contractuel défini pour cette thématique."
msgid "Centrer sur ce lot"
msgstr "Centrer sur ce lot"
msgid "Détails du contrat"
msgstr "Détails du contrat"
msgid "lots"
msgstr "lots"
msgid "Fonds de plan & Orthophotos"
msgstr "Fonds de plan & Orthophotos"

View file

@ -173,3 +173,24 @@ msgstr "Aucun"
msgid "(aucune sous-catégorie)" msgid "(aucune sous-catégorie)"
msgstr "(aucune sous-catégorie)" msgstr "(aucune sous-catégorie)"
msgid "Périmètres contractuels"
msgstr "Périmètres contractuels"
msgid "Afficher les lots"
msgstr "Afficher les lots"
msgid "Aucun périmètre contractuel défini pour cette thématique."
msgstr "Aucun périmètre contractuel défini pour cette thématique."
msgid "Centrer sur ce lot"
msgstr "Centrer sur ce lot"
msgid "Détails du contrat"
msgstr "Détails du contrat"
msgid "lots"
msgstr "lots"
msgid "Fonds de plan & Orthophotos"
msgstr "Fonds de plan & Orthophotos"

Binary file not shown.

View file

@ -1283,3 +1283,25 @@ msgstr "Kaart verkleinen"
#~ msgid "(aucune sous-catégorie)" #~ msgid "(aucune sous-catégorie)"
#~ msgstr "(geen subcategorie)" #~ msgstr "(geen subcategorie)"
msgid "Périmètres contractuels"
msgstr "Contractuele perimeters"
msgid "Afficher les lots"
msgstr "Toon percelen"
msgid "Aucun périmètre contractuel défini pour cette thématique."
msgstr "Geen contractuele perimeter gedefinieerd voor dit thema."
msgid "Centrer sur ce lot"
msgstr "Centreren op dit perceel"
msgid "Détails du contrat"
msgstr "Contractdetails"
msgid "lots"
msgstr "percelen"
msgid "Fonds de plan & Orthophotos"
msgstr "Achtergrondkaarten & Orthofoto's"

View file

@ -173,3 +173,24 @@ msgstr "Geen"
msgid "(aucune sous-catégorie)" msgid "(aucune sous-catégorie)"
msgstr "(geen subcategorie)" msgstr "(geen subcategorie)"
msgid "Périmètres contractuels"
msgstr "Contractuele perimeters"
msgid "Afficher les lots"
msgstr "Toon percelen"
msgid "Aucun périmètre contractuel défini pour cette thématique."
msgstr "Geen contractuele perimeter gedefinieerd voor dit thema."
msgid "Centrer sur ce lot"
msgstr "Centreren op dit perceel"
msgid "Détails du contrat"
msgstr "Contractdetails"
msgid "lots"
msgstr "percelen"
msgid "Fonds de plan & Orthophotos"
msgstr "Achtergrondkaarten & Orthofoto's"