feat: implement desktop view of inspection list view with filtering, export capabilities, and map integration
This commit is contained in:
parent
96492a9726
commit
0fc77df306
11 changed files with 3509 additions and 248 deletions
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-08-28 12:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0013_userconfig_exceptional_transport_can_edit_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userconfig',
|
||||||
|
name='inspection_list_filters',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Préférences de filtrage pour la vue liste des inspections.', verbose_name='Filtres de la liste des inspections'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userconfig',
|
||||||
|
name='inspection_list_sort',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Préférences de tri pour la vue liste des inspections.', verbose_name='Tri de la liste des inspections'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -229,6 +229,18 @@ class UserConfig(models.Model):
|
||||||
verbose_name="Tri de la liste des contrôles",
|
verbose_name="Tri de la liste des contrôles",
|
||||||
help_text="Préférences de tri pour la vue liste des contrôles.",
|
help_text="Préférences de tri pour la vue liste des contrôles.",
|
||||||
)
|
)
|
||||||
|
inspection_list_filters = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Filtres de la liste des inspections",
|
||||||
|
help_text="Préférences de filtrage pour la vue liste des inspections.",
|
||||||
|
)
|
||||||
|
inspection_list_sort = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Tri de la liste des inspections",
|
||||||
|
help_text="Préférences de tri pour la vue liste des inspections.",
|
||||||
|
)
|
||||||
ASSETS_LIST_LAYOUT_CHOICES = [
|
ASSETS_LIST_LAYOUT_CHOICES = [
|
||||||
('both', 'Tableau et carte'),
|
('both', 'Tableau et carte'),
|
||||||
('table_only', 'Tableau seul'),
|
('table_only', 'Tableau seul'),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ urlpatterns = [
|
||||||
path("save-intervention-list-preferences/", views.save_intervention_list_preferences, name="save_intervention_list_preferences"),
|
path("save-intervention-list-preferences/", views.save_intervention_list_preferences, name="save_intervention_list_preferences"),
|
||||||
path("save-assets-list-layout/", views.save_assets_list_layout, name="save_assets_list_layout"),
|
path("save-assets-list-layout/", views.save_assets_list_layout, name="save_assets_list_layout"),
|
||||||
path("save-observation-list-preferences/", views.save_observation_list_preferences, name="save_observation_list_preferences"),
|
path("save-observation-list-preferences/", views.save_observation_list_preferences, name="save_observation_list_preferences"),
|
||||||
|
path("save-inspection-list-preferences/", views.save_inspection_list_preferences, name="save_inspection_list_preferences"),
|
||||||
path("save-control-list-preferences/", views.save_control_list_preferences, name="save_control_list_preferences"),
|
path("save-control-list-preferences/", views.save_control_list_preferences, name="save_control_list_preferences"),
|
||||||
path("save-assets-index-preferences/", views.save_assets_index_preferences, name="save_assets_index_preferences"),
|
path("save-assets-index-preferences/", views.save_assets_index_preferences, name="save_assets_index_preferences"),
|
||||||
path("save-calendar-preferences/", views.save_calendar_preferences, name="save_calendar_preferences"),
|
path("save-calendar-preferences/", views.save_calendar_preferences, name="save_calendar_preferences"),
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,37 @@ def save_control_list_preferences(request):
|
||||||
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
|
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def save_inspection_list_preferences(request):
|
||||||
|
"""API endpoint to save the user's preferred filters and sort for the inspections list."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
filters = data.get('filters', {})
|
||||||
|
sort = data.get('sort', {})
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
return JsonResponse({'error': _('Données invalides')}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_config = request.user.config
|
||||||
|
|
||||||
|
if not isinstance(filters, dict):
|
||||||
|
return JsonResponse({'error': _('Format de filtres invalide')}, status=400)
|
||||||
|
|
||||||
|
if not isinstance(sort, dict):
|
||||||
|
return JsonResponse({'error': _('Format de tri invalide')}, status=400)
|
||||||
|
|
||||||
|
user_config.inspection_list_filters = filters
|
||||||
|
user_config.inspection_list_sort = sort
|
||||||
|
user_config.save(update_fields=['inspection_list_filters', 'inspection_list_sort'])
|
||||||
|
|
||||||
|
return JsonResponse({'success': True, 'filters': filters, 'sort': sort})
|
||||||
|
except UserConfig.DoesNotExist:
|
||||||
|
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def save_assets_index_preferences(request):
|
def save_assets_index_preferences(request):
|
||||||
|
|
|
||||||
199
loko/inspections/static/inspections/inspections_index.css
Normal file
199
loko/inspections/static/inspections/inspections_index.css
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
/* Layout helpers for Inspections desktop view */
|
||||||
|
.inspections-layout #inspections-table-container {
|
||||||
|
position: relative;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter-dropdown-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown {
|
||||||
|
min-width: min(640px, calc(100vw - 4rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspections-map {
|
||||||
|
height: calc(100vh - 210px);
|
||||||
|
min-height: 360px;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspections-map-filter {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 2.75rem;
|
||||||
|
z-index: 5;
|
||||||
|
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-scrollable {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Supprime le contour bleu Bootstrap au focus sur l'input de recherche */
|
||||||
|
#inspections-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour la modale de filtre */
|
||||||
|
#filterModal .modal-dialog {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
#filterModal .modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
#filterModal .modal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#filterModal .modal-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default badge styling */
|
||||||
|
.filter-option label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #e7f1ff;
|
||||||
|
color: #0c5cc9;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid #b8d4f1;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 140px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contextual color badges - inactive state */
|
||||||
|
.filter-option label.context-badge {
|
||||||
|
border-color: currentColor;
|
||||||
|
color: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option label.btn-outline-primary { border-color: #0c5cc9; color: #0c5cc9; }
|
||||||
|
.filter-option label.btn-outline-danger { border-color: #dc3545; color: #dc3545; }
|
||||||
|
.filter-option label.btn-outline-warning { border-color: #ffc107; color: #ffc107; }
|
||||||
|
.filter-option label.btn-outline-success { border-color: #28a745; color: #28a745; }
|
||||||
|
.filter-option label.btn-outline-info { border-color: #17a2b8; color: #17a2b8; }
|
||||||
|
.filter-option label.btn-outline-dark { border-color: #343a40; color: #343a40; }
|
||||||
|
.filter-option label.btn-outline-secondary { border-color: #6c757d; color: #6c757d; }
|
||||||
|
|
||||||
|
/* Default badges - checked state */
|
||||||
|
.filter-option input[type="checkbox"]:checked + label:not(.context-badge) {
|
||||||
|
background-color: #0c5cc9; color: white; border-color: #0c5cc9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contextual badges - checked state */
|
||||||
|
.filter-option input[type="checkbox"]:checked + label.context-badge { color: white; }
|
||||||
|
.filter-option input[type="checkbox"]:checked + label.btn-outline-primary { background-color: #0c5cc9; border-color: #0c5cc9; }
|
||||||
|
.filter-option input[type="checkbox"]:checked + label.btn-outline-danger { background-color: #dc3545; border-color: #dc3545; }
|
||||||
|
.filter-option input[type="checkbox"]:checked + label.btn-outline-warning { background-color: #ffc107; border-color: #ffc107; }
|
||||||
|
.filter-option input[type="checkbox"]:checked + label.btn-outline-success { background-color: #28a745; border-color: #28a745; }
|
||||||
|
.filter-option input[type="checkbox"]:checked + label.btn-outline-info { background-color: #17a2b8; border-color: #17a2b8; }
|
||||||
|
.filter-option input[type="checkbox"]:checked + label.btn-outline-dark { background-color: #343a40; border-color: #343a40; }
|
||||||
|
.filter-option input[type="checkbox"]:checked + label.btn-outline-secondary { background-color: #6c757d; border-color: #6c757d; }
|
||||||
|
|
||||||
|
/* Default badges - hover */
|
||||||
|
.filter-option input[type="checkbox"]:hover + label:not(.context-badge) { background-color: #cfe2f3; }
|
||||||
|
|
||||||
|
.filter-option.subcategory-option label {
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.filter-option-break {
|
||||||
|
flex-basis: 100%;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.filter-subcat-controls {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.filter-section-header label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.filter-section-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.filter-section-buttons button {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-option input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.inspections-layout #filter-dropdown-wrapper {
|
||||||
|
position: static;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspections-layout #inspections-table-container {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dropdown {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
1285
loko/inspections/static/inspections/inspections_index.js
Normal file
1285
loko/inspections/static/inspections/inspections_index.js
Normal file
File diff suppressed because it is too large
Load diff
399
loko/inspections/templates/inspections/inspections_index.html
Normal file
399
loko/inspections/templates/inspections/inspections_index.html
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% translate "Inspections d'assets" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="{% static 'inspections/inspections_index.css' %}">
|
||||||
|
{% endblock head %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- Modale de filtre des inspections -->
|
||||||
|
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les inspections" %}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form method="get" id="filter-form" class="mb-4">
|
||||||
|
|
||||||
|
<!-- Thématique -->
|
||||||
|
<div class="col-12 filter-section">
|
||||||
|
<div class="filter-section-header">
|
||||||
|
<label>{% translate "Thématique" %}</label>
|
||||||
|
<div class="filter-section-buttons">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="thematic">{% translate "Tous" %}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="thematic">{% translate "Aucun" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-options" data-field="thematic">
|
||||||
|
{% for key, value, icon in thematic_choices %}
|
||||||
|
<div class="filter-option">
|
||||||
|
<input type="checkbox" name="thematic" id="thematic_{{ key }}" value="{{ key }}" {% if key in selected_thematics %}checked{% endif %}>
|
||||||
|
<label for="thematic_{{ key }}">
|
||||||
|
{% if icon %}<i class="bi {{ icon }} me-1"></i>{% endif %}{{ value }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Catégorie / Type d'asset -->
|
||||||
|
<div class="col-12 filter-section">
|
||||||
|
<div class="filter-section-header">
|
||||||
|
<label>{% translate "Catégorie / Type d'asset" %}</label>
|
||||||
|
<div class="filter-section-buttons">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="category">{% translate "Tous" %}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="category">{% translate "Aucun" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-options" data-field="category">
|
||||||
|
{% for id, name, thematic_code, parent_id in category_choices %}
|
||||||
|
{% if parent_id %}{% ifchanged parent_id %}<div class="filter-option-break"></div>{% endifchanged %}{% endif %}
|
||||||
|
<div class="filter-option{% if parent_id %} subcategory-option{% endif %}" data-thematic="{{ thematic_code }}" data-category-id="{{ id }}" data-parent-id="{{ parent_id|default:'' }}">
|
||||||
|
<input type="checkbox" name="category" id="category_{{ id }}" value="{{ id }}" {% if id|stringformat:"s" in selected_categories %}checked{% endif %}>
|
||||||
|
<label for="category_{{ id }}">{% if parent_id %}<i class="bi bi-arrow-return-right me-1 text-muted small"></i>{% endif %}{{ name }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Résultat du contrôle -->
|
||||||
|
<div class="col-12 filter-section">
|
||||||
|
<div class="filter-section-header">
|
||||||
|
<label>{% translate "Résultat du contrôle" %}</label>
|
||||||
|
<div class="filter-section-buttons">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="result_status">{% translate "Tous" %}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="result_status">{% translate "Aucun" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-options" data-field="result_status">
|
||||||
|
{% for key, label, badge, color_hex, icon in result_status_choices %}
|
||||||
|
<div class="filter-option">
|
||||||
|
<input type="checkbox" name="result_status" id="result_status_{{ key }}" value="{{ key }}" {% if key in selected_result_statuses %}checked{% endif %}>
|
||||||
|
<label for="result_status_{{ key }}" class="context-badge btn-outline-{{ badge }}">
|
||||||
|
<i class="bi {{ icon }} me-1"></i>{{ label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nature -->
|
||||||
|
<div class="col-12 filter-section">
|
||||||
|
<div class="filter-section-header">
|
||||||
|
<label>{% translate "Nature" %}</label>
|
||||||
|
<div class="filter-section-buttons">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="nature">{% translate "Tous" %}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="nature">{% translate "Aucun" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-options" data-field="nature">
|
||||||
|
{% for key, label in nature_choices %}
|
||||||
|
<div class="filter-option">
|
||||||
|
<input type="checkbox" name="nature" id="nature_{{ key }}" value="{{ key }}" {% if key in selected_natures %}checked{% endif %}>
|
||||||
|
<label for="nature_{{ key }}">{{ label }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Période & Mes inspections -->
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">{% translate "Période" %}</label>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<input type="date" name="date_from" id="date_from" class="form-control" value="{{ date_from }}" title="{% translate 'Du' %}">
|
||||||
|
<span class="text-muted small">–</span>
|
||||||
|
<input type="date" name="date_to" id="date_to" class="form-control" value="{{ date_to }}" title="{% translate 'Au' %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check mt-4">
|
||||||
|
<input class="form-check-input" type="checkbox" name="mine" value="1" id="mine" {% if show_mine_only %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="mine">{% translate "Mes inspections" %}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" id="reset-filters" class="btn btn-secondary">{% translate "Réinitialiser" %}</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
|
||||||
|
<button type="button" id="apply-filters" class="btn btn-primary">{% translate "Filtrer" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="position-relative">
|
||||||
|
<!-- Layout selection buttons -->
|
||||||
|
<div class="position-absolute top-0 end-0 mt-2" style="z-index: 10;">
|
||||||
|
<div class="btn-group" role="group" aria-label="{% translate 'Sélection de la vue' %}">
|
||||||
|
<button id="layout-btn-table" class="btn btn-light" type="button" title="{% translate 'Vue tableau uniquement' %}">
|
||||||
|
<i class="bi bi-table"></i>
|
||||||
|
</button>
|
||||||
|
<button id="layout-btn-both" class="btn btn-light text-primary" type="button" title="{% translate 'Vue partagée' %}">
|
||||||
|
<i class="bi bi-layout-split"></i>
|
||||||
|
</button>
|
||||||
|
<button id="layout-btn-map" class="btn btn-light" type="button" title="{% translate 'Vue carte uniquement' %}">
|
||||||
|
<i class="bi bi-globe-europe-africa"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Widget de recherche — centré sur toute la largeur (tableau + carte) -->
|
||||||
|
<div id="inspections-search-widget"
|
||||||
|
style="position:absolute; top:4px; left:50%; transform:translateX(-50%); z-index:10; pointer-events:none; max-width:calc(100% - 360px);">
|
||||||
|
<div class="input-group" style="pointer-events:auto; flex-wrap:nowrap;">
|
||||||
|
<div style="position:relative; display:flex; width:360px; max-width:100%; min-width:60px;">
|
||||||
|
<input type="text" id="inspections-search-input" class="form-control form-control-sm"
|
||||||
|
style="width:100%; border-radius:20px 0 0 20px; border-color:#dee2e6; padding-right:1.6rem;"
|
||||||
|
placeholder="{% translate 'Cible, notes, inspecteur…' %}">
|
||||||
|
<button type="button" id="inspections-search-clear"
|
||||||
|
style="position:absolute; right:4px; top:50%; transform:translateY(-50%);
|
||||||
|
background:none; border:none; padding:0 2px; line-height:1;
|
||||||
|
color:#adb5bd; cursor:pointer; visibility:hidden;"
|
||||||
|
title="{% translate 'Effacer la recherche' %}">
|
||||||
|
<i class="bi bi-x-lg" style="font-size:0.7rem;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="input-group-text bg-white px-1" style="border-left:0; border-right:0; border-color:#dee2e6; flex-shrink:0;">
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="{% translate 'Mode' %}">
|
||||||
|
<input type="radio" class="btn-check" name="insp-search-mode" id="insp-search-mode-and" value="and" checked>
|
||||||
|
<label class="btn btn-outline-secondary py-0 px-2" for="insp-search-mode-and" title="{% translate 'Tous les mots doivent correspondre' %}">{% translate "AND" %}</label>
|
||||||
|
<input type="radio" class="btn-check" name="insp-search-mode" id="insp-search-mode-or" value="or">
|
||||||
|
<label class="btn btn-outline-secondary py-0 px-2" for="insp-search-mode-or" title="{% translate 'Au moins un mot doit correspondre' %}">{% translate "OR" %}</label>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-sm px-3" type="button" id="inspections-search-btn"
|
||||||
|
style="border-radius:0 20px 20px 0; border:1px solid #dee2e6; border-left:0; background:white; color:#495057; flex-shrink:0;"
|
||||||
|
title="{% translate 'Rechercher' %}">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-2 inspections-layout" style="height: calc(100vh - 130px);">
|
||||||
|
<div class="col-md-6" id="col-left" style="max-height: calc(100vh - 130px);">
|
||||||
|
<ul class="nav nav-tabs" id="tablesTab" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="inspections-table-tab" data-bs-toggle="tab" data-bs-target="#inspections-table-content" type="button" role="tab" aria-controls="inspections-table" aria-selected="true">{% translate "Inspections" %}</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<div class="my-1 ms-2">
|
||||||
|
<a href="{% url 'inspections:quick_inspection_map' %}" class="btn btn-light" title="{% translate 'Nouvelle inspection sur carte' %}">
|
||||||
|
<i class="bi bi-plus-circle"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content my-2" id="tablesTabContent">
|
||||||
|
<div class="tab-pane fade show active" id="inspections-table-content" role="tabpanel" aria-labelledby="inspections-table-tab">
|
||||||
|
<div class="d-flex justify-content-between flex-wrap align-items-center gap-2 mt-3">
|
||||||
|
<div class="text-muted small" id="inspections-result-count">
|
||||||
|
{% translate "Aucune inspection chargée" %}
|
||||||
|
</div>
|
||||||
|
<span id="filter-limit-alert" class="alert alert-info py-1 px-2 mb-0 d-none">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
{% translate "Contenu limité à 5 000 éléments." %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="inspections-spinner" class="table-spinner">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">{% translate "Chargement..." %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="inspections-table-container" class="d-none">
|
||||||
|
<div id="filter-dropdown-wrapper" class="d-flex align-items-center gap-2">
|
||||||
|
<button type="button" class="btn btn-light" data-bs-toggle="modal" data-bs-target="#filterModal">
|
||||||
|
{% translate "Filtre" %}
|
||||||
|
</button>
|
||||||
|
<a id="export-excel-btn" href="{% url 'inspections:inspections_export_excel' %}" class="btn btn-light" title="{% translate 'Exporter en Excel' %}">
|
||||||
|
<i class="bi bi-file-earmark-spreadsheet"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id="inspections-table" class="table"
|
||||||
|
data-height="550"
|
||||||
|
data-pagination="true"
|
||||||
|
data-page-size="50"
|
||||||
|
data-show-columns="true"
|
||||||
|
data-unique-id="id"
|
||||||
|
data-sort-name="inspection_date"
|
||||||
|
data-sort-order="desc">
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6" id="col-right">
|
||||||
|
<div id="map" class="rounded-2 inspections-map">
|
||||||
|
<button id="filterzone" class="btn btn-light btn-sm inspections-map-filter" type="button">
|
||||||
|
{% translate "Filtrer sur cette zone" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Détail d'une Inspection -->
|
||||||
|
<div class="modal fade" id="inspectionDetailModal" tabindex="-1" aria-labelledby="inspectionDetailModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header bg-light">
|
||||||
|
<h5 class="modal-title fw-bold" id="inspectionDetailModalLabel">
|
||||||
|
<i class="bi bi-search text-primary me-2" id="detail-modal-icon"></i>
|
||||||
|
<span id="detail-modal-title">{% translate "Détail de l'inspection" %}</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-3" id="detail-modal-body">
|
||||||
|
<div class="text-center py-4" id="detail-modal-spinner">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">{% translate "Chargement..." %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="detail-modal-content" class="d-none">
|
||||||
|
<div id="detail-cancelled-alert" class="alert alert-secondary py-2 px-3 small d-flex align-items-center mb-3 d-none">
|
||||||
|
<i class="bi bi-slash-circle me-2 fs-6"></i>
|
||||||
|
<div>
|
||||||
|
<strong>{% translate "Inspection annulée" %}</strong> — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="detail-asset-btn-container" class="mb-3 d-none">
|
||||||
|
<a href="#" id="detail-asset-link" class="btn btn-primary w-100 fw-semibold">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group list-group-flush mb-3">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Cible" %}</span>
|
||||||
|
<span class="fw-bold text-end ms-2" id="detail-target-display">-</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
|
||||||
|
<span class="badge bg-secondary" id="detail-asset-type">-</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
|
||||||
|
<span class="fw-semibold" id="detail-date">-</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-shield-check me-1"></i>{% translate "Résultat du contrôle" %}</span>
|
||||||
|
<span class="badge" id="detail-result-badge">-</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-eye me-1"></i>{% translate "Nature" %}</span>
|
||||||
|
<span class="badge bg-light text-dark border" id="detail-nature">-</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
|
||||||
|
<span class="fw-semibold" id="detail-inspector">-</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
|
||||||
|
<span class="fw-semibold" id="detail-updated-at">-</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Contenu spécifique de l'inspection (partial HTML) -->
|
||||||
|
<div id="detail-specific-content" class="mt-3"></div>
|
||||||
|
|
||||||
|
<!-- Remarques -->
|
||||||
|
<div id="detail-notes-card" class="card bg-light border-0 p-3 mb-3 d-none">
|
||||||
|
<h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6>
|
||||||
|
<div class="text-dark small" id="detail-notes-text"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Obsolescence -->
|
||||||
|
<div id="detail-obsolescence-card" class="card bg-warning-subtle border-0 p-3 mb-3 d-none">
|
||||||
|
<h6 class="fw-bold text-warning-emphasis mb-1"><i class="bi bi-exclamation-triangle me-2"></i>{% translate "Obsolescence" %}</h6>
|
||||||
|
<div class="text-warning-emphasis small" id="detail-obsolescence-text"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos et Documents -->
|
||||||
|
<div id="detail-documents-card" class="card bg-light border-0 p-3 mb-3 d-none">
|
||||||
|
<h6 class="fw-bold text-secondary mb-2">
|
||||||
|
<i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} <span id="detail-documents-count"></span>
|
||||||
|
</h6>
|
||||||
|
<div id="detail-carousel-container" class="carousel slide rounded overflow-hidden shadow-sm bg-black mb-3 d-none" data-bs-ride="false">
|
||||||
|
<div class="carousel-indicators" id="detail-carousel-indicators"></div>
|
||||||
|
<div class="carousel-inner" id="detail-carousel-inner" style="max-height: 300px; min-height: 180px;"></div>
|
||||||
|
<button class="carousel-control-prev" type="button" data-bs-target="#detail-carousel-container" data-bs-slide="prev">
|
||||||
|
<span class="carousel-control-prev-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Précédent</span>
|
||||||
|
</button>
|
||||||
|
<button class="carousel-control-next" type="button" data-bs-target="#detail-carousel-container" data-bs-slide="next">
|
||||||
|
<span class="carousel-control-next-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Suivant</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2" id="detail-non-image-docs"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-light py-2">
|
||||||
|
<div id="detail-edit-actions" class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm fw-semibold d-none" id="detail-cancel-btn">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm fw-semibold d-none" id="detail-restore-btn">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir l'inspection" %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm fw-semibold d-none" id="detail-edit-btn">
|
||||||
|
<i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay Carrousel Plein Écran pour Photos -->
|
||||||
|
<div id="fullscreenPhotoOverlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-black" style="z-index: 999999;">
|
||||||
|
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3 fs-4 shadow p-2" id="btn-close-photo-overlay" style="z-index: 1000005; cursor: pointer;" aria-label="Fermer"></button>
|
||||||
|
<div id="fullscreenPhotoCarousel" class="carousel slide w-100 h-100" data-bs-ride="false">
|
||||||
|
<div class="carousel-indicators mb-3" id="fullscreen-carousel-indicators" style="z-index: 1000001;"></div>
|
||||||
|
<div class="carousel-inner w-100 h-100" id="fullscreen-carousel-inner"></div>
|
||||||
|
<button class="carousel-control-prev" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="prev" style="z-index: 1000000;">
|
||||||
|
<span class="carousel-control-prev-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Précédent</span>
|
||||||
|
</button>
|
||||||
|
<button class="carousel-control-next" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="next" style="z-index: 1000000;">
|
||||||
|
<span class="carousel-control-next-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Suivant</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "inspections/components/edit_inspection_modal.html" %}
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
const inspectionsGeojsonUrl = "{% url 'inspections:inspections_geojson_get' %}";
|
||||||
|
const inspectionsIndexUrl = "{% url 'inspections:inspections_list' %}";
|
||||||
|
const inspectionsExportExcelUrl = "{% url 'inspections:inspections_export_excel' %}";
|
||||||
|
const savePreferencesUrl = "{% url 'common:save_inspection_list_preferences' %}";
|
||||||
|
const SAVED_SORT = {{ saved_sort|default:"{}"|safe }};
|
||||||
|
const subcatNoneLabel = "{% translate '(aucune sous-catégorie)' %}";
|
||||||
|
</script>
|
||||||
|
<script src="{% url 'javascript-catalog' %}"></script>
|
||||||
|
<script src="{% static 'common/base_script.js' %}"></script>
|
||||||
|
<script src="{% static 'common/base_map.js' %}"></script>
|
||||||
|
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
||||||
|
<script src="{% static 'inspections/inspections_index.js' %}"></script>
|
||||||
|
{% endblock script %}
|
||||||
|
|
@ -0,0 +1,751 @@
|
||||||
|
{% extends base_template|default:"base.html" %}
|
||||||
|
{% load i18n static l10n short_name inspections_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate "Inspections d'assets" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.inspection-card.is-cancelled {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
border: 1px dashed #adb5bd !important;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.inspection-card.is-cancelled .card-title,
|
||||||
|
.inspection-card.is-cancelled h6 {
|
||||||
|
color: #6c757d !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-0 fw-bold">
|
||||||
|
<i class="bi bi-search text-primary me-2"></i>{% translate "Inspections" %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted mb-0 small">{% translate "Suivi et historique des inspections et vérifications sur le terrain" %}</p>
|
||||||
|
</div>
|
||||||
|
{% if can_inspect %}
|
||||||
|
<div>
|
||||||
|
<a href="{% if is_mobile %}{% url 'mobile:quick_inspection_map_mobile' %}{% else %}{% url 'inspections:quick_inspection_map' %}{% endif %}" class="btn btn-primary shadow-sm">
|
||||||
|
<i class="bi bi-geo-alt-fill me-1"></i>{% translate "Nouvelle inspection" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation par Onglets -->
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if filter_type == 'my' %}active fw-bold{% endif %}" href="?tab=my">
|
||||||
|
<i class="bi bi-person-check me-1"></i>{% translate "Mes inspections" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if filter_type == 'all' %}active fw-bold{% endif %}" href="?tab=all">
|
||||||
|
<i class="bi bi-collection me-1"></i>{% translate "Toutes les inspections" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Barre de filtres -->
|
||||||
|
<div class="card mb-3 shadow-sm border-0 bg-light">
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<form method="get" class="row g-2 align-items-center">
|
||||||
|
<input type="hidden" name="tab" value="{{ filter_type }}">
|
||||||
|
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" name="q" value="{{ search_q }}" class="form-control" placeholder="{% translate 'Rechercher par note, asset, ...' %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-sm-6 col-md-2">
|
||||||
|
<select name="thematic" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">{% translate "-- Thématiques --" %}</option>
|
||||||
|
{% for t in thematics %}
|
||||||
|
<option value="{{ t.code }}" {% if selected_thematic == t.code %}selected{% endif %}>{{ t.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-sm-6 col-md-2">
|
||||||
|
<select name="nature" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">{% translate "-- Natures --" %}</option>
|
||||||
|
{% for val, label in nature_choices %}
|
||||||
|
<option value="{{ val }}" {% if selected_nature == val %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-sm-6 col-md-3">
|
||||||
|
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">{% translate "-- Tous les résultats --" %}</option>
|
||||||
|
{% for val, label in result_choices %}
|
||||||
|
<option value="{{ val }}" {% if selected_status == val %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-sm-6 col-md-2 d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-sm btn-secondary flex-grow-1">{% translate "Filtrer" %}</button>
|
||||||
|
<a href="?tab={{ filter_type }}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Réinitialiser' %}"><i class="bi bi-x-circle"></i></a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton Flottant (FAB) Carte des résultats -->
|
||||||
|
<button type="button" class="btn btn-primary rounded-circle shadow-lg position-fixed bottom-0 end-0 m-4 d-flex align-items-center justify-content-center"
|
||||||
|
style="width: 56px; height: 56px; z-index: 1050; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease;"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#inspectionsMapModal"
|
||||||
|
id="btn-open-inspections-map-fab"
|
||||||
|
title="{% translate 'Carte des résultats' %}"
|
||||||
|
aria-label="{% translate 'Carte des résultats' %}">
|
||||||
|
<i class="bi bi-map fs-4"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if inspections %}
|
||||||
|
<!-- Liste des inspections sous forme de Cards -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span class="fw-semibold text-muted small">
|
||||||
|
{% blocktranslate count counter=total_count %}{{ counter }} inspection trouvée{% plural %}{{ counter }} inspections trouvées{% endblocktranslate %}
|
||||||
|
<span class="fst-italic text-secondary">({% translate "limité aux 100 plus récentes" %})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
{% for insp in inspections %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow-sm border-0 h-100 inspection-card cursor-pointer {% if insp.is_cancelled %}is-cancelled{% endif %}"
|
||||||
|
style="cursor: pointer; transition: transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out;"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#inspectionDetailModal{{ insp.id }}">
|
||||||
|
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||||
|
<div>
|
||||||
|
<!-- Entête card : Thématique/Type + Badge résultat -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="badge bg-light text-dark border d-inline-flex align-items-center me-2">
|
||||||
|
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>
|
||||||
|
{{ insp.asset_content_type.name }}
|
||||||
|
</span>
|
||||||
|
<span class="badge {{ insp.status_badge_class }}">
|
||||||
|
<i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nom de la cible tronqué à 100 caractères -->
|
||||||
|
<h6 class="fw-bold text-dark mb-1 text-break">
|
||||||
|
{{ insp.target_display|truncatechars:100 }}
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<!-- Résumé des constats si présent -->
|
||||||
|
{% if insp.details_summary and insp.details_summary != "-" %}
|
||||||
|
<p class="text-muted small mb-2 text-truncate mt-1">
|
||||||
|
<i class="bi bi-ui-checks me-1 text-secondary"></i>{{ insp.details_summary }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ligne de pied card : Date & Inspecteur -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center pt-2 border-top mt-2 text-muted extra-small">
|
||||||
|
<span><i class="bi bi-clock me-1"></i>{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if insp.documents.all %}
|
||||||
|
<span class="badge bg-light text-secondary border" title="{% translate 'Photos / Documents joints' %}">
|
||||||
|
<i class="bi bi-paperclip me-1"></i>{{ insp.documents.count }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if insp.inspector %}
|
||||||
|
<span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name|default:insp.inspector.username }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de détail de l'inspection -->
|
||||||
|
<div class="modal fade" id="inspectionDetailModal{{ insp.id }}" tabindex="-1" aria-labelledby="inspectionDetailModalLabel{{ insp.id }}" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header bg-light">
|
||||||
|
<h5 class="modal-title fw-bold" id="inspectionDetailModalLabel{{ insp.id }}">
|
||||||
|
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>{% translate "Détail de l'inspection" %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-3">
|
||||||
|
{% if insp.is_cancelled %}
|
||||||
|
<div class="alert alert-secondary py-2 px-3 small d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-slash-circle me-2 fs-6"></i>
|
||||||
|
<div>
|
||||||
|
<strong>{% translate "Inspection annulée" %}</strong> — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Lien vers la fiche de l'asset -->
|
||||||
|
{% if insp.asset %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{% if is_mobile %}{{ insp.mobile_asset_url }}{% else %}{{ insp.asset.get_absolute_url }}{% endif %}" class="btn btn-primary w-100 fw-semibold">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<ul class="list-group list-group-flush mb-3">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Cible" %}</span>
|
||||||
|
<span class="fw-bold text-end ms-2">{{ insp.target_display }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
|
||||||
|
<span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
|
||||||
|
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-shield-check me-1"></i>{% translate "Résultat du contrôle" %}</span>
|
||||||
|
<span class="badge {{ insp.status_badge_class }}"><i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-eye me-1"></i>{% translate "Nature" %}</span>
|
||||||
|
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
|
||||||
|
<span class="fw-semibold">{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
|
||||||
|
<span class="fw-semibold">{{ insp.updated_at|date:"d/m/Y H:i" }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% get_inspection_detail_template insp as detail_template %}
|
||||||
|
{% if detail_template %}
|
||||||
|
<div class="mt-3">
|
||||||
|
{% include detail_template with insp=insp %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if insp.notes %}
|
||||||
|
<div class="card bg-light border-0 p-3 mb-3">
|
||||||
|
<h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6>
|
||||||
|
<div class="text-dark small">{{ insp.notes }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if insp.verified_obsolescence %}
|
||||||
|
<div class="card bg-warning-subtle border-0 p-3 mb-3">
|
||||||
|
<h6 class="fw-bold text-warning-emphasis mb-1"><i class="bi bi-exclamation-triangle me-2"></i>{% translate "Obsolescence" %}</h6>
|
||||||
|
<div class="text-warning-emphasis small">{{ insp.verified_obsolescence }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if insp.documents.exists %}
|
||||||
|
<div class="card bg-light border-0 p-3 mb-3">
|
||||||
|
<h6 class="fw-bold text-secondary mb-2">
|
||||||
|
<i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }})
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
{% with docs=insp.documents.all %}
|
||||||
|
<!-- Carrousel Photos Bootstrap -->
|
||||||
|
<div id="carouselInsp{{ insp.id }}" class="carousel slide rounded overflow-hidden shadow-sm bg-black mb-3" data-bs-ride="false">
|
||||||
|
<div class="carousel-indicators">
|
||||||
|
{% for doc in docs %}
|
||||||
|
{% if doc.is_image %}
|
||||||
|
<button type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide-to="{{ forloop.counter0 }}" {% if forloop.first %}class="active" aria-current="true"{% endif %} aria-label="Slide {{ forloop.counter }}"></button>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="carousel-inner" style="max-height: 300px; min-height: 180px;">
|
||||||
|
{% for doc in docs %}
|
||||||
|
{% if doc.is_image %}
|
||||||
|
<div class="carousel-item {% if forloop.first %}active{% endif %} h-100">
|
||||||
|
<div class="d-flex align-items-center justify-content-center bg-black position-relative" style="height: 260px;">
|
||||||
|
<img data-src="{{ doc.file_url }}" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3C/svg%3E" loading="lazy" class="d-block img-fluid mh-100 mw-100 list-insp-carousel-trigger" style="object-fit: contain; max-height: 260px; cursor: pointer;" data-insp-id="{{ insp.id }}" data-img-index="{{ forloop.counter0 }}" alt="{{ doc.filename }}">
|
||||||
|
<button type="button" class="btn btn-sm btn-dark bg-opacity-75 text-white rounded-circle p-1.5 shadow list-insp-carousel-trigger position-absolute bottom-0 end-0 m-2" style="z-index: 10; cursor: pointer;" data-insp-id="{{ insp.id }}" data-img-index="{{ forloop.counter0 }}" title="Plein écran">
|
||||||
|
<i class="bi bi-arrows-fullscreen"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="carousel-control-prev" type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide="prev">
|
||||||
|
<span class="carousel-control-prev-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Précédent</span>
|
||||||
|
</button>
|
||||||
|
<button class="carousel-control-next" type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide="next">
|
||||||
|
<span class="carousel-control-next-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Suivant</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents non-images -->
|
||||||
|
<div class="flex-wrap gap-2 d-flex">
|
||||||
|
{% for doc in docs %}
|
||||||
|
{% if not doc.is_image %}
|
||||||
|
<a href="{{ doc.file_url }}" target="_blank" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1">
|
||||||
|
<i class="bi bi-file-earmark-arrow-down text-primary"></i>
|
||||||
|
<span class="text-truncate" style="max-width: 160px;">{{ doc.filename }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-light py-2">
|
||||||
|
{% can_edit_inspection insp request.user as user_can_edit %}
|
||||||
|
{% if user_can_edit %}
|
||||||
|
{% if insp.is_cancelled %}
|
||||||
|
<button type="button" class="btn btn-outline-success btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'restore')">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir l'inspection" %}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'cancel')">
|
||||||
|
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm fw-semibold" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal">
|
||||||
|
<i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card shadow-sm border-0 p-5 text-center text-muted">
|
||||||
|
<i class="bi bi-inbox fs-1 d-block mb-3 text-secondary"></i>
|
||||||
|
<h5>{% translate "Aucune inspection trouvée" %}</h5>
|
||||||
|
<p class="small text-muted mb-0">{% translate "Aucune inspection ne correspond à vos critères de recherche." %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay Carrousel Plein Écran pour Photos (avec bouton fermer) -->
|
||||||
|
<div id="fullscreenPhotoOverlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-black" style="z-index: 999999;">
|
||||||
|
<!-- Bouton Fermer -->
|
||||||
|
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3 fs-4 shadow p-2" id="btn-close-photo-overlay" style="z-index: 1000005; cursor: pointer;" aria-label="Fermer"></button>
|
||||||
|
|
||||||
|
<!-- Conteneur Carrousel -->
|
||||||
|
<div id="fullscreenPhotoCarousel" class="carousel slide w-100 h-100" data-bs-ride="false">
|
||||||
|
<div class="carousel-indicators mb-3" id="fullscreen-carousel-indicators" style="z-index: 1000001;"></div>
|
||||||
|
<div class="carousel-inner w-100 h-100" id="fullscreen-carousel-inner"></div>
|
||||||
|
<button class="carousel-control-prev" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="prev" style="z-index: 1000000;">
|
||||||
|
<span class="carousel-control-prev-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Précédent</span>
|
||||||
|
</button>
|
||||||
|
<button class="carousel-control-next" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="next" style="z-index: 1000000;">
|
||||||
|
<span class="carousel-control-next-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Suivant</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const listTriggers = document.querySelectorAll('.list-insp-carousel-trigger');
|
||||||
|
listTriggers.forEach(trig => {
|
||||||
|
trig.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const inspId = this.getAttribute('data-insp-id');
|
||||||
|
const startIndex = parseInt(this.getAttribute('data-img-index'), 10) || 0;
|
||||||
|
const carouselEl = document.getElementById('carouselInsp' + inspId);
|
||||||
|
if (!carouselEl) return;
|
||||||
|
|
||||||
|
const imgs = carouselEl.querySelectorAll('img.list-insp-carousel-trigger');
|
||||||
|
const imagesData = [];
|
||||||
|
imgs.forEach(img => {
|
||||||
|
imagesData.push({
|
||||||
|
url: img.getAttribute('src'),
|
||||||
|
filename: img.getAttribute('alt') || 'Photo'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const overlay = document.getElementById('fullscreenPhotoOverlay');
|
||||||
|
const fsInner = document.getElementById('fullscreen-carousel-inner');
|
||||||
|
const fsIndicators = document.getElementById('fullscreen-carousel-indicators');
|
||||||
|
if (!overlay || !fsInner) return;
|
||||||
|
|
||||||
|
fsInner.innerHTML = '';
|
||||||
|
if (fsIndicators) fsIndicators.innerHTML = '';
|
||||||
|
|
||||||
|
imagesData.forEach((doc, idx) => {
|
||||||
|
const activeClass = idx === startIndex ? 'active' : '';
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = `carousel-item ${activeClass} w-100 h-100`;
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3">
|
||||||
|
<img src="${doc.url}" class="img-fluid mh-100 mw-100 shadow-lg" style="object-fit: contain; max-height: 85vh;" alt="${doc.filename}">
|
||||||
|
<div class="text-white small mt-2 bg-dark bg-opacity-75 px-3 py-1 rounded-pill">${doc.filename}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
fsInner.appendChild(item);
|
||||||
|
|
||||||
|
if (fsIndicators && imagesData.length > 1) {
|
||||||
|
const ind = document.createElement('button');
|
||||||
|
ind.type = 'button';
|
||||||
|
ind.setAttribute('data-bs-target', '#fullscreenPhotoCarousel');
|
||||||
|
ind.setAttribute('data-bs-slide-to', idx.toString());
|
||||||
|
if (idx === startIndex) {
|
||||||
|
ind.className = 'active';
|
||||||
|
ind.setAttribute('aria-current', 'true');
|
||||||
|
}
|
||||||
|
ind.setAttribute('aria-label', `Photo ${idx + 1}`);
|
||||||
|
fsIndicators.appendChild(ind);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.classList.remove('d-none');
|
||||||
|
|
||||||
|
const btnClose = document.getElementById('btn-close-photo-overlay');
|
||||||
|
if (btnClose) {
|
||||||
|
const closeHandler = function(e) {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
overlay.classList.add('d-none');
|
||||||
|
btnClose.removeEventListener('click', closeHandler);
|
||||||
|
};
|
||||||
|
btnClose.addEventListener('click', closeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const escHandler = function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
overlay.classList.add('d-none');
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<!-- Modale Carte des Inspections filtrées -->
|
||||||
|
<div class="modal fade" id="inspectionsMapModal" tabindex="-1" aria-labelledby="inspectionsMapModalLabel" aria-hidden="true" style="z-index: 1070;">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-centered mx-auto" style="width: 92vw; max-width: 1200px;">
|
||||||
|
<div class="modal-content shadow-lg border-0 rounded-3">
|
||||||
|
<div class="modal-header bg-dark text-white py-2.5">
|
||||||
|
<h5 class="modal-title fs-6 fw-bold d-flex align-items-center gap-2" id="inspectionsMapModalLabel">
|
||||||
|
<i class="bi bi-map text-info fs-5"></i>
|
||||||
|
{% translate "Carte des résultats d'inspections" %}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0 position-relative">
|
||||||
|
<div id="inspections-list-modal-map" style="width: 100%; height: 75vh; border-radius: 0 0 0.5rem 0.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'common/maplibre-gl.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const modalEl = document.getElementById('inspectionsMapModal');
|
||||||
|
if (!modalEl) return;
|
||||||
|
|
||||||
|
let mapModalInstance = null;
|
||||||
|
|
||||||
|
const rawInspectionsData = [
|
||||||
|
{% for insp in inspections %}
|
||||||
|
{% if insp.lon_lat %}
|
||||||
|
{
|
||||||
|
id: {{ insp.id }},
|
||||||
|
lon: {{ insp.lon_lat.0|unlocalize }},
|
||||||
|
lat: {{ insp.lon_lat.1|unlocalize }},
|
||||||
|
title: "{{ insp.target_display|escapejs }}",
|
||||||
|
date: "{{ insp.inspection_date|date:'d/m/Y H:i' }}",
|
||||||
|
date_raw: "{{ insp.inspection_date|date:'Y-m-d H:i:s' }}",
|
||||||
|
result: "{{ insp.get_result_status_display|escapejs }}",
|
||||||
|
result_code: "{{ insp.result_status|escapejs }}",
|
||||||
|
badge_class: "{{ insp.status_badge_class|escapejs }}",
|
||||||
|
inspector: "{{ insp.inspector|short_name|default:insp.inspector.username|escapejs }}",
|
||||||
|
nature: "{{ insp.get_nature_display|escapejs }}",
|
||||||
|
summary: "{{ insp.details_summary|escapejs }}"
|
||||||
|
}{% if not forloop.last %},{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Regroupement des inspections par emplacement (lon, lat)
|
||||||
|
const groupedMap = {};
|
||||||
|
rawInspectionsData.forEach(item => {
|
||||||
|
const key = item.lon.toFixed(6) + '_' + item.lat.toFixed(6);
|
||||||
|
if (!groupedMap[key]) {
|
||||||
|
groupedMap[key] = {
|
||||||
|
lon: item.lon,
|
||||||
|
lat: item.lat,
|
||||||
|
title: item.title,
|
||||||
|
inspections: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
groupedMap[key].inspections.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedList = Object.values(groupedMap);
|
||||||
|
groupedList.forEach(grp => {
|
||||||
|
grp.inspections.sort((a, b) => b.date_raw.localeCompare(a.date_raw));
|
||||||
|
grp.latest_result_code = grp.inspections[0].result_code;
|
||||||
|
grp.inspections_count = grp.inspections.length;
|
||||||
|
grp.inspections_json = JSON.stringify(grp.inspections);
|
||||||
|
});
|
||||||
|
|
||||||
|
modalEl.addEventListener('shown.bs.modal', function() {
|
||||||
|
if (!mapModalInstance) {
|
||||||
|
mapModalInstance = new maplibregl.Map({
|
||||||
|
container: 'inspections-list-modal-map',
|
||||||
|
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
||||||
|
center: [4.35, 50.85],
|
||||||
|
zoom: 12
|
||||||
|
});
|
||||||
|
|
||||||
|
mapModalInstance.addControl(new maplibregl.NavigationControl(), 'bottom-right');
|
||||||
|
|
||||||
|
mapModalInstance.on('load', function() {
|
||||||
|
const geojson = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: groupedList.map(m => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [m.lon, m.lat]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
title: m.title,
|
||||||
|
latest_result_code: m.latest_result_code,
|
||||||
|
inspections_count: m.inspections_count,
|
||||||
|
inspections_json: m.inspections_json
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
mapModalInstance.addSource('inspections-list-modal-source', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: geojson
|
||||||
|
});
|
||||||
|
|
||||||
|
mapModalInstance.addLayer({
|
||||||
|
id: 'inspections-list-modal-layer',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'inspections-list-modal-source',
|
||||||
|
paint: {
|
||||||
|
'circle-color': [
|
||||||
|
'match',
|
||||||
|
['get', 'latest_result_code'],
|
||||||
|
'compliant', '#198754',
|
||||||
|
'non_compliant', '#ffc107',
|
||||||
|
'damaged', '#dc3545',
|
||||||
|
'missing', '#212529',
|
||||||
|
'#0d6efd'
|
||||||
|
],
|
||||||
|
'circle-radius': 8,
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mapModalInstance.on('mouseenter', 'inspections-list-modal-layer', function() {
|
||||||
|
mapModalInstance.getCanvas().style.cursor = 'pointer';
|
||||||
|
});
|
||||||
|
mapModalInstance.on('mouseleave', 'inspections-list-modal-layer', function() {
|
||||||
|
mapModalInstance.getCanvas().style.cursor = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
mapModalInstance.on('click', 'inspections-list-modal-layer', function(e) {
|
||||||
|
if (!e.features || !e.features.length) return;
|
||||||
|
|
||||||
|
const clickPoint = e.point;
|
||||||
|
const rendered = mapModalInstance.queryRenderedFeatures(clickPoint, { layers: ['inspections-list-modal-layer'] });
|
||||||
|
|
||||||
|
let combinedInspections = [];
|
||||||
|
let popupTitle = '';
|
||||||
|
rendered.forEach(f => {
|
||||||
|
try {
|
||||||
|
const insps = JSON.parse(f.properties.inspections_json || '[]');
|
||||||
|
if (!popupTitle && f.properties.title) popupTitle = f.properties.title;
|
||||||
|
combinedInspections = combinedInspections.concat(insps);
|
||||||
|
} catch (err) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Déduplication & tri par date décroissante
|
||||||
|
const dedup = {};
|
||||||
|
combinedInspections.forEach(item => { dedup[item.id] = item; });
|
||||||
|
const sortedInspections = Object.values(dedup).sort((a, b) => b.date_raw.localeCompare(a.date_raw));
|
||||||
|
|
||||||
|
if (!sortedInspections.length) return;
|
||||||
|
|
||||||
|
const coords = e.features[0].geometry.coordinates.slice();
|
||||||
|
let contentHtml = '';
|
||||||
|
|
||||||
|
if (sortedInspections.length === 1) {
|
||||||
|
const insp = sortedInspections[0];
|
||||||
|
contentHtml = `
|
||||||
|
<div class="p-1" style="min-width: 240px;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1 gap-2">
|
||||||
|
<span class="badge ${insp.badge_class}">${insp.result}</span>
|
||||||
|
<small class="text-muted">${insp.date}</small>
|
||||||
|
</div>
|
||||||
|
<h6 class="fw-bold mb-1 text-dark">${insp.title}</h6>
|
||||||
|
<div class="small text-muted mb-1"><i class="bi bi-person me-1"></i>${insp.inspector}</div>
|
||||||
|
<p class="small text-secondary mb-2">${insp.summary}</p>
|
||||||
|
<button type="button" class="btn btn-xs btn-outline-primary w-100 py-1 open-detail-from-modal" data-insp-id="${insp.id}">
|
||||||
|
<i class="bi bi-eye me-1"></i>Détail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
let rowsHtml = '';
|
||||||
|
sortedInspections.forEach(insp => {
|
||||||
|
rowsHtml += `
|
||||||
|
<div class="p-2 border-bottom d-flex align-items-center justify-content-between gap-2">
|
||||||
|
<div class="min-w-0 flex-grow-1">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-1 mb-1">
|
||||||
|
<span class="badge ${insp.badge_class} text-truncate flex-shrink-0" style="font-size: 0.7rem; max-width: 140px;">${insp.result}</span>
|
||||||
|
<span class="fw-semibold text-primary text-nowrap insp-date-modal-link open-detail-from-modal" style="cursor: pointer; font-size: 0.78rem;" data-insp-id="${insp.id}">
|
||||||
|
📅 ${insp.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted text-truncate" style="max-width: 220px;"><i class="bi bi-person me-1"></i>${insp.inspector}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary px-2 py-1 flex-shrink-0 open-detail-from-modal ms-1" data-insp-id="${insp.id}" style="font-size: 0.75rem; border-radius: 0.375rem;" title="Voir l'inspection">
|
||||||
|
<i class="bi bi-eye me-1"></i>Détail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
contentHtml = `
|
||||||
|
<div class="p-1" style="min-width: 320px; max-width: 360px;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2 pb-1 border-bottom">
|
||||||
|
<h6 class="fw-bold mb-0 text-dark text-truncate" style="max-width: 230px;">${popupTitle || 'Équipement'}</h6>
|
||||||
|
<span class="badge bg-secondary rounded-pill small">${sortedInspections.length} inspections</span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto" style="max-height: 240px;">
|
||||||
|
${rowsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
new maplibregl.Popup({ closeButton: true, maxWidth: '380px' })
|
||||||
|
.setLngLat(coords)
|
||||||
|
.setHTML(contentHtml)
|
||||||
|
.addTo(mapModalInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (groupedList.length > 0) {
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
groupedList.forEach(m => bounds.extend([m.lon, m.lat]));
|
||||||
|
mapModalInstance.fitBounds(bounds, { padding: 50, maxZoom: 17 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mapModalInstance.resize();
|
||||||
|
if (groupedList.length > 0) {
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
groupedList.forEach(m => bounds.extend([m.lon, m.lat]));
|
||||||
|
mapModalInstance.fitBounds(bounds, { padding: 50, maxZoom: 17 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('.open-detail-from-modal');
|
||||||
|
if (btn) {
|
||||||
|
const inspId = btn.getAttribute('data-insp-id');
|
||||||
|
const bsMapModal = bootstrap.Modal.getInstance(modalEl);
|
||||||
|
if (bsMapModal) bsMapModal.hide();
|
||||||
|
const detailModalEl = document.getElementById('inspectionDetailModal' + inspId);
|
||||||
|
if (detailModalEl) {
|
||||||
|
detailModalEl.setAttribute('data-opened-from-map', 'true');
|
||||||
|
setTimeout(() => {
|
||||||
|
const bsDetailModal = bootstrap.Modal.getInstance(detailModalEl) || new bootstrap.Modal(detailModalEl);
|
||||||
|
bsDetailModal.show();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('show.bs.modal', function(e) {
|
||||||
|
const modal = e.target;
|
||||||
|
if (modal) {
|
||||||
|
modal.querySelectorAll('img[data-src]').forEach(function(img) {
|
||||||
|
const dataSrc = img.getAttribute('data-src');
|
||||||
|
if (dataSrc && (!img.src || img.src.indexOf('data:image') !== -1)) {
|
||||||
|
img.src = dataSrc;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('hidden.bs.modal', function(e) {
|
||||||
|
const modalTarget = e.target;
|
||||||
|
if (modalTarget && modalTarget.getAttribute('data-opened-from-map') === 'true') {
|
||||||
|
modalTarget.removeAttribute('data-opened-from-map');
|
||||||
|
setTimeout(() => {
|
||||||
|
const bsMapModal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
|
||||||
|
bsMapModal.show();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.handleToggleCancelInspection = function(inspectionId, action) {
|
||||||
|
const confirmMsg = action === 'cancel'
|
||||||
|
? "{% translate 'Êtes-vous sûr de vouloir annuler cette inspection ?' %}"
|
||||||
|
: "{% translate 'Êtes-vous sûr de vouloir rétablir cette inspection ?' %}";
|
||||||
|
if (!confirm(confirmMsg)) return;
|
||||||
|
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, 10) === 'csrftoken=') {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(10));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('action', action);
|
||||||
|
|
||||||
|
fetch(`/inspections/api/${inspectionId}/toggle-cancel/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': cookieValue
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert(data.error || "{% translate 'Une erreur est survenue.' %}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
alert("{% translate 'Erreur de communication avec le serveur.' %}");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% include "inspections/components/edit_inspection_modal.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -397,22 +397,28 @@ class InspectionModelAndViewsTestCase(TestCase):
|
||||||
result_status='compliant'
|
result_status='compliant'
|
||||||
)
|
)
|
||||||
|
|
||||||
url = reverse('inspections:inspections_list')
|
# Mobile list test
|
||||||
|
url_mobile = reverse('mobile:inspections_list_mobile')
|
||||||
# Filter water thematic
|
resp_water_m = self.client.get(url_mobile, {'thematic': 'water', 'tab': 'all'})
|
||||||
resp_water = self.client.get(url, {'thematic': 'water', 'tab': 'all'})
|
self.assertEqual(resp_water_m.status_code, 200)
|
||||||
self.assertEqual(resp_water.status_code, 200)
|
inspections_water = list(resp_water_m.context['inspections'])
|
||||||
inspections_water = list(resp_water.context['inspections'])
|
|
||||||
self.assertIn(insp_water, inspections_water)
|
self.assertIn(insp_water, inspections_water)
|
||||||
self.assertNotIn(insp_parking, inspections_water)
|
self.assertNotIn(insp_parking, inspections_water)
|
||||||
|
|
||||||
# Filter parking thematic
|
resp_parking_m = self.client.get(url_mobile, {'thematic': 'parking', 'tab': 'all'})
|
||||||
resp_parking = self.client.get(url, {'thematic': 'parking', 'tab': 'all'})
|
self.assertEqual(resp_parking_m.status_code, 200)
|
||||||
self.assertEqual(resp_parking.status_code, 200)
|
inspections_parking = list(resp_parking_m.context['inspections'])
|
||||||
inspections_parking = list(resp_parking.context['inspections'])
|
|
||||||
self.assertIn(insp_parking, inspections_parking)
|
self.assertIn(insp_parking, inspections_parking)
|
||||||
self.assertNotIn(insp_water, inspections_parking)
|
self.assertNotIn(insp_water, inspections_parking)
|
||||||
|
|
||||||
|
# Desktop GeoJSON test
|
||||||
|
url_geojson = reverse('inspections:inspections_geojson_get')
|
||||||
|
resp_water_g = self.client.get(url_geojson, {'thematic': 'water'})
|
||||||
|
self.assertEqual(resp_water_g.status_code, 200)
|
||||||
|
feat_ids_water = [f['properties']['id'] for f in resp_water_g.json()['features']]
|
||||||
|
self.assertIn(insp_water.id, feat_ids_water)
|
||||||
|
self.assertNotIn(insp_parking.id, feat_ids_water)
|
||||||
|
|
||||||
def test_toggle_cancel_inspection_api(self):
|
def test_toggle_cancel_inspection_api(self):
|
||||||
self.client.login(username='inspector_user', password='password123')
|
self.client.login(username='inspector_user', password='password123')
|
||||||
insp = Inspection.objects.create(
|
insp = Inspection.objects.create(
|
||||||
|
|
@ -529,6 +535,87 @@ class InspectionModelAndViewsTestCase(TestCase):
|
||||||
self.assertEqual(self.spot.inspection_status, 'unknown')
|
self.assertEqual(self.spot.inspection_status, 'unknown')
|
||||||
self.assertIsNone(self.spot.last_inspection_date)
|
self.assertIsNone(self.spot.last_inspection_date)
|
||||||
|
|
||||||
|
def test_desktop_inspections_list_view(self):
|
||||||
|
self.client.login(username='inspector_user', password='password123')
|
||||||
|
url = reverse('inspections:inspections_list')
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, 'inspections/inspections_index.html')
|
||||||
|
self.assertIn('thematic_choices', response.context)
|
||||||
|
self.assertIn('result_status_choices', response.context)
|
||||||
|
self.assertIn('saved_sort', response.context)
|
||||||
|
|
||||||
|
def test_mobile_inspections_list_view(self):
|
||||||
|
self.client.login(username='inspector_user', password='password123')
|
||||||
|
response = self.client.get(reverse('mobile:inspections_list_mobile'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, 'inspections/inspections_list_mobile.html')
|
||||||
|
|
||||||
|
def test_inspections_geojson_get_endpoint(self):
|
||||||
|
self.client.login(username='inspector_user', password='password123')
|
||||||
|
insp1 = Inspection.objects.create(
|
||||||
|
asset_content_type=self.spot_ct,
|
||||||
|
asset_object_id=self.spot.id,
|
||||||
|
inspector=self.user,
|
||||||
|
result_status='compliant',
|
||||||
|
notes='Test note search query'
|
||||||
|
)
|
||||||
|
url = reverse('inspections:inspections_geojson_get')
|
||||||
|
|
||||||
|
# Test basic GET
|
||||||
|
resp = self.client.get(url)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data['type'], 'FeatureCollection')
|
||||||
|
self.assertTrue(any(f['properties']['id'] == insp1.id for f in data['features']))
|
||||||
|
|
||||||
|
# Test thematic filter
|
||||||
|
resp_water = self.client.get(url, {'thematic': 'water'})
|
||||||
|
self.assertEqual(resp_water.status_code, 200)
|
||||||
|
data_water = resp_water.json()
|
||||||
|
self.assertFalse(any(f['properties']['id'] == insp1.id for f in data_water['features']))
|
||||||
|
|
||||||
|
# Test search filter (q)
|
||||||
|
resp_q = self.client.get(url, {'q': 'search query'})
|
||||||
|
self.assertEqual(resp_q.status_code, 200)
|
||||||
|
data_q = resp_q.json()
|
||||||
|
self.assertTrue(any(f['properties']['id'] == insp1.id for f in data_q['features']))
|
||||||
|
|
||||||
|
def test_inspections_export_excel(self):
|
||||||
|
self.client.login(username='inspector_user', password='password123')
|
||||||
|
Inspection.objects.create(
|
||||||
|
asset_content_type=self.spot_ct,
|
||||||
|
asset_object_id=self.spot.id,
|
||||||
|
inspector=self.user,
|
||||||
|
result_status='compliant',
|
||||||
|
notes='Test Excel Export Note'
|
||||||
|
)
|
||||||
|
url = reverse('inspections:inspections_export_excel')
|
||||||
|
resp = self.client.get(url)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
resp['Content-Type'],
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
self.assertTrue(resp.has_header('Content-Disposition'))
|
||||||
|
self.assertIn('attachment; filename="inspections_export_', resp['Content-Disposition'])
|
||||||
|
|
||||||
|
def test_save_inspection_list_preferences(self):
|
||||||
|
self.client.login(username='inspector_user', password='password123')
|
||||||
|
url = reverse('common:save_inspection_list_preferences')
|
||||||
|
import json
|
||||||
|
payload = {
|
||||||
|
'filters': {'thematic': ['parking'], 'mine': '1'},
|
||||||
|
'sort': {'sortName': 'inspection_date', 'sortOrder': 'asc'}
|
||||||
|
}
|
||||||
|
resp = self.client.post(url, json.dumps(payload), content_type='application/json')
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertTrue(resp.json()['success'])
|
||||||
|
|
||||||
|
self.user.config.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.config.inspection_list_filters, payload['filters'])
|
||||||
|
self.assertEqual(self.user.config.inspection_list_sort, payload['sort'])
|
||||||
|
|
||||||
|
|
||||||
class ExternalUserInspectionVisibilityTestCase(TestCase):
|
class ExternalUserInspectionVisibilityTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
@ -773,14 +860,22 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.client.login(username='extern_user', password='password123')
|
self.client.login(username='extern_user', password='password123')
|
||||||
url = reverse('inspections:inspections_list')
|
# Test mobile
|
||||||
response = self.client.get(url, {'tab': 'all'})
|
url_mobile = reverse('mobile:inspections_list_mobile')
|
||||||
|
response = self.client.get(url_mobile, {'tab': 'all'})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
inspections = list(response.context['inspections'])
|
inspections = list(response.context['inspections'])
|
||||||
self.assertIn(insp_parking, inspections)
|
self.assertIn(insp_parking, inspections)
|
||||||
self.assertNotIn(insp_water, inspections)
|
self.assertNotIn(insp_water, inspections)
|
||||||
|
|
||||||
|
# Test desktop GeoJSON
|
||||||
|
url_geojson = reverse('inspections:inspections_geojson_get')
|
||||||
|
resp_geojson = self.client.get(url_geojson)
|
||||||
|
self.assertEqual(resp_geojson.status_code, 200)
|
||||||
|
feat_ids = [f['properties']['id'] for f in resp_geojson.json()['features']]
|
||||||
|
self.assertIn(insp_parking.id, feat_ids)
|
||||||
|
self.assertNotIn(insp_water.id, feat_ids)
|
||||||
|
|
||||||
def test_external_user_inspection_detail_api(self):
|
def test_external_user_inspection_detail_api(self):
|
||||||
insp_parking = Inspection.objects.create(
|
insp_parking = Inspection.objects.create(
|
||||||
asset_content_type=self.spot_ct,
|
asset_content_type=self.spot_ct,
|
||||||
|
|
@ -928,8 +1023,3 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
|
||||||
self.assertEqual(detail_res.status_code, 200)
|
self.assertEqual(detail_res.status_code, 200)
|
||||||
data = detail_res.json()
|
data = detail_res.json()
|
||||||
self.assertTrue(data['is_signpanel'])
|
self.assertTrue(data['is_signpanel'])
|
||||||
self.assertEqual(data['verified_cleanliness'], 'missing')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ app_name = 'inspections'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.inspections_list, name='inspections_list'),
|
path('', views.inspections_list, name='inspections_list'),
|
||||||
|
path('geojson/', views.inspections_geojson, name='inspections_geojson_get'),
|
||||||
|
path('export/excel/', views.inspections_export_excel, name='inspections_export_excel'),
|
||||||
path('quick-map/', views.quick_inspection_map, name='quick_inspection_map'),
|
path('quick-map/', views.quick_inspection_map, name='quick_inspection_map'),
|
||||||
path('api/create/', views.create_inspection_api, name='create_inspection_api'),
|
path('api/create/', views.create_inspection_api, name='create_inspection_api'),
|
||||||
path('api/<int:inspection_id>/detail/', views.get_inspection_detail_api, name='get_inspection_detail_api'),
|
path('api/<int:inspection_id>/detail/', views.get_inspection_detail_api, name='get_inspection_detail_api'),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue