From 11c81c8e00f4d7cae8dd94714a1c76fc35eb9a23 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 2 Sep 2026 11:29:29 +0200 Subject: [PATCH] fix(teams-planning): immediate day J assignment, sticky headers, past days greyed out and team distinction --- loko/contracts/services_teams.py | 10 +- .../templates/contracts/teams_index.html | 157 +++++++++++++----- loko/contracts/test_planning.py | 59 +++++++ loko/contracts/views_teams.py | 13 ++ 4 files changed, 199 insertions(+), 40 deletions(-) diff --git a/loko/contracts/services_teams.py b/loko/contracts/services_teams.py index c52e2c1..d65227b 100644 --- a/loko/contracts/services_teams.py +++ b/loko/contracts/services_teams.py @@ -148,7 +148,7 @@ def get_teams_composition_for_period( ) -> Dict[tuple, List[Dict[str, Any]]]: """ Calcule la composition journalière de chaque équipe sur une période donnée [start_date, end_date]. - Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id, 'role': role}] + Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id}] """ histories = ( CompanyMemberHistory.objects @@ -167,6 +167,7 @@ def get_teams_composition_for_period( for h in histories: member_histories[h.member_id]['member'] = h.member member_histories[h.member_id]['histories'].append({ + 'id': h.id, 'team_id': h.team_id, 'join_date': h.join_date, 'leave_date': h.leave_date, @@ -188,9 +189,12 @@ def get_teams_composition_for_period( if not active: continue - # En cas de multiplicité, privilégier celui ayant join_date le plus récent + # En cas de multiplicité, privilégier un historique continuant (leave_date > d ou None) if len(active) > 1: - active.sort(key=lambda x: x['join_date'], reverse=True) + continuing = [h for h in active if h['leave_date'] is None or h['leave_date'] > d] + if continuing: + active = continuing + active.sort(key=lambda x: (x['join_date'], x.get('id', 0)), reverse=True) active = [active[0]] for h in active: diff --git a/loko/contracts/templates/contracts/teams_index.html b/loko/contracts/templates/contracts/teams_index.html index 8380df5..ba3ee7a 100644 --- a/loko/contracts/templates/contracts/teams_index.html +++ b/loko/contracts/templates/contracts/teams_index.html @@ -140,7 +140,7 @@ /* --- Onglet 2 : Planification / Calendrier des équipes --- */ .planning-toolbar { background: #ffffff; - border: 1px solid #e9ecef; + border: 1px solid #dee2e6; border-radius: 8px; padding: 10px 14px; margin-bottom: 16px; @@ -157,22 +157,24 @@ flex: 1 1 auto; min-width: 0; background: #ffffff; - border: 1px solid #e9ecef; + border: 1px solid #dee2e6; border-radius: 8px; - overflow-x: auto; - box-shadow: 0 1px 3px rgba(0,0,0,0.04); + overflow: auto; + max-height: calc(100vh - 170px); + box-shadow: 0 2px 6px rgba(0,0,0,0.04); + position: relative; } .planning-sidebar-wrapper { width: 300px; flex: 0 0 300px; background: #ffffff; - border: 1px solid #e9ecef; + border: 1px solid #dee2e6; border-radius: 8px; - box-shadow: 0 1px 3px rgba(0,0,0,0.04); + box-shadow: 0 2px 6px rgba(0,0,0,0.04); position: sticky; top: 20px; - max-height: calc(100vh - 140px); + max-height: calc(100vh - 170px); display: flex; flex-direction: column; } @@ -210,31 +212,48 @@ background: #f8f9fa; position: sticky; top: 0; - z-index: 10; + z-index: 20; padding: 10px 8px; text-align: center; font-weight: 600; min-width: 150px; + box-shadow: 0 2px 4px rgba(0,0,0,0.06); } .planning-table thead th.sticky-team-col { left: 0; - z-index: 20; - min-width: 200px; - max-width: 240px; + top: 0; + z-index: 30; + min-width: 230px; + max-width: 270px; text-align: left; - background: #f1f3f5; + background: #e9ecef; + box-shadow: 2px 2px 5px rgba(0,0,0,0.08); } .planning-table tbody td.sticky-team-col { position: sticky; left: 0; - z-index: 5; - background: #fdfdfd; - padding: 12px 10px; - box-shadow: 2px 0 4px rgba(0,0,0,0.02); + z-index: 10; + background: #ffffff; + padding: 10px 12px; + box-shadow: 2px 0 4px rgba(0,0,0,0.05); } + /* Différenciation des lignes d'équipes */ + .planning-team-row { + border-bottom: 2px solid #ced4da; + } + + .planning-team-row:nth-child(even) td.sticky-team-col { + background-color: #f8fafd; + } + + .planning-team-row:nth-child(even) td:not(.is-today-cell):not(.is-past-cell):not(.is-weekend-cell) { + background-color: #fafcff; + } + + /* En-têtes et cellules de jours */ .planning-table th.is-today-header { background: linear-gradient(180deg, #e7f1ff 0%, #d0e2ff 100%); color: #0b5ed7; @@ -242,11 +261,11 @@ } .planning-table td.is-today-cell { - background-color: #f8fbff; + background-color: #f3f8ff !important; } .planning-table th.is-weekend-header { - background: #fafafa; + background: #f8f9fa; color: #8c98a4; } @@ -254,6 +273,18 @@ background: #fcfcfc; } + /* Jours passés grisés */ + .planning-table th.is-past-header { + background: #e9ecef; + color: #6c757d; + } + + .planning-table td.is-past-cell { + background-color: #f3f4f6 !important; + background-image: repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(0,0,0,0.02) 10px, rgba(0,0,0,0.02) 20px); + cursor: not-allowed; + } + /* Cellules de dépôt */ .planning-drop-cell { min-height: 90px; @@ -305,6 +336,19 @@ color: #842029; } + /* Membre sur jour passé */ + .planning-member-card.is-past-member { + cursor: default; + opacity: 0.85; + background: #fdfdfd; + border-left-color: #6c757d; + } + + .planning-member-card.is-past-member:hover { + transform: none; + box-shadow: 0 1px 2px rgba(0,0,0,0.04); + } + .planning-member-info { flex: 1 1 auto; overflow: hidden; @@ -1376,6 +1420,14 @@ function updateCompaniesFilter(companies) { }); } +const TEAM_PALETTE = [ + '#0d6efd', '#6610f2', '#6f42c1', '#d63384', '#fd7e14', + '#198754', '#20c997', '#0dcaf0', '#495057', '#e83e8c' +]; +function getTeamColor(teamId) { + return TEAM_PALETTE[Math.abs(teamId || 0) % TEAM_PALETTE.length]; +} + function renderPlanningMatrix(data) { const days = data.days || []; const teams = data.teams || []; @@ -1389,14 +1441,16 @@ function renderPlanningMatrix(data) { let headerClasses = []; if (day.is_today) headerClasses.push('is-today-header'); if (day.is_weekend) headerClasses.push('is-weekend-header'); + if (day.is_past) headerClasses.push('is-past-header'); th.className = headerClasses.join(' '); th.innerHTML = `
-
${escapeHtml(day.day_short)}
-
${escapeHtml(day.day_num)}
+
${escapeHtml(day.day_short)}
+
${escapeHtml(day.day_num)}
${day.is_today ? `{% trans "Aujourd'hui"|escapejs %}` : ''} + ${day.is_past ? `{% trans "Passé"|escapejs %}` : ''}
${CAN_EDIT_TEAMS ? ` `; + } else if (member.is_absent) { + actionsHtml = ` +
+ +
`; + } + + card.innerHTML = ` +
+ ${escapeHtml(member.name)} + ${absenceBadge} +
+ ${actionsHtml} `; return card; @@ -1500,8 +1583,8 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit) { function initPlanningDragAndDrop() { if (!CAN_EDIT_TEAMS) return; - // Cartes de membres dans la grille draggables - $('.planning-member-card').draggable({ + // Cartes de membres dans la grille draggables (uniquement sur les jours présents ou futurs) + $('.planning-member-card:not(.is-past-member)').draggable({ revert: 'invalid', cursor: 'grabbing', helper: 'clone', @@ -1524,9 +1607,9 @@ function initPlanningDragAndDrop() { } }); - // Cellules de dépôt - $('.planning-drop-cell').droppable({ - accept: '.planning-member-card, .sidebar-member-item', + // Cellules de dépôt (uniquement sur les jours présents ou futurs) + $('.planning-drop-cell:not(.is-past-cell)').droppable({ + accept: '.planning-member-card:not(.is-past-member), .sidebar-member-item', hoverClass: 'drag-over', tolerance: 'pointer', drop: function(event, ui) { diff --git a/loko/contracts/test_planning.py b/loko/contracts/test_planning.py index ad0cb56..17e0714 100644 --- a/loko/contracts/test_planning.py +++ b/loko/contracts/test_planning.py @@ -202,3 +202,62 @@ class TeamPlanningAjaxViewsTests(TestCase): today = timezone.now().date() self.assertEqual(get_member_team_for_date(self.member_alice, today), self.team_a) + def test_move_member_takes_effect_on_day_j(self): + """Vérifie que déplacer un membre le jour J s'applique immédiatement le jour J dans les compositions.""" + today = timezone.now().date() + day_j = today + timedelta(days=2) + day_next = day_j + timedelta(days=1) + + # 1. Alice est dans Team A depuis day_j + set_member_team_schedule(self.member_alice, self.team_a.id, day_j) + comp = get_teams_composition_for_period({self.team_a.id, self.team_b.id}, day_j, day_next) + team_a_day_j_members = [m['id'] for m in comp.get((self.team_a.id, day_j), [])] + self.assertIn(self.member_alice.id, team_a_day_j_members) + + # 2. On déplace Alice vers Team B pour le jour J + set_member_team_schedule(self.member_alice, self.team_b.id, day_j) + + # 3. Vérifier que sur le jour J, Alice est bien dans Team B (et plus dans Team A) + comp_after = get_teams_composition_for_period({self.team_a.id, self.team_b.id}, day_j, day_next) + team_a_day_j_after = [m['id'] for m in comp_after.get((self.team_a.id, day_j), [])] + team_b_day_j_after = [m['id'] for m in comp_after.get((self.team_b.id, day_j), [])] + team_b_day_next_after = [m['id'] for m in comp_after.get((self.team_b.id, day_next), [])] + + self.assertNotIn(self.member_alice.id, team_a_day_j_after) + self.assertIn(self.member_alice.id, team_b_day_j_after) + self.assertIn(self.member_alice.id, team_b_day_next_after) + + def test_planning_past_date_is_forbidden(self): + """Vérifie que les endpoints AJAX rejettent les modifications sur des dates passées.""" + past_date = (timezone.now().date() - timedelta(days=2)).isoformat() + + # 1. Assign + url = reverse('contracts:team_planning_assign_member_ajax') + resp = self.client.post(url, data=json.dumps({ + 'member_id': self.member_alice.id, + 'team_id': self.team_a.id, + 'date': past_date, + }), content_type='application/json') + self.assertEqual(resp.status_code, 400) + self.assertIn('passé', resp.json().get('error', '').lower()) + + # 2. Absence + url_abs = reverse('contracts:team_planning_toggle_absence_ajax') + resp_abs = self.client.post(url_abs, data=json.dumps({ + 'member_id': self.member_alice.id, + 'date': past_date, + 'is_absent': True, + }), content_type='application/json') + self.assertEqual(resp_abs.status_code, 400) + self.assertIn('passé', resp_abs.json().get('error', '').lower()) + + # 3. Duplicate target in past + url_dup = reverse('contracts:team_planning_duplicate_ajax') + resp_dup = self.client.post(url_dup, data=json.dumps({ + 'mode': 'day', + 'source_date': timezone.now().date().isoformat(), + 'target_date': past_date, + }), content_type='application/json') + self.assertEqual(resp_dup.status_code, 400) + self.assertIn('passé', resp_dup.json().get('error', '').lower()) + diff --git a/loko/contracts/views_teams.py b/loko/contracts/views_teams.py index be20ded..beb19f6 100644 --- a/loko/contracts/views_teams.py +++ b/loko/contracts/views_teams.py @@ -438,6 +438,7 @@ def team_planning_data_ajax(request): 'day_num': f"{d.day:02d}/{d.month:02d}", 'formatted': f"{DAYS_FR_SHORT[w_idx]} {d.day:02d} {MONTHS_FR[d.month]}", 'is_today': d == today, + 'is_past': d < today, 'is_weekend': w_idx >= 5, }) @@ -559,6 +560,10 @@ def team_planning_assign_member_ajax(request): if not target_date: return JsonResponse({"error": "Date invalide."}, status=400) + today = timezone.now().date() + if target_date < today: + return JsonResponse({"error": "Impossible de modifier la planification d'un jour passé."}, status=400) + member = get_object_or_404(CompanyMember, id=member_id) viewable = get_viewable_teams_for_user(request.user) @@ -618,6 +623,10 @@ def team_planning_toggle_absence_ajax(request): if not target_date: return JsonResponse({"error": "Date invalide."}, status=400) + today = timezone.now().date() + if target_date < today: + return JsonResponse({"error": "Impossible de modifier les absences pour un jour passé."}, status=400) + member = get_object_or_404(CompanyMember, id=member_id) absence_obj = toggle_member_absence( @@ -669,6 +678,10 @@ def team_planning_duplicate_ajax(request): if not source_date or not target_date: return JsonResponse({"error": "Format de date invalide."}, status=400) + today = timezone.now().date() + if target_date < today: + return JsonResponse({"error": "La date cible ne peut pas être dans le passé."}, status=400) + # Récupérer les équipes que l'utilisateur a le droit d'éditer viewable = get_viewable_teams_for_user(request.user) if not _is_admin(request.user):