feat: implement notification inbox and discussion management views with corresponding templates and reminder command
This commit is contained in:
parent
07375e3be0
commit
d3484e626d
6 changed files with 513 additions and 371 deletions
|
|
@ -102,7 +102,7 @@ class Command(BaseCommand):
|
|||
html_items = []
|
||||
for r in valid_recipients:
|
||||
n = r.notification
|
||||
created_str = n.created_at.strftime('%d/%m/%Y à %H:%i')
|
||||
created_str = n.created_at.strftime('%d/%m/%Y à %H:%M')
|
||||
sender_str = n.sender.get_full_name() if n.sender else _("Système")
|
||||
link = f"{domain}{n.action_url}" if n.action_url else f"{domain}/notifications/"
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
|
||||
<!-- Breadcrumb / Header -->
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<a href="{% url 'notifications:discussion_list' %}" class="btn btn-sm btn-outline-secondary rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:36px; height:36px;">
|
||||
<a href="{% url 'notifications:inbox' %}?tab=discussions" class="btn btn-sm btn-outline-secondary rounded-circle p-2 d-flex align-items-center justify-content-center" style="width:36px; height:36px;" title="{% translate 'Retour aux discussions' %}">
|
||||
<i class="bi bi-arrow-left fs-6"></i>
|
||||
</a>
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
|
|
|
|||
|
|
@ -20,221 +20,446 @@
|
|||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.discussion-item {
|
||||
transition: background 0.15s;
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
.discussion-item.has-unread {
|
||||
background-color: #f0f4ff;
|
||||
border-left-color: var(--brand-color, #2f3e8b);
|
||||
}
|
||||
.discussion-item:hover {
|
||||
background-color: #e8edf8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.avatar-sm {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.inbox-nav-tabs .nav-link {
|
||||
font-size: 1.05rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
}
|
||||
.inbox-nav-tabs .nav-link:hover {
|
||||
color: var(--brand-color, #2f3e8b);
|
||||
border-bottom-color: #dee2e6;
|
||||
}
|
||||
.inbox-nav-tabs .nav-link.active {
|
||||
color: var(--brand-color, #2f3e8b);
|
||||
font-weight: 700;
|
||||
border-bottom-color: var(--brand-color, #2f3e8b);
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">
|
||||
<i class="bi bi-bell me-2"></i>{% translate "Notifications" %}
|
||||
{% if unread_count > 0 %}
|
||||
<span class="badge rounded-pill ms-2" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;">
|
||||
{{ unread_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="d-flex gap-2">
|
||||
{% if unread_count > 0 %}
|
||||
<form method="post" action="{% url 'notifications:mark_all_read' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-check2-all me-1"></i>{% translate "Tout marquer comme lu" %}
|
||||
<!-- Header with Tabs and Contextual Actions -->
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3 border-bottom pb-2">
|
||||
<!-- Tabs -->
|
||||
<ul class="nav inbox-nav-tabs mb-0 flex-nowrap" id="inboxTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if active_tab != 'discussions' %}active{% endif %}"
|
||||
id="tab-notifications-btn"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#pane-notifications"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="pane-notifications"
|
||||
aria-selected="{% if active_tab != 'discussions' %}true{% else %}false{% endif %}">
|
||||
<i class="bi bi-bell me-2"></i>{% translate "Notifications" %}
|
||||
{% if unread_count > 0 %}
|
||||
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;">
|
||||
{{ unread_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if user.config|has_any_role:"admin" %}
|
||||
<a href="{% url 'notifications:create' %}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% translate "Nouvelle notification" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'notifications:discussion_list' %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-chat-left-text me-1"></i>{% translate "Discussions" %}
|
||||
</a>
|
||||
<a href="{% url 'notifications:settings' %}" class="btn btn-outline-secondary btn-sm">
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if active_tab == 'discussions' %}active{% endif %}"
|
||||
id="tab-discussions-btn"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#pane-discussions"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="pane-discussions"
|
||||
aria-selected="{% if active_tab == 'discussions' %}true{% else %}false{% endif %}">
|
||||
<i class="bi bi-chat-dots me-2"></i>{% translate "Discussions" %}
|
||||
{% if unread_discussions_count > 0 %}
|
||||
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.75rem;">
|
||||
{{ unread_discussions_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Action buttons (contextual according to active tab) -->
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<!-- Notifications Tab Actions -->
|
||||
<div id="actions-notifications" class="d-flex gap-2 align-items-center {% if active_tab == 'discussions' %}d-none{% endif %}">
|
||||
{% if unread_count > 0 %}
|
||||
<form method="post" action="{% url 'notifications:mark_all_read' %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-check2-all me-1"></i>{% translate "Tout marquer comme lu" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if user.config|has_any_role:"admin" %}
|
||||
<a href="{% url 'notifications:create' %}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-circle me-1"></i>{% translate "Nouvelle notification" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Discussions Tab Actions -->
|
||||
<div id="actions-discussions" class="d-flex gap-2 align-items-center {% if active_tab != 'discussions' %}d-none{% endif %}">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
|
||||
<i class="bi bi-chat-plus-fill me-1"></i>{% translate "Nouvelle discussion" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Settings Button -->
|
||||
<a href="{% url 'notifications:settings' %}" class="btn btn-outline-secondary btn-sm" title="{% translate 'Paramètres' %}">
|
||||
<i class="bi bi-gear me-1"></i>{% translate "Paramètres" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-3 border-0 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-center">
|
||||
<div class="col-auto">
|
||||
<select name="type" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% translate "Tous les types" %}</option>
|
||||
{% for val, label in notification_types %}
|
||||
<option value="{{ val }}" {% if filter_type == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="showArchived"
|
||||
name="archived" value="1"
|
||||
{% if show_archived %}checked{% endif %}
|
||||
onchange="this.form.submit()">
|
||||
<label class="form-check-label" for="showArchived">{% translate "Archivées" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% if filter_type or show_archived %}
|
||||
<div class="col-auto">
|
||||
<a href="{% url 'notifications:inbox' %}" class="btn btn-link btn-sm text-muted">
|
||||
<i class="bi bi-x-circle me-1"></i>{% translate "Réinitialiser" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tabs Content -->
|
||||
<div class="tab-content" id="inboxTabsContent">
|
||||
|
||||
<!-- Notifications list -->
|
||||
{% if notifications %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="list-group list-group-flush">
|
||||
{% for item in notifications %}
|
||||
{% with notif=item.notification %}
|
||||
<div class="list-group-item list-group-item-action notif-item
|
||||
{% if not item.is_read and not item.is_archived %}unread{% endif %}"
|
||||
id="notif-row-{{ item.recipient_id }}-{{ item.recipient_type }}">
|
||||
<div class="d-flex align-items-start gap-3 py-1">
|
||||
<!-- ================= Pane 1: Notifications ================= -->
|
||||
<div class="tab-pane fade {% if active_tab != 'discussions' %}show active{% endif %}"
|
||||
id="pane-notifications"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-notifications-btn">
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<span class="text-{{ notif.type_color }} fs-4">
|
||||
<i class="bi {{ notif.type_icon }}"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<div>
|
||||
<span class="fw-semibold {% if not item.is_read and not item.is_archived %}text-dark{% else %}text-muted{% endif %}">
|
||||
{{ notif.title }}
|
||||
</span>
|
||||
{% if not item.is_read and not item.is_archived %}
|
||||
<span class="badge bg-primary notif-badge-type ms-1">{% translate "Nouveau" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-shrink-0 align-items-center">
|
||||
<!-- Mark read checkbox -->
|
||||
{% if not item.is_read %}
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input mark-read-btn" type="checkbox"
|
||||
id="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}"
|
||||
data-pk="{{ item.recipient_id }}"
|
||||
data-type="{{ item.recipient_type }}"
|
||||
title="{% if notif.is_single_claim %}{% translate 'Prendre en charge' %}{% else %}{% translate 'Marquer comme lu' %}{% endif %}">
|
||||
<label class="form-check-label small text-muted"
|
||||
for="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}">
|
||||
{% if notif.is_single_claim %}
|
||||
{% translate "Prendre en charge" %}
|
||||
{% else %}
|
||||
{% translate "Marquer comme lu" %}
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Archive (personal only) -->
|
||||
{% if item.recipient_type == 'personal' and not item.is_archived %}
|
||||
<button class="btn btn-link btn-sm text-muted p-0 archive-btn"
|
||||
data-pk="{{ item.recipient_id }}"
|
||||
title="{% translate 'Archiver' %}">
|
||||
<i class="bi bi-archive"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<!-- Filters -->
|
||||
<div class="card mb-3 border-0 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-center" id="notifications-filter-form">
|
||||
<input type="hidden" name="tab" value="notifications">
|
||||
<div class="col-auto">
|
||||
<select name="type" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">{% translate "Tous les types" %}</option>
|
||||
{% for val, label in notification_types %}
|
||||
<option value="{{ val }}" {% if filter_type == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="showArchived"
|
||||
name="archived" value="1"
|
||||
{% if show_archived %}checked{% endif %}
|
||||
onchange="this.form.submit()">
|
||||
<label class="form-check-label" for="showArchived">{% translate "Archivées" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if notif.content %}
|
||||
<p class="mb-1 text-muted small mt-1">{{ notif.content|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mt-1">
|
||||
<!-- Metadata -->
|
||||
<span class="badge bg-{{ notif.type_color }} bg-opacity-10 text-{{ notif.type_color }} notif-badge-type">
|
||||
<i class="bi {{ notif.type_icon }} me-1"></i>{{ notif.get_notification_type_display }}
|
||||
</span>
|
||||
{% if item.team_name %}
|
||||
<span class="badge bg-secondary notif-badge-type">
|
||||
<i class="bi bi-people me-1"></i>{{ item.team_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if notif.source_app %}
|
||||
<span class="badge bg-light text-muted notif-badge-type">{{ notif.source_app }}</span>
|
||||
{% endif %}
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-clock me-1"></i>{{ notif.created_at|date:"d/m/Y H:i" }}
|
||||
</span>
|
||||
{% if notif.sender %}
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-person me-1"></i>{{ notif.sender.get_full_name|default:notif.sender.username }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if notif.expires_at %}
|
||||
<span class="text-muted small {% if notif.is_expired %}text-danger{% endif %}">
|
||||
<i class="bi bi-hourglass me-1"></i>{% translate "Expire le" %} {{ notif.expires_at|date:"d/m/Y H:i" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if notif.action_url and notif.action_url != request.path and notif.action_url != '/notifications/' and notif.action_url != '/notifications' %}
|
||||
<a href="{{ notif.action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
|
||||
{{ notif.action_label|default:notif.action_url }}
|
||||
<i class="bi bi-arrow-right ms-1"></i>
|
||||
{% if filter_type or show_archived %}
|
||||
<div class="col-auto">
|
||||
<a href="{% url 'notifications:inbox' %}" class="btn btn-link btn-sm text-muted">
|
||||
<i class="bi bi-x-circle me-1"></i>{% translate "Réinitialiser" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
{% with attachments=notif.attachments.all %}
|
||||
{% if attachments %}
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
{% for att in attachments %}
|
||||
{% if att.is_image %}
|
||||
<a href="{{ att.file.url }}" target="_blank" title="{{ att.name }}">
|
||||
<img src="{{ att.file.url }}" alt="{{ att.name }}"
|
||||
style="height:60px; width:60px; object-fit:cover; border-radius:4px; border:1px solid #dee2e6;">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ att.file.url }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||
<i class="bi bi-paperclip me-1"></i>{{ att.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications list -->
|
||||
{% if notifications %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="list-group list-group-flush">
|
||||
{% for item in notifications %}
|
||||
{% with notif=item.notification %}
|
||||
<div class="list-group-item list-group-item-action notif-item
|
||||
{% if not item.is_read and not item.is_archived %}unread{% endif %}"
|
||||
id="notif-row-{{ item.recipient_id }}-{{ item.recipient_type }}">
|
||||
<div class="d-flex align-items-start gap-3 py-1">
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<span class="text-{{ notif.type_color }} fs-4">
|
||||
<i class="bi {{ notif.type_icon }}"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<div>
|
||||
<span class="fw-semibold {% if not item.is_read and not item.is_archived %}text-dark{% else %}text-muted{% endif %}">
|
||||
{{ notif.title }}
|
||||
</span>
|
||||
{% if not item.is_read and not item.is_archived %}
|
||||
<span class="badge bg-primary notif-badge-type ms-1">{% translate "Nouveau" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-shrink-0 align-items-center">
|
||||
<!-- Mark read checkbox -->
|
||||
{% if not item.is_read %}
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input mark-read-btn" type="checkbox"
|
||||
id="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}"
|
||||
data-pk="{{ item.recipient_id }}"
|
||||
data-type="{{ item.recipient_type }}"
|
||||
title="{% if notif.is_single_claim %}{% translate 'Prendre en charge' %}{% else %}{% translate 'Marquer comme lu' %}{% endif %}">
|
||||
<label class="form-check-label small text-muted"
|
||||
for="mark-read-{{ item.recipient_id }}-{{ item.recipient_type }}">
|
||||
{% if notif.is_single_claim %}
|
||||
{% translate "Prendre en charge" %}
|
||||
{% else %}
|
||||
{% translate "Marquer comme lu" %}
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Archive (personal only) -->
|
||||
{% if item.recipient_type == 'personal' and not item.is_archived %}
|
||||
<button class="btn btn-link btn-sm text-muted p-0 archive-btn"
|
||||
data-pk="{{ item.recipient_id }}"
|
||||
title="{% translate 'Archiver' %}">
|
||||
<i class="bi bi-archive"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if notif.content %}
|
||||
<p class="mb-1 text-muted small mt-1">{{ notif.content|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mt-1">
|
||||
<!-- Metadata -->
|
||||
<span class="badge bg-{{ notif.type_color }} bg-opacity-10 text-{{ notif.type_color }} notif-badge-type">
|
||||
<i class="bi {{ notif.type_icon }} me-1"></i>{{ notif.get_notification_type_display }}
|
||||
</span>
|
||||
{% if item.team_name %}
|
||||
<span class="badge bg-secondary notif-badge-type">
|
||||
<i class="bi bi-people me-1"></i>{{ item.team_name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if notif.source_app %}
|
||||
<span class="badge bg-light text-muted notif-badge-type">{{ notif.source_app }}</span>
|
||||
{% endif %}
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-clock me-1"></i>{{ notif.created_at|date:"d/m/Y H:i" }}
|
||||
</span>
|
||||
{% if notif.sender %}
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-person me-1"></i>{{ notif.sender.get_full_name|default:notif.sender.username }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if notif.expires_at %}
|
||||
<span class="text-muted small {% if notif.is_expired %}text-danger{% endif %}">
|
||||
<i class="bi bi-hourglass me-1"></i>{% translate "Expire le" %} {{ notif.expires_at|date:"d/m/Y H:i" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if notif.action_url and notif.action_url != request.path and notif.action_url != '/notifications/' and notif.action_url != '/notifications' %}
|
||||
<a href="{{ notif.action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
|
||||
{{ notif.action_label|default:notif.action_url }}
|
||||
<i class="bi bi-arrow-right ms-1"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
{% with attachments=notif.attachments.all %}
|
||||
{% if attachments %}
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
{% for att in attachments %}
|
||||
{% if att.is_image %}
|
||||
<a href="{{ att.file.url }}" target="_blank" title="{{ att.name }}">
|
||||
<img src="{{ att.file.url }}" alt="{{ att.name }}"
|
||||
style="height:60px; width:60px; object-fit:cover; border-radius:4px; border:1px solid #dee2e6;">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ att.file.url }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||
<i class="bi bi-paperclip me-1"></i>{{ att.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
|
||||
<p class="mt-3">
|
||||
{% if show_archived %}
|
||||
{% translate "Aucune notification archivée." %}
|
||||
{% else %}
|
||||
{% translate "Vous n'avez aucune notification." %}
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
|
||||
<p class="mt-3">
|
||||
{% if show_archived %}
|
||||
{% translate "Aucune notification archivée." %}
|
||||
{% else %}
|
||||
{% translate "Vous n'avez aucune notification." %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ================= Pane 2: Discussions ================= -->
|
||||
<div class="tab-pane fade {% if active_tab == 'discussions' %}show active{% endif %}"
|
||||
id="pane-discussions"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-discussions-btn">
|
||||
|
||||
{% if not discussion_items %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
<i class="bi bi-chat-square fs-1 d-block mb-3"></i>
|
||||
<p class="mb-0">{% translate "Vous n'avez encore aucune discussion." %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="list-group list-group-flush">
|
||||
{% for item in discussion_items %}
|
||||
<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 %}">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<!-- Icône -->
|
||||
<div class="avatar-sm bg-primary text-white">
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="flex-grow-1 min-width-0">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<span class="fw-semibold text-dark">{{ item.discussion.title|default:item.discussion }}</span>
|
||||
{% if item.discussion.object_label %}
|
||||
<span class="badge bg-light text-secondary ms-2 small border">
|
||||
<i class="bi bi-link-45deg me-1"></i>{{ item.discussion.object_label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-muted small ms-3 text-nowrap">
|
||||
{{ item.discussion.updated_at|date:"d/m/Y H:i" }}
|
||||
</span>
|
||||
</div>
|
||||
{% if item.last_message %}
|
||||
<p class="mb-1 small text-muted text-truncate mt-1" style="max-width:650px;">
|
||||
<strong>{{ item.last_message.author.get_full_name|default:item.last_message.author.username }}</strong> :
|
||||
{{ item.last_message.content|truncatechars:120 }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="mb-1 small text-muted mt-1">{% translate "Aucun message encore." %}</p>
|
||||
{% endif %}
|
||||
<!-- Participants -->
|
||||
<div class="d-flex align-items-center gap-1 mt-1">
|
||||
{% for p in item.discussion.participants.all|slice:":6" %}
|
||||
<span class="badge bg-secondary" title="{{ p.get_full_name|default:p.username }}">
|
||||
{{ p.get_full_name|default:p.username|truncatechars:12 }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% if item.discussion.participants.count > 6 %}
|
||||
<span class="badge bg-light text-muted border">+{{ item.discussion.participants.count|add:"-6" }}</span>
|
||||
{% endif %}
|
||||
</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>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% include "notifications/_new_discussion_modal.html" %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const CSRF_TOKEN = "{{ csrf_token }}";
|
||||
|
||||
// ---- Tab Switching & State Persistence ----
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const notifTabBtn = document.getElementById('tab-notifications-btn');
|
||||
const discTabBtn = document.getElementById('tab-discussions-btn');
|
||||
const notifActions = document.getElementById('actions-notifications');
|
||||
const discActions = document.getElementById('actions-discussions');
|
||||
|
||||
function syncActionsToolbar(targetId) {
|
||||
if (targetId === '#pane-discussions' || targetId === 'discussions') {
|
||||
if (notifActions) notifActions.classList.add('d-none');
|
||||
if (discActions) discActions.classList.remove('d-none');
|
||||
} else {
|
||||
if (discActions) discActions.classList.add('d-none');
|
||||
if (notifActions) notifActions.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
if (notifTabBtn && discTabBtn) {
|
||||
notifTabBtn.addEventListener('shown.bs.tab', (e) => {
|
||||
syncActionsToolbar(e.target.getAttribute('data-bs-target'));
|
||||
if (history.replaceState) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete('tab');
|
||||
url.hash = 'notifications';
|
||||
history.replaceState(null, '', url.pathname + url.search + url.hash);
|
||||
}
|
||||
});
|
||||
|
||||
discTabBtn.addEventListener('shown.bs.tab', (e) => {
|
||||
syncActionsToolbar(e.target.getAttribute('data-bs-target'));
|
||||
if (history.replaceState) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('tab', 'discussions');
|
||||
url.hash = 'discussions';
|
||||
history.replaceState(null, '', url.pathname + url.search + url.hash);
|
||||
}
|
||||
});
|
||||
|
||||
// Check URL hash or query params on load
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const tabParam = urlParams.get('tab');
|
||||
const hash = window.location.hash;
|
||||
|
||||
if (tabParam === 'discussions' || hash === '#discussions') {
|
||||
const tab = bootstrap.Tab.getOrCreateInstance(discTabBtn);
|
||||
tab.show();
|
||||
syncActionsToolbar('discussions');
|
||||
} else if (tabParam === 'notifications' || hash === '#notifications') {
|
||||
const tab = bootstrap.Tab.getOrCreateInstance(notifTabBtn);
|
||||
tab.show();
|
||||
syncActionsToolbar('notifications');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Mark Read ----
|
||||
document.querySelectorAll('.mark-read-btn').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', async () => {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class DiscussionListTests(TestCase):
|
|||
self.user2 = User.objects.create_user(username='user2', password='pass')
|
||||
self.user3 = User.objects.create_user(username='user3', password='pass')
|
||||
|
||||
def test_discussion_list_filtering(self):
|
||||
def test_discussion_list_filtering_on_inbox(self):
|
||||
# Discussion 1: created by user1
|
||||
d1 = Discussion.objects.create(title="Discussion 1", created_by=self.user1)
|
||||
d1.participants.add(self.user1, self.user2, self.user3)
|
||||
|
|
@ -38,16 +38,23 @@ class DiscussionListTests(TestCase):
|
|||
Message.objects.create(discussion=d5, author=self.user2, content="Hello user3")
|
||||
|
||||
self.client.force_login(self.user1)
|
||||
response = self.client.get(reverse('notifications:discussion_list'))
|
||||
response = self.client.get(reverse('notifications:inbox') + '?tab=discussions')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context['active_tab'], 'discussions')
|
||||
|
||||
discussions_in_context = [item['discussion'] for item in response.context['items']]
|
||||
discussions_in_context = [item['discussion'] for item in response.context['discussion_items']]
|
||||
self.assertIn(d1, discussions_in_context)
|
||||
self.assertIn(d2, discussions_in_context)
|
||||
self.assertIn(d3, discussions_in_context)
|
||||
self.assertNotIn(d4, discussions_in_context)
|
||||
self.assertNotIn(d5, discussions_in_context)
|
||||
|
||||
def test_discussion_list_url_redirect(self):
|
||||
self.client.force_login(self.user1)
|
||||
response = self.client.get(reverse('notifications:discussion_list'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn('tab=discussions', response.url)
|
||||
|
||||
def test_discussion_create(self):
|
||||
self.client.force_login(self.user1)
|
||||
response = self.client.post(reverse('notifications:discussion_create'), {
|
||||
|
|
@ -62,6 +69,46 @@ class DiscussionListTests(TestCase):
|
|||
self.assertEqual(discussion.messages.count(), 1)
|
||||
|
||||
|
||||
class InboxTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='test_inbox_user', password='pass')
|
||||
from notifications.models import Notification, NotificationRecipient, Discussion
|
||||
# Create a notification for user
|
||||
notif = Notification.objects.create(
|
||||
title="Test notification",
|
||||
content="Notification content",
|
||||
notification_type=Notification.TYPE_INFO,
|
||||
)
|
||||
NotificationRecipient.objects.create(
|
||||
notification=notif,
|
||||
user=self.user,
|
||||
)
|
||||
# Create a discussion for user
|
||||
d = Discussion.objects.create(title="Test discussion", created_by=self.user)
|
||||
d.participants.add(self.user)
|
||||
|
||||
def test_inbox_renders_both_notifications_and_discussions(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse('notifications:inbox'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context['active_tab'], 'notifications')
|
||||
self.assertEqual(response.context['unread_count'], 1)
|
||||
self.assertEqual(len(response.context['notifications']), 1)
|
||||
self.assertEqual(len(response.context['discussion_items']), 1)
|
||||
self.assertContains(response, 'Test notification')
|
||||
self.assertContains(response, 'Test discussion')
|
||||
self.assertContains(response, 'id="pane-notifications"')
|
||||
self.assertContains(response, 'id="pane-discussions"')
|
||||
self.assertContains(response, 'id="tab-notifications-btn"')
|
||||
self.assertContains(response, 'id="tab-discussions-btn"')
|
||||
|
||||
def test_inbox_tab_discussions_parameter(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse('notifications:inbox') + '?tab=discussions')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context['active_tab'], 'discussions')
|
||||
|
||||
|
||||
class NotificationReminderTests(TestCase):
|
||||
def setUp(self):
|
||||
from common.models import UserConfig
|
||||
|
|
@ -87,6 +134,9 @@ class NotificationReminderTests(TestCase):
|
|||
self.assertEqual(self.config.notification_reminder_emails, 'custom@example.com, other@example.com')
|
||||
self.assertEqual(self.config.notification_reminder_delay_hours, 12)
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
@override_settings(EMAIL_OVERRIDE=None)
|
||||
def test_send_unread_notification_reminders_command(self):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
|
|
@ -183,10 +184,46 @@ def _translate_notification_data(notification):
|
|||
}
|
||||
|
||||
|
||||
def _get_user_discussions_data(user):
|
||||
"""Récupère les discussions de l'utilisateur avec leur dernier message et compteur non-lus."""
|
||||
user_team_ids = list(user.notification_teams.values_list('id', flat=True))
|
||||
|
||||
mention_q = Q(messages__content__icontains=f'(u:{user.pk})')
|
||||
for tid in user_team_ids:
|
||||
mention_q |= Q(messages__content__icontains=f'(t:{tid})')
|
||||
|
||||
discussion_filter = (
|
||||
Q(messages__author=user, messages__is_deleted=False)
|
||||
| Q(notifications__recipients__user=user)
|
||||
| Q(notifications__team_targets__team_id__in=user_team_ids)
|
||||
| (mention_q & Q(messages__is_deleted=False))
|
||||
| (Q(created_by=user) & Q(content_type__isnull=True))
|
||||
)
|
||||
|
||||
discussions = (
|
||||
Discussion.objects.filter(discussion_filter)
|
||||
.distinct()
|
||||
.prefetch_related('participants', 'messages')
|
||||
.select_related('created_by')
|
||||
.order_by('-updated_at')
|
||||
)
|
||||
items = []
|
||||
for d in discussions:
|
||||
last_msg = d.messages.filter(is_deleted=False).order_by('-created_at').first()
|
||||
items.append({
|
||||
'discussion': d,
|
||||
'last_message': last_msg,
|
||||
'unread_count': d.unread_count_for(user),
|
||||
})
|
||||
unread_discussions_count = sum(1 for item in items if item['unread_count'] > 0)
|
||||
return items, unread_discussions_count
|
||||
|
||||
|
||||
def _get_inbox_context(request):
|
||||
"""Construit le contexte de la boîte de réception (desktop/mobile)."""
|
||||
show_archived = request.GET.get('archived') == '1'
|
||||
notification_type = request.GET.get('type', '')
|
||||
active_tab = request.GET.get('tab', 'notifications')
|
||||
|
||||
all_notifications = get_user_notifications(request.user, include_archived=show_archived)
|
||||
|
||||
|
|
@ -204,12 +241,17 @@ def _get_inbox_context(request):
|
|||
|
||||
unread_count = sum(1 for n in all_notifications if not n['is_read'] and not n['is_archived'])
|
||||
|
||||
discussion_items, unread_discussions_count = _get_user_discussions_data(request.user)
|
||||
|
||||
return {
|
||||
'notifications': all_notifications,
|
||||
'unread_count': unread_count,
|
||||
'show_archived': show_archived,
|
||||
'filter_type': notification_type,
|
||||
'notification_types': Notification.TYPES,
|
||||
'discussion_items': discussion_items,
|
||||
'unread_discussions_count': unread_discussions_count,
|
||||
'active_tab': active_tab,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -477,41 +519,10 @@ def team_delete(request, pk):
|
|||
@login_required
|
||||
def discussion_list(request):
|
||||
"""
|
||||
Liste les discussions dont l'utilisateur est l'auteur (créateur de la discussion
|
||||
ou auteur d'un message) ou pour lesquelles il (ou une de ses équipes) a été explicitement taggé.
|
||||
Redirige vers la boîte de réception sur l'onglet discussions
|
||||
(permet de préserver la compatibilité avec les anciens liens et routes).
|
||||
"""
|
||||
user = request.user
|
||||
user_team_ids = list(user.notification_teams.values_list('id', flat=True))
|
||||
|
||||
mention_q = Q(messages__content__icontains=f'(u:{user.pk})')
|
||||
for tid in user_team_ids:
|
||||
mention_q |= Q(messages__content__icontains=f'(t:{tid})')
|
||||
|
||||
discussion_filter = (
|
||||
Q(messages__author=user, messages__is_deleted=False)
|
||||
| Q(notifications__recipients__user=user)
|
||||
| Q(notifications__team_targets__team_id__in=user_team_ids)
|
||||
| (mention_q & Q(messages__is_deleted=False))
|
||||
| (Q(created_by=user) & Q(content_type__isnull=True))
|
||||
)
|
||||
|
||||
discussions = (
|
||||
Discussion.objects.filter(discussion_filter)
|
||||
.distinct()
|
||||
.prefetch_related('participants', 'messages')
|
||||
.select_related('created_by')
|
||||
.order_by('-updated_at')
|
||||
)
|
||||
# Enrichir avec le compteur de non-lus et le dernier message
|
||||
items = []
|
||||
for d in discussions:
|
||||
last_msg = d.messages.filter(is_deleted=False).order_by('-created_at').first()
|
||||
items.append({
|
||||
'discussion': d,
|
||||
'last_message': last_msg,
|
||||
'unread_count': d.unread_count_for(request.user),
|
||||
})
|
||||
return render(request, 'notifications/discussion_list.html', {'items': items})
|
||||
return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
|
||||
|
||||
|
||||
@login_required
|
||||
|
|
@ -548,7 +559,7 @@ def discussion_detail(request, pk):
|
|||
|
||||
if not has_access:
|
||||
messages.error(request, _("Vous n'êtes pas participant de cette discussion."))
|
||||
return redirect('notifications:discussion_list')
|
||||
return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
|
||||
|
||||
if request.method == 'POST':
|
||||
content = request.POST.get('content', '').strip()
|
||||
|
|
@ -603,7 +614,7 @@ def discussion_create(request):
|
|||
else:
|
||||
messages.error(request, _("Le contenu du message ne peut pas être vide."))
|
||||
|
||||
return redirect('notifications:discussion_list')
|
||||
return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
|
||||
|
||||
|
||||
@login_required
|
||||
|
|
|
|||
|
|
@ -207,10 +207,8 @@
|
|||
{% endwith %}
|
||||
|
||||
<!-- Notifications -->
|
||||
<li class="nav-item dropdown">
|
||||
<a href="#" class="nav-link position-relative" id="notifDropdown"
|
||||
data-bs-toggle="dropdown" aria-expanded="false"
|
||||
data-bs-auto-close="outside">
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'notifications:inbox' %}" class="nav-link position-relative" id="navbar-notif-link" title="{% translate 'Notifications' %}">
|
||||
<i class="bi bi-bell" style="font-size: 1.5rem;"></i>
|
||||
<span id="navbar-notif-badge"
|
||||
class="position-absolute translate-middle badge rounded-pill"
|
||||
|
|
@ -220,42 +218,6 @@
|
|||
<span class="visually-hidden">{% translate "notifications non lues" %}</span>
|
||||
</span>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-0 shadow"
|
||||
aria-labelledby="notifDropdown"
|
||||
style="min-width: 360px; max-width: 420px;">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom bg-light rounded-top">
|
||||
<span class="fw-semibold small">{% translate "Notifications" %}</span>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button id="markAllReadBtn"
|
||||
class="btn btn-link btn-sm p-0 text-muted small"
|
||||
title="{% translate 'Tout marquer comme lu' %}">
|
||||
<i class="bi bi-check2-all"></i>
|
||||
</button>
|
||||
<a href="{% url 'notifications:inbox' %}" class="btn btn-link btn-sm p-0 text-muted small">
|
||||
{% translate "Voir tout" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Notification list (populated by JS) -->
|
||||
<div id="notif-dropdown-list"
|
||||
style="max-height: 380px; overflow-y: auto; overflow-x: hidden;">
|
||||
<div class="text-center text-muted py-4 small" id="notif-dropdown-empty">
|
||||
<i class="bi bi-bell-slash d-block mb-1" style="font-size:1.5rem;"></i>
|
||||
{% translate "Aucune notification non lue" %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="border-top bg-light rounded-bottom py-2 px-3 d-flex justify-content-between align-items-center">
|
||||
<a href="{% url 'notifications:discussion_list' %}" class="small text-secondary text-decoration-none">
|
||||
<i class="bi bi-chat-dots me-1"></i>{% translate "Discussions" %}
|
||||
</a>
|
||||
<a href="{% url 'notifications:inbox' %}" class="small text-primary text-decoration-none">
|
||||
{% translate "Boîte de réception" %}
|
||||
<i class="bi bi-arrow-right ms-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Help Button -->
|
||||
|
|
@ -457,16 +419,8 @@
|
|||
{% endblock script %}
|
||||
|
||||
<script>
|
||||
/* ── Notification dropdown ───────────────────────────────────────────── */
|
||||
/* ── Notification badge update ───────────────────────────────────────── */
|
||||
(function () {
|
||||
const CSRF = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
|
||||
const API_RECENT = "{% url 'notifications:api_recent' %}";
|
||||
const MARK_READ_BASE = "{% url 'notifications:mark_read' 0 %}";
|
||||
const MARK_ALL = "{% url 'notifications:mark_all_read' %}";
|
||||
|
||||
const dropdown = document.getElementById('notifDropdown');
|
||||
const list = document.getElementById('notif-dropdown-list');
|
||||
const empty = document.getElementById('notif-dropdown-empty');
|
||||
const badge = document.getElementById('navbar-notif-badge');
|
||||
|
||||
function updateBadge(count) {
|
||||
|
|
@ -486,104 +440,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
const TYPE_ICONS = {
|
||||
message: 'bi-chat-dots', reminder: 'bi-alarm',
|
||||
task: 'bi-check2-square', alert: 'bi-exclamation-triangle', info: 'bi-info-circle',
|
||||
};
|
||||
const TYPE_COLORS = {
|
||||
message: 'primary', reminder: 'warning', task: 'success', alert: 'danger', info: 'info',
|
||||
};
|
||||
|
||||
async function loadRecent() {
|
||||
try {
|
||||
const resp = await fetch(API_RECENT, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await resp.json();
|
||||
updateBadge(data.unread_count);
|
||||
renderNotifications(data.notifications);
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
function renderNotifications(items) {
|
||||
// Remove old dynamic items
|
||||
list.querySelectorAll('.notif-dd-item').forEach(el => el.remove());
|
||||
if (!items || items.length === 0) {
|
||||
empty.style.display = '';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
items.forEach(n => {
|
||||
const icon = TYPE_ICONS[n.notification_type] || 'bi-bell';
|
||||
const color = TYPE_COLORS[n.notification_type] || 'secondary';
|
||||
const dt = new Date(n.created_at);
|
||||
const dateStr = dt.toLocaleDateString('fr-BE') + ' ' + dt.toLocaleTimeString('fr-BE', { hour: '2-digit', minute: '2-digit' });
|
||||
const div = document.createElement('div');
|
||||
div.className = 'notif-dd-item d-flex gap-2 px-3 py-2 border-bottom notif-dd-entry';
|
||||
div.setAttribute('data-pk', n.id);
|
||||
div.setAttribute('data-type', n.type);
|
||||
div.style.cssText = 'background:#f0f4ff; cursor:default;';
|
||||
div.innerHTML = `
|
||||
<span class="text-${color} flex-shrink-0 mt-1"><i class="bi ${icon}"></i></span>
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="d-flex justify-content-between align-items-start gap-1">
|
||||
<span class="fw-semibold small" style="white-space: normal; word-break: break-word;">${n.title}</span>
|
||||
<button class="btn btn-link btn-sm p-0 text-muted flex-shrink-0 notif-dd-mark-read"
|
||||
data-pk="${n.id}" data-type="${n.type}" title="Marquer comme lu" style="font-size:.8rem;">
|
||||
<i class="bi bi-check2"></i>
|
||||
</button>
|
||||
</div>
|
||||
${n.content ? `<p class="small text-muted mb-1" style="white-space: normal; word-break: break-word;">${n.content}</p>` : ''}
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<span class="text-muted" style="font-size:.7rem;">${dateStr}</span>
|
||||
${n.team_name ? `<span class="badge bg-secondary" style="font-size:.65rem;">${n.team_name}</span>` : ''}
|
||||
${n.action_url ? `<a href="${n.action_url}" class="badge bg-primary text-white text-decoration-none" style="font-size:.65rem;">${n.action_label || '→'}</a>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
list.insertBefore(div, empty);
|
||||
});
|
||||
|
||||
// Bind mark-read buttons inside dropdown
|
||||
list.querySelectorAll('.notif-dd-mark-read').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const pk = btn.dataset.pk;
|
||||
const type = btn.dataset.type;
|
||||
const url = MARK_READ_BASE.replace('0', pk);
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF, 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `type=${type}`,
|
||||
});
|
||||
if (resp.ok) {
|
||||
const d = await resp.json();
|
||||
updateBadge(d.unread_count);
|
||||
loadRecent();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Load when dropdown opens
|
||||
if (dropdown) {
|
||||
dropdown.addEventListener('show.bs.dropdown', loadRecent);
|
||||
}
|
||||
|
||||
// Mark all read button
|
||||
const markAllBtn = document.getElementById('markAllReadBtn');
|
||||
if (markAllBtn) {
|
||||
markAllBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const resp = await fetch(MARK_ALL, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': CSRF, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (resp.ok) {
|
||||
const d = await resp.json();
|
||||
updateBadge(d.unread_count);
|
||||
renderNotifications([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh badge every 60s
|
||||
setInterval(async () => {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue