feat(home): add user_history widget for viewers and adjust quick action shortcuts

This commit is contained in:
kdeterme 2026-10-07 15:54:57 +02:00
parent 51e6680763
commit 875efc67f4
17 changed files with 710 additions and 11 deletions

View file

@ -142,5 +142,25 @@
</div>
</div>
{% endblock %}
{% block script %}
<script>
try {
const item = {
type: 'contract',
id: '{{ contract.id }}',
title: 'Marché {{ contract.contract_number|escapejs }}',
subtitle: '{{ contract.description|truncatechars:60|default:""|escapejs }}',
company: '{{ contract.company.name|default:""|escapejs }}',
url: window.location.pathname,
timestamp: new Date().toISOString()
};
let h = JSON.parse(localStorage.getItem('loko_user_recent_history') || '[]');
h = h.filter(x => !(x.type === item.type && String(x.id) === String(item.id)));
h.unshift(item);
if (h.length > 20) h = h.slice(0, 20);
localStorage.setItem('loko_user_recent_history', JSON.stringify(h));
} catch(e) {}
</script>
{% endblock %}

View file

@ -1146,3 +1146,48 @@ msgstr "to process &middot; in progress"
msgid "Aucun bon à traiter ou en cours"
msgstr "No orders to process or in progress"
msgid "Activité des trois derniers mois"
msgstr "Activity of the last three months"
msgid "Rechercher une intervention"
msgstr "Search for an intervention"
msgid "Consulter les contrats"
msgstr "View contracts"
msgid "Calendrier"
msgstr "Calendar"
msgid "Mon historique"
msgstr "My history"
msgid "Vos consultations récentes d'interventions et de contrats"
msgstr "Your recent consultations of interventions and contracts"
msgid "Aucun élément consulté récemment"
msgstr "No recently viewed items"
msgid "Les interventions et contrats que vous consultez s'afficheront ici automatiquement pour vous permettre d'y revenir rapidement."
msgstr "Interventions and contracts you consult will automatically appear here for quick access."
msgid "Historique basé sur vos consultations dans l'application"
msgstr "History based on your consultations in the application"
msgid "Vos dernières consultations"
msgstr "Your latest consultations"
msgid "consultation(s)"
msgstr "consultation(s)"
msgid "Ouvrir en grand"
msgstr "Open fullscreen"
msgid "Vos consultations d'interventions et de contrats apparaîtront ici pour un accès rapide."
msgstr "Your consultations of interventions and contracts will appear here for quick access."
msgid "Toutes les interventions"
msgstr "All interventions"
msgid "Vue détaillée"
msgstr "Detailed view"

View file

@ -1155,3 +1155,48 @@ msgstr "à traiter &middot; en cours"
msgid "Aucun bon à traiter ou en cours"
msgstr "Aucun bon à traiter ou en cours"
msgid "Activité des trois derniers mois"
msgstr "Activité des trois derniers mois"
msgid "Rechercher une intervention"
msgstr "Rechercher une intervention"
msgid "Consulter les contrats"
msgstr "Consulter les contrats"
msgid "Calendrier"
msgstr "Calendrier"
msgid "Mon historique"
msgstr "Mon historique"
msgid "Vos consultations récentes d'interventions et de contrats"
msgstr "Vos consultations récentes d'interventions et de contrats"
msgid "Aucun élément consulté récemment"
msgstr "Aucun élément consulté récemment"
msgid "Les interventions et contrats que vous consultez s'afficheront ici automatiquement pour vous permettre d'y revenir rapidement."
msgstr "Les interventions et contrats que vous consultez s'afficheront ici automatiquement pour vous permettre d'y revenir rapidement."
msgid "Historique basé sur vos consultations dans l'application"
msgstr "Historique basé sur vos consultations dans l'application"
msgid "Vos dernières consultations"
msgstr "Vos dernières consultations"
msgid "consultation(s)"
msgstr "consultation(s)"
msgid "Ouvrir en grand"
msgstr "Ouvrir en grand"
msgid "Vos consultations d'interventions et de contrats apparaîtront ici pour un accès rapide."
msgstr "Vos consultations d'interventions et de contrats apparaîtront ici pour un accès rapide."
msgid "Toutes les interventions"
msgstr "Toutes les interventions"
msgid "Vue détaillée"
msgstr "Vue détaillée"

View file

@ -1152,3 +1152,48 @@ msgstr "te verwerken &middot; in behandeling"
msgid "Aucun bon à traiter ou en cours"
msgstr "Geen bonnen te verwerken of in behandeling"
msgid "Activité des trois derniers mois"
msgstr "Activiteit van de afgelopen drie maanden"
msgid "Rechercher une intervention"
msgstr "Een interventie zoeken"
msgid "Consulter les contrats"
msgstr "Contracten raadplegen"
msgid "Calendrier"
msgstr "Kalender"
msgid "Mon historique"
msgstr "Mijn geschiedenis"
msgid "Vos consultations récentes d'interventions et de contrats"
msgstr "Uw recente raadplegingen van interventies en contracten"
msgid "Aucun élément consulté récemment"
msgstr "Geen recent geraadpleegde items"
msgid "Les interventions et contrats que vous consultez s'afficheront ici automatiquement pour vous permettre d'y revenir rapidement."
msgstr "De interventies en contracten die u raadpleegt, worden hier automatisch weergegeven zodat u er snel naar kunt terugkeren."
msgid "Historique basé sur vos consultations dans l'application"
msgstr "Geschiedenis gebaseerd op uw raadplegingen in de applicatie"
msgid "Vos dernières consultations"
msgstr "Uw laatste raadplegingen"
msgid "consultation(s)"
msgstr "raadpleging(en)"
msgid "Ouvrir en grand"
msgstr "Vergroten"
msgid "Vos consultations d'interventions et de contrats apparaîtront ici pour un accès rapide."
msgstr "Uw raadplegingen van interventies en contracten verschijnen hier voor snelle toegang."
msgid "Toutes les interventions"
msgstr "Alle interventies"
msgid "Vue détaillée"
msgstr "Gedetailleerde weergave"

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-10-07 13:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0009_alter_homewidget_widget_code'),
]
operations = [
migrations.AlterField(
model_name='homewidget',
name='widget_code',
field=models.CharField(choices=[('territory_kpis', 'Métriques du patrimoine'), ('recent_activity', "Radar d'activité"), ('intervention_stats', 'Statistiques interventions'), ('contracts_summary', 'Résumé contrats'), ('map_today', 'Carte du jour'), ('operator_shortcuts', 'Raccourcis opérateur'), ('assets_links', 'Liens assets'), ('stock_summary', 'Résumé stock'), ('observer_shortcuts', 'Raccourcis observateur'), ('incident_counter', 'Compteur incidents à traiter'), ('intervention_to_verify', 'Compteur interventions à vérifier'), ('intervention_to_plan', 'Compteur interventions à planifier'), ('intervention_to_approve', 'Compteur interventions à approuver'), ('intervention_checked_to_approve', 'Compteur interventions vérifiées à approuver'), ('intervention_finished', 'Compteur interventions terminées'), ('intervention_on_pause', 'Compteur interventions en pause'), ('external_interventions', 'Interventions externes'), ('stock_actions', 'Actions stock'), ('exceptional_transport', 'Transport exceptionnel'), ('controller_shortcuts', 'Raccourcis contrôleur'), ('user_history', 'Mon historique')], max_length=50, unique=True, verbose_name='Code du widget'),
),
]

View file

@ -0,0 +1,49 @@
from django.db import migrations
def create_user_history_widget(apps, schema_editor):
HomeWidget = apps.get_model('home', 'HomeWidget')
HomeWidgetConditionGroup = apps.get_model('home', 'HomeWidgetConditionGroup')
HomeWidgetCondition = apps.get_model('home', 'HomeWidgetCondition')
Role = apps.get_model('common', 'Role')
widget, created = HomeWidget.objects.get_or_create(
widget_code='user_history',
defaults={'is_active': True}
)
if not widget.condition_groups.exists():
viewer_role, _ = Role.objects.get_or_create(name='viewer')
if viewer_role:
group = HomeWidgetConditionGroup.objects.create(
widget=widget,
label="Lecteurs (Viewers)",
order=20,
col_width=12,
priority=0,
is_active=True
)
HomeWidgetCondition.objects.create(
group=group,
condition_type='role',
role=viewer_role,
is_active=True,
is_negated=False
)
def remove_user_history_widget(apps, schema_editor):
HomeWidget = apps.get_model('home', 'HomeWidget')
HomeWidget.objects.filter(widget_code='user_history').delete()
class Migration(migrations.Migration):
dependencies = [
('home', '0010_alter_homewidget_widget_code'),
('common', '0001_initial'),
]
operations = [
migrations.RunPython(create_user_history_widget, remove_user_history_widget),
]

View file

@ -24,6 +24,7 @@ WIDGET_CODE_CHOICES = [
('stock_actions', _('Actions stock')),
('exceptional_transport', _('Transport exceptionnel')),
('controller_shortcuts', _('Raccourcis contrôleur')),
('user_history', _('Mon historique')),
]
# Condition type choices

View file

@ -48,7 +48,7 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
map.addControl(new maplibregl.NavigationControl(), 'top-left');
let currentMode = 'heatmap'; // 'heatmap' or 'points'
let currentMode = 'points'; // 'heatmap' or 'points'
let activityGeojsonData = null;
const THEMATIC_COLORS = {
@ -279,6 +279,9 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
type: 'heatmap',
source: 'activity-data',
maxzoom: 17,
layout: {
'visibility': 'none'
},
paint: {
'heatmap-weight': 1,
'heatmap-intensity': [
@ -350,12 +353,7 @@ if (mapContainer && typeof maplibregl !== 'undefined') {
'icon-anchor': 'center'
},
paint: {
// En mode Heatmap : les symboles s'estompent au dézoom pour laisser le champ libre à la heatmap
'icon-opacity': [
'interpolate', ['linear'], ['zoom'],
13.8, 0,
15.2, 1
]
'icon-opacity': 1
}
});

View file

@ -49,6 +49,20 @@
<div class="row justify-content-center mt-2 mb-3">
<div class="col-12">
<div class="quick-actions-bar">
{% if user.config|has_any_role:"viewer" and not user.config|has_any_role:"manager,admin,controller,operator,inspector" %}
<a href="{% url 'interventions:interventions_index' %}" class="quick-action-btn">
<span class="quick-action-icon"><i class="bi bi-search"></i></span>
<span class="quick-action-text">{% translate "Rechercher une intervention" %}</span>
</a>
<a href="{% url 'interventions:interventions_calendar' %}" class="quick-action-btn">
<span class="quick-action-icon"><i class="bi bi-calendar3"></i></span>
<span class="quick-action-text">{% translate "Calendrier" %}</span>
</a>
<a href="#widget-user-history" class="quick-action-btn" onclick="const el=document.getElementById('widget-user-history'); if(el){ el.scrollIntoView({behavior:'smooth'}); el.style.boxShadow='0 0 0 3px rgba(13, 110, 253, 0.4)'; setTimeout(()=>el.style.boxShadow='', 1800); return false; }" data-bs-toggle="modal" data-bs-target="#userHistoryModal">
<span class="quick-action-icon"><i class="bi bi-clock-history"></i></span>
<span class="quick-action-text">{% translate "Mon historique" %}</span>
</a>
{% endif %}
{% if user.config|has_any_role:"observer,manager,controller,admin,external_manager,top_manager" %}
<a href="{% url 'observations:observation_add' %}" class="quick-action-btn quick-action-obs">
<span class="quick-action-icon"><i class="bi bi-eye"></i></span>
@ -149,6 +163,7 @@
</button>
{% include "notifications/_new_discussion_modal.html" %}
{% include "home/includes/_user_history_modal.html" %}
{% endblock content%}

View file

@ -0,0 +1,155 @@
{% load i18n %}
{% load static %}
<!-- Modal Mon Historique -->
<div class="modal fade" id="userHistoryModal" tabindex="-1" aria-labelledby="userHistoryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg" style="border-radius: 14px; overflow: hidden;">
<div class="modal-header border-bottom py-3 px-4 bg-white">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle d-flex align-items-center justify-content-center bg-primary bg-opacity-10 text-primary" style="width: 40px; height: 40px;">
<i class="bi bi-clock-history fs-5"></i>
</div>
<div>
<h5 class="modal-title fw-bold mb-0 text-dark" id="userHistoryModalLabel">{% translate "Mon historique" %}</h5>
<small class="text-muted">{% translate "Vos consultations récentes d'interventions et de contrats" %}</small>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body p-3 p-md-4" style="max-height: 60vh; overflow-y: auto;">
{% if user_recent_history %}
<div class="list-group list-group-flush gap-2" id="user-history-list">
{% for item in user_recent_history %}
<a href="{{ item.url }}" class="list-group-item list-group-item-action d-flex align-items-center justify-content-between p-3 border rounded-3 text-decoration-none transition-all user-history-item shadow-sm-hover">
<div class="d-flex align-items-center gap-3 min-w-0">
{% if item.type == 'intervention' %}
<div class="rounded-3 d-flex align-items-center justify-content-center bg-primary text-white flex-shrink-0" style="width: 42px; height: 42px;">
<i class="bi bi-tools fs-5"></i>
</div>
<div class="text-truncate">
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
<span class="badge bg-light text-dark border fw-semibold">{% translate "Intervention" %} #{{ item.id }}</span>
{% if item.status %}
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">{{ item.status }}</span>
{% endif %}
</div>
<div class="fw-semibold text-dark text-truncate" style="max-width: 520px;" title="{{ item.title }}">{{ item.title }}</div>
{% if item.subtitle %}
<small class="text-muted"><i class="bi bi-tag me-1"></i>{{ item.subtitle }}</small>
{% endif %}
</div>
{% elif item.type == 'contract' %}
<div class="rounded-3 d-flex align-items-center justify-content-center bg-success text-white flex-shrink-0" style="width: 42px; height: 42px;">
<i class="bi bi-file-earmark-text fs-5"></i>
</div>
<div class="text-truncate">
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
<span class="badge bg-light text-dark border fw-semibold">{{ item.title }}</span>
</div>
<div class="fw-semibold text-dark text-truncate" style="max-width: 520px;" title="{{ item.subtitle }}">{{ item.subtitle }}</div>
{% if item.company %}
<small class="text-muted"><i class="bi bi-building me-1"></i>{{ item.company }}</small>
{% endif %}
</div>
{% endif %}
</div>
<div class="text-end text-nowrap ms-3 flex-shrink-0">
<small class="text-muted d-block"><i class="bi bi-clock me-1"></i>{{ item.relative_time }}</small>
<span class="btn btn-sm btn-outline-primary rounded-pill mt-1 py-0 px-2.5" style="font-size: 0.75rem;">
{% translate "Consulter" %} <i class="bi bi-chevron-right ms-1"></i>
</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 px-3" id="user-history-empty">
<div class="rounded-circle d-inline-flex align-items-center justify-content-center bg-light text-muted mb-3" style="width: 64px; height: 64px;">
<i class="bi bi-clock-history fs-2"></i>
</div>
<h6 class="fw-bold text-dark mb-1">{% translate "Aucun élément consulté récemment" %}</h6>
<p class="text-muted small mb-3" style="max-width: 380px; margin: 0 auto;">
{% translate "Les interventions et contrats que vous consultez s'afficheront ici automatiquement pour vous permettre d'y revenir rapidement." %}
</p>
<a href="{% url 'interventions:interventions_index' %}" class="btn btn-outline-primary btn-sm rounded-pill px-3">
<i class="bi bi-search me-1"></i>{% translate "Rechercher une intervention" %}
</a>
</div>
{% endif %}
</div>
<div class="modal-footer border-top bg-light py-2.5 px-4 d-flex justify-content-between align-items-center">
<small class="text-muted" style="font-size: 0.78rem;">
<i class="bi bi-info-circle me-1"></i>{% translate "Historique basé sur vos consultations dans l'application" %}
</small>
<button type="button" class="btn btn-secondary btn-sm rounded-pill px-3" data-bs-dismiss="modal">
{% translate "Fermer" %}
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modalEl = document.getElementById('userHistoryModal');
if (!modalEl) return;
modalEl.addEventListener('show.bs.modal', function() {
try {
const localHistory = JSON.parse(localStorage.getItem('loko_user_recent_history') || '[]');
if (!localHistory.length) return;
const listEl = document.getElementById('user-history-list');
const emptyEl = document.getElementById('user-history-empty');
if (emptyEl && !listEl) {
const bodyEl = modalEl.querySelector('.modal-body');
emptyEl.style.display = 'none';
const newList = document.createElement('div');
newList.className = 'list-group list-group-flush gap-2';
newList.id = 'user-history-list';
localHistory.slice(0, 10).forEach(item => {
const a = document.createElement('a');
a.href = item.url;
a.className = 'list-group-item list-group-item-action d-flex align-items-center justify-content-between p-3 border rounded-3 text-decoration-none transition-all user-history-item shadow-sm-hover';
const isInterv = item.type === 'intervention';
const iconBg = isInterv ? 'bg-primary' : 'bg-success';
const iconClass = isInterv ? 'bi-tools' : 'bi-file-earmark-text';
const badgeText = isInterv ? `Intervention #${item.id}` : item.title;
const titleText = isInterv ? item.title : item.subtitle || item.title;
const subText = isInterv ? item.subtitle : item.company;
a.innerHTML = `
<div class="d-flex align-items-center gap-3 min-w-0">
<div class="rounded-3 d-flex align-items-center justify-content-center ${iconBg} text-white flex-shrink-0" style="width: 42px; height: 42px;">
<i class="bi ${iconClass} fs-5"></i>
</div>
<div class="text-truncate">
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
<span class="badge bg-light text-dark border fw-semibold">${badgeText}</span>
${item.status ? `<span class="badge bg-primary-subtle text-primary border border-primary-subtle">${item.status}</span>` : ''}
</div>
<div class="fw-semibold text-dark text-truncate" style="max-width: 520px;" title="${titleText}">${titleText}</div>
${subText ? `<small class="text-muted"><i class="bi bi-tag me-1"></i>${subText}</small>` : ''}
</div>
</div>
<div class="text-end text-nowrap ms-3 flex-shrink-0">
<small class="text-muted d-block"><i class="bi bi-clock me-1"></i>Récemment</small>
<span class="btn btn-sm btn-outline-primary rounded-pill mt-1 py-0 px-2.5" style="font-size: 0.75rem;">
Consulter <i class="bi bi-chevron-right ms-1"></i>
</span>
</div>
`;
newList.appendChild(a);
});
bodyEl.appendChild(newList);
}
} catch (e) {
console.warn('Error reading local history:', e);
}
});
});
</script>

View file

@ -8,16 +8,15 @@
<i class="bi bi-geo-alt-fill text-primary"></i>
</div>
<div>
<h6 class="mb-0 fw-bold text-dark">{% translate "Activité du Territoire" %}</h6>
<small class="text-muted">{% translate "Mise en lumière & intensité des 90 derniers jours" %}</small>
<h6 class="mb-0 fw-bold text-dark">{% translate "Activité des trois derniers mois" %}</h6>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" id="activityMapModeToggle">
<button type="button" class="btn btn-primary active" id="btnModeHeatmap">
<button type="button" class="btn btn-outline-primary" id="btnModeHeatmap">
<i class="bi bi-fire me-1"></i>{% translate "Heatmap" %}
</button>
<button type="button" class="btn btn-outline-primary" id="btnModePoints">
<button type="button" class="btn btn-primary active" id="btnModePoints">
<i class="bi bi-geo-alt me-1"></i>{% translate "Points" %}
</button>
</div>

View file

@ -0,0 +1,161 @@
{% load i18n %}
{% load static %}
<div class="card border-0 shadow-sm user-history-card h-100" id="widget-user-history" style="border-radius: 12px; overflow: hidden; transition: box-shadow 0.3s ease;">
<div class="card-header bg-white border-bottom d-flex align-items-center justify-content-between py-2.5 px-3">
<div class="d-flex align-items-center gap-2">
<div class="rounded-circle d-flex align-items-center justify-content-center bg-primary bg-opacity-10 text-primary" style="width: 34px; height: 34px;">
<i class="bi bi-clock-history fs-5"></i>
</div>
<div>
<h6 class="mb-0 fw-bold text-dark">{% translate "Mon historique" %}</h6>
<small class="text-muted">{% translate "Vos dernières consultations" %}</small>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge rounded-pill bg-primary-subtle text-primary border border-primary-subtle px-2.5 py-1 fw-semibold" id="widget-user-history-count" style="font-size: 0.75rem;">
{{ user_recent_history|length|default:"0" }} {% translate "consultation(s)" %}
</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2 rounded-pill" data-bs-toggle="modal" data-bs-target="#userHistoryModal" title="{% translate 'Ouvrir en grand' %}">
<i class="bi bi-arrows-fullscreen"></i>
</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 480px; overflow-y: auto; overflow-x: hidden;">
{% if user_recent_history %}
<div class="list-group list-group-flush" id="widget-user-history-list">
{% for item in user_recent_history %}
<a href="{{ item.url }}" class="list-group-item list-group-item-action py-2.5 px-3 border-bottom-subtle transition-all user-history-item shadow-sm-hover">
<div class="d-flex align-items-center gap-3">
{% if item.type == 'intervention' %}
<div class="rounded-circle d-flex align-items-center justify-content-center bg-primary text-white flex-shrink-0" style="width: 38px; height: 38px;">
<i class="bi bi-tools fs-6"></i>
</div>
<div class="flex-grow-1 min-w-0">
<div class="d-flex align-items-center justify-content-between gap-1 mb-1">
<span class="badge bg-light text-dark border fw-semibold" style="font-size: 0.72rem;">{% translate "Intervention" %} #{{ item.id }}</span>
<small class="text-muted text-nowrap ms-1" style="font-size: 0.72rem;">
<i class="bi bi-clock me-1"></i>{{ item.relative_time }}
</small>
</div>
<div class="fw-semibold text-dark text-truncate" style="font-size: 0.85rem;" title="{{ item.title }}">{{ item.title }}</div>
<div class="d-flex align-items-center justify-content-between gap-1 mt-0.5">
{% if item.subtitle %}
<small class="text-muted text-truncate" style="font-size: 0.75rem;"><i class="bi bi-tag me-1"></i>{{ item.subtitle }}</small>
{% else %}
<span></span>
{% endif %}
{% if item.status %}
<span class="badge bg-primary-subtle text-primary border border-primary-subtle" style="font-size: 0.68rem; padding: 2px 6px;">{{ item.status }}</span>
{% endif %}
</div>
</div>
{% elif item.type == 'contract' %}
<div class="rounded-circle d-flex align-items-center justify-content-center bg-success text-white flex-shrink-0" style="width: 38px; height: 38px;">
<i class="bi bi-file-earmark-text fs-6"></i>
</div>
<div class="flex-grow-1 min-w-0">
<div class="d-flex align-items-center justify-content-between gap-1 mb-1">
<span class="badge bg-light text-dark border fw-semibold" style="font-size: 0.72rem;">{{ item.title }}</span>
<small class="text-muted text-nowrap ms-1" style="font-size: 0.72rem;">
<i class="bi bi-clock me-1"></i>{{ item.relative_time }}
</small>
</div>
<div class="fw-semibold text-dark text-truncate" style="font-size: 0.85rem;" title="{{ item.subtitle }}">{{ item.subtitle }}</div>
{% if item.company %}
<small class="text-muted text-truncate" style="font-size: 0.75rem;"><i class="bi bi-building me-1"></i>{{ item.company }}</small>
{% endif %}
</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 px-3 text-muted" id="widget-user-history-empty">
<i class="bi bi-clock-history fs-2 mb-2 d-block text-secondary"></i>
<h6 class="fw-bold text-dark mb-1">{% translate "Aucun élément consulté récemment" %}</h6>
<p class="small text-muted mb-3" style="max-width: 360px; margin: 0 auto;">
{% translate "Vos consultations d'interventions et de contrats apparaîtront ici pour un accès rapide." %}
</p>
<a href="{% url 'interventions:interventions_index' %}" class="btn btn-outline-primary btn-sm rounded-pill px-3">
<i class="bi bi-search me-1"></i>{% translate "Rechercher une intervention" %}
</a>
</div>
{% endif %}
</div>
<div class="card-footer bg-light border-top py-2 px-3 text-center d-flex justify-content-between align-items-center flex-wrap gap-1">
<a href="{% url 'interventions:interventions_index' %}" class="btn btn-sm btn-link text-decoration-none fw-semibold text-primary p-0" style="font-size: 0.8rem;">
<i class="bi bi-search me-1"></i>{% translate "Toutes les interventions" %}
</a>
<button type="button" class="btn btn-sm btn-link text-decoration-none fw-semibold text-secondary p-0" data-bs-toggle="modal" data-bs-target="#userHistoryModal" style="font-size: 0.8rem;">
<i class="bi bi-arrows-angle-expand me-1"></i>{% translate "Vue détaillée" %}
</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
try {
const localHistory = JSON.parse(localStorage.getItem('loko_user_recent_history') || '[]');
if (!localHistory.length) return;
const widgetList = document.getElementById('widget-user-history-list');
const widgetEmpty = document.getElementById('widget-user-history-empty');
const countBadge = document.getElementById('widget-user-history-count');
if (widgetEmpty && !widgetList) {
const bodyEl = document.querySelector('#widget-user-history .card-body');
if (bodyEl) {
widgetEmpty.style.display = 'none';
const newList = document.createElement('div');
newList.className = 'list-group list-group-flush';
newList.id = 'widget-user-history-list';
localHistory.slice(0, 10).forEach(item => {
const a = document.createElement('a');
a.href = item.url;
a.className = 'list-group-item list-group-item-action py-2.5 px-3 border-bottom-subtle transition-all user-history-item shadow-sm-hover';
const isInterv = item.type === 'intervention';
const iconBg = isInterv ? 'bg-primary' : 'bg-success';
const iconClass = isInterv ? 'bi-tools' : 'bi-file-earmark-text';
const badgeText = isInterv ? `Intervention #${item.id}` : item.title;
const titleText = isInterv ? item.title : item.subtitle || item.title;
const subText = isInterv ? item.subtitle : item.company;
a.innerHTML = `
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle d-flex align-items-center justify-content-center ${iconBg} text-white flex-shrink-0" style="width: 38px; height: 38px;">
<i class="bi ${iconClass} fs-6"></i>
</div>
<div class="flex-grow-1 min-w-0">
<div class="d-flex align-items-center justify-content-between gap-1 mb-1">
<span class="badge bg-light text-dark border fw-semibold" style="font-size: 0.72rem;">${badgeText}</span>
<small class="text-muted text-nowrap ms-1" style="font-size: 0.72rem;">
<i class="bi bi-clock me-1"></i>Récemment
</small>
</div>
<div class="fw-semibold text-dark text-truncate" style="font-size: 0.85rem;" title="${titleText}">${titleText}</div>
<div class="d-flex align-items-center justify-content-between gap-1 mt-0.5">
${subText ? `<small class="text-muted text-truncate" style="font-size: 0.75rem;"><i class="bi bi-tag me-1"></i>${subText}</small>` : '<span></span>'}
${item.status ? `<span class="badge bg-primary-subtle text-primary border border-primary-subtle" style="font-size: 0.68rem; padding: 2px 6px;">${item.status}</span>` : ''}
</div>
</div>
</div>
`;
newList.appendChild(a);
});
bodyEl.appendChild(newList);
if (countBadge) {
countBadge.textContent = `${localHistory.length} consultation(s)`;
}
}
}
} catch (e) {
console.warn('Error syncing widget local history:', e);
}
});
</script>

View file

@ -10,8 +10,11 @@ from datetime import timedelta
from collections import defaultdict
import json
import logging
import re
import time
logger = logging.getLogger(__name__)
# logger = logging.getLogger('home.perf')
@ -84,6 +87,19 @@ def index(request):
if result:
resolved_widgets.append(result)
# Fallback pour le rôle viewer : s'assurer que le widget d'historique s'affiche toujours
if not any(r['widget'].widget_code == 'user_history' for r in resolved_widgets):
user_roles_set = set(user_config.roles.values_list('name', flat=True))
if 'viewer' in user_roles_set and not (user_roles_set & {'admin', 'manager', 'controller', 'operator', 'inspector'}):
user_history_widget = HomeWidget.objects.filter(widget_code='user_history').first() or HomeWidget(widget_code='user_history')
resolved_widgets.append({
'widget': user_history_widget,
'order': 20,
'template_name': 'home/widgets/user_history.html',
'col_width': 12,
'col_order': 0,
})
resolved_widgets.sort(key=lambda r: (r['order'], r['col_order']))
# Group widgets by order into rows; within each row sort by col_order.
@ -786,10 +802,12 @@ def index(request):
territory_kpis = _get_territory_kpis(request.user, user_contracts)
recent_activity = _get_recent_activity(request.user, user_contracts)
user_recent_history = _get_user_recent_history(request.user)
context = {
'territory_kpis': territory_kpis,
'recent_activity': recent_activity,
'user_recent_history': user_recent_history,
'is_inspector': is_inspector,
'inspection_configs': inspection_configs,
'series_data': json.dumps(series_data),
@ -1230,6 +1248,118 @@ def _get_recent_activity(user, allowed_contracts):
return recent_items
def _get_user_recent_history(user, limit=10):
"""
Rassemble l'historique des dernières consultations (interventions et contrats)
de l'utilisateur, vérifié selon ses droits d'accès.
"""
try:
from analytics.models import PageView
views = (
PageView.objects.filter(
user=user,
method='GET',
path__regex=r"^/(interventions|contracts)/[0-9]+/?$"
)
.order_by('-timestamp')[:40]
)
seen_paths = set()
items = []
for v in views:
clean_path = v.path.rstrip('/') + '/'
if clean_path in seen_paths:
continue
seen_paths.add(clean_path)
m = re.match(r"^/(interventions|contracts)/([0-9]+)/$", clean_path)
if not m:
continue
items.append({
'type': m.group(1),
'id': int(m.group(2)),
'timestamp': v.timestamp,
'path': clean_path,
})
if len(items) >= limit:
break
interv_ids = [it['id'] for it in items if it['type'] == 'interventions']
contract_ids = [it['id'] for it in items if it['type'] == 'contracts']
interv_map = {}
if interv_ids:
from interventions.permissions import filter_viewable_interventions_for_user
interv_qs = filter_viewable_interventions_for_user(user).filter(
id__in=interv_ids
).select_related('thematic')
interv_map = {i.id: i for i in interv_qs}
contract_map = {}
if contract_ids:
from contracts.permissions import get_allowed_contracts_for_user
allowed_contracts = get_allowed_contracts_for_user(user)
contract_qs = Contract.objects.filter(
id__in=contract_ids
).filter(id__in=allowed_contracts).select_related('company')
contract_map = {c.id: c for c in contract_qs}
now = timezone.now()
result = []
for it in items:
if it['type'] == 'interventions' and it['id'] in interv_map:
interv = interv_map[it['id']]
diff = now - it['timestamp']
if diff.days == 0:
hours = int(diff.total_seconds() // 3600)
relative_time = "Il y a 1h" if hours <= 1 else f"Il y a {hours}h" if hours > 0 else "À l'instant"
elif diff.days == 1:
relative_time = "Hier"
elif diff.days < 7:
relative_time = f"Il y a {diff.days}j"
else:
relative_time = it['timestamp'].strftime("%d/%m/%Y")
result.append({
'type': 'intervention',
'id': interv.id,
'title': interv.title or f"Intervention #{interv.code or interv.id}",
'subtitle': interv.thematic.name if interv.thematic else '',
'status': interv.get_status_display(),
'status_code': interv.status,
'url': reverse('interventions:intervention_detail', args=[interv.id]),
'timestamp': it['timestamp'],
'relative_time': relative_time,
})
elif it['type'] == 'contracts' and it['id'] in contract_map:
contract = contract_map[it['id']]
diff = now - it['timestamp']
if diff.days == 0:
hours = int(diff.total_seconds() // 3600)
relative_time = "Il y a 1h" if hours <= 1 else f"Il y a {hours}h" if hours > 0 else "À l'instant"
elif diff.days == 1:
relative_time = "Hier"
elif diff.days < 7:
relative_time = f"Il y a {diff.days}j"
else:
relative_time = it['timestamp'].strftime("%d/%m/%Y")
result.append({
'type': 'contract',
'id': contract.id,
'title': f"Marché {contract.contract_number}",
'subtitle': (contract.description[:60] + '...') if len(contract.description) > 60 else contract.description,
'company': contract.company.name if contract.company else '',
'url': reverse('contracts:contracts_detail', args=[contract.id]),
'timestamp': it['timestamp'],
'relative_time': relative_time,
})
return result
except Exception as e:
logger.warning(f"Error fetching user recent history: {e}")
return []
@login_required
def activity_geojson(request):
"""

View file

@ -53,4 +53,22 @@
{% block script %}
{% include "interventions/detail/scripts.html" %}
<script>
try {
const item = {
type: 'intervention',
id: '{{ intervention.id }}',
title: '{{ intervention.title|default:""|escapejs }}' || 'Intervention #{{ intervention.code|default:intervention.id }}',
subtitle: '{{ intervention.thematic.name|default:""|escapejs }}',
status: '{{ intervention.get_status_display|default:""|escapejs }}',
url: window.location.pathname,
timestamp: new Date().toISOString()
};
let h = JSON.parse(localStorage.getItem('loko_user_recent_history') || '[]');
h = h.filter(x => !(x.type === item.type && String(x.id) === String(item.id)));
h.unshift(item);
if (h.length > 20) h = h.slice(0, 20);
localStorage.setItem('loko_user_recent_history', JSON.stringify(h));
} catch(e) {}
</script>
{% endblock script %}