From 28696e733913f4c464e62c3389c734f28b2ff1a6 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Wed, 2 Sep 2026 11:59:12 +0200 Subject: [PATCH] refactor: optimize member history scheduling logic and implement localStorage persistence for team planning UI state --- loko/contracts/services_teams.py | 89 +++++++-------- .../templates/contracts/teams_index.html | 101 ++++++++++++++++-- loko/contracts/views_teams.py | 2 + 3 files changed, 130 insertions(+), 62 deletions(-) diff --git a/loko/contracts/services_teams.py b/loko/contracts/services_teams.py index 2f75876..cfddb79 100644 --- a/loko/contracts/services_teams.py +++ b/loko/contracts/services_teams.py @@ -52,64 +52,51 @@ def set_member_team_schedule( ) -> Optional[CompanyMemberHistory]: """ Planifie l'appartenance d'un membre à une équipe à partir de `target_date`. - Règle de persistance : le membre reste dans cette équipe à partir de `target_date` - jusqu'à une éventuelle réassignation future. - - Si team_id est None, le membre devient non assigné à partir de target_date. + - Nettoie tout enregistrement existant commençant à `target_date`. + - Clôture les historiques antérieurs la veille (`target_date - 1`). + - Crée le nouvel enregistrement pour `target_date` jusqu'au prochain jalon futur. + - Si team_id est None, le membre est désassigné pour target_date. """ today = timezone.now().date() - - # 1. Récupérer les enregistrements d'historique actifs ou futurs - active_histories = list( + + if target_date > today: + # 1. Date future : supprimer tout enregistrement existant débutant à target_date + CompanyMemberHistory.objects.filter( + member=member, + join_date=target_date, + ).delete() + else: + # 1. Aujourd'hui ou passé : clôturer l'enregistrement commençant à target_date + if team_id is None: + CompanyMemberHistory.objects.filter( + member=member, + join_date=target_date, + ).delete() + else: + CompanyMemberHistory.objects.filter( + member=member, + join_date=target_date, + ).exclude(team_id=team_id).update(leave_date=target_date) + + # 2. Clôturer les enregistrements antérieurs encore actifs à target_date + prev_leave = target_date - timedelta(days=1) + CompanyMemberHistory.objects.filter( + member=member, + join_date__lt=target_date, + ).filter( + Q(leave_date__isnull=True) | Q(leave_date__gte=target_date) + ).update(leave_date=prev_leave) + + # 3. Trouver le prochain jalon futur + next_hist = ( CompanyMemberHistory.objects - .filter(member=member) - .filter( - Q(leave_date__isnull=True) | Q(leave_date__gte=target_date) - ) + .filter(member=member, join_date__gt=target_date) .order_by('join_date') + .first() ) - - # Trouver l'historique actif à la date target_date - current_at_target = None - for h in active_histories: - if h.join_date <= target_date and (h.leave_date is None or h.leave_date >= target_date): - current_at_target = h - break - - # 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 and current_at_target.join_date == target_date: - return current_at_target - if not current_at_target and team_id is None: - return None - - # Trouver le prochain historique qui commence STRICTEMENT après target_date (s'il y en a) - next_hist = None - for h in active_histories: - if h.join_date > target_date: - if next_hist is None or h.join_date < next_hist.join_date: - next_hist = h - next_leave_bound = (next_hist.join_date - timedelta(days=1)) if next_hist else None - # 2. Clôturer ou ajuster l'historique courant (actif à target_date) - if current_at_target: - if current_at_target.join_date == target_date: - if target_date > today or team_id is None: - # Sur une date future ou lors d'une désassignation, on supprime directement l'affectation du jour - current_at_target.delete() - else: - current_at_target.leave_date = target_date - current_at_target.save(update_fields=['leave_date']) - else: - # L'historique commençait avant target_date : on le clôture la veille - prev_leave = target_date - timedelta(days=1) - current_at_target.leave_date = prev_leave - current_at_target.save(update_fields=['leave_date']) - else: - # Aucun historique actif à target_date (membre non assigné) - pass - - # 3. Créer le nouvel enregistrement s'il y a une nouvelle équipe + # 4. Créer le nouvel enregistrement s'il y a une équipe new_history = None if team_id is not None: new_history = CompanyMemberHistory.objects.create( diff --git a/loko/contracts/templates/contracts/teams_index.html b/loko/contracts/templates/contracts/teams_index.html index 26195c9..f4e0f33 100644 --- a/loko/contracts/templates/contracts/teams_index.html +++ b/loko/contracts/templates/contracts/teams_index.html @@ -1289,10 +1289,45 @@ function showError(elementId, message) { // ─────────────────────────────────────────────── // ONGLET 2 : MOTEUR DE PLANIFICATION DES ÉQUIPES // ─────────────────────────────────────────────── +const TEAMS_STORAGE_PREFIX = 'loko_teams_planning_'; + +function getStoredSetting(key, defaultVal) { + try { + const val = localStorage.getItem(TEAMS_STORAGE_PREFIX + key); + return val !== null ? val : defaultVal; + } catch (e) { + return defaultVal; + } +} + +function setStoredSetting(key, val) { + try { + if (val === null || val === undefined) { + localStorage.removeItem(TEAMS_STORAGE_PREFIX + key); + } else { + localStorage.setItem(TEAMS_STORAGE_PREFIX + key, val); + } + } catch (e) {} +} + +const initialDate = (function() { + const saved = getStoredSetting('anchor_date', ''); + if (saved && /^\d{4}-\d{2}-\d{2}$/.test(saved)) { + return saved; + } + return new Date().toISOString().split('T')[0]; +})(); + +const initialViewMode = (function() { + const saved = getStoredSetting('view_mode', 'week'); + if (['work_week', 'week', 'two_weeks'].includes(saved)) return saved; + return 'week'; +})(); + let _planningState = { - anchorDate: new Date().toISOString().split('T')[0], - viewMode: 'week', - companyId: '', + anchorDate: initialDate, + viewMode: initialViewMode, + companyId: getStoredSetting('company_id', ''), data: null, members: [], flatpickrInstance: null, @@ -1300,6 +1335,17 @@ let _planningState = { // Initialisation au chargement de la page document.addEventListener('DOMContentLoaded', function() { + // Restaurer le mode de vue sur les boutons + $('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary'); + $(`#planning-viewmode-group button[data-mode="${_planningState.viewMode}"]`).removeClass('btn-outline-primary').addClass('btn-primary'); + + // Restaurer les filtres de la sidebar + const savedSearch = getStoredSetting('search', ''); + if (savedSearch) $('#planning-member-search').val(savedSearch); + + const savedPresence = getStoredSetting('presence', 'all'); + if (savedPresence) $('#planning-presence-filter').val(savedPresence); + // Initialiser Flatpickr sur la toolbar _planningState.flatpickrInstance = flatpickr("#planning-datepicker", { defaultDate: _planningState.anchorDate, @@ -1307,6 +1353,7 @@ document.addEventListener('DOMContentLoaded', function() { onChange: function(selectedDates, dateStr) { if (dateStr) { _planningState.anchorDate = dateStr; + setStoredSetting('anchor_date', dateStr); loadPlanningData(); } } @@ -1321,6 +1368,7 @@ document.addEventListener('DOMContentLoaded', function() { }); $('#btn-planning-today').on('click', function() { _planningState.anchorDate = new Date().toISOString().split('T')[0]; + setStoredSetting('anchor_date', _planningState.anchorDate); _planningState.flatpickrInstance.setDate(_planningState.anchorDate); loadPlanningData(); }); @@ -1333,21 +1381,38 @@ document.addEventListener('DOMContentLoaded', 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); loadPlanningData(); }); // Filtre société $('#planning-company-filter').on('change', function() { _planningState.companyId = $(this).val(); + setStoredSetting('company_id', _planningState.companyId); loadPlanningData(); }); // Filtres du volet latéral (Sidebar) - $('#planning-member-search, #planning-role-filter, #planning-presence-filter').on('input change', function() { + $('#planning-member-search').on('input', function() { + setStoredSetting('search', $(this).val()); + filterSidebarMembers(); + }); + $('#planning-role-filter').on('change', function() { + setStoredSetting('role', $(this).val()); + filterSidebarMembers(); + }); + $('#planning-presence-filter').on('change', function() { + setStoredSetting('presence', $(this).val()); filterSidebarMembers(); }); - // Chargement automatique lors du premier clic sur l'onglet Planification + // Mémorisation de l'onglet actif + $('.teams-nav-tabs .nav-link').on('shown.bs.tab', function(e) { + const activeTabId = $(e.target).attr('id'); + setStoredSetting('active_tab', activeTabId); + }); + + // Chargement automatique lors de l'accès à l'onglet Planification let planningLoadedOnce = false; $('#tab-planning-btn').on('shown.bs.tab', function() { if (!planningLoadedOnce) { @@ -1356,6 +1421,15 @@ document.addEventListener('DOMContentLoaded', function() { } }); + // Restaurer le dernier onglet actif + const savedActiveTab = getStoredSetting('active_tab', 'tab-current-state-btn'); + if (savedActiveTab === 'tab-planning-btn') { + const tabEl = document.getElementById('tab-planning-btn'); + if (tabEl) { + bootstrap.Tab.getOrCreateInstance(tabEl).show(); + } + } + // Mode duplication modal switch $('input[name="dupMode"]').on('change', function() { if ($(this).val() === 'week') { @@ -1377,6 +1451,7 @@ function navigatePlanningDate(direction) { const step = _planningState.viewMode === 'two_weeks' ? 14 : 7; d.setDate(d.getDate() + (direction * step)); _planningState.anchorDate = d.toISOString().split('T')[0]; + setStoredSetting('anchor_date', _planningState.anchorDate); _planningState.flatpickrInstance.setDate(_planningState.anchorDate); loadPlanningData(); } @@ -1409,19 +1484,19 @@ function loadPlanningData() { }) .catch(err => { $('#planning-loading-spinner').addClass('d-none'); - alert("{% trans 'Erreur lors du chargement de la planification.' %}"); + alert("{% trans 'Erreur lors du chargement de la planification.'|escapejs %}"); }); } function updateCompaniesFilter(companies) { const sel = document.getElementById('planning-company-filter'); - const currentVal = sel.value; - sel.innerHTML = ''; + const currentVal = _planningState.companyId || sel.value; + sel.innerHTML = ''; companies.forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.textContent = c.name; - if (c.id == currentVal) opt.selected = true; + if (String(c.id) === String(currentVal)) opt.selected = true; sel.appendChild(opt); }); } @@ -1659,8 +1734,8 @@ function renderPlanningSidebar(data) { (m.roles || []).forEach(r => rolesSet.add(r)); }); - const currentRole = roleSelect.value; - roleSelect.innerHTML = ''; + const currentRole = roleSelect.value || getStoredSetting('role', ''); + roleSelect.innerHTML = ''; Array.from(rolesSet).sort().forEach(r => { const opt = document.createElement('option'); opt.value = r; @@ -1691,6 +1766,10 @@ function filterSidebarMembers() { // Filtre rôle if (selectedRole && !(m.roles || []).includes(selectedRole)) return; + // Filtre présence + if (presenceFilter === 'present' && m.is_absent_on_anchor) return; + if (presenceFilter === 'absent' && !m.is_absent_on_anchor) return; + visibleCount++; const initials = m.name ? m.name.substring(0, 2).toUpperCase() : '??'; diff --git a/loko/contracts/views_teams.py b/loko/contracts/views_teams.py index beb19f6..b32e403 100644 --- a/loko/contracts/views_teams.py +++ b/loko/contracts/views_teams.py @@ -502,6 +502,7 @@ 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} members_data = [] for m in all_members: roles = [] @@ -514,6 +515,7 @@ def team_planning_data_ajax(request): 'user_display': m.user.get_full_name() or m.user.username if m.user else '', 'roles': roles, 'company_ids': list(m.teams.values_list('company_id', flat=True)), + 'is_absent_on_anchor': m.id in absent_member_ids, }) # 8. Récupérer les sociétés pour le filtre de la toolbar