loko/streetup/interventions/static/interventions/calendar/interventions_calendar.js
2026-07-22 14:48:40 +02:00

4674 lines
186 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// --- Status colors mapping (réfère à la constante de status_colors.js)
const STATUS_COLORS = INTERVENTION_STATUS_COLORS;
const DEFAULT_STATUS_COLOR = '#888888';
const EDITABLE_STATUSES = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed'];
const DAY_MS = 24 * 60 * 60 * 1000;
const HOUR_MS = 60 * 60 * 1000;
const MIN_BAR_DURATION_MS = 12 * 60 * 60 * 1000; // Largeur visuelle minimale des barres
const MIN_DRAG_DURATION_MS = 15 * 60 * 1000; // Durée minimale conservée lors du drag (15 min)
const TIMELINE_ROW_HEIGHT = 52;
const TIMELINE_BAR_HEIGHT = 26;
const TIMELINE_AXIS_HEIGHT = 44;
const TIMELINE_EXTRA_RANGE_HOURS = 72;
const TIMELINE_MIN_HEIGHT = 320;
const TIMELINE_SCROLLBAR_MIN_THUMB = 32;
const MIN_MS_PER_PIXEL = 60 * 1000; // 1 minute per pixel
const MAX_MS_PER_PIXEL = 12 * 60 * 60 * 1000; // 12 hours per pixel
const DEFAULT_TIMELINE_VIEW = 'week';
const TIMELINE_EMPTY_MESSAGE = 'Aucune intervention à afficher pour les filtres sélectionnés.';
const MEMBERS_LABEL_WIDTH = 260;
const MEMBERS_MEMBER_ROW_HEIGHT = 52;
const MEMBERS_TEAM_ROW_HEIGHT = 36;
const MEMBERS_TEAM_INTERVENTIONS_ROW_HEIGHT = 52;
const MEMBERS_AXIS_HEIGHT = TIMELINE_AXIS_HEIGHT;
const MEMBERS_DEFAULT_VIEW_RANGE_HOURS = 24 * 14;
const MEMBERS_SCROLLBAR_MIN_THUMB = TIMELINE_SCROLLBAR_MIN_THUMB;
const MEMBERS_EMPTY_MESSAGE = 'Aucune intervention assignée à afficher.';
const MEMBERS_ROW_VERTICAL_PADDING = 12;
const MEMBERS_LANE_VERTICAL_SPACING = 6;
const MEMBERS_LAYOUT_BUFFER_PX = 60;
const MEMBERS_LANE_HORIZONTAL_GAP_PX = 6;
const MEMBERS_LABEL_OUTSIDE_GAP = 10;
const VIEW_CONFIGS = {
hour: {
label: 'hour',
pixelsPerHour: 60,
windowHoursBefore: 12,
windowHoursAfter: 12,
tick: 'hour'
},
day: {
label: 'day',
pixelsPerHour: 24,
windowHoursBefore: 72,
windowHoursAfter: 96,
tick: 'six-hour'
},
week: {
label: 'week',
pixelsPerHour: 8,
windowHoursBefore: 24 * 7,
windowHoursAfter: 24 * 14,
tick: 'day'
},
month: {
label: 'month',
pixelsPerHour: 3,
windowHoursBefore: 24 * 30,
windowHoursAfter: 24 * 30,
tick: 'day'
}
};
const DOCUMENT_LOCALE = (typeof document !== 'undefined' && document.documentElement?.lang) || navigator?.language || 'fr-BE';
const axisDayFormatter = new Intl.DateTimeFormat(DOCUMENT_LOCALE, { weekday: 'short', day: 'numeric', month: 'short' });
const axisHourFormatter = new Intl.DateTimeFormat(DOCUMENT_LOCALE, { hour: '2-digit', minute: '2-digit' });
// Fallback for gettext if not available
if (typeof gettext === 'undefined') {
window.gettext = function(s) { return s; };
}
document.addEventListener('DOMContentLoaded', function () {
let calendarPreferences = {};
const preferencesScript = document.getElementById('calendar-preferences');
if (preferencesScript) {
try {
calendarPreferences = JSON.parse(preferencesScript.textContent) || {};
} catch (e) {
console.warn('Unable to parse initial calendar preferences.', e);
}
}
function saveCalendarPreferences(newPrefs) {
calendarPreferences = { ...calendarPreferences, ...newPrefs };
if (typeof saveCalendarPreferencesUrl === 'undefined' || !saveCalendarPreferencesUrl) {
return;
}
const token = getCookie('csrftoken');
fetch(saveCalendarPreferencesUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': token
},
body: JSON.stringify({
preferences: calendarPreferences
})
})
.then(response => {
if (!response.ok) {
console.warn('Failed to save calendar preferences to database.');
}
})
.catch(err => {
console.error('Error saving calendar preferences:', err);
});
}
function updateTimelineButtonsUI(viewType) {
const buttons = document.querySelectorAll('[data-timeline-view]');
buttons.forEach(btn => {
if (btn.getAttribute('data-timeline-view') === viewType) {
btn.classList.add('btn-primary');
btn.classList.remove('btn-light');
} else {
btn.classList.add('btn-light');
btn.classList.remove('btn-primary');
}
});
}
const timelineWrapper = document.getElementById('timeline-wrapper');
const canMoveTimelineItems = (timelineWrapper?.dataset.canDrag || '').toLowerCase() === 'true';
const isManager = (timelineWrapper?.dataset.isManager || '').toLowerCase() === 'true';
const membersViewLayout = document.getElementById('members-view-layout');
const membersTimelineWrapper = document.getElementById('members-timeline-wrapper');
const membersBacklogWrapper = document.getElementById('members-backlog-wrapper');
const timelineViewButtons = document.getElementById('timeline-view-buttons');
const calendarFiltersForm = document.getElementById('calendar-filters-form');
const filterViewModeSelect = document.getElementById('filter-view-mode');
// Teams daily view elements
const teamsDailyViewLayout = document.getElementById('teams-daily-view-layout');
const btnTeamsDailyPrev = document.getElementById('btn-teams-daily-prev');
const btnTeamsDailyToday = document.getElementById('btn-teams-daily-today');
const btnTeamsDailyNext = document.getElementById('btn-teams-daily-next');
const teamsDailyDatePicker = document.getElementById('teams-daily-datepicker');
const teamsDailyBacklogWrapper = document.getElementById('teams-daily-backlog-wrapper');
let teamsDailyActiveDate = new Date();
const planningModalEl = document.getElementById('interventionModal');
const planningForm = document.getElementById('planning-form');
const planningBeginInput = document.getElementById('planning-begin-input');
const planningEndInput = document.getElementById('planning-end-input');
const planningTeamInput = document.getElementById('planning-team-input');
const planningMemberInput = document.getElementById('planning-member-input');
const planningDurationInput = document.getElementById('planning-duration-input');
const planningFeedback = document.getElementById('planning-feedback');
const planningSaveBtn = document.getElementById('planning-save-btn');
const modalInterventionCode = document.getElementById('modal-intervention-code');
const modalInterventionTitle = document.getElementById('modal-intervention-title');
const modalInterventionStatus = document.getElementById('modal-intervention-status');
const modalInterventionLink = document.getElementById('modal-intervention-link');
const planningModal = (typeof bootstrap !== 'undefined' && planningModalEl) ? new bootstrap.Modal(planningModalEl) : null;
const timelineState = {
view: (calendarPreferences && calendarPreferences.timeline_view && VIEW_CONFIGS[calendarPreferences.timeline_view]) ? calendarPreferences.timeline_view : DEFAULT_TIMELINE_VIEW,
items: [],
anchorDate: null,
dom: null,
msPerPixel: null,
viewStartMs: null,
verticalOffset: 0,
viewportWidth: 0,
viewportHeight: 0,
bounds: null,
contentMetrics: null,
hitRegions: [],
pointer: {
active: false,
pointerId: null,
lastX: 0,
lastY: 0,
moved: false,
downTime: 0,
captureTarget: null
},
hoverCoords: null,
scrollbarDrag: {
active: false,
axis: null,
pointerId: null,
startPos: 0,
startValue: 0,
trackLength: 0,
thumbLength: 0,
trackScrollable: 0,
totalScrollable: 0
},
drag: {
active: false,
pointerId: null,
itemIndex: null,
item: null,
durationMs: 0,
startMs: 0,
currentStartMs: 0,
currentEndMs: 0,
startClientX: 0,
hasMoved: false,
preview: null,
captureTarget: null
},
_pendingFrame: null,
_pendingRestore: null
};
const membersState = {
rows: [],
items: [],
bounds: null,
dom: null,
msPerPixel: null,
viewStartMs: null,
anchorDate: null,
verticalOffset: 0,
viewportWidth: 0,
viewportHeight: 0,
totalHeight: 0,
hitRegions: [],
hoverCoords: null,
contentMetrics: null,
pointer: {
active: false,
pointerId: null,
lastX: 0,
lastY: 0,
moved: false,
downTime: 0,
captureTarget: null
},
scrollbarDrag: {
active: false,
axis: null,
pointerId: null,
startPos: 0,
startValue: 0,
trackLength: 0,
thumbLength: 0,
trackScrollable: 0,
totalScrollable: 0
},
drag: {
active: false,
pointerId: null,
item: null,
sourceRowId: null,
sourceMemberId: null,
sourceTeamId: null,
durationMs: 0,
startMs: 0,
endMs: 0,
startClientX: 0,
startClientY: 0,
hasMoved: false,
dropTarget: null,
preview: null,
captureTarget: null
},
_pendingFrame: null,
_measureCtx: null,
_initiallyCentered: false,
_pendingRestore: null
};
let currentModalInterventionId = null;
let currentModalMemberId = null;
let draggedBacklogItem = null;
let draggedBacklogGroup = null;
const csrfToken = getCookie('csrftoken');
let suppressDurationInputEvent = false;
let suppressEndInputEvent = false;
if (!canMoveTimelineItems) {
planningBeginInput?.setAttribute('disabled', 'disabled');
planningEndInput?.setAttribute('disabled', 'disabled');
planningDurationInput?.setAttribute('disabled', 'disabled');
planningSaveBtn?.setAttribute('disabled', 'disabled');
planningSaveBtn?.classList.add('disabled');
}
// Form submission handlers
planningForm?.addEventListener('submit', event => {
event.preventDefault();
handlePlanningSubmit();
});
planningSaveBtn?.addEventListener('click', () => {
planningForm?.requestSubmit();
});
planningBeginInput?.addEventListener('input', handlePlanningBeginChange);
planningBeginInput?.addEventListener('change', handlePlanningBeginChange);
planningEndInput?.addEventListener('input', handlePlanningEndChange);
planningEndInput?.addEventListener('change', handlePlanningEndChange);
planningDurationInput?.addEventListener('input', handlePlanningDurationChange);
planningDurationInput?.addEventListener('change', handlePlanningDurationChange);
filterViewModeSelect?.addEventListener('change', () => {
if (calendarFiltersForm?.requestSubmit) {
calendarFiltersForm.requestSubmit();
} else {
calendarFiltersForm?.submit();
}
});
// Timeline view button handlers
const timelineButtons = document.querySelectorAll('[data-timeline-view]');
timelineButtons.forEach(btn => {
btn.addEventListener('click', () => {
const viewType = btn.getAttribute('data-timeline-view');
setTimelineView(viewType);
});
});
let resizeTimer = null;
function updateTeamsDailyViewHeight() {
if (!teamsDailyViewLayout) return;
const viewportHeight = window.innerHeight || document.documentElement?.clientHeight || 800;
const rect = teamsDailyViewLayout.getBoundingClientRect();
const available = viewportHeight - rect.top - 16;
const target = Math.max(TIMELINE_MIN_HEIGHT, available);
teamsDailyViewLayout.style.minHeight = `${target}px`;
teamsDailyViewLayout.style.height = `${target}px`;
const mainContent = teamsDailyViewLayout.querySelector('.teams-daily-main-content');
if (mainContent) {
mainContent.style.minHeight = `${target}px`;
mainContent.style.height = `${target}px`;
const header = mainContent.querySelector('.teams-daily-header-container');
const headerHeight = header?.offsetHeight || 0;
const gridContainer = mainContent.querySelector('.teams-daily-grid-container');
if (gridContainer) {
gridContainer.style.height = `${target - headerHeight}px`;
gridContainer.style.maxHeight = `${target - headerHeight}px`;
}
}
if (teamsDailyBacklogWrapper) {
teamsDailyBacklogWrapper.style.minHeight = `${target}px`;
teamsDailyBacklogWrapper.style.height = `${target}px`;
teamsDailyBacklogWrapper.style.maxHeight = `${target}px`;
requestAnimationFrame(() => resizeBacklogCards());
}
}
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
updateTimelineWrapperHeight();
updateMembersViewHeight();
updateTeamsDailyViewHeight();
resizeBacklogCards();
if (timelineState.dom && timelineState.items.length) {
requestTimelineRender();
}
if (membersState.dom && membersState.rows.length) {
requestMembersRender();
}
}, 150);
});
// Teams daily view event listeners & render functions
btnTeamsDailyPrev?.addEventListener('click', () => {
teamsDailyActiveDate.setDate(teamsDailyActiveDate.getDate() - 7);
loadCalendarData(formatDateISO(teamsDailyActiveDate));
});
btnTeamsDailyToday?.addEventListener('click', () => {
teamsDailyActiveDate = new Date();
loadCalendarData(formatDateISO(teamsDailyActiveDate));
});
btnTeamsDailyNext?.addEventListener('click', () => {
teamsDailyActiveDate.setDate(teamsDailyActiveDate.getDate() + 7);
loadCalendarData(formatDateISO(teamsDailyActiveDate));
});
teamsDailyDatePicker?.addEventListener('change', () => {
if (teamsDailyDatePicker.value) {
teamsDailyActiveDate = new Date(teamsDailyDatePicker.value);
loadCalendarData(formatDateISO(teamsDailyActiveDate));
}
});
function formatDateISO(date) {
const pad = v => String(v).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function openInterventionModal(interventionId) {
fetch(`/interventions/${interventionId}/planning-data/`)
.then(r => r.json())
.then(data => {
if (data.error) {
alert(data.error);
return;
}
openPlanningModal(data);
})
.catch(err => {
console.error('Error fetching intervention planning data:', err);
});
}
function scrollToTodayColumn() {
const gridContainer = document.querySelector('.teams-daily-grid-container');
const todayHeader = document.querySelector('.today-column-header');
if (gridContainer && todayHeader) {
const containerRect = gridContainer.getBoundingClientRect();
const todayRect = todayHeader.getBoundingClientRect();
const scrollOffset = todayRect.left - containerRect.left + gridContainer.scrollLeft - (containerRect.width / 2) + (todayRect.width / 2);
gridContainer.scrollTo({
left: Math.max(0, scrollOffset),
behavior: 'smooth'
});
}
}
function renderTeamsDailyGrid(data) {
const todayStr = formatDateISO(new Date());
const headerRow = document.getElementById('teams-daily-table-header');
const tbody = document.getElementById('teams-daily-table-body');
const rangeLabel = document.getElementById('teams-daily-range-label');
const datepicker = document.getElementById('teams-daily-datepicker');
if (!headerRow || !tbody || !data) return;
const startDate = new Date(data.start_date);
teamsDailyActiveDate = startDate;
const endDate = new Date(data.end_date);
const formatOptionsMonth = { month: 'long', year: 'numeric' };
let labelText = '';
if (startDate.getMonth() === endDate.getMonth()) {
labelText = `${gettext('Semaine du')} ${startDate.getDate()} ${gettext('au')} ${endDate.getDate()} ${startDate.toLocaleDateString(DOCUMENT_LOCALE, formatOptionsMonth)}`;
} else {
labelText = `${gettext('Semaine du')} ${startDate.toLocaleDateString(DOCUMENT_LOCALE, {day: 'numeric', month: 'long'})} ${gettext('au')} ${endDate.toLocaleDateString(DOCUMENT_LOCALE, {day: 'numeric', month: 'long', year: 'numeric'})}`;
}
if (rangeLabel) {
rangeLabel.textContent = labelText;
}
if (datepicker) {
const pad = v => String(v).padStart(2, '0');
datepicker.value = `${startDate.getFullYear()}-${pad(startDate.getMonth() + 1)}-${pad(startDate.getDate())}`;
}
const dayHeaders = headerRow.querySelectorAll('.day-header');
dayHeaders.forEach(el => el.remove());
const dayFormatter = new Intl.DateTimeFormat(DOCUMENT_LOCALE, { weekday: 'short', day: 'numeric', month: 'short' });
data.days.forEach(day => {
const th = document.createElement('th');
th.className = 'day-header text-center align-middle';
th.style.minWidth = '200px';
th.style.width = '200px';
const dateObj = new Date(day.date);
th.textContent = dayFormatter.format(dateObj);
if (day.is_today) {
th.classList.add('bg-primary-subtle', 'border-primary', 'text-primary', 'today-column-header');
}
headerRow.appendChild(th);
});
tbody.innerHTML = '';
if (data.teams.length === 0) {
const tr = document.createElement('tr');
tr.innerHTML = `<td colspan="8" class="text-center text-muted py-4">${gettext('Aucune équipe à afficher.')}</td>`;
tbody.appendChild(tr);
return;
}
data.teams.forEach(team => {
const tr = document.createElement('tr');
const tdTeam = document.createElement('td');
tdTeam.className = 'sticky-left bg-light font-weight-bold align-middle';
tdTeam.style.zIndex = '2';
let teamHTML = `<div class="team-cell-title">${escapeHtml(team.name)}</div>`;
if (team.company) {
teamHTML += `<div class="team-cell-company text-muted small font-weight-normal">${escapeHtml(team.company)}</div>`;
}
tdTeam.innerHTML = teamHTML;
tr.appendChild(tdTeam);
data.days.forEach(day => {
const tdDay = document.createElement('td');
tdDay.className = 'teams-daily-grid-cell align-top';
tdDay.setAttribute('data-date', day.date);
tdDay.setAttribute('data-team-id', team.id !== null ? team.id : '');
if (day.is_today) {
tdDay.classList.add('bg-primary-subtle-light', 'today-cell');
}
const dayData = team.days[day.date] || { members: [], interventions: [] };
let membersHTML = '';
const isFutureDay = day.date > todayStr;
if (!isFutureDay) {
if (dayData.members && dayData.members.length > 0) {
membersHTML = `<div class="team-members-list text-muted small fst-italic mb-2" title="${gettext('Composition de l’équipe')}">
<i class="bi bi-people-fill me-1"></i>${dayData.members.map(m => escapeHtml(m)).join(', ')}
</div>`;
} else if (team.id !== null) {
membersHTML = `<div class="team-members-list text-muted small fst-italic mb-2 text-opacity-50">
<i class="bi bi-people me-1"></i>(${gettext('Aucun membre')})
</div>`;
}
}
let itvsHTML = '<div class="teams-daily-interventions-stack">';
if (dayData.interventions && dayData.interventions.length > 0) {
dayData.interventions.forEach(itv => {
const cardColor = STATUS_COLORS[itv.status] || DEFAULT_STATUS_COLOR;
const textCol = idealTextColor(cardColor);
let isEditable = canMoveTimelineItems && EDITABLE_STATUSES.includes(itv.status);
if (isEditable && isManager) {
const managerAllowedStatuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed'];
isEditable = managerAllowedStatuses.includes(itv.status);
}
itvsHTML += `
<div class="teams-daily-itv-card border-start border-3${isEditable ? ' draggable' : ''}" ${isEditable ? 'draggable="true"' : ''} style="border-left-color: ${cardColor} !important;" data-intervention-id="${itv.id}">
<div class="d-flex justify-content-between align-items-start gap-1">
<a href="#" draggable="false" class="teams-daily-itv-code fw-bold text-decoration-none small text-primary" data-intervention-id="${itv.id}">
${escapeHtml(itv.code)}
</a>
<span class="badge teams-daily-itv-status-badge rounded-pill" style="background-color: ${cardColor}; color: ${textCol}; font-size: 0.65rem;">
${escapeHtml(itv.status_display)}
</span>
</div>
<div class="teams-daily-itv-title text-dark fw-medium small text-truncate mt-1" title="${escapeHtml(itv.title)}">
${escapeHtml(itv.title)}
</div>
${itv.member_name ? `
<div class="teams-daily-itv-member text-muted small mt-1">
<i class="bi bi-person me-1"></i>${escapeHtml(itv.member_name)}
</div>
` : ''}
</div>
`;
});
} else {
itvsHTML += `<div class="text-muted small text-center py-2 text-opacity-50 fst-italic">${gettext('Aucune intervention')}</div>`;
}
itvsHTML += '</div>';
tdDay.innerHTML = membersHTML + itvsHTML;
tr.appendChild(tdDay);
});
tbody.appendChild(tr);
});
tbody.querySelectorAll('.teams-daily-itv-card, .teams-daily-itv-code').forEach(el => {
el.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
const itvId = el.getAttribute('data-intervention-id');
if (itvId) {
openInterventionModal(itvId);
}
});
});
tbody.querySelectorAll('.teams-daily-itv-card[draggable="true"]').forEach(card => {
card.addEventListener('dragstart', (e) => {
const itvId = card.getAttribute('data-intervention-id');
let itvInfo = null;
for (const team of data.teams) {
for (const dayKey in team.days) {
const found = team.days[dayKey].interventions?.find(it => String(it.id) === String(itvId));
if (found) {
itvInfo = found;
break;
}
}
if (itvInfo) break;
}
if (!itvInfo) {
itvInfo = {
id: itvId,
code: card.querySelector('.teams-daily-itv-code')?.textContent.trim() || '',
title: card.querySelector('.teams-daily-itv-title')?.textContent.trim() || '',
status: card.querySelector('.teams-daily-itv-status-badge')?.dataset.statusCode || 'to_be_planned'
};
}
draggedBacklogItem = {
id: itvInfo.id,
code: itvInfo.code,
title: itvInfo.title,
status_code: itvInfo.status,
status: itvInfo.status_display,
duration_ms: itvInfo.duration_ms || 4 * 60 * 60 * 1000
};
card.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', itvInfo.id);
});
card.addEventListener('dragend', () => {
card.classList.remove('is-dragging');
draggedBacklogItem = null;
});
});
tbody.querySelectorAll('.teams-daily-grid-cell').forEach(cell => {
const dateStr = cell.getAttribute('data-date');
const teamId = cell.getAttribute('data-team-id') || null;
const isPastCell = dateStr < todayStr;
if (isPastCell) return;
cell.addEventListener('dragover', (e) => {
if (draggedBacklogItem || draggedBacklogGroup) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
cell.classList.add('teams-daily-grid-cell--highlight');
}
});
cell.addEventListener('dragleave', () => {
cell.classList.remove('teams-daily-grid-cell--highlight');
});
cell.addEventListener('drop', (e) => {
cell.classList.remove('teams-daily-grid-cell--highlight');
if (!draggedBacklogItem && !draggedBacklogGroup) return;
e.preventDefault();
const start = new Date(dateStr + 'T08:00:00');
if (draggedBacklogGroup) {
const group = draggedBacklogGroup;
draggedBacklogGroup = null;
if (!group.items.length) return;
persistBulkScheduleUpdate(group.items, start.getTime(), { memberId: null, teamId: teamId });
return;
}
if (draggedBacklogItem) {
const duration = draggedBacklogItem.duration_ms || 4 * 60 * 60 * 1000;
const end = new Date(start.getTime() + duration);
const itemToAssign = draggedBacklogItem;
draggedBacklogItem = null;
persistScheduleUpdate({
interventionId: itemToAssign.id,
memberId: null,
teamId: teamId,
start: start,
end: end
});
}
});
});
setTimeout(scrollToTodayColumn, 100);
}
function setTimelineView(viewType) {
if (!VIEW_CONFIGS[viewType]) {
return;
}
timelineState.view = viewType;
updateTimelineButtonsUI(viewType);
saveCalendarPreferences({ timeline_view: viewType });
const config = VIEW_CONFIGS[viewType];
timelineState.msPerPixel = (60 * 60 * 1000) / config.pixelsPerHour;
// Always center on today's date when changing view
centerTimelineViewOnToday();
clampTimelineView();
requestTimelineRender();
}
function activateTimelineView() {
if (timelineWrapper) {
timelineWrapper.classList.remove('d-none');
updateTimelineWrapperHeight();
}
if (membersViewLayout) {
membersViewLayout.classList.add('d-none');
}
if (teamsDailyViewLayout) {
teamsDailyViewLayout.classList.add('d-none');
}
timelineViewButtons?.classList.remove('d-none');
}
function activateMembersView() {
timelineWrapper?.classList.add('d-none');
timelineViewButtons?.classList.add('d-none');
if (teamsDailyViewLayout) {
teamsDailyViewLayout.classList.add('d-none');
}
if (membersViewLayout) {
membersViewLayout.classList.remove('d-none');
membersTimelineWrapper?.classList.remove('d-none');
membersBacklogWrapper?.classList.remove('d-none');
}
updateMembersViewHeight();
}
function activateTeamsDailyView() {
timelineWrapper?.classList.add('d-none');
timelineViewButtons?.classList.add('d-none');
membersViewLayout?.classList.add('d-none');
if (teamsDailyViewLayout) {
teamsDailyViewLayout.classList.remove('d-none');
}
updateTeamsDailyViewHeight();
}
function updateTimelineWrapperHeight() {
if (!timelineWrapper) return;
const viewportHeight = window.innerHeight || document.documentElement?.clientHeight || 800;
const rect = timelineWrapper.getBoundingClientRect();
const available = viewportHeight - rect.top - 16;
const target = Math.max(TIMELINE_MIN_HEIGHT, available);
timelineWrapper.style.minHeight = `${target}px`;
timelineWrapper.style.height = `${target}px`;
}
function updateMembersViewHeight() {
if (!membersViewLayout) return;
const viewportHeight = window.innerHeight || document.documentElement?.clientHeight || 800;
const rect = membersViewLayout.getBoundingClientRect();
const available = viewportHeight - rect.top - 16;
const target = Math.max(TIMELINE_MIN_HEIGHT, available);
membersViewLayout.style.minHeight = `${target}px`;
membersViewLayout.style.height = `${target}px`;
if (membersTimelineWrapper) {
membersTimelineWrapper.style.minHeight = `${target}px`;
membersTimelineWrapper.style.height = `${target}px`;
}
if (membersBacklogWrapper) {
membersBacklogWrapper.style.minHeight = `${target}px`;
membersBacklogWrapper.style.height = `${target}px`;
membersBacklogWrapper.style.maxHeight = `${target}px`;
requestAnimationFrame(() => resizeBacklogCards());
}
}
function buildTimeline(events) {
const container = document.getElementById('vis-timeline');
if (!container) return;
updateTimelineWrapperHeight();
ensureTimelineCanvas(container);
updateTimelineCanvasSize();
if (timelineState.drag?.active) {
timelineState.dom?.bodyEl?.classList.remove('is-dragging-item');
resetTimelineDragState();
}
timelineState.items = normalizeTimelineItems(events);
timelineState.anchorDate = determineTimelineAnchor(timelineState.items);
timelineState.bounds = timelineState.items.length ? computeTimelineBounds(timelineState.items) : null;
if (!timelineState.items.length) {
const config = VIEW_CONFIGS[timelineState.view] || VIEW_CONFIGS[DEFAULT_TIMELINE_VIEW];
timelineState.msPerPixel = timelineState.msPerPixel || (60 * 60 * 1000) / config.pixelsPerHour;
timelineState.viewStartMs = Date.now() - (timelineState.viewportWidth || 1) * timelineState.msPerPixel / 2;
timelineState.verticalOffset = 0;
requestTimelineRender();
return;
}
const restoreState = timelineState._pendingRestore;
timelineState._pendingRestore = null;
const config = VIEW_CONFIGS[timelineState.view] || VIEW_CONFIGS[DEFAULT_TIMELINE_VIEW];
const defaultMsPerPixel = (60 * 60 * 1000) / config.pixelsPerHour;
const anchorTime = timelineState.anchorDate?.getTime() || timelineState.items[0].startDate.getTime();
const defaultViewStart = anchorTime - config.windowHoursBefore * HOUR_MS;
const anchorIndex = findAnchorRowIndex(timelineState.items, timelineState.anchorDate);
const defaultVerticalOffset = computeInitialVerticalOffset(anchorIndex);
let nextMsPerPixel = defaultMsPerPixel;
let nextViewStart = defaultViewStart;
let nextVerticalOffset = defaultVerticalOffset;
if (restoreState) {
if (Number.isFinite(restoreState.msPerPixel)) {
nextMsPerPixel = clampMsPerPixel(restoreState.msPerPixel);
}
if (Number.isFinite(restoreState.viewStartMs)) {
nextViewStart = restoreState.viewStartMs;
}
if (Number.isFinite(restoreState.verticalOffset)) {
nextVerticalOffset = restoreState.verticalOffset;
}
}
timelineState.msPerPixel = nextMsPerPixel;
timelineState.viewStartMs = nextViewStart;
clampTimelineView(true);
timelineState.verticalOffset = clampVerticalOffset(nextVerticalOffset);
// Center the view on today's date if no restore state
if (!restoreState) {
centerTimelineViewOnToday();
}
requestTimelineRender();
}
function centerTimelineViewOnToday() {
const viewportWidth = timelineState.viewportWidth || 0;
if (!viewportWidth) {
return;
}
const msPerPixel = timelineState.msPerPixel || MIN_MS_PER_PIXEL;
const now = Date.now();
const centerOffset = (viewportWidth / 2) * msPerPixel;
timelineState.viewStartMs = now - centerOffset;
clampTimelineView(true);
}
function normalizeTimelineItems(events) {
if (!Array.isArray(events)) return [];
return events
.map((source, index) => {
if (!source) {
return null;
}
const startValue = source.start || source.extendedProps?.start;
if (!startValue) {
return null;
}
const startDate = new Date(startValue);
if (!(startDate instanceof Date) || Number.isNaN(startDate.getTime())) {
return null;
}
const endValue = source.end || source.extendedProps?.end;
let endDate = endValue ? new Date(endValue) : null;
if (!(endDate instanceof Date) || Number.isNaN(endDate?.getTime?.())) {
endDate = new Date(startDate.getTime() + MIN_BAR_DURATION_MS);
}
const statusCode = source.extendedProps?.status_code || source.status_code || 'unknown';
const code = source.extendedProps?.code || source.code || '';
const title = source.title || source.extendedProps?.title || '';
const statusLabel = source.extendedProps?.status || source.status || '';
const memberName = source.extendedProps?.member_name || source.member_name || '';
const prepState = source.extendedProps?.prep_state || source.prep_state || 'none';
const assignedMemberId = source.extendedProps?.assigned_member_id
|| source.assigned_member_id
|| source.member_id
|| null;
// Déterminer si l'item est éditable
let isEditable = canMoveTimelineItems && EDITABLE_STATUSES.includes(statusCode);
// Pour les managers, restreindre aux statuts spécifiques
if (isEditable && isManager) {
const managerAllowedStatuses = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed'];
isEditable = managerAllowedStatuses.includes(statusCode);
}
return {
id: source.id ?? `event-${index}`,
code,
title,
statusCode,
statusLabel,
memberName,
prepState,
maintainTypeLabel: source.extendedProps?.maintain_type_label || source.maintain_type_label || '',
startDate,
endDate,
isEditable: isEditable,
assignedMemberId,
sourceEvent: source
};
})
.filter(Boolean)
.sort((a, b) => a.startDate - b.startDate);
}
function determineTimelineAnchor(items) {
if (!Array.isArray(items) || !items.length) {
return new Date();
}
const now = new Date();
const upcoming = items.find(item => item.startDate >= now);
return upcoming ? new Date(upcoming.startDate) : new Date(items[items.length - 1].startDate);
}
function computeTimelineBounds(items) {
if (!Array.isArray(items) || !items.length) {
return null;
}
let min = Infinity;
let max = -Infinity;
for (const item of items) {
const start = item.startDate?.getTime?.();
const end = item.endDate?.getTime?.();
if (Number.isFinite(start) && start < min) {
min = start;
}
if (Number.isFinite(end) && end > max) {
max = end;
}
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return null;
}
return { min, max };
}
function getTimelineRangeLimits() {
if (!timelineState.bounds) {
return null;
}
const extra = TIMELINE_EXTRA_RANGE_HOURS * 60 * 60 * 1000;
return {
start: timelineState.bounds.min - extra,
end: timelineState.bounds.max + extra
};
}
function ensureTimelineCanvas(container) {
if (timelineState.dom) {
return timelineState.dom;
}
container.innerHTML = `
<div class="canvas-timeline" aria-live="polite">
<div class="canvas-timeline__axis">
<canvas></canvas>
</div>
<div class="canvas-timeline__body">
<canvas></canvas>
<div class="canvas-scrollbar canvas-scrollbar--vertical">
<div class="canvas-scrollbar__thumb"></div>
</div>
<div class="canvas-scrollbar canvas-scrollbar--horizontal">
<div class="canvas-scrollbar__thumb"></div>
</div>
<div class="canvas-timeline__empty">${gettext(TIMELINE_EMPTY_MESSAGE)}</div>
</div>
</div>
`;
const axisCanvas = container.querySelector('.canvas-timeline__axis canvas');
const bodyCanvas = container.querySelector('.canvas-timeline__body canvas');
const bodyEl = container.querySelector('.canvas-timeline__body');
const emptyEl = container.querySelector('.canvas-timeline__empty');
const verticalScrollbar = container.querySelector('.canvas-scrollbar--vertical');
const horizontalScrollbar = container.querySelector('.canvas-scrollbar--horizontal');
const dom = {
axisCanvas,
axisCtx: axisCanvas.getContext('2d'),
bodyCanvas,
bodyCtx: bodyCanvas.getContext('2d'),
bodyEl,
emptyEl,
verticalScrollbar,
verticalThumb: verticalScrollbar?.querySelector('.canvas-scrollbar__thumb') || null,
horizontalScrollbar,
horizontalThumb: horizontalScrollbar?.querySelector('.canvas-scrollbar__thumb') || null
};
setupTimelineCanvasEvents(dom);
setupTimelineScrollbarEvents(dom);
timelineState.dom = dom;
return dom;
}
function setupTimelineCanvasEvents(dom) {
const body = dom.bodyEl;
const handlePointerDown = event => {
if (event.button !== 0) return;
const coords = getCanvasCoordinates(event);
if (attemptTimelineItemDrag(coords, event)) {
return;
}
if (timelineState.pointer.active) return;
let captureTarget = event.target;
if (captureTarget?.setPointerCapture) {
try {
captureTarget.setPointerCapture(event.pointerId);
} catch (error) {
captureTarget = body;
body.setPointerCapture?.(event.pointerId);
}
} else {
captureTarget = body;
body.setPointerCapture?.(event.pointerId);
}
timelineState.pointer.active = true;
timelineState.pointer.pointerId = event.pointerId;
timelineState.pointer.lastX = event.clientX;
timelineState.pointer.lastY = event.clientY;
timelineState.pointer.moved = false;
timelineState.pointer.downTime = Date.now();
timelineState.pointer.captureTarget = captureTarget?.releasePointerCapture ? captureTarget : null;
body.classList.add('is-panning');
body.classList.remove('is-hovering-item');
timelineState.hoverCoords = null;
};
const handlePointerMove = event => {
if (timelineState.drag?.active && timelineState.drag.pointerId === event.pointerId) {
updateTimelineItemDrag(event);
return;
}
const pointer = timelineState.pointer;
if (!pointer.active || pointer.pointerId !== event.pointerId) return;
const dx = event.clientX - pointer.lastX;
const dy = event.clientY - pointer.lastY;
if (dx !== 0 || dy !== 0) {
pointer.moved = true;
}
timelineState.viewStartMs -= dx * timelineState.msPerPixel;
timelineState.verticalOffset = clampVerticalOffset(timelineState.verticalOffset - dy);
pointer.lastX = event.clientX;
pointer.lastY = event.clientY;
clampTimelineView();
requestTimelineRender();
};
const handlePointerUp = event => {
if (timelineState.drag?.active && timelineState.drag.pointerId === event.pointerId) {
finishTimelineItemDrag(event);
return;
}
const pointer = timelineState.pointer;
if (!pointer.active || pointer.pointerId !== event.pointerId) return;
pointer.active = false;
pointer.captureTarget?.releasePointerCapture?.(event.pointerId);
pointer.captureTarget = null;
body.classList.remove('is-panning');
if (!pointer.moved && Date.now() - pointer.downTime < 300) {
handleTimelineClick(event);
}
updateHoverCursor(event);
};
const handlePointerCancel = event => {
if (timelineState.drag?.active && timelineState.drag.pointerId === event.pointerId) {
finishTimelineItemDrag(event, { cancel: true });
return;
}
if (timelineState.pointer.pointerId === event.pointerId) {
timelineState.pointer.active = false;
timelineState.pointer.captureTarget?.releasePointerCapture?.(event.pointerId);
timelineState.pointer.captureTarget = null;
body.classList.remove('is-panning');
body.classList.remove('is-hovering-item');
timelineState.hoverCoords = null;
}
};
const handleWheel = event => {
if (!timelineState.items.length) return;
event.preventDefault();
const coords = getCanvasCoordinates(event);
const anchorX = coords ? coords.x : (timelineState.viewportWidth || 0) / 2;
const anchorTime = timelineState.viewStartMs + anchorX * timelineState.msPerPixel;
const zoomFactor = Math.exp(event.deltaY * 0.001);
const newMsPerPixel = clampMsPerPixel(timelineState.msPerPixel * zoomFactor);
timelineState.msPerPixel = newMsPerPixel;
timelineState.viewStartMs = anchorTime - anchorX * newMsPerPixel;
if (Math.abs(event.deltaX) > 0.5) {
timelineState.viewStartMs += event.deltaX * timelineState.msPerPixel;
}
clampTimelineView();
requestTimelineRender();
};
const handleHoverPointerMove = event => {
if (timelineState.pointer.active || timelineState.drag?.active) return;
updateHoverCursor(event);
};
const handlePointerLeave = () => {
if (!timelineState.pointer.active && !timelineState.drag?.active) {
body.classList.remove('is-hovering-item');
timelineState.hoverCoords = null;
}
};
body.addEventListener('pointerdown', handlePointerDown);
body.addEventListener('pointermove', handlePointerMove);
body.addEventListener('pointermove', handleHoverPointerMove);
body.addEventListener('pointerup', handlePointerUp);
body.addEventListener('pointercancel', handlePointerCancel);
body.addEventListener('pointerleave', handlePointerLeave);
body.addEventListener('wheel', handleWheel, { passive: false });
}
function setupTimelineScrollbarEvents(dom) {
const verticalThumb = dom.verticalThumb;
const horizontalThumb = dom.horizontalThumb;
verticalThumb?.addEventListener('pointerdown', event => {
event.stopPropagation();
startScrollbarDrag('vertical', event);
});
horizontalThumb?.addEventListener('pointerdown', event => {
event.stopPropagation();
startScrollbarDrag('horizontal', event);
});
}
function startScrollbarDrag(axis, event) {
if (timelineState.scrollbarDrag.active) return;
event.preventDefault();
const dom = timelineState.dom;
if (!dom) return;
const metrics = timelineState.contentMetrics;
const horizontalRange = getHorizontalScrollableRangeMs();
if (axis === 'vertical' && (!metrics || metrics.totalHeight <= (timelineState.viewportHeight || 0))) {
return;
}
if (axis === 'horizontal' && horizontalRange <= 0) {
return;
}
const drag = timelineState.scrollbarDrag;
drag.active = true;
drag.axis = axis;
drag.pointerId = event.pointerId;
drag.startPos = axis === 'vertical' ? event.clientY : event.clientX;
drag.startValue = axis === 'vertical' ? timelineState.verticalOffset : timelineState.viewStartMs;
drag.trackLength = axis === 'vertical'
? dom.verticalScrollbar?.getBoundingClientRect().height || 0
: dom.horizontalScrollbar?.getBoundingClientRect().width || 0;
drag.thumbLength = axis === 'vertical'
? dom.verticalThumb?.getBoundingClientRect().height || 0
: dom.horizontalThumb?.getBoundingClientRect().width || 0;
if (!drag.trackLength || drag.trackLength <= drag.thumbLength) {
drag.active = false;
return;
}
drag.trackScrollable = Math.max(1, drag.trackLength - drag.thumbLength);
drag.totalScrollable = axis === 'vertical'
? Math.max(0, (metrics?.totalHeight || 0) - (timelineState.viewportHeight || 0))
: horizontalRange;
if (drag.totalScrollable <= 0) {
drag.active = false;
return;
}
window.addEventListener('pointermove', handleScrollbarDragMove, { passive: false });
window.addEventListener('pointerup', handleScrollbarDragEnd);
window.addEventListener('pointercancel', handleScrollbarDragEnd);
}
function handleScrollbarDragMove(event) {
const drag = timelineState.scrollbarDrag;
if (!drag.active || drag.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const currentPos = drag.axis === 'vertical' ? event.clientY : event.clientX;
const delta = currentPos - drag.startPos;
const ratio = delta / drag.trackScrollable;
if (drag.axis === 'vertical') {
const newOffset = clampVerticalOffset(drag.startValue + ratio * drag.totalScrollable);
timelineState.verticalOffset = newOffset;
} else {
timelineState.viewStartMs = drag.startValue + ratio * drag.totalScrollable;
clampTimelineView();
}
requestTimelineRender();
}
function handleScrollbarDragEnd(event) {
const drag = timelineState.scrollbarDrag;
if (!drag.active) {
window.removeEventListener('pointermove', handleScrollbarDragMove);
window.removeEventListener('pointerup', handleScrollbarDragEnd);
window.removeEventListener('pointercancel', handleScrollbarDragEnd);
return;
}
if (event && drag.pointerId !== event.pointerId) {
return;
}
drag.active = false;
drag.axis = null;
drag.pointerId = null;
window.removeEventListener('pointermove', handleScrollbarDragMove);
window.removeEventListener('pointerup', handleScrollbarDragEnd);
window.removeEventListener('pointercancel', handleScrollbarDragEnd);
}
function updateTimelineCanvasSize() {
const dom = timelineState.dom;
if (!dom) return;
const width = dom.bodyEl.clientWidth || dom.bodyEl.offsetWidth || 800;
const height = dom.bodyEl.clientHeight || 480;
const ratio = window.devicePixelRatio || 1;
function sizeCanvas(canvas, ctx, cssWidth, cssHeight) {
if (!canvas || !ctx) return;
const actualWidth = Math.max(1, Math.round(cssWidth * ratio));
const actualHeight = Math.max(1, Math.round(cssHeight * ratio));
if (canvas.width !== actualWidth || canvas.height !== actualHeight) {
canvas.width = actualWidth;
canvas.height = actualHeight;
}
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
}
sizeCanvas(dom.axisCanvas, dom.axisCtx, width, TIMELINE_AXIS_HEIGHT);
sizeCanvas(dom.bodyCanvas, dom.bodyCtx, width, height);
timelineState.viewportWidth = width;
timelineState.viewportHeight = height;
}
function requestTimelineRender() {
if (!timelineState.dom) return;
if (timelineState._pendingFrame) {
return;
}
timelineState._pendingFrame = requestAnimationFrame(() => {
timelineState._pendingFrame = null;
renderTimelineCanvas();
});
}
function renderTimelineCanvas() {
const dom = timelineState.dom;
if (!dom) return;
updateTimelineCanvasSize();
const width = timelineState.viewportWidth;
const height = timelineState.viewportHeight;
dom.bodyCtx.clearRect(0, 0, width, height);
dom.axisCtx.clearRect(0, 0, width, TIMELINE_AXIS_HEIGHT);
if (!timelineState.items.length) {
timelineState.hitRegions = [];
timelineState.contentMetrics = null;
dom.emptyEl?.classList.remove('d-none');
hideScrollbars();
timelineState.hoverCoords = null;
dom.bodyEl?.classList.remove('is-hovering-item');
return;
}
dom.emptyEl?.classList.add('d-none');
clampTimelineView();
timelineState.verticalOffset = clampVerticalOffset(timelineState.verticalOffset);
const tickStepMs = pickTickStepMs();
drawAxis(dom.axisCtx, width, tickStepMs);
const totalHeight = timelineState.items.length * TIMELINE_ROW_HEIGHT;
drawTimelineRows(dom.bodyCtx, width, height, tickStepMs);
updateContentMetrics(totalHeight);
updateScrollbars(totalHeight);
updateHoverCursor();
}
function drawAxis(ctx, width, tickStepMs) {
ctx.save();
ctx.fillStyle = '#f8f9fa';
ctx.fillRect(0, 0, width, TIMELINE_AXIS_HEIGHT);
ctx.strokeStyle = 'rgba(15, 23, 42, 0.15)';
ctx.beginPath();
ctx.moveTo(0, TIMELINE_AXIS_HEIGHT - 0.5);
ctx.lineTo(width, TIMELINE_AXIS_HEIGHT - 0.5);
ctx.stroke();
const showDayLabels = tickStepMs < DAY_MS;
if (showDayLabels) {
drawAxisDayLabels(ctx, width);
}
const viewStart = timelineState.viewStartMs || 0;
const viewEnd = viewStart + width * timelineState.msPerPixel;
const firstTick = Math.floor(viewStart / tickStepMs) * tickStepMs;
ctx.font = '500 12px "Inter", "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(15, 23, 42, 0.75)';
ctx.textBaseline = 'top';
const hourLabelY = showDayLabels ? 22 : 4;
for (let ms = firstTick; ms <= viewEnd; ms += tickStepMs) {
const x = (ms - viewStart) / timelineState.msPerPixel;
if (x < -50 || x > width + 50) continue;
ctx.strokeStyle = 'rgba(15, 23, 42, 0.12)';
ctx.beginPath();
ctx.moveTo(x, TIMELINE_AXIS_HEIGHT - 12);
ctx.lineTo(x, TIMELINE_AXIS_HEIGHT);
ctx.stroke();
const label = formatAxisLabel(new Date(ms), tickStepMs);
const textWidth = ctx.measureText(label).width;
ctx.fillText(label, x - textWidth / 2, hourLabelY);
}
drawAxisNowMarker(ctx, width);
ctx.restore();
}
function drawAxisDayLabels(ctx, width) {
const viewStart = timelineState.viewStartMs || 0;
const viewEnd = viewStart + width * timelineState.msPerPixel;
const firstDay = Math.floor(viewStart / DAY_MS) * DAY_MS;
ctx.save();
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(15, 23, 42, 0.85)';
ctx.textBaseline = 'top';
ctx.strokeStyle = 'rgba(15, 23, 42, 0.08)';
for (let ms = firstDay; ms <= viewEnd; ms += DAY_MS) {
const x = (ms - viewStart) / timelineState.msPerPixel;
const nextMs = ms + DAY_MS;
const nextX = (Math.min(nextMs, viewEnd) - viewStart) / timelineState.msPerPixel;
const widthPx = Math.max(24, nextX - x);
const label = axisDayFormatter.format(new Date(ms));
const textWidth = ctx.measureText(label).width;
const center = x + widthPx / 2;
ctx.fillText(label, center - textWidth / 2, 4);
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, TIMELINE_AXIS_HEIGHT);
ctx.stroke();
}
ctx.restore();
}
function drawAxisNowMarker(ctx, width) {
const now = Date.now();
const viewStart = timelineState.viewStartMs || 0;
const viewEnd = viewStart + width * timelineState.msPerPixel;
if (now < viewStart || now > viewEnd) {
return;
}
const x = (now - viewStart) / timelineState.msPerPixel;
ctx.strokeStyle = 'rgba(220, 53, 69, 0.8)';
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, TIMELINE_AXIS_HEIGHT);
ctx.stroke();
}
function drawTimelineRows(ctx, width, height, tickStepMs) {
ctx.save();
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
drawVerticalGridLines(ctx, width, height, tickStepMs);
const startIndex = Math.max(0, Math.floor(timelineState.verticalOffset / TIMELINE_ROW_HEIGHT) - 1);
const endIndex = Math.min(timelineState.items.length, Math.ceil((timelineState.verticalOffset + height) / TIMELINE_ROW_HEIGHT) + 1);
timelineState.hitRegions = [];
for (let i = startIndex; i < endIndex; i++) {
const rowTop = i * TIMELINE_ROW_HEIGHT - timelineState.verticalOffset;
const isAlt = i % 2 === 1;
ctx.fillStyle = isAlt ? 'rgba(15, 23, 42, 0.02)' : '#fff';
ctx.fillRect(0, rowTop, width, TIMELINE_ROW_HEIGHT);
ctx.strokeStyle = 'rgba(15, 23, 42, 0.08)';
ctx.beginPath();
ctx.moveTo(0, rowTop + TIMELINE_ROW_HEIGHT);
ctx.lineTo(width, rowTop + TIMELINE_ROW_HEIGHT);
ctx.stroke();
drawTimelineBar(ctx, timelineState.items[i], rowTop, width, i);
}
drawBodyNowMarker(ctx, width, height);
drawTimelineDragPreview(ctx, width, height);
ctx.restore();
}
function drawVerticalGridLines(ctx, width, height, tickStepMs) {
const viewStart = timelineState.viewStartMs || 0;
const viewEnd = viewStart + width * timelineState.msPerPixel;
const firstTick = Math.floor(viewStart / tickStepMs) * tickStepMs;
ctx.save();
ctx.strokeStyle = 'rgba(15, 23, 42, 0.06)';
ctx.setLineDash([4, 8]);
for (let ms = firstTick; ms <= viewEnd; ms += tickStepMs) {
const x = (ms - viewStart) / timelineState.msPerPixel;
if (x < 0 || x > width) continue;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
ctx.restore();
}
function drawTimelineBar(ctx, item, rowTop, width, index) {
const viewStart = timelineState.viewStartMs || 0;
const startX = (item.startDate.getTime() - viewStart) / timelineState.msPerPixel;
const endX = (item.endDate.getTime() - viewStart) / timelineState.msPerPixel;
if (endX < -120 || startX > width + 120) {
return;
}
const barY = rowTop + (TIMELINE_ROW_HEIGHT - TIMELINE_BAR_HEIGHT) / 2;
const barLeft = Math.max(startX, -60);
const barRight = Math.min(endX, width + 60);
const barWidth = Math.max(6, barRight - barLeft);
const color = STATUS_COLORS[item.statusCode] || DEFAULT_STATUS_COLOR;
const dragState = timelineState.drag;
const isDraggingThisItem = dragState?.active && dragState.itemIndex === index;
const shouldDim = isDraggingThisItem && dragState.hasMoved;
ctx.save();
if (shouldDim) {
ctx.globalAlpha = 0.4;
}
ctx.fillStyle = color;
drawRoundedRect(ctx, barLeft, barY, barWidth, TIMELINE_BAR_HEIGHT, 13);
ctx.fill();
const labelParts = [item.code, item.title].filter(Boolean);
const label = labelParts.join(' · ');
if (label) {
const textColor = idealTextColor(color);
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle';
const availableWidth = Math.max(0, barWidth - 24);
const labelWidth = ctx.measureText(label).width;
if (labelWidth <= availableWidth) {
ctx.fillStyle = textColor;
ctx.save();
ctx.beginPath();
ctx.rect(barLeft + 8, barY, Math.max(0, barWidth - 16), TIMELINE_BAR_HEIGHT);
ctx.clip();
ctx.fillText(label, barLeft + 12, barY + TIMELINE_BAR_HEIGHT / 2);
ctx.restore();
} else {
const outsideSpacing = 10;
const barRight = barLeft + barWidth;
const rightSpace = width - barRight - outsideSpacing;
const leftSpace = barLeft - outsideSpacing;
const prefersRight = rightSpace >= labelWidth || rightSpace >= leftSpace;
ctx.fillStyle = 'rgba(15, 23, 42, 0.85)';
let textX;
if (prefersRight) {
textX = Math.min(width - labelWidth - 4, barRight + outsideSpacing);
} else {
textX = Math.max(4, barLeft - outsideSpacing - labelWidth);
}
ctx.fillText(label, textX, barY + TIMELINE_BAR_HEIGHT / 2);
}
}
ctx.restore();
timelineState.hitRegions.push({
index,
x: barLeft,
y: barY,
width: barWidth,
height: TIMELINE_BAR_HEIGHT
});
}
function drawBodyNowMarker(ctx, width, height) {
const now = Date.now();
const viewStart = timelineState.viewStartMs || 0;
const viewEnd = viewStart + width * timelineState.msPerPixel;
if (now < viewStart || now > viewEnd) {
return;
}
const x = (now - viewStart) / timelineState.msPerPixel;
ctx.save();
ctx.strokeStyle = 'rgba(220, 53, 69, 0.65)';
ctx.setLineDash([6, 6]);
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
ctx.restore();
}
function updateContentMetrics(totalHeight) {
const range = getTimelineRangeLimits();
timelineState.contentMetrics = {
totalHeight,
rangeStartMs: range?.start ?? 0,
rangeEndMs: range?.end ?? 0
};
}
function updateScrollbars(totalHeight) {
const dom = timelineState.dom;
if (!dom) return;
updateVerticalScrollbar(dom, totalHeight);
updateHorizontalScrollbar(dom);
}
function hideScrollbars() {
timelineState.dom?.verticalScrollbar?.classList.add('is-hidden');
timelineState.dom?.horizontalScrollbar?.classList.add('is-hidden');
}
function updateVerticalScrollbar(dom, totalHeight) {
const track = dom.verticalScrollbar;
const thumb = dom.verticalThumb;
if (!track || !thumb) return;
if (!timelineState.items.length || totalHeight <= (timelineState.viewportHeight || 0)) {
track.classList.add('is-hidden');
return;
}
const trackSize = track.getBoundingClientRect().height || 0;
if (!trackSize) {
track.classList.add('is-hidden');
return;
}
const ratio = (timelineState.viewportHeight || 1) / totalHeight;
const thumbSize = Math.max(TIMELINE_SCROLLBAR_MIN_THUMB, trackSize * ratio);
thumb.style.height = `${thumbSize}px`;
const scrollableContent = Math.max(1, totalHeight - (timelineState.viewportHeight || 0));
const trackScrollable = Math.max(1, trackSize - thumbSize);
const offsetRatio = timelineState.verticalOffset / scrollableContent;
thumb.style.transform = `translateY(${offsetRatio * trackScrollable}px)`;
track.classList.remove('is-hidden');
}
function updateHorizontalScrollbar(dom) {
const track = dom.horizontalScrollbar;
const thumb = dom.horizontalThumb;
if (!track || !thumb) return;
const range = getTimelineRangeLimits();
if (!range) {
track.classList.add('is-hidden');
return;
}
const totalRange = Math.max(1, range.end - range.start);
const viewportMs = Math.min(totalRange, (timelineState.viewportWidth || 0) * (timelineState.msPerPixel || MIN_MS_PER_PIXEL));
if (viewportMs >= totalRange - 1) {
track.classList.add('is-hidden');
return;
}
const trackSize = track.getBoundingClientRect().width || 0;
if (!trackSize) {
track.classList.add('is-hidden');
return;
}
const ratio = viewportMs / totalRange;
const thumbSize = Math.max(TIMELINE_SCROLLBAR_MIN_THUMB, trackSize * ratio);
thumb.style.width = `${thumbSize}px`;
const trackScrollable = Math.max(1, trackSize - thumbSize);
const scrollableRange = Math.max(1, totalRange - viewportMs);
const start = Math.max(0, Math.min(scrollableRange, timelineState.viewStartMs - range.start));
const offsetRatio = start / scrollableRange;
thumb.style.transform = `translateX(${offsetRatio * trackScrollable}px)`;
track.classList.remove('is-hidden');
}
function handleTimelineClick(event) {
const coords = getCanvasCoordinates(event);
if (!coords) return;
const hit = timelineState.hitRegions.find(region =>
coords.x >= region.x &&
coords.x <= region.x + region.width &&
coords.y >= region.y &&
coords.y <= region.y + region.height
);
if (!hit) return;
const item = timelineState.items[hit.index];
if (item) {
openPlanningModal(item.sourceEvent || item);
}
}
function updateHoverCursor(event) {
const dom = timelineState.dom;
if (!dom || timelineState.pointer.active || timelineState.drag?.active) {
dom?.bodyEl?.classList.remove('is-hovering-item');
return;
}
let coords = event ? getCanvasCoordinates(event) : timelineState.hoverCoords;
if (event) {
timelineState.hoverCoords = coords;
}
if (!coords) {
dom.bodyEl.classList.remove('is-hovering-item');
timelineState.hoverCoords = null;
return;
}
const hit = timelineState.hitRegions.some(region =>
coords.x >= region.x &&
coords.x <= region.x + region.width &&
coords.y >= region.y &&
coords.y <= region.y + region.height
);
if (hit) {
dom.bodyEl.classList.add('is-hovering-item');
} else {
dom.bodyEl.classList.remove('is-hovering-item');
}
}
function getCanvasCoordinates(event) {
const dom = timelineState.dom;
if (!dom) return null;
const rect = dom.bodyCanvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
function attemptTimelineItemDrag(coords, event) {
if (!coords || !timelineState.hitRegions?.length) {
return false;
}
const hit = timelineState.hitRegions.find(region =>
coords.x >= region.x &&
coords.x <= region.x + region.width &&
coords.y >= region.y &&
coords.y <= region.y + region.height
);
if (!hit) return false;
const item = timelineState.items[hit.index];
if (!item || !item.isEditable) {
return false;
}
const startMs = item.startDate?.getTime?.();
const endMs = item.endDate?.getTime?.();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return false;
}
let captureTarget = event.target;
if (captureTarget?.setPointerCapture) {
try {
captureTarget.setPointerCapture(event.pointerId);
} catch (error) {
captureTarget = timelineState.dom?.bodyEl;
captureTarget?.setPointerCapture?.(event.pointerId);
}
} else {
captureTarget = timelineState.dom?.bodyEl;
captureTarget?.setPointerCapture?.(event.pointerId);
}
const drag = timelineState.drag;
drag.active = true;
drag.pointerId = event.pointerId;
drag.itemIndex = hit.index;
drag.item = item;
drag.durationMs = Math.max(MIN_DRAG_DURATION_MS, endMs - startMs);
drag.startMs = startMs;
drag.currentStartMs = startMs;
drag.currentEndMs = startMs + drag.durationMs;
drag.startClientX = event.clientX;
drag.hasMoved = false;
drag.preview = null;
drag.captureTarget = captureTarget?.releasePointerCapture ? captureTarget : null;
timelineState.hoverCoords = null;
timelineState.dom?.bodyEl?.classList.add('is-dragging-item');
return true;
}
function updateTimelineItemDrag(event) {
const drag = timelineState.drag;
if (!drag.active || drag.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const deltaPx = event.clientX - drag.startClientX;
if (!drag.hasMoved) {
if (Math.abs(deltaPx) <= 3) {
return;
}
drag.hasMoved = true;
}
const deltaMs = deltaPx * (timelineState.msPerPixel || MIN_MS_PER_PIXEL);
const proposedStart = drag.startMs + deltaMs;
const clampedStart = clampTimelineDragStart(proposedStart, drag.durationMs);
drag.currentStartMs = clampedStart;
drag.currentEndMs = clampedStart + drag.durationMs;
drag.preview = {
startMs: drag.currentStartMs,
endMs: drag.currentEndMs
};
requestTimelineRender();
}
function finishTimelineItemDrag(event, { cancel = false } = {}) {
const drag = timelineState.drag;
if (!drag.active || (event && drag.pointerId !== event.pointerId)) {
return;
}
drag.captureTarget?.releasePointerCapture?.(drag.pointerId);
timelineState.dom?.bodyEl?.classList.remove('is-dragging-item');
const wasMoved = drag.hasMoved;
const item = drag.item;
const durationMs = drag.durationMs || MIN_DRAG_DURATION_MS;
const dropStartMs = wasMoved ? (drag.currentStartMs ?? drag.startMs) : drag.startMs;
resetTimelineDragState();
requestTimelineRender();
if (cancel) {
return;
}
if (!item) {
return;
}
if (!wasMoved) {
openPlanningModal(item.sourceEvent || item);
return;
}
const snappedStart = snapToHour(dropStartMs);
const startDate = new Date(snappedStart);
const endDate = new Date(snappedStart + durationMs);
const interventionId = item.id ?? item.sourceEvent?.id;
if (!interventionId) {
return;
}
const memberId = item.assignedMemberId ?? item.sourceEvent?.assigned_member_id ?? null;
persistScheduleUpdate({
interventionId,
memberId,
start: startDate,
end: endDate
});
}
function resetTimelineDragState() {
const drag = timelineState.drag;
drag.active = false;
drag.pointerId = null;
drag.itemIndex = null;
drag.item = null;
drag.durationMs = 0;
drag.startMs = 0;
drag.currentStartMs = 0;
drag.currentEndMs = 0;
drag.startClientX = 0;
drag.hasMoved = false;
drag.preview = null;
drag.captureTarget = null;
}
function clampTimelineDragStart(startMs, durationMs) {
const range = getTimelineRangeLimits();
if (!range) {
return startMs;
}
const minStart = range.start;
const maxStart = Math.max(minStart, range.end - durationMs);
return Math.min(Math.max(startMs, minStart), maxStart);
}
function drawTimelineDragPreview(ctx, width, height) {
const drag = timelineState.drag;
if (!drag?.active || !drag.hasMoved || !drag.preview) {
return;
}
const rowTop = drag.itemIndex * TIMELINE_ROW_HEIGHT - timelineState.verticalOffset;
if (rowTop > height + TIMELINE_ROW_HEIGHT || rowTop + TIMELINE_ROW_HEIGHT < -TIMELINE_ROW_HEIGHT) {
return;
}
const viewStart = timelineState.viewStartMs || 0;
const startX = (drag.preview.startMs - viewStart) / timelineState.msPerPixel;
const endX = (drag.preview.endMs - viewStart) / timelineState.msPerPixel;
const barY = rowTop + (TIMELINE_ROW_HEIGHT - TIMELINE_BAR_HEIGHT) / 2;
const barWidth = Math.max(6, endX - startX);
const color = STATUS_COLORS[drag.item?.statusCode] || DEFAULT_STATUS_COLOR;
const label = [drag.item?.code, drag.item?.title].filter(Boolean).join(' · ');
ctx.save();
ctx.globalAlpha = 0.95;
ctx.fillStyle = color;
drawRoundedRect(ctx, startX, barY, barWidth, TIMELINE_BAR_HEIGHT, 13);
ctx.fill();
if (label) {
const textColor = idealTextColor(color);
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle';
ctx.fillStyle = textColor;
ctx.save();
ctx.beginPath();
ctx.rect(startX + 8, barY, Math.max(0, barWidth - 16), TIMELINE_BAR_HEIGHT);
ctx.clip();
ctx.fillText(label, startX + 12, barY + TIMELINE_BAR_HEIGHT / 2);
ctx.restore();
}
ctx.restore();
}
function snapshotTimelineViewState() {
if (!timelineState.dom) {
return null;
}
return {
viewStartMs: timelineState.viewStartMs,
msPerPixel: timelineState.msPerPixel,
verticalOffset: timelineState.verticalOffset
};
}
function clampTimelineView(force = false) {
const range = getTimelineRangeLimits();
if (!range) {
return;
}
const viewWidthMs = Math.max(1, (timelineState.viewportWidth || 1) * (timelineState.msPerPixel || MIN_MS_PER_PIXEL));
const maxStart = Math.max(range.start, range.end - viewWidthMs);
const minStart = range.start;
if (!Number.isFinite(timelineState.viewStartMs) || force) {
timelineState.viewStartMs = Math.min(Math.max(minStart, timelineState.viewStartMs || minStart), maxStart);
return;
}
timelineState.viewStartMs = Math.min(Math.max(timelineState.viewStartMs, minStart), maxStart);
}
function getHorizontalScrollableRangeMs() {
const range = getTimelineRangeLimits();
if (!range) {
return 0;
}
const totalRange = Math.max(1, range.end - range.start);
const viewportMs = Math.min(totalRange, (timelineState.viewportWidth || 0) * (timelineState.msPerPixel || MIN_MS_PER_PIXEL));
return Math.max(0, totalRange - viewportMs);
}
function clampVerticalOffset(value) {
if (!timelineState.items.length) return 0;
const totalHeight = timelineState.items.length * TIMELINE_ROW_HEIGHT;
const maxOffset = Math.max(0, totalHeight - (timelineState.viewportHeight || 0));
return Math.min(Math.max(0, value), maxOffset);
}
function computeInitialVerticalOffset(anchorIndex) {
const target = anchorIndex * TIMELINE_ROW_HEIGHT - (timelineState.viewportHeight || 0) / 2 + TIMELINE_ROW_HEIGHT / 2;
return clampVerticalOffset(Math.max(0, target));
}
function findAnchorRowIndex(items, anchorDate) {
if (!Array.isArray(items) || !items.length) {
return 0;
}
if (!(anchorDate instanceof Date) || Number.isNaN(anchorDate.getTime())) {
return 0;
}
const anchorTime = anchorDate.getTime();
let closestIndex = 0;
let smallestDelta = Infinity;
items.forEach((item, index) => {
const startTime = item.startDate?.getTime?.() || 0;
const delta = Math.abs(startTime - anchorTime);
if (delta < smallestDelta) {
smallestDelta = delta;
closestIndex = index;
}
});
return closestIndex;
}
function clampMsPerPixel(value) {
return Math.min(Math.max(value, MIN_MS_PER_PIXEL), MAX_MS_PER_PIXEL);
}
function pickTickStepMs(msPerPixelValue = timelineState.msPerPixel) {
const target = msPerPixelValue * 140;
const steps = [
15 * 60 * 1000,
30 * 60 * 1000,
60 * 60 * 1000,
3 * 60 * 60 * 1000,
6 * 60 * 60 * 1000,
12 * 60 * 60 * 1000,
DAY_MS,
3 * DAY_MS,
7 * DAY_MS,
14 * DAY_MS,
30 * DAY_MS
];
for (const step of steps) {
if (step >= target) {
return step;
}
}
return steps[steps.length - 1];
}
function drawRoundedRect(ctx, x, y, width, height, radius = 12) {
const r = Math.min(radius, Math.abs(width) / 2, Math.abs(height) / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + width - r, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + r);
ctx.lineTo(x + width, y + height - r);
ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
ctx.lineTo(x + r, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function formatAxisLabel(date, tickStepMs) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return '';
}
if (tickStepMs < DAY_MS) {
return axisHourFormatter.format(date);
}
if (tickStepMs < 7 * DAY_MS) {
return axisDayFormatter.format(date);
}
return new Intl.DateTimeFormat(DOCUMENT_LOCALE, { day: 'numeric', month: 'short', year: 'numeric' }).format(date);
}
function buildMembersTimeline(data) {
if (!membersTimelineWrapper) return;
ensureMembersCanvas(membersTimelineWrapper);
const teams = Array.isArray(data?.teams) ? data.teams : [];
membersState.rows = normalizeMembersRows(teams);
membersState._initiallyCentered = false;
membersState.items = membersState.rows
.filter(row => row.type === 'member')
.flatMap(row => row.interventions.map(intervention => ({
...intervention,
rowId: row.id,
memberId: row.memberId
})));
membersState.bounds = computeMembersBounds(membersState.rows);
membersState.anchorDate = determineTimelineAnchor(membersState.items);
const restoreState = membersState._pendingRestore;
membersState._pendingRestore = null;
const config = VIEW_CONFIGS.week;
const defaultMsPerPixel = (60 * 60 * 1000) / config.pixelsPerHour;
membersState.msPerPixel = defaultMsPerPixel;
const anchorTime = membersState.anchorDate?.getTime() || Date.now();
membersState.viewStartMs = anchorTime - config.windowHoursBefore * 60 * 60 * 1000;
clampMembersView(true);
membersState.verticalOffset = 0;
membersState.hoverCoords = null;
membersState._initiallyCentered = false;
if (restoreState) {
if (Number.isFinite(restoreState.msPerPixel)) {
membersState.msPerPixel = clampMsPerPixel(restoreState.msPerPixel);
}
if (Number.isFinite(restoreState.viewStartMs)) {
membersState.viewStartMs = restoreState.viewStartMs;
}
if (Number.isFinite(restoreState.verticalOffset)) {
membersState.verticalOffset = restoreState.verticalOffset;
}
membersState._initiallyCentered = true;
clampMembersView(true);
}
updateMembersCanvasSize();
if (!restoreState) {
centerMembersViewOnToday();
} else {
clampMembersView(true);
}
computeMembersRowLayouts();
membersState.verticalOffset = clampMembersVerticalOffset(membersState.verticalOffset);
requestMembersRender();
renderBacklog(data);
}
function ensureMembersCanvas(container) {
if (membersState.dom) {
return membersState.dom;
}
container.innerHTML = `
<div class="canvas-timeline members-canvas" aria-live="polite">
<div class="canvas-timeline__axis">
<canvas></canvas>
</div>
<div class="canvas-timeline__body">
<canvas></canvas>
<div class="canvas-scrollbar canvas-scrollbar--vertical">
<div class="canvas-scrollbar__thumb"></div>
</div>
<div class="canvas-scrollbar canvas-scrollbar--horizontal">
<div class="canvas-scrollbar__thumb"></div>
</div>
<div class="canvas-timeline__empty">${gettext(MEMBERS_EMPTY_MESSAGE)}</div>
</div>
</div>
`;
const axisCanvas = container.querySelector('.canvas-timeline__axis canvas');
const bodyCanvas = container.querySelector('.canvas-timeline__body canvas');
const bodyEl = container.querySelector('.canvas-timeline__body');
const emptyEl = container.querySelector('.canvas-timeline__empty');
const verticalScrollbar = container.querySelector('.canvas-scrollbar--vertical');
const horizontalScrollbar = container.querySelector('.canvas-scrollbar--horizontal');
const dom = {
axisCanvas,
axisCtx: axisCanvas.getContext('2d'),
bodyCanvas,
bodyCtx: bodyCanvas.getContext('2d'),
bodyEl,
emptyEl,
verticalScrollbar,
verticalThumb: verticalScrollbar?.querySelector('.canvas-scrollbar__thumb') || null,
horizontalScrollbar,
horizontalThumb: horizontalScrollbar?.querySelector('.canvas-scrollbar__thumb') || null
};
setupMembersCanvasEvents(dom);
setupMembersScrollbarEvents(dom);
setupMembersDragAndDrop(dom);
membersState.dom = dom;
return dom;
}
function setupMembersCanvasEvents(dom) {
const body = dom.bodyEl;
const handlePointerDown = event => {
if (event.button !== 0) return;
if (membersState.drag?.active) return;
const coords = getMembersCanvasCoordinates(event);
if (attemptMembersItemDrag(coords, event)) {
return;
}
if (membersState.pointer.active) return;
let captureTarget = event.target;
if (captureTarget?.setPointerCapture) {
try {
captureTarget.setPointerCapture(event.pointerId);
} catch (error) {
captureTarget = body;
body.setPointerCapture?.(event.pointerId);
}
} else {
captureTarget = body;
body.setPointerCapture?.(event.pointerId);
}
membersState.pointer.active = true;
membersState.pointer.pointerId = event.pointerId;
membersState.pointer.lastX = event.clientX;
membersState.pointer.lastY = event.clientY;
membersState.pointer.moved = false;
membersState.pointer.downTime = Date.now();
membersState.pointer.captureTarget = captureTarget?.releasePointerCapture ? captureTarget : null;
body.classList.add('is-panning');
body.classList.remove('is-hovering-item');
membersState.hoverCoords = null;
};
const handlePointerMove = event => {
if (membersState.drag?.active && membersState.drag.pointerId === event.pointerId) {
updateMembersItemDrag(event);
return;
}
const pointer = membersState.pointer;
if (!pointer.active || pointer.pointerId !== event.pointerId) {
if (!membersState.pointer.active) {
updateMembersHoverCursor(event);
}
return;
}
const dx = event.clientX - pointer.lastX;
const dy = event.clientY - pointer.lastY;
if (dx !== 0 || dy !== 0) {
pointer.moved = true;
}
membersState.viewStartMs -= dx * membersState.msPerPixel;
membersState.verticalOffset = clampMembersVerticalOffset(membersState.verticalOffset - dy);
pointer.lastX = event.clientX;
pointer.lastY = event.clientY;
clampMembersView();
requestMembersRender();
};
const handlePointerUp = event => {
if (membersState.drag?.active && membersState.drag.pointerId === event.pointerId) {
finishMembersItemDrag(event);
return;
}
const pointer = membersState.pointer;
if (!pointer.active || pointer.pointerId !== event.pointerId) return;
pointer.active = false;
pointer.captureTarget?.releasePointerCapture?.(event.pointerId);
pointer.captureTarget = null;
body.classList.remove('is-panning');
if (!pointer.moved && Date.now() - pointer.downTime < 300) {
handleMembersClick(event);
}
updateMembersHoverCursor(event);
};
const handlePointerCancel = event => {
if (membersState.drag?.active && membersState.drag.pointerId === event.pointerId) {
finishMembersItemDrag(event, { cancel: true });
return;
}
if (membersState.pointer.pointerId === event.pointerId) {
membersState.pointer.active = false;
membersState.pointer.captureTarget?.releasePointerCapture?.(event.pointerId);
membersState.pointer.captureTarget = null;
body.classList.remove('is-panning');
body.classList.remove('is-hovering-item');
membersState.hoverCoords = null;
}
};
const handleWheel = event => {
if (!membersState.rows.length) return;
event.preventDefault();
const coords = getMembersCanvasCoordinates(event);
const axisStart = MEMBERS_LABEL_WIDTH;
const anchorX = coords ? Math.max(axisStart, coords.x) : axisStart + (Math.max(0, membersState.viewportWidth - axisStart) / 2);
const anchorTime = membersState.viewStartMs + (anchorX - axisStart) * membersState.msPerPixel;
const zoomFactor = Math.exp(event.deltaY * 0.001);
const newMsPerPixel = clampMsPerPixel(membersState.msPerPixel * zoomFactor);
membersState.msPerPixel = newMsPerPixel;
membersState.viewStartMs = anchorTime - (anchorX - axisStart) * newMsPerPixel;
if (Math.abs(event.deltaX) > 0.5) {
membersState.viewStartMs += event.deltaX * membersState.msPerPixel;
}
clampMembersView();
requestMembersRender();
};
const handlePointerLeave = () => {
if (!membersState.pointer.active && !membersState.drag?.active) {
body.classList.remove('is-hovering-item');
membersState.hoverCoords = null;
}
};
body.addEventListener('pointerdown', handlePointerDown);
body.addEventListener('pointermove', handlePointerMove);
body.addEventListener('pointerup', handlePointerUp);
body.addEventListener('pointercancel', handlePointerCancel);
body.addEventListener('pointerleave', handlePointerLeave);
body.addEventListener('wheel', handleWheel, { passive: false });
}
function setupMembersScrollbarEvents(dom) {
dom.verticalThumb?.addEventListener('pointerdown', event => {
event.stopPropagation();
startMembersScrollbarDrag('vertical', event);
});
dom.horizontalThumb?.addEventListener('pointerdown', event => {
event.stopPropagation();
startMembersScrollbarDrag('horizontal', event);
});
}
function startMembersScrollbarDrag(axis, event) {
if (membersState.scrollbarDrag.active) return;
event.preventDefault();
const dom = membersState.dom;
if (!dom) return;
const range = getMembersTimelineRangeLimits();
if (axis === 'horizontal' && !range) {
return;
}
if (axis === 'vertical' && membersState.totalHeight <= (membersState.viewportHeight || 0)) {
return;
}
const drag = membersState.scrollbarDrag;
drag.active = true;
drag.axis = axis;
drag.pointerId = event.pointerId;
drag.startPos = axis === 'vertical' ? event.clientY : event.clientX;
drag.startValue = axis === 'vertical' ? membersState.verticalOffset : membersState.viewStartMs;
drag.trackLength = axis === 'vertical'
? dom.verticalScrollbar?.getBoundingClientRect().height || 0
: dom.horizontalScrollbar?.getBoundingClientRect().width || 0;
drag.thumbLength = axis === 'vertical'
? dom.verticalThumb?.getBoundingClientRect().height || 0
: dom.horizontalThumb?.getBoundingClientRect().width || 0;
if (!drag.trackLength || drag.trackLength <= drag.thumbLength) {
drag.active = false;
return;
}
drag.trackScrollable = Math.max(1, drag.trackLength - drag.thumbLength);
if (axis === 'vertical') {
drag.totalScrollable = Math.max(0, membersState.totalHeight - (membersState.viewportHeight || 0));
} else {
drag.totalScrollable = getMembersHorizontalScrollableRangeMs();
}
if (drag.totalScrollable <= 0) {
drag.active = false;
return;
}
window.addEventListener('pointermove', handleMembersScrollbarDragMove, { passive: false });
window.addEventListener('pointerup', handleMembersScrollbarDragEnd);
window.addEventListener('pointercancel', handleMembersScrollbarDragEnd);
}
function handleMembersScrollbarDragMove(event) {
const drag = membersState.scrollbarDrag;
if (!drag.active || drag.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const currentPos = drag.axis === 'vertical' ? event.clientY : event.clientX;
const delta = currentPos - drag.startPos;
const ratio = delta / drag.trackScrollable;
if (drag.axis === 'vertical') {
const newOffset = clampMembersVerticalOffset(drag.startValue + ratio * drag.totalScrollable);
membersState.verticalOffset = newOffset;
} else {
membersState.viewStartMs = drag.startValue + ratio * drag.totalScrollable;
clampMembersView();
}
requestMembersRender();
}
function handleMembersScrollbarDragEnd(event) {
const drag = membersState.scrollbarDrag;
if (!drag.active) {
window.removeEventListener('pointermove', handleMembersScrollbarDragMove);
window.removeEventListener('pointerup', handleMembersScrollbarDragEnd);
window.removeEventListener('pointercancel', handleMembersScrollbarDragEnd);
return;
}
if (event && drag.pointerId !== event.pointerId) {
return;
}
drag.active = false;
drag.axis = null;
drag.pointerId = null;
window.removeEventListener('pointermove', handleMembersScrollbarDragMove);
window.removeEventListener('pointerup', handleMembersScrollbarDragEnd);
window.removeEventListener('pointercancel', handleMembersScrollbarDragEnd);
}
function updateMembersCanvasSize() {
const dom = membersState.dom;
if (!dom) return;
const width = dom.bodyEl.clientWidth || dom.bodyEl.offsetWidth || 800;
const height = dom.bodyEl.clientHeight || 480;
const ratio = window.devicePixelRatio || 1;
const sizeCanvas = (canvas, ctx, cssWidth, cssHeight) => {
if (!canvas || !ctx) return;
const actualWidth = Math.max(1, Math.round(cssWidth * ratio));
const actualHeight = Math.max(1, Math.round(cssHeight * ratio));
if (canvas.width !== actualWidth || canvas.height !== actualHeight) {
canvas.width = actualWidth;
canvas.height = actualHeight;
}
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
};
sizeCanvas(dom.axisCanvas, dom.axisCtx, width, MEMBERS_AXIS_HEIGHT);
sizeCanvas(dom.bodyCanvas, dom.bodyCtx, width, height);
membersState.viewportWidth = width;
membersState.viewportHeight = height;
}
function centerMembersViewOnToday(force = false) {
if (!force && membersState._initiallyCentered) {
return;
}
const timelineWidth = Math.max(0, (membersState.viewportWidth || 0) - MEMBERS_LABEL_WIDTH);
if (!timelineWidth) {
return;
}
const msPerPixel = membersState.msPerPixel || MIN_MS_PER_PIXEL;
const anchorTime = Date.now();
const centerOffset = (timelineWidth / 2) * msPerPixel;
membersState.viewStartMs = anchorTime - centerOffset;
clampMembersView(true);
membersState._initiallyCentered = true;
}
function requestMembersRender() {
if (!membersState.dom) return;
if (membersState._pendingFrame) {
return;
}
membersState._pendingFrame = requestAnimationFrame(() => {
membersState._pendingFrame = null;
renderMembersCanvas();
});
}
function renderMembersCanvas() {
const dom = membersState.dom;
if (!dom) return;
updateMembersCanvasSize();
computeMembersRowLayouts();
const width = membersState.viewportWidth;
const height = membersState.viewportHeight;
dom.bodyCtx.clearRect(0, 0, width, height);
dom.axisCtx.clearRect(0, 0, width, MEMBERS_AXIS_HEIGHT);
if (!membersState.rows.length) {
membersState.hitRegions = [];
membersState.contentMetrics = null;
dom.emptyEl?.classList.remove('d-none');
hideMembersScrollbars();
return;
}
dom.emptyEl?.classList.add('d-none');
clampMembersView();
membersState.verticalOffset = clampMembersVerticalOffset(membersState.verticalOffset);
const tickStepMs = pickTickStepMs(membersState.msPerPixel);
drawMembersAxis(dom.axisCtx, width, tickStepMs);
drawMembersRows(dom.bodyCtx, width, height, tickStepMs);
updateMembersContentMetrics();
updateMembersScrollbars();
updateMembersHoverCursor();
}
function drawMembersAxis(ctx, width, tickStepMs) {
ctx.save();
ctx.fillStyle = '#f8f9fb';
ctx.fillRect(0, 0, width, MEMBERS_AXIS_HEIGHT);
ctx.fillStyle = '#eef2ff';
ctx.fillRect(0, 0, MEMBERS_LABEL_WIDTH, MEMBERS_AXIS_HEIGHT);
ctx.strokeStyle = 'rgba(15, 23, 42, 0.12)';
ctx.beginPath();
ctx.moveTo(MEMBERS_LABEL_WIDTH + 0.5, 0);
ctx.lineTo(MEMBERS_LABEL_WIDTH + 0.5, MEMBERS_AXIS_HEIGHT);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, MEMBERS_AXIS_HEIGHT - 0.5);
ctx.lineTo(width, MEMBERS_AXIS_HEIGHT - 0.5);
ctx.stroke();
const timelineWidth = Math.max(0, width - MEMBERS_LABEL_WIDTH);
const viewStart = membersState.viewStartMs || 0;
const viewEnd = viewStart + timelineWidth * membersState.msPerPixel;
const showDayLabels = tickStepMs < DAY_MS;
if (showDayLabels) {
drawMembersAxisDayLabels(ctx, width);
}
ctx.font = '500 12px "Inter", "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(15, 23, 42, 0.75)';
ctx.textBaseline = 'top';
const hourLabelY = showDayLabels ? 22 : 6;
const firstTick = Math.floor(viewStart / tickStepMs) * tickStepMs;
for (let ms = firstTick; ms <= viewEnd; ms += tickStepMs) {
const x = MEMBERS_LABEL_WIDTH + (ms - viewStart) / membersState.msPerPixel;
if (x < MEMBERS_LABEL_WIDTH - 50 || x > width + 50) continue;
ctx.strokeStyle = 'rgba(15, 23, 42, 0.12)';
ctx.beginPath();
ctx.moveTo(x, MEMBERS_AXIS_HEIGHT - 12);
ctx.lineTo(x, MEMBERS_AXIS_HEIGHT);
ctx.stroke();
const label = formatAxisLabel(new Date(ms), tickStepMs);
const textWidth = ctx.measureText(label).width;
ctx.fillText(label, x - textWidth / 2, hourLabelY);
}
drawMembersAxisNowMarker(ctx, width);
ctx.restore();
}
function drawMembersAxisDayLabels(ctx, width) {
const timelineWidth = Math.max(0, width - MEMBERS_LABEL_WIDTH);
const viewStart = membersState.viewStartMs || 0;
const viewEnd = viewStart + timelineWidth * membersState.msPerPixel;
const firstDay = Math.floor(viewStart / DAY_MS) * DAY_MS;
ctx.save();
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(15, 23, 42, 0.85)';
ctx.textBaseline = 'top';
ctx.strokeStyle = 'rgba(15, 23, 42, 0.08)';
for (let ms = firstDay; ms <= viewEnd; ms += DAY_MS) {
const x = MEMBERS_LABEL_WIDTH + (ms - viewStart) / membersState.msPerPixel;
const nextMs = ms + DAY_MS;
const nextX = MEMBERS_LABEL_WIDTH + (Math.min(nextMs, viewEnd) - viewStart) / membersState.msPerPixel;
const widthPx = Math.max(24, nextX - x);
const label = axisDayFormatter.format(new Date(ms));
const textWidth = ctx.measureText(label).width;
const center = x + widthPx / 2;
ctx.fillText(label, center - textWidth / 2, 4);
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, MEMBERS_AXIS_HEIGHT);
ctx.stroke();
}
ctx.restore();
}
function drawMembersAxisNowMarker(ctx, width) {
const now = Date.now();
const timelineWidth = Math.max(0, width - MEMBERS_LABEL_WIDTH);
const viewStart = membersState.viewStartMs || 0;
const viewEnd = viewStart + timelineWidth * membersState.msPerPixel;
if (now < viewStart || now > viewEnd) {
return;
}
const x = MEMBERS_LABEL_WIDTH + (now - viewStart) / membersState.msPerPixel;
ctx.strokeStyle = 'rgba(220, 53, 69, 0.8)';
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, MEMBERS_AXIS_HEIGHT);
ctx.stroke();
}
function drawMembersRows(ctx, width, height, tickStepMs) {
ctx.save();
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, MEMBERS_LABEL_WIDTH, height);
ctx.strokeStyle = 'rgba(15, 23, 42, 0.12)';
ctx.beginPath();
ctx.moveTo(MEMBERS_LABEL_WIDTH + 0.5, 0);
ctx.lineTo(MEMBERS_LABEL_WIDTH + 0.5, height);
ctx.stroke();
drawMembersVerticalGridLines(ctx, width, height, tickStepMs);
const viewportTop = membersState.verticalOffset;
const viewportBottom = viewportTop + height;
membersState.hitRegions = [];
for (const row of membersState.rows) {
const rowTop = row.offset;
const rowBottom = rowTop + row.height;
if (rowBottom < viewportTop - 20 || rowTop > viewportBottom + 20) {
continue;
}
const drawTop = rowTop - viewportTop;
if (row.type === 'team') {
drawTeamRow(ctx, row, drawTop, width);
} else if (row.type === 'team_interventions') {
drawTeamInterventionsRow(ctx, row, drawTop, width);
} else {
drawMemberRow(ctx, row, drawTop, width);
}
}
drawMembersBodyNowMarker(ctx, width, height);
drawMembersDragPreview(ctx);
ctx.restore();
}
function drawMembersVerticalGridLines(ctx, width, height, tickStepMs) {
const viewStart = membersState.viewStartMs || 0;
const viewEnd = viewStart + Math.max(0, width - MEMBERS_LABEL_WIDTH) * membersState.msPerPixel;
const firstTick = Math.floor(viewStart / tickStepMs) * tickStepMs;
ctx.save();
ctx.strokeStyle = 'rgba(15, 23, 42, 0.06)';
ctx.setLineDash([4, 8]);
for (let ms = firstTick; ms <= viewEnd; ms += tickStepMs) {
const x = MEMBERS_LABEL_WIDTH + (ms - viewStart) / membersState.msPerPixel;
if (x < MEMBERS_LABEL_WIDTH || x > width) continue;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
ctx.restore();
}
function drawTeamRow(ctx, row, drawTop, width) {
ctx.save();
ctx.fillStyle = 'rgba(13, 110, 253, 0.06)';
ctx.fillRect(0, drawTop, width, row.height);
ctx.strokeStyle = 'rgba(13, 110, 253, 0.2)';
ctx.beginPath();
ctx.moveTo(0, drawTop + row.height - 0.5);
ctx.lineTo(width, drawTop + row.height - 0.5);
ctx.stroke();
ctx.fillStyle = '#0b478b';
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle';
const label = row.name || gettext('Équipe');
const labelWidth = ctx.measureText(label).width;
const labelY = drawTop + row.height / 2;
ctx.fillText(label, 16, labelY);
if (row.company) {
ctx.font = '500 11px "Inter", "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(11, 71, 139, 0.85)';
const textX = 16 + labelWidth + 12;
ctx.fillText(row.company, textX, labelY);
}
ctx.restore();
}
function drawTeamInterventionsRow(ctx, row, drawTop, width) {
const labelAreaWidth = MEMBERS_LABEL_WIDTH;
const timelineWidth = Math.max(0, width - labelAreaWidth);
ctx.save();
// Background with subtle team color
ctx.fillStyle = 'rgba(13, 110, 253, 0.03)';
ctx.fillRect(labelAreaWidth, drawTop, timelineWidth, row.height);
ctx.fillStyle = 'rgba(13, 110, 253, 0.02)';
ctx.fillRect(0, drawTop, labelAreaWidth, row.height);
// Bottom border
ctx.strokeStyle = 'rgba(13, 110, 253, 0.12)';
ctx.beginPath();
ctx.moveTo(0, drawTop + row.height);
ctx.lineTo(width, drawTop + row.height);
ctx.stroke();
// Highlight when drop target
const isDropTargetRow = membersState.drag?.active
&& membersState.drag.dropTarget?.type === 'team_interventions'
&& membersState.drag.dropTarget?.rowId === row.id;
if (isDropTargetRow) {
ctx.save();
ctx.fillStyle = 'rgba(13, 110, 253, 0.12)';
ctx.fillRect(labelAreaWidth, drawTop, timelineWidth, row.height);
ctx.fillRect(0, drawTop, labelAreaWidth, row.height);
ctx.restore();
}
// Label with indentation to show it's under the team
ctx.fillStyle = 'rgba(13, 110, 253, 0.7)';
ctx.font = '500 12px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle';
ctx.fillText('↳ ' + (row.label || gettext("Assigné à l'équipe")), 24, drawTop + row.height / 2);
// Draw intervention bars
drawTeamInterventionBars(ctx, row, timelineWidth, labelAreaWidth, drawTop);
ctx.restore();
}
function drawTeamInterventionBars(ctx, row, timelineWidth, labelWidth, drawTop) {
const viewStart = membersState.viewStartMs || 0;
const visibleWidth = timelineWidth;
if (visibleWidth <= 0) {
return;
}
const laneBaseY = drawTop + MEMBERS_ROW_VERTICAL_PADDING;
const laneStep = TIMELINE_BAR_HEIGHT + MEMBERS_LANE_VERTICAL_SPACING;
const dragState = membersState.drag;
const draggingItemId = dragState?.active ? dragState.item?.id : null;
for (let idx = row.interventions.length - 1; idx >= 0; idx--) {
const item = row.interventions[idx];
const layout = item._layout;
const hasLayout = layout && layout.visible;
const startX = hasLayout ? layout.barLeft : MEMBERS_LABEL_WIDTH + (item.startDate.getTime() - viewStart) / membersState.msPerPixel;
const endX = hasLayout ? layout.barRight : MEMBERS_LABEL_WIDTH + (item.endDate.getTime() - viewStart) / membersState.msPerPixel;
if (endX < MEMBERS_LABEL_WIDTH - 120 || startX > MEMBERS_LABEL_WIDTH + visibleWidth + 120) {
continue;
}
const isDraggingThisItem = Boolean(draggingItemId && draggingItemId === item.id);
const laneIndex = Number.isFinite(layout?.lane)
? layout.lane
: (Number.isFinite(item._currentLane) ? item._currentLane : 0);
const barY = laneBaseY + laneIndex * laneStep;
const barLeft = hasLayout ? layout.barLeft : Math.max(startX, MEMBERS_LABEL_WIDTH - 60);
const barRight = hasLayout ? layout.barRight : Math.min(endX, MEMBERS_LABEL_WIDTH + visibleWidth + 60);
const barWidth = Math.max(6, hasLayout ? layout.barWidth : (barRight - barLeft));
const color = STATUS_COLORS[item.statusCode] || DEFAULT_STATUS_COLOR;
const shouldDim = isDraggingThisItem && dragState?.hasMoved;
ctx.save();
if (shouldDim) {
ctx.globalAlpha = 0.4;
}
ctx.fillStyle = color;
drawRoundedRect(ctx, barLeft, barY, barWidth, TIMELINE_BAR_HEIGHT, 12);
ctx.fill();
const label = layout?.label ?? [item.code, item.title].filter(Boolean).join(' · ');
if (label) {
const textColor = idealTextColor(color);
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle';
ctx.fillStyle = layout?.labelInside !== false ? textColor : 'rgba(15, 23, 42, 0.85)';
if (layout?.labelInside !== false) {
ctx.save();
ctx.beginPath();
ctx.rect(barLeft + 8, barY, Math.max(0, barWidth - 16), TIMELINE_BAR_HEIGHT);
ctx.clip();
ctx.fillText(label, barLeft + 12, barY + TIMELINE_BAR_HEIGHT / 2);
ctx.restore();
} else {
const textX = layout?.labelX ?? (barRight + MEMBERS_LABEL_OUTSIDE_GAP);
ctx.fillText(label, textX, barY + TIMELINE_BAR_HEIGHT / 2);
}
}
ctx.restore();
if (isDraggingThisItem) {
continue;
}
membersState.hitRegions.push({
item,
rowId: row.id,
rowType: 'team_interventions',
teamId: row.teamId,
x: barLeft,
y: barY,
width: barWidth,
height: TIMELINE_BAR_HEIGHT
});
}
}
function drawMemberRow(ctx, row, drawTop, width) {
const labelAreaWidth = MEMBERS_LABEL_WIDTH;
const timelineWidth = Math.max(0, width - labelAreaWidth);
ctx.save();
ctx.fillStyle = row.isAlt ? 'rgba(15, 23, 42, 0.015)' : '#ffffff';
ctx.fillRect(labelAreaWidth, drawTop, timelineWidth, row.height);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, drawTop, labelAreaWidth, row.height);
ctx.strokeStyle = 'rgba(15, 23, 42, 0.08)';
ctx.beginPath();
ctx.moveTo(0, drawTop + row.height);
ctx.lineTo(width, drawTop + row.height);
ctx.stroke();
const isDropTargetRow = membersState.drag?.active
&& membersState.drag.dropTarget?.type === 'member'
&& membersState.drag.dropTarget?.rowId === row.id;
if (isDropTargetRow) {
ctx.save();
ctx.fillStyle = 'rgba(13, 110, 253, 0.08)';
ctx.fillRect(labelAreaWidth, drawTop, timelineWidth, row.height);
ctx.fillRect(0, drawTop, labelAreaWidth, row.height);
ctx.restore();
}
ctx.fillStyle = '#0f172a';
ctx.font = '600 13px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle';
ctx.fillText(row.label || gettext('Membre'), 16, drawTop + row.height / 2 - 6);
if (row.teamName) {
ctx.font = '500 11px "Inter", "Segoe UI", sans-serif';
ctx.fillStyle = 'rgba(15, 23, 42, 0.55)';
ctx.fillText(row.teamName, 16, drawTop + row.height / 2 + 10);
}
drawMemberBars(ctx, row, timelineWidth, labelAreaWidth, drawTop);
ctx.restore();
}
function drawMemberBars(ctx, row, timelineWidth, labelWidth, drawTop) {
const viewStart = membersState.viewStartMs || 0;
const visibleWidth = timelineWidth;
if (visibleWidth <= 0) {
return;
}
const laneBaseY = drawTop + MEMBERS_ROW_VERTICAL_PADDING;
const laneStep = TIMELINE_BAR_HEIGHT + MEMBERS_LANE_VERTICAL_SPACING;
const dragState = membersState.drag;
const draggingItemId = dragState?.active ? dragState.item?.id : null;
for (let idx = row.interventions.length - 1; idx >= 0; idx--) {
const item = row.interventions[idx];
const layout = item._layout;
const hasLayout = layout && layout.visible;
const startX = hasLayout ? layout.barLeft : MEMBERS_LABEL_WIDTH + (item.startDate.getTime() - viewStart) / membersState.msPerPixel;
const endX = hasLayout ? layout.barRight : MEMBERS_LABEL_WIDTH + (item.endDate.getTime() - viewStart) / membersState.msPerPixel;
if (endX < MEMBERS_LABEL_WIDTH - 120 || startX > MEMBERS_LABEL_WIDTH + visibleWidth + 120) {
continue;
}
const isDraggingThisItem = Boolean(draggingItemId && draggingItemId === item.id);
const laneIndex = Number.isFinite(layout?.lane)
? layout.lane
: (Number.isFinite(item._currentLane) ? item._currentLane : 0);
const barY = laneBaseY + laneIndex * laneStep;
const barLeft = hasLayout ? layout.barLeft : Math.max(startX, MEMBERS_LABEL_WIDTH - 60);
const barRight = hasLayout ? layout.barRight : Math.min(endX, MEMBERS_LABEL_WIDTH + visibleWidth + 60);
const barWidth = Math.max(6, hasLayout ? layout.barWidth : (barRight - barLeft));
const color = STATUS_COLORS[item.statusCode] || DEFAULT_STATUS_COLOR;
const shouldDim = isDraggingThisItem && dragState?.hasMoved;
ctx.save();
if (shouldDim) {
ctx.globalAlpha = 0.4;
}
ctx.fillStyle = color;
drawRoundedRect(ctx, barLeft, barY, barWidth, TIMELINE_BAR_HEIGHT, 12);
ctx.fill();
const label = layout?.label ?? [item.code, item.title].filter(Boolean).join(' · ');
if (label) {
const textColor = idealTextColor(color);
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle';
ctx.fillStyle = layout?.labelInside !== false ? textColor : 'rgba(15, 23, 42, 0.85)';
if (layout?.labelInside !== false) {
ctx.save();
ctx.beginPath();
ctx.rect(barLeft + 8, barY, Math.max(0, barWidth - 16), TIMELINE_BAR_HEIGHT);
ctx.clip();
ctx.fillText(label, barLeft + 12, barY + TIMELINE_BAR_HEIGHT / 2);
ctx.restore();
} else {
const textX = layout?.labelX ?? (barRight + MEMBERS_LABEL_OUTSIDE_GAP);
ctx.fillText(label, textX, barY + TIMELINE_BAR_HEIGHT / 2);
}
}
ctx.restore();
if (isDraggingThisItem) {
continue;
}
membersState.hitRegions.push({
item,
rowId: row.id,
rowType: 'member',
memberId: row.memberId,
teamId: row.teamId,
x: barLeft,
y: barY,
width: barWidth,
height: TIMELINE_BAR_HEIGHT
});
}
}
function drawMembersBodyNowMarker(ctx, width, height) {
const now = Date.now();
const viewStart = membersState.viewStartMs || 0;
const viewEnd = viewStart + Math.max(0, width - MEMBERS_LABEL_WIDTH) * membersState.msPerPixel;
if (now < viewStart || now > viewEnd) {
return;
}
const x = MEMBERS_LABEL_WIDTH + (now - viewStart) / membersState.msPerPixel;
ctx.save();
ctx.strokeStyle = 'rgba(220, 53, 69, 0.65)';
ctx.setLineDash([6, 6]);
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
ctx.restore();
}
function drawMembersDragPreview(ctx) {
const drag = membersState.drag;
if (!drag?.active || !drag.item) {
return;
}
if (!drag.hasMoved) {
return;
}
// Support both member and team_interventions drop targets
if ((drag.dropTarget?.type !== 'member' && drag.dropTarget?.type !== 'team_interventions') || !drag.preview) {
return;
}
const row = membersState.rows.find(r => r.id === drag.preview.rowId);
if (!row) {
return;
}
const viewStart = membersState.viewStartMs || 0;
const msPerPixel = membersState.msPerPixel || MIN_MS_PER_PIXEL;
const startMs = drag.preview.startMs;
const endMs = drag.preview.endMs;
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return;
}
const barLeft = MEMBERS_LABEL_WIDTH + (startMs - viewStart) / msPerPixel;
const barWidth = Math.max(6, (endMs - startMs) / msPerPixel);
const rowTop = row.offset - membersState.verticalOffset;
const laneIndex = drag.preview.lane ?? drag.item._currentLane ?? 0;
const laneBaseY = rowTop + MEMBERS_ROW_VERTICAL_PADDING;
const laneStep = TIMELINE_BAR_HEIGHT + MEMBERS_LANE_VERTICAL_SPACING;
const barY = laneBaseY + laneIndex * laneStep;
const color = STATUS_COLORS[drag.item.statusCode] || DEFAULT_STATUS_COLOR;
ctx.save();
ctx.globalAlpha = 0.85;
ctx.fillStyle = color;
drawRoundedRect(ctx, barLeft, barY, barWidth, TIMELINE_BAR_HEIGHT, 12);
ctx.fill();
const label = drag.item._layout?.label ?? [drag.item.code, drag.item.title].filter(Boolean).join(' · ');
if (label) {
const textColor = idealTextColor(color);
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle';
ctx.fillStyle = textColor;
ctx.save();
ctx.beginPath();
ctx.rect(barLeft + 8, barY, Math.max(0, barWidth - 16), TIMELINE_BAR_HEIGHT);
ctx.clip();
ctx.fillText(label, barLeft + 12, barY + TIMELINE_BAR_HEIGHT / 2);
ctx.restore();
}
ctx.restore();
}
function updateMembersContentMetrics() {
const range = getMembersTimelineRangeLimits();
membersState.contentMetrics = {
totalHeight: membersState.totalHeight || 0,
rangeStartMs: range?.start ?? 0,
rangeEndMs: range?.end ?? 0
};
}
function updateMembersScrollbars() {
const dom = membersState.dom;
if (!dom) return;
updateMembersVerticalScrollbar(dom);
updateMembersHorizontalScrollbar(dom);
}
function hideMembersScrollbars() {
membersState.dom?.verticalScrollbar?.classList.add('is-hidden');
membersState.dom?.horizontalScrollbar?.classList.add('is-hidden');
}
function updateMembersVerticalScrollbar(dom) {
const track = dom.verticalScrollbar;
const thumb = dom.verticalThumb;
if (!track || !thumb) return;
if (!membersState.rows.length || membersState.totalHeight <= (membersState.viewportHeight || 0)) {
track.classList.add('is-hidden');
return;
}
const trackSize = track.getBoundingClientRect().height || 0;
if (!trackSize) {
track.classList.add('is-hidden');
return;
}
const ratio = (membersState.viewportHeight || 1) / membersState.totalHeight;
const thumbSize = Math.max(MEMBERS_SCROLLBAR_MIN_THUMB, trackSize * ratio);
thumb.style.height = `${thumbSize}px`;
const scrollableContent = Math.max(1, membersState.totalHeight - (membersState.viewportHeight || 0));
const trackScrollable = Math.max(1, trackSize - thumbSize);
const offsetRatio = membersState.verticalOffset / scrollableContent;
thumb.style.transform = `translateY(${offsetRatio * trackScrollable}px)`;
track.classList.remove('is-hidden');
}
function updateMembersHorizontalScrollbar(dom) {
const track = dom.horizontalScrollbar;
const thumb = dom.horizontalThumb;
if (!track || !thumb) return;
const range = getMembersTimelineRangeLimits();
if (!range) {
track.classList.add('is-hidden');
return;
}
const totalRange = Math.max(1, range.end - range.start);
const viewportMs = Math.min(totalRange, Math.max(0, (membersState.viewportWidth - MEMBERS_LABEL_WIDTH)) * (membersState.msPerPixel || MIN_MS_PER_PIXEL));
if (viewportMs >= totalRange - 1) {
track.classList.add('is-hidden');
return;
}
const trackSize = track.getBoundingClientRect().width || 0;
if (!trackSize) {
track.classList.add('is-hidden');
return;
}
const ratio = viewportMs / totalRange;
const thumbSize = Math.max(MEMBERS_SCROLLBAR_MIN_THUMB, trackSize * ratio);
thumb.style.width = `${thumbSize}px`;
const trackScrollable = Math.max(1, trackSize - thumbSize);
const scrollableRange = Math.max(1, totalRange - viewportMs);
const start = Math.max(0, Math.min(scrollableRange, membersState.viewStartMs - range.start));
const offsetRatio = start / scrollableRange;
thumb.style.transform = `translateX(${offsetRatio * trackScrollable}px)`;
track.classList.remove('is-hidden');
}
function getMembersTimelineRangeLimits() {
if (!membersState.bounds) {
return null;
}
const extra = TIMELINE_EXTRA_RANGE_HOURS * 60 * 60 * 1000;
return {
start: membersState.bounds.min - extra,
end: membersState.bounds.max + extra
};
}
function getMembersHorizontalScrollableRangeMs() {
const range = getMembersTimelineRangeLimits();
if (!range) {
return 0;
}
const totalRange = Math.max(1, range.end - range.start);
const viewportMs = Math.min(totalRange, Math.max(0, (membersState.viewportWidth - MEMBERS_LABEL_WIDTH)) * (membersState.msPerPixel || MIN_MS_PER_PIXEL));
return Math.max(0, totalRange - viewportMs);
}
function clampMembersView(force = false) {
const range = getMembersTimelineRangeLimits();
if (!range) {
return;
}
const visibleWidth = Math.max(1, (membersState.viewportWidth - MEMBERS_LABEL_WIDTH) * (membersState.msPerPixel || MIN_MS_PER_PIXEL));
const maxStart = Math.max(range.start, range.end - visibleWidth);
const minStart = range.start;
if (!Number.isFinite(membersState.viewStartMs) || force) {
membersState.viewStartMs = Math.min(Math.max(minStart, membersState.viewStartMs || minStart), maxStart);
return;
}
membersState.viewStartMs = Math.min(Math.max(membersState.viewStartMs, minStart), maxStart);
}
function clampMembersVerticalOffset(value) {
if (!membersState.rows.length) return 0;
const maxOffset = Math.max(0, membersState.totalHeight - (membersState.viewportHeight || 0));
return Math.min(Math.max(0, value), maxOffset);
}
function normalizeMembersRows(teams) {
const rows = [];
let memberRowIndex = 0;
teams.forEach(team => {
const members = Array.isArray(team.members) ? team.members : [];
const teamInterventions = Array.isArray(team.team_interventions) ? team.team_interventions : [];
// Team header row
rows.push({
type: 'team',
id: `team-${team.id ?? rows.length}`,
teamId: team.id ?? null,
name: team.name || gettext('Équipe'),
company: team.company || '',
baseHeight: MEMBERS_TEAM_ROW_HEIGHT,
height: MEMBERS_TEAM_ROW_HEIGHT,
interventions: [],
layout: null
});
// Team interventions row (interventions assigned to team but not to a member)
if (teamInterventions.length > 0 || true) { // Always show team intervention row for drop target
const normalizedTeamInterventions = teamInterventions
.map(record => normalizeTeamIntervention(record, team))
.filter(Boolean)
.sort((a, b) => a.startDate - b.startDate);
rows.push({
type: 'team_interventions',
id: `team-interventions-${team.id ?? rows.length}`,
teamId: team.id ?? null,
label: gettext('Assigné à l\'équipe'),
teamName: team.name || '',
baseHeight: MEMBERS_TEAM_INTERVENTIONS_ROW_HEIGHT,
height: MEMBERS_TEAM_INTERVENTIONS_ROW_HEIGHT,
interventions: normalizedTeamInterventions,
isAlt: false,
layout: null
});
}
members.forEach(member => {
const interventions = (member.interventions || [])
.map(record => normalizeMemberIntervention(record, member))
.filter(Boolean)
.sort((a, b) => a.startDate - b.startDate);
rows.push({
type: 'member',
id: `member-${member.id ?? `${rows.length}-${memberRowIndex}`}-${team.id ?? 'noteam'}`,
memberId: member.id ?? null,
label: member.name || gettext('Membre'),
teamName: team.name || '',
teamId: team.id ?? null,
baseHeight: MEMBERS_MEMBER_ROW_HEIGHT,
height: MEMBERS_MEMBER_ROW_HEIGHT,
interventions,
isAlt: memberRowIndex % 2 === 1,
layout: null
});
memberRowIndex += 1;
});
});
return rows;
}
function normalizeTeamIntervention(record, team) {
if (!record) return null;
const startValue = record.start || record.planned_begin || record.extendedProps?.start;
if (!startValue) return null;
const startDate = new Date(startValue);
if (!(startDate instanceof Date) || Number.isNaN(startDate.getTime())) {
return null;
}
const endValue = record.end || record.planned_end || record.extendedProps?.end;
let endDate = endValue ? new Date(endValue) : null;
if (!(endDate instanceof Date) || Number.isNaN(endDate?.getTime?.())) {
endDate = new Date(startDate.getTime() + MIN_BAR_DURATION_MS);
}
const statusCode = record.status_code || record.extendedProps?.status_code || 'unknown';
// Dans la vue membres, les managers ne peuvent pas déplacer
const isEditable = !isManager && EDITABLE_STATUSES.includes(statusCode);
const teamId = team?.id ?? null;
return {
id: record.id,
code: record.code || record.extendedProps?.code || '',
title: record.title || record.extendedProps?.title || '',
statusCode,
statusLabel: record.status || record.extendedProps?.status || '',
memberName: '',
memberId: null,
teamId,
teamName: team?.name || '',
startDate,
endDate,
isEditable,
assignedToTeamOnly: true,
sourceEvent: record
};
}
function normalizeMemberIntervention(record, member) {
if (!record) return null;
const startValue = record.start || record.planned_begin || record.extendedProps?.start;
if (!startValue) return null;
const startDate = new Date(startValue);
if (!(startDate instanceof Date) || Number.isNaN(startDate.getTime())) {
return null;
}
const endValue = record.end || record.planned_end || record.extendedProps?.end;
let endDate = endValue ? new Date(endValue) : null;
if (!(endDate instanceof Date) || Number.isNaN(endDate?.getTime?.())) {
endDate = new Date(startDate.getTime() + MIN_BAR_DURATION_MS);
}
const statusCode = record.status_code || record.extendedProps?.status_code || 'unknown';
// Dans la vue membres, les managers ne peuvent pas déplacer
const isEditable = !isManager && EDITABLE_STATUSES.includes(statusCode);
const memberId = member?.id ?? null;
return {
id: record.id,
code: record.code || record.extendedProps?.code || '',
title: record.title || record.extendedProps?.title || '',
statusCode,
statusLabel: record.status || record.extendedProps?.status || '',
memberName: member?.name || '',
memberId,
startDate,
endDate,
isEditable,
sourceEvent: record
};
}
function computeMembersRowLayouts() {
if (!Array.isArray(membersState.rows) || !membersState.rows.length) {
membersState.totalHeight = 0;
return;
}
let offset = 0;
const timelineWidth = Math.max(0, (membersState.viewportWidth || 0) - MEMBERS_LABEL_WIDTH);
const msPerPixel = membersState.msPerPixel || MIN_MS_PER_PIXEL;
const viewStart = membersState.viewStartMs || 0;
membersState.rows.forEach(row => {
let height = row.baseHeight || row.height || MEMBERS_MEMBER_ROW_HEIGHT;
if (row.type === 'member' || row.type === 'team_interventions') {
height = layoutMemberRow(row, viewStart, timelineWidth, msPerPixel);
} else {
row.layout = null;
}
row.height = height;
row.offset = offset;
offset += height;
});
membersState.totalHeight = offset;
}
function layoutMemberRow(row, viewStart, timelineWidth, msPerPixel) {
const interventions = row.interventions || [];
const ctx = getMembersMeasurementContext();
if (!timelineWidth || !Number.isFinite(msPerPixel) || msPerPixel <= 0) {
interventions.forEach(item => {
if (item) {
item._currentLane = 0;
item._layout = null;
}
});
return row.baseHeight || MEMBERS_MEMBER_ROW_HEIGHT;
}
const laneEndX = [];
const sorted = interventions.slice().sort((a, b) => (a.startDate - b.startDate));
sorted.forEach(item => {
if (!item) return;
const layout = computeMemberItemLayout(item, viewStart, msPerPixel, timelineWidth, ctx);
item._layout = layout;
if (!layout || !layout.visible) {
item._currentLane = 0;
return;
}
let laneIndex = 0;
while (laneIndex < laneEndX.length) {
if (layout.effectiveStart >= laneEndX[laneIndex] + MEMBERS_LANE_HORIZONTAL_GAP_PX) {
break;
}
laneIndex += 1;
}
if (laneIndex === laneEndX.length) {
laneEndX.push(layout.effectiveEnd);
} else {
laneEndX[laneIndex] = Math.max(laneEndX[laneIndex], layout.effectiveEnd);
}
item._currentLane = laneIndex;
layout.lane = laneIndex;
});
const laneCount = laneEndX.length;
const contentHeight = laneCount
? laneCount * TIMELINE_BAR_HEIGHT + (laneCount - 1) * MEMBERS_LANE_VERTICAL_SPACING
: TIMELINE_BAR_HEIGHT;
const computedHeight = Math.max(row.baseHeight || MEMBERS_MEMBER_ROW_HEIGHT, contentHeight + MEMBERS_ROW_VERTICAL_PADDING * 2);
row.visibleLaneCount = laneCount;
return computedHeight;
}
function computeMemberItemLayout(item, viewStart, msPerPixel, timelineWidth, ctx) {
const startMs = item.startDate?.getTime?.();
const endMs = item.endDate?.getTime?.();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return null;
}
const timelineStartPx = MEMBERS_LABEL_WIDTH;
const timelineEndPx = timelineStartPx + timelineWidth;
const buffer = MEMBERS_LAYOUT_BUFFER_PX;
const rawStartX = timelineStartPx + (startMs - viewStart) / msPerPixel;
const rawEndX = timelineStartPx + (endMs - viewStart) / msPerPixel;
if (rawEndX < timelineStartPx - buffer || rawStartX > timelineEndPx + buffer) {
return { visible: false, effectiveStart: rawStartX, effectiveEnd: rawEndX };
}
const barLeft = Math.max(rawStartX, timelineStartPx - buffer);
const barRight = Math.min(rawEndX, timelineEndPx + buffer);
const barWidth = Math.max(0, barRight - barLeft);
const labelParts = [item.code, item.title].filter(Boolean);
const label = labelParts.join(' · ');
const labelWidth = label ? measureMembersLabelWidth(label, ctx) : 0;
const availableInside = Math.max(0, barWidth - 24);
let labelInside = true;
let labelX = barLeft + 12;
let labelBoundsStart = barLeft;
let labelBoundsEnd = barRight;
if (label && labelWidth > availableInside) {
labelInside = false;
const maxRight = timelineEndPx - 4;
const minLeft = timelineStartPx + 4;
const rightSpace = Math.max(0, maxRight - (barRight + MEMBERS_LABEL_OUTSIDE_GAP));
const leftSpace = Math.max(0, (barLeft - MEMBERS_LABEL_OUTSIDE_GAP) - minLeft);
const prefersRight = rightSpace >= labelWidth || rightSpace >= leftSpace;
if (prefersRight) {
labelX = Math.min(maxRight - labelWidth, barRight + MEMBERS_LABEL_OUTSIDE_GAP);
} else {
labelX = Math.max(minLeft, barLeft - MEMBERS_LABEL_OUTSIDE_GAP - labelWidth);
}
labelBoundsStart = Math.min(barLeft, labelX);
labelBoundsEnd = Math.max(barRight, labelX + labelWidth);
}
return {
visible: true,
barLeft,
barRight,
barWidth,
label,
labelWidth,
labelInside,
labelX,
effectiveStart: Math.min(barLeft, labelBoundsStart),
effectiveEnd: Math.max(barRight, labelBoundsEnd)
};
}
function measureMembersLabelWidth(label, ctx) {
if (!label) return 0;
const context = ctx || getMembersMeasurementContext();
if (context?.measureText) {
context.save?.();
if (context.font !== '600 12px "Inter", "Segoe UI", sans-serif') {
context.font = '600 12px "Inter", "Segoe UI", sans-serif';
}
const width = context.measureText(label).width;
context.restore?.();
return width;
}
return label.length * 7;
}
function getMembersMeasurementContext() {
if (membersState.dom?.bodyCtx) {
return membersState.dom.bodyCtx;
}
if (!membersState._measureCtx && typeof document !== 'undefined') {
const canvas = document.createElement('canvas');
membersState._measureCtx = canvas.getContext('2d');
}
return membersState._measureCtx || null;
}
function computeMembersBounds(rows) {
const now = Date.now();
// Minimum range: today - 1 month to today + 3 months
const oneMonthAgo = now - (30 * 24 * 60 * 60 * 1000);
const threeMonthsAhead = now + (90 * 24 * 60 * 60 * 1000);
let min = oneMonthAgo;
let max = threeMonthsAhead;
rows.forEach(row => {
// Include both member and team_interventions rows
if (row.type !== 'member' && row.type !== 'team_interventions') return;
row.interventions?.forEach(intervention => {
const start = intervention.startDate?.getTime?.();
const end = intervention.endDate?.getTime?.();
if (Number.isFinite(start) && start < min) {
min = start;
}
if (Number.isFinite(end) && end > max) {
max = end;
}
});
});
return { min, max };
}
function getMembersCanvasCoordinates(event) {
const dom = membersState.dom;
if (!dom) return null;
const rect = dom.bodyCanvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
}
function getMemberRowAtY(y) {
const absoluteY = membersState.verticalOffset + y;
return membersState.rows.find(row => absoluteY >= row.offset && absoluteY <= row.offset + row.height);
}
function attemptMembersItemDrag(coords, event) {
if (!coords || coords.x < MEMBERS_LABEL_WIDTH) {
return false;
}
if (!membersState.hitRegions?.length) {
return false;
}
const hit = membersState.hitRegions.find(region =>
coords.x >= region.x &&
coords.x <= region.x + region.width &&
coords.y >= region.y &&
coords.y <= region.y + region.height
);
if (!hit || !hit.item?.isEditable) {
return false;
}
// Allow drag from both member rows and team_interventions rows
const row = membersState.rows.find(r => r.id === hit.rowId && (r.type === 'member' || r.type === 'team_interventions'));
if (!row) {
return false;
}
// For member rows, we need a memberId; for team_interventions rows, we need a teamId
if (row.type === 'member' && !row.memberId) {
return false;
}
if (row.type === 'team_interventions' && !row.teamId) {
return false;
}
const drag = membersState.drag;
const startMs = hit.item.startDate?.getTime?.() || Date.now();
const endMs = hit.item.endDate?.getTime?.() || (startMs + MIN_BAR_DURATION_MS);
const durationMs = Math.max(MIN_DRAG_DURATION_MS, endMs - startMs);
let captureTarget = event.target;
if (captureTarget?.setPointerCapture) {
try {
captureTarget.setPointerCapture(event.pointerId);
} catch (error) {
captureTarget = membersState.dom?.bodyEl;
captureTarget?.setPointerCapture?.(event.pointerId);
}
} else {
captureTarget = membersState.dom?.bodyEl;
captureTarget?.setPointerCapture?.(event.pointerId);
}
drag.active = true;
drag.pointerId = event.pointerId;
drag.item = hit.item;
drag.sourceRowId = row.id;
drag.sourceMemberId = row.type === 'member' ? row.memberId : null;
drag.sourceTeamId = row.teamId || null;
drag.durationMs = durationMs;
drag.startMs = startMs;
drag.endMs = endMs;
drag.dropTarget = row.type === 'team_interventions' ? {
type: 'team_interventions',
rowId: row.id,
teamId: row.teamId,
memberId: null,
startMs,
endMs
} : {
type: 'member',
rowId: row.id,
memberId: row.memberId,
teamId: row.teamId,
startMs,
endMs
};
drag.preview = {
...drag.dropTarget,
lane: hit.item._currentLane ?? 0
};
drag.startClientX = event.clientX;
drag.startClientY = event.clientY;
drag.hasMoved = false;
drag.captureTarget = captureTarget?.releasePointerCapture ? captureTarget : null;
membersState.hoverCoords = null;
return true;
}
function handleMembersClick(event) {
const coords = getMembersCanvasCoordinates(event);
if (!coords) return;
const hit = membersState.hitRegions.find(region =>
coords.x >= region.x &&
coords.x <= region.x + region.width &&
coords.y >= region.y &&
coords.y <= region.y + region.height
);
if (!hit) return;
if (hit.item?.sourceEvent) {
openPlanningModal(hit.item.sourceEvent);
}
}
function updateMembersItemDrag(event) {
const drag = membersState.drag;
if (!drag.active || drag.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (!drag.hasMoved) {
const dx = Math.abs(event.clientX - drag.startClientX);
const dy = Math.abs(event.clientY - drag.startClientY);
if (dx <= 3 && dy <= 3) {
return;
}
drag.hasMoved = true;
membersState.dom?.bodyEl?.classList.add('is-dragging-item');
}
const target = evaluateMembersDropTarget(event);
const isBacklog = target?.type === 'backlog';
drag.dropTarget = target;
drag.preview = (!isBacklog && target)
? { ...target, lane: drag.item?._currentLane ?? 0 }
: null;
setBacklogHighlight(isBacklog);
requestMembersRender();
}
function finishMembersItemDrag(event, { cancel = false } = {}) {
const drag = membersState.drag;
if (!drag.active || (event && drag.pointerId !== event.pointerId)) {
return;
}
drag.captureTarget?.releasePointerCapture?.(drag.pointerId);
setBacklogHighlight(false);
membersState.dom?.bodyEl?.classList.remove('is-dragging-item');
if (event) {
const latestTarget = evaluateMembersDropTarget(event);
if (latestTarget) {
drag.dropTarget = latestTarget;
drag.preview = (latestTarget.type === 'member' || latestTarget.type === 'team_interventions')
? { ...latestTarget, lane: drag.item?._currentLane ?? 0 }
: null;
}
}
const wasMoved = drag.hasMoved;
const dropTarget = (!cancel && wasMoved && drag.dropTarget) ? { ...drag.dropTarget } : null;
const interventionId = drag.item?.id;
const originalRange = { startMs: drag.startMs, endMs: drag.endMs };
const modalPayload = (!cancel && !wasMoved) ? drag.item?.sourceEvent : null;
resetMembersDragState();
requestMembersRender();
if (!wasMoved && modalPayload) {
openPlanningModal(modalPayload);
return;
}
if (!dropTarget || !interventionId) {
return;
}
if (dropTarget.type === 'backlog') {
if (!Number.isFinite(originalRange.startMs) || !Number.isFinite(originalRange.endMs)) {
return;
}
persistScheduleUpdate({
interventionId,
memberId: null,
teamId: null,
start: new Date(originalRange.startMs),
end: new Date(originalRange.endMs)
});
return;
}
if (dropTarget.type === 'team_interventions') {
// Assign to team only (no member)
const rawDuration = Number.isFinite(dropTarget.endMs)
? (dropTarget.endMs - dropTarget.startMs)
: membersState.drag?.durationMs;
const duration = Math.max(MIN_DRAG_DURATION_MS, rawDuration || MIN_DRAG_DURATION_MS);
const snappedStartMs = snapToHour(dropTarget.startMs);
const snappedEndMs = snappedStartMs + duration;
persistScheduleUpdate({
interventionId,
memberId: null,
teamId: dropTarget.teamId,
start: new Date(snappedStartMs),
end: new Date(snappedEndMs)
});
return;
}
if (dropTarget.type === 'member') {
const rawDuration = Number.isFinite(dropTarget.endMs)
? (dropTarget.endMs - dropTarget.startMs)
: membersState.drag?.durationMs;
const duration = Math.max(MIN_DRAG_DURATION_MS, rawDuration || MIN_DRAG_DURATION_MS);
const snappedStartMs = snapToHour(dropTarget.startMs);
const snappedEndMs = snappedStartMs + duration;
persistScheduleUpdate({
interventionId,
memberId: dropTarget.memberId,
teamId: dropTarget.teamId,
start: new Date(snappedStartMs),
end: new Date(snappedEndMs)
});
}
}
function resetMembersDragState() {
const drag = membersState.drag;
drag.active = false;
drag.pointerId = null;
drag.item = null;
drag.sourceRowId = null;
drag.sourceMemberId = null;
drag.sourceTeamId = null;
drag.durationMs = 0;
drag.startMs = 0;
drag.endMs = 0;
drag.startClientX = 0;
drag.startClientY = 0;
drag.hasMoved = false;
drag.dropTarget = null;
drag.preview = null;
drag.captureTarget = null;
}
function setBacklogHighlight(active) {
if (!membersBacklogWrapper) return;
membersBacklogWrapper.classList.toggle('members-view-layout__backlog--highlight', Boolean(active));
}
function evaluateMembersDropTarget(event) {
if (!event) return null;
const rect = membersBacklogWrapper?.getBoundingClientRect?.();
if (rect) {
const { clientX, clientY } = event;
if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
return { type: 'backlog' };
}
}
const coords = getMembersCanvasCoordinates(event);
if (!coords || coords.x < MEMBERS_LABEL_WIDTH) {
return null;
}
const row = getMemberRowAtY(coords.y);
if (!row) {
return null;
}
const msPerPixel = membersState.msPerPixel || MIN_MS_PER_PIXEL;
const viewStart = membersState.viewStartMs || 0;
const duration = membersState.drag?.durationMs || MIN_DRAG_DURATION_MS;
const timelinePx = coords.x - MEMBERS_LABEL_WIDTH;
const startMs = viewStart + timelinePx * msPerPixel;
const normalizedStart = Number.isFinite(startMs) ? startMs : (membersState.drag?.startMs || viewStart);
const endMs = normalizedStart + duration;
// Drop on team_interventions row (assign to team only, no member)
if (row.type === 'team_interventions' && row.teamId) {
return {
type: 'team_interventions',
rowId: row.id,
teamId: row.teamId,
memberId: null,
startMs: normalizedStart,
endMs
};
}
// Drop on member row
if (row.type === 'member' && row.memberId) {
return {
type: 'member',
rowId: row.id,
memberId: row.memberId,
teamId: row.teamId,
startMs: normalizedStart,
endMs
};
}
return null;
}
function snapshotMembersViewState() {
if (!membersState.dom) {
return null;
}
return {
viewStartMs: membersState.viewStartMs,
msPerPixel: membersState.msPerPixel,
verticalOffset: membersState.verticalOffset
};
}
function updateMembersHoverCursor(event) {
const dom = membersState.dom;
if (!dom || membersState.pointer.active || membersState.drag?.active) {
dom?.bodyEl?.classList.remove('is-hovering-item');
return;
}
let coords = event ? getMembersCanvasCoordinates(event) : membersState.hoverCoords;
if (event) {
membersState.hoverCoords = coords;
}
if (!coords) {
dom.bodyEl.classList.remove('is-hovering-item');
membersState.hoverCoords = null;
return;
}
if (coords.x < MEMBERS_LABEL_WIDTH) {
dom.bodyEl.classList.remove('is-hovering-item');
return;
}
const hit = membersState.hitRegions.some(region =>
coords.x >= region.x &&
coords.x <= region.x + region.width &&
coords.y >= region.y &&
coords.y <= region.y + region.height
);
if (hit) {
dom.bodyEl.classList.add('is-hovering-item');
} else {
dom.bodyEl.classList.remove('is-hovering-item');
}
}
function setupMembersDragAndDrop(dom) {
dom.bodyEl.addEventListener('dragover', event => {
if (draggedBacklogItem || draggedBacklogGroup) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
});
dom.bodyEl.addEventListener('drop', event => {
handleMembersDrop(event);
});
}
function handleMembersDrop(event) {
if (!draggedBacklogItem && !draggedBacklogGroup) return;
event.preventDefault();
const coords = getMembersCanvasCoordinates(event);
if (!coords) return;
const row = getMemberRowAtY(coords.y);
if (!row) {
return;
}
// Allow drop on member rows or team_interventions rows
const isMemberRow = row.type === 'member' && row.memberId;
const isTeamInterventionsRow = row.type === 'team_interventions' && row.teamId;
if (!isMemberRow && !isTeamInterventionsRow) {
return;
}
const timelineX = Math.max(coords.x, MEMBERS_LABEL_WIDTH);
const timelinePx = timelineX - MEMBERS_LABEL_WIDTH;
const startMs = membersState.viewStartMs + timelinePx * membersState.msPerPixel;
const snappedStartMs = snapToHour(startMs);
// Group bulk drop: assign all interventions of the group at once
if (draggedBacklogGroup) {
const group = draggedBacklogGroup;
draggedBacklogGroup = null;
if (!group.items.length) return;
const assignParams = isTeamInterventionsRow
? { memberId: null, teamId: row.teamId }
: { memberId: row.memberId, teamId: row.teamId };
persistBulkScheduleUpdate(group.items, snappedStartMs, assignParams);
return;
}
// Single intervention drop
const duration = draggedBacklogItem.duration_ms || MIN_BAR_DURATION_MS;
const startDate = new Date(snappedStartMs);
const endDate = new Date(snappedStartMs + duration);
if (isTeamInterventionsRow) {
// Assign to team only (no member)
persistScheduleUpdate({
interventionId: draggedBacklogItem.id,
memberId: null,
teamId: row.teamId,
start: startDate,
end: endDate
}).finally(() => {
draggedBacklogItem = null;
});
} else {
// Assign to member
persistScheduleUpdate({
interventionId: draggedBacklogItem.id,
memberId: row.memberId,
teamId: row.teamId,
start: startDate,
end: endDate
}).finally(() => {
draggedBacklogItem = null;
});
}
}
async function persistBulkScheduleUpdate(items, snappedStartMs, assignParams) {
if (!items.length || !interventionScheduleUpdateUrl) return;
if (membersState.dom) {
membersState._pendingRestore = snapshotMembersViewState();
}
let successCount = 0;
let errorCount = 0;
for (const intervention of items) {
const startDate = new Date(snappedStartMs);
const endDate = new Date(snappedStartMs + (intervention.duration_ms || MIN_BAR_DURATION_MS));
const payload = {
intervention_id: intervention.id,
planned_begin_time: startDate.toISOString(),
planned_end_time: endDate.toISOString(),
};
if (assignParams.memberId !== undefined) payload.assigned_member_id = assignParams.memberId;
if (assignParams.teamId !== undefined) payload.assigned_team_id = assignParams.teamId;
try {
const response = await fetch(interventionScheduleUpdateUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken || '',
},
credentials: 'same-origin',
body: JSON.stringify(payload),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data?.error || response.statusText);
}
successCount++;
} catch (err) {
console.error('Bulk schedule update error for intervention', intervention.id, err);
errorCount++;
}
}
// Reload calendar once after all updates
await loadCalendarData();
if (errorCount > 0) {
showPlanningFeedback(
gettext(`${successCount} intervention(s) planifiée(s), ${errorCount} erreur(s).`),
'warning'
);
}
}
function renderBacklog(data) {
const viewMode = filterViewModeSelect?.value || 'timeline';
const wrapper = viewMode === 'teams_daily' ? teamsDailyBacklogWrapper : membersBacklogWrapper;
if (!wrapper) return;
wrapper.innerHTML = '';
const container = document.createElement('div');
container.className = 'members-backlog';
const header = document.createElement('div');
header.className = 'members-backlog__header';
header.innerHTML = `
<div class="members-backlog__title text-uppercase">${gettext('Interventions non assignées')}</div>
<p class="members-backlog__help text-muted">${gettext('Glissez une intervention sur un membre pour la planifier, ou faites glisser le titre d\'un groupe pour assigner toutes ses interventions.')}</p>
`;
container.appendChild(header);
const backlogGroups = data.backlog_groups || {};
const groupsOrder = ['corrective', 'preventive', 'ameliorative'];
const labels = {
'corrective': gettext('Maintenance corrective'),
'preventive': gettext('Maintenance préventive'),
'ameliorative': gettext('Maintenance améliorative'),
};
groupsOrder.forEach(key => {
const items = backlogGroups[key] || [];
container.appendChild(createBacklogCard(labels[key], items, key));
});
wrapper.appendChild(container);
resizeBacklogCards(container);
requestAnimationFrame(() => resizeBacklogCards(container));
if (calendarPreferences && calendarPreferences.backlog_expanded && calendarPreferences.backlog_expanded !== 'none') {
const card = container.querySelector(`.members-backlog-card[data-card-key="${calendarPreferences.backlog_expanded}"]`);
if (card) {
requestAnimationFrame(() => {
toggleBacklogCardExpand(card);
});
}
}
}
function toggleBacklogCardExpand(card) {
const container = card.closest('.members-backlog');
if (!container) return;
const allCards = Array.from(container.querySelectorAll('.members-backlog-card'));
const isCurrentlyExpanded = card.classList.contains('is-expanded');
if (isCurrentlyExpanded) {
// Collapse: restore all cards to normal state
container.classList.remove('members-backlog--has-expanded');
allCards.forEach(c => {
c.classList.remove('is-expanded', 'is-collapsed');
updateExpandButtonIcon(c, false);
});
resizeBacklogCards(container);
saveCalendarPreferences({ backlog_expanded: 'none' });
} else {
// Expand: make this card full height, collapse others
container.classList.add('members-backlog--has-expanded');
allCards.forEach(c => {
if (c === card) {
c.classList.add('is-expanded');
c.classList.remove('is-collapsed');
updateExpandButtonIcon(c, true);
} else {
c.classList.add('is-collapsed');
c.classList.remove('is-expanded');
updateExpandButtonIcon(c, false);
}
});
resizeExpandedBacklogCard(card, container);
saveCalendarPreferences({ backlog_expanded: card.dataset.cardKey });
}
}
function updateExpandButtonIcon(card, isExpanded) {
const expandIcon = card.querySelector('.expand-icon');
const collapseIcon = card.querySelector('.collapse-icon');
if (expandIcon && collapseIcon) {
expandIcon.style.display = isExpanded ? 'none' : '';
collapseIcon.style.display = isExpanded ? '' : 'none';
}
}
function resizeExpandedBacklogCard(card, container) {
if (!card || !container) return;
const wrapper = container.parentElement;
const wrapperHeight = wrapper?.clientHeight || container.clientHeight || window.innerHeight;
const backlogHeader = container.querySelector('.members-backlog__header');
const backlogHeaderHeight = backlogHeader?.offsetHeight || 0;
const collapsedCards = Array.from(container.querySelectorAll('.members-backlog-card.is-collapsed'));
// Calculate height taken by collapsed cards
let collapsedHeight = 0;
collapsedCards.forEach(c => {
const header = c.querySelector('.card-header');
collapsedHeight += (header?.offsetHeight || 32) + 12; // header + margin
});
const computedStyle = window.getComputedStyle ? window.getComputedStyle(container) : null;
const gapValue = computedStyle?.rowGap || computedStyle?.gap || '0';
const gapSize = parseFloat(gapValue) || 0;
const totalGaps = (collapsedCards.length + 1) * gapSize;
const reservedSpace = backlogHeaderHeight + collapsedHeight + totalGaps + 24;
const availableHeight = Math.max(wrapperHeight - reservedSpace, 100);
card.style.minHeight = `${availableHeight}px`;
card.style.maxHeight = `${availableHeight}px`;
const header = card.querySelector('.card-header');
const list = card.querySelector('.members-backlog-list');
if (list) {
const headerHeight = header?.offsetHeight || 0;
const listHeight = Math.max(0, availableHeight - headerHeight - 12);
list.style.maxHeight = `${listHeight}px`;
list.style.overflowY = 'auto';
}
}
function resizeBacklogCards(container = null) {
if (!container) {
const viewMode = filterViewModeSelect?.value || 'timeline';
const wrapper = viewMode === 'teams_daily' ? teamsDailyBacklogWrapper : membersBacklogWrapper;
container = wrapper?.querySelector('.members-backlog');
}
if (!container) return;
const cards = Array.from(container.querySelectorAll('.members-backlog-card'));
if (!cards.length) return;
// If a card is expanded, use the expanded resize logic
const expandedCard = container.querySelector('.members-backlog-card.is-expanded');
if (expandedCard) {
resizeExpandedBacklogCard(expandedCard, container);
return;
}
const wrapper = container.parentElement;
const wrapperHeight = wrapper?.clientHeight
|| container.clientHeight
|| window.innerHeight;
if (!wrapperHeight) {
return;
}
const backlogHeader = container.querySelector('.members-backlog__header');
const backlogHeaderHeight = backlogHeader?.offsetHeight || 0;
const computedStyle = (typeof window !== 'undefined' && window.getComputedStyle)
? window.getComputedStyle(container)
: null;
const gapValue = computedStyle?.rowGap || computedStyle?.gap || '0';
const gapSize = parseFloat(gapValue) || 0;
const gapCount = cards.length; // header + cards => same number of gaps as cards
const reservedSpace = backlogHeaderHeight + gapSize * gapCount + 16; // padding breathing room
const availableHeight = Math.max(wrapperHeight - reservedSpace, 0);
const perCardHeight = cards.length ? Math.floor(availableHeight / cards.length) : availableHeight;
cards.forEach(card => {
const targetHeight = Math.max(0, perCardHeight - 8);
card.style.display = 'flex';
card.style.flexDirection = 'column';
card.style.minHeight = `${targetHeight}px`;
card.style.maxHeight = `${targetHeight}px`;
const header = card.querySelector('.card-header');
const list = card.querySelector('.members-backlog-list');
if (!list) {
return;
}
const headerHeight = header?.offsetHeight || 0;
const listHeight = Math.max(0, targetHeight - headerHeight - 12);
list.style.flex = '1 1 auto';
list.style.maxHeight = `${listHeight}px`;
list.style.overflowY = 'auto';
});
}
function createBacklogCard(title, items, cardKey) {
const card = document.createElement('div');
card.className = 'card members-backlog-card shadow-sm';
card.style.marginBottom = '0.75rem';
card.dataset.cardKey = cardKey;
const header = document.createElement('div');
header.className = 'card-header py-1 px-3 d-flex justify-content-between align-items-center';
const titleSpan = document.createElement('span');
titleSpan.className = 'fw-semibold text-uppercase small text-truncate';
titleSpan.textContent = title;
// Drag handle wrapping grip icon + title for group drag
const groupDragHandle = document.createElement('div');
groupDragHandle.className = 'backlog-card__group-drag-handle d-flex align-items-center gap-2 text-truncate';
const gripIcon = document.createElement('span');
gripIcon.className = 'backlog-card__grip-icon';
gripIcon.setAttribute('aria-hidden', 'true');
gripIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16"><path d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>`;
groupDragHandle.appendChild(gripIcon);
groupDragHandle.appendChild(titleSpan);
if (items.length > 0) {
groupDragHandle.draggable = true;
groupDragHandle.title = gettext('Glisser pour assigner toutes les interventions de ce groupe');
groupDragHandle.addEventListener('dragstart', (e) => {
draggedBacklogGroup = { key: cardKey, items: [...items], label: title };
header.classList.add('is-group-dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `group:${cardKey}`);
// Custom drag ghost showing label + count
const ghost = document.createElement('div');
ghost.style.cssText = 'position:fixed;top:-200px;left:-200px;background:#fff;border:1px solid #dee2e6;border-radius:6px;padding:4px 12px;font-size:0.82rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.18);white-space:nowrap;pointer-events:none;';
ghost.textContent = `${title} (${items.length})`;
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 60, 16);
requestAnimationFrame(() => ghost.remove());
});
groupDragHandle.addEventListener('dragend', () => {
draggedBacklogGroup = null;
header.classList.remove('is-group-dragging');
});
}
const actionsWrapper = document.createElement('div');
actionsWrapper.className = 'd-flex align-items-center gap-2';
const badge = document.createElement('span');
badge.className = 'badge rounded-pill text-bg-light';
badge.textContent = items.length;
const expandBtn = document.createElement('button');
expandBtn.type = 'button';
expandBtn.className = 'backlog-card__expand-btn';
expandBtn.title = gettext('Agrandir / Réduire');
expandBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path class="expand-icon" stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
<path class="collapse-icon" style="display:none" stroke-linecap="round" stroke-linejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
</svg>`;
expandBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleBacklogCardExpand(card);
});
actionsWrapper.appendChild(badge);
actionsWrapper.appendChild(expandBtn);
header.appendChild(groupDragHandle);
header.appendChild(actionsWrapper);
card.appendChild(header);
const list = document.createElement('div');
list.className = 'list-group list-group-flush members-backlog-list';
list.style.fontSize = '0.85rem';
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'list-group-item text-muted small bg-transparent py-2';
empty.textContent = gettext('Aucune intervention disponible.');
list.appendChild(empty);
} else {
items.forEach(intervention => {
list.appendChild(createBacklogItem(intervention));
});
}
card.appendChild(list);
return card;
}
function createBacklogItem(intervention) {
const item = document.createElement('div');
item.className = 'list-group-item list-group-item-action backlog-card__item py-2 px-3';
item.style.fontSize = '0.85rem';
item.style.lineHeight = '1.2';
item.draggable = true;
item.dataset.interventionId = intervention.id;
const color = STATUS_COLORS[intervention.status_code] || DEFAULT_STATUS_COLOR;
const textColor = idealTextColor(color);
const titleRow = document.createElement('div');
titleRow.className = 'd-flex justify-content-between align-items-start gap-2';
const titleSpan = document.createElement('span');
titleSpan.className = 'backlog-card__item-title text-truncate';
titleSpan.style.fontWeight = 500;
const titleText = `${intervention.code || ''} ${intervention.title || ''}`.trim();
titleSpan.textContent = titleText;
const badgesColumn = document.createElement('div');
badgesColumn.className = 'd-flex flex-column align-items-end gap-1';
badgesColumn.style.minWidth = '4.5rem';
const statusRow = document.createElement('div');
statusRow.className = 'd-flex align-items-center gap-1 flex-nowrap';
statusRow.style.whiteSpace = 'nowrap';
const statusBadge = document.createElement('span');
statusBadge.className = 'badge rounded-pill';
statusBadge.style.backgroundColor = color;
statusBadge.style.color = textColor;
statusBadge.style.fontSize = '0.7rem';
statusBadge.textContent = intervention.status || '';
statusRow.appendChild(statusBadge);
const prepState = intervention.prep_state || intervention.prepState || intervention.extendedProps?.prep_state || 'none';
const prepStateConfigs = {
'processed': { label: gettext('Stock prêt'), color: 'success' },
'not_processed': { label: gettext('Stock en attente'), color: 'danger' }
};
const prepConfig = prepStateConfigs[prepState];
if (prepConfig) {
const prepBadge = document.createElement('span');
prepBadge.className = `badge rounded-pill text-bg-${prepConfig.color}`;
prepBadge.style.fontSize = '0.7rem';
prepBadge.title = prepConfig.label;
prepBadge.setAttribute('aria-label', prepConfig.label);
prepBadge.innerHTML = '<i class="bi bi-box"></i>';
statusRow.appendChild(prepBadge);
}
badgesColumn.appendChild(statusRow);
if (intervention.member_name) {
const assigneeBadge = document.createElement('span');
assigneeBadge.className = 'badge rounded-pill text-bg-light text-secondary border border-secondary';
assigneeBadge.style.fontSize = '0.7rem';
assigneeBadge.textContent = intervention.member_name;
badgesColumn.appendChild(assigneeBadge);
}
titleRow.appendChild(titleSpan);
titleRow.appendChild(badgesColumn);
const meta = document.createElement('div');
meta.className = 'backlog-card__item-meta text-muted';
meta.style.fontSize = '0.75rem';
meta.textContent = intervention.maintain_type_label || gettext('Non défini');
item.appendChild(titleRow);
item.appendChild(meta);
setupBacklogDrag(item, intervention);
return item;
}
function setupBacklogDrag(item, intervention) {
item.addEventListener('dragstart', (e) => {
draggedBacklogItem = intervention;
item.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', intervention.id);
});
item.addEventListener('dragend', () => {
draggedBacklogItem = null;
item.classList.remove('is-dragging');
});
item.addEventListener('click', () => openPlanningModal(intervention));
}
function persistScheduleUpdate({ interventionId, memberId, teamId, start, end }) {
if (!interventionId || !start || !end || !interventionScheduleUpdateUrl) {
return Promise.resolve();
}
if (membersState.dom) {
membersState._pendingRestore = snapshotMembersViewState();
}
if (timelineState.dom) {
timelineState._pendingRestore = snapshotTimelineViewState();
}
const payload = {
intervention_id: interventionId,
planned_begin_time: start.toISOString(),
planned_end_time: end.toISOString(),
};
// Include member assignment if provided
if (memberId !== undefined) {
payload.assigned_member_id = memberId;
}
// Include team assignment if provided (for team-only assignment)
if (teamId !== undefined) {
payload.assigned_team_id = teamId;
}
return fetch(interventionScheduleUpdateUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken || '',
},
credentials: 'same-origin',
body: JSON.stringify(payload)
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data?.error || response.statusText);
}).catch(() => {
throw new Error(response.statusText || 'Erreur de planification');
});
}
return response.json();
})
.then(() => {
// Reload data after successful update
return loadCalendarData();
})
.catch(err => {
console.error('Schedule update error:', err);
showPlanningFeedback(err.message || gettext('Une erreur est survenue.'), 'danger');
});
}
function openPlanningModal(intervention) {
const record = typeof intervention === 'object' ? intervention : null;
if (!record) return;
currentModalInterventionId = record.id || record.extendedProps?.id;
currentModalMemberId = record.assigned_member_id
?? record.member_id
?? record.extendedProps?.assigned_member_id
?? record.extendedProps?.member_id
?? null;
if (!planningModal || !planningBeginInput || !planningEndInput) {
const link = record.link || record.extendedProps?.link || `/interventions/${currentModalInterventionId}/`;
if (link) {
window.open(link, '_blank');
}
return;
}
const startDate = new Date(record.start || record.planned_begin || record.extendedProps?.start);
const endDate = new Date(record.end || record.planned_end || record.extendedProps?.end);
planningBeginInput.value = toDatetimeLocalValue(startDate);
planningEndInput.value = toDatetimeLocalValue(endDate);
syncDurationFieldFromDates();
planningTeamInput.value = record.team_name || record.extendedProps?.team_name || gettext('Non assignée');
planningMemberInput.value = record.member_name || record.extendedProps?.member_name || gettext('Non assigné');
modalInterventionCode.textContent = record.code || record.extendedProps?.code || `#${currentModalInterventionId}`;
modalInterventionTitle.textContent = record.title || '';
const status = record.status || record.extendedProps?.status || '';
const statusCode = record.status_code || record.extendedProps?.status_code || '';
const color = STATUS_COLORS[statusCode] || DEFAULT_STATUS_COLOR;
const textColor = idealTextColor(color);
modalInterventionStatus.textContent = status;
modalInterventionStatus.style.backgroundColor = color;
modalInterventionStatus.style.color = textColor;
// Add preparation status badge
const prepState = record.prep_state || record.prepState || record.extendedProps?.prep_state || 'none';
const modalPrepStatus = document.getElementById('modal-preparation-status');
if (modalPrepStatus) {
const prepStateConfigs = {
'processed': { label: gettext('Prêt'), icon: 'bi-box', color: 'success' },
'not_processed': { label: gettext('En attente'), icon: 'bi-box', color: 'danger' }
};
const prepConfig = prepStateConfigs[prepState];
if (prepConfig) {
modalPrepStatus.innerHTML = `<span class="badge rounded-pill text-bg-${prepConfig.color}"><i class="bi ${prepConfig.icon} me-1"></i>${prepConfig.label}</span>`;
} else {
modalPrepStatus.innerHTML = '';
}
}
// Populate date table cells
populateModalDates(record);
const link = record.link || record.extendedProps?.link || `/interventions/${currentModalInterventionId}/`;
modalInterventionLink.href = link;
modalInterventionLink.classList.toggle('disabled', !link);
clearPlanningFeedback();
planningModal.show();
}
function populateModalDates(record) {
const formatDateTime = (isoString) => {
if (!isoString) return '-';
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString(DOCUMENT_LOCALE, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Expected (Prévision)
const expectedBegin = record.expected_begin || record.extendedProps?.expected_begin;
const expectedEnd = record.expected_end || record.extendedProps?.expected_end;
const modalExpectedBegin = document.getElementById('modal-expected-begin');
const modalExpectedEnd = document.getElementById('modal-expected-end');
const modalExpectedRow = document.getElementById('modal-dates-expected-row');
if (modalExpectedBegin) modalExpectedBegin.textContent = formatDateTime(expectedBegin);
if (modalExpectedEnd) modalExpectedEnd.textContent = formatDateTime(expectedEnd);
if (modalExpectedRow) {
modalExpectedRow.style.display = (expectedBegin || expectedEnd) ? '' : 'none';
}
// Planned (Planification)
const plannedBegin = record.planned_begin || record.extendedProps?.planned_begin;
const plannedEnd = record.planned_end || record.extendedProps?.planned_end;
const modalPlannedBegin = document.getElementById('modal-planned-begin');
const modalPlannedEnd = document.getElementById('modal-planned-end');
const modalPlannedRow = document.getElementById('modal-dates-planned-row');
if (modalPlannedBegin) modalPlannedBegin.textContent = formatDateTime(plannedBegin);
if (modalPlannedEnd) modalPlannedEnd.textContent = formatDateTime(plannedEnd);
if (modalPlannedRow) {
modalPlannedRow.style.display = (plannedBegin || plannedEnd) ? '' : 'none';
}
// Actual (Réalisation)
const actualBegin = record.actual_begin || record.extendedProps?.actual_begin;
const actualEnd = record.actual_end || record.extendedProps?.actual_end;
const modalActualBegin = document.getElementById('modal-actual-begin');
const modalActualEnd = document.getElementById('modal-actual-end');
const modalActualRow = document.getElementById('modal-dates-actual-row');
if (modalActualBegin) modalActualBegin.textContent = formatDateTime(actualBegin);
if (modalActualEnd) modalActualEnd.textContent = formatDateTime(actualEnd);
if (modalActualRow) {
modalActualRow.style.display = (actualBegin || actualEnd) ? '' : 'none';
}
}
function handlePlanningSubmit() {
if (!currentModalInterventionId) return;
if (!canMoveTimelineItems) {
showPlanningFeedback(gettext("Vous n'avez pas l'autorisation de modifier la planification."), 'warning');
return;
}
const beginValue = planningBeginInput?.value;
const endValue = planningEndInput?.value;
if (!beginValue || !endValue) {
showPlanningFeedback(gettext('Les dates planifiées sont obligatoires.'), 'danger');
return;
}
const beginDate = new Date(beginValue);
const endDate = new Date(endValue);
if (Number.isNaN(beginDate.getTime()) || Number.isNaN(endDate.getTime())) {
showPlanningFeedback(gettext('Format de date invalide.'), 'danger');
return;
}
if (endDate <= beginDate) {
showPlanningFeedback(gettext('La fin doit être postérieure au début.'), 'danger');
return;
}
persistScheduleUpdate({
interventionId: currentModalInterventionId,
// Ne pas inclure memberId/teamId : la modal ne gère pas les changements d'assignation.
// Envoyer ces clés provoquerait une désassignation involontaire (ex. intervention
// assignée à une équipe uniquement : currentModalMemberId serait null et effacerait
// l'équipe dans le backend).
start: beginDate,
end: endDate,
}).then(() => {
planningModal?.hide();
});
}
function handlePlanningBeginChange() {
if (!planningDurationInput) return;
const durationHours = parseFloat(planningDurationInput.value);
if (Number.isFinite(durationHours) && durationHours > 0) {
applyDurationToEnd(durationHours);
} else {
syncDurationFieldFromDates();
}
}
function handlePlanningEndChange() {
if (suppressEndInputEvent) {
return;
}
syncDurationFieldFromDates();
}
function handlePlanningDurationChange() {
if (suppressDurationInputEvent) {
return;
}
const hours = parseFloat(planningDurationInput?.value);
if (!Number.isFinite(hours) || hours <= 0) {
return;
}
applyDurationToEnd(hours);
}
function applyDurationToEnd(durationHours) {
if (!planningBeginInput || !planningEndInput) {
return;
}
const beginDate = parseDatetimeInput(planningBeginInput.value);
if (!beginDate) {
return;
}
const safeHours = Math.max(0.25, durationHours);
const durationMs = Math.round(safeHours * HOUR_MS);
const newEnd = new Date(beginDate.getTime() + durationMs);
suppressEndInputEvent = true;
planningEndInput.value = toDatetimeLocalValue(newEnd);
suppressEndInputEvent = false;
syncDurationFieldFromDates();
}
function syncDurationFieldFromDates() {
if (!planningDurationInput) {
return;
}
const beginDate = parseDatetimeInput(planningBeginInput?.value);
const endDate = parseDatetimeInput(planningEndInput?.value);
suppressDurationInputEvent = true;
if (!beginDate || !endDate || endDate <= beginDate) {
planningDurationInput.value = '';
suppressDurationInputEvent = false;
return;
}
const hours = (endDate.getTime() - beginDate.getTime()) / HOUR_MS;
planningDurationInput.value = formatDurationHours(hours);
suppressDurationInputEvent = false;
}
function toDatetimeLocalValue(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '';
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function parseDatetimeInput(value) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDurationHours(hours) {
if (!Number.isFinite(hours)) {
return '';
}
const rounded = Math.round(hours * 100) / 100;
return rounded % 1 === 0 ? String(rounded) : rounded.toFixed(2);
}
function showPlanningFeedback(message, variant = 'info') {
if (!planningFeedback) return;
planningFeedback.className = '';
planningFeedback.classList.add('alert', `alert-${variant}`);
planningFeedback.textContent = message;
planningFeedback.classList.remove('d-none');
}
function clearPlanningFeedback() {
if (!planningFeedback) return;
planningFeedback.classList.add('d-none');
planningFeedback.textContent = '';
}
function loadCalendarData(customDate) {
let url = interventionseventsUrl;
const viewMode = filterViewModeSelect?.value || 'timeline';
const targetDate = customDate || (viewMode === 'teams_daily' ? formatDateISO(teamsDailyActiveDate) : null);
if (targetDate) {
const urlObj = new URL(url, window.location.origin);
urlObj.searchParams.set('date', targetDate);
url = urlObj.pathname + urlObj.search;
}
return fetch(url, { cache: 'no-store' })
.then(r => r.json())
.then(data => {
if (data.view_mode === 'teams_daily') {
activateTeamsDailyView();
renderTeamsDailyGrid(data);
renderBacklog(data);
return;
}
if (data.view_mode === 'members') {
activateMembersView();
buildMembersTimeline(data);
return;
}
activateTimelineView();
const events = Array.isArray(data) ? data : [];
buildTimeline(events);
})
.catch(err => {
console.error(gettext('Erreur chargement événements:'), err);
});
}
// Reset filters handler
const resetFiltersBtn = document.getElementById('reset-filters');
if (resetFiltersBtn) {
resetFiltersBtn.addEventListener('click', event => {
event.preventDefault();
const targetUrl = resetFiltersBtn.getAttribute('href');
if (typeof saveCalendarPreferencesUrl !== 'undefined' && saveCalendarPreferencesUrl) {
const token = getCookie('csrftoken');
fetch(saveCalendarPreferencesUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': token
},
body: JSON.stringify({
preferences: {}
})
})
.then(() => {
window.location.href = targetUrl;
})
.catch(err => {
console.error('Error clearing calendar preferences:', err);
window.location.href = targetUrl;
});
} else {
window.location.href = targetUrl;
}
});
}
function setupBacklogDropZones() {
[membersBacklogWrapper, teamsDailyBacklogWrapper].forEach(wrapper => {
if (!wrapper) return;
wrapper.addEventListener('dragover', (e) => {
if (draggedBacklogItem && !draggedBacklogGroup) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
wrapper.classList.add('members-view-layout__backlog--highlight');
}
});
wrapper.addEventListener('dragleave', () => {
wrapper.classList.remove('members-view-layout__backlog--highlight');
});
wrapper.addEventListener('drop', (e) => {
wrapper.classList.remove('members-view-layout__backlog--highlight');
if (!draggedBacklogItem || draggedBacklogGroup) return;
e.preventDefault();
const itemToUnassign = draggedBacklogItem;
draggedBacklogItem = null;
let start = itemToUnassign.start ? new Date(itemToUnassign.start) : new Date();
let end = itemToUnassign.end ? new Date(itemToUnassign.end) : new Date(start.getTime() + 4 * 60 * 60 * 1000);
const todayStr = formatDateISO(new Date());
const startISO = formatDateISO(start);
if (startISO < todayStr) {
start = new Date(todayStr + 'T08:00:00');
const duration = itemToUnassign.duration_ms || 4 * 60 * 60 * 1000;
end = new Date(start.getTime() + duration);
}
persistScheduleUpdate({
interventionId: itemToUnassign.id,
memberId: null,
teamId: null,
start: start,
end: end
});
});
});
}
// Search handlers
const searchInput = document.getElementById('calendar-search-input');
const searchClearBtn = document.getElementById('calendar-search-clear');
const searchBtn = document.getElementById('calendar-search-btn');
if (searchInput) {
searchInput.addEventListener('input', () => {
if (searchClearBtn) {
searchClearBtn.style.display = searchInput.value ? '' : 'none';
}
});
searchClearBtn?.addEventListener('click', () => {
searchInput.value = '';
searchClearBtn.style.display = 'none';
if (calendarFiltersForm?.requestSubmit) {
calendarFiltersForm.requestSubmit();
} else {
calendarFiltersForm?.submit();
}
});
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (calendarFiltersForm?.requestSubmit) {
calendarFiltersForm.requestSubmit();
} else {
calendarFiltersForm?.submit();
}
}
});
}
// Initialize
updateTimelineButtonsUI(timelineState.view);
updateTimelineWrapperHeight();
setupBacklogDropZones();
loadCalendarData();
});
// Utility functions
function escapeHtml(s='') {
return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
}
function idealTextColor(hex) {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || '');
if (!m) return '#fff';
const r = parseInt(m[1],16), g = parseInt(m[2],16), b = parseInt(m[3],16);
return ((r*299 + g*587 + b*114) / 1000) >= 140 ? '#000' : '#fff';
}
function getCookie(name) {
if (!document?.cookie) return null;
const cookie = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith(name + '='));
if (!cookie) return null;
return decodeURIComponent(cookie.split('=')[1]);
}
function snapToHour(timestampMs) {
if (!Number.isFinite(timestampMs)) {
return timestampMs;
}
return Math.round(timestampMs / HOUR_MS) * HOUR_MS;
}