From 6ee0aa470b8b6fe95642563a9e410abbda479b18 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 5 Oct 2026 09:42:10 +0200 Subject: [PATCH] feat(calendar): enable drag and drop for team members in daily view - Render team members as individual draggable chips in daily teams calendar view - Decouple click (modal inspection) from drag and drop (team/date reassignment) - Add multi-team conflict confirmation modal on drop - Maintain permission checks and past date protection --- .../calendar/interventions_calendar.css | 75 +++- .../calendar/interventions_calendar.js | 332 ++++++++++++++---- .../interventions/interventions_calendar.html | 38 ++ 3 files changed, 363 insertions(+), 82 deletions(-) diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.css b/loko/interventions/static/interventions/calendar/interventions_calendar.css index e273468..4ca66c8 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.css +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.css @@ -938,19 +938,86 @@ line-height: 1.35; border-left: 3px solid #cbd5e1; box-shadow: 0 1px 2px rgba(0,0,0,0.03); - cursor: pointer; transition: background-color 0.15s ease, box-shadow 0.15s ease; } +.team-members-list.clickable-team-members { + cursor: pointer; +} + +.team-members-list:hover { + background: #f1f5f9; + box-shadow: 0 2px 5px rgba(0,0,0,0.08); +} + .team-members-list strong, .team-members-list .member-name { font-weight: 700; color: #0f172a; } -.team-members-list:hover { - background: #f1f5f9; - box-shadow: 0 2px 5px rgba(0,0,0,0.08); +/* Member chips in Teams Daily View */ +.team-daily-members-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.team-daily-member-chip { + display: inline-flex; + align-items: center; + background-color: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 2px 6px; + font-size: 0.76rem; + font-weight: 600; + color: #1e293b; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: all 0.15s ease; + user-select: none; + -webkit-user-select: none; + max-width: 100%; +} + +.team-daily-member-chip.draggable { + cursor: grab; +} + +.team-daily-member-chip.draggable:hover { + background-color: #f8fafc; + border-color: #0d6efd; + box-shadow: 0 2px 6px rgba(13, 110, 253, 0.2); + transform: translateY(-1px); +} + +.team-daily-member-chip.draggable:active { + cursor: grabbing; +} + +.team-daily-member-chip.is-dragging { + opacity: 0.45; + border: 1px dashed #0d6efd; + background-color: #e7f1ff; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); +} + +.team-daily-member-chip.is-absent { + background-color: #fff5f5; + border-color: #f8d7da; +} + +.team-daily-member-chip.is-absent .member-name-text { + text-decoration: line-through; + color: #dc3545; + opacity: 0.8; +} + +.team-daily-member-chip .member-name-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } /* Stack of interventions */ diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.js b/loko/interventions/static/interventions/calendar/interventions_calendar.js index d9dfcb1..a970e18 100644 --- a/loko/interventions/static/interventions/calendar/interventions_calendar.js +++ b/loko/interventions/static/interventions/calendar/interventions_calendar.js @@ -294,6 +294,10 @@ document.addEventListener('DOMContentLoaded', function () { let currentModalMemberId = null; let draggedBacklogItem = null; let draggedBacklogGroup = null; + let _draggedTeamMember = null; + let _memberDragJustEnded = false; + let _pendingMemberDailyAssign = null; + let _lastTeamsDailyData = null; const csrfToken = getCookie('csrftoken'); let suppressDurationInputEvent = false; let suppressEndInputEvent = false; @@ -451,6 +455,141 @@ document.addEventListener('DOMContentLoaded', function () { return TEAM_PALETTE[Math.abs(teamId || 0) % TEAM_PALETTE.length]; } + function buildTeamDailyMembersHtml(members, teamId, dateStr, teamName, canEditThisTeam, isPastDay) { + const teamColor = typeof getTeamColor === 'function' ? getTeamColor(teamId) : '#0d6efd'; + const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false; + const titleTooltip = canEditThisTeam && !isPastDay ? gettext('Modifier la composition de l’équipe pour ce jour') : gettext('Composition de l’équipe'); + const membersList = (members && Array.isArray(members)) ? members : []; + + if (membersList.length > 0) { + const chipsHtml = membersList.map(m => { + const name = typeof m === 'object' && m ? m.name : m; + const id = typeof m === 'object' && m ? m.id : null; + const isAbsent = typeof m === 'object' && m ? Boolean(m.is_absent) : false; + const absenceReason = typeof m === 'object' && m ? (m.absence_reason || '') : ''; + const isMultiTeam = typeof m === 'object' && m ? Boolean(m.is_multi_team) : false; + const otherTeams = typeof m === 'object' && m && m.other_teams ? m.other_teams : []; + + const canDrag = Boolean(canEditThisTeam && !isPastDay && id); + let chipTitle = escapeHtml(name); + if (canDrag) { + chipTitle += ` — ${gettext('Glisser pour réassigner. Cliquer pour gérer l’équipe.')}`; + } + + let multiTeamHtml = ''; + if (isMultiTeam) { + const otherNames = otherTeams.map(t => t.name).join(', '); + const tooltip = otherNames ? `${gettext('Planifié aussi dans')} : ${otherNames}` : gettext('Planifié dans plusieurs équipes ce jour'); + multiTeamHtml = ` `; + } + + let absentBadgeHtml = ''; + if (isAbsent) { + absentBadgeHtml = ` ${escapeHtml(absenceReason || gettext('Absent'))}`; + } + + let historyLinkHtml = ''; + if (isMemberHistoryAllowed && id) { + historyLinkHtml = ` `; + } + + return `
+ ${escapeHtml(name)}${multiTeamHtml}${absentBadgeHtml}${historyLinkHtml} +
`; + }).join(''); + + return `
+
+ + + ${gettext('Équipe')} (${membersList.length}) + + ${canEditThisTeam && !isPastDay ? ` + + ` : ''} +
+
+ ${chipsHtml} +
+
`; + } else if (teamId !== null && teamId !== '' && typeof teamId !== 'undefined') { + return `
+ (${gettext('Aucun membre')}) + ${canEditThisTeam && !isPastDay ? `` : ''} +
`; + } + return ''; + } + + function initTeamDailyMemberChips(container) { + const root = container || document.getElementById('teams-daily-table-body'); + if (!root) return; + + root.querySelectorAll('.team-daily-member-chip[draggable="true"]').forEach(chip => { + chip.addEventListener('dragstart', (e) => { + const memberId = parseInt(chip.getAttribute('data-member-id')); + const memberName = chip.getAttribute('data-member-name') || ''; + const sourceTeamId = chip.getAttribute('data-team-id') ? parseInt(chip.getAttribute('data-team-id')) : null; + const sourceDate = chip.getAttribute('data-date'); + + _draggedTeamMember = { + id: memberId, + name: memberName, + sourceTeamId: sourceTeamId, + sourceDate: sourceDate + }; + + chip.classList.add('is-dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(memberId)); + }); + + chip.addEventListener('dragend', () => { + chip.classList.remove('is-dragging'); + _draggedTeamMember = null; + _memberDragJustEnded = true; + setTimeout(() => { + _memberDragJustEnded = false; + }, 200); + }); + }); + } + + function showTeamDailyMultiTeamConfirmModal(member, targetTeamId, targetDate, otherAssignedTeams) { + const modalEl = document.getElementById('teamDailyMultiTeamConfirmModal'); + if (!modalEl) { + if (confirm(gettext("Ce membre est déjà affecté à une autre équipe ce jour-là. Voulez-vous le déplacer vers cette équipe ?"))) { + assignMemberToDailyTeam(member.id, targetTeamId, targetDate, 'move'); + } + return; + } + + const otherNames = otherAssignedTeams.map(t => t.name).join(', '); + const targetTeamObj = (_lastTeamsDailyData?.teams || []).find(t => t.id === targetTeamId); + const targetName = targetTeamObj ? targetTeamObj.name : `Équipe #${targetTeamId}`; + + const memberNameEl = document.getElementById('team-daily-multi-member-name'); + const dateEl = document.getElementById('team-daily-multi-date'); + const existingTeamsEl = document.getElementById('team-daily-multi-existing-teams'); + const targetTeamEl = document.getElementById('team-daily-multi-target-team'); + + if (memberNameEl) memberNameEl.textContent = member.name; + if (dateEl) dateEl.textContent = targetDate; + if (existingTeamsEl) existingTeamsEl.textContent = otherNames; + if (targetTeamEl) targetTeamEl.textContent = targetName; + + _pendingMemberDailyAssign = { + memberId: member.id, + targetTeamId: targetTeamId, + targetDate: targetDate + }; + + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + } + function renderTeamsDailyGrid(data) { const todayStr = formatDateISO(new Date()); const headerRow = document.getElementById('teams-daily-table-header'); @@ -459,6 +598,7 @@ document.addEventListener('DOMContentLoaded', function () { const datepicker = document.getElementById('teams-daily-datepicker'); if (!headerRow || !tbody || !data) return; + _lastTeamsDailyData = data; const startDate = new Date(data.start_date); teamsDailyActiveDate = startDate; @@ -550,40 +690,7 @@ document.addEventListener('DOMContentLoaded', function () { const dayData = team.days[day.date] || { members: [], interventions: [] }; - let membersHTML = ''; - const isPastDay = day.date < todayStr; - const isMemberHistoryAllowed = typeof canViewMemberHistory !== 'undefined' ? Boolean(canViewMemberHistory) : false; - const canEditThisTeam = (typeof canEditTeams !== 'undefined' && Boolean(canEditTeams)) && (team.can_edit !== false); - const titleTooltip = canEditThisTeam && !isPastDay ? gettext('Modifier la composition de l’équipe pour ce jour') : gettext('Composition de l’équipe'); - - if (dayData.members && dayData.members.length > 0) { - const formattedMembers = dayData.members.map(m => { - const name = typeof m === 'object' && m ? m.name : m; - const id = typeof m === 'object' && m ? m.id : null; - const isAbsent = typeof m === 'object' && m ? Boolean(m.is_absent) : false; - const absenceReason = typeof m === 'object' && m ? (m.absence_reason || '') : ''; - const isMultiTeam = typeof m === 'object' && m ? Boolean(m.is_multi_team) : false; - const otherTeams = typeof m === 'object' && m && m.other_teams ? m.other_teams : []; - - let memberNameHtml = `${escapeHtml(name)}`; - if (isMultiTeam) { - const otherNames = otherTeams.map(t => t.name).join(', '); - const tooltip = otherNames ? `${gettext('Planifié aussi dans')} : ${otherNames}` : gettext('Planifié dans plusieurs équipes ce jour'); - memberNameHtml += ` `; - } - if (isAbsent) { - return `${memberNameHtml} ${escapeHtml(absenceReason || gettext('Absent'))}`; - } - return memberNameHtml; - }).join(', '); - membersHTML = `
- ${formattedMembers} -
`; - } else if (team.id !== null) { - membersHTML = `
- (${gettext('Aucun membre')}) -
`; - } + const membersHTML = buildTeamDailyMembersHtml(dayData.members, team.id, day.date, team.name, canEditThisTeam, isPastDay); let itvsHTML = '
'; if (dayData.interventions && dayData.interventions.length > 0) { @@ -696,7 +803,7 @@ document.addEventListener('DOMContentLoaded', function () { if (isPastCell) return; cell.addEventListener('dragover', (e) => { - if (draggedBacklogItem || draggedBacklogGroup) { + if (draggedBacklogItem || draggedBacklogGroup || _draggedTeamMember) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; cell.classList.add('teams-daily-grid-cell--highlight'); @@ -709,8 +816,55 @@ document.addEventListener('DOMContentLoaded', function () { cell.addEventListener('drop', (e) => { cell.classList.remove('teams-daily-grid-cell--highlight'); - if (!draggedBacklogItem && !draggedBacklogGroup) return; + if (!draggedBacklogItem && !draggedBacklogGroup && !_draggedTeamMember) return; e.preventDefault(); + + if (_draggedTeamMember) { + const member = _draggedTeamMember; + _draggedTeamMember = null; + + const targetDate = dateStr; + const targetTeamId = teamId ? parseInt(teamId) : null; + + if (!member.id || !targetDate || !targetTeamId) return; + + // Si même équipe et même date, rien à faire + if (member.sourceTeamId === targetTeamId && member.sourceDate === targetDate) { + return; + } + + // Vérifier si le membre est déjà dans l'équipe cible ce jour-là + let alreadyInTargetTeam = false; + const otherAssignedTeams = []; + if (_lastTeamsDailyData && _lastTeamsDailyData.teams) { + _lastTeamsDailyData.teams.forEach(t => { + if (t.days && t.days[targetDate] && t.days[targetDate].members) { + const found = t.days[targetDate].members.some(m => { + const mId = typeof m === 'object' && m ? m.id : null; + return mId === member.id; + }); + if (found) { + if (t.id === targetTeamId) { + alreadyInTargetTeam = true; + } else { + otherAssignedTeams.push(t); + } + } + } + }); + } + + if (alreadyInTargetTeam) { + return; + } + + if (otherAssignedTeams.length > 0) { + showTeamDailyMultiTeamConfirmModal(member, targetTeamId, targetDate, otherAssignedTeams); + } else { + assignMemberToDailyTeam(member.id, targetTeamId, targetDate, 'move'); + } + return; + } const start = new Date(dateStr + 'T08:00:00'); @@ -741,6 +895,8 @@ document.addEventListener('DOMContentLoaded', function () { }); }); + initTeamDailyMemberChips(tbody); + setTimeout(scrollToTodayColumn, 100); } @@ -5412,55 +5568,27 @@ function updateTeamsDailyGridCell(teamId, dateStr, currentMembers, teamObj) { const cell = document.querySelector(`.teams-daily-grid-cell[data-team-id="${teamId}"][data-date="${dateStr}"]`); if (!cell) return; - let membersContainer = cell.querySelector('.team-members-list'); - const teamColor = typeof getTeamColor === 'function' ? getTeamColor(teamId) : (window.getTeamColor ? window.getTeamColor(teamId) : '#0d6efd'); const canEditThisTeam = (typeof canEditTeams !== 'undefined' && Boolean(canEditTeams)) && (!teamObj || teamObj.can_edit !== false); const todayStr = new Date().toISOString().split('T')[0]; const isPastDay = dateStr < todayStr; - const titleTooltip = canEditThisTeam && !isPastDay ? gettext('Modifier la composition de l’équipe pour ce jour') : gettext('Composition de l’équipe'); const teamName = teamObj && teamObj.name ? teamObj.name : ''; - if (!membersContainer) { - membersContainer = document.createElement('div'); - cell.prepend(membersContainer); + const membersContainer = cell.querySelector('.team-members-list'); + const newHtml = buildTeamDailyMembersHtml(currentMembers, teamId, dateStr, teamName, canEditThisTeam, isPastDay); + + if (membersContainer) { + membersContainer.outerHTML = newHtml; + } else if (newHtml) { + cell.insertAdjacentHTML('afterbegin', newHtml); } + initTeamDailyMemberChips(cell); - membersContainer.setAttribute('data-team-id', teamId); - membersContainer.setAttribute('data-date', dateStr); - if (teamName) { - membersContainer.setAttribute('data-team-name', teamName); - } - membersContainer.setAttribute('title', titleTooltip); - - if (currentMembers && currentMembers.length > 0) { - const formattedMembers = currentMembers.map(m => { - const name = typeof m === 'object' && m ? m.name : m; - const isAbsent = typeof m === 'object' && m ? Boolean(m.is_absent) : false; - const absenceReason = typeof m === 'object' && m ? (m.absence_reason || '') : ''; - const isMultiTeam = typeof m === 'object' && m ? Boolean(m.is_multi_team) : false; - const otherTeams = typeof m === 'object' && m && m.other_teams ? m.other_teams : []; - - let memberNameHtml = `${escapeHtml(name)}`; - if (isMultiTeam) { - const otherNames = otherTeams.map(t => t.name).join(', '); - const tooltip = otherNames ? `${gettext('Planifié aussi dans')} : ${otherNames}` : gettext('Planifié dans plusieurs équipes ce jour'); - memberNameHtml += ` `; - } - if (isAbsent) { - return `${memberNameHtml} ${escapeHtml(absenceReason || gettext('Absent'))}`; - } - return memberNameHtml; - }).join(', '); - - membersContainer.className = 'team-members-list clickable-team-members small mb-2'; - membersContainer.style.borderLeft = `3px solid ${teamColor}`; - membersContainer.style.cursor = 'pointer'; - membersContainer.innerHTML = `${formattedMembers}`; - } else { - membersContainer.className = 'team-members-list clickable-team-members text-muted small fst-italic mb-2 text-opacity-50'; - membersContainer.style.borderLeft = '3px solid #cbd5e1'; - membersContainer.style.cursor = 'pointer'; - membersContainer.innerHTML = `(${gettext('Aucun membre')})`; + // Mettre à jour les données en cache + if (_lastTeamsDailyData && _lastTeamsDailyData.teams) { + const teamInCache = _lastTeamsDailyData.teams.find(t => t.id === parseInt(teamId)); + if (teamInCache && teamInCache.days && teamInCache.days[dateStr]) { + teamInCache.days[dateStr].members = currentMembers || []; + } } } @@ -5670,6 +5798,34 @@ function assignMemberToDailyTeam(memberId, teamId, dateStr, action) { } document.addEventListener('click', function(e) { + const btnMultiMove = e.target.closest('#btn-team-daily-multi-move'); + if (btnMultiMove && _pendingMemberDailyAssign) { + e.preventDefault(); + const { memberId, targetTeamId, targetDate } = _pendingMemberDailyAssign; + _pendingMemberDailyAssign = null; + const modalEl = document.getElementById('teamDailyMultiTeamConfirmModal'); + if (modalEl) { + const modal = bootstrap.Modal.getInstance(modalEl); + if (modal) modal.hide(); + } + assignMemberToDailyTeam(memberId, targetTeamId, targetDate, 'move'); + return; + } + + const btnMultiAdd = e.target.closest('#btn-team-daily-multi-add'); + if (btnMultiAdd && _pendingMemberDailyAssign) { + e.preventDefault(); + const { memberId, targetTeamId, targetDate } = _pendingMemberDailyAssign; + _pendingMemberDailyAssign = null; + const modalEl = document.getElementById('teamDailyMultiTeamConfirmModal'); + if (modalEl) { + const modal = bootstrap.Modal.getInstance(modalEl); + if (modal) modal.hide(); + } + assignMemberToDailyTeam(memberId, targetTeamId, targetDate, 'add'); + return; + } + const btnAdd = e.target.closest('#btn-team-daily-add-member'); if (btnAdd) { e.preventDefault(); @@ -5696,6 +5852,19 @@ document.addEventListener('click', function(e) { const teamMembersList = e.target.closest('.clickable-team-members, .team-members-list'); if (teamMembersList) { + if (_memberDragJustEnded) return; + + const historyIcon = e.target.closest('.member-history-icon'); + if (historyIcon) { + e.preventDefault(); + e.stopPropagation(); + const mId = historyIcon.getAttribute('data-member-id'); + if (mId) { + openMemberHistoryModal(mId); + } + return; + } + if (e.target.closest('.member-history-link, .btn-team-daily-remove-member, #btn-team-daily-add-member')) return; const teamId = teamMembersList.getAttribute('data-team-id'); @@ -5709,6 +5878,13 @@ document.addEventListener('click', function(e) { } }); +const _multiTeamModalEl = document.getElementById('teamDailyMultiTeamConfirmModal'); +if (_multiTeamModalEl) { + _multiTeamModalEl.addEventListener('hidden.bs.modal', function () { + _pendingMemberDailyAssign = null; + }); +} + const _memberHistoryModalEl = document.getElementById('memberHistoryModal'); if (_memberHistoryModalEl) { function ensureHistoryModalOnTop() { diff --git a/loko/interventions/templates/interventions/interventions_calendar.html b/loko/interventions/templates/interventions/interventions_calendar.html index 1457739..489ad1c 100644 --- a/loko/interventions/templates/interventions/interventions_calendar.html +++ b/loko/interventions/templates/interventions/interventions_calendar.html @@ -745,6 +745,44 @@
+ + +