refactor: optimize member history scheduling logic and implement localStorage persistence for team planning UI state

This commit is contained in:
kdeterme 2026-09-02 11:59:12 +02:00
parent efdafa43de
commit 28696e7339
3 changed files with 130 additions and 62 deletions

View file

@ -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(
CompanyMemberHistory.objects
.filter(member=member)
.filter(
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, 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(

View file

@ -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 = '<option value="">{% trans "Toutes les sociétés" %}</option>';
const currentVal = _planningState.companyId || sel.value;
sel.innerHTML = '<option value="">{% trans "Toutes les sociétés"|escapejs %}</option>';
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 = '<option value="">{% trans "Tous les rôles" %}</option>';
const currentRole = roleSelect.value || getStoredSetting('role', '');
roleSelect.innerHTML = '<option value="">{% trans "Tous les rôles"|escapejs %}</option>';
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() : '??';

View file

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