feat: implement discussion list and notification inbox UI with mention handling tags

This commit is contained in:
Karl Determe 2026-08-14 22:08:13 +02:00
parent 34d8e51626
commit 441af33ddd
4 changed files with 331 additions and 78 deletions

View file

@ -376,6 +376,55 @@ class Discussion(models.Model):
archived_at__isnull=True, archived_at__isnull=True,
).count() ).count()
@property
def type_info(self):
"""Retourne les classes de style, l'icône et le libellé du type d'objet lié."""
model_name = self.content_type.model.lower() if self.content_type else ''
lbl = (self.object_label or '').lower()
title_low = (self.title or '').lower()
if model_name == 'intervention' or lbl.startswith('intervention') or title_low.startswith('intervention'):
return {
'icon': 'bi-tools',
'bg_class': 'bg-primary-subtle',
'text_class': 'text-primary',
'border_class': 'border-primary-subtle',
'label': _("Intervention"),
}
elif model_name == 'observation' or lbl.startswith('observation') or title_low.startswith('observation'):
return {
'icon': 'bi-eye-fill',
'bg_class': 'bg-warning-subtle',
'text_class': 'text-warning-emphasis',
'border_class': 'border-warning-subtle',
'label': _("Observation"),
}
elif model_name == 'preparationorder' or 'préparation' in lbl or 'preparation' in lbl:
return {
'icon': 'bi-box-seam-fill',
'bg_class': 'bg-info-subtle',
'text_class': 'text-info-emphasis',
'border_class': 'border-info-subtle',
'label': _("Ordre de préparation"),
}
elif model_name == 'project' or lbl.startswith('projet') or title_low.startswith('projet'):
return {
'icon': 'bi-kanban-fill',
'bg_class': 'bg-success-subtle',
'text_class': 'text-success-emphasis',
'border_class': 'border-success-subtle',
'label': _("Projet"),
}
else:
return {
'icon': 'bi-chat-dots-fill',
'bg_class': 'bg-indigo-subtle',
'text_class': 'text-primary',
'border_class': 'border-primary-subtle',
'label': _("Générale"),
}
class Meta: class Meta:
verbose_name = _("Discussion") verbose_name = _("Discussion")
verbose_name_plural = _("Discussions") verbose_name_plural = _("Discussions")

View file

@ -1,30 +1,63 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load mention_tags %}
{% block head %} {% block head %}
<style> <style>
.discussion-item { .discussion-item {
transition: background 0.15s; transition: all 0.15s ease-in-out;
border-left: 4px solid transparent; border-left: 4px solid transparent;
} }
.discussion-item.has-unread { .discussion-item.has-unread {
background-color: #f0f4ff; background-color: #f4f7ff;
border-left-color: var(--brand-color, #2f3e8b); border-left-color: var(--brand-color, #2f3e8b);
} }
.discussion-item:hover { .discussion-item:hover {
background-color: #e8edf8; background-color: #edf2fc;
text-decoration: none; text-decoration: none;
} }
.avatar-sm { .avatar-discussion {
width: 36px; width: 44px;
height: 36px; height: 44px;
font-size: 0.85rem; border-radius: 12px;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar-circle {
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid #ffffff;
margin-left: -6px;
transition: transform 0.15s ease, z-index 0.15s ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
cursor: default;
}
.avatar-group .avatar-circle:first-child {
margin-left: 0;
}
.avatar-group .avatar-circle:hover {
transform: translateY(-2px) scale(1.15);
z-index: 5;
}
.avatar-group .avatar-more {
background-color: #f1f5f9;
color: #475569;
font-size: 0.68rem;
font-weight: 700;
} }
</style> </style>
{% endblock %} {% endblock %}
@ -50,64 +83,95 @@
</div> </div>
{% if not items %} {% if not items %}
<div class="card shadow-sm"> <div class="card border-0 shadow-sm rounded-3">
<div class="card-body text-center py-5 text-muted"> <div class="card-body text-center py-5 text-muted">
<i class="bi bi-chat-square fs-1 d-block mb-3"></i> <i class="bi bi-chat-square fs-1 d-block mb-3 text-primary opacity-50"></i>
<p class="mb-0">{% translate "Vous n'avez encore aucune discussion." %}</p> <p class="mb-0">{% translate "Vous n'avez encore aucune discussion." %}</p>
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="card shadow-sm"> <div class="card border-0 shadow-sm rounded-3 overflow-hidden">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% for item in items %} {% for item in items %}
<a href="{% url 'notifications:discussion_detail' item.discussion.pk %}" <a href="{% url 'notifications:discussion_detail' item.discussion.pk %}"
class="list-group-item list-group-item-action discussion-item px-4 py-3 {% if item.unread_count %}has-unread{% endif %}"> class="list-group-item list-group-item-action discussion-item px-3 px-md-4 py-3 {% if item.unread_count %}has-unread{% endif %}">
<div class="d-flex align-items-start gap-3"> <div class="d-flex align-items-start gap-3">
<!-- Icône --> <!-- Icône typée -->
<div class="avatar-sm bg-primary text-white"> {% with type_info=item.discussion.type_info %}
<i class="bi bi-chat-dots"></i> <div class="avatar-discussion {{ type_info.bg_class }} {{ type_info.text_class }} border {{ type_info.border_class }}" title="{{ type_info.label }}">
<i class="bi {{ type_info.icon }} fs-5"></i>
</div> </div>
{% endwith %}
<!-- Contenu --> <!-- Contenu -->
<div class="flex-grow-1 min-width-0"> <div class="flex-grow-1 min-width-0">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start gap-2 mb-1">
<div> <div class="d-flex align-items-center gap-2 flex-wrap min-width-0">
<span class="fw-semibold">{{ item.discussion.title|default:item.discussion }}</span> <span class="fw-semibold text-dark text-truncate" style="font-size: 0.975rem;">
{% if item.discussion.object_label %} {{ item.discussion.title|default:item.discussion }}
<span class="badge bg-light text-secondary ms-2 small"> </span>
<i class="bi bi-link-45deg me-1"></i>{{ item.discussion.object_label }} {% if item.discussion.object_label and item.discussion.object_label != item.discussion.title %}
<span class="badge bg-light text-secondary border small rounded-pill px-2 py-0.5">
<i class="bi bi-link-45deg me-0.5"></i>{{ item.discussion.object_label }}
</span>
{% endif %}
{% if item.discussion.is_closed %}
<span class="badge bg-secondary-subtle text-secondary small rounded-pill px-2 py-0.5">
<i class="bi bi-lock-fill me-0.5"></i>{% translate "Clôturée" %}
</span> </span>
{% endif %} {% endif %}
</div> </div>
<span class="text-muted small ms-3 text-nowrap"> <span class="text-muted small ms-2 text-nowrap d-flex align-items-center gap-1">
<i class="bi bi-clock opacity-75"></i>
{{ item.discussion.updated_at|date:"d/m/Y H:i" }} {{ item.discussion.updated_at|date:"d/m/Y H:i" }}
</span> </span>
</div> </div>
<!-- Aperçu du message nettoyé des tags @[Nom](u:id) -->
{% if item.last_message %} {% if item.last_message %}
<p class="mb-1 small text-muted text-truncate mt-1" style="max-width:600px;"> <p class="mb-2 small text-muted text-truncate mt-0.5" style="max-width: 780px;">
<strong>{{ item.last_message.author.get_full_name|default:item.last_message.author.username }}</strong> : <strong class="text-secondary">{{ item.last_message.author.get_full_name|default:item.last_message.author.username }}</strong> :
{{ item.last_message.content|truncatechars:120 }} <span class="text-dark opacity-75">{{ item.last_message.content|clean_mentions_preview|truncatechars:140 }}</span>
</p> </p>
{% else %} {% else %}
<p class="mb-1 small text-muted mt-1">{% translate "Aucun message encore." %}</p> <p class="mb-2 small text-muted mt-0.5 fst-italic">{% translate "Aucun message encore." %}</p>
{% endif %} {% endif %}
<!-- Participants -->
<div class="d-flex align-items-center gap-1 mt-1"> <!-- Pied de ligne : Participants en avatars empilés & compteur non lu -->
{% for p in item.discussion.participants.all|slice:":6" %} <div class="d-flex align-items-center justify-content-between flex-wrap gap-2 pt-1">
<span class="badge bg-secondary" title="{{ p.get_full_name|default:p.username }}"> <div class="d-flex align-items-center gap-2">
{{ p.get_full_name|default:p.username|truncatechars:12 }} <span class="text-muted small d-none d-sm-inline" style="font-size: 0.78rem;">
<i class="bi bi-people me-1 opacity-75"></i>{% translate "Participants" %} :
</span>
<div class="avatar-group">
{% for p in item.discussion.participants.all|slice:":6" %}
<div class="avatar-circle"
style="background-color: {{ p|avatar_bg_color }}; color: {{ p|avatar_text_color }};"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{{ p.get_full_name|default:p.username }}">
{{ p|user_initials }}
</div>
{% endfor %}
{% if item.discussion.participants.count > 6 %}
<div class="avatar-circle avatar-more"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{{ item.discussion.participants.count }} {% translate 'participants au total' %}">
+{{ item.discussion.participants.count|add:"-6" }}
</div>
{% endif %}
</div>
</div>
<!-- Badge non-lus -->
{% if item.unread_count %}
<span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: var(--brand-color, #2f3e8b) !important;">
{{ item.unread_count }} {% translate "non lu(s)" %}
</span> </span>
{% endfor %}
{% if item.discussion.participants.count > 6 %}
<span class="badge bg-light text-muted">+{{ item.discussion.participants.count|add:"-6" }}</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Badge non-lus -->
{% if item.unread_count %}
<span class="badge bg-primary rounded-pill align-self-center">{{ item.unread_count }}</span>
{% endif %}
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
@ -118,3 +182,13 @@
</div> </div>
{% include "notifications/_new_discussion_modal.html" %} {% include "notifications/_new_discussion_modal.html" %}
{% endblock content %} {% endblock content %}
{% block script %}
<script>
document.addEventListener('DOMContentLoaded', () => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
});
</script>
{% endblock script %}

View file

@ -2,6 +2,7 @@
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
{% load roles %} {% load roles %}
{% load mention_tags %}
{% block head %} {% block head %}
<style> <style>
@ -21,26 +22,58 @@
padding: 2px 6px; padding: 2px 6px;
} }
.discussion-item { .discussion-item {
transition: background 0.15s; transition: all 0.15s ease-in-out;
border-left: 4px solid transparent; border-left: 4px solid transparent;
} }
.discussion-item.has-unread { .discussion-item.has-unread {
background-color: #f0f4ff; background-color: #f4f7ff;
border-left-color: var(--brand-color, #2f3e8b); border-left-color: var(--brand-color, #2f3e8b);
} }
.discussion-item:hover { .discussion-item:hover {
background-color: #e8edf8; background-color: #edf2fc;
text-decoration: none; text-decoration: none;
} }
.avatar-sm { .avatar-discussion {
width: 38px; width: 44px;
height: 38px; height: 44px;
font-size: 0.9rem; border-radius: 12px;
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar-circle {
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid #ffffff;
margin-left: -6px;
transition: transform 0.15s ease, z-index 0.15s ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
cursor: default;
}
.avatar-group .avatar-circle:first-child {
margin-left: 0;
}
.avatar-group .avatar-circle:hover {
transform: translateY(-2px) scale(1.15);
z-index: 5;
}
.avatar-group .avatar-more {
background-color: #f1f5f9;
color: #475569;
font-size: 0.68rem;
font-weight: 700;
} }
.inbox-nav-tabs .nav-link { .inbox-nav-tabs .nav-link {
font-size: 1.05rem; font-size: 1.05rem;
@ -327,64 +360,95 @@
aria-labelledby="tab-discussions-btn"> aria-labelledby="tab-discussions-btn">
{% if not discussion_items %} {% if not discussion_items %}
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm rounded-3">
<div class="card-body text-center py-5 text-muted"> <div class="card-body text-center py-5 text-muted">
<i class="bi bi-chat-square fs-1 d-block mb-3"></i> <i class="bi bi-chat-square fs-1 d-block mb-3 text-primary opacity-50"></i>
<p class="mb-0">{% translate "Vous n'avez encore aucune discussion." %}</p> <p class="mb-0">{% translate "Vous n'avez encore aucune discussion." %}</p>
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm rounded-3 overflow-hidden">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% for item in discussion_items %} {% for item in discussion_items %}
<a href="{% url 'notifications:discussion_detail' item.discussion.pk %}" <a href="{% url 'notifications:discussion_detail' item.discussion.pk %}"
class="list-group-item list-group-item-action discussion-item px-4 py-3 {% if item.unread_count %}has-unread{% endif %}"> class="list-group-item list-group-item-action discussion-item px-3 px-md-4 py-3 {% if item.unread_count %}has-unread{% endif %}">
<div class="d-flex align-items-start gap-3"> <div class="d-flex align-items-start gap-3">
<!-- Icône --> <!-- Icône typée -->
<div class="avatar-sm bg-primary text-white"> {% with type_info=item.discussion.type_info %}
<i class="bi bi-chat-dots"></i> <div class="avatar-discussion {{ type_info.bg_class }} {{ type_info.text_class }} border {{ type_info.border_class }}" title="{{ type_info.label }}">
<i class="bi {{ type_info.icon }} fs-5"></i>
</div> </div>
{% endwith %}
<!-- Contenu --> <!-- Contenu -->
<div class="flex-grow-1 min-width-0"> <div class="flex-grow-1 min-width-0">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start gap-2 mb-1">
<div> <div class="d-flex align-items-center gap-2 flex-wrap min-width-0">
<span class="fw-semibold text-dark">{{ item.discussion.title|default:item.discussion }}</span> <span class="fw-semibold text-dark text-truncate" style="font-size: 0.975rem;">
{% if item.discussion.object_label %} {{ item.discussion.title|default:item.discussion }}
<span class="badge bg-light text-secondary ms-2 small border"> </span>
<i class="bi bi-link-45deg me-1"></i>{{ item.discussion.object_label }} {% if item.discussion.object_label and item.discussion.object_label != item.discussion.title %}
<span class="badge bg-light text-secondary border small rounded-pill px-2 py-0.5">
<i class="bi bi-link-45deg me-0.5"></i>{{ item.discussion.object_label }}
</span>
{% endif %}
{% if item.discussion.is_closed %}
<span class="badge bg-secondary-subtle text-secondary small rounded-pill px-2 py-0.5">
<i class="bi bi-lock-fill me-0.5"></i>{% translate "Clôturée" %}
</span> </span>
{% endif %} {% endif %}
</div> </div>
<span class="text-muted small ms-3 text-nowrap"> <span class="text-muted small ms-2 text-nowrap d-flex align-items-center gap-1">
<i class="bi bi-clock opacity-75"></i>
{{ item.discussion.updated_at|date:"d/m/Y H:i" }} {{ item.discussion.updated_at|date:"d/m/Y H:i" }}
</span> </span>
</div> </div>
<!-- Aperçu du message nettoyé des tags @[Nom](u:id) -->
{% if item.last_message %} {% if item.last_message %}
<p class="mb-1 small text-muted text-truncate mt-1" style="max-width:650px;"> <p class="mb-2 small text-muted text-truncate mt-0.5" style="max-width: 780px;">
<strong>{{ item.last_message.author.get_full_name|default:item.last_message.author.username }}</strong> : <strong class="text-secondary">{{ item.last_message.author.get_full_name|default:item.last_message.author.username }}</strong> :
{{ item.last_message.content|truncatechars:120 }} <span class="text-dark opacity-75">{{ item.last_message.content|clean_mentions_preview|truncatechars:140 }}</span>
</p> </p>
{% else %} {% else %}
<p class="mb-1 small text-muted mt-1">{% translate "Aucun message encore." %}</p> <p class="mb-2 small text-muted mt-0.5 fst-italic">{% translate "Aucun message encore." %}</p>
{% endif %} {% endif %}
<!-- Participants -->
<div class="d-flex align-items-center gap-1 mt-1"> <!-- Pied de ligne : Participants en avatars empilés & compteur non lu -->
{% for p in item.discussion.participants.all|slice:":6" %} <div class="d-flex align-items-center justify-content-between flex-wrap gap-2 pt-1">
<span class="badge bg-secondary" title="{{ p.get_full_name|default:p.username }}"> <div class="d-flex align-items-center gap-2">
{{ p.get_full_name|default:p.username|truncatechars:12 }} <span class="text-muted small d-none d-sm-inline" style="font-size: 0.78rem;">
<i class="bi bi-people me-1 opacity-75"></i>{% translate "Participants" %} :
</span>
<div class="avatar-group">
{% for p in item.discussion.participants.all|slice:":6" %}
<div class="avatar-circle"
style="background-color: {{ p|avatar_bg_color }}; color: {{ p|avatar_text_color }};"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{{ p.get_full_name|default:p.username }}">
{{ p|user_initials }}
</div>
{% endfor %}
{% if item.discussion.participants.count > 6 %}
<div class="avatar-circle avatar-more"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="{{ item.discussion.participants.count }} {% translate 'participants au total' %}">
+{{ item.discussion.participants.count|add:"-6" }}
</div>
{% endif %}
</div>
</div>
<!-- Badge non-lus -->
{% if item.unread_count %}
<span class="badge rounded-pill bg-primary px-2.5 py-1 small fw-semibold shadow-sm" style="background-color: var(--brand-color, #2f3e8b) !important;">
{{ item.unread_count }} {% translate "non lu(s)" %}
</span> </span>
{% endfor %}
{% if item.discussion.participants.count > 6 %}
<span class="badge bg-light text-muted border">+{{ item.discussion.participants.count|add:"-6" }}</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Badge non-lus -->
{% if item.unread_count %}
<span class="badge rounded-pill align-self-center" style="background-color: var(--brand-color, #2f3e8b);">{{ item.unread_count }}</span>
{% endif %}
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
@ -407,6 +471,10 @@ const CSRF_TOKEN = "{{ csrf_token }}";
// ---- Tab Switching & State Persistence ---- // ---- Tab Switching & State Persistence ----
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Initialiser les tooltips Bootstrap
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
const notifTabBtn = document.getElementById('tab-notifications-btn'); const notifTabBtn = document.getElementById('tab-notifications-btn');
const discTabBtn = document.getElementById('tab-discussions-btn'); const discTabBtn = document.getElementById('tab-discussions-btn');
const notifActions = document.getElementById('actions-notifications'); const notifActions = document.getElementById('actions-notifications');

View file

@ -62,3 +62,65 @@ def render_mentions(value, is_mine=False):
html = ''.join(parts).replace('\n', '<br>') html = ''.join(parts).replace('\n', '<br>')
return mark_safe(html) return mark_safe(html)
@register.filter
def clean_mentions_preview(value):
"""
Supprime la syntaxe markdown @[Nom](u:123) ou @[Équipe](t:456) pour afficher un @Nom propre
dans les aperçus de messages, et normalise les retours à la ligne en espaces.
"""
if not value:
return ''
cleaned = MENTION_PATTERN.sub(r'@\1', str(value))
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
return cleaned
@register.filter
def user_initials(user):
"""Extrait 1 ou 2 initiales à partir d'un objet User ou d'un nom."""
if not user:
return '?'
first = getattr(user, 'first_name', '') or ''
last = getattr(user, 'last_name', '') or ''
if first and last:
return f"{first[0].upper()}{last[0].upper()}"
if first:
return first[:2].upper()
if last:
return last[:2].upper()
username = getattr(user, 'username', str(user)) or '?'
return username[:2].upper()
AVATAR_PALETTE = [
('#e0e7ff', '#3730a3'), # Indigo
('#dbeafe', '#1e40af'), # Blue
('#dcfce7', '#166534'), # Green
('#fef3c7', '#92400e'), # Amber
('#fce7f3', '#9d174d'), # Pink
('#f3e8ff', '#6b21a8'), # Purple
('#ffedd5', '#9a3412'), # Orange
('#ccfbf1', '#115e59'), # Teal
]
def _get_user_palette(user):
import hashlib
ident = str(getattr(user, 'pk', '') or getattr(user, 'username', str(user)))
idx = int(hashlib.md5(ident.encode('utf-8')).hexdigest(), 16) % len(AVATAR_PALETTE)
return AVATAR_PALETTE[idx]
@register.filter
def avatar_bg_color(user):
"""Couleur de fond pastel pour l'avatar du participant."""
return _get_user_palette(user)[0]
@register.filter
def avatar_text_color(user):
"""Couleur de texte assortie pour l'avatar du participant."""
return _get_user_palette(user)[1]