feat: implement mobile-optimized intervention list view with user-specific preference persistence and associated tests
This commit is contained in:
parent
fecd7430ad
commit
da038b7c4f
3 changed files with 101 additions and 44 deletions
|
|
@ -39,53 +39,85 @@
|
||||||
</div>
|
</div>
|
||||||
{% for intervention in interventions %}
|
{% for intervention in interventions %}
|
||||||
<div class="card mb-2 shadow-sm" data-href="{% url 'mobile:intervention_detail_mobile' intervention.id %}">
|
<div class="card mb-2 shadow-sm" data-href="{% url 'mobile:intervention_detail_mobile' intervention.id %}">
|
||||||
<div class="card-body d-flex justify-content-between align-items-center">
|
<div class="card-body">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h6 class="card-title mb-1">
|
<div>
|
||||||
<i class="bi bi-gear
|
<h6 class="card-title mb-1">
|
||||||
{% if intervention.maintain_type == 'corrective' %}text-danger
|
<i class="bi bi-gear
|
||||||
{% elif intervention.maintain_type == 'preventive' %}text-warning
|
{% if intervention.maintain_type == 'corrective' %}text-danger
|
||||||
{% elif intervention.maintain_type == 'ameliorative' %}text-success
|
{% elif intervention.maintain_type == 'preventive' %}text-warning
|
||||||
{% endif %}"
|
{% elif intervention.maintain_type == 'ameliorative' %}text-success
|
||||||
></i>
|
{% endif %}"
|
||||||
<a href="{% url 'mobile:intervention_detail_mobile' intervention.id %}" class="me-2">{{ intervention.code }}</a> {{ intervention.title }}
|
></i>
|
||||||
</h6>
|
<a href="{% url 'mobile:intervention_detail_mobile' intervention.id %}" class="me-2">{{ intervention.code }}</a> {{ intervention.title }}
|
||||||
<span class="badge bg-{{ intervention.get_status_color }} status-{{ intervention.status }}">{{ intervention.get_status_display }}</span>
|
</h6>
|
||||||
{% if intervention.prep_state %}
|
<span class="badge bg-{{ intervention.get_status_color }} status-{{ intervention.status }}">{{ intervention.get_status_display }}</span>
|
||||||
{% if intervention.prep_state == "processed" %}
|
{% if intervention.prep_state %}
|
||||||
<span class="badge bg-success" title="{% translate 'Stock prêt' %}">
|
{% if intervention.prep_state == "processed" %}
|
||||||
<i class="bi bi-box"></i>
|
<span class="badge bg-success" title="{% translate 'Stock prêt' %}">
|
||||||
</span>
|
<i class="bi bi-box"></i>
|
||||||
{% elif intervention.prep_state == "not_processed" %}
|
</span>
|
||||||
<span class="badge bg-danger" title="{% translate 'Stock en attente' %}">
|
{% elif intervention.prep_state == "not_processed" %}
|
||||||
<i class="bi bi-box"></i>
|
<span class="badge bg-danger" title="{% translate 'Stock en attente' %}">
|
||||||
|
<i class="bi bi-box"></i>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if intervention.list_begin_time %}
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
<i class="bi bi-calendar-event me-1"></i>
|
||||||
|
{{ intervention.list_begin_time|localtime|date:"d/m/Y H:i" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if intervention.lat and intervention.lon %}
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-primary btn-sm rounded-circle p-0 d-flex align-items-center justify-content-center"
|
||||||
|
style="width: 34px; height: 34px;"
|
||||||
|
title="{% translate 'Itinéraire GPS' %}"
|
||||||
|
aria-label="{% translate 'Itinéraire GPS' %}"
|
||||||
|
onclick="openGpsNavigation('{{ intervention.lat|stringformat:'f' }}', '{{ intervention.lon|stringformat:'f' }}', '{{ intervention.code|escapejs }}', false, event)">
|
||||||
|
<i class="bi bi-sign-turn-right-fill fs-6"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if not intervention.assigned_member %}
|
||||||
|
<i class="bi bi-person-x fs-5 s-4" title="{% translate 'Non assignée' %}"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-person-check fs-5 {% if request.user == intervention.assigned_member.user %}text-primary{% else %}text-info{% endif %}" title="{% translate 'Assignée' %}"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if intervention.preparation_orders.all %}
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-1 mt-2 pt-2 border-top">
|
||||||
|
<i class="bi bi-box-seam text-primary me-1" style="font-size:0.8rem;" title="{% translate 'Matériel / Stock' %}"></i>
|
||||||
|
{% for order in intervention.preparation_orders.all %}
|
||||||
|
{% if order.is_loan %}
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle" style="font-size:0.7rem;">
|
||||||
|
<i class="bi bi-arrow-left-right"></i> {% translate "Prêt" %}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% for item in order.preparationorderitem_set.all|slice:":3" %}
|
||||||
{% if intervention.list_begin_time %}
|
<span class="badge bg-light text-dark border text-truncate" style="font-size:0.7rem; max-width: 160px;" title="{{ item.product.name }} × {{ item.quantity }}">
|
||||||
<div class="small text-muted mt-1">
|
{{ item.product.name }} × {{ item.quantity }}
|
||||||
<i class="bi bi-calendar-event me-1"></i>
|
</span>
|
||||||
{{ intervention.list_begin_time|localtime|date:"d/m/Y H:i" }}
|
{% endfor %}
|
||||||
</div>
|
{% for item in order.other_items.all|slice:":2" %}
|
||||||
{% endif %}
|
<span class="badge bg-light text-dark border text-truncate" style="font-size:0.7rem; max-width: 160px;" title="{{ item.name }} × {{ item.quantity }}">
|
||||||
</div>
|
{{ item.name }} × {{ item.quantity }}
|
||||||
<div class="d-flex align-items-center gap-2">
|
</span>
|
||||||
{% if intervention.lat and intervention.lon %}
|
{% endfor %}
|
||||||
<button type="button"
|
{% with total_count=order.preparationorderitem_set.all|length|add:order.other_items.all|length %}
|
||||||
class="btn btn-outline-primary btn-sm rounded-circle p-0 d-flex align-items-center justify-content-center"
|
{% if total_count > 3 %}
|
||||||
style="width: 34px; height: 34px;"
|
<span class="badge bg-secondary-subtle text-muted" style="font-size:0.7rem;">
|
||||||
title="{% translate 'Itinéraire GPS' %}"
|
+{{ total_count|add:"-3" }}
|
||||||
aria-label="{% translate 'Itinéraire GPS' %}"
|
</span>
|
||||||
onclick="openGpsNavigation('{{ intervention.lat|stringformat:'f' }}', '{{ intervention.lon|stringformat:'f' }}', '{{ intervention.code|escapejs }}', false, event)">
|
{% endif %}
|
||||||
<i class="bi bi-sign-turn-right-fill fs-6"></i>
|
{% endwith %}
|
||||||
</button>
|
{% endfor %}
|
||||||
{% endif %}
|
|
||||||
{% if not intervention.assigned_member %}
|
|
||||||
<i class="bi bi-person-x fs-5 s-4" title="{% translate 'Non assignée' %}"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="bi bi-person-check fs-5 {% if request.user == intervention.assigned_member.user %}text-primary{% else %}text-info{% endif %}" title="{% translate 'Assignée' %}"></i>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,12 @@ def interventions_index_mobile(request):
|
||||||
# Apply limit (slice) at the very end, after all filtering
|
# Apply limit (slice) at the very end, after all filtering
|
||||||
interventions = interventions[:100]
|
interventions = interventions[:100]
|
||||||
|
|
||||||
|
# Preload preparation orders and items to avoid N+1 queries in mobile list cards
|
||||||
|
interventions = interventions.prefetch_related(
|
||||||
|
'preparation_orders__preparationorderitem_set__product',
|
||||||
|
'preparation_orders__other_items',
|
||||||
|
)
|
||||||
|
|
||||||
# Evaluate queryset once to avoid double DB round-trip
|
# Evaluate queryset once to avoid double DB round-trip
|
||||||
interventions_list = list(interventions)
|
interventions_list = list(interventions)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,4 +172,23 @@ class MobileInterventionDetailStockTests(TestCase):
|
||||||
self.assertFalse(response.context["can_create_preparation_order"])
|
self.assertFalse(response.context["can_create_preparation_order"])
|
||||||
self.assertEqual(len(response.context["preparation_orders"]), 1)
|
self.assertEqual(len(response.context["preparation_orders"]), 1)
|
||||||
|
|
||||||
|
def test_stock_items_rendered_in_mobile_interventions_list(self):
|
||||||
|
url = reverse("mobile:interventions_index_mobile")
|
||||||
|
response = self.client.get(url, follow=True)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
content = response.content.decode("utf-8")
|
||||||
|
self.assertIn("Poteau feux", content)
|
||||||
|
self.assertIn("× 3", content)
|
||||||
|
|
||||||
|
def test_stock_items_rendered_in_mobile_ajax_list(self):
|
||||||
|
url = reverse("mobile:interventions_index_mobile") + "?status=in_progress"
|
||||||
|
response = self.client.get(url, headers={"x-requested-with": "XMLHttpRequest"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertIn("html", payload)
|
||||||
|
self.assertIn("Poteau feux", payload["html"])
|
||||||
|
self.assertIn("× 3", payload["html"])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue