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:
parent
261ce2dd14
commit
d45feb9f6e
9 changed files with 731 additions and 9 deletions
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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'])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue