fix: restrict future planning visibility to explicit assignments and update team index UI styles.

This commit is contained in:
kdeterme 2026-09-02 11:38:51 +02:00
parent 11c81c8e00
commit 71b445797d
3 changed files with 46 additions and 22 deletions

View file

@ -32,12 +32,14 @@ def get_member_team_for_date(member: CompanyMember, target_date: date) -> Option
) )
if not histories: if not histories:
return None return None
# Si un historique est ouvert (leave_date IS NULL) ou se termine strictement après target_date, # Parmi les historiques ayant la date de début la plus récente (join_date maximal)
# on le préfère à un historique se terminant exactement à target_date (transfert en cours de journée) max_join = histories[0].join_date
continuing = [h for h in histories if h.leave_date is None or h.leave_date > target_date] candidates = [h for h in histories if h.join_date == max_join]
if len(candidates) > 1:
continuing = [h for h in candidates if h.leave_date is None or h.leave_date > target_date]
if continuing: if continuing:
return continuing[0].team return continuing[0].team
return histories[0].team return candidates[0].team
@transaction.atomic @transaction.atomic
@ -75,7 +77,7 @@ def set_member_team_schedule(
break break
# Si le membre est déjà dans la même équipe à cette date exacte, rien à faire # Si le membre est déjà dans la même équipe à cette date exacte, rien à faire
if current_at_target and current_at_target.team_id == team_id: if current_at_target and current_at_target.team_id == team_id and current_at_target.join_date == target_date:
return current_at_target return current_at_target
if not current_at_target and team_id is None: if not current_at_target and team_id is None:
return None return None
@ -148,8 +150,11 @@ def get_teams_composition_for_period(
) -> Dict[tuple, List[Dict[str, Any]]]: ) -> Dict[tuple, List[Dict[str, Any]]]:
""" """
Calcule la composition journalière de chaque équipe sur une période donnée [start_date, end_date]. Calcule la composition journalière de chaque équipe sur une période donnée [start_date, end_date].
- Pour aujourd'hui et les jours passés : utilise les affectations actives courantes et historiques.
- Pour les jours futurs : n'affiche que les journées qui ont été explicitement planifiées ou dupliquées (join_date == d).
Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id}] Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id}]
""" """
today = timezone.now().date()
histories = ( histories = (
CompanyMemberHistory.objects CompanyMemberHistory.objects
.filter( .filter(
@ -183,8 +188,13 @@ def get_teams_composition_for_period(
# Trouver les historiques actifs à la date d # Trouver les historiques actifs à la date d
active = [] active = []
for h in m_data['histories']: for h in m_data['histories']:
if d <= today:
if h['join_date'] <= d and (h['leave_date'] is None or h['leave_date'] >= d): if h['join_date'] <= d and (h['leave_date'] is None or h['leave_date'] >= d):
active.append(h) active.append(h)
else:
# Pour les jours futurs : affiché uniquement si explicitement planifié pour ce jour (join_date == d)
if h['join_date'] == d:
active.append(h)
if not active: if not active:
continue continue

View file

@ -203,8 +203,8 @@
.planning-table th, .planning-table th,
.planning-table td { .planning-table td {
border-right: 1px solid #e9ecef; border-right: 1px solid #cbd5e1;
border-bottom: 1px solid #e9ecef; border-bottom: 1px solid #cbd5e1;
vertical-align: top; vertical-align: top;
} }
@ -218,6 +218,7 @@
font-weight: 600; font-weight: 600;
min-width: 150px; min-width: 150px;
box-shadow: 0 2px 4px rgba(0,0,0,0.06); box-shadow: 0 2px 4px rgba(0,0,0,0.06);
border-bottom: 2px solid #64748b;
} }
.planning-table thead th.sticky-team-col { .planning-table thead th.sticky-team-col {
@ -227,8 +228,10 @@
min-width: 230px; min-width: 230px;
max-width: 270px; max-width: 270px;
text-align: left; text-align: left;
background: #e9ecef; background: #e2e8f0;
box-shadow: 2px 2px 5px rgba(0,0,0,0.08); box-shadow: 2px 2px 5px rgba(0,0,0,0.08);
border-right: 2px solid #94a3b8;
border-bottom: 2px solid #64748b;
} }
.planning-table tbody td.sticky-team-col { .planning-table tbody td.sticky-team-col {
@ -238,19 +241,22 @@
background: #ffffff; background: #ffffff;
padding: 10px 12px; padding: 10px 12px;
box-shadow: 2px 0 4px rgba(0,0,0,0.05); box-shadow: 2px 0 4px rgba(0,0,0,0.05);
border-right: 2px solid #94a3b8;
} }
/* Différenciation des lignes d'équipes */ /* Différenciation très marquée des lignes d'équipes */
.planning-team-row { .planning-table tbody tr.planning-team-row td {
border-bottom: 2px solid #ced4da; border-bottom: 3px solid #64748b !important;
padding-top: 10px;
padding-bottom: 12px;
} }
.planning-team-row:nth-child(even) td.sticky-team-col { .planning-table tbody tr.planning-team-row:nth-child(even) td.sticky-team-col {
background-color: #f8fafd; background-color: #f1f5f9;
} }
.planning-team-row:nth-child(even) td:not(.is-today-cell):not(.is-past-cell):not(.is-weekend-cell) { .planning-table tbody tr.planning-team-row:nth-child(even) td.planning-drop-cell:not(.is-today-cell):not(.is-past-cell):not(.is-weekend-cell) {
background-color: #fafcff; background-color: #f8fafc;
} }
/* En-têtes et cellules de jours */ /* En-têtes et cellules de jours */
@ -1542,6 +1548,11 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
card.dataset.date = dateStr; card.dataset.date = dateStr;
card.dataset.memberName = member.name; card.dataset.memberName = member.name;
const teamColor = getTeamColor(teamId);
if (!member.is_absent && !isPast) {
card.style.borderLeftColor = teamColor;
}
let absenceBadge = ''; let absenceBadge = '';
if (member.is_absent) { if (member.is_absent) {
const reasonDisplay = (member.absence && member.absence.reason_display) ? member.absence.reason_display : "{% trans 'Absent'|escapejs %}"; const reasonDisplay = (member.absence && member.absence.reason_display) ? member.absence.reason_display : "{% trans 'Absent'|escapejs %}";

View file

@ -203,12 +203,12 @@ class TeamPlanningAjaxViewsTests(TestCase):
self.assertEqual(get_member_team_for_date(self.member_alice, today), self.team_a) self.assertEqual(get_member_team_for_date(self.member_alice, today), self.team_a)
def test_move_member_takes_effect_on_day_j(self): 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.""" """Vérifie que déplacer un membre le jour J s'applique immédiatement le jour J dans les compositions, sans pré-remplir les jours futurs non planifiés."""
today = timezone.now().date() today = timezone.now().date()
day_j = today + timedelta(days=2) day_j = today + timedelta(days=2)
day_next = day_j + timedelta(days=1) day_next = day_j + timedelta(days=1)
# 1. Alice est dans Team A depuis day_j # 1. Alice est dans Team A pour day_j
set_member_team_schedule(self.member_alice, self.team_a.id, 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) 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), [])] team_a_day_j_members = [m['id'] for m in comp.get((self.team_a.id, day_j), [])]
@ -225,7 +225,10 @@ class TeamPlanningAjaxViewsTests(TestCase):
self.assertNotIn(self.member_alice.id, team_a_day_j_after) 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_j_after)
self.assertIn(self.member_alice.id, team_b_day_next_after) # Le jour suivant n'ayant pas été planifié, il ne doit pas être pré-rempli dans la matrice de planification
self.assertNotIn(self.member_alice.id, team_b_day_next_after)
# Mais get_member_team_for_date assure la persistance théorique pour les interventions
self.assertEqual(get_member_team_for_date(self.member_alice, day_next), self.team_b)
def test_planning_past_date_is_forbidden(self): def test_planning_past_date_is_forbidden(self):
"""Vérifie que les endpoints AJAX rejettent les modifications sur des dates passées.""" """Vérifie que les endpoints AJAX rejettent les modifications sur des dates passées."""