fix(teams-planning): immediate day J assignment, sticky headers, past days greyed out and team distinction

This commit is contained in:
kdeterme 2026-09-02 11:29:29 +02:00
parent 7c8360bfc1
commit 11c81c8e00
4 changed files with 199 additions and 40 deletions

View file

@ -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:

View file

@ -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 = `
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1 text-center">
<div class="small text-uppercase text-muted" style="font-size:0.75rem;">${escapeHtml(day.day_short)}</div>
<div class="fw-bold fs-6">${escapeHtml(day.day_num)}</div>
<div class="small text-uppercase ${day.is_past ? 'text-muted opacity-75' : 'text-muted'}" style="font-size:0.75rem;">${escapeHtml(day.day_short)}</div>
<div class="fw-bold fs-6 ${day.is_past ? 'text-muted' : ''}">${escapeHtml(day.day_num)}</div>
${day.is_today ? `<span class="badge bg-primary text-white mt-1" style="font-size:0.65rem;">{% trans "Aujourd'hui"|escapejs %}</span>` : ''}
${day.is_past ? `<span class="badge bg-light text-muted border mt-1" style="font-size:0.62rem;"><i class="bi bi-clock-history me-1"></i>{% trans "Passé"|escapejs %}</span>` : ''}
</div>
${CAN_EDIT_TEAMS ? `
<div class="dropdown">
@ -1425,13 +1479,26 @@ function renderPlanningMatrix(data) {
teams.forEach(team => {
const tr = document.createElement('tr');
tr.className = 'planning-team-row';
const teamColor = getTeamColor(team.id);
// Colonne Équipe sticky gauche
const teamTd = document.createElement('td');
teamTd.className = 'sticky-team-col';
teamTd.innerHTML = `
<div class="fw-semibold text-truncate text-dark">${escapeHtml(team.name)}</div>
<div class="text-muted small text-truncate" style="font-size:0.75rem;">${escapeHtml(team.company_name)}</div>
<div class="d-flex align-items-center">
<div class="team-color-indicator" style="background-color: ${teamColor}; min-height: 38px; width: 4px; margin-right: 8px; border-radius: 2px;"></div>
<div class="overflow-hidden flex-grow-1">
<div class="fw-bold text-truncate text-dark d-flex align-items-center gap-1">
<i class="bi bi-people-fill" style="color: ${teamColor}; font-size: 0.95rem;"></i>
<span class="text-truncate">${escapeHtml(team.name)}</span>
</div>
<div class="text-muted small text-truncate mt-1" style="font-size:0.75rem;">
<span class="badge bg-light text-secondary border px-1 py-0"><i class="bi bi-building me-1"></i>${escapeHtml(team.company_name)}</span>
</div>
</div>
</div>
`;
tr.appendChild(teamTd);
@ -1441,14 +1508,18 @@ function renderPlanningMatrix(data) {
let cellClasses = ['planning-drop-cell'];
if (day.is_today) cellClasses.push('is-today-cell');
if (day.is_weekend) cellClasses.push('is-weekend-cell');
if (day.is_past) cellClasses.push('is-past-cell');
cellTd.className = cellClasses.join(' ');
cellTd.dataset.teamId = team.id;
cellTd.dataset.date = day.date;
if (day.is_past) {
cellTd.dataset.isPast = 'true';
}
const membersOnDay = (team.days && team.days[day.date]) ? team.days[day.date] : [];
membersOnDay.forEach(m => {
const card = createPlanningMemberCard(m, team.id, day.date, team.can_edit);
const card = createPlanningMemberCard(m, team.id, day.date, team.can_edit, day.is_past);
cellTd.appendChild(card);
});
@ -1461,9 +1532,11 @@ function renderPlanningMatrix(data) {
initPlanningDragAndDrop();
}
function createPlanningMemberCard(member, teamId, dateStr, canEdit) {
function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
const card = document.createElement('div');
card.className = `planning-member-card ${member.is_absent ? 'is-absent' : ''}`;
const absentClass = member.is_absent ? 'is-absent' : '';
const pastClass = isPast ? 'is-past-member' : '';
card.className = `planning-member-card ${absentClass} ${pastClass}`.trim();
card.dataset.memberId = member.id;
card.dataset.teamId = teamId;
card.dataset.date = dateStr;
@ -1478,12 +1551,9 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit) {
const titleAbsence = member.is_absent ? "{% trans "Modifier ou annuler l'absence"|escapejs %}" : "{% trans "Marquer absent"|escapejs %}";
const titleUnassign = "{% trans 'Désassigner de cette équipe'|escapejs %}";
card.innerHTML = `
<div class="planning-member-info" title="${escapeHtml(member.name)}">
<span class="member-name-text fw-semibold">${escapeHtml(member.name)}</span>
${absenceBadge}
</div>
${canEdit ? `
let actionsHtml = '';
if (canEdit && !isPast) {
actionsHtml = `
<div class="planning-card-actions">
<button type="button" class="btn btn-sm btn-link p-0 text-muted btn-absence-toggle" title="${titleAbsence}" onclick="openAbsenceModal(${member.id}, '${escapeHtml(member.name)}', '${dateStr}', ${member.is_absent ? 'true' : 'false'}, '${escapeHtml(member.absence?.reason || 'conge')}', '${escapeHtml(member.absence?.comment || '')}')">
<i class="bi ${member.is_absent ? 'bi-person-x-fill text-danger' : 'bi-person-check text-muted'}"></i>
@ -1491,7 +1561,20 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit) {
<button type="button" class="btn btn-sm btn-link p-0 text-muted" title="${titleUnassign}" onclick="unassignMemberFromDate(${member.id}, '${dateStr}')">
<i class="bi bi-x fs-6"></i>
</button>
</div>` : ''}
</div>`;
} else if (member.is_absent) {
actionsHtml = `
<div class="planning-card-actions">
<i class="bi bi-person-x-fill text-danger" title="${escapeHtml(member.absence?.comment || '')}"></i>
</div>`;
}
card.innerHTML = `
<div class="planning-member-info" title="${escapeHtml(member.name)}">
<span class="member-name-text fw-semibold">${escapeHtml(member.name)}</span>
${absenceBadge}
</div>
${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) {

View file

@ -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())

View file

@ -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):