feat(calendar): intégrer les notes et documents dans la modale d'intervention
This commit is contained in:
parent
775151c72d
commit
2ef730508f
7 changed files with 682 additions and 85 deletions
|
|
@ -1146,4 +1146,44 @@
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Modal Notes & Documents --- */
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.border-start-lg {
|
||||||
|
border-left: 1px solid #dee2e6 !important;
|
||||||
|
padding-left: 1.25rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-modal-notes-docs-container {
|
||||||
|
min-height: 250px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-note-card {
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-note-card:hover {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
border-color: #b0c4de !important;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-doc-card {
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-doc-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||||
|
border-color: #b0c4de !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-nd-filter-btn {
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4894,6 +4894,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPlanningFeedback();
|
clearPlanningFeedback();
|
||||||
|
loadModalNotesAndDocuments(currentModalInterventionId);
|
||||||
planningModal.show();
|
planningModal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5087,6 +5088,102 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
planningFeedback.textContent = '';
|
planningFeedback.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadModalNotesAndDocuments(interventionId) {
|
||||||
|
const container = document.getElementById('modal-notes-docs-content');
|
||||||
|
const summaryText = document.getElementById('modal-notes-docs-summary');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (summaryText) {
|
||||||
|
summaryText.textContent = '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary me-2" role="status">
|
||||||
|
<span class="visually-hidden">${gettext('Chargement...')}</span>
|
||||||
|
</div>
|
||||||
|
<span class="small">${gettext('Chargement des notes et documents...')}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!interventionId) {
|
||||||
|
container.innerHTML = `<div class="text-muted small p-3 text-center">${gettext('Aucune intervention sélectionnée.')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = (typeof interventionCalendarDetailsUrlPattern !== 'undefined')
|
||||||
|
? interventionCalendarDetailsUrlPattern.replace('/0/details/', `/${interventionId}/details/`)
|
||||||
|
: `/interventions/calendar/intervention/${interventionId}/details/`;
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText || 'Erreur de chargement');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (currentModalInterventionId !== interventionId) return;
|
||||||
|
|
||||||
|
if (data.success && data.html) {
|
||||||
|
container.innerHTML = data.html;
|
||||||
|
|
||||||
|
const notesCount = data.notes_count || 0;
|
||||||
|
const docsCount = data.documents_count || 0;
|
||||||
|
const notesText = notesCount > 1 ? `${notesCount} ${gettext('notes')}` : `${notesCount} ${gettext('note')}`;
|
||||||
|
const docsText = docsCount > 1 ? `${docsCount} ${gettext('docs')}` : `${docsCount} ${gettext('doc')}`;
|
||||||
|
|
||||||
|
if (summaryText) {
|
||||||
|
summaryText.textContent = `${notesText} • ${docsText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup filter buttons
|
||||||
|
const filterBtns = container.querySelectorAll('.cal-nd-filter-btn');
|
||||||
|
const notesSection = container.querySelector('#cal-modal-notes-section');
|
||||||
|
const docsSection = container.querySelector('#cal-modal-docs-section');
|
||||||
|
|
||||||
|
filterBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
filterBtns.forEach(b => {
|
||||||
|
b.classList.remove('active');
|
||||||
|
b.classList.remove('btn-primary');
|
||||||
|
b.classList.add('btn-outline-primary');
|
||||||
|
});
|
||||||
|
this.classList.add('active');
|
||||||
|
this.classList.remove('btn-outline-primary');
|
||||||
|
this.classList.add('btn-primary');
|
||||||
|
const filter = this.getAttribute('data-filter');
|
||||||
|
|
||||||
|
if (notesSection) {
|
||||||
|
notesSection.style.display = (filter === 'all' || filter === 'notes') ? '' : 'none';
|
||||||
|
}
|
||||||
|
if (docsSection) {
|
||||||
|
docsSection.style.display = (filter === 'all' || filter === 'docs') ? '' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<div class="text-muted small p-3 text-center">${gettext('Impossible de charger les notes et documents.')}</div>`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (currentModalInterventionId !== interventionId) return;
|
||||||
|
console.error('Error loading notes/documents:', err);
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="alert alert-warning py-2 px-3 small m-2 d-flex align-items-center justify-content-between">
|
||||||
|
<span><i class="bi bi-exclamation-triangle me-2"></i>${gettext('Erreur lors du chargement des notes et documents.')}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-warning text-dark py-0 px-2" id="retry-load-notes-docs-btn">${gettext('Réessayer')}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const retryBtn = container.querySelector('#retry-load-notes-docs-btn');
|
||||||
|
if (retryBtn) {
|
||||||
|
retryBtn.addEventListener('click', () => loadModalNotesAndDocuments(interventionId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function loadCalendarData(customDate) {
|
function loadCalendarData(customDate) {
|
||||||
let url = interventionseventsUrl;
|
let url = interventionseventsUrl;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -554,36 +554,45 @@
|
||||||
|
|
||||||
<!-- Modal Bootstrap pour afficher les détails d'une intervention -->
|
<!-- Modal Bootstrap pour afficher les détails d'une intervention -->
|
||||||
<div class="modal fade" id="interventionModal" tabindex="-1" aria-labelledby="interventionModalLabel" aria-hidden="true">
|
<div class="modal fade" id="interventionModal" tabindex="-1" aria-labelledby="interventionModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
<div class="modal-dialog modal-dialog-centered modal-xl modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header py-2 px-3 bg-light border-bottom">
|
||||||
<h5 class="modal-title" id="interventionModalLabel">{% translate "Planification de l’intervention" %}</h5>
|
<h5 class="modal-title d-flex align-items-center gap-2 mb-0" id="interventionModalLabel">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
|
<i class="bi bi-calendar2-event text-primary"></i>
|
||||||
|
<span>{% translate "Détail & Planification de l’intervention" %}</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body p-3">
|
||||||
<div class="mb-3">
|
<!-- En-tête résumé intervention -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="p-2 mb-3 bg-light rounded border d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<div>
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
<strong>{% translate "Code" %} :</strong> <span id="modal-intervention-code" class="text-primary fw-bold"></span>
|
<span id="modal-intervention-code" class="text-primary fw-bold fs-6"></span>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span id="modal-intervention-priority" class="badge text-bg-light border"></span>
|
<span id="modal-intervention-priority" class="badge text-bg-light border"></span>
|
||||||
|
<span id="modal-intervention-status" class="badge rounded-pill"></span>
|
||||||
|
<span id="modal-preparation-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span id="modal-notes-docs-badge" class="badge text-bg-light border text-muted">
|
||||||
|
<i class="bi bi-journal-text me-1 text-primary"></i>
|
||||||
|
<span id="modal-notes-docs-summary">—</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-1">
|
|
||||||
<strong>{% translate "Titre" %} :</strong> <span id="modal-intervention-title"></span>
|
|
||||||
</p>
|
|
||||||
<p class="mb-1">
|
|
||||||
<strong>{% translate "Statut" %} :</strong> <span id="modal-intervention-status" class="badge rounded-pill"></span>
|
|
||||||
</p>
|
|
||||||
<p class="mb-0">
|
|
||||||
<strong>{% translate "Bon de préparation" %} :</strong> <span id="modal-preparation-status"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dates sections -->
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="table-responsive">
|
<strong>{% translate "Titre" %} :</strong> <span id="modal-intervention-title" class="fw-semibold"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Colonne gauche: Planification & Dates -->
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<div class="card h-100 border-0 bg-transparent">
|
||||||
|
<h6 class="text-muted fw-bold mb-2 d-flex align-items-center">
|
||||||
|
<i class="bi bi-calendar3 me-2 text-primary"></i>{% translate "Dates" %}
|
||||||
|
</h6>
|
||||||
|
<!-- Dates sections -->
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
<table class="table table-sm table-bordered mb-0">
|
<table class="table table-sm table-bordered mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -611,32 +620,32 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="planning-form" class="row g-3 mt-2">
|
<form id="planning-form" class="row g-2">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h6 class="text-muted mb-2">{% translate "Modifier la planification" %}</h6>
|
<h6 class="text-muted fw-bold mb-2 d-flex align-items-center">
|
||||||
|
<i class="bi bi-pencil-square me-2 text-primary"></i>{% translate "Modifier la planification" %}
|
||||||
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label for="planning-begin-input" class="form-label">{% translate "Début planifié" %}</label>
|
<label for="planning-begin-input" class="form-label small mb-1">{% translate "Début planifié" %}</label>
|
||||||
<input type="datetime-local" class="form-control" id="planning-begin-input" required>
|
<input type="datetime-local" class="form-control form-control-sm" id="planning-begin-input" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label for="planning-end-input" class="form-label">{% translate "Fin planifiée" %}</label>
|
<label for="planning-end-input" class="form-label small mb-1">{% translate "Fin planifiée" %}</label>
|
||||||
<input type="datetime-local" class="form-control" id="planning-end-input" required>
|
<input type="datetime-local" class="form-control form-control-sm" id="planning-end-input" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-sm-6">
|
||||||
<label for="planning-duration-input" class="form-label">{% translate "Durée (heures)" %}</label>
|
<label for="planning-duration-input" class="form-label small mb-1">{% translate "Durée (heures)" %}</label>
|
||||||
<input type="number" class="form-control" id="planning-duration-input" min="0.25" step="0.25" inputmode="decimal">
|
<input type="number" class="form-control form-control-sm" id="planning-duration-input" min="0.25" step="0.25" inputmode="decimal">
|
||||||
</div>
|
</div>
|
||||||
<div class="row mt-3">
|
<div class="col-12 col-sm-6">
|
||||||
<div class="col-12 col-md-6">
|
<label for="planning-team-input" class="form-label small mb-1">{% translate "Équipe assignée" %}</label>
|
||||||
<label for="planning-team-input" class="form-label">{% translate "Équipe assignée" %}</label>
|
<input type="text" class="form-control form-control-sm" id="planning-team-input" readonly disabled>
|
||||||
<input type="text" class="form-control" id="planning-team-input" readonly disabled>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12">
|
||||||
<label for="planning-member-input" class="form-label">{% translate "Membre assigné" %}</label>
|
<label for="planning-member-input" class="form-label small mb-1">{% translate "Membre assigné" %}</label>
|
||||||
<div class="input-group">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" class="form-control" id="planning-member-input" readonly disabled>
|
<input type="text" class="form-control" id="planning-member-input" readonly disabled>
|
||||||
{% if can_view_member_history %}
|
{% if can_view_member_history %}
|
||||||
<button type="button" class="btn btn-outline-secondary text-muted d-none" id="btn-modal-member-history" title="{% translate 'Voir l’historique du membre' %}">
|
<button type="button" class="btn btn-outline-secondary text-muted d-none" id="btn-modal-member-history" title="{% translate 'Voir l’historique du membre' %}">
|
||||||
|
|
@ -645,10 +654,19 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-12 d-none" id="planning-feedback"></div>
|
<div class="col-12 d-none" id="planning-feedback"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colonne droite: Notes & Documents -->
|
||||||
|
<div class="col-12 col-lg-7 border-start-lg">
|
||||||
|
<div id="modal-notes-docs-content" class="calendar-modal-notes-docs-container">
|
||||||
|
<!-- Injected via AJAX -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<a id="modal-intervention-link" href="#" class="btn btn-outline-primary" target="_blank">{% translate "Voir l’intervention" %}</a>
|
<a id="modal-intervention-link" href="#" class="btn btn-outline-primary" target="_blank">{% translate "Voir l’intervention" %}</a>
|
||||||
<button type="button" class="btn btn-primary" id="planning-save-btn">{% translate "Enregistrer" %}</button>
|
<button type="button" class="btn btn-primary" id="planning-save-btn">{% translate "Enregistrer" %}</button>
|
||||||
|
|
@ -892,6 +910,7 @@
|
||||||
const teamDailyMembersUrlPattern = "{% url 'interventions:team_daily_members_ajax' 0 %}";
|
const teamDailyMembersUrlPattern = "{% url 'interventions:team_daily_members_ajax' 0 %}";
|
||||||
const teamDailyMembersBaseUrl = teamDailyMembersUrlPattern.replace('/0/members/', '/');
|
const teamDailyMembersBaseUrl = teamDailyMembersUrlPattern.replace('/0/members/', '/');
|
||||||
const teamPlanningAssignUrl = "{% url 'contracts:team_planning_assign_member_ajax' %}";
|
const teamPlanningAssignUrl = "{% url 'contracts:team_planning_assign_member_ajax' %}";
|
||||||
|
const interventionCalendarDetailsUrlPattern = "{% url 'interventions:intervention_calendar_details_ajax' 0 %}";
|
||||||
</script>
|
</script>
|
||||||
<script src="{% url 'javascript-catalog' %}"></script>
|
<script src="{% url 'javascript-catalog' %}"></script>
|
||||||
<script src="{% static 'interventions/status_colors.js' %}"></script>
|
<script src="{% static 'interventions/status_colors.js' %}"></script>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
{% load i18n tz short_name interventions_tags document_viewer_tags %}
|
||||||
|
|
||||||
|
<div class="calendar-notes-docs-wrapper">
|
||||||
|
<!-- Barre d'outils filtres / compteurs -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="{% translate 'Filtrer les notes et documents' %}">
|
||||||
|
<button type="button" class="btn btn-outline-primary active cal-nd-filter-btn" data-filter="all">
|
||||||
|
<i class="bi bi-grid me-1"></i>{% translate "Tout" %}
|
||||||
|
<span class="badge text-bg-primary rounded-pill ms-1" id="cal-count-all">{{ total_count }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary cal-nd-filter-btn" data-filter="notes">
|
||||||
|
<i class="bi bi-sticky me-1"></i>{% translate "Notes" %}
|
||||||
|
<span class="badge text-bg-secondary rounded-pill ms-1" id="cal-count-notes">{{ notes_count }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary cal-nd-filter-btn" data-filter="docs">
|
||||||
|
<i class="bi bi-paperclip me-1"></i>{% translate "Documents" %}
|
||||||
|
<span class="badge text-bg-secondary rounded-pill ms-1" id="cal-count-docs">{{ documents_count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{% if total_count > 0 %}
|
||||||
|
<span>{{ total_count }} {% if total_count > 1 %}{% translate "éléments" %}{% else %}{% translate "élément" %}{% endif %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if total_count == 0 %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-journal-x fs-1 d-block mb-2 text-secondary opacity-50"></i>
|
||||||
|
<p class="mb-1 fw-semibold text-secondary">{% translate "Aucune note ni document" %}</p>
|
||||||
|
<p class="small text-muted mb-0">{% translate "Cette intervention n'a pas encore de note ni de document associé." %}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<!-- Section Notes -->
|
||||||
|
<div id="cal-modal-notes-section" class="cal-nd-section mb-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<h6 class="mb-0 fw-bold text-dark d-flex align-items-center" style="font-size: 0.85rem;">
|
||||||
|
<i class="bi bi-sticky-fill text-warning me-2"></i>{% translate "Notes" %}
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary-emphasis rounded-pill ms-2" style="font-size: 0.72rem;">{{ notes_count }}</span>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if notes %}
|
||||||
|
<div class="cal-notes-list d-flex flex-column gap-2">
|
||||||
|
{% for note in notes %}
|
||||||
|
<div class="card shadow-none border bg-light-subtle rounded-3 cal-note-card">
|
||||||
|
<div class="card-body p-2 px-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-2 mb-1">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-1">
|
||||||
|
<span class="badge {{ note.type_badge_class }} d-inline-flex align-items-center gap-1 py-1 px-2" style="font-size: 0.72rem;">
|
||||||
|
<i class="bi {{ note.type_icon_class }}"></i>
|
||||||
|
{{ note.get_note_type_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted small text-nowrap" style="font-size: 0.75rem;">
|
||||||
|
<i class="bi bi-clock me-1"></i>{{ note.note_time|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-dark small note-content-text py-1" style="white-space: pre-wrap; line-height: 1.45; word-break: break-word;">{{ note.content }}</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-1 pt-1 border-top border-light-subtle" style="font-size: 0.72rem;">
|
||||||
|
<span class="text-muted">
|
||||||
|
<i class="bi bi-person me-1"></i>{{ note.note_author|short_name|default:"—" }}
|
||||||
|
</span>
|
||||||
|
{% if note.updated_at %}
|
||||||
|
<span class="text-muted fst-italic" title="{% translate 'Mis à jour le' %} {{ note.updated_at|timezone:'Europe/Paris'|date:'d/m/Y H:i' }}{% if note.updated_by %} {% translate 'par' %} {{ note.updated_by|short_name }}{% endif %}">
|
||||||
|
<i class="bi bi-pencil-fill me-1" style="font-size: 0.65rem;"></i>{% translate "Modifié" %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small fst-italic py-2 px-1">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>{% translate "Aucune note pour cette intervention." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Documents -->
|
||||||
|
<div id="cal-modal-docs-section" class="cal-nd-section {% if notes %}mt-4 pt-3 border-top{% endif %}">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<h6 class="mb-0 fw-bold text-dark d-flex align-items-center" style="font-size: 0.85rem;">
|
||||||
|
<i class="bi bi-paperclip text-primary me-2"></i>{% translate "Documents & Photos" %}
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary-emphasis rounded-pill ms-2" style="font-size: 0.72rem;">{{ documents_count }}</span>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if documents %}
|
||||||
|
<div class="row g-2">
|
||||||
|
{% for doc in documents %}
|
||||||
|
{% private_file_url doc 'file' as file_url %}
|
||||||
|
{% if doc.thumbnail %}
|
||||||
|
{% private_file_url doc 'thumbnail' as thumb_url %}
|
||||||
|
{% else %}
|
||||||
|
{% private_file_url doc 'file' as thumb_url %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-6 col-sm-4">
|
||||||
|
<div class="card h-100 border shadow-none bg-white rounded-3 overflow-hidden position-relative cal-doc-card">
|
||||||
|
<a href="{{ file_url }}" target="_blank" rel="noopener noreferrer" class="d-block text-decoration-none bg-light d-flex align-items-center justify-content-center text-center overflow-hidden position-relative" style="height: 105px;" title="{% translate 'Ouvrir' %} {{ doc.filename }}">
|
||||||
|
{% if doc.is_image %}
|
||||||
|
<img src="{{ thumb_url }}" alt="{{ doc.filename }}" style="width: 100%; height: 100%; object-fit: cover;" loading="lazy">
|
||||||
|
<div class="position-absolute bottom-0 end-0 p-1">
|
||||||
|
<span class="badge bg-dark bg-opacity-75 rounded-pill" style="font-size: 0.65rem;" title="{% translate 'Agrandir' %}">
|
||||||
|
<i class="bi bi-zoom-in"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<i class="{{ doc.ext|file_icon_class }}" style="font-size: 2.3rem;"></i>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
<div class="card-body p-2 d-flex flex-column justify-content-between" style="min-height: 52px;">
|
||||||
|
<div class="text-truncate fw-semibold text-dark small" style="font-size: 0.76rem;" title="{{ doc.filename }}">
|
||||||
|
{{ doc.filename }}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-1 pt-1 border-top" style="font-size: 0.7rem;">
|
||||||
|
<span class="text-muted text-truncate me-1" title="{{ doc.upload_date|timezone:'Europe/Paris'|date:'d/m/Y H:i' }}{% if doc.uploaded_by %} · {{ doc.uploaded_by|short_name }}{% endif %}">
|
||||||
|
{{ doc.upload_date|timezone:"Europe/Paris"|date:"d/m/Y" }}
|
||||||
|
</span>
|
||||||
|
<a href="{{ file_url }}" download="{{ doc.filename }}" class="text-primary text-decoration-none p-0" title="{% translate 'Télécharger' %}">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted small fst-italic py-2 px-1">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>{% translate "Aucun document pour cette intervention." %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
234
loko/interventions/tests/test_calendar_notes_docs.py
Normal file
234
loko/interventions/tests/test_calendar_notes_docs.py
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import json
|
||||||
|
from unittest import mock
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, RequestFactory
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from interventions.views.calendar import intervention_calendar_details_ajax
|
||||||
|
from interventions.models import InterventionNote
|
||||||
|
|
||||||
|
|
||||||
|
class DummyConfig:
|
||||||
|
regional_standard = 'FR'
|
||||||
|
linked_configs = mock.MagicMock(values_list=lambda *args, **kwargs: [])
|
||||||
|
|
||||||
|
|
||||||
|
class DummyUser:
|
||||||
|
def __init__(self, id=1, first_name="Jean", last_name="Dupont"):
|
||||||
|
self.id = id
|
||||||
|
self.pk = id
|
||||||
|
self.first_name = first_name
|
||||||
|
self.last_name = last_name
|
||||||
|
self.is_authenticated = True
|
||||||
|
self.config = DummyConfig()
|
||||||
|
|
||||||
|
def get_full_name(self):
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.get_full_name()
|
||||||
|
|
||||||
|
|
||||||
|
class DummyNote:
|
||||||
|
def __init__(self, id, note_type, type_display, badge_class, icon_class, content, note_time, note_author, updated_at=None, updated_by=None):
|
||||||
|
self.id = id
|
||||||
|
self.pk = id
|
||||||
|
self.note_type = note_type
|
||||||
|
self._type_display = type_display
|
||||||
|
self.type_badge_class = badge_class
|
||||||
|
self.type_icon_class = icon_class
|
||||||
|
self.content = content
|
||||||
|
self.note_time = note_time
|
||||||
|
self.note_author = note_author
|
||||||
|
self.updated_at = updated_at
|
||||||
|
self.updated_by = updated_by
|
||||||
|
|
||||||
|
def get_note_type_display(self):
|
||||||
|
return self._type_display
|
||||||
|
|
||||||
|
|
||||||
|
class DummyDoc:
|
||||||
|
def __init__(self, id, filename, thumbnail_path=None, upload_date=None, uploaded_by=None):
|
||||||
|
self.id = id
|
||||||
|
self.pk = id
|
||||||
|
self.filename = filename
|
||||||
|
self.upload_date = upload_date or timezone.now()
|
||||||
|
self.uploaded_by = uploaded_by
|
||||||
|
self.file = type('DummyFile', (), {'name': filename})()
|
||||||
|
if thumbnail_path:
|
||||||
|
self.thumbnail = type('DummyThumb', (), {'name': thumbnail_path})()
|
||||||
|
else:
|
||||||
|
self.thumbnail = None
|
||||||
|
self._meta = type('DummyMeta', (), {
|
||||||
|
'app_label': 'interventions',
|
||||||
|
'model_name': 'interventiondocument',
|
||||||
|
'object_name': 'InterventionDocument',
|
||||||
|
})()
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarInterventionDetailsAjaxSimpleTests(SimpleTestCase):
|
||||||
|
databases = set()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.url = reverse('interventions:intervention_calendar_details_ajax', args=[123])
|
||||||
|
|
||||||
|
def _create_request(self, user=None):
|
||||||
|
request = self.factory.get(self.url)
|
||||||
|
request.user = user if user is not None else AnonymousUser()
|
||||||
|
request.session = {
|
||||||
|
'account_switch_allowed_user_ids': [getattr(request.user, 'pk', 1)],
|
||||||
|
'account_switch_origin_user_id': getattr(request.user, 'pk', 1),
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
|
||||||
|
def test_url_resolves(self):
|
||||||
|
self.assertEqual(self.url, '/interventions/calendar/intervention/123/details/')
|
||||||
|
|
||||||
|
def test_anonymous_user_redirects_to_login(self):
|
||||||
|
request = self._create_request(AnonymousUser())
|
||||||
|
response = intervention_calendar_details_ajax(request, intervention_id=123)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn('/accounts/login/', response.url)
|
||||||
|
|
||||||
|
@mock.patch('interventions.views.calendar.can_view_intervention_summary', return_value=False)
|
||||||
|
@mock.patch('interventions.views.calendar.can_view_intervention', return_value=False)
|
||||||
|
@mock.patch('interventions.views.calendar.get_object_or_404')
|
||||||
|
def test_permission_denied_returns_403(self, mock_get_obj, mock_can_view, mock_can_view_summary):
|
||||||
|
mock_itv = mock.MagicMock(id=123)
|
||||||
|
mock_get_obj.return_value = mock_itv
|
||||||
|
|
||||||
|
user = DummyUser(id=99)
|
||||||
|
request = self._create_request(user)
|
||||||
|
|
||||||
|
response = intervention_calendar_details_ajax(request, intervention_id=123)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
@mock.patch('interventions.views.calendar.can_view_intervention_summary', return_value=False)
|
||||||
|
@mock.patch('interventions.views.calendar.can_view_intervention', return_value=True)
|
||||||
|
@mock.patch('interventions.views.calendar.InterventionNote.objects')
|
||||||
|
@mock.patch('interventions.views.calendar.get_object_or_404')
|
||||||
|
def test_authorized_user_receives_notes_and_documents(
|
||||||
|
self, mock_get_obj, mock_notes_qs, mock_can_view, mock_can_view_summary
|
||||||
|
):
|
||||||
|
mock_itv = mock.MagicMock(id=123, title="Intervention Réparation Éclairage")
|
||||||
|
mock_get_obj.return_value = mock_itv
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
author = DummyUser(id=1, first_name="Jean", last_name="Dupont")
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
note1 = DummyNote(
|
||||||
|
id=1,
|
||||||
|
note_type=InterventionNote.NoteType.EQUIPMENT,
|
||||||
|
type_display="Matériel",
|
||||||
|
badge_class="text-bg-warning text-dark",
|
||||||
|
icon_class="bi-tools",
|
||||||
|
content="Nacelle 15m requise pour intervention sur mât",
|
||||||
|
note_time=now,
|
||||||
|
note_author=author,
|
||||||
|
)
|
||||||
|
note2 = DummyNote(
|
||||||
|
id=2,
|
||||||
|
note_type=InterventionNote.NoteType.IMPORTANT_INFO,
|
||||||
|
type_display="Information importante",
|
||||||
|
badge_class="text-bg-danger text-white",
|
||||||
|
icon_class="bi-exclamation-triangle-fill",
|
||||||
|
content="Attention présence de câbles haute tension à proximité",
|
||||||
|
note_time=now,
|
||||||
|
note_author=author,
|
||||||
|
updated_at=now,
|
||||||
|
updated_by=author,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_filtered_notes = mock.MagicMock()
|
||||||
|
mock_filtered_notes.select_related.return_value = mock_filtered_notes
|
||||||
|
mock_filtered_notes.annotate.return_value = mock_filtered_notes
|
||||||
|
mock_filtered_notes.order_by.return_value = [note1, note2]
|
||||||
|
mock_notes_qs.filter.return_value = mock_filtered_notes
|
||||||
|
|
||||||
|
# Documents
|
||||||
|
doc_img = DummyDoc(
|
||||||
|
id=10,
|
||||||
|
filename="photo_degats.jpg",
|
||||||
|
thumbnail_path="thumbnails/photo_degats.jpg",
|
||||||
|
upload_date=now,
|
||||||
|
uploaded_by=author,
|
||||||
|
)
|
||||||
|
doc_pdf = DummyDoc(
|
||||||
|
id=11,
|
||||||
|
filename="rapport_securite.pdf",
|
||||||
|
thumbnail_path=None,
|
||||||
|
upload_date=now,
|
||||||
|
uploaded_by=author,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_docs_qs = mock.MagicMock()
|
||||||
|
mock_docs_qs.select_related.return_value = mock_docs_qs
|
||||||
|
mock_docs_qs.order_by.return_value = [doc_img, doc_pdf]
|
||||||
|
mock_itv.documents = mock_docs_qs
|
||||||
|
|
||||||
|
user = DummyUser(id=99)
|
||||||
|
request = self._create_request(user)
|
||||||
|
|
||||||
|
response = intervention_calendar_details_ajax(request, intervention_id=123)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = json.loads(response.content.decode('utf-8'))
|
||||||
|
self.assertTrue(data['success'])
|
||||||
|
self.assertEqual(data['notes_count'], 2)
|
||||||
|
self.assertEqual(data['documents_count'], 2)
|
||||||
|
self.assertEqual(data['total_count'], 4)
|
||||||
|
|
||||||
|
# Content assertions
|
||||||
|
self.assertIn("Nacelle 15m requise", data['html'])
|
||||||
|
self.assertIn("Attention présence de câbles haute tension", data['html'])
|
||||||
|
self.assertIn("photo_degats.jpg", data['html'])
|
||||||
|
self.assertIn("rapport_securite.pdf", data['html'])
|
||||||
|
self.assertIn("Jean D.", data['html'])
|
||||||
|
|
||||||
|
# Document flags computed by view
|
||||||
|
self.assertTrue(doc_img.is_image)
|
||||||
|
self.assertFalse(doc_img.is_pdf)
|
||||||
|
self.assertEqual(doc_img.ext, '.jpg')
|
||||||
|
|
||||||
|
self.assertFalse(doc_pdf.is_image)
|
||||||
|
self.assertTrue(doc_pdf.is_pdf)
|
||||||
|
self.assertEqual(doc_pdf.ext, '.pdf')
|
||||||
|
|
||||||
|
@mock.patch('interventions.views.calendar.can_view_intervention_summary', return_value=False)
|
||||||
|
@mock.patch('interventions.views.calendar.can_view_intervention', return_value=True)
|
||||||
|
@mock.patch('interventions.views.calendar.InterventionNote.objects')
|
||||||
|
@mock.patch('interventions.views.calendar.get_object_or_404')
|
||||||
|
def test_empty_notes_and_documents(
|
||||||
|
self, mock_get_obj, mock_notes_qs, mock_can_view, mock_can_view_summary
|
||||||
|
):
|
||||||
|
mock_itv = mock.MagicMock(id=456, title="Intervention Sans Notes")
|
||||||
|
mock_get_obj.return_value = mock_itv
|
||||||
|
|
||||||
|
mock_filtered_notes = mock.MagicMock()
|
||||||
|
mock_filtered_notes.select_related.return_value = mock_filtered_notes
|
||||||
|
mock_filtered_notes.annotate.return_value = mock_filtered_notes
|
||||||
|
mock_filtered_notes.order_by.return_value = []
|
||||||
|
mock_notes_qs.filter.return_value = mock_filtered_notes
|
||||||
|
|
||||||
|
mock_docs_qs = mock.MagicMock()
|
||||||
|
mock_docs_qs.select_related.return_value = mock_docs_qs
|
||||||
|
mock_docs_qs.order_by.return_value = []
|
||||||
|
mock_itv.documents = mock_docs_qs
|
||||||
|
|
||||||
|
user = DummyUser(id=99)
|
||||||
|
request = self._create_request(user)
|
||||||
|
|
||||||
|
response = intervention_calendar_details_ajax(request, intervention_id=456)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = json.loads(response.content.decode('utf-8'))
|
||||||
|
self.assertTrue(data['success'])
|
||||||
|
self.assertEqual(data['notes_count'], 0)
|
||||||
|
self.assertEqual(data['documents_count'], 0)
|
||||||
|
self.assertEqual(data['total_count'], 0)
|
||||||
|
self.assertIn("Aucune note ni document", data['html'])
|
||||||
|
|
@ -78,6 +78,7 @@ urlpatterns = [
|
||||||
path('calendar/', views.interventions_calendar, name='interventions_calendar'),
|
path('calendar/', views.interventions_calendar, name='interventions_calendar'),
|
||||||
path('calendar/member/<int:member_id>/history/', views.member_history_ajax, name='member_history_ajax'),
|
path('calendar/member/<int:member_id>/history/', views.member_history_ajax, name='member_history_ajax'),
|
||||||
path('calendar/team/<int:team_id>/members/', views.team_daily_members_ajax, name='team_daily_members_ajax'),
|
path('calendar/team/<int:team_id>/members/', views.team_daily_members_ajax, name='team_daily_members_ajax'),
|
||||||
|
path('calendar/intervention/<int:intervention_id>/details/', views.intervention_calendar_details_ajax, name='intervention_calendar_details_ajax'),
|
||||||
path('add/choose-thematic/', views.choose_thematic_before_add, name='intervention_add_choose_thematic'),
|
path('add/choose-thematic/', views.choose_thematic_before_add, name='intervention_add_choose_thematic'),
|
||||||
path('<str:thematic_code>/add/', views.interventions_add, name='intervention_add'),
|
path('<str:thematic_code>/add/', views.interventions_add, name='intervention_add'),
|
||||||
path('<str:thematic_code>/add-mobile/', views.interventions_add, name='intervention_add_mobile_form'),
|
path('<str:thematic_code>/add-mobile/', views.interventions_add, name='intervention_add_mobile_form'),
|
||||||
|
|
|
||||||
|
|
@ -1754,3 +1754,68 @@ def team_daily_members_ajax(request, team_id):
|
||||||
'available_members': available_members,
|
'available_members': available_members,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_GET
|
||||||
|
def intervention_calendar_details_ajax(request, intervention_id):
|
||||||
|
"""
|
||||||
|
Retourne les notes et documents d'une intervention pour la modale du calendrier.
|
||||||
|
"""
|
||||||
|
intervention = get_object_or_404(Intervention, pk=intervention_id)
|
||||||
|
|
||||||
|
if not (can_view_intervention(request.user, intervention) or can_view_intervention_summary(request.user, intervention)):
|
||||||
|
return HttpResponseForbidden(_("Vous n'avez pas la permission de voir cette intervention."))
|
||||||
|
|
||||||
|
note_priority_case = Case(
|
||||||
|
When(note_type=InterventionNote.NoteType.EQUIPMENT, then=Value(0)),
|
||||||
|
When(note_type=InterventionNote.NoteType.IMPORTANT_INFO, then=Value(1)),
|
||||||
|
When(note_type=InterventionNote.NoteType.ORIGIN, then=Value(2)),
|
||||||
|
default=Value(3),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
notes = list(
|
||||||
|
InterventionNote.objects
|
||||||
|
.filter(intervention=intervention)
|
||||||
|
.select_related('note_author', 'updated_by')
|
||||||
|
.annotate(type_priority_order=note_priority_case)
|
||||||
|
.order_by('type_priority_order', '-note_time', '-id')
|
||||||
|
)
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'}
|
||||||
|
documents = list(
|
||||||
|
intervention.documents
|
||||||
|
.select_related('uploaded_by')
|
||||||
|
.order_by('-upload_date', '-id')
|
||||||
|
)
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
fn = getattr(doc, 'filename', '') or ''
|
||||||
|
ext = os.path.splitext(fn)[1].lower() if fn else ''
|
||||||
|
doc.ext = ext
|
||||||
|
doc.is_image = bool(doc.thumbnail or ext in IMAGE_EXTENSIONS)
|
||||||
|
doc.is_pdf = ext == '.pdf'
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'intervention': intervention,
|
||||||
|
'notes': notes,
|
||||||
|
'documents': documents,
|
||||||
|
'notes_count': len(notes),
|
||||||
|
'documents_count': len(documents),
|
||||||
|
'total_count': len(notes) + len(documents),
|
||||||
|
}
|
||||||
|
|
||||||
|
html = render_to_string(
|
||||||
|
'interventions/partials/calendar_intervention_notes_docs.html',
|
||||||
|
context,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'success': True,
|
||||||
|
'notes_count': len(notes),
|
||||||
|
'documents_count': len(documents),
|
||||||
|
'total_count': len(notes) + len(documents),
|
||||||
|
'html': html,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue