feat: add discussion thread support to preparation orders with messaging and access control
This commit is contained in:
parent
2ae78f70f6
commit
d3cc08cdce
9 changed files with 1172 additions and 32 deletions
|
|
@ -1063,6 +1063,27 @@ def stock_preparation_order_detail_mobile(request, pk):
|
|||
exit_batches = preparation_order.stock_batches.filter(movement_type='out').order_by('date')
|
||||
return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date')
|
||||
|
||||
# Discussion liée à ce bon de préparation (mobile)
|
||||
discussion_messages_mobile = []
|
||||
can_post_discussion_message_mobile = False
|
||||
try:
|
||||
from notifications.models import Discussion as _Discussion
|
||||
from notifications.services import mark_discussion_seen as _mark_seen, can_post_preparation_order_message as _can_post
|
||||
from django.contrib.contenttypes.models import ContentType as _CT
|
||||
_ct = _CT.objects.get_for_model(preparation_order)
|
||||
_discussion = _Discussion.objects.filter(content_type=_ct, object_id=preparation_order.pk).first()
|
||||
if _discussion:
|
||||
discussion_messages_mobile = list(
|
||||
_discussion.messages.filter(is_deleted=False)
|
||||
.select_related('author')
|
||||
.prefetch_related('attachments')
|
||||
.order_by('created_at')
|
||||
)
|
||||
_mark_seen(_discussion, user=request.user)
|
||||
can_post_discussion_message_mobile = _can_post(request.user, preparation_order)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return render(request, "stock/mobile/stock_preparation_order_detail_mobile.html", {
|
||||
'preparation_order': preparation_order,
|
||||
'items_with_stock': items_with_stock,
|
||||
|
|
@ -1076,6 +1097,8 @@ 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_messages': discussion_messages_mobile,
|
||||
'can_post_discussion_message': can_post_discussion_message_mobile,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -603,8 +603,8 @@ def get_or_create_intervention_discussion(intervention):
|
|||
"""
|
||||
Trouve ou crée la discussion associée à une intervention.
|
||||
|
||||
Les participants sont : les utilisateurs ayant un accès contrat à cette
|
||||
intervention + les membres de l'équipe assignée (si présente).
|
||||
Les participants initiaux sont : le créateur de l'intervention et le gestionnaire (si présents).
|
||||
Les autres intervenants sont ajoutés lorsqu'ils répondent ou sont mentionnés.
|
||||
Retourne (discussion, created).
|
||||
"""
|
||||
from .models import Discussion
|
||||
|
|
@ -636,34 +636,117 @@ def get_or_create_intervention_discussion(intervention):
|
|||
)
|
||||
|
||||
participants = set()
|
||||
|
||||
# Membres de l'équipe assignée
|
||||
if intervention.assigned_team:
|
||||
try:
|
||||
from contracts.models import CompanyMember
|
||||
from django.contrib.auth.models import User as AuthUser
|
||||
team_users = AuthUser.objects.filter(
|
||||
companymember__teams=intervention.assigned_team,
|
||||
).distinct()
|
||||
participants.update(team_users)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Utilisateurs avec accès contrat
|
||||
if intervention.contract:
|
||||
try:
|
||||
from common.models import UserContractAccess
|
||||
from django.contrib.auth.models import User as AuthUser
|
||||
contract_users = AuthUser.objects.filter(
|
||||
config__usercontractaccess__contract=intervention.contract,
|
||||
config__usercontractaccess__can_view_interventions=True,
|
||||
).distinct()
|
||||
participants.update(contract_users)
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(intervention, 'intervention_manager') and intervention.intervention_manager:
|
||||
participants.add(intervention.intervention_manager)
|
||||
|
||||
if participants:
|
||||
discussion.participants.set(participants)
|
||||
return discussion, True
|
||||
|
||||
|
||||
def can_post_preparation_order_message(user, preparation_order):
|
||||
"""
|
||||
Retourne True si l'utilisateur peut poster un message dans la discussion
|
||||
d'un bon de préparation. Critères :
|
||||
- Il est superuser, OU
|
||||
- Il est admin/operator/stock_manager, OU
|
||||
- Il a la permission can_manage_preparation_orders_any, OU
|
||||
- L'intervention liée peut être vue (can_view_intervention).
|
||||
"""
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
user_config = getattr(user, 'config', None)
|
||||
if user_config and hasattr(user_config, 'has_role'):
|
||||
if user_config.has_role('admin') or user_config.has_role('operator') or user_config.has_role('stock_manager'):
|
||||
return True
|
||||
if hasattr(user_config, 'can_manage_preparation_orders_any') and user_config.can_manage_preparation_orders_any():
|
||||
return True
|
||||
if preparation_order and preparation_order.intervention:
|
||||
try:
|
||||
from interventions.permissions import can_view_intervention
|
||||
if can_view_intervention(user, preparation_order.intervention):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def get_or_create_preparation_order_discussion(preparation_order):
|
||||
"""
|
||||
Trouve ou crée la discussion associée à un bon de préparation.
|
||||
"""
|
||||
from .models import Discussion
|
||||
from django.contrib.contenttypes.models import ContentType as CT
|
||||
from django.urls import reverse
|
||||
|
||||
ct = CT.objects.get_for_model(preparation_order)
|
||||
discussion = Discussion.objects.filter(
|
||||
content_type=ct, object_id=preparation_order.pk
|
||||
).first()
|
||||
|
||||
if discussion:
|
||||
return discussion, False
|
||||
|
||||
try:
|
||||
obj_url = reverse('stock:preparation_order_detail', args=[preparation_order.pk])
|
||||
except Exception:
|
||||
obj_url = ''
|
||||
|
||||
obj_label = f"Bon de préparation {preparation_order.order_number}"
|
||||
|
||||
created_by_user = None
|
||||
if preparation_order.intervention and hasattr(preparation_order.intervention, 'created_by'):
|
||||
created_by_user = preparation_order.intervention.created_by
|
||||
|
||||
discussion = Discussion.objects.create(
|
||||
title=obj_label,
|
||||
created_by=created_by_user,
|
||||
content_type=ct,
|
||||
object_id=preparation_order.pk,
|
||||
object_url=obj_url,
|
||||
object_label=obj_label,
|
||||
)
|
||||
|
||||
participants = set()
|
||||
if preparation_order.intervention:
|
||||
interv = preparation_order.intervention
|
||||
if hasattr(interv, 'intervention_manager') and interv.intervention_manager:
|
||||
participants.add(interv.intervention_manager)
|
||||
|
||||
if participants:
|
||||
discussion.participants.set(participants)
|
||||
return discussion, True
|
||||
|
||||
|
||||
def cleanup_linked_discussion_participants(discussion):
|
||||
"""
|
||||
Conserve uniquement comme participants le gestionnaire d'intervention,
|
||||
ainsi que les auteurs de messages et personnes mentionnées.
|
||||
Ne conserve pas l'auteur/créateur de l'intervention par défaut.
|
||||
"""
|
||||
if not discussion.content_type_id:
|
||||
return
|
||||
obj = discussion.content_object
|
||||
if not obj:
|
||||
return
|
||||
|
||||
keep_user_ids = set()
|
||||
|
||||
if hasattr(obj, 'intervention_manager_id') and obj.intervention_manager_id:
|
||||
keep_user_ids.add(obj.intervention_manager_id)
|
||||
|
||||
msg_author_ids = discussion.messages.values_list('author_id', flat=True)
|
||||
keep_user_ids.update(msg_author_ids)
|
||||
|
||||
for msg in discussion.messages.all():
|
||||
for m in _MENTION_PATTERN.finditer(msg.content or ''):
|
||||
if m.group(2) == 'u':
|
||||
keep_user_ids.add(int(m.group(3)))
|
||||
|
||||
discussion.participants.set(keep_user_ids)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -522,12 +522,31 @@ def discussion_detail(request, pk):
|
|||
pk=pk,
|
||||
)
|
||||
|
||||
# Vérifier que l'utilisateur est participant (ou admin)
|
||||
# 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'))
|
||||
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 not is_admin and not discussion.participants.filter(pk=request.user.pk).exists():
|
||||
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:
|
||||
messages.error(request, _("Vous n'êtes pas participant de cette discussion."))
|
||||
return redirect('notifications:discussion_list')
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
{% load i18n %}
|
||||
{% load stock_custom_filters %}
|
||||
{% load short_name %}
|
||||
{% load mention_tags %}
|
||||
|
||||
{% block title %}{% translate "Bon de Préparation" %} #{{ preparation_order.order_number }}{% endblock title %}
|
||||
|
||||
|
|
@ -741,4 +742,448 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Widget Discussion mobile ===== -->
|
||||
<style>
|
||||
#discussion-mob-fab {
|
||||
position: fixed;
|
||||
bottom: 5.5rem;
|
||||
right: 1rem;
|
||||
z-index: 1050;
|
||||
border-radius: 50%;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||
padding: 0;
|
||||
background: #0d6efd;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
#discussion-mob-fab .mob-fab-badge {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
font-size: .65rem;
|
||||
font-weight: 700;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
border: 2px solid #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
#discussion-mob-panel {
|
||||
position: fixed;
|
||||
top: 6.5rem;
|
||||
bottom: 0;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 1060;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 14px 14px 0 0;
|
||||
box-shadow: 0 -4px 24px rgba(0,0,0,.18);
|
||||
overflow: hidden;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform .28s cubic-bezier(.4,0,.2,1), opacity .2s ease;
|
||||
}
|
||||
#discussion-mob-panel.open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#discussion-mob-panel-header {
|
||||
background: #0d6efd;
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#discussion-mob-thread {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
#discussion-mob-form-area {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
|
||||
<button id="discussion-mob-fab" onclick="toggleMobDiscussionPanel()"
|
||||
title="{% translate 'Discussion' %}" aria-label="{% translate 'Discussion' %}">
|
||||
<i class="bi bi-chat-dots-fill"></i>
|
||||
{% if discussion_messages %}
|
||||
<span class="mob-fab-badge" id="discussion-mob-badge">{{ discussion_messages|length }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
<div id="discussion-mob-panel" role="dialog" aria-label="{% translate 'Discussion' %}">
|
||||
<div id="discussion-mob-panel-header">
|
||||
<span class="fw-semibold d-flex align-items-center gap-2 small">
|
||||
<i class="bi bi-chat-dots-fill"></i> {% translate "Discussion" %}
|
||||
</span>
|
||||
<button type="button" class="btn-close btn-close-white btn-sm"
|
||||
onclick="toggleMobDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
|
||||
</div>
|
||||
|
||||
<div id="discussion-mob-thread">
|
||||
{% if discussion_messages %}
|
||||
{% for msg in discussion_messages %}
|
||||
<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"
|
||||
style="width:28px;height:28px;font-size:.7rem;">
|
||||
{{ msg.author|short_name|first|upper|default:msg.author.username|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="max-width:80%;">
|
||||
{% if msg.author != request.user %}
|
||||
<div class="small text-muted mb-1">
|
||||
<strong>{{ msg.author|short_name }}</strong>
|
||||
· {{ msg.created_at|date:"d/m H:i" }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="small text-muted mb-1 text-end">{{ msg.created_at|date:"d/m H:i" }}</div>
|
||||
{% endif %}
|
||||
<div class="p-2 rounded {% if msg.author != request.user %}bg-light{% endif %}"
|
||||
style="{% if msg.author == request.user %}background:#e7ebff;{% endif %}border-radius:12px;word-break:break-word;display:inline-block;text-align:left;font-size:.85rem;">
|
||||
{{ msg.content|render_mentions }}
|
||||
{% if msg.attachments.exists %}
|
||||
<div class="mt-1 d-flex flex-wrap gap-1">
|
||||
{% for att in msg.attachments.all %}
|
||||
{% if att.is_image %}
|
||||
<a href="{{ att.file.url }}" target="_blank">
|
||||
<img src="{{ att.file.url }}" style="width:44px;height:44px;object-fit:cover;border-radius:6px;" alt="{{ att.name }}">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ att.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-1" style="font-size:.75rem;">
|
||||
<i class="bi bi-paperclip"></i>{{ att.name|truncatechars:16 }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted small text-center py-3 mb-0">
|
||||
<i class="bi bi-chat-square me-1"></i>{% translate "Aucun message." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if can_post_discussion_message %}
|
||||
<div id="discussion-mob-form-area">
|
||||
<form method="post"
|
||||
action="{% url 'stock:preparation_order_add_message' preparation_order.id %}"
|
||||
enctype="multipart/form-data"
|
||||
id="mob-msg-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="content" id="mob-msg-content-real">
|
||||
<div class="d-flex gap-2 align-items-end">
|
||||
<div class="flex-grow-1 position-relative">
|
||||
<textarea id="mob-msg-content" class="form-control form-control-sm"
|
||||
rows="2" style="resize:none;font-size:.85rem;"
|
||||
placeholder="{% translate 'Écrire… (@ pour mentionner)' %}"></textarea>
|
||||
<div id="mob-mention-dropdown"
|
||||
class="list-group shadow"
|
||||
style="display:none;position:absolute;bottom:100%;left:0;right:0;z-index:1080;max-height:160px;overflow-y:auto;margin-bottom:2px;"></div>
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<button type="submit" class="btn btn-primary btn-sm" title="{% translate 'Envoyer' %}" style="padding:5px 8px;">
|
||||
<i class="bi bi-send-fill"></i>
|
||||
</button>
|
||||
<label class="btn btn-outline-secondary btn-sm mb-0" title="{% translate 'Joindre un fichier' %}" style="padding:5px 8px;">
|
||||
<i class="bi bi-paperclip"></i>
|
||||
<input type="file" name="attachments" multiple class="d-none" id="mob-msg-file-input">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mob-msg-file-preview" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- ===== fin widget Discussion mobile ===== -->
|
||||
|
||||
<script>
|
||||
function toggleMobDiscussionPanel() {
|
||||
const panel = document.getElementById('discussion-mob-panel');
|
||||
if (!panel) return;
|
||||
const opening = !panel.classList.contains('open');
|
||||
panel.classList.toggle('open', opening);
|
||||
if (opening) {
|
||||
const thread = document.getElementById('discussion-mob-thread');
|
||||
if (thread) setTimeout(() => { thread.scrollTop = thread.scrollHeight; }, 50);
|
||||
const badge = document.getElementById('discussion-mob-badge');
|
||||
if (badge) badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
const panel = document.getElementById('discussion-mob-panel');
|
||||
const fab = document.getElementById('discussion-mob-fab');
|
||||
if (!panel || !panel.classList.contains('open')) return;
|
||||
if (panel.contains(e.target) || (fab && fab.contains(e.target))) return;
|
||||
panel.classList.remove('open');
|
||||
});
|
||||
|
||||
if (window.location.hash === '#discussion') {
|
||||
toggleMobDiscussionPanel();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const fileInput = document.getElementById('mob-msg-file-input');
|
||||
const filePreview = document.getElementById('mob-msg-file-preview');
|
||||
if (fileInput && filePreview) {
|
||||
fileInput.addEventListener('change', renderMobPreview);
|
||||
function renderMobPreview() {
|
||||
filePreview.innerHTML = '';
|
||||
const files = fileInput.files;
|
||||
if (!files || !files.length) { filePreview.style.display = 'none'; return; }
|
||||
filePreview.style.display = 'flex';
|
||||
Array.from(files).forEach((file, idx) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'position-relative';
|
||||
wrapper.style.cssText = 'display:inline-block;';
|
||||
if (isImage) {
|
||||
const img = document.createElement('img');
|
||||
img.style.cssText = 'width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid #dee2e6;';
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => { img.src = e.target.result; };
|
||||
reader.readAsDataURL(file);
|
||||
wrapper.appendChild(img);
|
||||
} else {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'd-flex align-items-center gap-1 bg-light border rounded px-2 py-1 small';
|
||||
chip.style.cssText = 'max-width:120px;';
|
||||
chip.innerHTML = `<i class="bi bi-paperclip text-secondary"></i><span class="text-truncate">${file.name}</span>`;
|
||||
wrapper.appendChild(chip);
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-danger btn-sm position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
|
||||
btn.style.cssText = 'width:18px;height:18px;font-size:10px;border-radius:50%;transform:translate(40%,-40%);line-height:1;';
|
||||
btn.innerHTML = '×';
|
||||
btn.addEventListener('click', () => {
|
||||
const dt = new DataTransfer();
|
||||
Array.from(fileInput.files).forEach((f, i) => { if (i !== idx) dt.items.add(f); });
|
||||
fileInput.files = dt.files;
|
||||
renderMobPreview();
|
||||
});
|
||||
wrapper.appendChild(btn);
|
||||
filePreview.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
const msgForm = document.getElementById('mob-msg-form');
|
||||
if (msgForm) {
|
||||
msgForm.addEventListener('submit', () => { filePreview.innerHTML = ''; filePreview.style.display = 'none'; });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// @mention autocomplete (mobile)
|
||||
(function () {
|
||||
const textarea = document.getElementById('mob-msg-content');
|
||||
const realInput = document.getElementById('mob-msg-content-real');
|
||||
const dropdown = document.getElementById('mob-mention-dropdown');
|
||||
const form = document.getElementById('mob-msg-form');
|
||||
if (!textarea || !dropdown || !realInput || !form) return;
|
||||
|
||||
let mentions = [];
|
||||
let mentionStart = -1;
|
||||
let mentionQuery = null;
|
||||
let searchTimer = null;
|
||||
let currentResults = [];
|
||||
let selectedIndex = -1;
|
||||
|
||||
function escHtml(str) {
|
||||
return (str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function syncReal() {
|
||||
const dv = textarea.value;
|
||||
if (!mentions.length) { realInput.value = dv; return; }
|
||||
let real = '';
|
||||
let pos = 0;
|
||||
const sorted = [...mentions].sort((a, b) => a.start - b.start);
|
||||
for (const m of sorted) {
|
||||
if (m.start > pos) real += dv.slice(pos, m.start);
|
||||
real += m.marker;
|
||||
pos = m.end;
|
||||
}
|
||||
real += dv.slice(pos);
|
||||
realInput.value = real;
|
||||
}
|
||||
|
||||
function shiftMentions(fromPos, delta) {
|
||||
for (const m of mentions) {
|
||||
if (m.start >= fromPos) { m.start += delta; m.end += delta; }
|
||||
}
|
||||
}
|
||||
|
||||
function removeMentionsIn(start, end) {
|
||||
mentions = mentions.filter(m => m.end <= start || m.start >= end);
|
||||
}
|
||||
|
||||
function highlightIndex(idx) {
|
||||
const items = dropdown.querySelectorAll('.list-group-item');
|
||||
if (!items || !items.length) return;
|
||||
items.forEach((btn, i) => {
|
||||
if (i === idx) {
|
||||
btn.classList.add('active');
|
||||
btn.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDropdown(results) {
|
||||
dropdown.innerHTML = '';
|
||||
currentResults = results || [];
|
||||
selectedIndex = -1;
|
||||
if (!results || !results.length) { hideDropdown(); return; }
|
||||
results.forEach((r, idx) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
|
||||
const icon = r.type === 'user' ? 'bi-person-fill' : 'bi-people-fill';
|
||||
const badgeCls = r.type === 'user'
|
||||
? 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle'
|
||||
: 'badge bg-info-subtle text-info-emphasis border border-info-subtle';
|
||||
btn.innerHTML = `<span class="${badgeCls}"><i class="bi ${icon}"></i></span><span>${escHtml(r.display)}</span>`;
|
||||
btn.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
insertMention(r);
|
||||
});
|
||||
btn.addEventListener('mouseenter', function () {
|
||||
selectedIndex = idx;
|
||||
highlightIndex(selectedIndex);
|
||||
});
|
||||
dropdown.appendChild(btn);
|
||||
});
|
||||
|
||||
selectedIndex = 0;
|
||||
highlightIndex(0);
|
||||
dropdown.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideDropdown() {
|
||||
dropdown.style.display = 'none';
|
||||
dropdown.innerHTML = '';
|
||||
mentionStart = -1;
|
||||
mentionQuery = null;
|
||||
currentResults = [];
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
function insertMention(r) {
|
||||
const shortTag = `@[${r.display}]`;
|
||||
const marker = r.type === 'user'
|
||||
? `@[${r.display}](u:${r.id})`
|
||||
: `@[${r.display}](t:${r.id})`;
|
||||
const dv = textarea.value;
|
||||
const qLen = mentionQuery ? mentionQuery.length : 0;
|
||||
const before = dv.slice(0, mentionStart);
|
||||
const after = dv.slice(mentionStart + 1 + qLen);
|
||||
textarea.value = before + shortTag + ' ' + after;
|
||||
removeMentionsIn(mentionStart, mentionStart + 1 + qLen);
|
||||
shiftMentions(mentionStart + 1 + qLen, shortTag.length + 1 - (1 + qLen));
|
||||
mentions.push({ start: mentionStart, end: mentionStart + shortTag.length, marker });
|
||||
syncReal();
|
||||
const pos = mentionStart + shortTag.length + 1;
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
textarea.focus();
|
||||
hideDropdown();
|
||||
}
|
||||
|
||||
textarea.addEventListener('keydown', function (e) {
|
||||
if (dropdown.style.display === 'block' && currentResults.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % currentResults.length;
|
||||
highlightIndex(selectedIndex);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + currentResults.length) % currentResults.length;
|
||||
highlightIndex(selectedIndex);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
|
||||
e.preventDefault();
|
||||
insertMention(currentResults[selectedIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
hideDropdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function searchMentions(q) {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
fetch(`{% url 'notifications:api_mention_search' %}?q=${encodeURIComponent(q)}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(resp => resp.json())
|
||||
.then(data => showDropdown(data.results))
|
||||
.catch(() => hideDropdown());
|
||||
}, 180);
|
||||
}
|
||||
|
||||
textarea.addEventListener('input', function () {
|
||||
syncReal();
|
||||
const cur = this.selectionStart;
|
||||
const before = this.value.slice(0, cur);
|
||||
const match = before.match(/@([^\s@]*)$/);
|
||||
if (!match) { hideDropdown(); return; }
|
||||
mentionStart = match.index;
|
||||
mentionQuery = match[1];
|
||||
searchMentions(mentionQuery);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
syncReal();
|
||||
if (!realInput.value.trim()) {
|
||||
e.preventDefault();
|
||||
textarea.setCustomValidity('{% translate "Le message ne peut pas être vide." %}');
|
||||
textarea.reportValidity();
|
||||
} else {
|
||||
textarea.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
{% load i18n %}
|
||||
{% load stock_custom_filters %}
|
||||
{% load short_name %}
|
||||
{% load mention_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-3">
|
||||
|
|
@ -1612,4 +1613,465 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ===== Widget Discussion flottant ===== -->
|
||||
<style>
|
||||
#discussion-fab {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 1060;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: #0d6efd;
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
#discussion-fab:hover { background: #0b5ed7; transform: scale(1.08); }
|
||||
#discussion-fab .fab-badge {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
line-height: 1;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
#discussion-panel {
|
||||
position: fixed;
|
||||
bottom: 90px;
|
||||
right: 24px;
|
||||
z-index: 1060;
|
||||
width: 390px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 580px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.25s cubic-bezier(0.34,1.56,0.64,1), opacity 0.2s ease;
|
||||
}
|
||||
#discussion-panel.open {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#discussion-panel-header {
|
||||
background: #0d6efd;
|
||||
color: #fff;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#discussion-thread {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
#discussion-form-area {
|
||||
padding: 10px 14px 14px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
|
||||
<button id="discussion-fab" onclick="toggleDiscussionPanel()" title="{% translate 'Discussion' %}" aria-label="{% translate 'Discussion' %}">
|
||||
<i class="bi bi-chat-dots-fill"></i>
|
||||
{% if discussion_messages %}
|
||||
<span class="fab-badge" id="discussion-fab-badge">{{ discussion_messages|length }}</span>
|
||||
{% endif %}
|
||||
</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" %}
|
||||
</span>
|
||||
<button type="button" class="btn-close btn-close-white btn-sm" onclick="toggleDiscussionPanel()" aria-label="{% translate 'Fermer' %}"></button>
|
||||
</div>
|
||||
|
||||
<!-- Fil de messages -->
|
||||
<div id="discussion-thread">
|
||||
{% if discussion_messages %}
|
||||
{% for msg in discussion_messages %}
|
||||
<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"
|
||||
style="width:30px;height:30px;font-size:.75rem;">
|
||||
{{ msg.author|short_name|first|upper|default:msg.author.username|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="max-width:78%;">
|
||||
{% if msg.author != request.user %}
|
||||
<div class="small text-muted mb-1">
|
||||
<strong>{{ msg.author|short_name }}</strong>
|
||||
· {{ msg.created_at|date:"d/m H:i" }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="small text-muted mb-1 text-end">{{ msg.created_at|date:"d/m H:i" }}</div>
|
||||
{% endif %}
|
||||
<div class="p-2 rounded {% if msg.author != request.user %}bg-light{% endif %}"
|
||||
style="{% if msg.author == request.user %}background:#e7ebff;{% endif %}border-radius:12px;word-break:break-word;display:inline-block;text-align:left;font-size:.85rem;">
|
||||
{{ msg.content|render_mentions }}
|
||||
{% if msg.attachments.exists %}
|
||||
<div class="mt-1 d-flex flex-wrap gap-1">
|
||||
{% for att in msg.attachments.all %}
|
||||
{% if att.is_image %}
|
||||
<a href="{{ att.file.url }}" target="_blank">
|
||||
<img src="{{ att.file.url }}" style="width:48px;height:48px;object-fit:cover;border-radius:6px;" alt="{{ att.name }}">
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ att.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-1" style="font-size:.75rem;">
|
||||
<i class="bi bi-paperclip"></i>{{ att.name|truncatechars:18 }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted small text-center py-3 mb-0">
|
||||
<i class="bi bi-chat-square me-1"></i>{% translate "Aucun message." %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
{% if can_post_discussion_message %}
|
||||
<div id="discussion-form-area">
|
||||
<form method="post"
|
||||
action="{% url 'stock:preparation_order_add_message' preparation_order.id %}"
|
||||
enctype="multipart/form-data"
|
||||
id="prep-msg-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="content" id="prep-msg-content-real">
|
||||
<div class="d-flex gap-2 align-items-end">
|
||||
<div class="flex-grow-1 position-relative">
|
||||
<textarea id="prep-msg-content" class="form-control form-control-sm"
|
||||
rows="2" style="resize:none;font-size:.85rem;"
|
||||
placeholder="{% translate 'Écrire… (@ pour mentionner)' %}"></textarea>
|
||||
<div id="prep-mention-dropdown"
|
||||
class="list-group shadow"
|
||||
style="display:none;position:absolute;bottom:100%;left:0;right:0;z-index:1070;max-height:180px;overflow-y:auto;margin-bottom:2px;"></div>
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<button type="submit" class="btn btn-primary btn-sm" title="{% translate 'Envoyer' %}" style="padding:5px 8px;">
|
||||
<i class="bi bi-send-fill"></i>
|
||||
</button>
|
||||
<label class="btn btn-outline-secondary btn-sm mb-0" title="{% translate 'Joindre un fichier' %}" style="padding:5px 8px;">
|
||||
<i class="bi bi-paperclip"></i>
|
||||
<input type="file" name="attachments" multiple class="d-none" id="prep-msg-file-input">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="prep-msg-file-preview" class="d-flex flex-wrap gap-2 mt-2"></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- ===== fin widget Discussion ===== -->
|
||||
|
||||
<script>
|
||||
// Discussion – widget flottant + @mention autocomplete + file preview
|
||||
function toggleDiscussionPanel() {
|
||||
const panel = document.getElementById('discussion-panel');
|
||||
if (!panel) return;
|
||||
const opening = !panel.classList.contains('open');
|
||||
panel.classList.toggle('open', opening);
|
||||
if (opening) {
|
||||
const thread = document.getElementById('discussion-thread');
|
||||
if (thread) setTimeout(() => { thread.scrollTop = thread.scrollHeight; }, 50);
|
||||
const badge = document.getElementById('discussion-fab-badge');
|
||||
if (badge) badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
const panel = document.getElementById('discussion-panel');
|
||||
const fab = document.getElementById('discussion-fab');
|
||||
if (!panel || !panel.classList.contains('open')) return;
|
||||
if (panel.contains(e.target) || (fab && fab.contains(e.target))) return;
|
||||
panel.classList.remove('open');
|
||||
});
|
||||
|
||||
if (window.location.hash === '#discussion') {
|
||||
toggleDiscussionPanel();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const fileInput = document.getElementById('prep-msg-file-input');
|
||||
const filePreview = document.getElementById('prep-msg-file-preview');
|
||||
|
||||
if (fileInput && filePreview) {
|
||||
fileInput.addEventListener('change', renderPreview);
|
||||
|
||||
function renderPreview() {
|
||||
filePreview.innerHTML = '';
|
||||
const files = fileInput.files;
|
||||
if (!files || files.length === 0) {
|
||||
filePreview.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
filePreview.style.display = 'flex';
|
||||
|
||||
Array.from(files).forEach((file, idx) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'position-relative';
|
||||
wrapper.style.cssText = 'display:inline-block;';
|
||||
|
||||
if (isImage) {
|
||||
const img = document.createElement('img');
|
||||
img.style.cssText = 'width:64px;height:64px;object-fit:cover;border-radius:6px;border:1px solid #dee2e6;';
|
||||
img.alt = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => { img.src = e.target.result; };
|
||||
reader.readAsDataURL(file);
|
||||
wrapper.appendChild(img);
|
||||
} else {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'd-flex align-items-center gap-1 bg-light border rounded px-2 py-1 small';
|
||||
chip.style.cssText = 'max-width:140px;';
|
||||
chip.innerHTML = `<i class="bi bi-paperclip text-secondary"></i>
|
||||
<span class="text-truncate" title="${file.name}">${file.name}</span>`;
|
||||
wrapper.appendChild(chip);
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-danger btn-sm position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
|
||||
btn.style.cssText = 'width:18px;height:18px;font-size:10px;border-radius:50%;transform:translate(40%,-40%);line-height:1;';
|
||||
btn.innerHTML = '×';
|
||||
btn.title = '{% translate "Retirer" %}';
|
||||
btn.addEventListener('click', () => removeFile(idx));
|
||||
wrapper.appendChild(btn);
|
||||
filePreview.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
|
||||
function removeFile(indexToRemove) {
|
||||
const dt = new DataTransfer();
|
||||
Array.from(fileInput.files).forEach((f, i) => {
|
||||
if (i !== indexToRemove) dt.items.add(f);
|
||||
});
|
||||
fileInput.files = dt.files;
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
const msgForm = document.getElementById('prep-msg-form');
|
||||
if (msgForm) {
|
||||
msgForm.addEventListener('submit', () => {
|
||||
filePreview.innerHTML = '';
|
||||
filePreview.style.display = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// @mention autocomplete
|
||||
(function () {
|
||||
const textarea = document.getElementById('prep-msg-content');
|
||||
const realInput = document.getElementById('prep-msg-content-real');
|
||||
const dropdown = document.getElementById('prep-mention-dropdown');
|
||||
const form = document.getElementById('prep-msg-form');
|
||||
if (!textarea || !dropdown || !realInput || !form) return;
|
||||
|
||||
let mentions = [];
|
||||
let mentionStart = -1;
|
||||
let mentionQuery = null;
|
||||
let searchTimer = null;
|
||||
let currentResults = [];
|
||||
let selectedIndex = -1;
|
||||
|
||||
function escHtml(str) {
|
||||
return (str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function syncReal() {
|
||||
const dv = textarea.value;
|
||||
if (!mentions.length) { realInput.value = dv; return; }
|
||||
let real = '';
|
||||
let pos = 0;
|
||||
const sorted = [...mentions].sort((a, b) => a.start - b.start);
|
||||
for (const m of sorted) {
|
||||
if (m.start > pos) real += dv.slice(pos, m.start);
|
||||
real += m.marker;
|
||||
pos = m.end;
|
||||
}
|
||||
real += dv.slice(pos);
|
||||
realInput.value = real;
|
||||
}
|
||||
|
||||
function shiftMentions(fromPos, delta) {
|
||||
for (const m of mentions) {
|
||||
if (m.start >= fromPos) { m.start += delta; m.end += delta; }
|
||||
}
|
||||
}
|
||||
|
||||
function removeMentionsIn(start, end) {
|
||||
mentions = mentions.filter(m => m.end <= start || m.start >= end);
|
||||
}
|
||||
|
||||
function highlightIndex(idx) {
|
||||
const items = dropdown.querySelectorAll('.list-group-item');
|
||||
if (!items || !items.length) return;
|
||||
items.forEach((btn, i) => {
|
||||
if (i === idx) {
|
||||
btn.classList.add('active');
|
||||
btn.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDropdown(results) {
|
||||
dropdown.innerHTML = '';
|
||||
currentResults = results || [];
|
||||
selectedIndex = -1;
|
||||
if (!results || !results.length) { hideDropdown(); return; }
|
||||
results.forEach((r, idx) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 small';
|
||||
const icon = r.type === 'user' ? 'bi-person-fill' : 'bi-people-fill';
|
||||
const badgeCls = r.type === 'user'
|
||||
? 'badge bg-primary-subtle text-primary-emphasis border border-primary-subtle'
|
||||
: 'badge bg-info-subtle text-info-emphasis border border-info-subtle';
|
||||
btn.innerHTML = `<span class="${badgeCls}"><i class="bi ${icon}"></i></span><span>${escHtml(r.display)}</span>`;
|
||||
btn.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
insertMention(r);
|
||||
});
|
||||
btn.addEventListener('mouseenter', function () {
|
||||
selectedIndex = idx;
|
||||
highlightIndex(selectedIndex);
|
||||
});
|
||||
dropdown.appendChild(btn);
|
||||
});
|
||||
|
||||
selectedIndex = 0;
|
||||
highlightIndex(0);
|
||||
dropdown.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideDropdown() {
|
||||
dropdown.style.display = 'none';
|
||||
dropdown.innerHTML = '';
|
||||
mentionStart = -1;
|
||||
mentionQuery = null;
|
||||
currentResults = [];
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
function insertMention(r) {
|
||||
const shortTag = `@[${r.display}]`;
|
||||
const marker = r.type === 'user'
|
||||
? `@[${r.display}](u:${r.id})`
|
||||
: `@[${r.display}](t:${r.id})`;
|
||||
const dv = textarea.value;
|
||||
const qLen = mentionQuery ? mentionQuery.length : 0;
|
||||
const before = dv.slice(0, mentionStart);
|
||||
const after = dv.slice(mentionStart + 1 + qLen);
|
||||
textarea.value = before + shortTag + ' ' + after;
|
||||
removeMentionsIn(mentionStart, mentionStart + 1 + qLen);
|
||||
shiftMentions(mentionStart + 1 + qLen, shortTag.length + 1 - (1 + qLen));
|
||||
mentions.push({ start: mentionStart, end: mentionStart + shortTag.length, marker });
|
||||
syncReal();
|
||||
const pos = mentionStart + shortTag.length + 1;
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
textarea.focus();
|
||||
hideDropdown();
|
||||
}
|
||||
|
||||
textarea.addEventListener('keydown', function (e) {
|
||||
if (dropdown.style.display === 'block' && currentResults.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % currentResults.length;
|
||||
highlightIndex(selectedIndex);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + currentResults.length) % currentResults.length;
|
||||
highlightIndex(selectedIndex);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (selectedIndex >= 0 && selectedIndex < currentResults.length) {
|
||||
e.preventDefault();
|
||||
insertMention(currentResults[selectedIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
hideDropdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function searchMentions(q) {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
fetch(`{% url 'notifications:api_mention_search' %}?q=${encodeURIComponent(q)}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(resp => resp.json())
|
||||
.then(data => showDropdown(data.results))
|
||||
.catch(() => hideDropdown());
|
||||
}, 180);
|
||||
}
|
||||
|
||||
textarea.addEventListener('input', function () {
|
||||
syncReal();
|
||||
const cur = this.selectionStart;
|
||||
const before = this.value.slice(0, cur);
|
||||
const match = before.match(/@([^\s@]*)$/);
|
||||
if (!match) { hideDropdown(); return; }
|
||||
mentionStart = match.index;
|
||||
mentionQuery = match[1];
|
||||
searchMentions(mentionQuery);
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
syncReal();
|
||||
if (!realInput.value.trim()) {
|
||||
e.preventDefault();
|
||||
textarea.setCustomValidity('{% translate "Le message ne peut pas être vide." %}');
|
||||
textarea.reportValidity();
|
||||
} else {
|
||||
textarea.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth.models import User
|
||||
from .models import (
|
||||
Inventory,
|
||||
InventoryItem,
|
||||
|
|
@ -13,6 +14,7 @@ from .models import (
|
|||
Warehouse,
|
||||
WarehouseLocation,
|
||||
SerializedItem,
|
||||
PreparationOrder,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1735,7 +1737,8 @@ class PreparationOrderAutocompleteTests(TestCase):
|
|||
response_detail = self.client.get(reverse("stock:preparation_order_detail", args=[po.pk]))
|
||||
self.assertEqual(response_detail.status_code, 200)
|
||||
self.assertContains(response_detail, f"preparation_order={po.pk}")
|
||||
self.assertContains(response_detail, "Créer un retour")
|
||||
from django.utils.translation import gettext as _
|
||||
self.assertContains(response_detail, _("Créer un retour"))
|
||||
|
||||
# 2. Accéder à la page de création d'entrée de stock avec la QueryParam preparation_order
|
||||
url_entry = reverse("stock:new_stock_entry") + f"?preparation_order={po.pk}"
|
||||
|
|
@ -1766,6 +1769,49 @@ class PreparationOrderAutocompleteTests(TestCase):
|
|||
self.assertEqual(data["default_location_id"], loc.id)
|
||||
|
||||
|
||||
class PreparationOrderDiscussionTests(TestCase):
|
||||
def setUp(self):
|
||||
from common.models import UserConfig
|
||||
self.user = User.objects.create_superuser(username="admin_prep_disc", email="admin@test.com", password="password")
|
||||
UserConfig.objects.get_or_create(user=self.user)
|
||||
self.client.force_login(self.user)
|
||||
self.po = PreparationOrder.objects.create(order_number="BP-DISC-001")
|
||||
|
||||
def test_get_or_create_preparation_order_discussion(self):
|
||||
from notifications.services import get_or_create_preparation_order_discussion
|
||||
discussion, created = get_or_create_preparation_order_discussion(self.po)
|
||||
self.assertTrue(created)
|
||||
self.assertIn("BP-DISC-001", discussion.title)
|
||||
|
||||
discussion2, created2 = get_or_create_preparation_order_discussion(self.po)
|
||||
self.assertFalse(created2)
|
||||
self.assertEqual(discussion.pk, discussion2.pk)
|
||||
|
||||
def test_add_message_to_preparation_order(self):
|
||||
url = reverse("stock:preparation_order_add_message", args=[self.po.pk])
|
||||
response = self.client.post(url, {"content": "Message de test sur le bon"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
detail_url = reverse("stock:preparation_order_detail", args=[self.po.pk])
|
||||
detail_resp = self.client.get(detail_url)
|
||||
self.assertEqual(detail_resp.status_code, 200)
|
||||
self.assertEqual(len(detail_resp.context["discussion_messages"]), 1)
|
||||
self.assertEqual(detail_resp.context["discussion_messages"][0].content, "Message de test sur le bon")
|
||||
|
||||
def test_mobile_preparation_order_discussion_messages(self):
|
||||
from notifications.services import get_or_create_preparation_order_discussion, post_message
|
||||
discussion, _ = get_or_create_preparation_order_discussion(self.po)
|
||||
post_message(discussion=discussion, author=self.user, content="Message mobile")
|
||||
|
||||
mob_url = reverse("mobile:stock_preparation_order_detail_mobile", args=[self.po.pk])
|
||||
resp = self.client.get(mob_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(len(resp.context["discussion_messages"]), 1)
|
||||
self.assertEqual(resp.context["discussion_messages"][0].content, "Message mobile")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -442,7 +442,8 @@ class WarehouseViewsPermissionsTestCase(TestCase):
|
|||
# Let's call the view
|
||||
response = new_stock_entry(request)
|
||||
# It should show error message and stay on the form page (not redirect to success)
|
||||
self.assertTrue(any("Le sous-type 'Correction' est réservé" in str(m) for m in messages))
|
||||
from django.utils.translation import gettext as _
|
||||
self.assertTrue(any(_("Le sous-type 'Correction' est réservé aux administrateurs de l'entrepôt.") in str(m) for m in messages))
|
||||
|
||||
# Scenario 2: User has can_administer=True
|
||||
access.can_administer = True
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ urlpatterns = [
|
|||
path("preparation-orders/autocomplete/", views.preparation_orders_autocomplete_api, name="preparation_orders_autocomplete_api"),
|
||||
path("preparation-orders/new-from-intervention/<int:intervention_id>/", views.new_preparation_order_from_intervention, name="new_preparation_order_from_intervention"),
|
||||
path("preparation-orders/<int:pk>/", views.preparation_order_detail, name="preparation_order_detail"),
|
||||
path("preparation-orders/<int:pk>/add-message/", views.preparation_order_add_message, name="preparation_order_add_message"),
|
||||
path("preparation-orders/<int:pk>/items/", views.preparation_order_items_api, name="preparation_order_items_api"),
|
||||
path("preparation-orders/<int:pk>/cancel/", views.preparation_order_cancel, name="preparation_order_cancel"),
|
||||
path("preparation-orders/<int:pk>/reopen/", views.preparation_order_reopen, name="preparation_order_reopen"),
|
||||
|
|
|
|||
|
|
@ -3918,6 +3918,29 @@ def preparation_order_detail(request, pk):
|
|||
exit_batches = preparation_order.stock_batches.filter(movement_type='out').order_by('date')
|
||||
return_batches = preparation_order.stock_batches.filter(movement_type='in').order_by('date')
|
||||
|
||||
# Discussion liée à ce bon de préparation
|
||||
discussion_messages = []
|
||||
can_post_discussion_message = False
|
||||
try:
|
||||
from notifications.models import Discussion
|
||||
from notifications.services import mark_discussion_seen, can_post_preparation_order_message
|
||||
from django.contrib.contenttypes.models import ContentType as CT
|
||||
_ct = CT.objects.get_for_model(preparation_order)
|
||||
discussion = Discussion.objects.filter(
|
||||
content_type=_ct, object_id=preparation_order.pk
|
||||
).first()
|
||||
if discussion:
|
||||
discussion_messages = list(
|
||||
discussion.messages.filter(is_deleted=False)
|
||||
.select_related('author')
|
||||
.prefetch_related('attachments')
|
||||
.order_by('created_at')
|
||||
)
|
||||
mark_discussion_seen(discussion, user=request.user)
|
||||
can_post_discussion_message = can_post_preparation_order_message(request.user, preparation_order)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context = {
|
||||
'preparation_order': preparation_order,
|
||||
'items_with_stock': items_with_stock,
|
||||
|
|
@ -3932,11 +3955,48 @@ 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_messages': discussion_messages,
|
||||
'can_post_discussion_message': can_post_discussion_message,
|
||||
}
|
||||
|
||||
return render(request, 'stock/preparation_order_detail.html', context)
|
||||
|
||||
|
||||
@require_POST
|
||||
def preparation_order_add_message(request, pk):
|
||||
"""
|
||||
Poste un message dans la discussion liée à ce bon de préparation.
|
||||
"""
|
||||
preparation_order = get_object_or_404(PreparationOrder, pk=pk)
|
||||
|
||||
from notifications.services import (
|
||||
can_post_preparation_order_message,
|
||||
get_or_create_preparation_order_discussion,
|
||||
post_message,
|
||||
)
|
||||
if not can_post_preparation_order_message(request.user, preparation_order):
|
||||
messages.error(request, _("Vous n'avez pas la permission de poster un message sur ce bon de préparation."))
|
||||
next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
|
||||
return redirect(next_url)
|
||||
|
||||
content = (request.POST.get('content') or '').strip()
|
||||
if not content:
|
||||
messages.warning(request, _("Le message ne peut pas être vide."))
|
||||
next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
|
||||
if '#discussion' not in next_url:
|
||||
next_url += '#discussion'
|
||||
return redirect(next_url)
|
||||
|
||||
discussion, _ = get_or_create_preparation_order_discussion(preparation_order)
|
||||
files = request.FILES.getlist('attachments') or None
|
||||
post_message(discussion=discussion, author=request.user, content=content, files=files)
|
||||
|
||||
next_url = request.META.get('HTTP_REFERER') or reverse('stock:preparation_order_detail', args=[pk])
|
||||
if '#discussion' not in next_url:
|
||||
next_url += '#discussion'
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@require_POST
|
||||
def preparation_order_cancel(request, pk):
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in a new issue