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