feat: integrate global document viewer modal and update note UI for interventions
This commit is contained in:
parent
2d5166e3f3
commit
2a6066381e
8 changed files with 142 additions and 26 deletions
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
{% if docs %}
|
||||
|
||||
{% if not modal_only %}
|
||||
{# ═══════════════════════════ Thumbnail Grid ═══════════════════════════ #}
|
||||
<div class="row g-2" id="{{ modal_id }}-grid">
|
||||
{% for doc in docs %}
|
||||
|
|
@ -163,8 +164,10 @@
|
|||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if not grid_only %}
|
||||
{# ═══════════════════════════ Viewer Modal ════════════════════════════ #}
|
||||
<div class="modal fade"
|
||||
id="{{ modal_id }}"
|
||||
|
|
@ -264,8 +267,13 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
{# Caption bar #}
|
||||
<div class="px-3 py-2 text-center text-white"
|
||||
style="background: rgba(0,0,0,.55);">
|
||||
<div class="px-3 py-2 text-center text-white d-flex align-items-center justify-content-center flex-wrap gap-2"
|
||||
style="background: rgba(0,0,0,.65);">
|
||||
{% if doc.phase_title %}
|
||||
<span class="badge {{ doc.phase_badge_class }}" style="font-size: .8rem;">
|
||||
<i class="bi {{ doc.phase_icon }} me-1"></i>{{ doc.phase_title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<strong>{{ doc.filename }}</strong>
|
||||
{% if show_tags and doc.tags_list %}
|
||||
{% for t in doc.tags_list %}
|
||||
|
|
@ -277,7 +285,7 @@
|
|||
{% endif %}
|
||||
{% if doc.attached_at %}
|
||||
<span class="ms-2 opacity-75" style="font-size: .85em;">
|
||||
{{ doc.attached_at|date:"d/m/Y" }}
|
||||
{{ doc.attached_at|date:"d/m/Y H:i" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if doc.attached_by %}
|
||||
|
|
@ -322,6 +330,7 @@
|
|||
data-bs-target="#{{ carousel_id }}"
|
||||
data-bs-slide-to="{{ doc.index }}"
|
||||
aria-label="{{ doc.title }}"
|
||||
title="{% if doc.phase_title %}{{ doc.phase_title }} - {% endif %}{{ doc.filename }}"
|
||||
style="border: 3px solid {% if forloop.first %}#0d6efd{% else %}transparent{% endif %};
|
||||
padding: 0; background: none; cursor: pointer;
|
||||
border-radius: 4px; transition: border-color .2s;">
|
||||
|
|
@ -355,6 +364,7 @@
|
|||
</div>{# /modal-content #}
|
||||
</div>{# /modal-dialog #}
|
||||
</div>{# /modal #}
|
||||
{% endif %}
|
||||
|
||||
{# ═══════════════════════════ Inline JS ══════════════════════════════ #}
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ def render_document_viewer(
|
|||
show_tags=False,
|
||||
show_versions=False,
|
||||
location_id=None,
|
||||
modal_only=False,
|
||||
grid_only=False,
|
||||
):
|
||||
"""Render a thumbnail grid + carousel viewer modal.
|
||||
|
||||
|
|
@ -142,6 +144,10 @@ def render_document_viewer(
|
|||
doc_tags = [tag] if tag else []
|
||||
attached_at = getattr(doc, "updated_at", None)
|
||||
attached_by = getattr(version, "uploaded_by", None) if version else None
|
||||
phase_title = item.get("phase_title") or getattr(doc, "phase_title", "")
|
||||
phase_badge_class = item.get("phase_badge_class") or getattr(doc, "phase_badge_class", "")
|
||||
phase_icon = item.get("phase_icon") or getattr(doc, "phase_icon", "")
|
||||
global_index = item.get("global_index") if item.get("global_index") is not None else getattr(doc, "global_index", None)
|
||||
elif hasattr(item, "file") and not hasattr(item, "document"):
|
||||
# ── Direct legacy Document subclass (InterventionDocument, ITSLocationDocument, …) ──
|
||||
doc = item # the item IS the file-bearing object
|
||||
|
|
@ -150,6 +156,10 @@ def render_document_viewer(
|
|||
tag = None
|
||||
attached_at = getattr(item, "upload_date", None)
|
||||
attached_by = getattr(item, "uploaded_by", None)
|
||||
phase_title = getattr(item, "phase_title", "")
|
||||
phase_badge_class = getattr(item, "phase_badge_class", "")
|
||||
phase_icon = getattr(item, "phase_icon", "")
|
||||
global_index = getattr(item, "global_index", None)
|
||||
else:
|
||||
# ── Legacy DocumentAttachment format ──
|
||||
doc = getattr(item, "document", None)
|
||||
|
|
@ -158,6 +168,10 @@ def render_document_viewer(
|
|||
tag = doc_tags[0] if doc_tags else None
|
||||
attached_at = getattr(item, "attached_at", None)
|
||||
attached_by = getattr(item, "attached_by", None)
|
||||
phase_title = getattr(item, "phase_title", "") or getattr(doc, "phase_title", "")
|
||||
phase_badge_class = getattr(item, "phase_badge_class", "") or getattr(doc, "phase_badge_class", "")
|
||||
phase_icon = getattr(item, "phase_icon", "") or getattr(doc, "phase_icon", "")
|
||||
global_index = getattr(item, "global_index", None) or getattr(doc, "global_index", None)
|
||||
|
||||
if not doc:
|
||||
continue
|
||||
|
|
@ -238,6 +252,7 @@ def render_document_viewer(
|
|||
|
||||
docs.append({
|
||||
"index": i,
|
||||
"global_index": global_index,
|
||||
"doc_id": doc_id,
|
||||
"title": getattr(doc, "title", None) or filename,
|
||||
"description": getattr(doc, "description", None) or "",
|
||||
|
|
@ -254,6 +269,10 @@ def render_document_viewer(
|
|||
"attached_at": attached_at,
|
||||
"attached_by": attached_by,
|
||||
"legacy_delete_url": legacy_delete_url,
|
||||
# ── Phase info ──
|
||||
"phase_title": phase_title,
|
||||
"phase_badge_class": phase_badge_class,
|
||||
"phase_icon": phase_icon,
|
||||
# ── Tag info ──
|
||||
"tag_name": tag.name if tag else "",
|
||||
"tag_slug": tag.slug if tag else "",
|
||||
|
|
@ -267,8 +286,11 @@ def render_document_viewer(
|
|||
"unique_params": unique_params,
|
||||
})
|
||||
|
||||
# ── Sort docs by tag display order, then tag name, then document title ──
|
||||
docs.sort(key=lambda d: (d["tag_order"], d["tag_name"].lower(), d["title"].lower()))
|
||||
# ── Sort docs: if global_index is specified, preserve that order; otherwise sort by tag/title ──
|
||||
if any(d["global_index"] is not None for d in docs):
|
||||
docs.sort(key=lambda d: d["global_index"] if d["global_index"] is not None else 9999)
|
||||
else:
|
||||
docs.sort(key=lambda d: (d["tag_order"], d["tag_name"].lower(), d["title"].lower()))
|
||||
|
||||
# Re-assign sequential indices after sorting so carousel slides stay in sync
|
||||
for i, d in enumerate(docs):
|
||||
|
|
@ -287,4 +309,6 @@ def render_document_viewer(
|
|||
"show_tags": show_tags,
|
||||
"show_versions": show_versions,
|
||||
"location_id": location_id,
|
||||
"modal_only": modal_only,
|
||||
"grid_only": grid_only,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ def get_sequenced_intervention_documents(
|
|||
'icon': 'bi-clipboard-check',
|
||||
'badge_class': 'bg-danger-subtle text-danger-emphasis border border-danger-subtle',
|
||||
'period_display': period_before,
|
||||
'modal_id': 'interventionDocViewerModal_before',
|
||||
'modal_id': 'interventionDocViewerModal',
|
||||
'documents': before_docs,
|
||||
'notes': before_notes,
|
||||
'documents_count': len(before_docs),
|
||||
|
|
@ -231,7 +231,7 @@ def get_sequenced_intervention_documents(
|
|||
'icon': 'bi-tools',
|
||||
'badge_class': 'bg-primary-subtle text-primary-emphasis border border-primary-subtle',
|
||||
'period_display': period_during,
|
||||
'modal_id': 'interventionDocViewerModal_during',
|
||||
'modal_id': 'interventionDocViewerModal',
|
||||
'documents': during_docs,
|
||||
'notes': during_notes,
|
||||
'documents_count': len(during_docs),
|
||||
|
|
@ -246,7 +246,7 @@ def get_sequenced_intervention_documents(
|
|||
'icon': 'bi-shield-check',
|
||||
'badge_class': 'bg-success-subtle text-success-emphasis border border-success-subtle',
|
||||
'period_display': period_after,
|
||||
'modal_id': 'interventionDocViewerModal_after',
|
||||
'modal_id': 'interventionDocViewerModal',
|
||||
'documents': after_docs,
|
||||
'notes': after_notes,
|
||||
'documents_count': len(after_docs),
|
||||
|
|
@ -256,12 +256,27 @@ def get_sequenced_intervention_documents(
|
|||
},
|
||||
]
|
||||
|
||||
# Attribution de l'index global et des métadonnées de phase à chaque document
|
||||
all_documents = []
|
||||
global_idx = 0
|
||||
for block in blocks:
|
||||
for doc in block['documents']:
|
||||
doc.global_index = global_idx
|
||||
doc.phase_key = block['key']
|
||||
doc.phase_title = block['title']
|
||||
doc.phase_badge_class = block['badge_class']
|
||||
doc.phase_icon = block['icon']
|
||||
doc.phase_period = block['period_display']
|
||||
all_documents.append(doc)
|
||||
global_idx += 1
|
||||
|
||||
total_docs = len(docs)
|
||||
total_notes = len(notes_list)
|
||||
total_items = total_docs + total_notes
|
||||
|
||||
return {
|
||||
'blocks': blocks,
|
||||
'all_documents': all_documents,
|
||||
'total_documents_count': total_docs,
|
||||
'total_notes_count': total_notes,
|
||||
'total_count': total_docs, # compatibilité ascendante pour les vérifications de documents
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
<!-- Documents du bloc -->
|
||||
{% if block.documents %}
|
||||
<div class="mb-3">
|
||||
{% render_document_viewer block.documents modal_id=block.modal_id allow_delete=can_delete_documents delete_always_visible=can_delete_documents allow_copy=can_copy_documents %}
|
||||
{% render_document_viewer block.documents modal_id="interventionDocViewerModal" grid_only=True allow_delete=can_delete_documents delete_always_visible=can_delete_documents allow_copy=can_copy_documents %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
@ -119,14 +119,17 @@
|
|||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-0 note-text">{{ note.content|safe|linebreaksbr }}</p>
|
||||
<p class="mb-0">{{ note.content|linebreaksbr }}</p>
|
||||
</div>
|
||||
{% if note.id in editable_note_ids and can_edit_notes %}
|
||||
<div class="note-actions ms-auto">
|
||||
<button type="button" class="btn btn-link text-danger p-0 delete-note-btn" data-note-id="{{ note.id }}" aria-label="{% translate 'Supprimer la note' %}" style="display: none;">
|
||||
<i class="bi bi-trash fs-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="note-actions cms-note-actions" style="display: none;">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary btn-edit-note me-1" title="{% translate 'Modifier la note' %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-note" title="{% translate 'Supprimer la note' %}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -158,6 +161,10 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if all_sequenced_documents %}
|
||||
{% render_document_viewer all_sequenced_documents modal_id="interventionDocViewerModal" modal_only=True allow_delete=can_delete_documents delete_always_visible=can_delete_documents allow_copy=can_copy_documents %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
{% load short_name %}
|
||||
{% load interventions_tags %}
|
||||
{% load mention_tags %}
|
||||
{% load document_viewer_tags %}
|
||||
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
|
||||
|
|
@ -615,7 +616,13 @@
|
|||
</button>
|
||||
{% endif %}
|
||||
{% private_file_url document 'file' as document_file_url %}
|
||||
<a href="{{ document_file_url }}" target="_blank">
|
||||
<a href="#"
|
||||
class="d-block text-decoration-none doc-viewer-thumb"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#interventionDocViewerModal"
|
||||
data-viewer-modal="interventionDocViewerModal"
|
||||
data-doc-index="{{ document.global_index }}"
|
||||
title="{{ document.filename|default:'' }}">
|
||||
<div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
|
||||
{% if document.thumbnail %}
|
||||
{% private_file_url document 'thumbnail' as document_thumbnail_url %}
|
||||
|
|
@ -685,6 +692,10 @@
|
|||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if all_sequenced_documents %}
|
||||
{% render_document_viewer all_sequenced_documents modal_id="interventionDocViewerModal" modal_only=True allow_delete=False %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -348,3 +348,46 @@ class DocumentSequencingViewsTests(TestCase):
|
|||
self.assertIn('Execution of the intervention', titles_en)
|
||||
self.assertIn('After realization', titles_en)
|
||||
|
||||
def test_all_documents_with_phase_metadata(self):
|
||||
result = get_sequenced_intervention_documents(self.intervention)
|
||||
self.assertIn('all_documents', result)
|
||||
all_docs = result['all_documents']
|
||||
self.assertEqual(len(all_docs), 2)
|
||||
|
||||
# Vérifier index global et métadonnées de phase
|
||||
self.assertEqual(all_docs[0].global_index, 0)
|
||||
self.assertEqual(all_docs[0].phase_key, 'before_work')
|
||||
self.assertTrue(all_docs[0].phase_title)
|
||||
self.assertTrue(all_docs[0].phase_badge_class)
|
||||
self.assertTrue(all_docs[0].phase_icon)
|
||||
|
||||
self.assertEqual(all_docs[1].global_index, 1)
|
||||
self.assertEqual(all_docs[1].phase_key, 'during_work')
|
||||
self.assertTrue(all_docs[1].phase_title)
|
||||
self.assertTrue(all_docs[1].phase_badge_class)
|
||||
self.assertTrue(all_docs[1].phase_icon)
|
||||
|
||||
def test_desktop_detail_view_renders_global_carousel_modal(self):
|
||||
url = reverse('interventions:intervention_detail', args=[self.intervention.id])
|
||||
response = self.client.get(url + '?force_desktop=1')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('all_sequenced_documents', response.context)
|
||||
|
||||
content = response.content.decode('utf-8')
|
||||
# Vérifie la présence de la modale carousel globale
|
||||
self.assertIn('id="interventionDocViewerModal"', content)
|
||||
self.assertIn('id="interventionDocViewerModalCarousel"', content)
|
||||
self.assertIn('data-viewer-modal="interventionDocViewerModal"', content)
|
||||
|
||||
def test_mobile_detail_view_renders_global_carousel_modal(self):
|
||||
url = reverse('interventions:intervention_detail_mobile', args=[self.intervention.id])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('all_sequenced_documents', response.context)
|
||||
|
||||
content = response.content.decode('utf-8')
|
||||
# Vérifie la présence de la modale carousel globale et les data-attributes des vignettes mobiles
|
||||
self.assertIn('id="interventionDocViewerModal"', content)
|
||||
self.assertIn('id="interventionDocViewerModalCarousel"', content)
|
||||
self.assertIn('data-viewer-modal="interventionDocViewerModal"', content)
|
||||
|
||||
|
|
|
|||
|
|
@ -624,6 +624,8 @@ def intervention_detail(request, intervention_id):
|
|||
import json as _json_mod
|
||||
copy_targets_json = _json_mod.dumps(_copy_targets)
|
||||
|
||||
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
|
||||
|
||||
context = {
|
||||
"intervention": intervention,
|
||||
"status_choices" : [{"key": key, "label": label} for key, label in STATUS_CHOICES.items()],
|
||||
|
|
@ -743,11 +745,12 @@ def intervention_detail(request, intervention_id):
|
|||
'discussion_messages': discussion_messages,
|
||||
'can_post_discussion_message': can_post_discussion_message,
|
||||
# Sequenced notes & documents
|
||||
'sequenced_blocks': get_sequenced_intervention_documents(intervention, notes_qs=notes)['blocks'],
|
||||
'document_blocks': get_sequenced_intervention_documents(intervention, notes_qs=notes)['blocks'],
|
||||
'total_documents_count': intervention.documents.count(),
|
||||
'total_notes_count': notes.count() if hasattr(notes, 'count') else len(notes),
|
||||
'total_items_count': intervention.documents.count() + (notes.count() if hasattr(notes, 'count') else len(notes)),
|
||||
'sequenced_blocks': sequenced_data['blocks'],
|
||||
'document_blocks': sequenced_data['blocks'],
|
||||
'all_sequenced_documents': sequenced_data['all_documents'],
|
||||
'total_documents_count': sequenced_data['total_documents_count'],
|
||||
'total_notes_count': sequenced_data['total_notes_count'],
|
||||
'total_items_count': sequenced_data['total_items_count'],
|
||||
'can_delete_documents': 'documents' in allowed_update_fields,
|
||||
'can_copy_documents': 'documents' in allowed_update_fields and copy_targets_json != '[]',
|
||||
# Guided operation template
|
||||
|
|
|
|||
|
|
@ -1073,13 +1073,16 @@ def intervention_detail_mobile(request, intervention_id):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
sequenced_data = get_sequenced_intervention_documents(intervention, notes_qs=notes)
|
||||
|
||||
context = {
|
||||
'intervention': intervention,
|
||||
'sequenced_blocks': get_sequenced_intervention_documents(intervention, notes_qs=notes)['blocks'],
|
||||
'document_blocks': get_sequenced_intervention_documents(intervention, notes_qs=notes)['blocks'],
|
||||
'total_documents_count': intervention.documents.count(),
|
||||
'total_notes_count': notes.count() if hasattr(notes, 'count') else len(notes),
|
||||
'total_items_count': intervention.documents.count() + (notes.count() if hasattr(notes, 'count') else len(notes)),
|
||||
'sequenced_blocks': sequenced_data['blocks'],
|
||||
'document_blocks': sequenced_data['blocks'],
|
||||
'all_sequenced_documents': sequenced_data['all_documents'],
|
||||
'total_documents_count': sequenced_data['total_documents_count'],
|
||||
'total_notes_count': sequenced_data['total_notes_count'],
|
||||
'total_items_count': sequenced_data['total_items_count'],
|
||||
'assets': assets,
|
||||
'notes': notes,
|
||||
'can_edit_intervention': can_edit_mobile,
|
||||
|
|
|
|||
Loading…
Reference in a new issue