refactor: optimize member history scheduling logic and implement localStorage persistence for team planning UI state
This commit is contained in:
parent
efdafa43de
commit
28696e7339
3 changed files with 130 additions and 62 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() : '??';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue