feat: implement discussion flagging system with status filtering and UI updates
This commit is contained in:
parent
79697f392c
commit
9657111a1c
20 changed files with 1080 additions and 63 deletions
|
|
@ -485,6 +485,21 @@
|
|||
border: 2px solid #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
#discussion-fab .fab-flag-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: .75rem;
|
||||
border: 2px solid #fff;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.2);
|
||||
}
|
||||
#discussion-panel {
|
||||
position: fixed;
|
||||
bottom: 92px;
|
||||
|
|
@ -541,6 +556,11 @@
|
|||
{% if discussion_messages %}
|
||||
<span class="fab-badge" id="discussion-fab-badge">{{ discussion_messages|length }}</span>
|
||||
{% endif %}
|
||||
<span class="fab-flag-badge {% if not discussion or not discussion.flag %}d-none{% endif %}" id="discussion-fab-flag-badge"
|
||||
style="background-color: {{ discussion.flag_info.color|default:'#dc3545' }};"
|
||||
title="{{ discussion.flag_info.label }}">
|
||||
<i class="bi {{ discussion.flag_info.icon|default:'bi-flag-fill' }}"></i>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div id="discussion-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
|
||||
|
|
@ -548,15 +568,45 @@
|
|||
<!-- En-tête -->
|
||||
<div id="discussion-panel-header">
|
||||
<span class="fw-semibold d-flex align-items-center gap-2 small">
|
||||
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion liée à cette intervention" %}
|
||||
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %}
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if discussion %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-light py-0 px-2 rounded-pill d-flex align-items-center gap-1 border-0 bg-white bg-opacity-20 dropdown-toggle"
|
||||
type="button" id="interventionDiscussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false" style="font-size: 0.75rem;">
|
||||
<span id="intervention-flag-icon-display">
|
||||
{% if discussion.flag == 'red' %}🚩{% elif discussion.flag == 'orange' %}🟧{% elif discussion.flag == 'green' %}🟩{% else %}🏳️{% endif %}
|
||||
</span>
|
||||
<span id="intervention-flag-label-display">
|
||||
{{ discussion.flag_info.label|default:_("Statut") }}
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="interventionDiscussionFlagBtn" style="font-size: 0.8rem; z-index: 1070;">
|
||||
<li><h6 class="dropdown-header small text-muted py-1">{% translate "Statut de suivi" %}</h6></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 intervention-flag-opt" data-flag="red"><i class="bi bi-flag-fill text-danger"></i> {% translate "Important / À traiter" %}</button></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 intervention-flag-opt" data-flag="orange"><i class="bi bi-flag-fill text-warning"></i> {% translate "En cours" %}</button></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 intervention-flag-opt" data-flag="green"><i class="bi bi-flag-fill text-success"></i> {% translate "Traité / Fait" %}</button></li>
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 intervention-flag-opt text-muted" data-flag=""><i class="bi bi-flag"></i> {% translate "Aucun drapeau" %}</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="button" class="btn-close btn-close-white btn-sm" onclick="toggleDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fil de messages -->
|
||||
<div id="discussion-thread">
|
||||
{% if discussion_messages %}
|
||||
{% for msg in discussion_messages %}
|
||||
{% if msg.is_system %}
|
||||
<div class="d-flex justify-content-center my-1">
|
||||
<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
|
||||
{{ msg.content }}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
|
||||
{% if msg.author != request.user %}
|
||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
|
||||
|
|
@ -594,6 +644,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted small text-center py-3 mb-0">
|
||||
|
|
|
|||
|
|
@ -1020,6 +1020,74 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
})();
|
||||
// ---- fin @mention autocomplete ----
|
||||
|
||||
// ---- Changement de drapeau de la discussion ----
|
||||
{% if discussion %}
|
||||
(function() {
|
||||
document.querySelectorAll('.intervention-flag-opt').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const flag = this.getAttribute('data-flag');
|
||||
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
|
||||
const formData = new FormData();
|
||||
formData.append('flag', flag);
|
||||
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
const fabFlagBadge = document.getElementById('discussion-fab-flag-badge');
|
||||
const headerIcon = document.getElementById('intervention-flag-icon-display');
|
||||
const headerLabel = document.getElementById('intervention-flag-label-display');
|
||||
|
||||
if (data.flag) {
|
||||
if (fabFlagBadge) {
|
||||
fabFlagBadge.classList.remove('d-none');
|
||||
fabFlagBadge.style.backgroundColor = data.flag_info.color;
|
||||
fabFlagBadge.title = data.flag_info.label;
|
||||
fabFlagBadge.innerHTML = `<i class="bi ${data.flag_info.icon}"></i>`;
|
||||
}
|
||||
} else {
|
||||
if (fabFlagBadge) {
|
||||
fabFlagBadge.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
if (headerIcon) {
|
||||
if (data.flag === 'red') headerIcon.textContent = '🚩';
|
||||
else if (data.flag === 'orange') headerIcon.textContent = '🟧';
|
||||
else if (data.flag === 'green') headerIcon.textContent = '🟩';
|
||||
else headerIcon.textContent = '🏳️';
|
||||
}
|
||||
if (headerLabel) {
|
||||
headerLabel.textContent = data.flag_info.label || '{% translate "Statut" %}';
|
||||
}
|
||||
|
||||
if (data.system_message) {
|
||||
const thread = document.getElementById('discussion-thread');
|
||||
if (thread) {
|
||||
const sysDiv = document.createElement('div');
|
||||
sysDiv.className = 'd-flex justify-content-center my-1';
|
||||
sysDiv.innerHTML = `<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
|
||||
${data.system_message.content}
|
||||
</span>`;
|
||||
thread.appendChild(sysDiv);
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error changing discussion flag:', err));
|
||||
});
|
||||
});
|
||||
})();
|
||||
{% endif %}
|
||||
|
||||
function toggleSubscription(interventionId) {
|
||||
const btn = document.getElementById('subscription-btn');
|
||||
const icon = document.getElementById('subscription-icon');
|
||||
|
|
|
|||
|
|
@ -342,6 +342,21 @@
|
|||
border: 2px solid #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
#discussion-mob-fab .mob-fab-flag-badge {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: .7rem;
|
||||
border: 2px solid #fff;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.2);
|
||||
}
|
||||
#discussion-mob-panel {
|
||||
position: fixed;
|
||||
top: 10.5rem;
|
||||
|
|
@ -2542,6 +2557,11 @@
|
|||
{% if discussion_messages %}
|
||||
<span class="mob-fab-badge" id="discussion-mob-badge">{{ discussion_messages|length }}</span>
|
||||
{% endif %}
|
||||
<span class="mob-fab-flag-badge {% if not discussion or not discussion.flag %}d-none{% endif %}" id="discussion-mob-flag-badge"
|
||||
style="background-color: {{ discussion.flag_info.color|default:'#dc3545' }};"
|
||||
title="{{ discussion.flag_info.label }}">
|
||||
<i class="bi {{ discussion.flag_info.icon|default:'bi-flag-fill' }}"></i>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div id="discussion-mob-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
|
||||
|
|
@ -2550,13 +2570,43 @@
|
|||
<span class="fw-semibold d-flex align-items-center gap-2 small">
|
||||
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %}
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if discussion %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-light py-0 px-2 rounded-pill d-flex align-items-center gap-1 border-0 bg-white bg-opacity-20 dropdown-toggle"
|
||||
type="button" id="mobDiscussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false" style="font-size: 0.75rem;">
|
||||
<span id="mob-flag-icon-display">
|
||||
{% if discussion.flag == 'red' %}🚩{% elif discussion.flag == 'orange' %}🟧{% elif discussion.flag == 'green' %}🟩{% else %}🏳️{% endif %}
|
||||
</span>
|
||||
<span id="mob-flag-label-display">
|
||||
{{ discussion.flag_info.label|default:_("Statut") }}
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="mobDiscussionFlagBtn" style="font-size: 0.8rem; z-index: 1070;">
|
||||
<li><h6 class="dropdown-header small text-muted py-1">{% translate "Statut de suivi" %}</h6></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 mob-flag-opt" data-flag="red"><i class="bi bi-flag-fill text-danger"></i> {% translate "Important / À traiter" %}</button></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 mob-flag-opt" data-flag="orange"><i class="bi bi-flag-fill text-warning"></i> {% translate "En cours" %}</button></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 mob-flag-opt" data-flag="green"><i class="bi bi-flag-fill text-success"></i> {% translate "Traité / Fait" %}</button></li>
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 mob-flag-opt text-muted" data-flag=""><i class="bi bi-flag"></i> {% translate "Aucun drapeau" %}</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="button" class="btn-close btn-close-white btn-sm"
|
||||
onclick="toggleMobDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="discussion-mob-thread">
|
||||
{% if discussion_messages %}
|
||||
{% for msg in discussion_messages %}
|
||||
{% if msg.is_system %}
|
||||
<div class="d-flex justify-content-center my-1">
|
||||
<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
|
||||
{{ msg.content }}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
|
||||
{% if msg.author != request.user %}
|
||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
|
||||
|
|
@ -2594,6 +2644,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted small text-center py-3 mb-0">
|
||||
|
|
@ -6640,6 +6691,74 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
setTimeout(() => { textarea.value = ''; realInput.value = ''; mentions = []; }, 0);
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- Changement de drapeau de la discussion (mobile) ----
|
||||
{% if discussion %}
|
||||
(function() {
|
||||
document.querySelectorAll('.mob-flag-opt').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const flag = this.getAttribute('data-flag');
|
||||
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
|
||||
const formData = new FormData();
|
||||
formData.append('flag', flag);
|
||||
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
const fabFlagBadge = document.getElementById('discussion-mob-flag-badge');
|
||||
const headerIcon = document.getElementById('mob-flag-icon-display');
|
||||
const headerLabel = document.getElementById('mob-flag-label-display');
|
||||
|
||||
if (data.flag) {
|
||||
if (fabFlagBadge) {
|
||||
fabFlagBadge.classList.remove('d-none');
|
||||
fabFlagBadge.style.backgroundColor = data.flag_info.color;
|
||||
fabFlagBadge.title = data.flag_info.label;
|
||||
fabFlagBadge.innerHTML = `<i class="bi ${data.flag_info.icon}"></i>`;
|
||||
}
|
||||
} else {
|
||||
if (fabFlagBadge) {
|
||||
fabFlagBadge.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
if (headerIcon) {
|
||||
if (data.flag === 'red') headerIcon.textContent = '🚩';
|
||||
else if (data.flag === 'orange') headerIcon.textContent = '🟧';
|
||||
else if (data.flag === 'green') headerIcon.textContent = '🟩';
|
||||
else headerIcon.textContent = '🏳️';
|
||||
}
|
||||
if (headerLabel) {
|
||||
headerLabel.textContent = data.flag_info.label || '{% translate "Statut" %}';
|
||||
}
|
||||
|
||||
if (data.system_message) {
|
||||
const thread = document.getElementById('discussion-mob-thread');
|
||||
if (thread) {
|
||||
const sysDiv = document.createElement('div');
|
||||
sysDiv.className = 'd-flex justify-content-center my-1';
|
||||
sysDiv.innerHTML = `<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
|
||||
${data.system_message.content}
|
||||
</span>`;
|
||||
thread.appendChild(sysDiv);
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error changing mobile discussion flag:', err));
|
||||
});
|
||||
});
|
||||
})();
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -742,6 +742,7 @@ def intervention_detail(request, intervention_id):
|
|||
# Observation source
|
||||
'source_observation': source_observation,
|
||||
# Discussion
|
||||
'discussion': discussion,
|
||||
'discussion_messages': discussion_messages,
|
||||
'can_post_discussion_message': can_post_discussion_message,
|
||||
# Sequenced notes & documents
|
||||
|
|
|
|||
|
|
@ -1059,6 +1059,7 @@ def intervention_detail_mobile(request, intervention_id):
|
|||
linked_observations = [obs for obs in all_observations if obs_access_context.allows(obs)]
|
||||
|
||||
# Discussion liée à cette intervention (mobile)
|
||||
discussion = None
|
||||
discussion_messages_mobile = []
|
||||
can_post_discussion_message_mobile = False
|
||||
try:
|
||||
|
|
@ -1066,15 +1067,15 @@ def intervention_detail_mobile(request, intervention_id):
|
|||
from notifications.models import Discussion as _Discussion
|
||||
from notifications.services import mark_discussion_seen as _mark_seen, can_post_intervention_message as _can_post
|
||||
_ct = _CT.objects.get_for_model(intervention)
|
||||
_discussion = _Discussion.objects.filter(content_type=_ct, object_id=intervention.pk).first()
|
||||
if _discussion:
|
||||
discussion = _Discussion.objects.filter(content_type=_ct, object_id=intervention.pk).first()
|
||||
if discussion:
|
||||
discussion_messages_mobile = list(
|
||||
_discussion.messages.filter(is_deleted=False)
|
||||
discussion.messages.filter(is_deleted=False)
|
||||
.select_related('author')
|
||||
.prefetch_related('attachments')
|
||||
.order_by('created_at')
|
||||
)
|
||||
_mark_seen(_discussion, user=request.user)
|
||||
_mark_seen(discussion, user=request.user)
|
||||
can_post_discussion_message_mobile = _can_post(request.user, intervention)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -1170,6 +1171,7 @@ def intervention_detail_mobile(request, intervention_id):
|
|||
# Observations liées
|
||||
'linked_observations': linked_observations,
|
||||
# Discussion
|
||||
'discussion': discussion,
|
||||
'discussion_messages': discussion_messages_mobile,
|
||||
'can_post_discussion_message': can_post_discussion_message_mobile,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1099,6 +1099,7 @@ def stock_preparation_order_detail_mobile(request, pk):
|
|||
'has_executed_outputs': has_executed_outputs,
|
||||
'is_reopened': is_reopened,
|
||||
'deleted_items_to_return': deleted_items_to_return,
|
||||
'discussion': _discussion,
|
||||
'discussion_messages': discussion_messages_mobile,
|
||||
'can_post_discussion_message': can_post_discussion_message_mobile,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 6.0.7 on 2026-09-09 14:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0003_webpushsubscription'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='discussion',
|
||||
name='flag',
|
||||
field=models.CharField(blank=True, choices=[('', 'Aucun'), ('red', 'Important / À traiter'), ('orange', 'En cours'), ('green', 'Traité / Fait')], default='', max_length=20, verbose_name='Flag'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='discussion',
|
||||
name='flag_set_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Flag défini le'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='discussion',
|
||||
name='flag_set_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='flagged_discussions', to=settings.AUTH_USER_MODEL, verbose_name='Flag défini par'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='message',
|
||||
name='is_system',
|
||||
field=models.BooleanField(default=False, help_text='Indique si le message a été généré automatiquement par le système.', verbose_name='Message système'),
|
||||
),
|
||||
]
|
||||
|
|
@ -350,6 +350,46 @@ class Discussion(models.Model):
|
|||
)
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
# Statuts de suivi (Flags)
|
||||
FLAG_NONE = ''
|
||||
FLAG_RED = 'red'
|
||||
FLAG_ORANGE = 'orange'
|
||||
FLAG_GREEN = 'green'
|
||||
|
||||
FLAG_CHOICES = [
|
||||
(FLAG_NONE, _('Aucun')),
|
||||
(FLAG_RED, _('Important / À traiter')),
|
||||
(FLAG_ORANGE, _('En cours')),
|
||||
(FLAG_GREEN, _('Traité / Fait')),
|
||||
]
|
||||
|
||||
FLAG_META = {
|
||||
FLAG_RED: {'label': _('Important / À traiter'), 'short_label': _('Important'), 'color': 'danger', 'icon': 'bi-flag-fill'},
|
||||
FLAG_ORANGE: {'label': _('En cours'), 'short_label': _('En cours'), 'color': 'warning', 'icon': 'bi-flag-fill'},
|
||||
FLAG_GREEN: {'label': _('Traité / Fait'), 'short_label': _('Traité'), 'color': 'success', 'icon': 'bi-flag-fill'},
|
||||
}
|
||||
|
||||
flag = models.CharField(
|
||||
max_length=20,
|
||||
choices=FLAG_CHOICES,
|
||||
default=FLAG_NONE,
|
||||
blank=True,
|
||||
verbose_name=_("Flag"),
|
||||
)
|
||||
flag_set_by = models.ForeignKey(
|
||||
User,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='flagged_discussions',
|
||||
verbose_name=_("Flag défini par"),
|
||||
)
|
||||
flag_set_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Flag défini le"),
|
||||
)
|
||||
|
||||
# URL et libellé pré-calculés pour éviter de résoudre le FK générique en template
|
||||
object_url = models.CharField(
|
||||
max_length=500, blank=True, verbose_name=_("URL objet lié")
|
||||
|
|
@ -361,6 +401,16 @@ class Discussion(models.Model):
|
|||
def __str__(self):
|
||||
return self.title or f"Discussion #{self.pk}"
|
||||
|
||||
@property
|
||||
def flag_info(self):
|
||||
if not self.flag or self.flag not in self.FLAG_META:
|
||||
return None
|
||||
info = dict(self.FLAG_META[self.flag])
|
||||
info['flag'] = self.flag
|
||||
info['set_by'] = self.flag_set_by
|
||||
info['set_at'] = self.flag_set_at
|
||||
return info
|
||||
|
||||
def get_absolute_url(self):
|
||||
from django.urls import reverse
|
||||
return reverse('notifications:discussion_detail', args=[self.pk])
|
||||
|
|
@ -455,6 +505,11 @@ class Message(models.Model):
|
|||
content = models.TextField(verbose_name=_("Contenu"))
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Envoyé le"))
|
||||
is_deleted = models.BooleanField(default=False, verbose_name=_("Supprimé"))
|
||||
is_system = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Message système"),
|
||||
help_text=_("Indique si le message a été généré automatiquement par le système."),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
author_name = self.author.username if self.author else 'système'
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ def get_unread_count(user):
|
|||
# Discussion / Messaging services
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def post_message(discussion, author, content, files=None):
|
||||
def post_message(discussion, author, content, files=None, flag=None):
|
||||
"""
|
||||
Poste un message dans une discussion.
|
||||
|
||||
|
|
@ -289,6 +289,7 @@ def post_message(discussion, author, content, files=None):
|
|||
participants (la réponse annule les alertes précédentes).
|
||||
- Crée une nouvelle notification pour tous les participants sauf l'auteur.
|
||||
- Met à jour discussion.updated_at pour la faire remonter dans les listes.
|
||||
- Si `flag` est renseigné et différent du flag actuel, met à jour le flag de la discussion.
|
||||
|
||||
Retourne le Message créé.
|
||||
"""
|
||||
|
|
@ -300,6 +301,9 @@ def post_message(discussion, author, content, files=None):
|
|||
for f in files:
|
||||
MessageAttachment.objects.create(message=message, file=f, name=f.name)
|
||||
|
||||
if flag is not None and flag != discussion.flag:
|
||||
set_discussion_flag(discussion, flag, user=author)
|
||||
|
||||
# Effacer les anciennes notifications non lues de cette discussion
|
||||
NotificationRecipient.objects.filter(
|
||||
notification__source_discussion=discussion,
|
||||
|
|
@ -456,6 +460,58 @@ def mark_discussion_seen(discussion, user=None):
|
|||
qs.update(read_at=timezone.now())
|
||||
|
||||
|
||||
def set_discussion_flag(discussion, flag, user=None):
|
||||
"""
|
||||
Définit ou retire le flag de suivi sur une discussion.
|
||||
Génère un message système dans le fil pour tracer la modification.
|
||||
Met à jour updated_at sur la discussion.
|
||||
Retourne le message système créé (ou None si inchangé).
|
||||
"""
|
||||
from django.utils.translation import gettext as _
|
||||
from .models import Message
|
||||
|
||||
old_flag = discussion.flag or ''
|
||||
new_flag = flag if flag in dict(discussion.FLAG_CHOICES) else ''
|
||||
|
||||
if old_flag == new_flag:
|
||||
return None
|
||||
|
||||
user_name = user.get_full_name().strip() or user.username if user else _("Système")
|
||||
now = timezone.now()
|
||||
|
||||
discussion.flag = new_flag
|
||||
discussion.flag_set_by = user
|
||||
discussion.flag_set_at = now if new_flag else None
|
||||
discussion.save(update_fields=['flag', 'flag_set_by', 'flag_set_at', 'updated_at'])
|
||||
|
||||
# Créer un message système dans le fil
|
||||
if new_flag:
|
||||
flag_label = dict(discussion.FLAG_CHOICES).get(new_flag, new_flag)
|
||||
if new_flag == 'red':
|
||||
flag_icon = '🚩'
|
||||
elif new_flag == 'orange':
|
||||
flag_icon = '🟧'
|
||||
elif new_flag == 'green':
|
||||
flag_icon = '🟩'
|
||||
else:
|
||||
flag_icon = '🏷️'
|
||||
content = _("{icon} {user} a défini le statut : {label}").format(
|
||||
icon=flag_icon,
|
||||
user=user_name,
|
||||
label=flag_label,
|
||||
)
|
||||
else:
|
||||
content = _("🏳️ {user} a retiré le drapeau de suivi.").format(user=user_name)
|
||||
|
||||
sys_message = Message.objects.create(
|
||||
discussion=discussion,
|
||||
author=user,
|
||||
content=content,
|
||||
is_system=True,
|
||||
)
|
||||
return sys_message
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Règles de notification automatiques
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -12,13 +12,26 @@
|
|||
<form method="post" action="{% url 'notifications:discussion_create' %}" enctype="multipart/form-data" id="new-discussion-form">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body pt-3">
|
||||
<div class="mb-3">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-sm-8">
|
||||
<label for="new-discussion-title" class="form-label small fw-semibold text-secondary">
|
||||
{% translate "Sujet / Titre (optionnel)" %}
|
||||
</label>
|
||||
<input type="text" class="form-control rounded-3" id="new-discussion-title" name="title"
|
||||
placeholder="{% translate 'Ex: Question sur le dossier...' %}">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label for="new-discussion-flag" class="form-label small fw-semibold text-secondary">
|
||||
{% translate "Statut initial" %}
|
||||
</label>
|
||||
<select class="form-select rounded-3" id="new-discussion-flag" name="flag">
|
||||
<option value="">{% translate "Aucun" %}</option>
|
||||
<option value="red">🚩 {% translate "Important" %}</option>
|
||||
<option value="orange">🟧 {% translate "En cours" %}</option>
|
||||
<option value="green">🟩 {% translate "Traité" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 position-relative">
|
||||
<label for="new-discussion-content-display" class="form-label small fw-semibold text-secondary">
|
||||
|
|
|
|||
|
|
@ -81,11 +81,12 @@
|
|||
<div class="container py-4" style="max-width:880px;">
|
||||
|
||||
<!-- Breadcrumb / Header -->
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-3 flex-wrap">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<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">
|
||||
<div class="min-w-0">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<h1 class="h4 mb-0 fw-bold text-dark">{{ discussion.title|default:discussion }}</h1>
|
||||
{% if discussion.object_label %}
|
||||
|
|
@ -100,6 +101,64 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flag Dropdown Selector -->
|
||||
<div class="dropdown" id="discussionFlagContainer">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle d-flex align-items-center gap-1.5 px-2.5 py-1.5 rounded-pill shadow-xs"
|
||||
type="button" id="discussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span id="flagIconDisplay">
|
||||
{% if discussion.flag == 'red' %}
|
||||
<i class="bi bi-flag-fill text-danger"></i>
|
||||
{% elif discussion.flag == 'orange' %}
|
||||
<i class="bi bi-flag-fill text-warning"></i>
|
||||
{% elif discussion.flag == 'green' %}
|
||||
<i class="bi bi-flag-fill text-success"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-flag text-secondary"></i>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span id="flagLabelDisplay" class="fw-medium small">
|
||||
{% if discussion.flag %}
|
||||
{{ discussion.flag_info.label }}
|
||||
{% else %}
|
||||
{% translate "Statut" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="discussionFlagBtn">
|
||||
<li><h6 class="dropdown-header small text-muted">{% translate "Statut de suivi" %}</h6></li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center gap-2 small flag-opt-btn" data-flag="red">
|
||||
<i class="bi bi-flag-fill text-danger"></i>
|
||||
<span>{% translate "Important / À traiter" %}</span>
|
||||
{% if discussion.flag == 'red' %}<i class="bi bi-check ms-auto text-primary"></i>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center gap-2 small flag-opt-btn" data-flag="orange">
|
||||
<i class="bi bi-flag-fill text-warning"></i>
|
||||
<span>{% translate "En cours" %}</span>
|
||||
{% if discussion.flag == 'orange' %}<i class="bi bi-check ms-auto text-primary"></i>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center gap-2 small flag-opt-btn" data-flag="green">
|
||||
<i class="bi bi-flag-fill text-success"></i>
|
||||
<span>{% translate "Traité / Fait" %}</span>
|
||||
{% if discussion.flag == 'green' %}<i class="bi bi-check ms-auto text-primary"></i>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center gap-2 small text-muted flag-opt-btn" data-flag="">
|
||||
<i class="bi bi-flag"></i>
|
||||
<span>{% translate "Aucun drapeau" %}</span>
|
||||
{% if not discussion.flag %}<i class="bi bi-check ms-auto text-primary"></i>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participants -->
|
||||
<div class="mb-3 d-flex flex-wrap gap-1.5 align-items-center">
|
||||
<span class="small text-muted me-1"><i class="bi bi-people me-1"></i>{% translate "Participants" %} :</span>
|
||||
|
|
@ -120,8 +179,16 @@
|
|||
<p class="mb-0">{% translate "Aucun message pour l'instant. Soyez le premier à écrire." %}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="thread-container">
|
||||
<div class="thread-container" id="thread-container">
|
||||
{% for msg in thread_messages %}
|
||||
{% if msg.is_system %}
|
||||
<div class="d-flex justify-content-center my-2">
|
||||
<span class="badge bg-light text-secondary border px-3 py-1.5 rounded-pill shadow-xs" style="font-size: 0.8rem; font-weight: 500;">
|
||||
{{ msg.content }}
|
||||
<span class="ms-1 opacity-75 small">({{ msg.created_at|date:"d/m H:i" }})</span>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex flex-column {% if msg.author == request.user %}msg-mine{% else %}msg-other{% endif %}">
|
||||
{% if msg.author != request.user %}
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
|
|
@ -400,5 +467,72 @@ if (threadScroll) {
|
|||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// Flag selection AJAX
|
||||
document.querySelectorAll('.flag-opt-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const flag = this.getAttribute('data-flag');
|
||||
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
|
||||
const formData = new FormData();
|
||||
formData.append('flag', flag);
|
||||
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
const iconDisplay = document.getElementById('flagIconDisplay');
|
||||
const labelDisplay = document.getElementById('flagLabelDisplay');
|
||||
if (data.flag === 'red') {
|
||||
iconDisplay.innerHTML = '<i class="bi bi-flag-fill text-danger"></i>';
|
||||
labelDisplay.textContent = data.flag_info.label;
|
||||
} else if (data.flag === 'orange') {
|
||||
iconDisplay.innerHTML = '<i class="bi bi-flag-fill text-warning"></i>';
|
||||
labelDisplay.textContent = data.flag_info.label;
|
||||
} else if (data.flag === 'green') {
|
||||
iconDisplay.innerHTML = '<i class="bi bi-flag-fill text-success"></i>';
|
||||
labelDisplay.textContent = data.flag_info.label;
|
||||
} else {
|
||||
iconDisplay.innerHTML = '<i class="bi bi-flag text-secondary"></i>';
|
||||
labelDisplay.textContent = '{% translate "Statut" %}';
|
||||
}
|
||||
|
||||
// Update checks in menu
|
||||
document.querySelectorAll('.flag-opt-btn').forEach(b => {
|
||||
const check = b.querySelector('.bi-check');
|
||||
if (check) check.remove();
|
||||
if (b.getAttribute('data-flag') === (data.flag || '')) {
|
||||
const newCheck = document.createElement('i');
|
||||
newCheck.className = 'bi bi-check ms-auto text-primary';
|
||||
b.appendChild(newCheck);
|
||||
}
|
||||
});
|
||||
|
||||
// Append system message if returned
|
||||
if (data.system_message) {
|
||||
const threadContainer = document.getElementById('thread-container');
|
||||
if (threadContainer) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'd-flex justify-content-center my-2';
|
||||
div.innerHTML = `<span class="badge bg-light text-secondary border px-3 py-1.5 rounded-pill shadow-xs" style="font-size: 0.8rem; font-weight: 500;">
|
||||
${data.system_message.content}
|
||||
<span class="ms-1 opacity-75 small">(${data.system_message.created_at})</span>
|
||||
</span>`;
|
||||
threadContainer.appendChild(div);
|
||||
const threadScroll = document.getElementById('thread-scroll');
|
||||
if (threadScroll) threadScroll.scrollTop = threadScroll.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error changing flag:', err));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,12 @@
|
|||
<span class="fw-semibold text-dark text-truncate" style="font-size: 0.975rem;">
|
||||
{{ item.discussion.title|default:item.discussion }}
|
||||
</span>
|
||||
{% if item.discussion.flag %}
|
||||
<span class="badge {{ item.discussion.flag_info.badge_class }} rounded-pill px-2 py-0.5 small"
|
||||
title="{{ item.discussion.flag_info.label }}{% if item.discussion.flag_set_by %} - {{ item.discussion.flag_set_by.get_full_name|default:item.discussion.flag_set_by.username }}{% endif %}">
|
||||
<i class="bi {{ item.discussion.flag_info.icon }} me-1"></i>{{ item.discussion.flag_info.label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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 }}
|
||||
|
|
|
|||
|
|
@ -359,6 +359,34 @@
|
|||
role="tabpanel"
|
||||
aria-labelledby="tab-discussions-btn">
|
||||
|
||||
<!-- Filter by Flag -->
|
||||
<div class="card mb-3 border-0 shadow-sm">
|
||||
<div class="card-body py-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<div class="d-flex flex-wrap align-items-center gap-1.5">
|
||||
<span class="small text-muted me-1 fw-medium"><i class="bi bi-funnel me-1"></i>{% translate "Statut" %} :</span>
|
||||
<a href="?tab=discussions" class="btn btn-sm {% if not filter_flag %}btn-primary{% else %}btn-outline-secondary{% endif %} rounded-pill px-2.5 py-0.5" style="font-size: 0.8rem;">
|
||||
{% translate "Tous" %} ({{ flag_counts.all|default:"0" }})
|
||||
</a>
|
||||
<a href="?tab=discussions&flag=red" class="btn btn-sm {% if filter_flag == 'red' %}btn-danger text-white{% else %}btn-outline-danger{% endif %} rounded-pill px-2.5 py-0.5" style="font-size: 0.8rem;">
|
||||
<i class="bi bi-flag-fill me-1"></i>{% translate "Important" %} ({{ flag_counts.red|default:"0" }})
|
||||
</a>
|
||||
<a href="?tab=discussions&flag=orange" class="btn btn-sm {% if filter_flag == 'orange' %}btn-warning text-dark{% else %}btn-outline-warning text-dark{% endif %} rounded-pill px-2.5 py-0.5" style="font-size: 0.8rem;">
|
||||
<i class="bi bi-flag-fill me-1"></i>{% translate "En cours" %} ({{ flag_counts.orange|default:"0" }})
|
||||
</a>
|
||||
<a href="?tab=discussions&flag=green" class="btn btn-sm {% if filter_flag == 'green' %}btn-success text-white{% else %}btn-outline-success{% endif %} rounded-pill px-2.5 py-0.5" style="font-size: 0.8rem;">
|
||||
<i class="bi bi-flag-fill me-1"></i>{% translate "Traité" %} ({{ flag_counts.green|default:"0" }})
|
||||
</a>
|
||||
</div>
|
||||
{% if filter_flag %}
|
||||
<div>
|
||||
<a href="?tab=discussions" class="btn btn-link btn-sm text-muted p-0 text-decoration-none" style="font-size: 0.8rem;">
|
||||
<i class="bi bi-x-circle me-1"></i>{% translate "Réinitialiser" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not discussion_items %}
|
||||
<div class="card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
|
|
@ -387,6 +415,12 @@
|
|||
<span class="fw-semibold text-dark text-truncate" style="font-size: 0.975rem;">
|
||||
{{ item.discussion.title|default:item.discussion }}
|
||||
</span>
|
||||
{% if item.discussion.flag %}
|
||||
<span class="badge {{ item.discussion.flag_info.badge_class }} rounded-pill px-2 py-0.5 small"
|
||||
title="{{ item.discussion.flag_info.label }}{% if item.discussion.flag_set_by %} - {{ item.discussion.flag_set_by.get_full_name|default:item.discussion.flag_set_by.username }}{% endif %}">
|
||||
<i class="bi {{ item.discussion.flag_info.icon }} me-1"></i>{{ item.discussion.flag_info.label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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 }}
|
||||
|
|
|
|||
|
|
@ -288,12 +288,28 @@
|
|||
aria-labelledby="tab-discussions-btn">
|
||||
|
||||
<!-- Action Button: Nouvelle Discussion -->
|
||||
<div class="mb-3">
|
||||
<div class="mb-2">
|
||||
<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>
|
||||
|
||||
<!-- Filter by Flag -->
|
||||
<div class="d-flex gap-1.5 overflow-x-auto pb-2 mb-2 no-scrollbar align-items-center">
|
||||
<a href="?tab=discussions" class="btn btn-sm {% if not filter_flag %}btn-primary{% else %}btn-outline-secondary{% endif %} rounded-pill px-2.5 py-1 text-nowrap" style="font-size: 0.78rem;">
|
||||
{% translate "Tous" %} ({{ flag_counts.all|default:"0" }})
|
||||
</a>
|
||||
<a href="?tab=discussions&flag=red" class="btn btn-sm {% if filter_flag == 'red' %}btn-danger text-white{% else %}btn-outline-danger{% endif %} rounded-pill px-2.5 py-1 text-nowrap" style="font-size: 0.78rem;">
|
||||
<i class="bi bi-flag-fill me-1"></i>{% translate "Important" %} ({{ flag_counts.red|default:"0" }})
|
||||
</a>
|
||||
<a href="?tab=discussions&flag=orange" class="btn btn-sm {% if filter_flag == 'orange' %}btn-warning text-dark{% else %}btn-outline-warning text-dark{% endif %} rounded-pill px-2.5 py-1 text-nowrap" style="font-size: 0.78rem;">
|
||||
<i class="bi bi-flag-fill me-1"></i>{% translate "En cours" %} ({{ flag_counts.orange|default:"0" }})
|
||||
</a>
|
||||
<a href="?tab=discussions&flag=green" class="btn btn-sm {% if filter_flag == 'green' %}btn-success text-white{% else %}btn-outline-success{% endif %} rounded-pill px-2.5 py-1 text-nowrap" style="font-size: 0.78rem;">
|
||||
<i class="bi bi-flag-fill me-1"></i>{% translate "Traité" %} ({{ flag_counts.green|default:"0" }})
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if not discussion_items %}
|
||||
<div class="card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
|
|
@ -328,6 +344,11 @@
|
|||
|
||||
<!-- Badges -->
|
||||
<div class="d-flex flex-wrap gap-1 mb-1.5">
|
||||
{% if item.discussion.flag %}
|
||||
<span class="badge {{ item.discussion.flag_info.badge_class }} rounded-pill py-0.5 px-1.5" style="font-size: 0.68rem;">
|
||||
<i class="bi {{ item.discussion.flag_info.icon }} me-0.5"></i>{{ item.discussion.flag_info.label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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 }}
|
||||
|
|
|
|||
|
|
@ -518,4 +518,106 @@ class TeamMentionTests(TestCase):
|
|||
self.assertTrue(NotificationRecipient.objects.filter(user=self.team_member_user).exists())
|
||||
|
||||
|
||||
class DiscussionFlagTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='flag_user', password='pass', first_name='Alice', last_name='Smith')
|
||||
self.other_user = User.objects.create_user(username='other_user', password='pass', first_name='Bob')
|
||||
self.discussion = Discussion.objects.create(title="Discussion Flag Test", created_by=self.user)
|
||||
self.discussion.participants.add(self.user)
|
||||
|
||||
def test_set_discussion_flag_service(self):
|
||||
from notifications.services import set_discussion_flag
|
||||
|
||||
# 1. Set red flag
|
||||
msg = set_discussion_flag(self.discussion, 'red', user=self.user)
|
||||
self.discussion.refresh_from_db()
|
||||
self.assertEqual(self.discussion.flag, 'red')
|
||||
self.assertEqual(self.discussion.flag_set_by, self.user)
|
||||
self.assertIsNotNone(self.discussion.flag_set_at)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertTrue(msg.is_system)
|
||||
self.assertIn("Alice Smith", msg.content)
|
||||
self.assertIn("🚩", msg.content)
|
||||
|
||||
# 2. Change to green flag
|
||||
msg2 = set_discussion_flag(self.discussion, 'green', user=self.user)
|
||||
self.discussion.refresh_from_db()
|
||||
self.assertEqual(self.discussion.flag, 'green')
|
||||
self.assertIn("🟩", msg2.content)
|
||||
|
||||
# 3. Setting same flag returns None and doesn't create new message
|
||||
msg_noop = set_discussion_flag(self.discussion, 'green', user=self.user)
|
||||
self.assertIsNone(msg_noop)
|
||||
|
||||
# 4. Remove flag
|
||||
msg_clear = set_discussion_flag(self.discussion, '', user=self.user)
|
||||
self.discussion.refresh_from_db()
|
||||
self.assertEqual(self.discussion.flag, '')
|
||||
self.assertIsNone(self.discussion.flag_set_at)
|
||||
self.assertIn("retiré", msg_clear.content)
|
||||
|
||||
def test_discussion_set_flag_view_ajax(self):
|
||||
self.client.force_login(self.user)
|
||||
url = reverse('notifications:discussion_set_flag', args=[self.discussion.pk])
|
||||
res = self.client.post(url, {'flag': 'orange'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
data = res.json()
|
||||
self.assertTrue(data['ok'])
|
||||
self.assertEqual(data['flag'], 'orange')
|
||||
self.assertIn(data['flag_info']['label'], ['En cours', 'In progress'])
|
||||
self.discussion.refresh_from_db()
|
||||
self.assertEqual(self.discussion.flag, 'orange')
|
||||
|
||||
# Non-participant receives 403
|
||||
self.client.force_login(self.other_user)
|
||||
res_forbidden = self.client.post(url, {'flag': 'green'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
self.assertEqual(res_forbidden.status_code, 403)
|
||||
|
||||
def test_discussion_filter_by_flag(self):
|
||||
from notifications.views import _get_user_discussions_data, _get_inbox_context
|
||||
from django.test import RequestFactory
|
||||
|
||||
# Create discussions with different flags
|
||||
d_red = Discussion.objects.create(title="Red Disc", created_by=self.user, flag='red')
|
||||
d_red.participants.add(self.user)
|
||||
d_green = Discussion.objects.create(title="Green Disc", created_by=self.user, flag='green')
|
||||
d_green.participants.add(self.user)
|
||||
|
||||
# Filter red
|
||||
items_red, _ = _get_user_discussions_data(self.user, flag='red')
|
||||
pks_red = [i['discussion'].pk for i in items_red]
|
||||
self.assertIn(d_red.pk, pks_red)
|
||||
self.assertNotIn(d_green.pk, pks_red)
|
||||
|
||||
# Filter green
|
||||
items_green, _ = _get_user_discussions_data(self.user, flag='green')
|
||||
pks_green = [i['discussion'].pk for i in items_green]
|
||||
self.assertIn(d_green.pk, pks_green)
|
||||
self.assertNotIn(d_red.pk, pks_green)
|
||||
|
||||
# Inbox context with flag filter
|
||||
factory = RequestFactory()
|
||||
req = factory.get('/notifications/', {'tab': 'discussions', 'flag': 'red'})
|
||||
req.user = self.user
|
||||
ctx = _get_inbox_context(req)
|
||||
self.assertEqual(ctx['filter_flag'], 'red')
|
||||
self.assertEqual(ctx['flag_counts']['red'], 1)
|
||||
self.assertEqual(ctx['flag_counts']['green'], 1)
|
||||
|
||||
def test_discussion_create_with_flag(self):
|
||||
self.client.force_login(self.user)
|
||||
url = reverse('notifications:discussion_create')
|
||||
res = self.client.post(url, {
|
||||
'title': 'New Flagged Discussion',
|
||||
'content': 'First message',
|
||||
'flag': 'red',
|
||||
})
|
||||
self.assertEqual(res.status_code, 302)
|
||||
disc = Discussion.objects.filter(title='New Flagged Discussion').first()
|
||||
self.assertIsNotNone(disc)
|
||||
self.assertEqual(disc.flag, 'red')
|
||||
self.assertEqual(disc.flag_set_by, self.user)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ urlpatterns = [
|
|||
path('discussions/', views.discussion_list, name='discussion_list'),
|
||||
path('discussions/new/', views.discussion_create, name='discussion_create'),
|
||||
path('discussions/<int:pk>/', views.discussion_detail, name='discussion_detail'),
|
||||
path('discussions/<int:pk>/flag/', views.discussion_set_flag, name='discussion_set_flag'),
|
||||
# API
|
||||
path('api/unread-count/', views.api_unread_count, name='api_unread_count'),
|
||||
path('api/recent/', views.api_recent, name='api_recent'),
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from .services import (
|
|||
notify_team,
|
||||
post_message,
|
||||
mark_discussion_seen,
|
||||
set_discussion_flag,
|
||||
)
|
||||
from .forms import NotificationCreateForm, TeamForm
|
||||
|
||||
|
|
@ -188,7 +189,7 @@ def _translate_notification_data(notification):
|
|||
}
|
||||
|
||||
|
||||
def _get_user_discussions_data(user):
|
||||
def _get_user_discussions_data(user, flag=None, return_counts=False):
|
||||
"""Récupère les discussions de l'utilisateur avec leur dernier message et compteur non-lus."""
|
||||
from contracts.models import CompanyTeam
|
||||
company_team_ids = list(
|
||||
|
|
@ -209,13 +210,29 @@ def _get_user_discussions_data(user):
|
|||
| (Q(created_by=user) & Q(content_type__isnull=True))
|
||||
)
|
||||
|
||||
discussions = (
|
||||
all_discussions_qs = (
|
||||
Discussion.objects.filter(discussion_filter)
|
||||
.distinct()
|
||||
.prefetch_related('participants', 'messages')
|
||||
.select_related('created_by')
|
||||
.select_related('created_by', 'flag_set_by')
|
||||
.order_by('-updated_at')
|
||||
)
|
||||
|
||||
flag_counts = {
|
||||
'all': all_discussions_qs.count(),
|
||||
'red': all_discussions_qs.filter(flag='red').count(),
|
||||
'orange': all_discussions_qs.filter(flag='orange').count(),
|
||||
'green': all_discussions_qs.filter(flag='green').count(),
|
||||
'none': all_discussions_qs.filter(flag='').count(),
|
||||
}
|
||||
|
||||
discussions = all_discussions_qs
|
||||
if flag:
|
||||
if flag == 'none':
|
||||
discussions = discussions.filter(flag='')
|
||||
elif flag in ('red', 'orange', 'green'):
|
||||
discussions = discussions.filter(flag=flag)
|
||||
|
||||
items = []
|
||||
for d in discussions:
|
||||
last_msg = d.messages.filter(is_deleted=False).order_by('-created_at').first()
|
||||
|
|
@ -225,6 +242,8 @@ def _get_user_discussions_data(user):
|
|||
'unread_count': d.unread_count_for(user),
|
||||
})
|
||||
unread_discussions_count = sum(1 for item in items if item['unread_count'] > 0)
|
||||
if return_counts:
|
||||
return items, unread_discussions_count, flag_counts
|
||||
return items, unread_discussions_count
|
||||
|
||||
|
||||
|
|
@ -233,6 +252,7 @@ def _get_inbox_context(request):
|
|||
show_archived = request.GET.get('archived') == '1'
|
||||
notification_type = request.GET.get('type', '')
|
||||
active_tab = request.GET.get('tab', 'notifications')
|
||||
filter_flag = request.GET.get('flag', '').strip()
|
||||
|
||||
all_notifications = get_user_notifications(request.user, include_archived=show_archived)
|
||||
|
||||
|
|
@ -250,7 +270,9 @@ 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)
|
||||
discussion_items, unread_discussions_count, flag_counts = _get_user_discussions_data(
|
||||
request.user, flag=filter_flag, return_counts=True
|
||||
)
|
||||
|
||||
return {
|
||||
'notifications': all_notifications,
|
||||
|
|
@ -260,6 +282,10 @@ def _get_inbox_context(request):
|
|||
'notification_types': Notification.TYPES,
|
||||
'discussion_items': discussion_items,
|
||||
'unread_discussions_count': unread_discussions_count,
|
||||
'filter_flag': filter_flag,
|
||||
'flag_counts': flag_counts,
|
||||
'flag_choices': Discussion.FLAG_CHOICES,
|
||||
'flag_meta': Discussion.FLAG_META,
|
||||
'active_tab': active_tab,
|
||||
}
|
||||
|
||||
|
|
@ -534,6 +560,38 @@ def discussion_list(request):
|
|||
return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
|
||||
|
||||
|
||||
def _user_can_access_discussion(user, discussion):
|
||||
"""Vérifie si l'utilisateur a le droit d'accéder à la discussion (lecture / écriture / flag)."""
|
||||
user_config = getattr(user, 'config', None)
|
||||
is_admin = user.is_superuser or (
|
||||
user_config and (
|
||||
user_config.has_role('admin')
|
||||
or user_config.has_role('manager')
|
||||
or user_config.has_role('operator')
|
||||
or user_config.has_role('stock_manager')
|
||||
)
|
||||
)
|
||||
if is_admin or discussion.participants.filter(pk=user.pk).exists():
|
||||
return True
|
||||
|
||||
if discussion.content_type_id and discussion.object_id:
|
||||
obj = discussion.content_object
|
||||
if obj:
|
||||
from stock.models import PreparationOrder
|
||||
from interventions.models import Intervention
|
||||
from observations.models import Observation
|
||||
if isinstance(obj, Intervention):
|
||||
from notifications.services import can_post_intervention_message
|
||||
return can_post_intervention_message(user, obj)
|
||||
elif isinstance(obj, PreparationOrder):
|
||||
from notifications.services import can_post_preparation_order_message
|
||||
return can_post_preparation_order_message(user, obj)
|
||||
elif isinstance(obj, Observation):
|
||||
from notifications.services import can_post_observation_message
|
||||
return can_post_observation_message(user, obj)
|
||||
return False
|
||||
|
||||
|
||||
@login_required
|
||||
def discussion_detail(request, pk):
|
||||
"""Affiche le fil de discussion et permet de répondre."""
|
||||
|
|
@ -542,35 +600,14 @@ def discussion_detail(request, pk):
|
|||
pk=pk,
|
||||
)
|
||||
|
||||
# Vérifier que l'utilisateur est participant (ou admin / accès à l'objet lié)
|
||||
user_config = getattr(request.user, 'config', None)
|
||||
is_admin = request.user.is_superuser or (
|
||||
user_config and (user_config.has_role('admin') or user_config.has_role('manager') or user_config.has_role('operator') or user_config.has_role('stock_manager'))
|
||||
)
|
||||
is_participant = discussion.participants.filter(pk=request.user.pk).exists()
|
||||
|
||||
has_access = is_admin or is_participant
|
||||
if not has_access and discussion.content_type_id and discussion.object_id:
|
||||
obj = discussion.content_object
|
||||
if obj:
|
||||
from stock.models import PreparationOrder
|
||||
from interventions.models import Intervention
|
||||
from observations.models import Observation
|
||||
if isinstance(obj, Intervention):
|
||||
from notifications.services import can_post_intervention_message
|
||||
has_access = can_post_intervention_message(request.user, obj)
|
||||
elif isinstance(obj, PreparationOrder):
|
||||
from notifications.services import can_post_preparation_order_message
|
||||
has_access = can_post_preparation_order_message(request.user, obj)
|
||||
elif isinstance(obj, Observation):
|
||||
from notifications.services import can_post_observation_message
|
||||
has_access = can_post_observation_message(request.user, obj)
|
||||
|
||||
if not has_access:
|
||||
if not _user_can_access_discussion(request.user, discussion):
|
||||
messages.error(request, _("Vous n'êtes pas participant de cette discussion."))
|
||||
return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
|
||||
|
||||
if request.method == 'POST':
|
||||
if 'flag' in request.POST:
|
||||
set_discussion_flag(discussion, request.POST.get('flag', ''), user=request.user)
|
||||
|
||||
content = request.POST.get('content', '').strip()
|
||||
if content:
|
||||
files = request.FILES.getlist('attachments')
|
||||
|
|
@ -591,9 +628,80 @@ def discussion_detail(request, pk):
|
|||
return render(request, 'notifications/discussion_detail.html', {
|
||||
'discussion': discussion,
|
||||
'thread_messages': thread_messages,
|
||||
'flag_choices': Discussion.FLAG_CHOICES,
|
||||
'flag_meta': Discussion.FLAG_META,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def discussion_set_flag(request, pk):
|
||||
"""
|
||||
Définit ou retire le drapeau de suivi sur une discussion.
|
||||
Supporte les requêtes AJAX (JSON) et les soumissions classiques.
|
||||
"""
|
||||
discussion = get_object_or_404(Discussion, pk=pk)
|
||||
|
||||
if not _user_can_access_discussion(request.user, discussion):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.content_type == 'application/json':
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
messages.error(request, _("Vous n'avez pas accès à cette discussion."))
|
||||
return redirect(f"{reverse('notifications:inbox')}?tab=discussions")
|
||||
|
||||
flag = request.POST.get('flag')
|
||||
if flag is None and request.content_type == 'application/json':
|
||||
try:
|
||||
body = json.loads(request.body.decode('utf-8'))
|
||||
flag = body.get('flag')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
flag = (flag or '').strip()
|
||||
sys_msg = set_discussion_flag(discussion, flag, user=request.user)
|
||||
|
||||
is_ajax = (
|
||||
request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
or request.content_type == 'application/json'
|
||||
or request.GET.get('format') == 'json'
|
||||
)
|
||||
if is_ajax:
|
||||
flag_info_data = None
|
||||
if discussion.flag_info:
|
||||
flag_info_data = {
|
||||
'flag': discussion.flag,
|
||||
'label': str(discussion.flag_info.get('label', '')),
|
||||
'color': discussion.flag_info.get('color', ''),
|
||||
'badge_class': discussion.flag_info.get('badge_class', ''),
|
||||
'icon': discussion.flag_info.get('icon', 'bi-flag-fill'),
|
||||
'set_by': (
|
||||
discussion.flag_set_by.get_full_name().strip() or discussion.flag_set_by.username
|
||||
if discussion.flag_set_by else None
|
||||
),
|
||||
'set_at': discussion.flag_set_at.strftime('%d/%m/%Y %H:%M') if discussion.flag_set_at else None,
|
||||
}
|
||||
return JsonResponse({
|
||||
'ok': True,
|
||||
'flag': discussion.flag,
|
||||
'flag_info': flag_info_data,
|
||||
'flag_set_by': (
|
||||
discussion.flag_set_by.get_full_name().strip() or discussion.flag_set_by.username
|
||||
if discussion.flag_set_by else None
|
||||
),
|
||||
'flag_set_at': discussion.flag_set_at.isoformat() if discussion.flag_set_at else None,
|
||||
'system_message': {
|
||||
'id': sys_msg.pk,
|
||||
'content': sys_msg.content,
|
||||
'created_at': sys_msg.created_at.strftime('%d/%m/%Y %H:%M'),
|
||||
} if sys_msg else None,
|
||||
})
|
||||
|
||||
messages.success(request, _("Statut de la discussion mis à jour."))
|
||||
referer = request.META.get('HTTP_REFERER')
|
||||
if referer:
|
||||
return redirect(referer)
|
||||
return redirect('notifications:discussion_detail', pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
def discussion_create(request):
|
||||
"""
|
||||
|
|
@ -604,12 +712,17 @@ def discussion_create(request):
|
|||
title = request.POST.get('title', '').strip()
|
||||
content = request.POST.get('content', '').strip()
|
||||
files = request.FILES.getlist('attachments')
|
||||
flag = request.POST.get('flag', '').strip()
|
||||
|
||||
if content:
|
||||
user_name = request.user.get_full_name() or request.user.username
|
||||
now = timezone.now()
|
||||
discussion = Discussion.objects.create(
|
||||
title=title or (_("Discussion de %(name)s") % {'name': user_name}),
|
||||
created_by=request.user,
|
||||
flag=flag if flag in dict(Discussion.FLAG_CHOICES) else '',
|
||||
flag_set_by=request.user if flag in dict(Discussion.FLAG_CHOICES) and flag else None,
|
||||
flag_set_at=now if flag in dict(Discussion.FLAG_CHOICES) and flag else None,
|
||||
)
|
||||
discussion.participants.add(request.user)
|
||||
|
||||
|
|
|
|||
|
|
@ -851,6 +851,27 @@
|
|||
<i class="bi bi-chat-dots text-primary"></i>
|
||||
{% translate "Messages" %}
|
||||
</h5>
|
||||
{% if discussion %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary py-0 px-2 rounded-pill d-flex align-items-center gap-1 dropdown-toggle"
|
||||
type="button" id="obsDiscussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false" style="font-size: 0.75rem;">
|
||||
<span id="obs-flag-icon-display">
|
||||
{% if discussion.flag == 'red' %}🚩{% elif discussion.flag == 'orange' %}🟧{% elif discussion.flag == 'green' %}🟩{% else %}🏳️{% endif %}
|
||||
</span>
|
||||
<span id="obs-flag-label-display">
|
||||
{{ discussion.flag_info.label|default:_("Statut") }}
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="obsDiscussionFlagBtn" style="font-size: 0.8rem;">
|
||||
<li><h6 class="dropdown-header small text-muted py-1">{% translate "Statut de suivi" %}</h6></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 obs-flag-opt" data-flag="red"><i class="bi bi-flag-fill text-danger"></i> {% translate "Important / À traiter" %}</button></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 obs-flag-opt" data-flag="orange"><i class="bi bi-flag-fill text-warning"></i> {% translate "En cours" %}</button></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 obs-flag-opt" data-flag="green"><i class="bi bi-flag-fill text-success"></i> {% translate "Traité / Fait" %}</button></li>
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 obs-flag-opt text-muted" data-flag=""><i class="bi bi-flag"></i> {% translate "Aucun drapeau" %}</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
|
||||
|
|
@ -859,6 +880,13 @@
|
|||
<div class="d-flex flex-column gap-3 mb-3" id="discussion-thread"
|
||||
style="max-height:380px; overflow-y:auto; padding-right:4px;">
|
||||
{% for msg in discussion_messages %}
|
||||
{% if msg.is_system %}
|
||||
<div class="d-flex justify-content-center my-1">
|
||||
<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
|
||||
{{ msg.content }}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
|
||||
{% if msg.author != request.user %}
|
||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
|
||||
|
|
@ -899,6 +927,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
@ -1845,6 +1874,60 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
})();
|
||||
// ---- fin @mention autocomplete ----
|
||||
|
||||
// ---- Changement de drapeau de la discussion (observation) ----
|
||||
{% if discussion %}
|
||||
(function() {
|
||||
document.querySelectorAll('.obs-flag-opt').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const flag = this.getAttribute('data-flag');
|
||||
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
|
||||
const formData = new FormData();
|
||||
formData.append('flag', flag);
|
||||
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
const headerIcon = document.getElementById('obs-flag-icon-display');
|
||||
const headerLabel = document.getElementById('obs-flag-label-display');
|
||||
|
||||
if (headerIcon) {
|
||||
if (data.flag === 'red') headerIcon.textContent = '🚩';
|
||||
else if (data.flag === 'orange') headerIcon.textContent = '🟧';
|
||||
else if (data.flag === 'green') headerIcon.textContent = '🟩';
|
||||
else headerIcon.textContent = '🏳️';
|
||||
}
|
||||
if (headerLabel) {
|
||||
headerLabel.textContent = data.flag_info.label || '{% translate "Statut" %}';
|
||||
}
|
||||
|
||||
if (data.system_message) {
|
||||
const thread = document.getElementById('discussion-thread');
|
||||
if (thread) {
|
||||
const sysDiv = document.createElement('div');
|
||||
sysDiv.className = 'd-flex justify-content-center my-1';
|
||||
sysDiv.innerHTML = `<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
|
||||
${data.system_message.content}
|
||||
</span>`;
|
||||
thread.appendChild(sysDiv);
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error changing observation discussion flag:', err));
|
||||
});
|
||||
});
|
||||
})();
|
||||
{% endif %}
|
||||
|
||||
// ---- Live-search modals : Lier à une intervention / un projet ----
|
||||
(function () {
|
||||
function makeLiveSearch({ inputId, resultsId, hiddenId, displayId, confirmBtnId, searchUrl }) {
|
||||
|
|
|
|||
|
|
@ -1649,6 +1649,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
line-height: 1;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
#discussion-fab .fab-flag-badge {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: .75rem;
|
||||
border: 2px solid #fff;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.2);
|
||||
}
|
||||
#discussion-panel {
|
||||
position: fixed;
|
||||
bottom: 90px;
|
||||
|
|
@ -1702,21 +1717,56 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
{% if discussion_messages %}
|
||||
<span class="fab-badge" id="discussion-fab-badge">{{ discussion_messages|length }}</span>
|
||||
{% endif %}
|
||||
<span class="fab-flag-badge {% if not discussion or not discussion.flag %}d-none{% endif %}" id="discussion-fab-flag-badge"
|
||||
style="background-color: {{ discussion.flag_info.color|default:'#dc3545' }};"
|
||||
title="{{ discussion.flag_info.label }}">
|
||||
<i class="bi {{ discussion.flag_info.icon|default:'bi-flag-fill' }}"></i>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div id="discussion-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
|
||||
<!-- En-tête -->
|
||||
<div id="discussion-panel-header">
|
||||
<span class="fw-semibold d-flex align-items-center gap-2 small">
|
||||
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion liée à ce bon" %}
|
||||
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %}
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if discussion %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-light py-0 px-2 rounded-pill d-flex align-items-center gap-1 border-0 bg-white bg-opacity-20 dropdown-toggle"
|
||||
type="button" id="poDiscussionFlagBtn" data-bs-toggle="dropdown" aria-expanded="false" style="font-size: 0.75rem;">
|
||||
<span id="po-flag-icon-display">
|
||||
{% if discussion.flag == 'red' %}🚩{% elif discussion.flag == 'orange' %}🟧{% elif discussion.flag == 'green' %}🟩{% else %}🏳️{% endif %}
|
||||
</span>
|
||||
<span id="po-flag-label-display">
|
||||
{{ discussion.flag_info.label|default:_("Statut") }}
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="poDiscussionFlagBtn" style="font-size: 0.8rem; z-index: 1070;">
|
||||
<li><h6 class="dropdown-header small text-muted py-1">{% translate "Statut de suivi" %}</h6></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 po-flag-opt" data-flag="red"><i class="bi bi-flag-fill text-danger"></i> {% translate "Important / À traiter" %}</button></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 po-flag-opt" data-flag="orange"><i class="bi bi-flag-fill text-warning"></i> {% translate "En cours" %}</button></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 po-flag-opt" data-flag="green"><i class="bi bi-flag-fill text-success"></i> {% translate "Traité / Fait" %}</button></li>
|
||||
<li><hr class="dropdown-divider my-1"></li>
|
||||
<li><button type="button" class="dropdown-item d-flex align-items-center gap-2 po-flag-opt text-muted" data-flag=""><i class="bi bi-flag"></i> {% translate "Aucun drapeau" %}</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="button" class="btn-close btn-close-white btn-sm" onclick="toggleDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fil de messages -->
|
||||
<div id="discussion-thread">
|
||||
{% if discussion_messages %}
|
||||
{% for msg in discussion_messages %}
|
||||
{% if msg.is_system %}
|
||||
<div class="d-flex justify-content-center my-1">
|
||||
<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
|
||||
{{ msg.content }}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="d-flex {% if msg.author == request.user %}justify-content-end{% endif %}">
|
||||
{% if msg.author != request.user %}
|
||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center me-2 flex-shrink-0"
|
||||
|
|
@ -1754,6 +1804,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted small text-center py-3 mb-0">
|
||||
|
|
@ -2073,5 +2124,73 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- Changement de drapeau de la discussion (bon de préparation) ----
|
||||
{% if discussion %}
|
||||
(function() {
|
||||
document.querySelectorAll('.po-flag-opt').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const flag = this.getAttribute('data-flag');
|
||||
const url = "{% url 'notifications:discussion_set_flag' discussion.pk %}";
|
||||
const formData = new FormData();
|
||||
formData.append('flag', flag);
|
||||
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
const fabFlagBadge = document.getElementById('discussion-fab-flag-badge');
|
||||
const headerIcon = document.getElementById('po-flag-icon-display');
|
||||
const headerLabel = document.getElementById('po-flag-label-display');
|
||||
|
||||
if (data.flag) {
|
||||
if (fabFlagBadge) {
|
||||
fabFlagBadge.classList.remove('d-none');
|
||||
fabFlagBadge.style.backgroundColor = data.flag_info.color;
|
||||
fabFlagBadge.title = data.flag_info.label;
|
||||
fabFlagBadge.innerHTML = `<i class="bi ${data.flag_info.icon}"></i>`;
|
||||
}
|
||||
} else {
|
||||
if (fabFlagBadge) {
|
||||
fabFlagBadge.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
if (headerIcon) {
|
||||
if (data.flag === 'red') headerIcon.textContent = '🚩';
|
||||
else if (data.flag === 'orange') headerIcon.textContent = '🟧';
|
||||
else if (data.flag === 'green') headerIcon.textContent = '🟩';
|
||||
else headerIcon.textContent = '🏳️';
|
||||
}
|
||||
if (headerLabel) {
|
||||
headerLabel.textContent = data.flag_info.label || '{% translate "Statut" %}';
|
||||
}
|
||||
|
||||
if (data.system_message) {
|
||||
const thread = document.getElementById('discussion-thread');
|
||||
if (thread) {
|
||||
const sysDiv = document.createElement('div');
|
||||
sysDiv.className = 'd-flex justify-content-center my-1';
|
||||
sysDiv.innerHTML = `<span class="badge bg-light text-secondary border px-2.5 py-1 rounded-pill" style="font-size: 0.75rem; font-weight: 500;">
|
||||
${data.system_message.content}
|
||||
</span>`;
|
||||
thread.appendChild(sysDiv);
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Error changing preparation order discussion flag:', err));
|
||||
});
|
||||
});
|
||||
})();
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -3923,6 +3923,7 @@ def preparation_order_detail(request, pk):
|
|||
return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date')
|
||||
|
||||
# Discussion liée à ce bon de préparation
|
||||
discussion = None
|
||||
discussion_messages = []
|
||||
can_post_discussion_message = False
|
||||
try:
|
||||
|
|
@ -3959,6 +3960,7 @@ def preparation_order_detail(request, pk):
|
|||
'has_executed_outputs': has_executed_outputs,
|
||||
'is_reopened': is_reopened,
|
||||
'deleted_items_to_return': deleted_items_to_return,
|
||||
'discussion': discussion,
|
||||
'discussion_messages': discussion_messages,
|
||||
'can_post_discussion_message': can_post_discussion_message,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue