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>
<!-- 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>
{% endblock content %}
@ -1118,6 +1211,7 @@
<script>
const CSRF_TOKEN = '{{ csrf_token }}';
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) {
$.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>
</div>
<i class="bi bi-grip-vertical text-muted"></i>
<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>
</div>
`;
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>
{% 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.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_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(
request,
"contracts/teams_index.html",
{
"teams": teams,
"can_edit": can_edit,
"can_view_member_history": can_view_member_history,
"available_users": available_users,
"unassigned_members": unassigned_members,
"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])]
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({
'days': days_list,
'teams': teams_data,
@ -574,6 +583,7 @@ def team_planning_data_ajax(request):
'anchor_date': anchor_date.isoformat(),
'view_mode': view_mode,
'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;
border-left: 3px solid #cbd5e1;
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 */

View file

@ -525,8 +525,11 @@ document.addEventListener('DOMContentLoaded', function () {
const dayData = team.days[day.date] || { members: [], interventions: [] };
let membersHTML = '';
const isFutureDay = day.date > todayStr;
const isPastDay = day.date < todayStr;
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) {
const formattedMembers = dayData.members.map(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 : [];
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) {
const otherNames = otherTeams.map(t => t.name).join(', ');
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;
}).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}
</div>`;
} else if (team.id !== null && !isFutureDay) {
membersHTML = `<div class="team-members-list text-muted small fst-italic mb-2 text-opacity-50" style="border-left: 3px solid #cbd5e1;">
} else if (team.id !== null) {
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')})
</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>
<!-- 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%}
{% block script %}
@ -755,6 +842,9 @@
const calendarGeojsonBaseUrl = "{% url 'interventions:interventions_geojson' %}";
const saveCalendarPreferencesUrl = "{% url 'common:save_calendar_preferences' %}";
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 src="{% url 'javascript-catalog' %}"></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_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('calendar/', views.interventions_calendar, name='interventions_calendar'),
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('<str:thematic_code>/add/', views.interventions_add, name='intervention_add'),
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 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 assets.models import (
PublicLightingStreet,
@ -278,6 +280,7 @@ def interventions_calendar(request):
'selected_search': selected_search,
'can_use_member_view': can_use_member_view,
'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,
'is_manager': is_manager,
'asset_category_choices': asset_category_choices,
@ -1115,6 +1118,7 @@ def _interventions_events_by_teams_daily(
'id': team.id,
'name': team.name,
'company': team.company.name if team.company else '',
'can_edit': user_can_edit_specific_team(request.user, team.id),
'days': team_days
})
@ -1630,3 +1634,93 @@ def member_history_ajax(request, member_id):
'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,
})