diff --git a/loko/contracts/templates/contracts/teams_index.html b/loko/contracts/templates/contracts/teams_index.html
index 392e4e8..efa6b82 100644
--- a/loko/contracts/templates/contracts/teams_index.html
+++ b/loko/contracts/templates/contracts/teams_index.html
@@ -727,9 +727,8 @@
{% trans "Vue :" %}
-
-
-
+
+
@@ -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 = `{% trans "Aujourd'hui"|escapejs %}`;
+ 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 = `${todayBadgeText}`;
} else if (day.is_past) {
badgeHtml = `{% trans "Passé"|escapejs %}`;
}
@@ -2048,6 +2086,11 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
multiTeamBadge = `{% trans "Multi-équipes"|escapejs %}`;
}
+ let durationBadge = '';
+ if (member.days_count && member.total_days && member.days_count < member.total_days) {
+ durationBadge = `${member.days_count}j`;
+ }
+
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) {
${escapeHtml(member.name)}
${multiTeamBadge}
+ ${durationBadge}
${absenceBadge}
${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,
diff --git a/loko/contracts/test_planning.py b/loko/contracts/test_planning.py
index d96dc46..45ee94a 100644
--- a/loko/contracts/test_planning.py
+++ b/loko/contracts/test_planning.py
@@ -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."""
diff --git a/loko/contracts/views_teams.py b/loko/contracts/views_teams.py
index 78866f5..e02acec 100644
--- a/loko/contracts/views_teams.py
+++ b/loko/contracts/views_teams.py
@@ -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 = []