feat: add mobile inbox template for notifications and discussions management

This commit is contained in:
Karl Determe 2026-08-14 23:01:43 +02:00
parent ef3f1bdaa6
commit 750abb703e

View file

@ -1,150 +1,393 @@
{% extends "mobile/mobile_base.html" %}
{% load i18n %}
{% load static %}
{% load roles %}
{% load mention_tags %}
{% block title %}{% translate "Notifications" %}{% endblock title %}
{% block extra_head %}
<style>
.notif-card {
border-left: 4px solid transparent;
/* Tabs styling */
.mobile-inbox-tabs {
border-bottom: 2px solid #e2e8f0;
gap: 0.5rem;
}
.mobile-inbox-tabs .nav-link {
font-size: 0.95rem;
padding: 0.55rem 0.9rem;
border: none;
border-bottom: 3px solid transparent;
color: #64748b;
font-weight: 600;
background: transparent;
border-radius: 0;
display: inline-flex;
align-items: center;
transition: all 0.15s ease;
}
.mobile-inbox-tabs .nav-link.active {
color: var(--brand-color, #2f3e8b);
border-bottom-color: var(--brand-color, #2f3e8b);
background: transparent;
}
/* Notification cards */
.notif-card {
border-left: 4px solid transparent;
transition: all 0.15s ease;
}
.notif-card.unread {
border-left-color: var(--brand-color, #2f3e8b);
background-color: #f8faff;
}
.notif-meta {
font-size: 0.78rem;
}
.notif-type-pill {
font-size: 0.72rem;
}
/* Discussion cards */
.discussion-card {
border-left: 4px solid transparent;
transition: all 0.15s ease;
text-decoration: none;
color: inherit;
display: block;
}
.discussion-card:hover, .discussion-card:focus {
background-color: #f8faff;
text-decoration: none;
color: inherit;
}
.discussion-card.has-unread {
border-left-color: var(--brand-color, #2f3e8b);
background-color: #f4f7ff;
}
.avatar-discussion {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Stacked avatars */
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar-circle {
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 0.65rem;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid #ffffff;
margin-left: -5px;
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
}
.avatar-group .avatar-circle:first-child {
margin-left: 0;
}
.avatar-group .avatar-more {
background-color: #f1f5f9;
color: #475569;
font-size: 0.65rem;
font-weight: 700;
}
</style>
{% endblock extra_head %}
{% block content %}
<!-- Top Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h5 mb-0">
{% translate "Notifications" %}
{% if unread_count > 0 %}
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b);">{{ unread_count }}</span>
{% endif %}
<h1 class="h5 mb-0 fw-bold">
<i class="bi bi-bell me-1 text-primary"></i>{% translate "Notifications" %}
</h1>
<a href="{% url 'mobile:mobile_index' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-house me-1"></i>{% translate "Accueil" %}
</a>
</div>
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-center">
<div class="col-12">
<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-12 d-flex justify-content-between align-items-center">
<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>
{% if filter_type or show_archived %}
<a href="{% url 'mobile:notifications_mobile' %}" class="btn btn-link btn-sm text-muted p-0">
{% translate "Réinitialiser" %}
</a>
{% endif %}
</div>
</form>
<div class="d-flex gap-2">
<a href="{% url 'notifications:settings' %}" class="btn btn-outline-secondary btn-sm" title="{% translate 'Paramètres' %}">
<i class="bi bi-gear"></i>
</a>
<a href="{% url 'mobile:mobile_index' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-house me-1"></i>{% translate "Accueil" %}
</a>
</div>
</div>
{% if unread_count > 0 %}
<div class="mb-3">
<button type="button" class="btn btn-sm btn-outline-secondary w-100" id="mark-all-read-btn">
<i class="bi bi-check2-all me-1"></i>{% translate "Tout marquer comme lu" %}
</button>
</div>
{% endif %}
<!-- Tabs Navigation -->
<ul class="nav mobile-inbox-tabs mb-3 flex-nowrap" id="mobileInboxTabs" role="tablist">
<li class="nav-item flex-fill text-center" role="presentation">
<button class="nav-link w-100 justify-content-center {% 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-1.5"></i>{% translate "Notifications" %}
{% if unread_count > 0 %}
<span class="badge rounded-pill ms-1" style="background-color: var(--brand-color, #2f3e8b); font-size: 0.72rem;">
{{ unread_count }}
</span>
{% endif %}
</button>
</li>
<li class="nav-item flex-fill text-center" role="presentation">
<button class="nav-link w-100 justify-content-center {% 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-1.5"></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.72rem;">
{{ unread_discussions_count }}
</span>
{% endif %}
</button>
</li>
</ul>
{% if notifications %}
<div class="d-flex flex-column gap-2">
{% for item in notifications %}
{% with notif=item.notification %}
<div class="card notif-card {% if not item.is_read and not item.is_archived %}unread{% endif %}"
id="notif-row-{{ item.recipient_id }}-{{ item.recipient_type }}">
<div class="card-body py-2">
<div class="d-flex justify-content-between gap-2">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
<i class="bi {{ notif.type_icon }} text-{{ notif.type_color }}"></i>
<strong class="small">{{ notif.title }}</strong>
{% if not item.is_read and not item.is_archived %}
<span class="badge bg-primary notif-type-pill">{% translate "Nouveau" %}</span>
{% endif %}
<!-- Tabs Content -->
<div class="tab-content" id="mobileInboxTabsContent">
<!-- ================= 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">
<!-- Filters & Quick Actions -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-center" id="mobile-filter-form">
<input type="hidden" name="tab" value="notifications">
<div class="col-12">
<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>
{% if notif.content %}
<p class="text-muted small mb-2">{{ notif.content|linebreaksbr }}</p>
{% endif %}
<div class="d-flex flex-wrap gap-2 align-items-center notif-meta text-muted">
<span>{{ notif.created_at|date:"d/m/Y H:i" }}</span>
{% if item.team_name %}
<span class="badge bg-secondary notif-type-pill">{{ item.team_name }}</span>
{% endif %}
{% if item.mobile_action_url and item.mobile_action_url != request.path and item.mobile_action_url != '/notifications/' and item.mobile_action_url != '/notifications' and item.mobile_action_url != '/mobile/notifications/' and item.mobile_action_url != '/mobile/notifications' %}
<a href="{{ item.mobile_action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
{{ notif.action_label|default:item.mobile_action_url }}
<div class="col-12 d-flex justify-content-between align-items-center">
<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 small" for="showArchived">{% translate "Archivées" %}</label>
</div>
{% if filter_type or show_archived %}
<a href="{% url 'mobile:notifications_mobile' %}?tab=notifications" class="btn btn-link btn-sm text-muted p-0">
{% translate "Réinitialiser" %}
</a>
{% endif %}
</div>
</div>
<div class="d-flex flex-column align-items-end gap-2">
{% if not item.is_read %}
<button type="button"
class="btn btn-sm btn-outline-success mark-read-btn"
data-pk="{{ item.recipient_id }}"
data-type="{{ item.recipient_type }}">
{% if notif.is_single_claim %}
{% translate "Prendre" %}
{% else %}
{% translate "Lu" %}
{% endif %}
</button>
{% endif %}
{% if item.recipient_type == 'personal' and not item.is_archived %}
<button type="button"
class="btn btn-sm btn-outline-secondary archive-btn"
data-pk="{{ item.recipient_id }}"
aria-label="{% translate 'Archiver' %}">
<i class="bi bi-archive"></i>
</button>
{% endif %}
</div>
</form>
</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash" style="font-size: 2.5rem;"></i>
<p class="mt-2 mb-0">
{% if show_archived %}
{% translate "Aucune notification archivée." %}
{% else %}
{% translate "Vous n'avez aucune notification." %}
{% if unread_count > 0 %}
<div class="mb-3">
<button type="button" class="btn btn-sm btn-outline-secondary w-100" id="mark-all-read-btn">
<i class="bi bi-check2-all me-1"></i>{% translate "Tout marquer comme lu" %}
</button>
</div>
{% endif %}
</p>
{% if notifications %}
<div class="d-flex flex-column gap-2">
{% for item in notifications %}
{% with notif=item.notification %}
<div class="card notif-card shadow-sm border-0 {% if not item.is_read and not item.is_archived %}unread{% endif %}"
id="notif-row-{{ item.recipient_id }}-{{ item.recipient_type }}">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between gap-2">
<div class="flex-grow-1 min-w-0">
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
<i class="bi {{ notif.type_icon }} text-{{ notif.type_color }}"></i>
<strong class="small text-dark">{{ notif.title }}</strong>
{% if not item.is_read and not item.is_archived %}
<span class="badge bg-primary notif-type-pill">{% translate "Nouveau" %}</span>
{% endif %}
</div>
{% if notif.content %}
<p class="text-muted small mb-2">{{ notif.content|linebreaksbr }}</p>
{% endif %}
<div class="d-flex flex-wrap gap-2 align-items-center notif-meta text-muted">
<span>{{ notif.created_at|date:"d/m/Y H:i" }}</span>
{% if item.team_name %}
<span class="badge bg-secondary notif-type-pill">{{ item.team_name }}</span>
{% endif %}
{% if item.mobile_action_url and item.mobile_action_url != request.path and item.mobile_action_url != '/notifications/' and item.mobile_action_url != '/notifications' and item.mobile_action_url != '/mobile/notifications/' and item.mobile_action_url != '/mobile/notifications' %}
<a href="{{ item.mobile_action_url }}" class="btn btn-sm btn-outline-primary py-0 px-2">
{{ notif.action_label|default:item.mobile_action_url }}
</a>
{% endif %}
</div>
</div>
<div class="d-flex flex-column align-items-end gap-2 flex-shrink-0">
{% if not item.is_read %}
<button type="button"
class="btn btn-sm btn-outline-success mark-read-btn"
data-pk="{{ item.recipient_id }}"
data-type="{{ item.recipient_type }}">
{% if notif.is_single_claim %}
{% translate "Prendre" %}
{% else %}
{% translate "Lu" %}
{% endif %}
</button>
{% endif %}
{% if item.recipient_type == 'personal' and not item.is_archived %}
<button type="button"
class="btn btn-sm btn-outline-secondary archive-btn"
data-pk="{{ item.recipient_id }}"
aria-label="{% translate 'Archiver' %}">
<i class="bi bi-archive"></i>
</button>
{% endif %}
</div>
</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash" style="font-size: 2.5rem;"></i>
<p class="mt-2 mb-0">
{% if show_archived %}
{% translate "Aucune notification archivée." %}
{% else %}
{% translate "Vous n'avez aucune notification." %}
{% endif %}
</p>
</div>
{% endif %}
</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">
<!-- Action Button: Nouvelle Discussion -->
<div class="mb-3">
<button type="button" class="btn btn-primary btn-sm w-100 shadow-sm" data-bs-toggle="modal" data-bs-target="#newDiscussionModal">
<i class="bi bi-chat-plus-fill me-1.5"></i>{% translate "Nouvelle discussion" %}
</button>
</div>
{% if not discussion_items %}
<div class="card border-0 shadow-sm rounded-3">
<div class="card-body text-center py-5 text-muted">
<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>
</div>
</div>
{% else %}
<div class="d-flex flex-column gap-2">
{% for item in discussion_items %}
<a href="{% url 'notifications:discussion_detail' item.discussion.pk %}"
class="card discussion-card border-0 shadow-sm rounded-3 {% if item.unread_count %}has-unread{% endif %}">
<div class="card-body py-2 px-3">
<div class="d-flex align-items-start gap-2.5">
<!-- Icône typée -->
{% with type_info=item.discussion.type_info %}
<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>
{% endwith %}
<!-- Détail -->
<div class="flex-grow-1 min-w-0">
<div class="d-flex justify-content-between align-items-start gap-1 mb-1">
<span class="fw-bold text-dark text-truncate small" style="max-width: 70%;">
{{ item.discussion.title|default:item.discussion }}
</span>
<span class="text-muted text-nowrap" style="font-size: 0.72rem;">
{{ item.discussion.updated_at|date:"d/m H:i" }}
</span>
</div>
<!-- Badges -->
<div class="d-flex flex-wrap gap-1 mb-1.5">
{% if item.discussion.object_label and item.discussion.object_label != item.discussion.title %}
<span class="badge bg-light text-secondary border rounded-pill py-0.5 px-1.5" style="font-size: 0.68rem;">
<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 rounded-pill py-0.5 px-1.5" style="font-size: 0.68rem;">
<i class="bi bi-lock-fill me-0.5"></i>{% translate "Clôturée" %}
</span>
{% endif %}
</div>
<!-- Aperçu dernier message -->
{% if item.last_message %}
<p class="mb-2 text-muted text-truncate" style="font-size: 0.82rem;">
<strong class="text-secondary">{{ item.last_message.author.get_full_name|default:item.last_message.author.username }}</strong> :
<span class="text-dark opacity-75">{{ item.last_message.content|clean_mentions_preview|truncatechars:90 }}</span>
</p>
{% else %}
<p class="mb-2 text-muted fst-italic" style="font-size: 0.82rem;">{% translate "Aucun message encore." %}</p>
{% endif %}
<!-- Participants & Badge non lus -->
<div class="d-flex align-items-center justify-content-between pt-1">
<div class="avatar-group">
{% for p in item.discussion.participants.all|slice:":4" %}
<div class="avatar-circle"
style="background-color: {{ p|avatar_bg_color }}; color: {{ p|avatar_text_color }};"
title="{{ p.get_full_name|default:p.username }}">
{{ p|user_initials }}
</div>
{% endfor %}
{% if item.discussion.participants.count > 4 %}
<div class="avatar-circle avatar-more">
+{{ item.discussion.participants.count|add:"-4" }}
</div>
{% endif %}
</div>
{% if item.unread_count %}
<span class="badge rounded-pill px-2 py-0.5 fw-semibold" style="background-color: var(--brand-color, #2f3e8b); color: #fff; font-size: 0.72rem;">
{{ item.unread_count }} {% translate "non lu(s)" %}
</span>
{% endif %}
</div>
</div>
</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Modal Nouvelle Discussion -->
{% include "notifications/_new_discussion_modal.html" %}
{% endblock content %}
{% block extra_scripts %}
@ -155,6 +398,18 @@
const archiveBase = "{% url 'notifications:archive' 0 %}";
const markAllUrl = "{% url 'notifications:mark_all_read' %}";
// Synchronize tab changes in URL query param
const tabs = document.querySelectorAll('#mobileInboxTabs button[data-bs-toggle="tab"]');
tabs.forEach(tab => {
tab.addEventListener('shown.bs.tab', event => {
const isDiscussions = event.target.id === 'tab-discussions-btn';
const tabName = isDiscussions ? 'discussions' : 'notifications';
const url = new URL(window.location);
url.searchParams.set('tab', tabName);
window.history.replaceState({}, '', url);
});
});
function updateBadge(count) {
const badges = document.querySelectorAll('#mobile-navbar-notif-badge, #navbar-notif-badge');
badges.forEach((badge) => {
@ -188,7 +443,8 @@
}
document.querySelectorAll('.mark-read-btn').forEach((button) => {
button.addEventListener('click', async () => {
button.addEventListener('click', async (e) => {
e.stopPropagation();
button.disabled = true;
const pk = button.dataset.pk;
const type = button.dataset.type;
@ -207,7 +463,8 @@
});
document.querySelectorAll('.archive-btn').forEach((button) => {
button.addEventListener('click', async () => {
button.addEventListener('click', async (e) => {
e.stopPropagation();
button.disabled = true;
const pk = button.dataset.pk;
try {