From 40ce03d8127cff553c508c37c0fcaa394c2ee032 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 25 Sep 2026 09:16:35 +0200 Subject: [PATCH] feat: add rolling horizons and aggregated column support to team planning view modes --- .../templates/contracts/teams_index.html | 133 ++++++++++-- loko/contracts/test_planning.py | 17 +- loko/contracts/views_teams.py | 190 +++++++++++++----- 3 files changed, 270 insertions(+), 70 deletions(-) diff --git a/loko/contracts/templates/contracts/teams_index.html b/loko/contracts/templates/contracts/teams_index.html index 392e4e8..efa6b82 100644 --- a/loko/contracts/templates/contracts/teams_index.html +++ b/loko/contracts/templates/contracts/teams_index.html @@ -727,9 +727,8 @@ {% trans "Vue :" %}
- - - + +
@@ -1720,12 +1719,30 @@ document.addEventListener('DOMContentLoaded', function() { loadPlanningData(); }); - // Changement de mode de vue (5j / 7j / 14j / mois) + // Changement de mode de vue (5j / Semaine / Mois) $('#planning-viewmode-group button').on('click', function() { $('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary'); $(this).removeClass('btn-outline-primary').addClass('btn-primary'); _planningState.viewMode = $(this).data('mode'); setStoredSetting('view_mode', _planningState.viewMode); + + if (_planningState.viewMode === 'month') { + _planningState.assignScope = 'month'; + setStoredSetting('assign_scope', 'month'); + $('#planning-assign-scope-group button').removeClass('btn-primary').addClass('btn-outline-primary'); + $('#planning-assign-scope-group button[data-scope="month"]').removeClass('btn-outline-primary').addClass('btn-primary'); + } else if (_planningState.viewMode === 'week') { + _planningState.assignScope = 'week'; + setStoredSetting('assign_scope', 'week'); + $('#planning-assign-scope-group button').removeClass('btn-primary').addClass('btn-outline-primary'); + $('#planning-assign-scope-group button[data-scope="week"]').removeClass('btn-outline-primary').addClass('btn-primary'); + } else if (_planningState.viewMode === 'work_week') { + _planningState.assignScope = 'day'; + setStoredSetting('assign_scope', 'day'); + $('#planning-assign-scope-group button').removeClass('btn-primary').addClass('btn-outline-primary'); + $('#planning-assign-scope-group button[data-scope="day"]').removeClass('btn-outline-primary').addClass('btn-primary'); + } + loadPlanningData(); }); @@ -1828,11 +1845,16 @@ function navigatePlanningDate(direction) { if (_planningState.viewMode === 'month') { d.setDate(1); d.setMonth(d.getMonth() + direction); + } else if (_planningState.viewMode === 'week') { + d.setDate(d.getDate() + (direction * 7)); } else { const step = _planningState.viewMode === 'two_weeks' ? 14 : 7; d.setDate(d.getDate() + (direction * step)); } - _planningState.anchorDate = d.toISOString().split('T')[0]; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const dayNum = String(d.getDate()).padStart(2, '0'); + _planningState.anchorDate = `${y}-${m}-${dayNum}`; setStoredSetting('anchor_date', _planningState.anchorDate); _planningState.flatpickrInstance.setDate(_planningState.anchorDate); loadPlanningData(); @@ -1907,19 +1929,35 @@ function renderPlanningMatrix(data) { if (day.is_past) headerClasses.push('is-past-header'); th.className = headerClasses.join(' '); - const dParts = day.date.split('-'); - const dateObj = new Date(parseInt(dParts[0], 10), parseInt(dParts[1], 10) - 1, parseInt(dParts[2], 10)); - const isFriday = dateObj.getDay() === 5; + let quickDupTitle = "{% trans 'Dupliquer vers le jour suivant (avec absences)'|escapejs %}"; + let optionsTitle = "{% trans 'Options de la journée'|escapejs %}"; + let dupCustomTitle = "{% trans 'Dupliquer cette journée…'|escapejs %}"; - const quickDupTitle = (_planningState.viewMode === 'work_week' && isFriday) - ? "{% trans 'Dupliquer vers le lundi suivant (avec absences)'|escapejs %}" - : "{% trans 'Dupliquer vers le jour suivant (avec absences)'|escapejs %}"; - const optionsTitle = "{% trans 'Options de la journée'|escapejs %}"; - const dupCustomTitle = "{% trans 'Dupliquer cette journée…'|escapejs %}"; + if (_planningState.viewMode === 'week') { + quickDupTitle = "{% trans 'Dupliquer vers la semaine suivante (avec absences)'|escapejs %}"; + optionsTitle = "{% trans 'Options de la semaine'|escapejs %}"; + dupCustomTitle = "{% trans 'Dupliquer cette semaine…'|escapejs %}"; + } else if (_planningState.viewMode === 'month') { + quickDupTitle = "{% trans 'Dupliquer vers le mois suivant (avec absences)'|escapejs %}"; + optionsTitle = "{% trans 'Options du mois'|escapejs %}"; + dupCustomTitle = "{% trans 'Dupliquer ce mois…'|escapejs %}"; + } else if (_planningState.viewMode === 'work_week') { + const dParts = day.date.split('-'); + const dateObj = new Date(parseInt(dParts[0], 10), parseInt(dParts[1], 10) - 1, parseInt(dParts[2], 10)); + if (dateObj.getDay() === 5) { + quickDupTitle = "{% trans 'Dupliquer vers le lundi suivant (avec absences)'|escapejs %}"; + } + } let badgeHtml = ''; if (day.is_today) { - badgeHtml = `{% trans "Aujourd'hui"|escapejs %}`; + let todayBadgeText = "{% trans "Aujourd'hui"|escapejs %}"; + if (_planningState.viewMode === 'week') { + todayBadgeText = "{% trans "En cours"|escapejs %}"; + } else if (_planningState.viewMode === 'month') { + todayBadgeText = "{% trans "En cours"|escapejs %}"; + } + badgeHtml = `${todayBadgeText}`; } else if (day.is_past) { badgeHtml = `{% trans "Passé"|escapejs %}`; } @@ -2048,6 +2086,11 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) { multiTeamBadge = `{% trans "Multi-équipes"|escapejs %}`; } + let durationBadge = ''; + if (member.days_count && member.total_days && member.days_count < member.total_days) { + durationBadge = `${member.days_count}j`; + } + 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 %}"; @@ -2073,6 +2116,7 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
${escapeHtml(member.name)} ${multiTeamBadge} + ${durationBadge} ${absenceBadge}
${actionsHtml} @@ -2084,6 +2128,7 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) { let _pendingPlanningAssign = null; function executePlanningAssign(memberId, targetTeamId, targetDate, action) { + const scope = _planningState.assignScope || (_planningState.viewMode === 'month' ? 'month' : (_planningState.viewMode === 'week' ? 'week' : 'day')); postJSON( '/contracts/teams/planning/assign/', { @@ -2091,7 +2136,7 @@ function executePlanningAssign(memberId, targetTeamId, targetDate, action) { team_id: parseInt(targetTeamId), date: targetDate, action: action || 'move', - scope: _planningState.assignScope || 'day', + scope: scope, }, function(resp) { loadPlanningData(); @@ -2279,6 +2324,7 @@ function filterSidebarMembers() { } function unassignMemberFromDate(memberId, dateStr, teamId) { + const scope = _planningState.assignScope || (_planningState.viewMode === 'month' ? 'month' : (_planningState.viewMode === 'week' ? 'week' : 'day')); postJSON( '/contracts/teams/planning/assign/', { @@ -2286,7 +2332,7 @@ function unassignMemberFromDate(memberId, dateStr, teamId) { team_id: null, date: dateStr, unassign_team_id: teamId ? parseInt(teamId) : null, - scope: _planningState.assignScope || 'day', + scope: scope, }, function(resp) { loadPlanningData(); @@ -2404,19 +2450,64 @@ function openDuplicateModal() { function openDuplicateModalForDay(dateStr) { openDuplicateModal(); - $('#dupSourceDate').val(dateStr); - const targetDateStr = getTargetDuplicateDate(dateStr, _planningState.viewMode); - $('#dupTargetDate').val(targetDateStr); + if (_planningState.viewMode === 'month') { + $('input[name="dupMode"][value="month"]').prop('checked', true).trigger('change'); + const monthVal = dateStr.slice(0, 7); + $('#dupSourceMonth').val(monthVal); + const parts = monthVal.split('-'); + const d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, 1); + d.setMonth(d.getMonth() + 1); + const nextMonthVal = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + $('#dupTargetMonth').val(nextMonthVal); + } else if (_planningState.viewMode === 'week') { + $('input[name="dupMode"][value="week"]').prop('checked', true).trigger('change'); + $('#dupSourceWeek').val(dateStr); + const parts = dateStr.split('-'); + const d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)); + d.setDate(d.getDate() + 7); + const nextYear = d.getFullYear(); + const nextMonth = String(d.getMonth() + 1).padStart(2, '0'); + const nextDay = String(d.getDate()).padStart(2, '0'); + $('#dupTargetWeek').val(`${nextYear}-${nextMonth}-${nextDay}`); + } else { + $('input[name="dupMode"][value="day"]').prop('checked', true).trigger('change'); + $('#dupSourceDate').val(dateStr); + const targetDateStr = getTargetDuplicateDate(dateStr, _planningState.viewMode); + $('#dupTargetDate').val(targetDateStr); + } } function quickDuplicateToNextDay(sourceDateStr) { - const targetDateStr = getTargetDuplicateDate(sourceDateStr, _planningState.viewMode); + let mode = 'day'; + let targetDateStr; + + if (_planningState.viewMode === 'week') { + mode = 'week'; + const parts = sourceDateStr.split('-'); + const d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)); + d.setDate(d.getDate() + 7); + const nextYear = d.getFullYear(); + const nextMonth = String(d.getMonth() + 1).padStart(2, '0'); + const nextDay = String(d.getDate()).padStart(2, '0'); + targetDateStr = `${nextYear}-${nextMonth}-${nextDay}`; + } else if (_planningState.viewMode === 'month') { + mode = 'month'; + const parts = sourceDateStr.split('-'); + const d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, 1); + d.setMonth(d.getMonth() + 1); + const nextYear = d.getFullYear(); + const nextMonth = String(d.getMonth() + 1).padStart(2, '0'); + targetDateStr = `${nextYear}-${nextMonth}-01`; + } else { + targetDateStr = getTargetDuplicateDate(sourceDateStr, _planningState.viewMode); + } + if (!targetDateStr) return; postJSON( '/contracts/teams/planning/duplicate/', { - mode: 'day', + mode: mode, source_date: sourceDateStr, target_date: targetDateStr, copy_absences: true, diff --git a/loko/contracts/test_planning.py b/loko/contracts/test_planning.py index d96dc46..45ee94a 100644 --- a/loko/contracts/test_planning.py +++ b/loko/contracts/test_planning.py @@ -238,7 +238,8 @@ class TeamPlanningAjaxViewsTests(TestCase): self.assertEqual(response.status_code, 200) data = response.json() self.assertIn('days', data) - self.assertEqual(len(data['days']), 7) + self.assertEqual(len(data['days']), 5) + self.assertEqual(data['days'][0]['col_type'], 'week') self.assertIn('teams', data) self.assertIn('members', data) @@ -607,9 +608,19 @@ class TeamPlanningAjaxViewsTests(TestCase): response = self.client.get(url, {'date': '2026-09-15', 'view_mode': 'month'}) self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(len(data['days']), 30) # Septembre compte 30 jours + self.assertEqual(len(data['days']), 6) # Horizon glissant de 6 mois + self.assertEqual(data['days'][0]['col_type'], 'month') self.assertEqual(data['days'][0]['date'], '2026-09-01') - self.assertEqual(data['days'][-1]['date'], '2026-09-30') + self.assertEqual(data['days'][-1]['date'], '2027-02-01') + + def test_planning_data_ajax_work_week_view(self): + """Tester le retour du mode 'work_week' (5 jours ouvrés) dans team_planning_data_ajax.""" + url = reverse('contracts:team_planning_data_ajax') + response = self.client.get(url, {'date': '2026-09-02', 'view_mode': 'work_week'}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data['days']), 5) + self.assertEqual(data['days'][0]['col_type'], 'day') def test_planning_assign_member_ajax_week_scope(self): """Tester l'assignation avec portée 'week' via l'API AJAX.""" diff --git a/loko/contracts/views_teams.py b/loko/contracts/views_teams.py index 78866f5..e02acec 100644 --- a/loko/contracts/views_teams.py +++ b/loko/contracts/views_teams.py @@ -450,46 +450,98 @@ def team_planning_data_ajax(request): view_mode = request.GET.get('view_mode', 'week') monday = anchor_date - timedelta(days=anchor_date.weekday()) + today = timezone.now().date() + + DAYS_FR_SHORT = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'] + DAYS_FR_FULL = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'] + MONTHS_FR = ['', 'Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] + MONTHS_FR_FULL = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'] + + days_list = [] if view_mode == 'work_week': num_days = 5 start_date = monday end_date = monday + timedelta(days=4) + for i in range(num_days): + d = start_date + timedelta(days=i) + w_idx = d.weekday() + days_list.append({ + 'date': d.isoformat(), + 'day_name': DAYS_FR_FULL[w_idx], + 'day_short': DAYS_FR_SHORT[w_idx], + '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': False, + 'col_type': 'day', + }) elif view_mode == 'two_weeks': num_days = 14 start_date = monday end_date = monday + timedelta(days=13) + for i in range(num_days): + d = start_date + timedelta(days=i) + w_idx = d.weekday() + days_list.append({ + 'date': d.isoformat(), + 'day_name': DAYS_FR_FULL[w_idx], + 'day_short': DAYS_FR_SHORT[w_idx], + '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, + 'col_type': 'day', + }) elif view_mode == 'month': - _, days_in_month = calendar.monthrange(anchor_date.year, anchor_date.month) - num_days = days_in_month + # Horizon glissant de 6 mois start_date = date(anchor_date.year, anchor_date.month, 1) - end_date = date(anchor_date.year, anchor_date.month, days_in_month) - else: # 'week' (default) - num_days = 7 + end_date = start_date + for i in range(6): + m_year = anchor_date.year + (anchor_date.month - 1 + i) // 12 + m_month = (anchor_date.month - 1 + i) % 12 + 1 + _, days_in_m = calendar.monthrange(m_year, m_month) + m_start = date(m_year, m_month, 1) + m_end = date(m_year, m_month, days_in_m) + if i == 5: + end_date = m_end + is_current_month = (m_start <= today <= m_end) + is_past_month = (m_end < today) + days_list.append({ + 'date': m_start.isoformat(), + 'end_date': m_end.isoformat(), + 'day_name': f"{MONTHS_FR_FULL[m_month]} {m_year}", + 'day_short': MONTHS_FR[m_month], + 'day_num': str(m_year), + 'formatted': f"{MONTHS_FR_FULL[m_month]} {m_year}", + 'is_today': is_current_month, + 'is_past': is_past_month, + 'is_weekend': False, + 'col_type': 'month', + }) + else: # 'week' (défaut) : Horizon glissant de 5 semaines start_date = monday - end_date = monday + timedelta(days=6) - - today = timezone.now().date() - - # Construire la liste des jours - DAYS_FR_SHORT = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'] - DAYS_FR_FULL = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'] - MONTHS_FR = ['', 'Janv', 'Févr', 'Mars', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sept', 'Oct', 'Nov', 'Déc'] - - days_list = [] - for i in range(num_days): - d = start_date + timedelta(days=i) - w_idx = d.weekday() - days_list.append({ - 'date': d.isoformat(), - 'day_name': DAYS_FR_FULL[w_idx], - 'day_short': DAYS_FR_SHORT[w_idx], - '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, - }) + end_date = monday + timedelta(days=5 * 7 - 1) + for i in range(5): + w_start = monday + timedelta(weeks=i) + w_end = w_start + timedelta(days=6) + iso_year, iso_week, _ = w_start.isocalendar() + is_current_week = (w_start <= today <= w_end) + is_past_week = (w_end < today) + days_list.append({ + 'date': w_start.isoformat(), + 'end_date': w_end.isoformat(), + 'day_name': f"Semaine {iso_week}", + 'day_short': f"S{iso_week}", + 'day_num': f"{w_start.day:02d}/{w_start.month:02d} - {w_end.day:02d}/{w_end.month:02d}", + 'formatted': f"Semaine {iso_week} ({w_start.day:02d} {MONTHS_FR[w_start.month]} - {w_end.day:02d} {MONTHS_FR[w_end.month]})", + 'is_today': is_current_week, + 'is_past': is_past_week, + 'is_weekend': False, + 'col_type': 'week', + }) # 2. Récupérer les équipes autorisées teams_qs = get_viewable_teams_for_user(request.user).select_related('company') @@ -525,23 +577,61 @@ def team_planning_data_ajax(request): for t in teams: can_edit_this = user_can_edit_specific_team(request.user, t.id) days_dict = {} - for i in range(num_days): - d = start_date + timedelta(days=i) - d_str = d.isoformat() - members_on_day = [] - for m in compositions.get((t.id, d), []): - m_id = m['id'] - abs_info = absences.get((m_id, d)) - members_on_day.append({ - 'id': m_id, - 'name': m['name'], - 'user_id': m.get('user_id'), - 'is_absent': abs_info is not None, - 'absence': abs_info, - 'is_multi_team': m.get('is_multi_team', False), - 'other_teams': m.get('other_teams', []), - }) - days_dict[d_str] = members_on_day + + if view_mode in ('week', 'month'): + for col in days_list: + col_start = parse_date(col['date']) + col_end = parse_date(col['end_date']) + num_col_days = (col_end - col_start).days + 1 + + members_map = {} + for day_idx in range(num_col_days): + d = col_start + timedelta(days=day_idx) + for m in compositions.get((t.id, d), []): + m_id = m['id'] + if m_id not in members_map: + members_map[m_id] = { + 'id': m_id, + 'name': m['name'], + 'user_id': m.get('user_id'), + 'is_absent': False, + 'absence': None, + 'is_multi_team': m.get('is_multi_team', False), + 'other_teams': [], + 'days_count': 0, + 'total_days': num_col_days, + } + members_map[m_id]['days_count'] += 1 + if m.get('is_multi_team'): + members_map[m_id]['is_multi_team'] = True + for ot in m.get('other_teams', []): + if not any(x['id'] == ot['id'] for x in members_map[m_id]['other_teams']): + members_map[m_id]['other_teams'].append(ot) + + abs_info = absences.get((m_id, d)) + if abs_info is not None: + members_map[m_id]['is_absent'] = True + members_map[m_id]['absence'] = abs_info + + days_dict[col['date']] = sorted(members_map.values(), key=lambda x: x['name'].lower()) + else: + for day_info in days_list: + d = parse_date(day_info['date']) + d_str = day_info['date'] + members_on_day = [] + for m in compositions.get((t.id, d), []): + m_id = m['id'] + abs_info = absences.get((m_id, d)) + members_on_day.append({ + 'id': m_id, + 'name': m['name'], + 'user_id': m.get('user_id'), + 'is_absent': abs_info is not None, + 'absence': abs_info, + 'is_multi_team': m.get('is_multi_team', False), + 'other_teams': m.get('other_teams', []), + }) + days_dict[d_str] = members_on_day teams_data.append({ 'id': t.id, @@ -553,7 +643,15 @@ def team_planning_data_ajax(request): }) # 7. Structurer les membres pour le volet latéral (Sidebar) - absent_member_ids = {mid for (mid, d) in absences.keys() if d == anchor_date} + if view_mode == 'week': + absent_member_ids = {mid for (mid, d) in absences.keys() if monday <= d <= monday + timedelta(days=6)} + elif view_mode == 'month': + _, d_in_m = calendar.monthrange(anchor_date.year, anchor_date.month) + m_s = date(anchor_date.year, anchor_date.month, 1) + m_e = date(anchor_date.year, anchor_date.month, d_in_m) + absent_member_ids = {mid for (mid, d) in absences.keys() if m_s <= d <= m_e} + else: + absent_member_ids = {mid for (mid, d) in absences.keys() if d == anchor_date} members_data = [] for m in all_members: roles = []