4674 lines
186 KiB
JavaScript
4674 lines
186 KiB
JavaScript
|
||
// --- 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;
|
||
}
|