loko/loko/assets/templates/assets/detail_generic.html

1876 lines
No EOL
94 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends base_template|default:"base.html" %}
{% load i18n %}
{% load static %}
{% load asset_tags %}
{% block head %}
<!-- MapLibre GL 5.7.0 -->
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<link rel="stylesheet" href="{% static 'assets/asset_cms_edit_mode.css' %}">
{% if asset.geom or asset.lon or asset.lat %}
<!-- MapboxDraw for geometry editing -->
<link href="{% static 'common/mapbox-gl-draw.css' %}" rel="stylesheet"/>
{% endif %}
<style>
.replacement-chain {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.replacement-item {
position: relative;
}
.replacement-badge {
display: inline-block;
padding: 0.5rem 0.75rem;
background-color: #e9ecef;
border: 2px solid #6c757d;
border-radius: 0.5rem;
font-size: 0.95rem;
transition: all 0.2s ease;
}
.replacement-badge:hover {
background-color: #dee2e6;
border-color: #495057;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.replacement-badge.current {
background-color: #cfe2ff;
border-color: #0d6efd;
font-weight: bold;
}
.replacement-arrow {
font-size: 1.25rem;
color: #6c757d;
display: flex;
align-items: center;
}
/* Fix for mobile: force decimal separator to dot for coordinate inputs */
input[type="number"][data-coordinate] {
font-variant-numeric: tabular-nums;
}
/* Accentuation des bordures de cartes pour meilleure distinction */
#asset-detail-container .card {
border: 1px solid #b8c5d3;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
border-radius: 8px;
overflow: hidden;
}
#asset-detail-container .card-header {
border-bottom: 1px solid #b8c5d3;
background-color: #f8fafc;
}
/* Adoucissement des lignes alternées des tables (stripes très clairs et élégants) */
#asset-detail-container .table-striped {
--bs-table-striped-bg: #f8fafc;
--bs-table-accent-bg: #f8fafc;
}
#asset-detail-container .table-striped > tbody > tr:nth-of-type(odd) > * {
--bs-table-bg-type: #f8fafc;
--bs-table-accent-bg: #f8fafc;
background-color: #f8fafc !important;
box-shadow: inset 0 0 0 9999px #f8fafc !important;
}
#asset-detail-container .table-striped > tbody > tr:nth-of-type(even) > * {
--bs-table-bg-type: #ffffff;
--bs-table-accent-bg: #ffffff;
background-color: #ffffff !important;
box-shadow: inset 0 0 0 9999px #ffffff !important;
}
#asset-detail-container .table th {
color: #475569;
font-weight: 500;
border-color: #e2e8f0;
}
#asset-detail-container .table td {
border-color: #e2e8f0;
}
/* Styles pour la barre d'édition et mode édition mobile */
#asset-cms-edit-bar.mobile-edit-bar {
position: sticky;
top: 4.5rem;
z-index: 1020;
background-color: #ffffff;
border: 1px solid #b8c5d3;
box-shadow: 0 4px 6px -1px rgba(15, 23, 42, 0.08);
}
#asset-cms-edit-bar .mode-editing-label {
display: none;
}
#asset-cms-edit-bar .mode-viewing-label {
display: inline-flex;
align-items: center;
}
body.asset-cms-edit-mode #asset-cms-edit-bar .mode-editing-label {
display: inline-flex;
align-items: center;
}
body.asset-cms-edit-mode #asset-cms-edit-bar .mode-viewing-label {
display: none;
}
body.asset-cms-edit-mode #asset-edit-mode-badge {
background-color: #fef08a !important;
color: #854d0e !important;
border-color: #fde047 !important;
}
@media (max-width: 767.98px) {
.asset-editable-field .field-edit input,
.asset-editable-field .field-edit select,
.asset-editable-field .field-edit textarea {
min-height: 42px;
font-size: 1rem;
}
#asset-map {
height: 280px !important;
}
}
</style>
{% endblock head %}
{% block extra_head %}
<!-- MapLibre GL 5.7.0 -->
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<link rel="stylesheet" href="{% static 'assets/asset_cms_edit_mode.css' %}">
{% if asset.geom or asset.lon or asset.lat %}
<!-- MapboxDraw for geometry editing -->
<link href="{% static 'common/mapbox-gl-draw.css' %}" rel="stylesheet"/>
{% endif %}
{% endblock extra_head %}
{% block upleft %}
{% if mobile_view and back_url %}
<a href="{{ back_url }}" class="nav-link d-flex align-items-center gap-1" style="color: white;" title="{% translate 'Retour' %}">
<i class="bi bi-arrow-left fs-4"></i>
</a>
{% elif mobile_view or is_mobile %}
<a href="{% url 'mobile:mobile_index' %}?home=1" class="nav-link" style="color: white;" title="{% translate 'Accueil' %}">
<i class="bi bi-house fs-3"></i>
</a>
{% else %}
<a href="/" class="nav-link" style="color: white;">
<i class="bi bi-house fs-3" title="Home"></i>
</a>
{% endif %}
{% endblock upleft %}
{% block title %}
{% if mobile_view %}
<span>{{ asset.code }} - {% translate "Détails" %}</span>
{% else %}
{{ product_name }}
{% endif %}
{% endblock title %}
{% block upright %}
<a href="{% url 'common:account_info' %}?next={{ request.get_full_path|urlencode }}" class="nav-link" style="color: white;">
<i class="bi bi-person-gear fs-3" title="Account"></i>
</a>
{% endblock upright %}
{% block content %}
<div id="asset-detail-container"
class="{% if mobile_view %}px-2 px-sm-3 pb-3{% else %}container-fluid mt-4{% endif %}"
data-asset-model="{{ asset_model }}"
data-asset-id="{{ asset.id }}"
data-thematic-code="{{ thematic_code }}"
data-has-support="{% if asset.pole_id or asset.publiclighting_pole_id or asset.trafficlights_pole_id %}true{% else %}false{% endif %}"
{% if asset_geometry_json %}data-asset-geometry="{{ asset_geometry_json }}"{% endif %}
{% if thematic_code == 'trafficlights' %}
data-tl-icon-url="{% static 'assets/traffic_light_icon.svg' %}"
data-simple-pole-icon-url="{% static 'assets/simple_pole_icon.svg' %}"
data-cabinet-icon-url="{% static 'assets/electrical-panel.png' %}"
data-radar-icon-url="{% static 'assets/radar_preventif_icon.svg' %}?v=20260710"
{% endif %}>
<!-- CMS-style Edit Bar (Desktop & Mobile) -->
{% if mobile_view %}
{% if allowed_update_fields %}
<div id="asset-cms-edit-bar" class="mobile-edit-bar rounded-3 p-2 mb-3 d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<span id="asset-edit-mode-badge" class="badge bg-secondary-subtle text-secondary border">
<span class="mode-viewing-label"><i class="bi bi-eye me-1"></i>{% translate "Consultation" %}</span>
<span class="mode-editing-label"><i class="bi bi-pencil-fill me-1"></i>{% translate "Édition en cours" %}</span>
</span>
<span class="fw-bold small text-truncate" style="max-width: 140px;" title="{{ asset.code }}">
{{ asset.code }}
</span>
</div>
<div class="d-flex align-items-center gap-1 ms-auto">
{% if 'archive' in allowed_actions %}
<button id="asset-archive-btn" class="btn btn-outline-secondary btn-sm asset-normal-action px-2" title="{% translate 'Archiver' %}">
<i class="bi bi-archive"></i>
</button>
{% endif %}
{% if 'replace' in allowed_actions %}
<button id="asset-replace-btn" class="btn btn-outline-secondary btn-sm asset-normal-action px-2" title="{% translate 'Remplacer' %}">
<i class="bi bi-arrow-repeat"></i>
</button>
{% endif %}
<button id="asset-cms-edit-btn" class="btn btn-primary btn-sm px-3 fw-semibold">
<i class="bi bi-pencil me-1"></i>{% translate "Éditer" %}
</button>
<button id="asset-cms-save-btn" class="btn btn-success btn-sm px-3 fw-semibold d-none">
<i class="bi bi-check-circle me-1"></i>{% translate "Sauvegarder" %}
</button>
<button id="asset-cms-cancel-btn" class="btn btn-outline-danger btn-sm px-2 d-none">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler" %}
</button>
</div>
</div>
{% endif %}
{% else %}
{% if allowed_update_fields %}
<div id="asset-cms-edit-bar" class="px-3 py-2 mb-3 shadow-sm d-flex align-items-center justify-content-between border-bottom bg-light">
<div class="d-flex align-items-center gap-2">
{% block asset_header_title %}
<div>
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% translate "Asset" %} : {{ asset.code }}
</h2>
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;">
{% if thematic.icon %}
<i class="bi {{ thematic.icon }}"></i>
{% endif %}
{% if thematic.name %}
<span>{{ thematic.name }}</span>
<span>&mdash;</span>
{% endif %}
<span>{{ asset_verbose_name }}</span>
</div>
</div>
{% endblock asset_header_title %}
</div>
<div class="d-flex gap-2">
{% if 'archive' in allowed_actions %}
<button id="asset-archive-btn" class="btn btn-light btn-sm asset-normal-action" title="{% translate 'Archiver' %}">
<i class="bi bi-archive"></i> {% translate "Archiver" %}
</button>
{% endif %}
{% if 'replace' in allowed_actions %}
<button id="asset-replace-btn" class="btn btn-light btn-sm asset-normal-action" title="{% translate 'Remplacer' %}">
<i class="bi bi-arrow-repeat"></i> {% translate "Remplacer" %}
</button>
{% endif %}
<button id="asset-cms-edit-btn" class="btn btn-light btn-sm">
<i class="bi bi-pencil"></i> {% translate "Éditer" %}
</button>
<button id="asset-cms-save-btn" class="btn btn-success btn-sm d-none">
<i class="bi bi-check-circle"></i> {% translate "Sauvegarder" %}
</button>
<button id="asset-cms-cancel-btn" class="btn btn-outline-danger btn-sm d-none">
<i class="bi bi-x-circle"></i> {% translate "Annuler" %}
</button>
</div>
</div>
{% endif %}
{% endif %}
{% if mobile_view or not allowed_update_fields %}
<div class="mb-3">
{% block asset_header_title_readonly %}
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
{% if thematic.icon %}
<i class="bi {{ thematic.icon }} me-2"></i>
{% endif %}
{% translate "Asset" %} : {{ asset.code }}
</h2>
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.85rem; color: #6c757d;">
{% if thematic.icon %}
<i class="bi {{ thematic.icon }}"></i>
{% endif %}
{% if thematic.name %}
<span>{{ thematic.name }}</span>
<span>&mdash;</span>
{% endif %}
<span>{{ asset_verbose_name }}</span>
</div>
{% endblock asset_header_title_readonly %}
</div>
{% endif %}
<div class="row {% if not mobile_view %}mt-4{% endif %}">
<!-- Colonne principale : informations de base -->
<div class="{% if mobile_view %}col-12{% else %}col-md-8{% endif %}">
{% block main_asset_content %}
<div class="card mb-3">
<div class="card-header bg-light">
<h5 class="mb-0">{% translate "Informations générales" %}</h5>
</div>
<div class="card-body">
<table class="table table-striped mb-0">
<tbody>
<!-- Code -->
<tr class="asset-editable-field" data-field="code" data-can-edit="{% if 'code' in allowed_update_fields %}true{% else %}false{% endif %}">
<th style="width: 30%;">{% translate "Code" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.code }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.code }}">
</div>
</td>
</tr>
<!-- Référence externe -->
{% if asset.external_reference or 'external_reference' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="external_reference" data-can-edit="{% if 'external_reference' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Référence externe" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.external_reference|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.external_reference|default:'' }}">
</div>
</td>
</tr>
{% endif %}
<!-- Nom FR -->
<tr class="asset-editable-field" data-field="name_fr" data-can-edit="{% if 'name_fr' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Nom" %} (FR)</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.name_fr|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.name_fr|default:'' }}">
</div>
</td>
</tr>
<!-- Nom NL -->
<tr class="asset-editable-field" data-field="name_nl" data-can-edit="{% if 'name_nl' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Nom" %} (NL)</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.name_nl|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.name_nl|default:'' }}">
</div>
</td>
</tr>
<!-- Statut -->
<tr class="asset-editable-field" data-field="status" data-can-edit="{% if 'status' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Statut" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{% if asset.status == 'active' %}
<span class="badge bg-success">{{ asset.get_status_display }}</span>
{% elif asset.status == 'damaged' %}
<span class="badge bg-warning">{{ asset.get_status_display }}</span>
{% elif asset.status == 'to_replace' %}
<span class="badge bg-danger">{{ asset.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ asset.get_status_display }}</span>
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<select class="form-select form-select-sm">
{% for value, label in status_choices %}
<option value="{{ value }}" {% if value == asset.status %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</tr>
<!-- Statut de validation -->
{% if asset.validation_status is not None or 'validation_status' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="validation_status" data-can-edit="{% if 'validation_status' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Statut de validation" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{% if asset.validation_status == 'validated' %}
<span class="badge bg-success">{{ asset.get_validation_status_display }}</span>
{% elif asset.validation_status == 'to_be_validated' %}
<span class="badge bg-warning text-dark">{{ asset.get_validation_status_display }}</span>
{% elif asset.validation_status == 'to_be_completed' %}
<span class="badge bg-info text-dark">{{ asset.get_validation_status_display }}</span>
{% elif asset.validation_status == 'rejected' %}
<span class="badge bg-danger">{{ asset.get_validation_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ asset.get_validation_status_display|default:"-" }}</span>
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<select class="form-select form-select-sm">
<option value="">-</option>
{% for value, label in validation_status_choices %}
<option value="{{ value }}" {% if value == asset.validation_status %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</td>
</tr>
{% endif %}
<!-- Catégorie -->
<tr class="asset-editable-field" data-field="category" data-can-edit="{% if 'category' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Catégorie" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.category|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<select class="form-select form-select-sm">
<option value="">-</option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if cat.id == asset.category_id %}selected{% endif %}>{{ cat.get_name }}</option>
{% endfor %}
</select>
</div>
</td>
</tr>
<!-- Date d'installation -->
<tr class="asset-editable-field" data-field="installation_date" data-can-edit="{% if 'installation_date' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Installé le" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.installation_date|date:"d/m/Y"|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="date" class="form-control form-control-sm" value="{{ asset.installation_date|date:'Y-m-d'|default:'' }}">
</div>
</td>
</tr>
<!-- N° de série -->
<tr class="asset-editable-field" data-field="serial_number" data-can-edit="{% if 'serial_number' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "N° de série" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.serial_number|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.serial_number|default:'' }}">
</div>
</td>
</tr>
<!-- Marque -->
<tr class="asset-editable-field" data-field="brand" data-can-edit="{% if 'brand' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Marque" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.brand|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.brand|default:'' }}">
</div>
</td>
</tr>
<!-- Programme de financement / Projet -->
{% if asset.funding_program or 'funding_program' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="funding_program" data-can-edit="{% if 'funding_program' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Programme / Financement" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.funding_program|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.funding_program|default:'' }}">
</div>
</td>
</tr>
{% endif %}
<!-- Durée de garantie -->
<tr class="asset-editable-field" data-field="warranty_duration" data-can-edit="{% if 'warranty_duration' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Durée de garantie" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{% if asset.warranty_duration %}
{{ asset.warranty_duration }} {% translate "mois" %}
{% with remaining=asset|warranty_remaining_months %}
{% if remaining is not None %}
&mdash;
{% if remaining > 0 %}
<span class="badge bg-success ms-1">{% blocktranslate with n=remaining %}{{ n }} mois restants{% endblocktranslate %}</span>
{% elif remaining == 0 %}
<span class="badge bg-warning text-dark ms-1">{% translate "Expire ce mois-ci" %}</span>
{% else %}
<span class="badge bg-secondary ms-1">{% blocktranslate with n=remaining|cut:"-" %}Expirée ({{ n }} mois){% endblocktranslate %}</span>
{% endif %}
{% endif %}
{% endwith %}
{% else %}-{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="number" class="form-control form-control-sm" value="{{ asset.warranty_duration|default:'' }}" min="0">
</div>
</td>
</tr>
<!-- Dernière inspection -->
<tr class="asset-editable-field" data-field="last_inspection_date" data-can-edit="{% if 'last_inspection_date' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Dernière inspection" %}</th>
<td>
<div class="field-display">
<span class="field-value">{{ asset.last_inspection_date|date:"d/m/Y"|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="date" class="form-control form-control-sm" value="{{ asset.last_inspection_date|date:'Y-m-d'|default:'' }}">
</div>
</td>
</tr>
<!-- Note générale -->
<tr class="asset-editable-field" data-field="note" data-can-edit="{% if 'note' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Note générale" %}</th>
<td>
<div class="field-display">
<span class="field-value text-muted">{{ asset.note|default:"-"|linebreaksbr }}</span>
</div>
<div class="field-edit d-none">
<textarea class="form-control form-control-sm" rows="3">{{ asset.note|default:'' }}</textarea>
</div>
</td>
</tr>
<!-- Champs spécifiques au type d'asset -->
{% get_asset_specific_fields asset as asset_specific_fields %}
{% if asset_specific_fields %}
<tr style="--bs-table-bg-type: transparent; --bs-table-bg-state: transparent;">
<td colspan="2" style="padding-top: 1.25rem; padding-bottom: 0;"></td>
</tr>
<!-- <tr>
<td colspan="2" class="p-0">
<hr class="my-0" style="border-top: 2px solid #dee2e6; ">
</td>
</tr> -->
<tr class="table-light" style="--bs-table-bg-type: transparent; --bs-table-bg-state: transparent;">
<th colspan="2" class="text-muted" style="color: #475569 !important; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;">
{% translate "Attributs spécifiques" %}
</th>
</tr>
{% endif %}
{% for field in asset_specific_fields %}
<tr class="asset-editable-field" data-field="{{ field.name }}" data-can-edit="{% if field.name in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{{ field.label }}</th>
<td>
<div class="field-display">
<span class="field-value">{{ field.display_value }}</span>
</div>
<div class="field-edit d-none">
{% if field.field_type == 'boolean' %}
<select class="form-select form-select-sm">
<option value="" {% if field.input_value == '' %}selected{% endif %}>---------</option>
<option value="true" {% if field.input_value == 'true' %}selected{% endif %}>{% translate "Oui" %}</option>
<option value="false" {% if field.input_value == 'false' %}selected{% endif %}>{% translate "Non" %}</option>
</select>
{% elif field.field_type == 'select' %}
<select class="form-select form-select-sm">
<option value="">---------</option>
{% for choice_val, choice_label in field.choices %}
<option value="{{ choice_val }}" {% if field.input_value == choice_val %}selected{% endif %}>{{ choice_label }}</option>
{% endfor %}
</select>
{% elif field.field_type == 'number' %}
<input type="number" step="any" class="form-control form-control-sm" value="{{ field.input_value }}">
{% elif field.field_type == 'date' %}
<input type="date" class="form-control form-control-sm" value="{{ field.input_value }}">
{% elif field.field_type == 'textarea' %}
<textarea class="form-control form-control-sm" rows="3">{{ field.input_value }}</textarea>
{% else %}
<input type="text" class="form-control form-control-sm" value="{{ field.input_value }}">
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock main_asset_content %}
{% block asset_documents_block %}
<!-- Documents associés -->
{% asset_documents asset %}
{% endblock asset_documents_block %}
<!-- Blocs supplémentaires (extensible par les templates spécifiques) -->
{% block extra_blocks %}{% endblock extra_blocks %}
<!-- Panneau de gestion des permissions (admins uniquement) -->
{% if is_admin and content_type %}
<div class="card mb-3">
<div class="card-header bg-light">
<div class="d-flex align-items-center justify-content-between" role="button"
data-bs-toggle="collapse" data-bs-target="#permissions-panel-collapse"
aria-expanded="false" aria-controls="permissions-panel-collapse"
style="cursor:pointer;"
id="permissions-panel-header">
<h5 class="mb-0">
<i class="bi bi-shield-lock me-2"></i>{% translate "Permissions spécifiques" %}
</h5>
<div class="d-flex align-items-center gap-2">
<a href="{% url 'assets:asset_type_permissions_page' content_type.pk %}"
class="btn btn-outline-secondary btn-sm"
title="{% translate 'Voir tous les accès pour ce type' %}"
onclick="event.stopPropagation();">
<i class="bi bi-list-ul me-1"></i>{% translate "Vue par type" %}
</a>
<i class="bi bi-chevron-down"></i>
</div>
</div>
</div>
<div class="collapse" id="permissions-panel-collapse">
<div class="card-body p-0" id="permissions-panel-body"
data-load-url="{% url 'assets:asset_permissions_panel' content_type.pk asset.pk %}">
<div class="p-3 text-muted" id="permissions-panel-loading">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
{% translate "Chargement des permissions…" %}
</div>
</div>
</div>
</div>
<script>
(function(){
var loaded = false;
var collapse = document.getElementById('permissions-panel-collapse');
if (!collapse) return;
collapse.addEventListener('show.bs.collapse', function(){
if (loaded) return;
loaded = true;
var body = document.getElementById('permissions-panel-body');
var url = body.getAttribute('data-load-url');
fetch(url, {headers: {'X-Requested-With': 'XMLHttpRequest'}})
.then(function(r){ return r.text(); })
.then(function(html){ body.innerHTML = html; })
.catch(function(){ body.innerHTML = '<p class="p-3 text-danger">{% translate "Erreur lors du chargement." %}</p>'; });
});
})();
</script>
{% endif %}
{% block linked_assets_block %}
<!-- Assets liés (parents puis enfants) -->
{% get_asset_parents asset as parent_groups %}
{% get_asset_children_tree asset as children_tree %}
{% if parent_groups or children_tree or allowed_update_fields %}
<div class="card mb-3" id="linked-assets-card">
<div class="card-header bg-light d-flex align-items-center justify-content-between">
<h5 class="mb-0">{% translate "Assets liés" %}</h5>
{% if allowed_update_fields %}
<button type="button"
class="btn btn-outline-secondary btn-sm d-none"
data-edit-mode-only="true"
id="open-link-asset-modal-btn"
title="{% translate 'Lier ou créer un asset' %}">
<i class="bi bi-plus-lg me-1"></i>{% translate "Lier / Créer" %}
</button>
{% endif %}
</div>
<div class="card-body pb-1" id="linked-assets-body">
{% if not parent_groups and not children_tree %}
<p class="text-muted small mb-2" id="no-linked-assets-msg">{% translate "Aucun asset lié." %}</p>
{% endif %}
{% include "assets/components/asset_children_tree.html" with children_tree=parent_groups editable=allowed_update_fields parent_model=asset_model parent_id=asset.id %}
{% include "assets/components/asset_children_tree.html" with children_tree=children_tree editable=allowed_update_fields parent_model=asset_model parent_id=asset.id %}
</div>
</div>
{% endif %}
{% endblock linked_assets_block %}
<!-- Historique des remplacements -->
{% if asset_replacements and asset_replacements|length > 1 %}
<div class="card mb-3">
<div class="card-header bg-light">
<h5 class="mb-0">{% translate "Historique des remplacements" %}</h5>
</div>
<div class="card-body">
<!-- Chaîne de remplacement visuelle -->
<div class="replacement-chain mb-4">
{% for replacement in asset_replacements %}
<div class="replacement-item d-inline-flex align-items-center me-2 mb-2">
<div class="replacement-badge {% if replacement.id == asset.id %}current{% endif %}">
{% if thematic_code %}
<a href="{% url 'assets:'|add:thematic_code|add:'_assets_detail' asset_model replacement.id %}"
class="text-decoration-none text-reset">
<strong>{{ replacement.code }}</strong>
</a>
{% else %}
<strong>{{ replacement.code }}</strong>
{% endif %}
</div>
{% if not forloop.last %}
<div class="replacement-arrow ms-2 me-2">
<i class="bi bi-arrow-right"></i>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Tableau détaillé -->
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 20%;">{% translate "Code" %}</th>
<th style="width: 15%;">{% translate "Statut" %}</th>
<th style="width: 20%;">{% translate "Installé le" %}</th>
<th style="width: 20%;">{% translate "Désinstallé le" %}</th>
<th style="width: 25%;">{% translate "Durée" %}</th>
</tr>
</thead>
<tbody>
{% for replacement in asset_replacements %}
<tr {% if replacement.id == asset.id %}class="table-info fw-bold"{% endif %}>
<td>
{% if thematic_code %}
<a href="{% url 'assets:'|add:thematic_code|add:'_assets_detail' asset_model replacement.id %}">
{{ replacement.code }}
</a>
{% else %}
{{ replacement.code }}
{% endif %}
</td>
<td>
{% if replacement.status == 'active' %}
<span class="badge bg-success">{{ replacement.get_status_display }}</span>
{% elif replacement.status == 'damaged' %}
<span class="badge bg-warning">{{ replacement.get_status_display }}</span>
{% elif replacement.status == 'to_replace' %}
<span class="badge bg-danger">{{ replacement.get_status_display }}</span>
{% elif replacement.status == 'removed' %}
<span class="badge bg-secondary">{{ replacement.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ replacement.get_status_display }}</span>
{% endif %}
</td>
<td>{{ replacement.installation_date|date:"d/m/Y H:i"|default:"-" }}</td>
<td>{{ replacement.uninstallation_date|date:"d/m/Y H:i"|default:"-" }}</td>
<td>
{% if replacement.installation_date and replacement.uninstallation_date %}
{% with duration=replacement.uninstallation_date|timeuntil:replacement.installation_date %}
<small class="text-muted">{{ duration }}</small>
{% endwith %}
{% elif replacement.installation_date %}
<small class="text-muted">{% translate "En service" %}</small>
{% else %}
<small class="text-muted">-</small>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Colonne latérale : modèle et localisation -->
<div class="{% if mobile_view %}col-12{% else %}col-md-4{% endif %}">
<!-- Modèle associé -->
{% block model_card %}
{% asset_model_card asset %}
{% endblock model_card %}
{% block extra_side_cards %}{% endblock extra_side_cards %}
<!-- Localisation associée -->
{% asset_location_link asset %}
<!-- Carte et géolocalisation -->
<div class="card mb-3">
<div class="card-header bg-light {% if mobile_view %}d-flex flex-column gap-2{% else %}d-flex justify-content-between align-items-center{% endif %}">
<h5 class="mb-0">{% translate "Géolocalisation" %}</h5>
{% if asset.lon and asset.lat %}
{% load l10n %}
<div class="d-flex gap-2{% if mobile_view %} w-100{% endif %}"{% if mobile_view %} style="justify-content: center;"{% endif %}>
<a href="https://www.google.com/maps/@{% localize off %}{{ asset.lat }},{{ asset.lon }}{% endlocalize %},18z?layer=c&cbll={% localize off %}{{ asset.lat }},{{ asset.lon }}{% endlocalize %}&cbp=0,0,0,0,0"
target="_blank"
class="btn btn-outline-primary btn-sm"
title="{% translate 'Voir sur Street View' %}">
<i class="bi bi-person-walking"></i> Street View
</a>
<a href="https://www.mapillary.com/app/?lat={% localize off %}{{ asset.lat }}{% endlocalize %}&lng={% localize off %}{{ asset.lon }}{% endlocalize %}&z=17"
target="_blank"
class="btn btn-outline-secondary btn-sm"
title="{% translate 'Voir sur Mapillary' %}">
<i class="bi bi-images"></i> Mapillary
</a>
</div>
{% endif %}
</div>
<div class="card-body p-0">
<!-- Carte MapLibre -->
<div id="asset-map" style="height: 400px; width: 100%;"></div>
<!-- Coordonnées -->
<div class="p-3 border-top">
<div class="row">
<div class="col-6">
<div class="asset-editable-field" data-field="lon" data-can-edit="{% if 'lon' in allowed_update_fields %}true{% else %}false{% endif %}">
<label class="small text-muted">{% translate "Longitude" %}:</label>
<div class="field-display">
<span class="field-value fw-medium">{{ asset.lon|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="number" step="0.000001" class="form-control form-control-sm" data-coordinate="lon" value="{{ lon_display|default:'' }}"></input>
</div>
</div>
</div>
<div class="col-6">
<div class="asset-editable-field" data-field="lat" data-can-edit="{% if 'lat' in allowed_update_fields %}true{% else %}false{% endif %}">
<label class="small text-muted">{% translate "Latitude" %}:</label>
<div class="field-display">
<span class="field-value fw-medium">{{ asset.lat|default:"-" }}</span>
</div>
<div class="field-edit d-none">
<input type="number" step="0.000001" class="form-control form-control-sm" data-coordinate="lat" value="{{ lat_display|default:'' }}"></input>
</div>
</div>
</div>
</div>
{% if asset.geom %}
<div class="mt-2">
<span id="geometry-display" class="badge bg-secondary">
{% if asset.geom.geom_type == 'Point' %}
Point
{% elif asset.geom.geom_type == 'LineString' %}
Ligne: {{ asset.geom.num_points }} points
{% elif asset.geom.geom_type == 'Polygon' %}
Polygone: {{ asset.geom.num_points }} points
{% else %}
{{ asset.geom.geom_type }}
{% endif %}
</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Blocs supplémentaires en bas de colonne droite -->
{% block side_bottom_cards %}{% endblock side_bottom_cards %}
</div>
</div>
<!-- Interventions liées (pleine largeur) -->
<div class="row mt-3">
<div class="col-12">
{% asset_interventions asset %}
</div>
</div>
<!-- Inspections et contrôles liés (pleine largeur) -->
<div class="row mt-3">
<div class="col-12">
{% asset_inspections asset %}
</div>
</div>
<!-- Historique des modifications (plein largeur, en bas) -->
<div class="row mt-3">
<div class="col-12">
{% include 'assets/components/asset_history.html' %}
</div>
</div>
</div>
<!-- Replace Asset Modal -->
{% if 'replace' in allowed_actions %}
<div class="modal fade asset-confirm-modal" id="replaceAssetModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% translate "Remplacer l'asset" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form class="replace-asset-form">
<div class="modal-body">
<p>{% translate "L'asset actuel sera archivé et un nouvel asset sera créé." %}</p>
<p class="text-muted small">{% translate "Les noms (FR/NL) de l'ancien asset seront automatiquement copiés." %}</p>
{% if asset_models %}
<div class="mb-3">
<label class="form-label">{% translate "Modèle du nouvel asset" %}</label>
<select name="new_model_id" class="form-select">
<option value="">{% translate "Garder le même modèle" %}</option>
{% for model in asset_models %}
<option value="{{ model.id }}">{{ model.get_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="mb-3">
<label class="form-label">{% translate "Numéro de série" %} <small class="text-muted">({% translate "optionnel" %})</small></label>
<input type="text" name="serial_number" class="form-control" placeholder="{% translate 'Numéro de série' %}">
</div>
<div class="mb-3">
<label class="form-label">{% translate "Marque" %} <small class="text-muted">({% translate "optionnel" %})</small></label>
<input type="text" name="brand" class="form-control" placeholder="{% translate 'Marque' %}">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-arrow-repeat"></i> {% translate "Remplacer" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Modal : Lier / Créer un asset lié -->
{% if allowed_update_fields %}
<div class="modal fade" id="linkAssetModal" tabindex="-1" aria-labelledby="linkAssetModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="linkAssetModalLabel">
<i class="bi bi-link-45deg me-2"></i>{% translate "Lier / Créer un asset" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Step 1: Type selector -->
<div id="link-asset-step-type">
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Type d'asset à lier" %}</label>
<select class="form-select" id="link-asset-type-select">
<option value="">{% translate "Chargement..." %}</option>
</select>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary flex-fill" id="link-asset-tab-link">
<i class="bi bi-link-45deg me-1"></i>{% translate "Lier un asset existant" %}
</button>
<button type="button" class="btn btn-outline-success flex-fill" id="link-asset-tab-create">
<i class="bi bi-plus-circle me-1"></i>{% translate "Créer un nouvel asset" %}
</button>
</div>
</div>
<!-- Step 2a: Link existing -->
<div id="link-asset-step-link" class="d-none mt-3">
<h6 class="text-primary"><i class="bi bi-link-45deg me-1"></i>{% translate "Lier un asset existant" %}</h6>
<div class="mb-3">
<label class="form-label">{% translate "Rechercher l'asset à lier" %}</label>
<input type="text" class="form-control" id="link-asset-search-input"
placeholder="{% translate 'Code ou nom...' %}" autocomplete="off">
<div id="link-asset-search-results" class="list-group mt-1" style="max-height:240px;overflow-y:auto;"></div>
</div>
<div id="link-asset-selected" class="alert alert-info d-none">
<i class="bi bi-check-circle me-1"></i>
<span id="link-asset-selected-label"></span>
<button type="button" class="btn-close float-end" id="link-asset-clear-selected"></button>
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-secondary" id="link-asset-back-btn">
<i class="bi bi-arrow-left"></i> {% translate "Retour" %}
</button>
<button type="button" class="btn btn-primary ms-auto" id="link-asset-confirm-btn" disabled>
<i class="bi bi-link-45deg me-1"></i>{% translate "Lier" %}
</button>
</div>
</div>
<!-- Step 2b: Create new -->
<div id="link-asset-step-create" class="d-none mt-3">
<h6 class="text-success"><i class="bi bi-plus-circle me-1"></i>{% translate "Créer un nouvel asset" %}</h6>
<p class="text-muted small">{% translate "Un nouvel asset sera créé et lié automatiquement à cet asset parent." %}</p>
<div id="link-asset-create-form">
<!-- Dynamic form fields rendered by JS -->
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-secondary" id="link-asset-create-back-btn">
<i class="bi bi-arrow-left"></i> {% translate "Retour" %}
</button>
<button type="button" class="btn btn-success ms-auto" id="link-asset-create-confirm-btn">
<i class="bi bi-plus-circle me-1"></i>{% translate "Créer et lier" %}
</button>
</div>
</div>
<!-- Spinner -->
<div id="link-asset-spinner" class="text-center mt-3 d-none">
<div class="spinner-border text-primary" role="status"></div>
</div>
<!-- Error -->
<div id="link-asset-error" class="alert alert-danger mt-3 d-none"></div>
</div>
</div>
</div>
</div>
<script>
(function () {
'use strict';
const assetContainer = document.getElementById('asset-detail-container');
const PARENT_MODEL = assetContainer ? assetContainer.dataset.assetModel : '';
const PARENT_ID = assetContainer ? assetContainer.dataset.assetId : '';
const BASE_URL = '/assets/';
/* ------------------------------------------------------------------ */
/* Modal state */
/* ------------------------------------------------------------------ */
let selectedChildType = null; // {model_name, field_on_child|field_name, verbose_name, is_parent}
let selectedChildAsset = null; // {id, code}
const modal = document.getElementById('linkAssetModal');
const typeSelect = document.getElementById('link-asset-type-select');
const btnTabLink = document.getElementById('link-asset-tab-link');
const btnTabCreate = document.getElementById('link-asset-tab-create');
const stepType = document.getElementById('link-asset-step-type');
const stepLink = document.getElementById('link-asset-step-link');
const stepCreate = document.getElementById('link-asset-step-create');
const searchInput = document.getElementById('link-asset-search-input');
const searchResults = document.getElementById('link-asset-search-results');
const selectedBox = document.getElementById('link-asset-selected');
const selectedLabel = document.getElementById('link-asset-selected-label');
const clearSelected = document.getElementById('link-asset-clear-selected');
const confirmLink = document.getElementById('link-asset-confirm-btn');
const backFromLink = document.getElementById('link-asset-back-btn');
const backFromCreate = document.getElementById('link-asset-create-back-btn');
const confirmCreate = document.getElementById('link-asset-create-confirm-btn');
const spinner = document.getElementById('link-asset-spinner');
const errorBox = document.getElementById('link-asset-error');
const openBtn = document.getElementById('open-link-asset-modal-btn');
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getCsrfToken() {
const m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function showSpinner(on) {
spinner.classList.toggle('d-none', !on);
}
function showError(msg) {
errorBox.textContent = msg;
errorBox.classList.remove('d-none');
}
function clearError() {
errorBox.classList.add('d-none');
errorBox.textContent = '';
}
function resetModal() {
selectedChildType = null;
selectedChildAsset = null;
childFormFields = [];
stepType.classList.remove('d-none');
stepLink.classList.add('d-none');
stepCreate.classList.add('d-none');
searchInput.value = '';
searchResults.innerHTML = '';
selectedBox.classList.add('d-none');
confirmLink.disabled = true;
const formContainer = document.getElementById('link-asset-create-form');
if (formContainer) formContainer.innerHTML = '';
clearError();
showSpinner(false);
}
/* ------------------------------------------------------------------ */
/* Load child types from API */
/* ------------------------------------------------------------------ */
async function loadChildTypes() {
typeSelect.innerHTML = '<option value="">Chargement...</option>';
try {
const resp = await fetch(`${BASE_URL}${PARENT_MODEL}/${PARENT_ID}/linked-types/`);
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Erreur');
typeSelect.innerHTML = '<option value="">— Sélectionner un type —</option>';
const childTypes = data.child_types || [];
const parentTypes = data.parent_types || [];
if (childTypes.length > 0) {
const grpChild = document.createElement('optgroup');
grpChild.label = '{% translate "Assets enfants" %}';
childTypes.forEach(ct => {
const opt = document.createElement('option');
opt.value = JSON.stringify({
model_name: ct.model_name,
field_on_child: ct.field_on_child,
verbose_name: ct.verbose_name,
is_parent: false
});
opt.textContent = ct.verbose_name_plural;
grpChild.appendChild(opt);
});
typeSelect.appendChild(grpChild);
}
if (parentTypes.length > 0) {
const grpParent = document.createElement('optgroup');
grpParent.label = '{% translate "Assets parents" %}';
parentTypes.forEach(pt => {
const opt = document.createElement('option');
opt.value = JSON.stringify({
model_name: pt.model_name,
field_name: pt.field_name,
verbose_name: pt.verbose_name,
current_id: pt.current_id,
current_code: pt.current_code,
is_parent: true
});
// Show current parent code if set
opt.textContent = pt.verbose_name + (pt.current_code ? ' (actuel : ' + pt.current_code + ')' : '');
grpParent.appendChild(opt);
});
typeSelect.appendChild(grpParent);
}
if (typeSelect.options.length === 1) {
typeSelect.innerHTML = '<option value="">Aucun type disponible</option>';
btnTabLink.disabled = true;
btnTabCreate.disabled = true;
}
} catch(e) {
typeSelect.innerHTML = '<option value="">Erreur de chargement</option>';
}
}
// Show/hide the "Create" button depending on whether a parent type is selected
typeSelect.addEventListener('change', () => {
const t = getSelectedType();
if (t && t.is_parent) {
btnTabCreate.classList.add('d-none');
} else {
btnTabCreate.classList.remove('d-none');
}
});
/* ------------------------------------------------------------------ */
/* Open modal entry point */
/* ------------------------------------------------------------------ */
if (openBtn) {
openBtn.addEventListener('click', () => {
resetModal();
loadChildTypes();
new bootstrap.Modal(modal).show();
});
}
// Also listen for per-group "add" buttons (rendered in asset_children_tree)
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action="add-linked-asset"]');
if (!btn) return;
resetModal();
loadChildTypes().then(() => {
// Pre-select the type for this group
const childModel = btn.dataset.childModel;
const fieldOnChild = btn.dataset.fieldOnChild;
for (const opt of typeSelect.options) {
if (!opt.value) continue;
const parsed = JSON.parse(opt.value);
if (parsed.model_name === childModel && parsed.field_on_child === fieldOnChild) {
typeSelect.value = opt.value;
break;
}
}
});
new bootstrap.Modal(modal).show();
});
// Listen for per-group "set parent" buttons
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action="set-parent-asset"]');
if (!btn) return;
resetModal();
const fieldName = btn.dataset.fieldName;
const parentModelName = btn.dataset.parentModelName;
loadChildTypes().then(() => {
// Pre-select the matching parent type option
for (const opt of typeSelect.options) {
if (!opt.value) continue;
try {
const parsed = JSON.parse(opt.value);
if (parsed.is_parent && parsed.field_name === fieldName && parsed.model_name === parentModelName) {
typeSelect.value = opt.value;
btnTabCreate.classList.add('d-none');
// Jump straight to the link step
selectedChildType = parsed;
const searchLabel = stepLink.querySelector('label');
if (searchLabel) searchLabel.textContent = `{% translate "Rechercher le parent à lier" %}`;
stepType.classList.add('d-none');
stepLink.classList.remove('d-none');
if (parsed.current_id) {
searchInput.value = parsed.current_code || '';
doSearch();
}
break;
}
} catch(_e) {}
}
});
new bootstrap.Modal(modal).show();
});
/* ------------------------------------------------------------------ */
/* Tab selection */
/* ------------------------------------------------------------------ */
function getSelectedType() {
if (!typeSelect.value) return null;
try { return JSON.parse(typeSelect.value); } catch(_e) { return null; }
}
btnTabLink.addEventListener('click', () => {
selectedChildType = getSelectedType();
if (!selectedChildType) { showError('Veuillez sélectionner un type d\'asset.'); return; }
clearError();
// Update search label to show it's a parent if needed
const searchLabel = stepLink.querySelector('label');
if (searchLabel) {
searchLabel.textContent = selectedChildType.is_parent
? `{% translate "Rechercher le parent à lier" %}`
: `{% translate "Rechercher l'asset à lier" %}`;
}
stepType.classList.add('d-none');
stepLink.classList.remove('d-none');
// Pre-fill search if there's an existing parent
if (selectedChildType.is_parent && selectedChildType.current_id) {
searchInput.value = selectedChildType.current_code || '';
doSearch();
}
});
btnTabCreate.addEventListener('click', async () => {
selectedChildType = getSelectedType();
if (!selectedChildType) { showError('Veuillez sélectionner un type d\'asset.'); return; }
clearError();
stepType.classList.add('d-none');
stepCreate.classList.remove('d-none');
showSpinner(true);
await loadChildForm(selectedChildType.model_name);
showSpinner(false);
});
backFromLink.addEventListener('click', () => {
stepLink.classList.add('d-none');
stepType.classList.remove('d-none');
clearError();
});
backFromCreate.addEventListener('click', () => {
stepCreate.classList.add('d-none');
stepType.classList.remove('d-none');
clearError();
});
/* ------------------------------------------------------------------ */
/* Search autocomplete */
/* ------------------------------------------------------------------ */
let searchTimer = null;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(doSearch, 300);
});
searchInput.addEventListener('focus', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(doSearch, 100);
});
async function doSearch() {
if (!selectedChildType) return;
const q = searchInput.value.trim();
searchResults.innerHTML = '';
try {
let url;
if (selectedChildType.is_parent) {
// Search in the parent model; no field_on_child filter needed
url = `${BASE_URL}api/search-assets/${selectedChildType.model_name}/?q=${encodeURIComponent(q)}&limit=10`;
} else {
url = `${BASE_URL}api/search-assets/${selectedChildType.model_name}/?q=${encodeURIComponent(q)}&field_on_child=${encodeURIComponent(selectedChildType.field_on_child)}&parent_id=${PARENT_ID}`;
}
const resp = await fetch(url);
const data = await resp.json();
(data.results || []).forEach(r => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'list-group-item list-group-item-action py-1 px-2';
item.innerHTML = `<strong>${r.code}</strong>${r.name ? ' — ' + r.name : ''}`;
item.addEventListener('click', () => selectAsset(r));
searchResults.appendChild(item);
});
if (!data.results || data.results.length === 0) {
searchResults.innerHTML = '<div class="list-group-item text-muted small">Aucun résultat</div>';
}
} catch(e) {
showError('Erreur lors de la recherche.');
}
}
function selectAsset(r) {
selectedChildAsset = r;
selectedLabel.textContent = `${r.code}${r.name ? ' — ' + r.name : ''}`;
selectedBox.classList.remove('d-none');
confirmLink.disabled = false;
searchResults.innerHTML = '';
searchInput.value = '';
}
clearSelected.addEventListener('click', () => {
selectedChildAsset = null;
selectedBox.classList.add('d-none');
confirmLink.disabled = true;
});
/* ------------------------------------------------------------------ */
/* Dynamic form: load fields + render */
/* ------------------------------------------------------------------ */
let childFormFields = [];
async function loadChildForm(modelName) {
childFormFields = [];
const container = document.getElementById('link-asset-create-form');
container.innerHTML = '<p class="text-muted small">{% translate "Chargement du formulaire…" %}</p>';
try {
const url = `${BASE_URL}api/child-asset-form-fields/${modelName}/`
+ `?field_on_child=${encodeURIComponent(selectedChildType.field_on_child)}`
+ `&parent_model=${encodeURIComponent(PARENT_MODEL)}`
+ `&parent_id=${encodeURIComponent(PARENT_ID)}`;
const resp = await fetch(url);
const data = await resp.json();
childFormFields = data.fields || [];
renderChildForm(container, childFormFields);
} catch {
container.innerHTML = '<p class="text-danger small">{% translate "Erreur lors du chargement du formulaire." %}</p>';
}
}
function renderChildForm(container, fields) {
container.innerHTML = '';
if (fields.length === 0) {
container.innerHTML = '<p class="text-muted small">{% translate "Aucun champ supplémentaire requis." %}</p>';
return;
}
fields.forEach(f => {
const wrap = document.createElement('div');
wrap.className = 'mb-2';
const lbl = document.createElement('label');
lbl.className = 'form-label small mb-1';
lbl.setAttribute('for', 'child-field-' + f.name);
lbl.textContent = f.label;
if (f.required) {
const star = document.createElement('span');
star.className = 'text-danger ms-1';
star.textContent = '*';
lbl.appendChild(star);
}
wrap.appendChild(lbl);
let input;
if (f.type === 'fk' || f.type === 'choice') {
input = document.createElement('select');
input.className = 'form-select form-select-sm';
const blank = document.createElement('option');
blank.value = '';
blank.textContent = f.required ? '{% translate "— Sélectionner —" %}' : '{% translate "— Aucun —" %}';
input.appendChild(blank);
(f.choices || []).forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.label;
input.appendChild(opt);
});
if (f.prefill != null) {
input.value = String(f.prefill);
// If the value wasn't matched (prefill not in choices), reset to blank
if (input.value !== String(f.prefill)) input.value = '';
}
} else if (f.type === 'boolean') {
const checkWrap = document.createElement('div');
checkWrap.className = 'form-check';
input = document.createElement('input');
input.type = 'checkbox';
input.className = 'form-check-input';
input.checked = !!f.prefill;
checkWrap.appendChild(input);
wrap.appendChild(checkWrap);
input.id = 'child-field-' + f.name;
input.name = f.name;
lbl.setAttribute('for', 'child-field-' + f.name);
container.appendChild(wrap);
return;
} else if (f.type === 'datetime') {
input = document.createElement('input');
input.type = 'datetime-local';
input.className = 'form-control form-control-sm';
} else if (f.type === 'number') {
input = document.createElement('input');
input.type = 'number';
input.className = 'form-control form-control-sm';
} else {
input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm';
if (f.prefill != null) input.value = f.prefill;
}
input.id = 'child-field-' + f.name;
input.name = f.name;
if (f.required) {
input.required = true;
input.dataset.fieldLabel = f.label;
}
wrap.appendChild(input);
container.appendChild(wrap);
});
}
function collectChildFormData() {
const container = document.getElementById('link-asset-create-form');
const data = {};
container.querySelectorAll('[name]').forEach(el => {
const name = el.name;
if (el.type === 'checkbox') {
data[name] = el.checked;
} else if (el.value !== '') {
data[name] = (el.tagName === 'SELECT' || el.type === 'number')
? (isNaN(Number(el.value)) ? el.value : Number(el.value))
: el.value;
}
});
return data;
}
function validateChildForm() {
const container = document.getElementById('link-asset-create-form');
const missing = [];
container.querySelectorAll('[required]').forEach(el => {
el.classList.remove('is-invalid');
if (!el.value) {
el.classList.add('is-invalid');
missing.push(el.dataset.fieldLabel || el.name);
}
});
return missing;
}
/* ------------------------------------------------------------------ */
/* DOM helpers: add / remove linked asset rows without reload */
/* ------------------------------------------------------------------ */
function _makeStatusBadge(status) {
const labels = {
active: '{% translate "Actif" %}',
damaged: '{% translate "Endommagé" %}',
to_replace: '{% translate "A remplacer" %}',
removed: '{% translate "Enlevé" %}',
};
const classes = {
active: 'bg-success', damaged: 'bg-warning',
to_replace: 'bg-danger', removed: 'bg-secondary',
};
const cls = classes[status] || 'bg-secondary';
const lbl = labels[status] || status;
return `<span class="badge ${cls} ms-1" style="font-size:0.65rem;">${lbl}</span>`;
}
function appendLinkedAssetRow(childModelName, fieldOnChild, childId, childCode, childName, childUrl) {
// Find the group card matching this child type + field
const card = document.querySelector(
`.card[data-group-model="${childModelName}"][data-field-on-child="${fieldOnChild}"]`
);
const li = document.createElement('li');
li.className = 'list-group-item py-2 px-3';
li.dataset.itemId = childId;
li.dataset.itemModel = childModelName;
li.dataset.itemField = fieldOnChild;
const codeHtml = childUrl
? `<a href="${childUrl}" class="fw-medium text-decoration-none">${childCode}</a>`
: `<span class="fw-medium">${childCode}</span>`;
const nameHtml = childName ? `<small class="text-muted">${childName}</small>` : '';
const badge = _makeStatusBadge('active');
li.innerHTML = `
<div class="d-flex align-items-start gap-2">
<div class="flex-grow-1">
<div>${codeHtml}${badge}</div>
${nameHtml}
</div>
<button type="button"
class="btn btn-outline-danger btn-sm py-0 px-1 flex-shrink-0"
data-edit-mode-only="true"
data-action="unlink-child-asset"
data-child-model="${childModelName}"
data-child-id="${childId}"
data-field-on-child="${fieldOnChild}"
data-parent-model="${PARENT_MODEL}"
data-parent-id="${PARENT_ID}"
title="{% translate 'Délier cet asset' %}">
<i class="bi bi-x-lg" style="font-size:0.7rem;"></i>
</button>
</div>`;
if (card) {
// Add the row to the existing group
const ul = card.querySelector('ul.list-group');
ul.appendChild(li);
// Update the count badge
const badge = card.querySelector('.badge.bg-secondary');
if (badge) badge.textContent = ul.querySelectorAll('li').length;
} else {
// Group doesn't exist yet — create a new card
const body = document.getElementById('linked-assets-body');
const newCard = document.createElement('div');
newCard.className = 'card border mb-2';
newCard.dataset.groupModel = childModelName;
newCard.dataset.groupIsParent = 'false';
newCard.dataset.fieldOnChild = fieldOnChild;
newCard.innerHTML = `
<div class="card-header py-2 bg-light d-flex align-items-center gap-2">
<i class="bi bi-diagram-2 text-muted small"></i>
<h6 class="mb-0 small fw-semibold">${childModelName}</h6>
<span class="badge bg-secondary ms-auto">1</span>
<button type="button"
class="btn btn-outline-secondary btn-sm"
data-edit-mode-only="true"
data-action="add-linked-asset"
data-child-model="${childModelName}"
data-field-on-child="${fieldOnChild}"
data-parent-model="${PARENT_MODEL}"
data-parent-id="${PARENT_ID}"
title="{% translate 'Lier un asset existant' %}">
<i class="bi bi-link-45deg"></i>
</button>
</div>
<ul class="list-group list-group-flush"></ul>`;
newCard.querySelector('ul').appendChild(li);
body.appendChild(newCard);
}
// Hide the "no linked assets" message if present
const noMsg = document.getElementById('no-linked-assets-msg');
if (noMsg) noMsg.classList.add('d-none');
// Ensure new edit-mode-only buttons respect current edit state
const editModeActive = document.body.dataset.editMode === 'true'
|| document.querySelector('[data-edit-mode-only]')?.classList.contains('d-none') === false;
li.querySelectorAll('[data-edit-mode-only]').forEach(el => {
if (!editModeActive) el.classList.add('d-none');
});
}
function removeLinkedAssetRow(childModel, childId) {
const li = document.querySelector(
`.list-group-item[data-item-id="${childId}"][data-item-model="${childModel}"]`
);
if (!li) return;
const ul = li.closest('ul.list-group');
const card = li.closest('.card[data-group-model]');
li.remove();
if (ul && card) {
const remaining = ul.querySelectorAll('li').length;
const badge = card.querySelector('.badge.bg-secondary');
if (badge) badge.textContent = remaining;
if (remaining === 0) card.remove();
}
}
function removeLinkedParentRow(fieldName) {
const card = document.querySelector(
`.card[data-group-is-parent="true"][data-field-name="${fieldName}"]`
);
if (card) {
card.remove();
}
const body = document.getElementById('linked-assets-body');
if (body && body.querySelectorAll('.card').length === 0) {
const noMsg = document.getElementById('no-linked-assets-msg');
if (noMsg) noMsg.classList.remove('d-none');
}
}
/* ------------------------------------------------------------------ */
/* Confirm: Link existing */
/* ------------------------------------------------------------------ */
confirmLink.addEventListener('click', async () => {
if (!selectedChildAsset || !selectedChildType) return;
clearError();
showSpinner(true);
confirmLink.disabled = true;
try {
let resp, data;
if (selectedChildType.is_parent) {
// Set the parent FK on the current asset
resp = await fetch(`${BASE_URL}${PARENT_MODEL}/${PARENT_ID}/set-parent/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({
field_name: selectedChildType.field_name,
parent_model: selectedChildType.model_name,
parent_id: selectedChildAsset.id,
})
});
data = await resp.json();
if (data.success) {
bootstrap.Modal.getInstance(modal).hide();
updateParentRow(
selectedChildType.field_name, selectedChildType.model_name,
data.parent_id, data.parent_code, data.parent_name || '', data.parent_url
);
} else {
showError(data.error || 'Erreur lors de la liaison.');
confirmLink.disabled = false;
}
} else {
// Link child to this parent
resp = await fetch(`${BASE_URL}${PARENT_MODEL}/${PARENT_ID}/link-child/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({
child_model: selectedChildType.model_name,
child_id: selectedChildAsset.id,
field_on_child: selectedChildType.field_on_child,
})
});
data = await resp.json();
if (data.success) {
bootstrap.Modal.getInstance(modal).hide();
appendLinkedAssetRow(
data.child_model_name, data.field_on_child,
data.child_id, data.child_code, data.child_name || '', data.child_url
);
} else {
showError(data.error || 'Erreur lors de la liaison.');
confirmLink.disabled = false;
}
}
} catch(e) {
showError('Erreur réseau.');
confirmLink.disabled = false;
} finally {
showSpinner(false);
}
});
/* ------------------------------------------------------------------ */
/* Confirm: Create and link */
/* ------------------------------------------------------------------ */
confirmCreate.addEventListener('click', async () => {
if (!selectedChildType) return;
clearError();
const missing = validateChildForm();
if (missing.length > 0) {
showError('{% translate "Champs obligatoires manquants" %} : ' + missing.join(', '));
return;
}
showSpinner(true);
confirmCreate.disabled = true;
try {
const body = {
child_model: selectedChildType.model_name,
field_on_child: selectedChildType.field_on_child,
data: collectChildFormData(),
};
const resp = await fetch(`${BASE_URL}${PARENT_MODEL}/${PARENT_ID}/create-child/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify(body)
});
const data = await resp.json();
if (data.success) {
bootstrap.Modal.getInstance(modal).hide();
appendLinkedAssetRow(
data.child_model_name, data.field_on_child,
data.child_id, data.child_code, data.child_name || '', data.child_url
);
} else {
showError(data.error || 'Erreur lors de la création.');
confirmCreate.disabled = false;
}
} catch(e) {
showError('Erreur réseau.');
confirmCreate.disabled = false;
} finally {
showSpinner(false);
}
});
/* ------------------------------------------------------------------ */
/* Update parent row after set-parent */
/* ------------------------------------------------------------------ */
function updateParentRow(fieldName, parentModelName, parentId, parentCode, parentName, parentUrl) {
// Find the existing parent group card
const card = document.querySelector(
`.card[data-group-is-parent="true"][data-field-name="${fieldName}"]`
);
if (!card) return;
const ul = card.querySelector('ul.list-group');
if (!ul) return;
// Clear existing item(s) and add the new one
ul.innerHTML = '';
const codeHtml = parentUrl
? `<a href="${parentUrl}" class="fw-medium text-decoration-none">${parentCode}</a>`
: `<span class="fw-medium">${parentCode}</span>`;
const nameHtml = parentName ? `<small class="text-muted">${parentName}</small>` : '';
const li = document.createElement('li');
li.className = 'list-group-item py-2 px-3';
li.dataset.itemId = parentId;
li.dataset.itemModel = parentModelName;
li.dataset.itemField = fieldName;
li.innerHTML = `
<div class="d-flex align-items-start gap-2">
<div class="flex-grow-1">
<div>${codeHtml}</div>
${nameHtml}
</div>
</div>`;
ul.appendChild(li);
// Update count badge
const badge = card.querySelector('.badge.bg-secondary');
if (badge) badge.textContent = 1;
}
/* ------------------------------------------------------------------ */
/* Unlink child asset */
/* ------------------------------------------------------------------ */
document.addEventListener('click', async function(e) {
const btn = e.target.closest('[data-action="unlink-child-asset"]');
if (!btn) return;
if (!confirm('{% translate "Délier cet asset de son parent ?" %}')) return;
const childModel = btn.dataset.childModel;
const childId = btn.dataset.childId;
const fieldOnChild = btn.dataset.fieldOnChild;
const parentModel = btn.dataset.parentModel;
const parentId = btn.dataset.parentId;
try {
const resp = await fetch(`${BASE_URL}${parentModel}/${parentId}/unlink-child/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({
child_model: childModel,
child_id: parseInt(childId),
field_on_child: fieldOnChild,
})
});
const data = await resp.json();
if (data.success) {
removeLinkedAssetRow(childModel, childId);
} else {
alert(data.error || 'Erreur lors de la déliason.');
}
} catch(e) {
alert('Erreur réseau.');
}
});
/* Unlink parent asset */
document.addEventListener('click', async function(e) {
const btn = e.target.closest('[data-action="unlink-parent-asset"]');
if (!btn) return;
if (!confirm('{% translate "Délier cet asset de son parent ?" %}')) return;
const fieldName = btn.dataset.fieldName;
const parentModel = btn.dataset.parentModel;
const parentId = btn.dataset.parentId;
const childModel = btn.dataset.childModel;
const childId = btn.dataset.childId;
try {
const resp = await fetch(`${BASE_URL}${childModel}/${childId}/set-parent/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({
field_name: fieldName,
parent_model: parentModel,
parent_id: null,
})
});
const data = await resp.json();
if (data.success) {
removeLinkedParentRow(fieldName);
} else {
alert(data.error || 'Erreur lors de la déliason.');
}
} catch(e) {
alert('Erreur réseau.');
}
});
/* ------------------------------------------------------------------ */
/* Reset modal on close */
/* ------------------------------------------------------------------ */
if (modal) {
modal.addEventListener('hidden.bs.modal', resetModal);
}
})();
</script>
{% endif %}
{% if intersection_assets_geojson %}
{{ intersection_assets_geojson|json_script:"intersection-assets-geojson" }}
{% endif %}
{% if connection_lines_geojson %}
{{ connection_lines_geojson|json_script:"connection-lines-geojson" }}
{% endif %}
{% endblock content %}
{% block script %}
<!-- MapLibre GL 5.7.0 -->
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261010_order"></script>
<script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script>
<script src="{% static 'assets/asset_location_edit.js' %}"></script>
<!-- MapLibre map initialization for asset detail -->
<script src="{% static 'assets/asset_generic_map.js' %}?v=20261010_order"></script>
<!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>
{% endblock script %}
{% block extra_scripts %}
<!-- MapLibre GL 5.7.0 -->
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261010_order"></script>
<script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script>
<script src="{% static 'assets/asset_location_edit.js' %}"></script>
<!-- MapLibre map initialization for asset detail -->
<script src="{% static 'assets/asset_generic_map.js' %}?v=20261010_order"></script>
<!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>
{% endblock extra_scripts %}