fix(teams-planning): immediate day J assignment, sticky headers, past days greyed out and team distinction
This commit is contained in:
parent
7c8360bfc1
commit
11c81c8e00
4 changed files with 199 additions and 40 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue