diff --git a/loko/contracts/templates/contracts/teams_index.html b/loko/contracts/templates/contracts/teams_index.html index fcf9e1e..8b36a02 100644 --- a/loko/contracts/templates/contracts/teams_index.html +++ b/loko/contracts/templates/contracts/teams_index.html @@ -1107,6 +1107,99 @@ + + {% endblock content %} @@ -1118,6 +1211,7 @@ {% endblock script %} diff --git a/loko/contracts/test_planning.py b/loko/contracts/test_planning.py index 0a99be5..13f064e 100644 --- a/loko/contracts/test_planning.py +++ b/loko/contracts/test_planning.py @@ -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']) + + diff --git a/loko/contracts/views_teams.py b/loko/contracts/views_teams.py index a0bbfce..14349cb 100644 --- a/loko/contracts/views_teams.py +++ b/loko/contracts/views_teams.py @@ -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, }) diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.css b/loko/interventions/static/interventions/calendar/interventions_calendar.css index f2c89fc..4f9ec51 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.css +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.css @@ -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 */ diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.js b/loko/interventions/static/interventions/calendar/interventions_calendar.js index 6b5a008..33f06e6 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.js +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.js @@ -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 = `${escapeHtml(name)}`; - } 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 = `
+ membersHTML = `
${formattedMembers}
`; - } else if (team.id !== null && !isFutureDay) { - membersHTML = `
+ } else if (team.id !== null) { + membersHTML = `
(${gettext('Aucun membre')})
`; } @@ -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 += `${gettext('Multi-équipes')}`; + } + if (isAbsent) { + badgesHtml += `${escapeHtml(absenceReason || gettext('Absent'))}`; + } + + const removeBtnHtml = data.can_edit ? ` + + ` : ''; + + const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false; + const historyBtnHtml = isMemberHistoryAllowed ? ` + + ` : ''; + + return ` +
+
+
+ ${escapeHtml(initials)} +
+
+ ${escapeHtml(m.name)} + ${badgesHtml} +
+
+
+ ${historyBtnHtml} + ${removeBtnHtml} +
+
+ `; + }).join(''); + } + } + + // Render add member candidate dropdown + if (data.can_edit) { + if (addSection) addSection.classList.remove('d-none'); + if (candidateSelect) { + candidateSelect.innerHTML = ``; + 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'); + } + }); +} + diff --git a/loko/interventions/templates/interventions/interventions_calendar.html b/loko/interventions/templates/interventions/interventions_calendar.html index beb5967..d5ca284 100644 --- a/loko/interventions/templates/interventions/interventions_calendar.html +++ b/loko/interventions/templates/interventions/interventions_calendar.html @@ -745,6 +745,93 @@
+ + + {% 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' %}"; diff --git a/loko/interventions/tests/test_calendar.py b/loko/interventions/tests/test_calendar.py index 2aa3606..6a413f1 100644 --- a/loko/interventions/tests/test_calendar.py +++ b/loko/interventions/tests/test_calendar.py @@ -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) + + diff --git a/loko/interventions/urls.py b/loko/interventions/urls.py index 50529a6..6ec96c1 100644 --- a/loko/interventions/urls.py +++ b/loko/interventions/urls.py @@ -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//history/', views.member_history_ajax, name='member_history_ajax'), + path('calendar/team//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/', views.interventions_add, name='intervention_add'), path('/add-mobile/', views.interventions_add, name='intervention_add_mobile_form'), diff --git a/loko/interventions/views/calendar.py b/loko/interventions/views/calendar.py index 83410fd..89bdb17 100644 --- a/loko/interventions/views/calendar.py +++ b/loko/interventions/views/calendar.py @@ -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, + }) +