refactor: consolidate notes and documents into a shared mobile interface component

This commit is contained in:
kdeterme 2026-08-31 13:56:55 +02:00
parent 022217218f
commit fd2c696780
14 changed files with 3279 additions and 3233 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -72,16 +72,17 @@ def get_intervention_milestones(intervention) -> tuple[Optional[datetime], Optio
def get_sequenced_intervention_documents(
intervention,
notes_qs=None,
tolerance_minutes: int = DEFAULT_TOLERANCE_MINUTES,
) -> Dict[str, Any]:
"""
Organise les documents et photos de l'intervention en 3 blocs temporels :
1. before_work : Avant réalisation (préparation, constat, signalement)
2. during_work : Exécution de l'intervention (pendant les travaux)
3. after_work : Après réalisation (contrôle, levée de réserves, clôture)
Organise les documents, photos et notes de l'intervention en 3 blocs temporels :
1. before_work : Avant réalisation (préparation, constat, signalement, devis)
2. during_work : Exécution de l'intervention (travaux en cours, photos d'avancement, notes de chantier)
3. after_work : Après réalisation (contrôle, levée de réserves, PV de réception, clôture)
Une tolérance temporelle (par défaut 15 minutes) est appliquée autour des jalons
pour tenir compte des photos ajoutées juste avant de changer le statut ou juste après
pour tenir compte des photos et notes ajoutées juste avant de changer le statut ou juste après
avoir terminé.
Retourne un dictionnaire :
@ -96,12 +97,21 @@ def get_sequenced_intervention_documents(
'period_display': '...',
'modal_id': '...',
'documents': [...],
'notes': [...],
'documents_count': int,
'notes_count': int,
'count': int,
'has_items': bool,
},
...
],
'total_documents_count': int,
'total_notes_count': int,
'total_count': int,
'total_items_count': int,
'has_documents': bool,
'has_notes': bool,
'has_items': bool,
'start_dt': datetime|None,
'end_dt': datetime|None,
}
@ -112,6 +122,7 @@ def get_sequenced_intervention_documents(
start_threshold = (start_dt - timedelta(minutes=tolerance_minutes)) if start_dt else None
end_threshold = (end_dt + timedelta(minutes=tolerance_minutes)) if end_dt else None
# Récupération et partitionnement des documents
docs = list(
intervention.documents
.all()
@ -142,6 +153,39 @@ def get_sequenced_intervention_documents(
else:
after_docs.append(doc)
# Récupération et partitionnement des notes
if notes_qs is None:
notes_list = list(
intervention.notes
.all()
.select_related('note_author', 'intervention', 'intervention__thematic')
.order_by('note_time', 'id')
)
else:
notes_list = list(notes_qs)
before_notes: List[Any] = []
during_notes: List[Any] = []
after_notes: List[Any] = []
for note in notes_list:
note_dt = getattr(note, 'note_time', None) or getattr(note, 'updated_at', None) or getattr(intervention, 'creation_time', None)
if not start_threshold:
before_notes.append(note)
elif not note_dt:
before_notes.append(note)
elif note_dt < start_threshold:
before_notes.append(note)
else:
# note_dt >= start_threshold
if not end_threshold:
during_notes.append(note)
elif note_dt <= end_threshold:
during_notes.append(note)
else:
after_notes.append(note)
# Libellés temporels formatés
def _fmt(dt: datetime) -> str:
loc_dt = timezone.localtime(dt) if timezone.is_aware(dt) else dt
@ -174,7 +218,11 @@ def get_sequenced_intervention_documents(
'period_display': period_before,
'modal_id': 'interventionDocViewerModal_before',
'documents': before_docs,
'count': len(before_docs),
'notes': before_notes,
'documents_count': len(before_docs),
'notes_count': len(before_notes),
'count': len(before_docs) + len(before_notes),
'has_items': (len(before_docs) + len(before_notes)) > 0,
},
{
'key': 'during_work',
@ -185,7 +233,11 @@ def get_sequenced_intervention_documents(
'period_display': period_during,
'modal_id': 'interventionDocViewerModal_during',
'documents': during_docs,
'count': len(during_docs),
'notes': during_notes,
'documents_count': len(during_docs),
'notes_count': len(during_notes),
'count': len(during_docs) + len(during_notes),
'has_items': (len(during_docs) + len(during_notes)) > 0,
},
{
'key': 'after_work',
@ -196,16 +248,27 @@ def get_sequenced_intervention_documents(
'period_display': period_after,
'modal_id': 'interventionDocViewerModal_after',
'documents': after_docs,
'count': len(after_docs),
'notes': after_notes,
'documents_count': len(after_docs),
'notes_count': len(after_notes),
'count': len(after_docs) + len(after_notes),
'has_items': (len(after_docs) + len(after_notes)) > 0,
},
]
total_count = len(docs)
total_docs = len(docs)
total_notes = len(notes_list)
total_items = total_docs + total_notes
return {
'blocks': blocks,
'total_count': total_count,
'has_documents': total_count > 0,
'total_documents_count': total_docs,
'total_notes_count': total_notes,
'total_count': total_docs, # compatibilité ascendante pour les vérifications de documents
'total_items_count': total_items,
'has_documents': total_docs > 0,
'has_notes': total_notes > 0,
'has_items': total_items > 0,
'start_dt': start_dt,
'end_dt': end_dt,
}

View file

@ -0,0 +1,516 @@
{% load document_viewer_tags i18n interventions_tags short_name tz %}
<div id="section-notes" class="card mb-4 mt-4" data-intervention-id="{{ intervention.id }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0 d-flex align-items-center flex-wrap gap-2">
<i class="bi bi-journal-text me-1 text-primary"></i>{% translate "Notes et documents" %}
{% if total_items_count %}
<span class="badge bg-secondary" style="font-size:0.75rem;">
{{ total_documents_count }} {% if total_documents_count > 1 %}{% translate "docs" %}{% else %}{% translate "doc" %}{% endif %} &bull; {{ total_notes_count }} {% if total_notes_count > 1 %}{% translate "notes" %}{% else %}{% translate "note" %}{% endif %}
</span>
{% endif %}
{% if photos_required %}
<span class="badge bg-warning text-dark ms-2" style="font-size:0.75rem;">
<i class="bi bi-camera-fill me-1"></i>{% translate "Photos obligatoires" %}
</span>
{% endif %}
</h4>
<div class="d-flex align-items-center gap-1">
{% if can_edit_notes %}
<button class="btn btn-light border-0 cms-add-note-btn" title="{% translate 'Ajouter une note' %}">
<i class="bi bi-sticky text-primary me-1"></i><span class="d-none d-lg-inline small">{% translate "Note" %}</span>
</button>
{% endif %}
{% if 'documents' in allowed_update_fields %}
<button class="btn btn-light border-0" title="{% translate 'Ajouter des documents' %}" data-bs-toggle="modal" data-bs-target="#addDocumentModal">
<i class="bi bi-file-earmark-plus text-primary me-1"></i><span class="d-none d-lg-inline small">{% translate "Doc" %}</span>
</button>
{% endif %}
</div>
</div>
<div class="card-body" id="notesContainer">
<div id="notesAlertContainer" class="m-1"></div>
<div id="documentsAlertContainer" class="m-1"></div>
<!-- New note form (hidden by default, shown in CMS edit mode when clicking +) -->
{% if can_edit_notes %}
<div class="new-note-editor mb-3 p-3 border rounded" style="display: none;" data-is-new="true">
<div class="mb-2">
<label class="form-label small fw-bold">{% translate "Type de note" %}</label>
<select class="form-select form-select-sm new-note-type">
{% for value, label in add_note_type_choices %}
<option value="{{ value }}"{% if value == default_note_type %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="mb-2">
<label class="form-label small fw-bold">{% translate "Contenu" %}</label>
<textarea class="form-control new-note-content" rows="3" placeholder="{% translate 'Saisir le contenu de la note...' %}"></textarea>
</div>
</div>
{% endif %}
<div id="documentsContainer">
{% if total_items_count == 0 %}
<div class="text-center text-muted py-4">
<i class="bi bi-journal-text fs-2 d-block mb-2 text-secondary opacity-50"></i>
<p class="mb-0">{% translate "Aucun document ou note pour cette intervention." %}</p>
</div>
{% else %}
{% for block in sequenced_blocks %}
{% if block.has_items %}
<div class="document-block mb-4 p-3 rounded bg-light border">
<!-- En-tête du bloc -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="badge {{ block.badge_class }} py-1 px-2" style="font-size: 0.85rem;">
<i class="bi {{ block.icon }} me-1"></i>{{ block.title }}
</span>
<span class="text-secondary small fw-medium">
{% if block.documents_count > 0 %}{{ block.documents_count }} {% if block.documents_count > 1 %}{% translate "fichiers" %}{% else %}{% translate "fichier" %}{% endif %}{% endif %}
{% if block.documents_count > 0 and block.notes_count > 0 %}&bull;{% endif %}
{% if block.notes_count > 0 %}{{ block.notes_count }} {% if block.notes_count > 1 %}{% translate "notes" %}{% else %}{% translate "note" %}{% endif %}{% endif %}
</span>
</div>
{% if block.period_display %}
<span class="text-muted small">
<i class="bi bi-clock me-1"></i>{{ block.period_display }}
</span>
{% endif %}
</div>
<!-- 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 %}
</div>
{% endif %}
<!-- Notes du bloc -->
{% if block.notes %}
<div class="notes-list {% if block.documents %}border-top pt-3{% endif %}">
{% for note in block.notes %}
<div id="note-container-{{ note.id }}"
class="note-item py-2 {% if not forloop.last %}border-bottom{% endif %} {% if note.id in editable_note_ids and can_edit_notes %}editable-note{% endif %}"
data-note-id="{{ note.id }}"
data-note-content="{{ note.content|urlencode }}"
data-note-author-id="{{ note.note_author_id }}"
data-note-author-display="{{ note.note_author|short_name|escapejs }}"
data-note-type="{{ note.note_type }}"
data-note-priority="{{ note.type_priority_order|default:note.type_priority_value }}"
data-note-time="{{ note.note_time|date:'c' }}"
data-note-type-display="{{ note.get_note_type_display }}"
data-note-badge-class="{{ note.type_badge_class }}"
data-note-icon-class="{{ note.type_icon_class }}">
<!-- Display mode -->
<div class="note-display">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="note-content flex-grow-1">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="badge note-type-badge d-inline-flex align-items-center gap-1 {{ note.type_badge_class }}">
<i class="bi {{ note.type_icon_class }}"></i>
{{ note.get_note_type_display }}
</span>
<span class="note-metadata text-muted small">
{{ note.note_time|timezone:"Europe/Paris"|date:"d-m-Y H:i" }} - {{ note.note_author|short_name }}
{% if note.updated_at %}
({% translate "Mis à jour le" %} {{ note.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}
{% translate "par " %}{{ note.updated_by|short_name }})
{% endif %}
</span>
</div>
<p class="mb-0 note-text">{{ note.content|safe|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>
{% endif %}
</div>
</div>
<!-- Edit mode (shown only in CMS edit mode for editable notes) -->
{% if note.id in editable_note_ids and can_edit_notes %}
<div class="note-edit" style="display: none;">
<div class="mb-2">
<label class="form-label small fw-bold">{% translate "Type de note" %}</label>
<select class="form-select form-select-sm edit-note-type">
{% for value, label in note_type_choices %}
{% if value != 'origin' or note.note_type == 'origin' %}
<option value="{{ value }}" {% if value == note.note_type %}selected{% endif %}>{{ label }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="mb-2">
<label class="form-label small fw-bold">{% translate "Contenu" %}</label>
<textarea class="form-control edit-note-content" rows="3">{{ note.content }}</textarea>
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% if 'documents' in allowed_update_fields %}
<div class="modal fade" id="deleteDocumentModal" tabindex="-1" aria-labelledby="deleteDocumentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteDocumentModalLabel">{% translate "Supprimer le document" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-1">{% translate "Voulez-vous supprimer ce document ?" %}</p>
<p class="fw-semibold mb-0" id="documentNameToDelete"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-danger" id="confirmDeleteDocument">{% translate "Supprimer" %}</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="addDocumentModal" tabindex="-1" aria-labelledby="addDocumentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% translate "Ajouter des documents" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addDocumentForm" method="post" enctype="multipart/form-data" action="{% url 'interventions:intervention_add_document' intervention.id %}">
{% csrf_token %}
{{ add_document_form.as_p }}
<div class="modal-footer">
<button type="submit" class="btn btn-primary" id="documentSubmitButton">
{% translate "Ajouter" %}
<span class="spinner-border spinner-border-sm ms-2 d-none" role="status" aria-hidden="true" id="documentSubmitSpinner"></span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{# ── Copy-document modal ── #}
<div class="modal fade" id="copyDocumentModal" tabindex="-1" aria-labelledby="copyDocumentModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="copyDocumentModalLabel">
<i class="bi bi-copy me-2"></i>{% translate "Copier le document vers…" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-3" id="copyDocModalDocName"></p>
<div class="mb-3">
<label for="copyDocTargetSelect" class="form-label fw-medium">{% translate "Cible" %}</label>
<select class="form-select" id="copyDocTargetSelect">
<option value="">{% translate "— Choisir une localisation ou un asset —" %}</option>
</select>
</div>
<div id="copyDocTagsSection" class="d-none mb-3">
<label class="form-label fw-medium">{% translate "Tags" %}</label>
<div id="copyDocTagsList" class="d-flex flex-wrap gap-2"></div>
<p class="text-muted small mt-2 mb-0 d-none" id="copyDocNoTagsMsg">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun tag disponible pour ce type de cible." %}
</p>
</div>
<div id="copyDocTagsLoading" class="d-none text-muted small">
<span class="spinner-border spinner-border-sm me-1" role="status"></span>{% translate "Chargement des tags…" %}
</div>
<div id="copyDocModalAlert" class="alert d-none mt-3 mb-0" role="alert"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" class="btn btn-primary" id="copyDocSubmitBtn" disabled>
<i class="bi bi-copy me-1"></i>{% translate "Copier" %}
</button>
</div>
</div>
</div>
</div>
{# Copy-targets JSON for JS #}
<script id="copyDocTargetsData" type="application/json">{{ copy_targets_json|safe }}</script>
<script>
(function () {
'use strict';
// Detect active language from <html lang="…">
var _lang = (document.documentElement.lang || 'fr').split('-')[0].toLowerCase();
function _pick(obj, frKey, nlKey) {
if (_lang === 'nl' && obj[nlKey]) return obj[nlKey];
return obj[frKey] || '';
}
var copyTargets = [];
try {
var _el = document.getElementById('copyDocTargetsData');
if (_el) copyTargets = JSON.parse(_el.textContent || '[]');
} catch (_) {}
var copyDocModal = document.getElementById('copyDocumentModal');
var targetSelect = document.getElementById('copyDocTargetSelect');
var tagsSection = document.getElementById('copyDocTagsSection');
var tagsLoading = document.getElementById('copyDocTagsLoading');
var tagsList = document.getElementById('copyDocTagsList');
var noTagsMsg = document.getElementById('copyDocNoTagsMsg');
var submitBtn = document.getElementById('copyDocSubmitBtn');
var docNameEl = document.getElementById('copyDocModalDocName');
var alertEl = document.getElementById('copyDocModalAlert');
if (!copyDocModal) return;
// Populate target dropdown
copyTargets.forEach(function (t) {
var opt = document.createElement('option');
opt.value = JSON.stringify({ct_id: t.ct_id, obj_id: t.obj_id});
opt.textContent = t.label;
targetSelect.appendChild(opt);
});
// URL templates
var copyTagsBaseUrl = "{% url 'interventions:intervention_get_copy_target_tags' intervention.id %}";
var copyDocBaseUrl = "{% url 'interventions:intervention_copy_document' intervention.id 0 %}";
var currentDocId = null;
// Wire copy buttons
document.addEventListener('click', function (e) {
var btn = e.target.closest('.copy-document-btn');
if (!btn) return;
currentDocId = btn.dataset.documentId;
if (docNameEl) docNameEl.textContent = btn.dataset.documentName || '';
});
// Reset modal on close
copyDocModal.addEventListener('hidden.bs.modal', function () {
currentDocId = null;
targetSelect.value = '';
tagsList.innerHTML = '';
tagsSection.classList.add('d-none');
noTagsMsg.classList.add('d-none');
tagsLoading.classList.add('d-none');
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="bi bi-copy me-1"></i>' + gettext('Copier');
alertEl.className = 'alert d-none mt-3 mb-0';
alertEl.textContent = '';
});
// Build tag rows with optional extra-param fields
function _buildTagRow(tag) {
var wrapper = document.createElement('div');
wrapper.className = 'border rounded p-2 mb-1';
// Checkbox + label row
var cbLabel = document.createElement('label');
cbLabel.className = 'form-check d-flex align-items-center mb-0';
cbLabel.style.cursor = 'pointer';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'form-check-input copy-doc-tag-cb me-2';
cb.value = tag.id;
cb.dataset.tagId = String(tag.id);
var span = document.createElement('span');
span.className = 'form-check-label';
span.textContent = tag.name;
cbLabel.appendChild(cb);
cbLabel.appendChild(span);
wrapper.appendChild(cbLabel);
// Extra params section (hidden until checked)
var schema = tag.extra_params_schema || [];
if (schema.length > 0) {
var extraDiv = document.createElement('div');
extraDiv.className = 'mt-2 ps-3 d-none copy-doc-extra-params';
extraDiv.dataset.tagId = String(tag.id);
schema.forEach(function (param) {
var fieldDiv = document.createElement('div');
fieldDiv.className = 'mb-2';
var lbl = document.createElement('label');
lbl.className = 'form-label small mb-1';
lbl.textContent = _pick(param, 'label_fr', 'label_nl') || param.key;
var inputId = 'ep_field_' + tag.id + '_' + param.key;
lbl.htmlFor = inputId;
if (param.required) {
var req = document.createElement('span');
req.className = 'text-danger ms-1';
req.textContent = '*';
lbl.appendChild(req);
}
var input;
if (param.type === 'select' && Array.isArray(param.options)) {
input = document.createElement('select');
input.className = 'form-select form-select-sm';
if (!param.required) {
var emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = '—';
input.appendChild(emptyOpt);
}
param.options.forEach(function (opt) {
var o = document.createElement('option');
o.value = opt.value;
o.textContent = _pick(opt, 'label_fr', 'label_nl') || opt.value;
input.appendChild(o);
});
} else if (param.type === 'textarea') {
input = document.createElement('textarea');
input.className = 'form-control form-control-sm';
input.rows = 2;
} else {
input = document.createElement('input');
input.type = param.type === 'number' ? 'number' : 'text';
input.className = 'form-control form-control-sm';
}
input.id = inputId;
// POST key: ep_{tag_id}_{param_key}
input.name = 'ep_' + tag.id + '_' + param.key;
if (param.required) input.required = true;
var ph = _pick(param, 'placeholder_fr', 'placeholder_nl');
if (ph) input.placeholder = ph;
fieldDiv.appendChild(lbl);
fieldDiv.appendChild(input);
extraDiv.appendChild(fieldDiv);
});
wrapper.appendChild(extraDiv);
cb.addEventListener('change', function () {
extraDiv.classList.toggle('d-none', !this.checked);
});
}
return wrapper;
}
// Load tags when a target is selected
targetSelect.addEventListener('change', async function () {
tagsList.innerHTML = '';
tagsSection.classList.add('d-none');
noTagsMsg.classList.add('d-none');
submitBtn.disabled = true;
alertEl.className = 'alert d-none mt-3 mb-0';
if (!this.value) return;
var target = JSON.parse(this.value);
tagsLoading.classList.remove('d-none');
try {
var url = copyTagsBaseUrl + '?ct_id=' + encodeURIComponent(target.ct_id) + '&obj_id=' + encodeURIComponent(target.obj_id);
var resp = await fetch(url, {headers: {'X-Requested-With': 'XMLHttpRequest'}});
var data = await resp.json();
var tags = data.tags || [];
tagsLoading.classList.add('d-none');
tagsSection.classList.remove('d-none');
if (tags.length === 0) {
noTagsMsg.classList.remove('d-none');
} else {
noTagsMsg.classList.add('d-none');
// Change flex-wrap to flex-column to accommodate extra param blocks
tagsList.className = 'd-flex flex-column';
tags.forEach(function (tag) {
tagsList.appendChild(_buildTagRow(tag));
});
}
submitBtn.disabled = false;
} catch (err) {
tagsLoading.classList.add('d-none');
tagsSection.classList.add('d-none');
submitBtn.disabled = false;
}
});
// Submit
submitBtn.addEventListener('click', async function () {
if (!currentDocId || !targetSelect.value) return;
var target = JSON.parse(targetSelect.value);
var tagIds = Array.from(
tagsList.querySelectorAll('.copy-doc-tag-cb:checked')
).map(function (cb) { return cb.value; });
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>' + gettext('Copie…');
var formData = new FormData();
formData.append('ct_id', target.ct_id);
formData.append('obj_id', target.obj_id);
tagIds.forEach(function (id) { formData.append('tag_ids', id); });
// Collect extra params for checked tags
tagsList.querySelectorAll('.copy-doc-extra-params').forEach(function (extraDiv) {
var tId = extraDiv.dataset.tagId;
var cb = tagsList.querySelector('.copy-doc-tag-cb[data-tag-id="' + tId + '"]');
if (!cb || !cb.checked) return;
extraDiv.querySelectorAll('input[name], select[name], textarea[name]').forEach(function (inp) {
if (inp.value !== '') formData.append(inp.name, inp.value);
});
});
var copyUrl = copyDocBaseUrl.replace('/0/', '/' + currentDocId + '/');
try {
var resp = await fetch(copyUrl, {
method: 'POST',
headers: {'X-CSRFToken': _getCsrfToken()},
body: formData,
});
var data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Erreur');
var bsModal = bootstrap.Modal.getInstance(copyDocModal);
if (bsModal) bsModal.hide();
var container = document.getElementById('documentsAlertContainer');
if (container) {
container.innerHTML = '<div class="alert alert-success alert-dismissible" role="alert">' +
gettext('Document copié avec succès') +
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
setTimeout(function () { container.innerHTML = ''; }, 4000);
}
} catch (err) {
alertEl.className = 'alert alert-danger mt-3 mb-0';
alertEl.textContent = err.message;
alertEl.classList.remove('d-none');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-copy me-1"></i>' + gettext('Copier');
}
});
function _getCsrfToken() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
})();
</script>
{% endif %}

View file

@ -608,8 +608,7 @@ function toggleInterventionChecked(interventionId, checkboxEl) {
// =====================================================================
const SECTION_NAV_ITEMS = [
{ id: 'section-recommandations', icon: 'bi-exclamation-circle-fill', label: "{% translate 'Recommandations' %}" },
{ id: 'section-notes', icon: 'bi-sticky', label: "{% translate 'Notes' %}" },
{ id: 'section-documents', icon: 'bi-file-earmark', label: "{% translate 'Documents' %}" },
{ id: 'section-notes', icon: 'bi-journal-text', label: "{% translate 'Notes et documents' %}" },
{ id: 'operationsCard', icon: 'bi-list-task', label: "{% translate 'Opérations' %}" },
{ id: 'section-stock', icon: 'bi-box-seam', label: "{% translate 'Stock' %}" },
{ id: 'section-controles', icon: 'bi-clipboard2-check', label: "{% translate 'Contrôles / Rapports' %}" },

View file

@ -33,8 +33,7 @@
<div id="col-left" class="col-12 {% if not hide_map_by_default %}col-md-8{% endif %}" style="max-height: calc(100vh - 140px); overflow: auto;">
{% include "interventions/detail/card_info.html" %}
{% include "interventions/detail/card_notes.html" %}
{% include "interventions/detail/card_documents.html" %}
{% include "interventions/detail/card_notes_documents.html" %}
{% include "interventions/detail/card_operations.html" %}
{% include "interventions/detail/card_stock.html" %}
{% include "interventions/detail/card_controles.html" %}

View file

@ -552,135 +552,140 @@
{% if back_history %}<input type="hidden" name="back" value="{{ back_history }}">{% endif %}
{% endif %}
<!-- Notes -->
<!-- Notes et documents -->
<div class="card mt-4 mb-4 bg-white">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">{% translate "Notes" %}</span>
</div>
<div class="card-body">
<div id="notesContainer">
<div id="notesAlertContainer" class="m-1"></div>
{% if notes %}
{% for note in notes %}
<div id="note-container-{{ note.id }}"
class="note-item py-3 {% if not forloop.last %}border-bottom border-primary{% endif %}"
data-note-id="{{ note.id }}"
data-note-content="{{ note.content|urlencode }}"
data-note-author-id="{{ note.note_author_id }}"
data-note-author-display="{{ note.note_author|short_name|escapejs }}"
data-note-type="{{ note.note_type }}"
data-note-priority="{{ note.type_priority_order|default:note.type_priority_value }}"
data-note-time="{{ note.note_time|date:'c' }}"
data-note-type-display="{{ note.get_note_type_display }}"
data-note-badge-class="{{ note.type_badge_class }}"
data-note-icon-class="{{ note.type_icon_class }}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="note-content flex-grow-1">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="badge note-type-badge d-inline-flex align-items-center gap-1 {{ note.type_badge_class }}">
<i class="bi {{ note.type_icon_class }}"></i>
{{ note.get_note_type_display }}
</span>
<span class="text-muted small">
{{ note.note_time|timezone:"Europe/Paris"|date:"d-m-Y H:i" }} - {{ note.note_author|short_name }}
</span>
</div>
<p class="mb-0">{{ note.content|safe|linebreaksbr }}</p>
</div>
{% if note.id in editable_note_ids and can_edit_notes %}
<div class="note-actions d-flex flex-column align-items-center gap-2 ms-auto">
<button type="button" class="btn btn-link text-primary p-0 edit-note-btn"
data-note-id="{{ note.id }}" aria-label="{% translate "Modifier la note" %}">
<i class="bi bi-pencil-square fs-6"></i>
</button>
<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" %}">
<i class="bi bi-trash fs-6"></i>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted mb-0 small">{% translate "Aucune note pour le moment." %}</p>
<span class="fw-semibold d-flex align-items-center gap-1">
<i class="bi bi-journal-text me-1 text-primary"></i>{% translate "Notes et documents" %}
</span>
<div class="d-flex align-items-center gap-1">
{% if total_items_count %}
<span class="badge bg-secondary rounded-pill" style="font-size:0.75rem;">
{{ total_documents_count }} {% if total_documents_count > 1 %}{% translate "docs" %}{% else %}{% translate "doc" %}{% endif %} &bull; {{ total_notes_count }} {% if total_notes_count > 1 %}{% translate "notes" %}{% else %}{% translate "note" %}{% endif %}
</span>
{% endif %}
</div>
</div>
</div>
<!-- Documents & Photos -->
<div class="card mb-4 bg-white">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<i class="bi bi-images me-1 text-primary"></i>{% translate "Documents & Photos" %}
</span>
{% if total_documents_count %}
<span class="badge bg-secondary rounded-pill">{{ total_documents_count }}</span>
{% endif %}
</div>
<div class="card-body">
<div id="notesAlertContainer" class="mb-2"></div>
{% if 'documents' in allowed_update_fields %}
<div id="documentsAlertContainer" class="mb-2"></div>
{% endif %}
<div id="documentsContainer">
{% if total_documents_count == 0 %}
<div class="text-center text-muted py-3">
<i class="bi bi-images fs-3 d-block mb-1 text-secondary opacity-50"></i>
<span class="small">{% translate "Aucun document ou photo pour cette intervention." %}</span>
</div>
{% else %}
{% for block in document_blocks %}
{% if block.count > 0 %}
<div class="document-block-mobile mb-3 {% if not forloop.last %}border-bottom pb-3{% endif %}">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center">
<span class="badge {{ block.badge_class }} me-2" style="font-size: 0.75rem;">
<i class="bi {{ block.icon }} me-1"></i>{{ block.title }}
</span>
<span class="text-muted small">({{ block.count }})</span>
<div id="notesContainer">
<div id="documentsContainer">
{% if total_items_count == 0 %}
<div class="text-center text-muted py-3">
<i class="bi bi-journal-text fs-3 d-block mb-1 text-secondary opacity-50"></i>
<span class="small">{% translate "Aucun document ou note pour cette intervention." %}</span>
</div>
{% else %}
{% for block in sequenced_blocks %}
{% if block.has_items %}
<div class="document-block-mobile mb-4 {% if not forloop.last %}border-bottom pb-3{% endif %}">
<!-- En-tête du bloc -->
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-1 flex-wrap">
<span class="badge {{ block.badge_class }} me-1" style="font-size: 0.75rem;">
<i class="bi {{ block.icon }} me-1"></i>{{ block.title }}
</span>
<span class="text-muted small">
({% if block.documents_count > 0 %}{{ block.documents_count }} {% if block.documents_count > 1 %}{% translate "fichiers" %}{% else %}{% translate "fichier" %}{% endif %}{% endif %}{% if block.documents_count > 0 and block.notes_count > 0 %}, {% endif %}{% if block.notes_count > 0 %}{{ block.notes_count }} {% if block.notes_count > 1 %}{% translate "notes" %}{% else %}{% translate "note" %}{% endif %}{% endif %})
</span>
</div>
{% if block.period_display %}
<span class="text-muted" style="font-size: 0.72rem;">{{ block.period_display }}</span>
{% endif %}
</div>
<!-- Documents du bloc -->
{% if block.documents %}
<div class="row g-2 mb-2">
{% for document in block.documents %}
<div id="document-card-{{ document.id }}" class="col-6 col-md-2 mb-2" data-document-id="{{ document.id }}" data-upload-date="{{ document.upload_date|date:'c' }}">
<div class="position-relative">
{% if 'documents' in allowed_update_fields and document.id in deletable_document_ids %}
<button type="button"
class="btn btn-link text-danger p-0 position-absolute top-0 end-0 delete-document-btn bg-white"
data-document-id="{{ document.id }}"
data-document-name="{{ document.filename|default:''|escape }}"
aria-label="{% translate "Supprimer le document" %}"
title="{% translate "Supprimer le document" %}">
<i class="bi bi-trash fs-6"></i>
</button>
{% endif %}
{% private_file_url document 'file' as document_file_url %}
<a href="{{ document_file_url }}" target="_blank">
<div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
{% if document.thumbnail %}
{% private_file_url document 'thumbnail' as document_thumbnail_url %}
<img src="{{ document_thumbnail_url }}" alt="{{ document.filename }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% else %}
<img src="{{ document_file_url }}" alt="{{ document.filename }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% endif %}
</div>
</a>
</div>
<div class="text-center text-muted mt-1" style="font-size: 0.8em;">
{{ document.upload_date|date:"d/m/Y H:i" }}<br>
{{ document.uploaded_by|short_name }}
</div>
</div>
{% endfor %}
</div>
{% if block.period_display %}
<span class="text-muted" style="font-size: 0.72rem;">{{ block.period_display }}</span>
{% endif %}
</div>
<div class="row g-2">
{% for document in block.documents %}
<div id="document-card-{{ document.id }}" class="col-6 col-md-2 mb-2" data-document-id="{{ document.id }}" data-upload-date="{{ document.upload_date|date:'c' }}">
<div class="position-relative">
{% if 'documents' in allowed_update_fields and document.id in deletable_document_ids %}
<button type="button"
class="btn btn-link text-danger p-0 position-absolute top-0 end-0 delete-document-btn bg-white"
data-document-id="{{ document.id }}"
data-document-name="{{ document.filename|default:''|escape }}"
aria-label="{% translate "Supprimer le document" %}"
title="{% translate "Supprimer le document" %}">
<i class="bi bi-trash fs-6"></i>
</button>
{% endif %}
{% private_file_url document 'file' as document_file_url %}
<a href="{{ document_file_url }}" target="_blank">
<div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
{% if document.thumbnail %}
{% private_file_url document 'thumbnail' as document_thumbnail_url %}
<img src="{{ document_thumbnail_url }}" alt="{{ document.filename }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% else %}
<img src="{{ document_file_url }}" alt="{{ document.filename }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
<!-- Notes du bloc -->
{% if block.notes %}
<div class="notes-list-mobile {% if block.documents %}border-top pt-2 mt-1{% endif %}">
{% for note in block.notes %}
<div id="note-container-{{ note.id }}"
class="note-item py-2 {% if not forloop.last %}border-bottom border-light{% endif %}"
data-note-id="{{ note.id }}"
data-note-content="{{ note.content|urlencode }}"
data-note-author-id="{{ note.note_author_id }}"
data-note-author-display="{{ note.note_author|short_name|escapejs }}"
data-note-type="{{ note.note_type }}"
data-note-priority="{{ note.type_priority_order|default:note.type_priority_value }}"
data-note-time="{{ note.note_time|date:'c' }}"
data-note-type-display="{{ note.get_note_type_display }}"
data-note-badge-class="{{ note.type_badge_class }}"
data-note-icon-class="{{ note.type_icon_class }}">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="note-content flex-grow-1">
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
<span class="badge note-type-badge d-inline-flex align-items-center gap-1 {{ note.type_badge_class }}" style="font-size: 0.75rem;">
<i class="bi {{ note.type_icon_class }}"></i>
{{ note.get_note_type_display }}
</span>
<span class="text-muted small" style="font-size: 0.75rem;">
{{ note.note_time|timezone:"Europe/Paris"|date:"d-m-Y H:i" }} - {{ note.note_author|short_name }}
</span>
</div>
<p class="mb-0 small">{{ note.content|safe|linebreaksbr }}</p>
</div>
{% if note.id in editable_note_ids and can_edit_notes %}
<div class="note-actions d-flex flex-column align-items-center gap-2 ms-auto">
<button type="button" class="btn btn-link text-primary p-0 edit-note-btn"
data-note-id="{{ note.id }}" aria-label="{% translate "Modifier la note" %}">
<i class="bi bi-pencil-square fs-6"></i>
</button>
<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" %}">
<i class="bi bi-trash fs-6"></i>
</button>
</div>
{% endif %}
</div>
</a>
</div>
<div class="text-center text-muted mt-1" style="font-size: 0.8em;">
{{ document.upload_date|date:"d/m/Y H:i" }}<br>
{{ document.uploaded_by|short_name }}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
</div>
</div>

View file

@ -9,6 +9,7 @@ from common.models import UserConfig, Role, Thematic
from interventions.models import (
Intervention,
InterventionDocument,
InterventionNote,
InterventionTimeLine,
InterventionRealizationTimeLine,
)
@ -101,20 +102,78 @@ class DocumentSequencingServiceTests(TestCase):
InterventionDocument.objects.filter(id=doc_after.id).update(upload_date=self.now + timedelta(hours=6))
result = get_sequenced_intervention_documents(self.intervention)
self.assertEqual(result['total_count'], 3)
self.assertEqual(result['total_documents_count'], 3)
self.assertTrue(result['has_documents'])
blocks_map = {b['key']: b for b in result['blocks']}
self.assertEqual(blocks_map['before_work']['count'], 1)
self.assertEqual(blocks_map['before_work']['documents_count'], 1)
self.assertEqual(blocks_map['before_work']['documents'][0].id, doc_before.id)
self.assertEqual(blocks_map['during_work']['count'], 1)
self.assertEqual(blocks_map['during_work']['documents_count'], 1)
self.assertEqual(blocks_map['during_work']['documents'][0].id, doc_during.id)
self.assertEqual(blocks_map['after_work']['count'], 1)
self.assertEqual(blocks_map['after_work']['documents_count'], 1)
self.assertEqual(blocks_map['after_work']['documents'][0].id, doc_after.id)
def test_sequenced_notes_and_documents(self):
t_begin = self.now + timedelta(hours=2)
t_end = self.now + timedelta(hours=5)
self.intervention.begin_time = t_begin
self.intervention.end_time = t_end
self.intervention.save()
dummy_file = SimpleUploadedFile("doc.pdf", b"fake pdf", content_type="application/pdf")
doc1 = InterventionDocument.objects.create(
intervention=self.intervention,
file=dummy_file,
uploaded_by=self.user,
)
InterventionDocument.objects.filter(id=doc1.id).update(upload_date=self.now)
note_before = InterventionNote.objects.create(
intervention=self.intervention,
note_author=self.user,
content="Note de préparation",
note_time=self.now + timedelta(hours=1),
)
note_during = InterventionNote.objects.create(
intervention=self.intervention,
note_author=self.user,
content="Note pendant travaux",
note_time=self.now + timedelta(hours=3),
)
note_after = InterventionNote.objects.create(
intervention=self.intervention,
note_author=self.user,
content="Note de contrôle",
note_time=self.now + timedelta(hours=6),
)
result = get_sequenced_intervention_documents(self.intervention)
self.assertEqual(result['total_documents_count'], 1)
self.assertEqual(result['total_notes_count'], 3)
self.assertEqual(result['total_items_count'], 4)
blocks_map = {b['key']: b for b in result['blocks']}
# Avant réalisation : 1 doc, 1 note
self.assertEqual(blocks_map['before_work']['documents_count'], 1)
self.assertEqual(blocks_map['before_work']['notes_count'], 1)
self.assertEqual(blocks_map['before_work']['notes'][0].id, note_before.id)
# Exécution : 0 doc, 1 note
self.assertEqual(blocks_map['during_work']['documents_count'], 0)
self.assertEqual(blocks_map['during_work']['notes_count'], 1)
self.assertEqual(blocks_map['during_work']['notes'][0].id, note_during.id)
# Après réalisation : 0 doc, 1 note
self.assertEqual(blocks_map['after_work']['documents_count'], 0)
self.assertEqual(blocks_map['after_work']['notes_count'], 1)
self.assertEqual(blocks_map['after_work']['notes'][0].id, note_after.id)
def test_sequenced_documents_before_start_only(self):
# Intervention pas encore démarrée
self.intervention.begin_time = None
@ -129,12 +188,12 @@ class DocumentSequencingServiceTests(TestCase):
)
result = get_sequenced_intervention_documents(self.intervention)
self.assertEqual(result['total_count'], 1)
self.assertEqual(result['total_documents_count'], 1)
blocks_map = {b['key']: b for b in result['blocks']}
self.assertEqual(blocks_map['before_work']['count'], 1)
self.assertEqual(blocks_map['during_work']['count'], 0)
self.assertEqual(blocks_map['after_work']['count'], 0)
self.assertEqual(blocks_map['before_work']['documents_count'], 1)
self.assertEqual(blocks_map['during_work']['documents_count'], 0)
self.assertEqual(blocks_map['after_work']['documents_count'], 0)
def test_sequenced_documents_with_tolerance(self):
# Début à T+2h, Fin à T+4h
@ -182,17 +241,17 @@ class DocumentSequencingServiceTests(TestCase):
blocks_map = {b['key']: b for b in result['blocks']}
# Doc C dans Avant réalisation
self.assertEqual(blocks_map['before_work']['count'], 1)
self.assertEqual(blocks_map['before_work']['documents_count'], 1)
self.assertEqual(blocks_map['before_work']['documents'][0].id, doc_early.id)
# Docs A et B dans Exécution de l'intervention (grâce à la tolérance)
self.assertEqual(blocks_map['during_work']['count'], 2)
self.assertEqual(blocks_map['during_work']['documents_count'], 2)
during_ids = [d.id for d in blocks_map['during_work']['documents']]
self.assertIn(doc_just_before.id, during_ids)
self.assertIn(doc_just_after.id, during_ids)
# Doc D dans Après réalisation
self.assertEqual(blocks_map['after_work']['count'], 1)
self.assertEqual(blocks_map['after_work']['documents_count'], 1)
self.assertEqual(blocks_map['after_work']['documents'][0].id, doc_late.id)
@ -229,6 +288,13 @@ class DocumentSequencingViewsTests(TestCase):
)
InterventionDocument.objects.filter(id=doc2.id).update(upload_date=self.now - timedelta(hours=1))
self.note = InterventionNote.objects.create(
intervention=self.intervention,
note_author=self.user,
content="Note de test pendant travaux",
note_time=self.now - timedelta(hours=1),
)
self.client.force_login(self.user)
self.client.cookies['django_language'] = 'fr'
@ -238,13 +304,16 @@ class DocumentSequencingViewsTests(TestCase):
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('document_blocks', response.context)
self.assertIn('sequenced_blocks', response.context)
self.assertEqual(response.context['total_documents_count'], 2)
self.assertEqual(response.context['total_notes_count'], 1)
import html
content = html.unescape(response.content.decode('utf-8'))
self.assertIn('Notes et documents', content)
self.assertIn('Avant réalisation', content)
self.assertIn("Exécution de l'intervention", content)
self.assertIn("Note de test pendant travaux", content)
def test_mobile_detail_view_contains_sequenced_blocks(self):
from django.utils import translation
@ -252,13 +321,16 @@ class DocumentSequencingViewsTests(TestCase):
url = reverse('interventions:intervention_detail_mobile', args=[self.intervention.id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertIn('document_blocks', response.context)
self.assertIn('sequenced_blocks', response.context)
self.assertEqual(response.context['total_documents_count'], 2)
self.assertEqual(response.context['total_notes_count'], 1)
import html
content = html.unescape(response.content.decode('utf-8'))
self.assertIn('Notes et documents', content)
self.assertIn('Avant réalisation', content)
self.assertIn("Exécution de l'intervention", content)
self.assertIn("Note de test pendant travaux", content)
def test_translations_nl_and_en(self):
from django.utils import translation
@ -275,3 +347,4 @@ class DocumentSequencingViewsTests(TestCase):
self.assertIn('Before realization', titles_en)
self.assertIn('Execution of the intervention', titles_en)
self.assertIn('After realization', titles_en)

View file

@ -742,9 +742,12 @@ def intervention_detail(request, intervention_id):
# Discussion
'discussion_messages': discussion_messages,
'can_post_discussion_message': can_post_discussion_message,
# Document permissions & sequenced blocks
'document_blocks': get_sequenced_intervention_documents(intervention)['blocks'],
# 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)),
'can_delete_documents': 'documents' in allowed_update_fields,
'can_copy_documents': 'documents' in allowed_update_fields and copy_targets_json != '[]',
# Guided operation template

View file

@ -1075,8 +1075,11 @@ def intervention_detail_mobile(request, intervention_id):
context = {
'intervention': intervention,
'document_blocks': get_sequenced_intervention_documents(intervention)['blocks'],
'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)),
'assets': assets,
'notes': notes,
'can_edit_intervention': can_edit_mobile,