2076 lines
112 KiB
HTML
2076 lines
112 KiB
HTML
{% extends "base.html" %}
|
|
{% load i18n %}
|
|
{% load static %}
|
|
|
|
{% block content %}
|
|
<!-- MapLibre & CSS/JS requirements -->
|
|
<link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet" />
|
|
<script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script>
|
|
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
|
<script src="{% static 'common/address_search_control.js' %}"></script>
|
|
|
|
<div class="container-fluid py-4" style="background-color: #f8fafc; min-height: 100vh;">
|
|
<!-- Header/Breadcrumbs -->
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<nav aria-label="breadcrumb">
|
|
<ol class="breadcrumb mb-2" style="background-color: transparent; padding: 0;">
|
|
<li class="breadcrumb-item"><a href="{% url 'assets:trafficlights_index' %}">{% translate "Thématiques" %}</a></li>
|
|
<li class="breadcrumb-item"><a href="{% url 'assets:trafficlights_intersections_detail' intersection.id %}">{{ intersection.code }}</a></li>
|
|
<li class="breadcrumb-item active" aria-current="page">{% translate "Importation Plan de croix" %}</li>
|
|
</ol>
|
|
</nav>
|
|
<div class="d-flex align-items-center justify-content-between">
|
|
<h2 class="h3 font-weight-bold text-dark mb-0">
|
|
<i class="bi bi-file-earmark-excel text-success me-2"></i>
|
|
{% translate "Importation d'un plan de croix (Excel)" %}
|
|
</h2>
|
|
<a href="{% url 'assets:trafficlights_intersections_detail' intersection.id %}" class="btn btn-outline-secondary btn-sm">
|
|
<i class="bi bi-arrow-left"></i> {% translate "Retour au carrefour" %}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 1: Select/Upload Plan -->
|
|
{% if step == 'select' %}
|
|
<div class="row">
|
|
<div class="col-md-6 mx-auto">
|
|
<div class="card border-0 shadow-sm rounded-4 overflow-hidden">
|
|
<div class="card-header text-white py-3" style="background: linear-gradient(135deg, #10b981, #059669);">
|
|
<h5 class="card-title mb-0 font-weight-semibold">
|
|
<i class="bi bi-file-earmark-arrow-up me-2"></i> {% translate "Sélectionner / Téléverser le plan de croix (.xlsx)" %}
|
|
</h5>
|
|
</div>
|
|
<div class="card-body p-4" style="background-color: #ffffff;">
|
|
{% if error %}
|
|
<div class="alert alert-danger rounded-3 d-flex align-items-center mb-4" role="alert">
|
|
<i class="bi bi-exclamation-triangle-fill fs-5 me-2"></i>
|
|
<div>{{ error }}</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<form method="post" enctype="multipart/form-data" id="select-import-form">
|
|
{% csrf_token %}
|
|
|
|
<!-- Select Existing Cross Plans -->
|
|
{% if cross_plan_choices %}
|
|
<div class="mb-4">
|
|
<label class="form-label font-weight-semibold text-secondary d-flex justify-content-between align-items-center">
|
|
<span>{% translate "Utiliser des plans de croix déjà rattachés" %}</span>
|
|
<div class="form-check form-check-inline m-0">
|
|
<input class="form-check-input" type="checkbox" id="select_all_existing_plans">
|
|
<label class="form-check-label small text-muted font-weight-medium" for="select_all_existing_plans">
|
|
{% translate "Tout sélectionner" %}
|
|
</label>
|
|
</div>
|
|
</label>
|
|
<div class="border rounded-3 p-3 bg-light overflow-auto" style="max-height: 250px;">
|
|
{% for cp in cross_plan_choices %}
|
|
<div class="form-check py-1 border-bottom last-border-0">
|
|
<input class="form-check-input existing-plan-checkbox" type="checkbox" name="existing_cross_plans" value="{{ cp.id }}" id="cp_{{ cp.id }}">
|
|
<label class="form-check-label text-dark small font-weight-medium d-block cursor-pointer" for="cp_{{ cp.id }}">
|
|
<i class="bi bi-file-earmark-excel-fill text-success me-1"></i>
|
|
{{ cp.title }} <span class="text-muted font-weight-normal">({{ cp.filename }})</span>
|
|
</label>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
<div class="form-text mt-2 text-muted">
|
|
{% translate "Sélectionnez un ou plusieurs plans de croix déjà associés à ce carrefour, ou téléversez-en de nouveaux ci-dessous." %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-center my-3 text-muted font-weight-medium">
|
|
— {% translate "OU" %} —
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Upload Excel File -->
|
|
<div class="mb-4">
|
|
<label for="cross_plan_file_upload" class="form-label font-weight-semibold text-secondary">
|
|
{% translate "Fichier Excel du plan de croix" %}
|
|
</label>
|
|
<input type="file" name="cross_plan_file_upload" id="cross_plan_file_upload" class="form-control form-control-lg rounded-3" accept=".xlsx" multiple required>
|
|
<div class="form-text mt-2 text-muted">
|
|
{% translate "Téléversez un ou plusieurs classeurs Excel de plans de croix (contenant l'onglet 'kruisjesplan' ou 'Plan croix')." %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-grid mt-4">
|
|
<button type="submit" class="btn btn-success btn-lg rounded-3 shadow-sm font-weight-semibold">
|
|
<i class="bi bi-search me-1"></i> {% translate "Analyser et comparer les assets" %}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const fileInput = document.getElementById('cross_plan_file_upload');
|
|
const checkboxes = document.querySelectorAll('.existing-plan-checkbox');
|
|
const selectAllCheckbox = document.getElementById('select_all_existing_plans');
|
|
const form = document.getElementById('select-import-form');
|
|
|
|
if (selectAllCheckbox) {
|
|
selectAllCheckbox.addEventListener('change', function() {
|
|
checkboxes.forEach(cb => {
|
|
cb.checked = selectAllCheckbox.checked;
|
|
});
|
|
});
|
|
}
|
|
|
|
if (fileInput && form) {
|
|
fileInput.removeAttribute('required');
|
|
form.addEventListener('submit', function(e) {
|
|
const anyChecked = Array.from(checkboxes).some(cb => cb.checked);
|
|
if (!fileInput.value && !anyChecked) {
|
|
e.preventDefault();
|
|
alert("{% translate 'Veuillez téléverser un fichier Excel ou sélectionner au moins un plan existant.' %}");
|
|
}
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
|
|
<!-- Step 2: Compare & Edit Actions -->
|
|
{% if step == 'preview' %}
|
|
<form method="post" action="{% url 'assets:integrate_cross_plan' intersection.id %}" id="combined-import-form">
|
|
{% csrf_token %}
|
|
<input type="hidden" name="cross_plan_filename" value="{{ cross_plan_filename }}">
|
|
<input type="hidden" name="global_action" id="global_action_input" value="apply">
|
|
|
|
<!-- Hidden input fields for poles coordinates -->
|
|
{% for item in poles %}
|
|
{% if item.status != 'db_only' %}
|
|
<input type="hidden" name="lon_pole_{{ item.code }}" id="lon_pole_{{ item.code }}" value="">
|
|
<input type="hidden" name="lat_pole_{{ item.code }}" id="lat_pole_{{ item.code }}" value="">
|
|
<input type="hidden" name="x_3812_pole_{{ item.code }}" id="x_3812_pole_{{ item.code }}" value="">
|
|
<input type="hidden" name="y_3812_pole_{{ item.code }}" id="y_3812_pole_{{ item.code }}" value="">
|
|
{% endif %}
|
|
{% endfor %}
|
|
|
|
<!-- Hidden input fields for cables geometries -->
|
|
{% for item in cables %}
|
|
{% if item.status != 'db_only' %}
|
|
<input type="hidden" name="geom_cable_{{ item.code }}" id="geom_cable_{{ item.code }}" value="">
|
|
{% endif %}
|
|
{% endfor %}
|
|
|
|
<!-- A. STANDARD COMPARATIVE TABLE VIEW (Default Visible) -->
|
|
<div id="table-view" class="d-block">
|
|
<div class="row">
|
|
<!-- Summary Card -->
|
|
<div class="col-12 mb-4">
|
|
<div class="card border-0 shadow-sm rounded-4 p-3 d-flex flex-row align-items-center justify-content-between" style="background-color: #ffffff;">
|
|
<div>
|
|
<h5 class="mb-1 text-dark font-weight-semibold">{% translate "Aperçu de la comparaison des assets" %}</h5>
|
|
<p class="text-muted mb-0 small">
|
|
{% if uploaded_filenames %}
|
|
{% translate "Fichiers :" %} <strong>{{ uploaded_filenames|join:", " }}</strong>
|
|
{% else %}
|
|
{% translate "Fichier :" %} <code>{{ cross_plan_filename }}</code>
|
|
{% endif %}
|
|
| {% translate "Carrefour :" %} <strong>{{ intersection.code }}</strong>
|
|
</p>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button type="button" class="btn btn-outline-danger btn-sm rounded-3 shadow-sm px-3" onclick="triggerGlobalAction('replace_all')">
|
|
<i class="bi bi-trash3-fill"></i> {% translate "Tout remplacer (Archiver l'existant + Recréer)" %}
|
|
</button>
|
|
<button type="submit" class="btn btn-primary btn-sm rounded-3 shadow-sm px-4">
|
|
<i class="bi bi-check2-circle"></i> {% translate "Appliquer les décisions" %}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Interactive Table Grid -->
|
|
<div class="col-12">
|
|
<div class="card border-0 shadow-sm rounded-4 overflow-hidden mb-4">
|
|
<!-- Tab Headers -->
|
|
<div class="card-header bg-light border-bottom p-0">
|
|
<ul class="nav nav-tabs border-0" id="comparisonTabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active px-4 py-3 border-0 rounded-0" id="poles-tab" data-bs-toggle="tab" data-bs-target="#poles-pane" type="button" role="tab" aria-controls="poles-pane" aria-selected="true">
|
|
<i class="bi bi-cone-striped me-2"></i> {% translate "Poteaux" %} <span class="badge bg-secondary ms-1">{{ poles|length }}</span>
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link px-4 py-3 border-0 rounded-0" id="lanterns-tab" data-bs-toggle="tab" data-bs-target="#lanterns-pane" type="button" role="tab" aria-controls="lanterns-pane" aria-selected="false">
|
|
<i class="bi bi-traffic-light me-2"></i> {% translate "Lanternes" %} <span class="badge bg-secondary ms-1">{{ lanterns|length }}</span>
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link px-4 py-3 border-0 rounded-0" id="detectors-tab" data-bs-toggle="tab" data-bs-target="#detectors-pane" type="button" role="tab" aria-controls="detectors-pane" aria-selected="false">
|
|
<i class="bi bi-broadcast-pin me-2"></i> {% translate "Détecteurs & BP" %} <span class="badge bg-secondary ms-1">{{ detectors|length }}</span>
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link px-4 py-3 border-0 rounded-0" id="cables-tab" data-bs-toggle="tab" data-bs-target="#cables-pane" type="button" role="tab" aria-controls="cables-pane" aria-selected="false">
|
|
<i class="bi bi-bezier2 me-2"></i> {% translate "Câbles" %} <span class="badge bg-secondary ms-1">{{ cables|length }}</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Tab Contents -->
|
|
<div class="card-body p-0">
|
|
<div class="tab-content" id="comparisonTabsContent">
|
|
|
|
<!-- 1. POLES PANEL -->
|
|
<div class="tab-pane fade show active" id="poles-pane" role="tabpanel" aria-labelledby="poles-tab">
|
|
<div class="p-3 bg-light border-bottom d-flex align-items-center justify-content-between">
|
|
<span class="text-secondary small">
|
|
<i class="bi bi-info-circle"></i> {% translate "Vous pouvez géolocaliser les poteaux en les plaçant sur le plan de la carte." %}
|
|
</span>
|
|
<button type="button" class="btn btn-outline-primary btn-sm rounded-3 show-map-btn" data-target-tab="poles">
|
|
<i class="bi bi-map-fill me-1"></i> {% translate "Positionner les poteaux sur la carte" %}
|
|
</button>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th class="ps-4" style="width: 40px;"><input type="checkbox" id="select-all-poles" class="form-check-input" checked></th>
|
|
<th>{% translate "Code Asset" %}</th>
|
|
<th>{% translate "Nom" %}</th>
|
|
<th>{% translate "Statut" %}</th>
|
|
<th>{% translate "Modèle Déduit (Excel)" %}</th>
|
|
<th>{% translate "Modèle Actuel (DB)" %}</th>
|
|
<th>{% translate "Décision" %}</th>
|
|
<th class="pe-4">{% translate "Forcer le Modèle" %}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in poles %}
|
|
<tr class="{% if item.status == 'new' %}table-success-light{% elif item.status == 'modified' %}table-warning-light{% elif item.status == 'db_only' %}table-danger-light{% endif %}">
|
|
<td class="ps-4"><input type="checkbox" name="{% if item.status == 'db_only' %}selected_pole_db_{{ item.code }}{% else %}selected_pole_{{ item.code }}{% endif %}" class="form-check-input pole-checkbox" checked></td>
|
|
<td class="font-weight-medium"><code>{{ item.code }}</code><input type="hidden" name="db_id_pole_{{ item.code }}" value="{{ item.db_id|default:'' }}"></td>
|
|
<td>
|
|
<input type="text" name="name_pole_{{ item.code }}" class="form-control form-control-sm" value="{{ item.name }}">
|
|
</td>
|
|
<td>
|
|
{% if item.status == 'new' %}
|
|
<span class="badge bg-success">{% translate "Nouveau" %}</span>
|
|
{% elif item.status == 'modified' %}
|
|
<span class="badge bg-warning text-dark">{% translate "Modifié" %}</span>
|
|
{% elif item.status == 'identical' %}
|
|
<span class="badge bg-light text-secondary border">{% translate "Identique" %}</span>
|
|
{% elif item.status == 'db_only' %}
|
|
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td>
|
|
<td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td>
|
|
<td>
|
|
{% if item.status == 'db_only' %}
|
|
<select name="action_pole_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
<option value="keep">{% translate "Conserver en DB" %}</option>
|
|
<option value="archive" selected>{% translate "Archiver" %}</option>
|
|
</select>
|
|
{% else %}
|
|
<select name="action_pole_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
{% if item.status == 'new' %}
|
|
<option value="add" selected>{% translate "Créer l'asset" %}</option>
|
|
<option value="keep">{% translate "Ignorer" %}</option>
|
|
{% elif item.status == 'modified' %}
|
|
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
|
|
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
|
|
<option value="keep">{% translate "Conserver existant" %}</option>
|
|
{% elif item.status == 'identical' %}
|
|
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
|
|
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
|
|
{% endif %}
|
|
</select>
|
|
{% endif %}
|
|
</td>
|
|
<td class="pe-4">
|
|
{% if item.status != 'db_only' %}
|
|
<select name="model_pole_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
<option value="">-- {% translate "Laisser par défaut" %} --</option>
|
|
{% for m in pole_models %}
|
|
<option value="{{ m.id }}">{{ m.name_fr }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% else %}
|
|
<span class="text-muted small">—</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% empty %}
|
|
<tr>
|
|
<td colspan="7" class="text-center py-4 text-muted">{% translate "Aucun poteau détecté." %}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 2. LANTERNS PANEL -->
|
|
<div class="tab-pane fade" id="lanterns-pane" role="tabpanel" aria-labelledby="lanterns-tab">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th class="ps-4" style="width: 40px;"><input type="checkbox" id="select-all-lanterns" class="form-check-input" checked></th>
|
|
<th>{% translate "Code Asset" %}</th>
|
|
<th>{% translate "Nom" %}</th>
|
|
<th>{% translate "Poteau" %}</th>
|
|
<th>{% translate "Statut" %}</th>
|
|
<th>{% translate "Modèle Déduit (Excel)" %}</th>
|
|
<th>{% translate "Modèle Actuel (DB)" %}</th>
|
|
<th>{% translate "Décision" %}</th>
|
|
<th class="pe-4">{% translate "Forcer le Modèle" %}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in lanterns %}
|
|
<tr class="{% if item.status == 'new' %}table-success-light{% elif item.status == 'modified' %}table-warning-light{% elif item.status == 'db_only' %}table-danger-light{% endif %}">
|
|
<td class="ps-4"><input type="checkbox" name="{% if item.status == 'db_only' %}selected_lantern_db_{{ item.code }}{% else %}selected_lantern_{{ item.code }}{% endif %}" class="form-check-input lantern-checkbox" checked></td>
|
|
<td class="font-weight-medium"><code>{{ item.code }}</code><input type="hidden" name="db_id_lantern_{{ item.code }}" value="{{ item.db_id|default:'' }}"></td>
|
|
<td>
|
|
<input type="text" name="name_lantern_{{ item.code }}" class="form-control form-control-sm" value="{{ item.name }}">
|
|
</td>
|
|
<td><code>{{ item.pole_code }}</code></td>
|
|
<td>
|
|
{% if item.status == 'new' %}
|
|
<span class="badge bg-success">{% translate "Nouveau" %}</span>
|
|
{% elif item.status == 'modified' %}
|
|
<span class="badge bg-warning text-dark">{% translate "Modifié" %}</span>
|
|
{% elif item.status == 'identical' %}
|
|
<span class="badge bg-light text-secondary border">{% translate "Identique" %}</span>
|
|
{% elif item.status == 'db_only' %}
|
|
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td>
|
|
<td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td>
|
|
<td>
|
|
{% if item.status == 'db_only' %}
|
|
<select name="action_lantern_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
<option value="keep">{% translate "Conserver en DB" %}</option>
|
|
<option value="archive" selected>{% translate "Archiver" %}</option>
|
|
</select>
|
|
{% else %}
|
|
<select name="action_lantern_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
{% if item.status == 'new' %}
|
|
<option value="add" selected>{% translate "Créer l'asset" %}</option>
|
|
<option value="keep">{% translate "Ignorer" %}</option>
|
|
{% elif item.status == 'modified' %}
|
|
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
|
|
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
|
|
<option value="keep">{% translate "Conserver existant" %}</option>
|
|
{% elif item.status == 'identical' %}
|
|
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
|
|
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
|
|
{% endif %}
|
|
</select>
|
|
{% endif %}
|
|
</td>
|
|
<td class="pe-4">
|
|
{% if item.status != 'db_only' %}
|
|
<select name="model_lantern_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
<option value="">-- {% translate "Laisser par défaut" %} --</option>
|
|
{% for m in lantern_models %}
|
|
<option value="{{ m.id }}">{{ m.name_fr }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% else %}
|
|
<span class="text-muted small">—</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% empty %}
|
|
<tr>
|
|
<td colspan="8" class="text-center py-4 text-muted">{% translate "Aucune lanterne détectée." %}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 3. DETECTORS PANEL -->
|
|
<div class="tab-pane fade" id="detectors-pane" role="tabpanel" aria-labelledby="detectors-tab">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th class="ps-4" style="width: 40px;"><input type="checkbox" id="select-all-detectors" class="form-check-input" checked></th>
|
|
<th>{% translate "Code Asset" %}</th>
|
|
<th>{% translate "Nom" %}</th>
|
|
<th>{% translate "Poteau" %}</th>
|
|
<th>{% translate "Statut" %}</th>
|
|
<th>{% translate "Modèle Déduit (Excel)" %}</th>
|
|
<th>{% translate "Modèle Actuel (DB)" %}</th>
|
|
<th>{% translate "Décision" %}</th>
|
|
<th class="pe-4">{% translate "Forcer le Modèle" %}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in detectors %}
|
|
<tr class="{% if item.status == 'new' %}table-success-light{% elif item.status == 'modified' %}table-warning-light{% elif item.status == 'db_only' %}table-danger-light{% endif %}">
|
|
<td class="ps-4"><input type="checkbox" name="{% if item.status == 'db_only' %}selected_detector_db_{{ item.code }}{% else %}selected_detector_{{ item.code }}{% endif %}" class="form-check-input detector-checkbox" checked></td>
|
|
<td class="font-weight-medium"><code>{{ item.code }}</code><input type="hidden" name="db_id_detector_{{ item.code }}" value="{{ item.db_id|default:'' }}"></td>
|
|
<td>
|
|
<input type="text" name="name_detector_{{ item.code }}" class="form-control form-control-sm" value="{{ item.name }}">
|
|
</td>
|
|
<td><code>{{ item.pole_code|default:"—" }}</code></td>
|
|
<td>
|
|
{% if item.status == 'new' %}
|
|
<span class="badge bg-success">{% translate "Nouveau" %}</span>
|
|
{% elif item.status == 'modified' %}
|
|
<span class="badge bg-warning text-dark">{% translate "Modifié" %}</span>
|
|
{% elif item.status == 'identical' %}
|
|
<span class="badge bg-light text-secondary border">{% translate "Identique" %}</span>
|
|
{% elif item.status == 'db_only' %}
|
|
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td>
|
|
<td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td>
|
|
<td>
|
|
{% if item.status == 'db_only' %}
|
|
<select name="action_detector_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
<option value="keep">{% translate "Conserver en DB" %}</option>
|
|
<option value="archive" selected>{% translate "Archiver" %}</option>
|
|
</select>
|
|
{% else %}
|
|
<select name="action_detector_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
{% if item.status == 'new' %}
|
|
<option value="add" selected>{% translate "Créer l'asset" %}</option>
|
|
<option value="keep">{% translate "Ignorer" %}</option>
|
|
{% elif item.status == 'modified' %}
|
|
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
|
|
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
|
|
<option value="keep">{% translate "Conserver existant" %}</option>
|
|
{% elif item.status == 'identical' %}
|
|
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
|
|
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
|
|
{% endif %}
|
|
</select>
|
|
{% endif %}
|
|
</td>
|
|
<td class="pe-4">
|
|
{% if item.status != 'db_only' %}
|
|
<select name="model_detector_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
<option value="">-- {% translate "Laisser par défaut" %} --</option>
|
|
{% for m in detector_models %}
|
|
<option value="{{ m.id }}">{{ m.name_fr }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% else %}
|
|
<span class="text-muted small">—</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% empty %}
|
|
<tr>
|
|
<td colspan="8" class="text-center py-4 text-muted">{% translate "Aucun détecteur détecté." %}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 4. CABLES PANEL -->
|
|
<div class="tab-pane fade" id="cables-pane" role="tabpanel" aria-labelledby="cables-tab">
|
|
<div class="p-3 bg-light border-bottom d-flex align-items-center justify-content-between">
|
|
<span class="text-secondary small">
|
|
<i class="bi bi-info-circle"></i> {% translate "Vous pouvez tracer précisément les câbles d'interconnexion le long des tranchées de la carte." %}
|
|
</span>
|
|
<button type="button" class="btn btn-outline-success btn-sm rounded-3 show-map-btn" data-target-tab="cables">
|
|
<i class="bi bi-bezier2 me-1"></i> {% translate "Tracer les câbles sur la carte" %}
|
|
</button>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th class="ps-4" style="width: 40px;"><input type="checkbox" id="select-all-cables" class="form-check-input" checked></th>
|
|
<th>{% translate "Code Asset" %}</th>
|
|
<th>{% translate "Nom" %}</th>
|
|
<th>{% translate "Statut" %}</th>
|
|
<th>{% translate "Modèle Déduit (Excel)" %}</th>
|
|
<th>{% translate "Modèle Actuel (DB)" %}</th>
|
|
<th>{% translate "Décision" %}</th>
|
|
<th class="pe-4">{% translate "Forcer le Modèle" %}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for item in cables %}
|
|
<tr class="{% if item.status == 'new' %}table-success-light{% elif item.status == 'modified' %}table-warning-light{% elif item.status == 'db_only' %}table-danger-light{% endif %}">
|
|
<td class="ps-4"><input type="checkbox" name="{% if item.status == 'db_only' %}selected_cable_db_{{ item.code }}{% else %}selected_cable_{{ item.code }}{% endif %}" class="form-check-input cable-checkbox" checked></td>
|
|
<td class="font-weight-medium"><code>{{ item.code }}</code><input type="hidden" name="db_id_cable_{{ item.code }}" value="{{ item.db_id|default:'' }}"></td>
|
|
<td>
|
|
<input type="text" name="name_cable_{{ item.code }}" class="form-control form-control-sm" value="{{ item.name }}">
|
|
</td>
|
|
<td>
|
|
{% if item.status == 'new' %}
|
|
<span class="badge bg-success">{% translate "Nouveau" %}</span>
|
|
{% elif item.status == 'modified' %}
|
|
<span class="badge bg-warning text-dark">{% translate "Modifié" %}</span>
|
|
{% elif item.status == 'identical' %}
|
|
<span class="badge bg-light text-secondary border">{% translate "Identique" %}</span>
|
|
{% elif item.status == 'db_only' %}
|
|
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td>
|
|
<td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td>
|
|
<td>
|
|
{% if item.status == 'db_only' %}
|
|
<select name="action_cable_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
<option value="keep">{% translate "Conserver en DB" %}</option>
|
|
<option value="archive" selected>{% translate "Archiver" %}</option>
|
|
</select>
|
|
{% else %}
|
|
<select name="action_cable_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
{% if item.status == 'new' %}
|
|
<option value="add" selected>{% translate "Créer l'asset" %}</option>
|
|
<option value="keep">{% translate "Ignorer" %}</option>
|
|
{% elif item.status == 'modified' %}
|
|
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
|
|
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
|
|
<option value="keep">{% translate "Conserver existant" %}</option>
|
|
{% elif item.status == 'identical' %}
|
|
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
|
|
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
|
|
{% endif %}
|
|
</select>
|
|
{% endif %}
|
|
</td>
|
|
<td class="pe-4">
|
|
{% if item.status != 'db_only' %}
|
|
<select name="model_cable_{{ item.code }}" class="form-select form-select-sm rounded-2">
|
|
<option value="">-- {% translate "Laisser par défaut" %} --</option>
|
|
{% for m in cable_models %}
|
|
<option value="{{ m.id }}">{{ m.name_fr }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% else %}
|
|
<span class="text-muted small">—</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% empty %}
|
|
<tr>
|
|
<td colspan="7" class="text-center py-4 text-muted">{% translate "Aucun câble détecté." %}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- B. SPLIT SCREEN WORKSPACE VIEW (Default Hidden) -->
|
|
<div id="map-view" class="d-none">
|
|
<div class="row">
|
|
<!-- Left Panel (Sidebar for map elements) -->
|
|
<div class="col-lg-4 col-md-5 mb-3 d-flex flex-column" style="height: calc(100vh - 120px);">
|
|
<div class="card border-0 shadow-sm rounded-4 overflow-hidden h-100 d-flex flex-column" style="background-color: #ffffff;">
|
|
|
|
<!-- Header & Switch back button + placed count display -->
|
|
<div class="p-3 border-bottom bg-light d-flex flex-column gap-2">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm w-100 hide-map-btn rounded-3">
|
|
<i class="bi bi-arrow-left me-1"></i> {% translate "Retour au tableau comparatif" %}
|
|
</button>
|
|
<div class="d-flex align-items-center justify-content-between text-muted small px-1">
|
|
<span>{% translate "Poteaux positionnés :" %}</span>
|
|
<strong id="placed-poles-count" class="text-primary font-monospace">0 / 0</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs Header -->
|
|
<div class="card-header bg-white py-2 border-bottom">
|
|
<ul class="nav nav-tabs card-header-tabs" id="mapTabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active font-weight-bold px-2 py-1.5" id="map-poles-tab" data-bs-toggle="tab" data-bs-target="#map-poles-pane" type="button" role="tab" aria-controls="map-poles-pane" aria-selected="true" style="font-size: 0.82rem;">
|
|
{% translate "Poteaux" %}
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link font-weight-bold px-2 py-1.5" id="map-cables-tab" data-bs-toggle="tab" data-bs-target="#map-cables-pane" type="button" role="tab" aria-controls="map-cables-pane" aria-selected="false" style="font-size: 0.82rem;">
|
|
{% translate "Câbles" %}
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Scrollable Content without double scrollbars -->
|
|
<div class="card-body p-3 flex-grow-1 overflow-hidden d-flex flex-column" style="font-size: 0.82rem;">
|
|
<div class="tab-content flex-grow-1 overflow-hidden" id="mapTabsContent">
|
|
|
|
<!-- Map Poles Sub-pane -->
|
|
<div class="tab-pane fade show active h-100" id="map-poles-pane" role="tabpanel" aria-labelledby="map-poles-tab">
|
|
<div class="d-flex flex-column h-100 align-items-start justify-content-start">
|
|
<div class="mb-2 p-2 bg-light rounded-3 border w-100">
|
|
<div class="font-weight-semibold text-primary mb-1">
|
|
<i class="bi bi-info-circle-fill"></i> {% translate "Aide au placement" %}
|
|
</div>
|
|
<p class="text-secondary mb-0 small">
|
|
{% translate "Cliquez sur 'Placer sur la carte' pour un poteau, puis cliquez sur la carte pour le positionner." %}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="list-group list-group-flush overflow-auto flex-grow-1 w-100 align-self-stretch">
|
|
{% for item in poles %}
|
|
{% if item.status != 'db_only' %}
|
|
<div class="list-group-item py-2 px-1 border-bottom d-flex flex-column gap-1 pole-config-item align-self-stretch" data-code="{{ item.code }}">
|
|
<div class="d-flex align-items-center justify-content-between w-100">
|
|
<strong class="font-monospace text-dark">{{ item.short_code }}</strong>
|
|
<span class="badge bg-danger text-white georef-status-badge" id="georef_badge_{{ item.code }}" style="font-size: 0.7rem;">{% translate "À placer" %}</span>
|
|
</div>
|
|
<div class="text-muted small font-monospace">{{ item.deduced_model_name|default:item.db_model_name }}</div>
|
|
<div class="small text-muted">
|
|
<span id="meta_lanterns_{{ item.code }}">0 Lanternes</span> |
|
|
<span id="meta_detectors_{{ item.code }}">0 Détecteurs</span>
|
|
</div>
|
|
<div class="mt-1 align-self-start">
|
|
<button type="button" class="btn btn-xs btn-outline-primary py-0.5 px-2 start-placement-btn rounded-2" data-code="{{ item.code }}">
|
|
<i class="bi bi-geo-alt-fill"></i> {% translate "Placer sur la carte" %}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map Cables Sub-pane -->
|
|
<div class="tab-pane fade h-100" id="map-cables-pane" role="tabpanel" aria-labelledby="map-cables-tab">
|
|
<div class="d-flex flex-column h-100 align-items-start justify-content-start">
|
|
<div class="mb-2 p-2 bg-light rounded-3 border w-100">
|
|
<div class="font-weight-semibold text-success mb-1">
|
|
<i class="bi bi-bezier"></i> {% translate "Tracé des câbles" %}
|
|
</div>
|
|
<p class="text-secondary mb-0 small">
|
|
{% translate "Cliquez sur 'Tracer / Éditer' puis double-cliquez sur la carte pour tracer la ligne du câble." %}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="list-group list-group-flush overflow-auto flex-grow-1 w-100 align-self-stretch">
|
|
{% for item in cables %}
|
|
{% if item.status != 'db_only' %}
|
|
<div class="list-group-item py-2 px-1 border-bottom d-flex flex-column gap-1 cable-config-item align-self-stretch" data-code="{{ item.code }}">
|
|
<div class="d-flex align-items-center justify-content-between w-100">
|
|
<strong class="text-dark font-monospace">{{ item.name }}</strong>
|
|
</div>
|
|
<div class="text-muted small font-monospace">{{ item.deduced_model_name }}</div>
|
|
<div class="mt-1 align-self-start">
|
|
<button type="button" class="btn btn-xs btn-outline-success py-0.5 px-2 edit-cable-btn rounded-2" data-code="{{ item.code }}">
|
|
<i class="bi bi-pencil-square"></i> {% translate "Tracer / Éditer" %}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Panel (Interactive Map) -->
|
|
<div class="col-lg-8 col-md-7 mb-3" style="height: calc(100vh - 120px);">
|
|
<div class="card border-0 shadow-sm rounded-4 overflow-hidden h-100 position-relative">
|
|
<!-- Mode indicator centered at the top -->
|
|
<div class="position-absolute top-0 start-50 translate-middle-x m-3 z-3 bg-white py-1 px-3 rounded-pill shadow border d-flex align-items-center gap-2" style="white-space: nowrap;">
|
|
<span class="small text-muted font-monospace font-weight-semibold mb-0" id="map-mode-indicator">{% translate "Mode: Lecture" %}</span>
|
|
<button type="button" id="btn-clear-trace" class="btn btn-xs btn-outline-danger rounded-pill py-0.5 px-2" style="display: none; font-size: 0.75rem;">
|
|
<i class="bi bi-trash"></i> {% translate "Effacer" %}
|
|
</button>
|
|
</div>
|
|
|
|
<div id="map" class="w-100 h-100"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Georeferenced Plans Modal -->
|
|
<div class="modal fade" id="georefPlanModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content rounded-3 border-0 shadow-lg">
|
|
<div class="modal-header bg-light border-bottom-0 py-3">
|
|
<h5 class="modal-title font-weight-bold text-dark"><i class="bi bi-geo-alt-fill text-primary me-2"></i>{% translate "Plans géoréférencés" %}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body py-3">
|
|
<!-- Nav Tabs -->
|
|
<ul class="nav nav-tabs nav-fill mb-3" id="georefPlanTabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active font-weight-bold" id="tab-image-plan" data-bs-toggle="tab" data-bs-target="#panel-image-plan" type="button" role="tab" aria-controls="panel-image-plan" aria-selected="true">
|
|
<i class="bi bi-file-earmark-image me-1"></i>{% translate "Plan image" %}
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link font-weight-bold" id="tab-vector-plan" data-bs-toggle="tab" data-bs-target="#panel-vector-plan" type="button" role="tab" aria-controls="panel-vector-plan" aria-selected="false">
|
|
<i class="bi bi-file-earmark-code me-1"></i>{% translate "Plan CAO" %}
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
|
|
<!-- Tab Content -->
|
|
<div class="tab-content pt-2" id="georefPlanTabContent">
|
|
<!-- Tab 1: Image Plan -->
|
|
<div class="tab-pane fade show active" id="panel-image-plan" role="tabpanel" aria-labelledby="tab-image-plan">
|
|
<div id="image-plan-selection-container">
|
|
<div id="image-plans-list" class="d-flex flex-column gap-3 mb-2">
|
|
<!-- Dynamically populated list of images with switches and sliders -->
|
|
</div>
|
|
<div id="no-georef-images-alert" class="text-center py-4" style="display: none;">
|
|
<div class="text-muted mb-3"><i class="bi bi-info-circle" style="font-size: 2.5rem;"></i></div>
|
|
<p class="text-dark font-weight-semibold mb-1">{% translate "Aucun plan d'image géoréférencé disponible" %}</p>
|
|
<p class="text-muted small">{% translate "Géoréférencez d'abord un plan PDF ou image dans l'onglet Documents de la fiche." %}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: Vector Plan (DXF) -->
|
|
<div class="tab-pane fade" id="panel-vector-plan" role="tabpanel" aria-labelledby="tab-vector-plan">
|
|
<div id="dxf-selection-container">
|
|
{% if not dxf_files %}
|
|
<div class="text-center py-4">
|
|
<div class="text-muted mb-3"><i class="bi bi-exclamation-triangle" style="font-size: 2.5rem;"></i></div>
|
|
<p class="text-dark font-weight-semibold mb-1">{% translate "Aucun fichier DXF disponible pour ce carrefour" %}</p>
|
|
<p class="text-muted small">{% translate "Veuillez d'abord en importer un." %}</p>
|
|
</div>
|
|
{% else %}
|
|
<!-- File Selection -->
|
|
<div class="mb-3">
|
|
<label class="form-label font-weight-semibold text-muted small uppercase">{% translate "Sélectionner le plan DXF" %}</label>
|
|
{% if dxf_files|length == 1 %}
|
|
<div class="p-3 bg-light border rounded-3 font-weight-semibold text-dark mb-3">
|
|
<i class="bi bi-file-earmark-check-fill text-success me-2"></i>{{ dxf_files.0 }}
|
|
<input type="hidden" id="select-dxf-file" value="{{ dxf_files.0 }}">
|
|
</div>
|
|
{% else %}
|
|
<select id="select-dxf-file" class="form-select rounded-3 p-3 mb-3">
|
|
<option value="">{% translate "-- Sélectionner un plan CAO (DXF) --" %}</option>
|
|
{% for f in dxf_files %}
|
|
<option value="{{ f }}">{{ f }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Layers selection checklist -->
|
|
<div id="dxf-detail-layers-section" style="display: none;" class="mb-3">
|
|
<label class="form-label font-weight-semibold text-muted small uppercase d-flex justify-content-between align-items-center mb-2">
|
|
<span>{% translate "Couches vectorielles CAO" %}</span>
|
|
<button type="button" id="dxf-detail-toggle-all-layers" class="btn btn-link btn-sm p-0 text-decoration-none" style="font-size: 0.75rem;">
|
|
{% translate "Décocher tout" %}
|
|
</button>
|
|
</label>
|
|
<div id="dxf-detail-layers-spinner" class="text-center py-4" style="display: none;">
|
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
|
<div class="text-muted small mt-2">{% translate "Lecture des couches du plan..." %}</div>
|
|
</div>
|
|
<div id="dxf-detail-layers-list" class="border rounded-3 p-3 bg-white" style="max-height: 250px; overflow-y: auto;">
|
|
<!-- Checkboxes will be populated dynamically -->
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer border-top-0 pt-0 pb-4 justify-content-between px-4">
|
|
<button type="button" id="btn-disable-all-plans" class="btn btn-outline-danger px-3 rounded-3">
|
|
<i class="bi bi-eye-slash-fill me-1"></i>{% translate "Désactiver tout" %}
|
|
</button>
|
|
<button type="button" class="btn btn-secondary px-4 rounded-3 font-weight-semibold shadow-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if step == 'preview' %}
|
|
<!-- Embedded JSON Data for JS Map Initialization -->
|
|
{{ existing_poles_geojson|json_script:"existing_poles_geojson" }}
|
|
{{ intersection.lon|json_script:"intersection_lon" }}
|
|
{{ intersection.lat|json_script:"intersection_lat" }}
|
|
{{ dxf_poles|json_script:"dxf_poles" }}
|
|
{{ dxf_files|json_script:"dxf_files" }}
|
|
{{ georeferenced_documents_json|json_script:"georeferencedDocumentsData" }}
|
|
{% endif %}
|
|
|
|
<style>
|
|
.table-success-light { background-color: #f0fdf4 !important; }
|
|
.table-warning-light { background-color: #fffbeb !important; }
|
|
.table-danger-light { background-color: #fef2f2 !important; }
|
|
.nav-tabs .nav-link {
|
|
color: #64748b;
|
|
font-weight: 500;
|
|
background-color: transparent;
|
|
transition: background-color 0.15s, color 0.15s;
|
|
}
|
|
.nav-tabs .nav-link:hover {
|
|
background-color: #f1f5f9;
|
|
color: #334155;
|
|
}
|
|
.nav-tabs .nav-link.active {
|
|
color: #2563eb !important;
|
|
background-color: #ffffff !important;
|
|
border-bottom: 2px solid #2563eb !important;
|
|
}
|
|
.pole-marker {
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
transition: transform 0.15s ease;
|
|
}
|
|
.pole-marker:hover { transform: scale(1.1); }
|
|
.btn-xs { padding: 2px 6px; font-size: 0.75rem; }
|
|
.cable-handle:hover { transform: scale(1.3); }
|
|
|
|
/* Pull top-right controls to the top and leave margin below orthophotos button */
|
|
#map .maplibregl-ctrl-top-right {
|
|
top: 10px !important;
|
|
}
|
|
.layer-switcher-ctrl {
|
|
margin-bottom: 15px !important;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
function triggerGlobalAction(actionType) {
|
|
if (actionType === 'replace_all') {
|
|
if (confirm("{% translate 'Voulez-vous vraiment archiver TOUS les assets actifs existants de ce carrefour et recréer les nouveaux à partir du fichier Excel ? Cette action est irréversible.' %}")) {
|
|
document.getElementById('global_action_input').value = 'replace_all';
|
|
document.getElementById('combined-import-form').submit();
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{% if step == 'preview' %}
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// 1. Select all checkboxes in tables
|
|
const bindSelectAll = (headerId, checkboxClass) => {
|
|
const headerCheckbox = document.getElementById(headerId);
|
|
if (!headerCheckbox) return;
|
|
headerCheckbox.addEventListener('change', () => {
|
|
const checkboxes = document.querySelectorAll('.' + checkboxClass);
|
|
checkboxes.forEach(cb => {
|
|
cb.checked = headerCheckbox.checked;
|
|
});
|
|
});
|
|
};
|
|
|
|
bindSelectAll('select-all-poles', 'pole-checkbox');
|
|
bindSelectAll('select-all-lanterns', 'lantern-checkbox');
|
|
bindSelectAll('select-all-detectors', 'detector-checkbox');
|
|
bindSelectAll('select-all-cables', 'cable-checkbox');
|
|
|
|
// 2. Interactive Switch Logic between Table View and Map View
|
|
const tableView = document.getElementById('table-view');
|
|
const mapView = document.getElementById('map-view');
|
|
|
|
document.querySelectorAll('.show-map-btn').forEach(btn => {
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const targetTab = this.dataset.targetTab;
|
|
|
|
tableView.classList.remove('d-block');
|
|
tableView.classList.add('d-none');
|
|
|
|
mapView.classList.remove('d-none');
|
|
mapView.classList.add('d-block');
|
|
|
|
// Trigger MapLibre GL canvas resize recalculation
|
|
if (map) {
|
|
map.resize();
|
|
}
|
|
|
|
// Activate corresponding tab in map sidebar
|
|
if (targetTab === 'poles') {
|
|
const tabEl = document.getElementById('map-poles-tab');
|
|
if (tabEl) new bootstrap.Tab(tabEl).show();
|
|
} else if (targetTab === 'cables') {
|
|
const tabEl = document.getElementById('map-cables-tab');
|
|
if (tabEl) new bootstrap.Tab(tabEl).show();
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.hide-map-btn').forEach(btn => {
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
|
|
mapView.classList.remove('d-block');
|
|
mapView.classList.add('d-none');
|
|
|
|
tableView.classList.remove('d-none');
|
|
tableView.classList.add('d-block');
|
|
});
|
|
});
|
|
|
|
// 3. MapLibre GL interactive workspace integration
|
|
const intersectionId = "{{ intersection.id }}";
|
|
window.dxfFile = "{{ dxf_file|escapejs }}";
|
|
|
|
const intersectionLon = JSON.parse(document.getElementById('intersection_lon').textContent);
|
|
const intersectionLat = JSON.parse(document.getElementById('intersection_lat').textContent);
|
|
const existingPolesGeojson = JSON.parse(document.getElementById('existing_poles_geojson').textContent);
|
|
const initialDxfPoles = JSON.parse(document.getElementById('dxf_poles').textContent);
|
|
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
|
|
|
const polesList = [
|
|
{% for item in poles %}
|
|
{
|
|
code: "{{ item.code|escapejs }}",
|
|
short_code: "{{ item.short_code|escapejs }}",
|
|
status: "{{ item.status|escapejs }}"
|
|
},
|
|
{% endfor %}
|
|
];
|
|
|
|
const lanternsList = [
|
|
{% for item in lanterns %}
|
|
{
|
|
code: "{{ item.code|escapejs }}",
|
|
pole_code: "{{ item.pole_code|escapejs }}"
|
|
},
|
|
{% endfor %}
|
|
];
|
|
|
|
const detectorsList = [
|
|
{% for item in detectors %}
|
|
{
|
|
code: "{{ item.code|escapejs }}",
|
|
pole_code: "{{ item.pole_code|escapejs }}"
|
|
},
|
|
{% endfor %}
|
|
];
|
|
|
|
const cablesList = [
|
|
{% for item in cables %}
|
|
{
|
|
code: "{{ item.code|escapejs }}",
|
|
name: "{{ item.name|escapejs }}",
|
|
connected_poles: {{ item.connected_poles|safe|default:"[]" }}
|
|
},
|
|
{% endfor %}
|
|
];
|
|
|
|
// Compute metadata counts for sidebar listing
|
|
polesList.forEach(p => {
|
|
const lanterns = lanternsList.filter(l => l.pole_code === p.code).length;
|
|
const detectors = detectorsList.filter(d => d.pole_code === p.code).length;
|
|
|
|
const ml = document.getElementById(`meta_lanterns_${p.code}`);
|
|
const md = document.getElementById(`meta_detectors_${p.code}`);
|
|
if (ml) ml.textContent = `${lanterns} Lanternes`;
|
|
if (md) md.textContent = `${detectors} Détecteurs`;
|
|
});
|
|
|
|
let center = [4.3508, 50.8465]; // default Brussels
|
|
if (intersectionLon !== null && intersectionLat !== null) {
|
|
center = [parseFloat(intersectionLon), parseFloat(intersectionLat)];
|
|
}
|
|
|
|
const map = new maplibregl.Map({
|
|
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
|
center: center,
|
|
zoom: 17,
|
|
container: 'map'
|
|
});
|
|
|
|
let activePlacementPole = null;
|
|
let activeEditCable = null;
|
|
const placedPoles = {};
|
|
const cableGeometries = {};
|
|
let cableHandles = [];
|
|
|
|
const snapCandidates = initialDxfPoles.map(dp => [dp.lon, dp.lat, dp.x_3812, dp.y_3812]);
|
|
|
|
// AddressSearchControl initialization
|
|
const AUTOCOMPLETE_STREET_URL = "{% url 'common:autocomplete_street' %}";
|
|
if (typeof AddressSearchControl !== 'undefined') {
|
|
const searchCtrl = new AddressSearchControl({
|
|
autocompleteUrl: AUTOCOMPLETE_STREET_URL,
|
|
placeholder: "{% translate 'Rechercher une adresse…' %}"
|
|
});
|
|
map.addControl(searchCtrl, 'top-left');
|
|
}
|
|
|
|
// Custom Plans Control in Top Right (first, at the top)
|
|
class CustomPlansControl {
|
|
onAdd(map) {
|
|
this._container = document.createElement('div');
|
|
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
|
|
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.id = 'btn-load-georef-plan';
|
|
button.className = 'btn btn-xs btn-light bg-white border shadow-sm d-flex align-items-center gap-1';
|
|
button.style.border = 'none';
|
|
button.style.padding = '8px 12px';
|
|
button.style.height = 'auto';
|
|
button.style.width = 'auto';
|
|
button.style.fontWeight = 'bold';
|
|
button.style.fontSize = '0.75rem';
|
|
button.innerHTML = '<i class="bi bi-geo-alt-fill text-primary"></i> Plans';
|
|
|
|
// Toggle bootstrap modal natively
|
|
button.setAttribute('data-bs-toggle', 'modal');
|
|
button.setAttribute('data-bs-target', '#georefPlanModal');
|
|
|
|
this._container.appendChild(button);
|
|
return this._container;
|
|
}
|
|
onRemove() {
|
|
this._container.parentNode.removeChild(this._container);
|
|
this._map = undefined;
|
|
}
|
|
}
|
|
map.addControl(new CustomPlansControl(), 'top-right');
|
|
|
|
// --- Contrôle photo aérienne (LayerSwitcherControl) --- (second, below Plans button)
|
|
if (typeof LayerSwitcherControl !== 'undefined') {
|
|
const layerSwitcher = new LayerSwitcherControl({
|
|
layers: [
|
|
{
|
|
id: 'ortho',
|
|
name: 'Orthophotos',
|
|
icon: 'satellite',
|
|
group: 'ortho',
|
|
beforeLayer: 'existing-poles-layer',
|
|
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 || '© 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: '© Digitaal Vlaanderen - Orthofoto 2023'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
});
|
|
map.addControl(layerSwitcher, 'top-right');
|
|
}
|
|
|
|
// Classic Zoom Control with Bearing/Compass in Top Right (below Orthophotos control)
|
|
map.addControl(new maplibregl.NavigationControl({ showCompass: true }), 'top-right');
|
|
|
|
map.on('load', function() {
|
|
// Load active poles as read-only features
|
|
map.addSource('existing-poles', {
|
|
type: 'geojson',
|
|
data: existingPolesGeojson
|
|
});
|
|
map.addLayer({
|
|
id: 'existing-poles-layer',
|
|
type: 'circle',
|
|
source: 'existing-poles',
|
|
paint: {
|
|
'circle-radius': 6,
|
|
'circle-color': '#6b7280',
|
|
'circle-stroke-width': 1.5,
|
|
'circle-stroke-color': '#ffffff'
|
|
}
|
|
});
|
|
|
|
// Add DXF snapping points source and layer
|
|
const dxfPolesFeatures = initialDxfPoles.map(dp => ({
|
|
type: 'Feature',
|
|
geometry: {
|
|
type: 'Point',
|
|
coordinates: [dp.lon, dp.lat]
|
|
},
|
|
properties: {
|
|
id: dp.id,
|
|
label: dp.label
|
|
}
|
|
}));
|
|
map.addSource('dxf-snap-points', {
|
|
type: 'geojson',
|
|
data: {
|
|
type: 'FeatureCollection',
|
|
features: dxfPolesFeatures
|
|
}
|
|
});
|
|
map.addLayer({
|
|
id: 'dxf-snap-layer',
|
|
type: 'circle',
|
|
source: 'dxf-snap-points',
|
|
paint: {
|
|
'circle-radius': 5,
|
|
'circle-color': '#e2e8f0',
|
|
'circle-stroke-width': 1.5,
|
|
'circle-stroke-color': '#94a3b8'
|
|
}
|
|
});
|
|
|
|
// Add Cables source and layer for tracing representation
|
|
map.addSource('cable-lines-source', {
|
|
type: 'geojson',
|
|
data: {
|
|
type: 'FeatureCollection',
|
|
features: []
|
|
}
|
|
});
|
|
map.addLayer({
|
|
id: 'cable-lines-layer',
|
|
type: 'line',
|
|
source: 'cable-lines-source',
|
|
paint: {
|
|
'line-color': '#10b981',
|
|
'line-width': 1.8,
|
|
'line-dasharray': [1.5, 1.5]
|
|
}
|
|
});
|
|
|
|
map.addLayer({
|
|
id: 'cable-lines-active-layer',
|
|
type: 'line',
|
|
source: 'cable-lines-source',
|
|
filter: ['==', 'active', true],
|
|
paint: {
|
|
'line-color': '#f59e0b',
|
|
'line-width': 2.8
|
|
}
|
|
});
|
|
|
|
if (window.dxfFile && window.dxfFile !== "") {
|
|
loadDxfBackground(window.dxfFile);
|
|
}
|
|
|
|
updatePolesCounter();
|
|
});
|
|
|
|
function getSnappedCoords(lon, lat) {
|
|
let bestDistance = Infinity;
|
|
let bestCoords = [lon, lat];
|
|
let bestLambert = [null, null];
|
|
const threshold = 0.00005; // approx 5m
|
|
|
|
snapCandidates.forEach(pt => {
|
|
const d = Math.sqrt((pt[0] - lon) ** 2 + (pt[1] - lat) ** 2);
|
|
if (d < threshold && d < bestDistance) {
|
|
bestDistance = d;
|
|
bestCoords = [pt[0], pt[1]];
|
|
bestLambert = [pt[2], pt[3]];
|
|
}
|
|
});
|
|
return { coords: bestCoords, lambert: bestLambert };
|
|
}
|
|
|
|
function updatePolesCounter() {
|
|
const total = polesList.filter(p => p.status !== 'db_only').length;
|
|
const placed = Object.keys(placedPoles).length;
|
|
document.getElementById('placed-poles-count').textContent = `${placed} / ${total}`;
|
|
}
|
|
|
|
function markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812) {
|
|
const badge = document.getElementById(`georef_badge_${code}`);
|
|
if (badge) {
|
|
badge.classList.remove('bg-danger');
|
|
badge.classList.add('bg-success');
|
|
badge.textContent = `{% translate "Positionné" %}`;
|
|
}
|
|
|
|
document.getElementById(`lon_pole_${code}`).value = lon;
|
|
document.getElementById(`lat_pole_${code}`).value = lat;
|
|
document.getElementById(`x_3812_pole_${code}`).value = x_3812 !== undefined && x_3812 !== null ? x_3812 : "";
|
|
document.getElementById(`y_3812_pole_${code}`).value = y_3812 !== undefined && y_3812 !== null ? y_3812 : "";
|
|
|
|
redrawCablesOnMap();
|
|
updatePolesCounter();
|
|
saveCablesToForm();
|
|
}
|
|
|
|
map.on('click', function(e) {
|
|
if (!activePlacementPole) return;
|
|
|
|
const originalCoords = [e.lngLat.lng, e.lngLat.lat];
|
|
const snapped = getSnappedCoords(originalCoords[0], originalCoords[1]);
|
|
|
|
placePoleMarker(activePlacementPole, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1]);
|
|
});
|
|
|
|
function placePoleMarker(code, lon, lat, x_3812, y_3812) {
|
|
if (placedPoles[code]) {
|
|
placedPoles[code].marker.remove();
|
|
}
|
|
|
|
const el = document.createElement('div');
|
|
el.className = 'pole-marker shadow-sm';
|
|
el.style.backgroundColor = '#2563eb';
|
|
el.style.color = '#ffffff';
|
|
el.style.padding = '3px 8px';
|
|
el.style.borderRadius = '20px';
|
|
el.style.fontWeight = 'bold';
|
|
el.style.fontSize = '12px';
|
|
el.style.border = '2px solid #ffffff';
|
|
el.style.cursor = 'move';
|
|
|
|
const short_code = polesList.find(p => p.code === code)?.short_code || code;
|
|
el.textContent = short_code;
|
|
|
|
const marker = new maplibregl.Marker({
|
|
element: el,
|
|
draggable: true
|
|
})
|
|
.setLngLat([lon, lat])
|
|
.addTo(map);
|
|
|
|
placedPoles[code] = {
|
|
lon: lon,
|
|
lat: lat,
|
|
x_3812: x_3812,
|
|
y_3812: y_3812,
|
|
marker: marker
|
|
};
|
|
|
|
marker.on('dragend', function() {
|
|
const lngLat = marker.getLngLat();
|
|
const snapped = getSnappedCoords(lngLat.lng, lngLat.lat);
|
|
marker.setLngLat(snapped.coords);
|
|
placedPoles[code].lon = snapped.coords[0];
|
|
placedPoles[code].lat = snapped.coords[1];
|
|
placedPoles[code].x_3812 = snapped.lambert[0];
|
|
placedPoles[code].y_3812 = snapped.lambert[1];
|
|
markPoleAsGeoreferenced(code, snapped.coords[0], snapped.coords[1], snapped.lambert[0], snapped.lambert[1]);
|
|
});
|
|
|
|
markPoleAsGeoreferenced(code, lon, lat, x_3812, y_3812);
|
|
|
|
const mapPane = document.getElementById('map-poles-pane');
|
|
mapPane.querySelectorAll('.pole-config-item').forEach(item => item.classList.remove('bg-light'));
|
|
const listItem = mapPane.querySelector(`.pole-config-item[data-code="${code}"]`);
|
|
if (listItem) {
|
|
listItem.classList.add('bg-light');
|
|
}
|
|
|
|
const unplaced = polesList.filter(p => p.status !== 'db_only' && !placedPoles[p.code]);
|
|
if (unplaced.length > 0) {
|
|
activatePlacementMode(unplaced[0].code);
|
|
} else {
|
|
deactivatePlacementMode();
|
|
}
|
|
}
|
|
|
|
function activatePlacementMode(code) {
|
|
deactivateCableEditingMode();
|
|
activePlacementPole = code;
|
|
map.getCanvas().style.cursor = 'crosshair';
|
|
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Placement de" %} ${code.split('_').pop()}`;
|
|
|
|
document.querySelectorAll('.start-placement-btn').forEach(btn => {
|
|
btn.classList.remove('btn-primary');
|
|
btn.classList.add('btn-outline-primary');
|
|
});
|
|
const activeBtn = document.querySelector(`.start-placement-btn[data-code="${code}"]`);
|
|
if (activeBtn) {
|
|
activeBtn.classList.remove('btn-outline-primary');
|
|
activeBtn.classList.add('btn-primary');
|
|
}
|
|
}
|
|
|
|
function deactivatePlacementMode() {
|
|
activePlacementPole = null;
|
|
map.getCanvas().style.cursor = '';
|
|
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Lecture" %}`;
|
|
document.querySelectorAll('.start-placement-btn').forEach(btn => {
|
|
btn.classList.remove('btn-primary');
|
|
btn.classList.add('btn-outline-primary');
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('.start-placement-btn').forEach(btn => {
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const code = this.dataset.code;
|
|
if (activePlacementPole === code) {
|
|
deactivatePlacementMode();
|
|
} else {
|
|
activatePlacementMode(code);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Cable editor helpers
|
|
function activateCableEditingMode(code) {
|
|
deactivatePlacementMode();
|
|
deactivateCableEditingMode();
|
|
|
|
activeEditCable = code;
|
|
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Tracé" %} ${cablesList.find(c => c.code === code).name}`;
|
|
document.getElementById('btn-clear-trace').style.display = 'block';
|
|
|
|
const mapPane = document.getElementById('map-cables-pane');
|
|
mapPane.querySelectorAll('.cable-config-item').forEach(item => item.classList.remove('bg-light'));
|
|
const item = mapPane.querySelector(`.cable-config-item[data-code="${code}"]`);
|
|
if (item) item.classList.add('bg-light');
|
|
|
|
let coords = cableGeometries[code];
|
|
if (!coords || coords.length === 0) {
|
|
const cableInfo = cablesList.find(c => c.code === code);
|
|
const connected = cableInfo.connected_poles || [];
|
|
|
|
coords = [];
|
|
connected.forEach(pCode => {
|
|
if (placedPoles[pCode]) {
|
|
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
|
|
}
|
|
});
|
|
cableGeometries[code] = coords;
|
|
}
|
|
|
|
drawCableHandles(code);
|
|
redrawCablesOnMap();
|
|
}
|
|
|
|
function deactivateCableEditingMode() {
|
|
activeEditCable = null;
|
|
document.getElementById('btn-clear-trace').style.display = 'none';
|
|
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Lecture" %}`;
|
|
|
|
cableHandles.forEach(h => h.remove());
|
|
cableHandles = [];
|
|
|
|
const mapPane = document.getElementById('map-cables-pane');
|
|
mapPane.querySelectorAll('.cable-config-item').forEach(item => item.classList.remove('bg-light'));
|
|
redrawCablesOnMap();
|
|
}
|
|
|
|
function drawCableHandles(code) {
|
|
cableHandles.forEach(h => h.remove());
|
|
cableHandles = [];
|
|
|
|
const coords = cableGeometries[code] || [];
|
|
coords.forEach((pt, index) => {
|
|
const el = document.createElement('div');
|
|
el.className = 'cable-handle shadow';
|
|
el.style.width = '12px';
|
|
el.style.height = '12px';
|
|
el.style.backgroundColor = '#f59e0b';
|
|
el.style.border = '2px solid #ffffff';
|
|
el.style.borderRadius = '50%';
|
|
el.style.cursor = 'pointer';
|
|
|
|
const marker = new maplibregl.Marker({
|
|
element: el,
|
|
draggable: true
|
|
})
|
|
.setLngLat(pt)
|
|
.addTo(map);
|
|
|
|
cableHandles.push(marker);
|
|
|
|
marker.on('drag', function() {
|
|
const lngLat = marker.getLngLat();
|
|
cableGeometries[code][index] = [lngLat.lng, lngLat.lat];
|
|
redrawCablesOnMap();
|
|
});
|
|
|
|
marker.on('dragend', function() {
|
|
const lngLat = marker.getLngLat();
|
|
const snapped = getSnappedCoords(lngLat.lng, lngLat.lat);
|
|
marker.setLngLat(snapped.coords);
|
|
cableGeometries[code][index] = snapped.coords;
|
|
redrawCablesOnMap();
|
|
saveCablesToForm();
|
|
});
|
|
|
|
el.addEventListener('dblclick', function(e) {
|
|
e.stopPropagation();
|
|
cableGeometries[code].splice(index, 1);
|
|
drawCableHandles(code);
|
|
redrawCablesOnMap();
|
|
saveCablesToForm();
|
|
});
|
|
});
|
|
}
|
|
|
|
function getDistanceToSegment(pt, p1, p2) {
|
|
const x = pt[0], y = pt[1];
|
|
const x1 = p1[0], y1 = p1[1];
|
|
const x2 = p2[0], y2 = p2[1];
|
|
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
|
|
if (dx === 0 && dy === 0) {
|
|
return Math.sqrt((x - x1) ** 2 + (y - y1) ** 2);
|
|
}
|
|
|
|
let t = ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy);
|
|
t = Math.max(0, Math.min(1, t)); // clamp to segment
|
|
|
|
const px = x1 + t * dx;
|
|
const py = y1 + t * dy;
|
|
|
|
return Math.sqrt((x - px) ** 2 + (y - py) ** 2);
|
|
}
|
|
|
|
let dblClickLayerHandled = false;
|
|
|
|
// Double-click on segment to insert intermediate point
|
|
map.on('dblclick', 'cable-lines-active-layer', function(e) {
|
|
if (!activeEditCable) return;
|
|
dblClickLayerHandled = true;
|
|
e.preventDefault();
|
|
|
|
const clickPoint = [e.lngLat.lng, e.lngLat.lat];
|
|
const snapped = getSnappedCoords(clickPoint[0], clickPoint[1]);
|
|
|
|
const coords = cableGeometries[activeEditCable];
|
|
if (!coords || coords.length < 2) return;
|
|
|
|
let minDistance = Infinity;
|
|
let insertIndex = coords.length;
|
|
|
|
for (let i = 0; i < coords.length - 1; i++) {
|
|
const p1 = coords[i];
|
|
const p2 = coords[i+1];
|
|
const d = getDistanceToSegment(clickPoint, p1, p2);
|
|
if (d < minDistance) {
|
|
minDistance = d;
|
|
insertIndex = i + 1;
|
|
}
|
|
}
|
|
|
|
coords.splice(insertIndex, 0, snapped.coords);
|
|
drawCableHandles(activeEditCable);
|
|
redrawCablesOnMap();
|
|
saveCablesToForm();
|
|
});
|
|
|
|
map.on('dblclick', function(e) {
|
|
if (!activeEditCable) return;
|
|
e.preventDefault();
|
|
|
|
if (dblClickLayerHandled) {
|
|
dblClickLayerHandled = false;
|
|
return;
|
|
}
|
|
|
|
const coords = [e.lngLat.lng, e.lngLat.lat];
|
|
const snapped = getSnappedCoords(coords[0], coords[1]);
|
|
|
|
if (!cableGeometries[activeEditCable]) {
|
|
cableGeometries[activeEditCable] = [];
|
|
}
|
|
|
|
cableGeometries[activeEditCable].push(snapped.coords);
|
|
drawCableHandles(activeEditCable);
|
|
redrawCablesOnMap();
|
|
saveCablesToForm();
|
|
});
|
|
|
|
// Hover mouse styles for active cable line
|
|
map.on('mouseenter', 'cable-lines-active-layer', function() {
|
|
if (activeEditCable) {
|
|
map.getCanvas().style.cursor = 'pointer';
|
|
}
|
|
});
|
|
map.on('mouseleave', 'cable-lines-active-layer', function() {
|
|
if (activeEditCable) {
|
|
map.getCanvas().style.cursor = 'crosshair';
|
|
} else {
|
|
map.getCanvas().style.cursor = '';
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-clear-trace').addEventListener('click', function() {
|
|
if (activeEditCable) {
|
|
cableGeometries[activeEditCable] = [];
|
|
drawCableHandles(activeEditCable);
|
|
redrawCablesOnMap();
|
|
saveCablesToForm();
|
|
}
|
|
});
|
|
|
|
function saveCablesToForm() {
|
|
cablesList.forEach(c => {
|
|
let coords = cableGeometries[c.code];
|
|
if (!coords || coords.length === 0) {
|
|
coords = [];
|
|
const connected = c.connected_poles || [];
|
|
connected.forEach(pCode => {
|
|
if (placedPoles[pCode]) {
|
|
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
|
|
}
|
|
});
|
|
}
|
|
const input = document.getElementById(`geom_cable_${c.code}`);
|
|
if (input && coords && coords.length >= 2) {
|
|
input.value = JSON.stringify(coords);
|
|
} else if (input) {
|
|
input.value = "";
|
|
}
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('.edit-cable-btn').forEach(btn => {
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const code = this.dataset.code;
|
|
if (activeEditCable === code) {
|
|
deactivateCableEditingMode();
|
|
} else {
|
|
activateCableEditingMode(code);
|
|
}
|
|
});
|
|
});
|
|
|
|
function redrawCablesOnMap() {
|
|
if (!map.getSource('cable-lines-source')) return;
|
|
|
|
const features = [];
|
|
cablesList.forEach(c => {
|
|
// Only draw/show the cable if it is actively being edited!
|
|
if (c.code !== activeEditCable) return;
|
|
|
|
let coords = cableGeometries[c.code];
|
|
if (!coords || coords.length === 0) {
|
|
coords = [];
|
|
const connected = c.connected_poles || [];
|
|
connected.forEach(pCode => {
|
|
if (placedPoles[pCode]) {
|
|
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (coords && coords.length >= 2) {
|
|
// Force a new array reference to bypass MapLibre's internal cache
|
|
const freshCoords = coords.map(pt => [pt[0], pt[1]]);
|
|
features.push({
|
|
type: 'Feature',
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: freshCoords
|
|
},
|
|
properties: {
|
|
code: c.code,
|
|
active: true
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
map.getSource('cable-lines-source').setData({
|
|
type: 'FeatureCollection',
|
|
features: features
|
|
});
|
|
|
|
map.triggerRepaint();
|
|
}
|
|
|
|
// ── Georeferenced Plans & DXF Background Loader ──
|
|
const georeferencedDocumentsDataEl = document.getElementById('georeferencedDocumentsData');
|
|
let georeferencedDocuments = [];
|
|
if (georeferencedDocumentsDataEl) {
|
|
try {
|
|
const parsed = JSON.parse(georeferencedDocumentsDataEl.textContent);
|
|
if (Array.isArray(parsed)) {
|
|
georeferencedDocuments = parsed;
|
|
}
|
|
} catch (e) {
|
|
console.error("Error parsing georeferenced documents data:", e);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function showGeorefImage(doc, active, opacity) {
|
|
const srcId = `georef-image-overlay-${doc.id}`;
|
|
const lyrId = `georef-image-overlay-layer-${doc.id}`;
|
|
|
|
if (!active) {
|
|
if (map.getLayer(lyrId)) {
|
|
map.setLayoutProperty(lyrId, 'visibility', 'none');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (map.getSource(srcId)) {
|
|
if (map.getLayer(lyrId)) {
|
|
map.setLayoutProperty(lyrId, 'visibility', 'visible');
|
|
map.setPaintProperty(lyrId, 'raster-opacity', opacity);
|
|
}
|
|
} else {
|
|
map.addSource(srcId, {
|
|
type: 'image',
|
|
url: doc.url,
|
|
coordinates: doc.coords
|
|
});
|
|
|
|
let beforeId = undefined;
|
|
if (map.getLayer('dxf-layout-fills')) {
|
|
beforeId = 'dxf-layout-fills';
|
|
} else if (map.getLayer('dxf-layout-lines')) {
|
|
beforeId = 'dxf-layout-lines';
|
|
} else if (map.getLayer('existing-poles-layer')) {
|
|
beforeId = 'existing-poles-layer';
|
|
}
|
|
|
|
map.addLayer({
|
|
'id': lyrId,
|
|
'type': 'raster',
|
|
'source': srcId,
|
|
'layout': { 'visibility': 'visible' },
|
|
'paint': { 'raster-opacity': opacity }
|
|
}, beforeId);
|
|
}
|
|
|
|
// Zoom/fitBounds to document
|
|
const coords = doc.coords;
|
|
const bounds = coords.reduce((acc, coord) => {
|
|
return acc.extend(coord);
|
|
}, new maplibregl.LngLatBounds(coords[0], coords[0]));
|
|
map.fitBounds(bounds, { padding: 40, maxZoom: 19 });
|
|
}
|
|
|
|
let fullDxfGeoJson = null;
|
|
|
|
function fetchDxfGeoJson(file) {
|
|
const spinner = document.getElementById('dxf-detail-layers-spinner');
|
|
if (spinner) spinner.style.display = 'block';
|
|
|
|
const url = `/assets/trafficlights/intersections/${intersectionId}/dxf-layout-geojson/?file=${encodeURIComponent(file)}&detail=true&layers=all`;
|
|
fetch(url)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
fullDxfGeoJson = data;
|
|
applyDxfVectorFilters();
|
|
if (spinner) spinner.style.display = 'none';
|
|
})
|
|
.catch(err => {
|
|
console.error("Error fetching DXF GeoJSON:", err);
|
|
if (spinner) spinner.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
function applyDxfVectorFilters() {
|
|
if (!fullDxfGeoJson) return;
|
|
|
|
const selectedLayers = [];
|
|
document.querySelectorAll('.dxf-vector-layer-toggle:checked').forEach(cb => {
|
|
selectedLayers.push(cb.dataset.layer);
|
|
});
|
|
|
|
const filteredFeatures = fullDxfGeoJson.features.filter(f => {
|
|
const layerName = f.properties && (f.properties.Layer || f.properties.layer);
|
|
return selectedLayers.includes(layerName);
|
|
});
|
|
|
|
const filteredData = {
|
|
...fullDxfGeoJson,
|
|
features: filteredFeatures
|
|
};
|
|
|
|
ensureDxfSourcesAndLayers();
|
|
if (map.getSource('dxf-layout')) {
|
|
map.getSource('dxf-layout').setData(filteredData);
|
|
|
|
['dxf-layout-fills', 'dxf-layout-lines', 'dxf-layout-symbols'].forEach(lId => {
|
|
if (map.getLayer(lId)) {
|
|
map.setLayoutProperty(lId, 'visibility', 'visible');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function ensureDxfSourcesAndLayers() {
|
|
if (!map.getSource('dxf-layout')) {
|
|
map.addSource('dxf-layout', {
|
|
type: 'geojson',
|
|
data: { type: "FeatureCollection", features: [] }
|
|
});
|
|
|
|
const beforeId = map.getLayer('existing-poles-layer') ? 'existing-poles-layer' : undefined;
|
|
|
|
map.addLayer({
|
|
'id': 'dxf-layout-fills',
|
|
'type': 'fill',
|
|
'source': 'dxf-layout',
|
|
'layout': {
|
|
'visibility': 'visible'
|
|
},
|
|
'paint': {
|
|
'fill-color': ['coalesce', ['get', 'color'], '#94a3b8'],
|
|
'fill-opacity': 0.1
|
|
}
|
|
}, beforeId);
|
|
|
|
map.addLayer({
|
|
'id': 'dxf-layout-lines',
|
|
'type': 'line',
|
|
'source': 'dxf-layout',
|
|
'layout': {
|
|
'visibility': 'visible'
|
|
},
|
|
'paint': {
|
|
'line-color': ['coalesce', ['get', 'color'], '#475569'],
|
|
'line-width': 1.2,
|
|
'line-opacity': 0.7
|
|
}
|
|
}, beforeId);
|
|
|
|
map.addLayer({
|
|
'id': 'dxf-layout-symbols',
|
|
'type': 'symbol',
|
|
'source': 'dxf-layout',
|
|
'filter': ['has', 'Text'],
|
|
'layout': {
|
|
'visibility': 'visible',
|
|
'text-field': ['get', 'Text'],
|
|
'text-font': ['Noto Sans Regular'],
|
|
'text-size': [
|
|
'interpolate',
|
|
['linear'],
|
|
['zoom'],
|
|
17, ['coalesce', ['*', ['get', 'text_height'], 6], 10],
|
|
21, ['coalesce', ['*', ['get', 'text_height'], 24], 14]
|
|
],
|
|
'text-rotate': ['coalesce', ['*', ['get', 'text_angle'], -1], 0],
|
|
'text-rotation-alignment': 'map',
|
|
'text-anchor': ['coalesce', ['get', 'text_anchor'], 'center'],
|
|
'text-allow-overlap': true,
|
|
'text-ignore-placement': true
|
|
},
|
|
'paint': {
|
|
'text-color': ['coalesce', ['get', 'color'], '#000000'],
|
|
'text-halo-color': '#ffffff',
|
|
'text-halo-width': 2
|
|
}
|
|
}, beforeId);
|
|
}
|
|
}
|
|
|
|
function updateButtonActiveState() {
|
|
const btn = document.getElementById('btn-load-georef-plan');
|
|
if (!btn) return;
|
|
|
|
let anyImageActive = false;
|
|
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
|
|
if (cb.checked) anyImageActive = true;
|
|
});
|
|
|
|
let anyVectorActive = false;
|
|
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
|
|
if (cb.checked) anyVectorActive = true;
|
|
});
|
|
|
|
if (anyImageActive || anyVectorActive) {
|
|
btn.classList.remove('btn-light', 'bg-white', 'border');
|
|
btn.classList.add('btn-primary');
|
|
} else {
|
|
btn.classList.remove('btn-primary');
|
|
btn.classList.add('btn-light', 'bg-white', 'border');
|
|
}
|
|
}
|
|
|
|
const noGeorefImagesAlert = document.getElementById('no-georef-images-alert');
|
|
const imagePlansList = document.getElementById('image-plans-list');
|
|
if (imagePlansList) {
|
|
imagePlansList.innerHTML = '';
|
|
if (georeferencedDocuments.length === 0) {
|
|
if (noGeorefImagesAlert) noGeorefImagesAlert.style.display = 'block';
|
|
} else {
|
|
if (noGeorefImagesAlert) noGeorefImagesAlert.style.display = 'none';
|
|
|
|
georeferencedDocuments.forEach(doc => {
|
|
const item = document.createElement('div');
|
|
item.className = 'image-plan-item p-3 bg-light border rounded-3 d-flex flex-column gap-2';
|
|
item.innerHTML = `
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<span class="text-dark font-weight-bold" style="font-size: 0.9rem;">
|
|
<i class="bi ${doc.is_dxf ? 'bi-file-earmark-code text-success' : 'bi-file-earmark-image text-primary'} me-2"></i>${doc.title}
|
|
</span>
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input image-plan-toggle" type="checkbox" role="switch" id="switch-georef-img-${doc.id}" data-id="${doc.id}">
|
|
</div>
|
|
</div>
|
|
<div class="image-plan-opacity-container" id="opacity-container-${doc.id}" style="display: none;">
|
|
<label class="form-label font-weight-semibold text-muted small uppercase d-flex justify-content-between mb-1">
|
|
<span>Opacité</span>
|
|
<span class="opacity-val text-primary font-weight-bold">95%</span>
|
|
</label>
|
|
<input type="range" class="form-range image-plan-opacity-slider" min="0" max="100" value="95" id="range-georef-img-opacity-${doc.id}" data-id="${doc.id}">
|
|
</div>
|
|
`;
|
|
imagePlansList.appendChild(item);
|
|
|
|
const toggle = item.querySelector('.image-plan-toggle');
|
|
const opacityContainer = item.querySelector('.image-plan-opacity-container');
|
|
const slider = item.querySelector('.image-plan-opacity-slider');
|
|
const opacityVal = item.querySelector('.opacity-val');
|
|
|
|
toggle.addEventListener('change', () => {
|
|
const active = toggle.checked;
|
|
if (active) {
|
|
opacityContainer.style.display = 'block';
|
|
} else {
|
|
opacityContainer.style.display = 'none';
|
|
}
|
|
const opacity = parseFloat(slider.value) / 100.0;
|
|
showGeorefImage(doc, active, opacity);
|
|
updateButtonActiveState();
|
|
});
|
|
|
|
slider.addEventListener('input', () => {
|
|
const val = slider.value;
|
|
opacityVal.textContent = val + '%';
|
|
const opacity = parseFloat(val) / 100.0;
|
|
showGeorefImage(doc, toggle.checked, opacity);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
const dxfFileSelect = document.getElementById('select-dxf-file');
|
|
let detailLayersFetched = {};
|
|
let detailLayersResponseData = {};
|
|
|
|
function fetchDetailLayers() {
|
|
if (!dxfFileSelect) return;
|
|
const file = dxfFileSelect.value;
|
|
if (!file) {
|
|
const section = document.getElementById('dxf-detail-layers-section');
|
|
if (section) section.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
if (detailLayersFetched[file]) {
|
|
renderDetailLayers(file);
|
|
fetchDxfGeoJson(file);
|
|
return;
|
|
}
|
|
|
|
const spinner = document.getElementById('dxf-detail-layers-spinner');
|
|
const list = document.getElementById('dxf-detail-layers-list');
|
|
const section = document.getElementById('dxf-detail-layers-section');
|
|
if (section) section.style.display = 'block';
|
|
if (spinner) spinner.style.display = 'block';
|
|
if (list) list.style.display = 'none';
|
|
|
|
fetch(`/assets/trafficlights/intersections/${intersectionId}/dxf-layers/?file=${encodeURIComponent(file)}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
detailLayersFetched[file] = data.layers || [];
|
|
detailLayersResponseData[file] = data;
|
|
renderDetailLayers(file);
|
|
fetchDxfGeoJson(file);
|
|
if (spinner) spinner.style.display = 'none';
|
|
if (list) list.style.display = 'block';
|
|
})
|
|
.catch(err => {
|
|
console.error("Error fetching detail layers:", err);
|
|
if (spinner) {
|
|
spinner.innerHTML = `<div class="text-danger small"><i class="bi bi-exclamation-triangle-fill"></i> Erreur de chargement.</div>`;
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderDetailLayers(file) {
|
|
const container = document.getElementById('dxf-detail-layers-list');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
const layers = detailLayersFetched[file] || [];
|
|
const responseData = detailLayersResponseData[file] || {};
|
|
const selectedLayers = responseData.selected_layers || [];
|
|
|
|
layers.forEach(layer => {
|
|
const isChecked = selectedLayers.includes(layer.name) || selectedLayers.length === 0;
|
|
const div = document.createElement('div');
|
|
div.className = 'form-check py-1 d-flex align-items-center justify-content-between';
|
|
div.innerHTML = `
|
|
<div class="d-flex align-items-center">
|
|
<span class="color-indicator me-2" style="display: inline-block; width: 14px; height: 14px; border-radius: 3px; background-color: ${layer.color}; border: 1px solid rgba(0,0,0,0.1);"></span>
|
|
<label class="form-check-label text-dark font-weight-medium small" for="dxf-detail-layer-${layer.name}">
|
|
${layer.name}
|
|
</label>
|
|
</div>
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input dxf-vector-layer-toggle" type="checkbox" role="switch"
|
|
id="dxf-detail-layer-${layer.name}" data-layer="${layer.name}" ${isChecked ? 'checked' : ''}>
|
|
</div>
|
|
`;
|
|
container.appendChild(div);
|
|
|
|
const toggle = div.querySelector('.dxf-vector-layer-toggle');
|
|
toggle.addEventListener('change', () => {
|
|
applyDxfVectorFilters();
|
|
updateButtonActiveState();
|
|
});
|
|
});
|
|
}
|
|
|
|
if (dxfFileSelect) {
|
|
dxfFileSelect.addEventListener('change', () => {
|
|
fetchDetailLayers();
|
|
});
|
|
|
|
// Auto-load if there is a default select value or single dxf
|
|
if (dxfFileSelect.value) {
|
|
fetchDetailLayers();
|
|
}
|
|
}
|
|
|
|
const btnToggleAllLayers = document.getElementById('dxf-detail-toggle-all-layers');
|
|
if (btnToggleAllLayers) {
|
|
btnToggleAllLayers.addEventListener('click', () => {
|
|
const checkboxes = document.querySelectorAll('.dxf-vector-layer-toggle');
|
|
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
|
checkboxes.forEach(cb => {
|
|
cb.checked = !allChecked;
|
|
});
|
|
btnToggleAllLayers.textContent = allChecked ? "Tout cocher" : "Décocher tout";
|
|
applyDxfVectorFilters();
|
|
updateButtonActiveState();
|
|
});
|
|
}
|
|
|
|
const btnDisableAllPlans = document.getElementById('btn-disable-all-plans');
|
|
if (btnDisableAllPlans) {
|
|
btnDisableAllPlans.addEventListener('click', () => {
|
|
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
|
|
if (cb.checked) {
|
|
cb.checked = false;
|
|
cb.dispatchEvent(new Event('change'));
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
|
|
if (cb.checked) {
|
|
cb.checked = false;
|
|
}
|
|
});
|
|
applyDxfVectorFilters();
|
|
|
|
const modalEl = document.getElementById('georefPlanModal');
|
|
if (modalEl) {
|
|
const modal = bootstrap.Modal.getInstance(modalEl);
|
|
if (modal) modal.hide();
|
|
}
|
|
|
|
updateButtonActiveState();
|
|
});
|
|
}
|
|
|
|
// GeoJSON Client-side Vector Layers Loading
|
|
const geojsonFileInput = document.getElementById('geojson-file-input');
|
|
if (geojsonFileInput) {
|
|
geojsonFileInput.addEventListener('change', function() {
|
|
const file = this.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
try {
|
|
const geojson = JSON.parse(e.target.result);
|
|
const sourceId = `user-vector-source-${Date.now()}`;
|
|
|
|
map.addSource(sourceId, { type: 'geojson', data: geojson });
|
|
map.addLayer({
|
|
id: `${sourceId}-lines`,
|
|
type: 'line',
|
|
source: sourceId,
|
|
filter: ['in', '$type', 'LineString', 'Polygon', 'MultiLineString', 'MultiPolygon'],
|
|
paint: { 'line-color': '#3b82f6', 'line-width': 2, 'line-opacity': 0.8 }
|
|
});
|
|
map.addLayer({
|
|
id: `${sourceId}-points`,
|
|
type: 'circle',
|
|
source: sourceId,
|
|
filter: ['==', '$type', 'Point'],
|
|
paint: {
|
|
'circle-radius': 5,
|
|
'circle-color': '#3b82f6',
|
|
'circle-stroke-width': 1,
|
|
'circle-stroke-color': '#ffffff'
|
|
}
|
|
});
|
|
|
|
const coordinates = [];
|
|
const extractCoords = (geom) => {
|
|
if (!geom) return;
|
|
if (geom.type === 'Point') coordinates.push(geom.coordinates);
|
|
else if (geom.type === 'LineString' || geom.type === 'MultiPoint') coordinates.push(...geom.coordinates);
|
|
else if (geom.type === 'Polygon' || geom.type === 'MultiLineString') geom.coordinates.forEach(c => coordinates.push(...c));
|
|
};
|
|
|
|
if (geojson.features) {
|
|
geojson.features.forEach(f => extractCoords(f.geometry));
|
|
} else {
|
|
extractCoords(geojson.geometry);
|
|
}
|
|
|
|
if (coordinates.length > 0) {
|
|
const bounds = coordinates.reduce((b, coord) => {
|
|
return [
|
|
[Math.min(b[0][0], coord[0]), Math.min(b[0][1], coord[1])],
|
|
[Math.max(b[1][0], coord[0]), Math.max(b[1][1], coord[1])]
|
|
];
|
|
}, [[coordinates[0][0], coordinates[0][1]], [coordinates[0][0], coordinates[0][1]]]);
|
|
map.fitBounds(bounds, { padding: 40 });
|
|
}
|
|
alert(`{% translate "Couche vectorielle chargée avec succès !" %}`);
|
|
} catch (err) {
|
|
alert("Fichier GeoJSON invalide: " + err.message);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
}
|
|
|
|
const form = document.getElementById('combined-import-form');
|
|
if (form) {
|
|
form.addEventListener('submit', function() {
|
|
saveCablesToForm();
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|