feat: add rolling horizons and aggregated column support to team planning view modes

This commit is contained in:
kdeterme 2026-09-25 09:16:35 +02:00
parent 096886a194
commit 40ce03d812
3 changed files with 270 additions and 70 deletions

View file

@ -727,9 +727,8 @@
<span class="text-muted small text-nowrap"><i class="bi bi-eye me-1"></i>{% trans "Vue :" %}</span>
<div class="btn-group btn-group-sm" role="group" id="planning-viewmode-group">
<button type="button" class="btn btn-outline-primary" data-mode="work_week">{% trans "5j (Ouvré)" %}</button>
<button type="button" class="btn btn-primary" data-mode="week">{% trans "1 Semaine (7j)" %}</button>
<button type="button" class="btn btn-outline-primary" data-mode="two_weeks">{% trans "2 Semaines" %}</button>
<button type="button" class="btn btn-outline-primary" data-mode="month">{% trans "1 Mois" %}</button>
<button type="button" class="btn btn-primary" data-mode="week">{% trans "Semaine" %}</button>
<button type="button" class="btn btn-outline-primary" data-mode="month">{% trans "Mois" %}</button>
</div>
</div>
@ -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 = `<span class="badge bg-primary text-white" style="font-size:0.65rem; padding: 2px 6px;">{% trans "Aujourd'hui"|escapejs %}</span>`;
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 = `<span class="badge bg-primary text-white" style="font-size:0.65rem; padding: 2px 6px;">${todayBadgeText}</span>`;
} else if (day.is_past) {
badgeHtml = `<span class="badge bg-light text-muted border" style="font-size:0.62rem; padding: 2px 5px;"><i class="bi bi-clock-history me-1"></i>{% trans "Passé"|escapejs %}</span>`;
}
@ -2048,6 +2086,11 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
multiTeamBadge = `<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-1 py-0 me-1" style="font-size:0.65rem;" title="${multiTitle}"><i class="bi bi-diagram-3 me-1"></i>{% trans "Multi-équipes"|escapejs %}</span>`;
}
let durationBadge = '';
if (member.days_count && member.total_days && member.days_count < member.total_days) {
durationBadge = `<span class="badge bg-secondary-subtle text-secondary border px-1 py-0 me-1" style="font-size:0.65rem;" title="${member.days_count} {% trans 'jours sur'|escapejs %} ${member.total_days}">${member.days_count}j</span>`;
}
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) {
<div class="planning-member-info" title="${escapeHtml(member.name)}">
<span class="member-name-text fw-semibold">${escapeHtml(member.name)}</span>
${multiTeamBadge}
${durationBadge}
${absenceBadge}
</div>
${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,

View file

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

View file

@ -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 = []