feat: implement team daily members AJAX endpoint and add support for managing team composition and member history in calendar views

This commit is contained in:
kdeterme 2026-09-11 13:31:34 +02:00
parent 261ce2dd14
commit d45feb9f6e
9 changed files with 731 additions and 9 deletions

View file

@ -1107,6 +1107,99 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Modale : Historique d’un membre -->
<div class="modal fade" id="memberHistoryModal" tabindex="-1" aria-labelledby="memberHistoryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title d-flex align-items-center gap-2" id="memberHistoryModalLabel">
<i class="bi bi-person-badge text-primary fs-4"></i>
<span id="member-history-title-name">{% trans "Historique du membre" %}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div id="member-history-spinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% trans "Chargement..." %}</span>
</div>
</div>
<div id="member-history-content" class="d-none">
<!-- Header info card -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="p-3 bg-light rounded border">
<div class="small text-muted mb-1">{% trans "Technicien / Membre" %}</div>
<h5 class="mb-0 fw-bold text-primary" id="member-history-name-card"></h5>
</div>
</div>
<div class="col-md-6">
<div class="p-3 bg-light rounded border">
<div class="small text-muted mb-1">{% trans "Entreprise" %}</div>
<h5 class="mb-0 fw-bold" id="member-history-company-card"></h5>
</div>
</div>
</div>
<!-- Tables for Teams History and Interventions History -->
<div class="row g-4">
<!-- Team membership history -->
<div class="col-md-5">
<div class="card h-100 shadow-sm border">
<div class="card-header bg-white fw-bold d-flex align-items-center gap-2">
<i class="bi bi-people-fill text-primary"></i>
{% trans "Historique des équipes" %}
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% trans "Équipe" %}</th>
<th>{% trans "Entrée" %}</th>
<th>{% trans "Sortie" %}</th>
</tr>
</thead>
<tbody id="member-history-teams-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Interventions assignments history -->
<div class="col-md-7">
<div class="card h-100 shadow-sm border">
<div class="card-header bg-white fw-bold d-flex align-items-center gap-2">
<i class="bi bi-tools text-primary"></i>
{% trans "Dernières interventions réalisées / assignées" %}
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-hover align-middle mb-0">
<thead class="table-light sticky-top">
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "Titre" %}</th>
<th>{% trans "Équipe" %}</th>
<th>{% trans "Statut" %}</th>
</tr>
</thead>
<tbody id="member-history-interventions-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Fermer" %}</button>
</div>
</div>
</div>
</div> </div>
{% endblock content %} {% endblock content %}
@ -1118,6 +1211,7 @@
<script> <script>
const CSRF_TOKEN = '{{ csrf_token }}'; const CSRF_TOKEN = '{{ csrf_token }}';
const CAN_EDIT_TEAMS = {% if can_edit %}true{% else %}false{% endif %}; const CAN_EDIT_TEAMS = {% if can_edit %}true{% else %}false{% endif %};
const CAN_VIEW_MEMBER_HISTORY = {% if can_view_member_history %}true{% else %}false{% endif %};
function postJSON(url, data, onSuccess, onError) { function postJSON(url, data, onSuccess, onError) {
$.ajax({ $.ajax({
@ -2052,7 +2146,14 @@ function filterSidebarMembers() {
<div class="text-muted small text-truncate" style="font-size:0.72rem;">${escapeHtml(m.user_display || '')}</div> <div class="text-muted small text-truncate" style="font-size:0.72rem;">${escapeHtml(m.user_display || '')}</div>
</div> </div>
</div> </div>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
${CAN_VIEW_MEMBER_HISTORY ? `
<button type="button" class="btn btn-sm btn-link p-0 text-muted btn-sidebar-member-history" data-member-id="${m.id}" title="{% trans "Historique du technicien"|escapejs %}">
<i class="bi bi-clock-history"></i>
</button>
` : ''}
<i class="bi bi-grip-vertical text-muted"></i> <i class="bi bi-grip-vertical text-muted"></i>
</div>
`; `;
sidebarContainer.appendChild(card); sidebarContainer.appendChild(card);
@ -2368,5 +2469,90 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
} }
}); });
function openMemberHistoryModal(memberId) {
if (!memberId || !CAN_VIEW_MEMBER_HISTORY) return;
const modalEl = document.getElementById('memberHistoryModal');
if (!modalEl || typeof bootstrap === 'undefined') return;
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
const spinner = document.getElementById('member-history-spinner');
const content = document.getElementById('member-history-content');
const titleName = document.getElementById('member-history-title-name');
const cardName = document.getElementById('member-history-name-card');
const cardCompany = document.getElementById('member-history-company-card');
const teamsTbody = document.getElementById('member-history-teams-tbody');
const itvsTbody = document.getElementById('member-history-interventions-tbody');
if (spinner) spinner.classList.remove('d-none');
if (content) content.classList.add('d-none');
modal.show();
fetch(`/interventions/calendar/member/${memberId}/history/`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(res => {
if (!res.ok) throw new Error('Network error');
return res.json();
})
.then(data => {
if (titleName) titleName.textContent = `{% trans "Historique de"|escapejs %} ${data.member.name}`;
if (cardName) cardName.textContent = data.member.name;
if (cardCompany) cardCompany.textContent = data.member.company || '—';
if (teamsTbody) {
teamsTbody.innerHTML = (data.team_history && data.team_history.length > 0)
? data.team_history.map(h => `
<tr>
<td><span class="fw-semibold">${escapeHtml(h.team_name)}</span></td>
<td>${escapeHtml(h.join_date || '—')}</td>
<td>${h.is_active ? `<span class="badge text-bg-success">{% trans "En poste"|escapejs %}</span>` : escapeHtml(h.leave_date || '—')}</td>
</tr>
`).join('')
: `<tr><td colspan="3" class="text-center text-muted py-3">{% trans "Aucun historique d'équipe"|escapejs %}</td></tr>`;
}
if (itvsTbody) {
if (data.interventions && data.interventions.length > 0) {
let lastDate = null;
itvsTbody.innerHTML = data.interventions.map((itv, idx) => {
const isNewDate = itv.date !== lastDate;
lastDate = itv.date;
const borderStyle = (isNewDate && idx > 0) ? 'border-top: 2px solid #cbd5e1 !important;' : '';
return `
<tr style="${borderStyle}">
<td class="text-nowrap fw-semibold">${escapeHtml(itv.date)}</td>
<td><a href="${itv.detail_url}" target="_blank" class="fw-bold text-primary text-decoration-none">${escapeHtml(itv.code)}</a></td>
<td class="text-truncate" style="max-width: 180px;" title="${escapeHtml(itv.title)}">${escapeHtml(itv.title)}</td>
<td>${escapeHtml(itv.team_name)}</td>
<td><span class="badge bg-secondary">${escapeHtml(itv.status_display)}</span></td>
</tr>
`;
}).join('');
} else {
itvsTbody.innerHTML = `<tr><td colspan="5" class="text-center text-muted py-3">{% trans "Aucune intervention enregistrée"|escapejs %}</td></tr>`;
}
}
if (spinner) spinner.classList.add('d-none');
if (content) content.classList.remove('d-none');
})
.catch(err => {
console.error(err);
if (teamsTbody) teamsTbody.innerHTML = `<tr><td colspan="3" class="text-danger py-3 text-center">{% trans "Erreur de chargement"|escapejs %}</td></tr>`;
if (spinner) spinner.classList.add('d-none');
if (content) content.classList.remove('d-none');
});
}
$(document).on('click', '.btn-sidebar-member-history', function(e) {
e.preventDefault();
e.stopPropagation();
const memberId = $(this).data('member-id') || $(this).attr('data-member-id');
if (memberId) {
openMemberHistoryModal(memberId);
}
});
</script> </script>
{% endblock script %} {% endblock script %}

View file

@ -483,3 +483,13 @@ class TeamPlanningAjaxViewsTests(TestCase):
self.assertEqual(self.member_alice.teams.count(), 1) self.assertEqual(self.member_alice.teams.count(), 1)
self.assertEqual(self.member_alice.teams.first(), self.team_b) self.assertEqual(self.member_alice.teams.first(), self.team_b)
def test_can_view_member_history_in_teams_views(self):
resp = self.client.get(reverse('contracts:teams_index'))
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.context['can_view_member_history'])
data_resp = self.client.get(reverse('contracts:team_planning_data_ajax'))
self.assertEqual(data_resp.status_code, 200)
self.assertTrue(data_resp.json()['can_view_member_history'])

View file

@ -97,12 +97,17 @@ def teams_index(request):
unassigned_qs = unassigned_qs.filter(user__config__company=user_company) unassigned_qs = unassigned_qs.filter(user__config__company=user_company)
unassigned_members = unassigned_qs.order_by("name") unassigned_members = unassigned_qs.order_by("name")
user_config = getattr(request.user, 'config', None)
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
can_view_member_history = bool(user_roles & {'admin', 'manager', 'external_manager'}) or request.user.is_superuser
return render( return render(
request, request,
"contracts/teams_index.html", "contracts/teams_index.html",
{ {
"teams": teams, "teams": teams,
"can_edit": can_edit, "can_edit": can_edit,
"can_view_member_history": can_view_member_history,
"available_users": available_users, "available_users": available_users,
"unassigned_members": unassigned_members, "unassigned_members": unassigned_members,
"user_company": user_company, "user_company": user_company,
@ -564,6 +569,10 @@ def team_planning_data_ajax(request):
companies_list = [{'id': cid, 'name': cname} for cid, cname in sorted(companies_dict.items(), key=lambda x: x[1])] companies_list = [{'id': cid, 'name': cname} for cid, cname in sorted(companies_dict.items(), key=lambda x: x[1])]
user_config = getattr(request.user, 'config', None)
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
can_view_member_history = bool(user_roles & {'admin', 'manager', 'external_manager'}) or request.user.is_superuser
return JsonResponse({ return JsonResponse({
'days': days_list, 'days': days_list,
'teams': teams_data, 'teams': teams_data,
@ -574,6 +583,7 @@ def team_planning_data_ajax(request):
'anchor_date': anchor_date.isoformat(), 'anchor_date': anchor_date.isoformat(),
'view_mode': view_mode, 'view_mode': view_mode,
'can_edit': user_can_edit_teams(request.user), 'can_edit': user_can_edit_teams(request.user),
'can_view_member_history': can_view_member_history,
}) })

View file

@ -929,6 +929,13 @@
line-height: 1.35; line-height: 1.35;
border-left: 3px solid #cbd5e1; border-left: 3px solid #cbd5e1;
box-shadow: 0 1px 2px rgba(0,0,0,0.03); box-shadow: 0 1px 2px rgba(0,0,0,0.03);
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.team-members-list:hover {
background: #f1f5f9;
box-shadow: 0 2px 5px rgba(0,0,0,0.08);
} }
/* Stack of interventions */ /* Stack of interventions */

View file

@ -525,8 +525,11 @@ document.addEventListener('DOMContentLoaded', function () {
const dayData = team.days[day.date] || { members: [], interventions: [] }; const dayData = team.days[day.date] || { members: [], interventions: [] };
let membersHTML = ''; let membersHTML = '';
const isFutureDay = day.date > todayStr; const isPastDay = day.date < todayStr;
const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false; const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false;
const canEditThisTeam = (typeof canEditTeams !== 'undefined' && Boolean(canEditTeams)) && (team.can_edit !== false);
const titleTooltip = canEditThisTeam && !isPastDay ? gettext('Modifier la composition de l’équipe pour ce jour') : gettext('Composition de l’équipe');
if (dayData.members && dayData.members.length > 0) { if (dayData.members && dayData.members.length > 0) {
const formattedMembers = dayData.members.map(m => { const formattedMembers = dayData.members.map(m => {
const name = typeof m === 'object' && m ? m.name : m; const name = typeof m === 'object' && m ? m.name : m;
@ -537,9 +540,6 @@ document.addEventListener('DOMContentLoaded', function () {
const otherTeams = typeof m === 'object' && m && m.other_teams ? m.other_teams : []; const otherTeams = typeof m === 'object' && m && m.other_teams ? m.other_teams : [];
let memberNameHtml = escapeHtml(name); let memberNameHtml = escapeHtml(name);
if (isMemberHistoryAllowed && id) {
memberNameHtml = `<a href="#" class="member-history-link text-decoration-none ${isAbsent ? 'text-danger' : 'text-muted fw-semibold'}" data-member-id="${id}">${escapeHtml(name)}</a>`;
}
if (isMultiTeam) { if (isMultiTeam) {
const otherNames = otherTeams.map(t => t.name).join(', '); const otherNames = otherTeams.map(t => t.name).join(', ');
const tooltip = otherNames ? `${gettext('Planifié aussi dans')} : ${otherNames}` : gettext('Planifié dans plusieurs équipes ce jour'); const tooltip = otherNames ? `${gettext('Planifié aussi dans')} : ${otherNames}` : gettext('Planifié dans plusieurs équipes ce jour');
@ -550,11 +550,11 @@ document.addEventListener('DOMContentLoaded', function () {
} }
return memberNameHtml; return memberNameHtml;
}).join(', '); }).join(', ');
membersHTML = `<div class="team-members-list text-muted small fst-italic mb-2" style="border-left: 3px solid ${teamColor};" title="${gettext('Composition de l’équipe')}"> membersHTML = `<div class="team-members-list clickable-team-members text-muted small fst-italic mb-2" style="border-left: 3px solid ${teamColor}; cursor: pointer;" title="${escapeHtml(titleTooltip)}" data-team-id="${team.id}" data-date="${day.date}" data-team-name="${escapeHtml(team.name)}">
<i class="bi bi-people-fill me-1" style="color: ${teamColor};"></i>${formattedMembers} <i class="bi bi-people-fill me-1" style="color: ${teamColor};"></i>${formattedMembers}
</div>`; </div>`;
} else if (team.id !== null && !isFutureDay) { } else if (team.id !== null) {
membersHTML = `<div class="team-members-list text-muted small fst-italic mb-2 text-opacity-50" style="border-left: 3px solid #cbd5e1;"> membersHTML = `<div class="team-members-list clickable-team-members text-muted small fst-italic mb-2 text-opacity-50" style="border-left: 3px solid #cbd5e1; cursor: pointer;" title="${escapeHtml(titleTooltip)}" data-team-id="${team.id}" data-date="${day.date}" data-team-name="${escapeHtml(team.name)}">
<i class="bi bi-people me-1"></i>(${gettext('Aucun membre')}) <i class="bi bi-people me-1"></i>(${gettext('Aucun membre')})
</div>`; </div>`;
} }
@ -5285,3 +5285,271 @@ document.addEventListener('click', function(e) {
} }
} }
}); });
let _currentTeamDailyModalState = {
teamId: null,
dateStr: null,
teamName: '',
};
function openTeamDailyMembersModal(teamId, dateStr, teamName) {
if (!teamId || !dateStr) return;
const modalEl = document.getElementById('teamDailyMembersModal');
if (!modalEl || typeof bootstrap === 'undefined') return;
_currentTeamDailyModalState = { teamId, dateStr, teamName: teamName || '' };
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
const spinner = document.getElementById('team-daily-modal-spinner');
const content = document.getElementById('team-daily-modal-content');
const errAlert = document.getElementById('team-daily-error-alert');
const succAlert = document.getElementById('team-daily-success-alert');
if (errAlert) { errAlert.classList.add('d-none'); errAlert.textContent = ''; }
if (succAlert) { succAlert.classList.add('d-none'); succAlert.textContent = ''; }
if (spinner) spinner.classList.remove('d-none');
if (content) content.classList.add('d-none');
modal.show();
loadTeamDailyMembersModalData(teamId, dateStr);
}
function loadTeamDailyMembersModalData(teamId, dateStr) {
const spinner = document.getElementById('team-daily-modal-spinner');
const content = document.getElementById('team-daily-modal-content');
const teamNameEl = document.getElementById('team-daily-modal-team-name');
const companyEl = document.getElementById('team-daily-modal-company');
const dateDisplayEl = document.getElementById('team-daily-modal-date-display');
const pastAlert = document.getElementById('team-daily-past-alert');
const readonlyAlert = document.getElementById('team-daily-readonly-alert');
const membersListEl = document.getElementById('team-daily-members-list');
const emptyMembersEl = document.getElementById('team-daily-empty-members');
const membersCountEl = document.getElementById('team-daily-members-count');
const addSection = document.getElementById('team-daily-add-section');
const candidateSelect = document.getElementById('team-daily-candidate-select');
const baseUrl = typeof teamDailyMembersBaseUrl !== 'undefined' ? teamDailyMembersBaseUrl : '/interventions/calendar/team/';
const url = `${baseUrl}${teamId}/members/?date=${encodeURIComponent(dateStr)}`;
fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(res => {
if (!res.ok) throw new Error('Network error');
return res.json();
})
.then(data => {
if (teamNameEl) teamNameEl.textContent = data.team ? data.team.name : '';
if (companyEl) {
if (data.team && data.team.company) {
companyEl.textContent = data.team.company;
companyEl.classList.remove('d-none');
} else {
companyEl.classList.add('d-none');
}
}
if (dateDisplayEl) dateDisplayEl.textContent = data.date_display || data.date;
if (pastAlert) {
if (data.is_past) pastAlert.classList.remove('d-none');
else pastAlert.classList.add('d-none');
}
if (readonlyAlert) {
if (!data.can_edit && !data.is_past) readonlyAlert.classList.remove('d-none');
else readonlyAlert.classList.add('d-none');
}
// Render current members
const members = data.current_members || [];
if (membersCountEl) membersCountEl.textContent = members.length;
if (members.length === 0) {
if (membersListEl) { membersListEl.innerHTML = ''; membersListEl.classList.add('d-none'); }
if (emptyMembersEl) emptyMembersEl.classList.remove('d-none');
} else {
if (emptyMembersEl) emptyMembersEl.classList.add('d-none');
if (membersListEl) {
membersListEl.classList.remove('d-none');
membersListEl.innerHTML = members.map(m => {
const initials = m.name ? m.name.substring(0, 2).toUpperCase() : '??';
const isAbsent = Boolean(m.is_absent);
const absenceReason = m.absence_reason || '';
const isMultiTeam = Boolean(m.is_multi_team);
const otherNames = (m.other_teams || []).map(t => t.name).join(', ');
let badgesHtml = '';
if (isMultiTeam) {
const tooltip = otherNames ? `${gettext('Planifié aussi dans')} : ${otherNames}` : gettext('Planifié dans plusieurs équipes ce jour');
badgesHtml += `<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle ms-1" style="font-size:0.7rem;" title="${escapeHtml(tooltip)}"><i class="bi bi-diagram-3 me-1"></i>${gettext('Multi-équipes')}</span>`;
}
if (isAbsent) {
badgesHtml += `<span class="badge text-bg-danger ms-1" style="font-size:0.7rem;" title="${escapeHtml(absenceReason)}">${escapeHtml(absenceReason || gettext('Absent'))}</span>`;
}
const removeBtnHtml = data.can_edit ? `
<button type="button" class="btn btn-outline-danger btn-sm btn-team-daily-remove-member py-1 px-2" data-member-id="${m.id}" title="${gettext('Retirer de l’équipe pour ce jour')}">
<i class="bi bi-trash"></i>
</button>
` : '';
const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false;
const historyBtnHtml = isMemberHistoryAllowed ? `
<button type="button" class="btn btn-outline-secondary btn-sm py-1 px-2 member-history-link" data-member-id="${m.id}" title="${gettext('Historique du technicien')}">
<i class="bi bi-clock-history"></i>
</button>
` : '';
return `
<div class="list-group-item d-flex align-items-center justify-content-between py-2 px-3">
<div class="d-flex align-items-center gap-2 overflow-hidden">
<div class="rounded-circle bg-primary-subtle text-primary fw-bold d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; font-size: 0.8rem; flex-shrink: 0;">
${escapeHtml(initials)}
</div>
<div class="overflow-hidden">
<span class="fw-semibold text-dark ${isAbsent ? 'text-decoration-line-through opacity-75' : ''}">${escapeHtml(m.name)}</span>
${badgesHtml}
</div>
</div>
<div class="d-flex align-items-center gap-1">
${historyBtnHtml}
${removeBtnHtml}
</div>
</div>
`;
}).join('');
}
}
// Render add member candidate dropdown
if (data.can_edit) {
if (addSection) addSection.classList.remove('d-none');
if (candidateSelect) {
candidateSelect.innerHTML = `<option value="">${gettext('Sélectionner un membre...')}</option>`;
const available = data.available_members || [];
available.forEach(cand => {
const opt = document.createElement('option');
opt.value = cand.id;
opt.textContent = cand.name + (cand.user_display ? ` (${cand.user_display})` : '');
candidateSelect.appendChild(opt);
});
}
} else {
if (addSection) addSection.classList.add('d-none');
}
if (spinner) spinner.classList.add('d-none');
if (content) content.classList.remove('d-none');
})
.catch(err => {
console.error(err);
if (spinner) spinner.classList.add('d-none');
if (content) content.classList.remove('d-none');
const errAlert = document.getElementById('team-daily-error-alert');
if (errAlert) {
errAlert.textContent = gettext('Erreur lors du chargement des données.');
errAlert.classList.remove('d-none');
}
});
}
function assignMemberToDailyTeam(memberId, teamId, dateStr, action) {
const errAlert = document.getElementById('team-daily-error-alert');
const succAlert = document.getElementById('team-daily-success-alert');
if (errAlert) { errAlert.classList.add('d-none'); errAlert.textContent = ''; }
if (succAlert) { succAlert.classList.add('d-none'); succAlert.textContent = ''; }
const assignUrl = typeof teamPlanningAssignUrl !== 'undefined' ? teamPlanningAssignUrl : '/contracts/teams/planning/assign/';
const csrfToken = getCookie('csrftoken');
return fetch(assignUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
member_id: parseInt(memberId),
team_id: action === 'unassign' ? null : (teamId ? parseInt(teamId) : null),
date: dateStr,
action: action === 'unassign' ? 'move' : (action || 'add'),
unassign_team_id: action === 'unassign' ? parseInt(teamId) : null,
})
})
.then(res => {
if (!res.ok) {
return res.json().then(data => { throw new Error(data.error || 'Erreur serveur'); });
}
return res.json();
})
.then(data => {
if (succAlert) {
succAlert.textContent = action === 'unassign' ? gettext('Membre retiré avec succès.') : gettext('Membre ajouté avec succès.');
succAlert.classList.remove('d-none');
setTimeout(() => { if (succAlert) succAlert.classList.add('d-none'); }, 3000);
}
loadTeamDailyMembersModalData(teamId, dateStr);
if (typeof loadCalendarData === 'function') {
loadCalendarData();
}
})
.catch(err => {
console.error(err);
if (errAlert) {
errAlert.textContent = err.message || gettext('Une erreur est survenue.');
errAlert.classList.remove('d-none');
}
});
}
document.addEventListener('click', function(e) {
const btnAdd = e.target.closest('#btn-team-daily-add-member');
if (btnAdd) {
e.preventDefault();
const candidateSelect = document.getElementById('team-daily-candidate-select');
const memberId = candidateSelect ? candidateSelect.value : null;
if (!memberId) return;
const { teamId, dateStr } = _currentTeamDailyModalState;
if (teamId && dateStr) {
assignMemberToDailyTeam(memberId, teamId, dateStr, 'add');
}
return;
}
const btnRemove = e.target.closest('.btn-team-daily-remove-member');
if (btnRemove) {
e.preventDefault();
const memberId = btnRemove.getAttribute('data-member-id');
const { teamId, dateStr } = _currentTeamDailyModalState;
if (memberId && teamId && dateStr) {
assignMemberToDailyTeam(memberId, teamId, dateStr, 'unassign');
}
return;
}
const teamMembersList = e.target.closest('.clickable-team-members, .team-members-list');
if (teamMembersList) {
if (e.target.closest('.member-history-link, .btn-team-daily-remove-member, #btn-team-daily-add-member')) return;
const teamId = teamMembersList.getAttribute('data-team-id');
const dateStr = teamMembersList.getAttribute('data-date');
const teamName = teamMembersList.getAttribute('data-team-name');
if (teamId && dateStr) {
e.preventDefault();
e.stopPropagation();
openTeamDailyMembersModal(teamId, dateStr, teamName);
}
}
});
const _memberHistoryModalEl = document.getElementById('memberHistoryModal');
if (_memberHistoryModalEl) {
_memberHistoryModalEl.addEventListener('hidden.bs.modal', function () {
const teamModalEl = document.getElementById('teamDailyMembersModal');
if (teamModalEl && teamModalEl.classList.contains('show')) {
document.body.classList.add('modal-open');
}
});
}

View file

@ -745,6 +745,93 @@
</div> </div>
</div> </div>
<!-- Modal Bootstrap pour la modification de l'équipe pour un jour donné -->
<div class="modal fade" id="teamDailyMembersModal" tabindex="-1" aria-labelledby="teamDailyMembersModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title d-flex align-items-center gap-2" id="teamDailyMembersModalLabel">
<i class="bi bi-people-fill text-primary"></i>
<span id="team-daily-modal-title">{% translate "Composition de l'équipe" %}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<div id="team-daily-modal-spinner" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% translate "Chargement..." %}</span>
</div>
</div>
<div id="team-daily-modal-content" class="d-none">
<!-- En-tête d'information -->
<div class="d-flex align-items-center justify-content-between p-2 mb-3 bg-light rounded border">
<div>
<span class="fw-bold text-dark fs-6" id="team-daily-modal-team-name"></span>
<span class="badge bg-secondary-subtle text-secondary border ms-2" id="team-daily-modal-company"></span>
</div>
<div class="text-muted small fw-semibold" id="team-daily-modal-date-display"></div>
</div>
<!-- Alertes lecture seule -->
<div class="alert alert-warning py-2 small d-none" id="team-daily-past-alert">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
{% translate "Cette date est passée. La composition de l'équipe ne peut plus être modifiée." %}
</div>
<div class="alert alert-info py-2 small d-none" id="team-daily-readonly-alert">
<i class="bi bi-info-circle-fill me-1"></i>
{% translate "Vous n'avez pas les droits de modification sur cette équipe." %}
</div>
<!-- Feedback messages -->
<div class="alert alert-danger py-2 small d-none" id="team-daily-error-alert"></div>
<div class="alert alert-success py-2 small d-none" id="team-daily-success-alert"></div>
<!-- Liste des membres actuels -->
<div class="mb-4">
<h6 class="fw-semibold text-secondary mb-2 d-flex align-items-center gap-2">
<i class="bi bi-person-check-fill text-success"></i>
{% translate "Membres de l'équipe pour ce jour" %}
<span class="badge bg-secondary rounded-pill ms-1" id="team-daily-members-count">0</span>
</h6>
<div id="team-daily-members-list" class="list-group list-group-flush border rounded">
<!-- Injecté par JS -->
</div>
<div id="team-daily-empty-members" class="text-center text-muted py-3 border rounded bg-light d-none">
<i class="bi bi-people fs-4 d-block mb-1 text-secondary"></i>
{% translate "Aucun membre affecté à cette équipe ce jour-là." %}
</div>
</div>
<!-- Formulaire d'ajout de membre -->
<div id="team-daily-add-section" class="card border bg-light-subtle d-none">
<div class="card-body p-3">
<h6 class="fw-semibold text-dark mb-2">
<i class="bi bi-person-plus-fill text-primary me-1"></i>
{% translate "Ajouter un membre pour ce jour" %}
</h6>
<div class="row g-2 align-items-center">
<div class="col-sm-8">
<select class="form-select form-select-sm" id="team-daily-candidate-select">
<option value="">{% translate "Sélectionner un membre..." %}</option>
</select>
</div>
<div class="col-sm-4 d-grid">
<button type="button" class="btn btn-sm btn-primary" id="btn-team-daily-add-member">
<i class="bi bi-plus-lg me-1"></i>{% translate "Ajouter" %}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endblock content%} {% endblock content%}
{% block script %} {% block script %}
@ -755,6 +842,9 @@
const calendarGeojsonBaseUrl = "{% url 'interventions:interventions_geojson' %}"; const calendarGeojsonBaseUrl = "{% url 'interventions:interventions_geojson' %}";
const saveCalendarPreferencesUrl = "{% url 'common:save_calendar_preferences' %}"; const saveCalendarPreferencesUrl = "{% url 'common:save_calendar_preferences' %}";
const canViewMemberHistory = {{ can_view_member_history|yesno:"true,false" }}; const canViewMemberHistory = {{ can_view_member_history|yesno:"true,false" }};
const canEditTeams = {{ can_edit_teams|yesno:"true,false" }};
const teamDailyMembersBaseUrl = "{% url 'interventions:team_daily_members_ajax' 0 %}".replace('/0/', '/');
const teamPlanningAssignUrl = "{% url 'contracts:team_planning_assign_member_ajax' %}";
</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>

View file

@ -561,6 +561,62 @@ class CalendarEventsTests(TestCase):
self.assertIsNone(itv.assigned_team) self.assertIsNone(itv.assigned_team)
self.assertIsNone(itv.assigned_member) self.assertIsNone(itv.assigned_member)
def test_team_daily_members_ajax(self):
# 1. Unauthenticated request redirects to login
self.client.logout()
today = timezone.now().date()
url = reverse('interventions:team_daily_members_ajax', args=[self.team_a.id])
res = self.client.get(url, {'date': today.isoformat()})
self.assertEqual(res.status_code, 302)
# 2. Login as admin
self.client.login(username='calendar-user', password='pwd')
# 3. Missing date
res = self.client.get(url)
self.assertEqual(res.status_code, 400)
# 4. Invalid date
res = self.client.get(url, {'date': 'not-a-date'})
self.assertEqual(res.status_code, 400)
# 5. Success today
from contracts.services_teams import set_member_team_schedule
set_member_team_schedule(self.member, self.team_a.id, today, sync_current=True)
cand_user = get_user_model().objects.create_user(username='candidate-user', password='pwd')
UserConfig.objects.create(user=cand_user, company=self.company)
candidate_member = CompanyMember.objects.create(user=cand_user, name='Candidate Tech')
res = self.client.get(url, {'date': today.isoformat()})
self.assertEqual(res.status_code, 200)
data = res.json()
self.assertEqual(data['team']['id'], self.team_a.id)
self.assertEqual(data['team']['name'], self.team_a.name)
self.assertEqual(data['date'], today.isoformat())
self.assertFalse(data['is_past'])
self.assertTrue(data['can_edit'])
# Check current members has self.member
cur_ids = [m['id'] for m in data['current_members']]
self.assertIn(self.member.id, cur_ids)
# Check available members has candidate_member, but NOT self.member
avail_ids = [m['id'] for m in data['available_members']]
self.assertIn(candidate_member.id, avail_ids)
self.assertNotIn(self.member.id, avail_ids)
# 6. Past date -> is_past=True and can_edit=False
past_date = today - timedelta(days=5)
res_past = self.client.get(url, {'date': past_date.isoformat()})
self.assertEqual(res_past.status_code, 200)
data_past = res_past.json()
self.assertTrue(data_past['is_past'])
self.assertFalse(data_past['can_edit'])
self.assertEqual(len(data_past['available_members']), 0)

View file

@ -77,6 +77,7 @@ urlpatterns = [
path('schedule/update/', views.interventions_schedule_update, name='interventions_schedule_update'), path('schedule/update/', views.interventions_schedule_update, name='interventions_schedule_update'),
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('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'),

View file

@ -82,7 +82,9 @@ from interventions.utils import resolve_intervention_localisations
from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics from common.models import UserConfig, UserContractAccess, UserContractStatusPermission, Thematic, UserThematics
from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory, CompanyMemberAbsence from contracts.models import ContractOrder, ContractPost, Contract, Company, CompanyTeam, CompanyMember, UserCompanyTeamAccess, CompanyMemberHistory, CompanyMemberAbsence
from contracts.permissions import get_allowed_contracts_for_user, user_has_access_to_contract_order from contracts.permissions import (get_allowed_contracts_for_user, user_has_access_to_contract_order,
user_can_edit_teams, user_can_edit_specific_team, get_viewable_teams_for_user)
from contracts.services_teams import get_teams_composition_for_period, get_member_absences_for_period
from projects.models import Project, ProjectIntervention from projects.models import Project, ProjectIntervention
from assets.models import ( from assets.models import (
PublicLightingStreet, PublicLightingStreet,
@ -278,6 +280,7 @@ def interventions_calendar(request):
'selected_search': selected_search, 'selected_search': selected_search,
'can_use_member_view': can_use_member_view, 'can_use_member_view': can_use_member_view,
'can_view_member_history': bool(role_names & {'admin', 'manager', 'external_manager'}), 'can_view_member_history': bool(role_names & {'admin', 'manager', 'external_manager'}),
'can_edit_teams': user_can_edit_teams(request.user),
'can_move_interventions': can_move_interventions, 'can_move_interventions': can_move_interventions,
'is_manager': is_manager, 'is_manager': is_manager,
'asset_category_choices': asset_category_choices, 'asset_category_choices': asset_category_choices,
@ -1115,6 +1118,7 @@ def _interventions_events_by_teams_daily(
'id': team.id, 'id': team.id,
'name': team.name, 'name': team.name,
'company': team.company.name if team.company else '', 'company': team.company.name if team.company else '',
'can_edit': user_can_edit_specific_team(request.user, team.id),
'days': team_days 'days': team_days
}) })
@ -1630,3 +1634,93 @@ def member_history_ajax(request, member_id):
'interventions': interventions_list, 'interventions': interventions_list,
}) })
def team_daily_members_ajax(request, team_id):
"""
Renvoie la composition d'une équipe à une date donnée, ainsi que les
membres éligibles disponibles pour un éventuel ajout.
"""
if not request.user.is_authenticated:
return JsonResponse({'error': _("Authentification requise.")}, status=403)
date_str = request.GET.get('date')
if not date_str:
return JsonResponse({'error': _("Paramètre date requis.")}, status=400)
from django.utils.dateparse import parse_date
target_date = parse_date(date_str)
if not target_date:
return JsonResponse({'error': _("Date invalide.")}, status=400)
viewable_teams = get_viewable_teams_for_user(request.user)
team = get_object_or_404(CompanyTeam.objects.select_related('company'), id=team_id)
if not viewable_teams.filter(id=team.id).exists():
return JsonResponse({'error': _("Accès non autorisé à cette équipe.")}, status=403)
can_edit = user_can_edit_specific_team(request.user, team.id)
today = timezone.now().date()
is_past = target_date < today
# Composition de l'équipe à cette date
compositions = get_teams_composition_for_period([team.id], target_date, target_date)
raw_members = compositions.get((team.id, target_date), [])
member_ids = {m['id'] for m in raw_members}
absences = get_member_absences_for_period(member_ids, target_date, target_date)
current_members = []
for m in raw_members:
mid = m['id']
abs_info = absences.get((mid, target_date))
current_members.append({
'id': mid,
'name': m['name'],
'is_absent': abs_info is not None,
'absence_reason': abs_info.get('reason_display') if abs_info else '',
'is_multi_team': m.get('is_multi_team', False),
'other_teams': m.get('other_teams', []),
})
# Membres candidats disponibles pour l'ajout (excluant ceux déjà dans l'équipe ce jour-là)
available_members = []
if can_edit and not is_past:
user_config = getattr(request.user, 'config', None)
user_roles = set(user_config.roles.values_list('name', flat=True)) if user_config else set()
is_admin_user = bool(user_roles & {'admin', 'top_manager'}) or request.user.is_superuser
user_company = getattr(user_config, 'company', None) if user_config else None
candidates_qs = CompanyMember.objects.select_related('user', 'user__config').order_by('name')
if team.company_id:
candidates_qs = candidates_qs.filter(
Q(user__config__company_id=team.company_id) | Q(teams__company_id=team.company_id)
).distinct()
elif not is_admin_user and user_company is not None:
candidates_qs = candidates_qs.filter(user__config__company=user_company)
candidates_qs = candidates_qs.exclude(id__in=member_ids)
for cand in candidates_qs:
available_members.append({
'id': cand.id,
'name': cand.name,
'user_display': cand.user.get_full_name() or cand.user.username if cand.user else '',
})
from django.utils.dateformat import format as dj_format_date
date_display = dj_format_date(target_date, 'l d F Y')
return JsonResponse({
'team': {
'id': team.id,
'name': team.name,
'company': team.company.name if team.company else '',
},
'date': target_date.isoformat(),
'date_display': date_display,
'is_past': is_past,
'can_edit': can_edit and not is_past,
'current_members': current_members,
'available_members': available_members,
})