diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.css b/loko/interventions/static/interventions/calendar/interventions_calendar.css
index d02e08d..4434e83 100644
--- a/loko/interventions/static/interventions/calendar/interventions_calendar.css
+++ b/loko/interventions/static/interventions/calendar/interventions_calendar.css
@@ -139,6 +139,31 @@
opacity: 1;
}
+.canvas-timeline__tooltip {
+ position: absolute;
+ pointer-events: none;
+ z-index: 1050;
+ background: rgba(15, 23, 42, 0.94);
+ color: #fff;
+ padding: 0.45rem 0.75rem;
+ border-radius: 0.5rem;
+ font-size: 0.75rem;
+ line-height: 1.35;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22);
+ backdrop-filter: blur(4px);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ transition: opacity 0.12s ease, transform 0.12s ease;
+ white-space: nowrap;
+ transform: translate(-50%, -100%);
+ margin-top: -8px;
+}
+
+.canvas-timeline__tooltip.is-hidden {
+ opacity: 0;
+ visibility: hidden;
+ transform: translate(-50%, -90%);
+}
+
.canvas-scrollbar.is-hidden {
opacity: 0;
pointer-events: none;
diff --git a/loko/interventions/static/interventions/calendar/interventions_calendar.js b/loko/interventions/static/interventions/calendar/interventions_calendar.js
index d2f3878..add97e1 100644
--- a/loko/interventions/static/interventions/calendar/interventions_calendar.js
+++ b/loko/interventions/static/interventions/calendar/interventions_calendar.js
@@ -891,12 +891,48 @@ document.addEventListener('DOMContentLoaded', function () {
isEditable = managerAllowedStatuses.includes(statusCode);
}
+ const rawHistory = source.extendedProps?.status_history || source.status_history || [];
+ let statusHistory = rawHistory.map(h => {
+ const d = h.event_time ? new Date(h.event_time) : null;
+ return {
+ statusCode: h.status_code || 'unknown',
+ statusLabel: h.status_label || '',
+ date: (d && !Number.isNaN(d.getTime())) ? d : startDate,
+ userName: h.user_name || null
+ };
+ }).filter(h => h.date);
+
+ if (!statusHistory.length) {
+ statusHistory.push({
+ statusCode: statusCode,
+ statusLabel: statusLabel,
+ date: startDate,
+ userName: null
+ });
+ }
+ statusHistory.sort((a, b) => a.date - b.date);
+
+ const rawMilestones = source.extendedProps?.milestones || source.milestones || [];
+ const milestones = rawMilestones.map(m => {
+ const d = m.time ? new Date(m.time) : null;
+ if (!d || Number.isNaN(d.getTime())) return null;
+ return {
+ type: m.type,
+ label: m.label,
+ date: d,
+ shape: m.shape || 'square',
+ dashed: m.dashed !== false
+ };
+ }).filter(Boolean);
+
return {
id: source.id ?? `event-${index}`,
code,
title,
statusCode,
statusLabel,
+ statusHistory,
+ milestones,
memberName,
prepState,
maintainTypeLabel: source.extendedProps?.maintain_type_label || source.maintain_type_label || '',
@@ -936,6 +972,24 @@ document.addEventListener('DOMContentLoaded', function () {
if (Number.isFinite(end) && end > max) {
max = end;
}
+ if (Array.isArray(item.statusHistory)) {
+ for (const sh of item.statusHistory) {
+ const t = sh.date?.getTime?.();
+ if (Number.isFinite(t)) {
+ if (t < min) min = t;
+ if (t > max) max = t;
+ }
+ }
+ }
+ if (Array.isArray(item.milestones)) {
+ for (const m of item.milestones) {
+ const t = m.date?.getTime?.();
+ if (Number.isFinite(t)) {
+ if (t < min) min = t;
+ if (t > max) max = t;
+ }
+ }
+ }
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return null;
@@ -972,6 +1026,7 @@ document.addEventListener('DOMContentLoaded', function () {
+
${gettext(TIMELINE_EMPTY_MESSAGE)}
@@ -981,6 +1036,7 @@ document.addEventListener('DOMContentLoaded', function () {
const bodyCanvas = container.querySelector('.canvas-timeline__body canvas');
const bodyEl = container.querySelector('.canvas-timeline__body');
const emptyEl = container.querySelector('.canvas-timeline__empty');
+ const tooltipEl = container.querySelector('.canvas-timeline__tooltip');
const verticalScrollbar = container.querySelector('.canvas-scrollbar--vertical');
const horizontalScrollbar = container.querySelector('.canvas-scrollbar--horizontal');
@@ -991,6 +1047,7 @@ document.addEventListener('DOMContentLoaded', function () {
bodyCtx: bodyCanvas.getContext('2d'),
bodyEl,
emptyEl,
+ tooltipEl,
verticalScrollbar,
verticalThumb: verticalScrollbar?.querySelector('.canvas-scrollbar__thumb') || null,
horizontalScrollbar,
@@ -1035,6 +1092,7 @@ document.addEventListener('DOMContentLoaded', function () {
body.classList.add('is-panning');
body.classList.remove('is-hovering-item');
timelineState.hoverCoords = null;
+ hideTimelineTooltip();
};
const handlePointerMove = event => {
@@ -1090,6 +1148,7 @@ document.addEventListener('DOMContentLoaded', function () {
body.classList.remove('is-panning');
body.classList.remove('is-hovering-item');
timelineState.hoverCoords = null;
+ hideTimelineTooltip();
}
};
@@ -1122,6 +1181,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (!timelineState.pointer.active && !timelineState.drag?.active) {
body.classList.remove('is-hovering-item');
timelineState.hoverCoords = null;
+ hideTimelineTooltip();
}
};
@@ -1445,71 +1505,201 @@ document.addEventListener('DOMContentLoaded', function () {
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) {
+ const msPerPixel = timelineState.msPerPixel || MIN_MS_PER_PIXEL;
+ const trackY = rowTop + 33;
+ const labelY = rowTop + 14;
+
+ // Positions des changements de statut (sphères)
+ const historyPoints = (item.statusHistory || []).map((sh, idx) => ({
+ ...sh,
+ x: (sh.date.getTime() - viewStart) / msPerPixel,
+ isCurrent: idx === (item.statusHistory.length - 1)
+ }));
+
+ // Positions des jalons (carrés prévus / planifiés)
+ const milestonePoints = (item.milestones || []).map(m => ({
+ ...m,
+ x: (m.date.getTime() - viewStart) / msPerPixel
+ }));
+
+ const allX = [
+ ...historyPoints.map(p => p.x),
+ ...milestonePoints.map(m => m.x)
+ ];
+
+ if (!allX.length) {
+ return;
+ }
+
+ const minX = Math.min(...allX);
+ const maxX = Math.max(...allX);
+
+ if (maxX < -150 || minX > width + 150) {
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.globalAlpha = 0.35;
}
- ctx.fillStyle = color;
- drawRoundedRect(ctx, barLeft, barY, barWidth, TIMELINE_BAR_HEIGHT, 13);
- ctx.fill();
+ // 1. Libellé (Code · Titre) au-dessus de la ligne directrice
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;
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.88)';
+ const textWidth = ctx.measureText(label).width;
+ const labelLeft = Math.max(8, Math.min(width - textWidth - 8, minX));
+ ctx.fillText(label, labelLeft, labelY);
+ }
- if (labelWidth <= availableWidth) {
- ctx.fillStyle = textColor;
+ // 2. Lignes en pointillés vers les jalons futurs / projetés (carrés)
+ if (historyPoints.length > 0) {
+ const currentPoint = historyPoints[historyPoints.length - 1];
+ for (const m of milestonePoints) {
+ if (Math.abs(m.x - currentPoint.x) > 2) {
+ ctx.save();
+ ctx.strokeStyle = '#94a3b8';
+ ctx.lineWidth = 2;
+ if (m.dashed) {
+ ctx.setLineDash([4, 4]);
+ }
+ ctx.beginPath();
+ ctx.moveTo(currentPoint.x, trackY);
+ ctx.lineTo(m.x, trackY);
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+ }
+
+ // 3. Ligne continue reliant les statuts passés / réalisés
+ if (historyPoints.length > 1) {
+ ctx.save();
+ ctx.strokeStyle = '#64748b';
+ ctx.lineWidth = 3;
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ ctx.moveTo(historyPoints[0].x, trackY);
+ for (let i = 1; i < historyPoints.length; i++) {
+ ctx.lineTo(historyPoints[i].x, trackY);
+ }
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ // 4. Jalons carrés (Dates prévisionnelles et planifiées)
+ const squareSize = 12;
+ for (const m of milestonePoints) {
+ ctx.save();
+ ctx.fillStyle = '#ffffff';
+ ctx.strokeStyle = '#64748b';
+ ctx.lineWidth = 2;
+ if (m.dashed) {
+ ctx.setLineDash([3, 2]);
+ }
+ drawRoundedRect(ctx, m.x - squareSize / 2, trackY - squareSize / 2, squareSize, squareSize, 2);
+ ctx.fill();
+ ctx.stroke();
+
+ // Point d'accent au centre du carré
+ ctx.fillStyle = '#64748b';
+ ctx.fillRect(m.x - 2, trackY - 2, 4, 4);
+ ctx.restore();
+
+ timelineState.hitRegions.push({
+ index,
+ isMilestone: true,
+ kind: 'milestone',
+ milestoneType: m.type,
+ label: m.label,
+ date: m.date,
+ itemCode: item.code,
+ itemTitle: item.title,
+ x: m.x - 9,
+ y: trackY - 9,
+ width: 18,
+ height: 18,
+ anchorX: m.x,
+ anchorY: trackY - 9
+ });
+ }
+
+ // 5. Sphères colorées pour chaque changement de statut
+ const sphereRadius = 8;
+ for (let i = 0; i < historyPoints.length; i++) {
+ const sh = historyPoints[i];
+ const sphereColor = STATUS_COLORS[sh.statusCode] || DEFAULT_STATUS_COLOR;
+
+ // Halo sur le statut actuel
+ if (sh.isCurrent) {
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.arc(sh.x, trackY, sphereRadius + 4.5, 0, Math.PI * 2);
+ ctx.strokeStyle = sphereColor;
+ ctx.globalAlpha = 0.45;
+ ctx.lineWidth = 2.5;
+ ctx.stroke();
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);
}
+
+ // Dégradé sphérique 3D
+ ctx.save();
+ const grad = ctx.createRadialGradient(
+ sh.x - 2.5, trackY - 2.5, 1,
+ sh.x, trackY, sphereRadius
+ );
+ grad.addColorStop(0, '#ffffff');
+ grad.addColorStop(0.35, sphereColor);
+ grad.addColorStop(1, shadeColor(sphereColor, -28));
+
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(sh.x, trackY, sphereRadius, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Contour blanc fin et soigné
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
+ ctx.lineWidth = 1.2;
+ ctx.stroke();
+ ctx.restore();
+
+ timelineState.hitRegions.push({
+ index,
+ isMilestone: true,
+ kind: 'status',
+ statusCode: sh.statusCode,
+ statusLabel: sh.statusLabel,
+ date: sh.date,
+ userName: sh.userName,
+ isCurrent: sh.isCurrent,
+ itemCode: item.code,
+ itemTitle: item.title,
+ x: sh.x - 10,
+ y: trackY - 10,
+ width: 20,
+ height: 20,
+ anchorX: sh.x,
+ anchorY: trackY - 10
+ });
}
ctx.restore();
+ // 6. Hit region globale pour le clic et le drag de l'intervention
timelineState.hitRegions.push({
index,
- x: barLeft,
- y: barY,
- width: barWidth,
- height: TIMELINE_BAR_HEIGHT
+ isBar: true,
+ x: minX - 12,
+ y: rowTop + 2,
+ width: Math.max(48, maxX - minX + 24),
+ height: TIMELINE_ROW_HEIGHT - 4
});
}
@@ -1635,6 +1825,7 @@ document.addEventListener('DOMContentLoaded', function () {
const dom = timelineState.dom;
if (!dom || timelineState.pointer.active || timelineState.drag?.active) {
dom?.bodyEl?.classList.remove('is-hovering-item');
+ hideTimelineTooltip();
return;
}
let coords = event ? getCanvasCoordinates(event) : timelineState.hoverCoords;
@@ -1644,18 +1835,89 @@ document.addEventListener('DOMContentLoaded', function () {
if (!coords) {
dom.bodyEl.classList.remove('is-hovering-item');
timelineState.hoverCoords = null;
+ hideTimelineTooltip();
return;
}
- const hit = timelineState.hitRegions.some(region =>
+
+ const milestoneHit = timelineState.hitRegions.find(region =>
+ region.isMilestone &&
coords.x >= region.x &&
coords.x <= region.x + region.width &&
coords.y >= region.y &&
coords.y <= region.y + region.height
);
- if (hit) {
+
+ if (milestoneHit) {
dom.bodyEl.classList.add('is-hovering-item');
+ showTimelineTooltip(milestoneHit);
+ return;
+ }
+
+ const barHit = timelineState.hitRegions.find(region =>
+ region.isBar &&
+ coords.x >= region.x &&
+ coords.x <= region.x + region.width &&
+ coords.y >= region.y &&
+ coords.y <= region.y + region.height
+ );
+
+ if (barHit) {
+ dom.bodyEl.classList.add('is-hovering-item');
+ hideTimelineTooltip();
} else {
dom.bodyEl.classList.remove('is-hovering-item');
+ hideTimelineTooltip();
+ }
+ }
+
+ function showTimelineTooltip(hit) {
+ const dom = timelineState.dom;
+ if (!dom?.tooltipEl) return;
+
+ let dateStr = '';
+ if (hit.date instanceof Date && !Number.isNaN(hit.date.getTime())) {
+ dateStr = hit.date.toLocaleString(DOCUMENT_LOCALE, {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ }
+
+ let content = '';
+ if (hit.kind === 'status') {
+ const color = STATUS_COLORS[hit.statusCode] || DEFAULT_STATUS_COLOR;
+ const currentBadge = hit.isCurrent ? ` ${gettext('Actuel')}` : '';
+ const userStr = hit.userName ? `${escapeHtml(hit.userName)}
` : '';
+ content = `
+
+
+ ${escapeHtml(hit.statusLabel || hit.statusCode)}${currentBadge}
+
+ ${escapeHtml(dateStr)}
+ ${userStr}
+ `;
+ } else if (hit.kind === 'milestone') {
+ content = `
+
+
+ ${escapeHtml(hit.label || hit.milestoneType)}
+
+ ${escapeHtml(dateStr)}
+ `;
+ }
+
+ dom.tooltipEl.innerHTML = content;
+ dom.tooltipEl.style.left = `${hit.anchorX || (hit.x + hit.width / 2)}px`;
+ dom.tooltipEl.style.top = `${hit.anchorY || hit.y}px`;
+ dom.tooltipEl.classList.remove('is-hidden');
+ }
+
+ function hideTimelineTooltip() {
+ const dom = timelineState.dom;
+ if (dom?.tooltipEl) {
+ dom.tooltipEl.classList.add('is-hidden');
}
}
@@ -1826,29 +2088,123 @@ document.addEventListener('DOMContentLoaded', function () {
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(' · ');
+ const msPerPixel = timelineState.msPerPixel || MIN_MS_PER_PIXEL;
+ const deltaMs = drag.preview.startMs - drag.startMs;
+ const trackY = rowTop + 33;
+ const labelY = rowTop + 14;
+
+ const item = drag.item;
+ const historyPoints = (item?.statusHistory || []).map((sh, idx) => ({
+ ...sh,
+ x: (sh.date.getTime() + deltaMs - viewStart) / msPerPixel,
+ isCurrent: idx === (item.statusHistory.length - 1)
+ }));
+ const milestonePoints = (item?.milestones || []).map(m => ({
+ ...m,
+ x: (m.date.getTime() + deltaMs - viewStart) / msPerPixel
+ }));
+
+ const allX = [
+ ...historyPoints.map(p => p.x),
+ ...milestonePoints.map(m => m.x)
+ ];
+ if (!allX.length) return;
+ const minX = Math.min(...allX);
ctx.save();
ctx.globalAlpha = 0.95;
- ctx.fillStyle = color;
- drawRoundedRect(ctx, startX, barY, barWidth, TIMELINE_BAR_HEIGHT, 13);
- ctx.fill();
+ // Label
+ const 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 = textColor;
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.95)';
+ ctx.fillText(label, Math.max(8, minX), labelY);
+ }
+
+ // Dashed lines to milestones
+ if (historyPoints.length > 0) {
+ const currentPoint = historyPoints[historyPoints.length - 1];
+ for (const m of milestonePoints) {
+ if (Math.abs(m.x - currentPoint.x) > 2) {
+ ctx.save();
+ ctx.strokeStyle = '#64748b';
+ ctx.lineWidth = 2;
+ if (m.dashed) {
+ ctx.setLineDash([4, 4]);
+ }
+ ctx.beginPath();
+ ctx.moveTo(currentPoint.x, trackY);
+ ctx.lineTo(m.x, trackY);
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+ }
+
+ // Connecting line
+ if (historyPoints.length > 1) {
ctx.save();
+ ctx.strokeStyle = '#475569';
+ ctx.lineWidth = 3;
+ ctx.lineCap = 'round';
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.moveTo(historyPoints[0].x, trackY);
+ for (let i = 1; i < historyPoints.length; i++) {
+ ctx.lineTo(historyPoints[i].x, trackY);
+ }
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ // Milestone squares
+ const squareSize = 12;
+ for (const m of milestonePoints) {
+ ctx.save();
+ ctx.fillStyle = '#ffffff';
+ ctx.strokeStyle = '#475569';
+ ctx.lineWidth = 2;
+ if (m.dashed) ctx.setLineDash([3, 2]);
+ drawRoundedRect(ctx, m.x - squareSize / 2, trackY - squareSize / 2, squareSize, squareSize, 2);
+ ctx.fill();
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ // Status Spheres
+ const sphereRadius = 8;
+ for (let i = 0; i < historyPoints.length; i++) {
+ const sh = historyPoints[i];
+ const sphereColor = STATUS_COLORS[sh.statusCode] || DEFAULT_STATUS_COLOR;
+
+ if (sh.isCurrent) {
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(sh.x, trackY, sphereRadius + 4.5, 0, Math.PI * 2);
+ ctx.strokeStyle = sphereColor;
+ ctx.lineWidth = 2.5;
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ ctx.save();
+ const grad = ctx.createRadialGradient(
+ sh.x - 2.5, trackY - 2.5, 1,
+ sh.x, trackY, sphereRadius
+ );
+ grad.addColorStop(0, '#ffffff');
+ grad.addColorStop(0.35, sphereColor);
+ grad.addColorStop(1, shadeColor(sphereColor, -28));
+
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(sh.x, trackY, sphereRadius, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.strokeStyle = '#ffffff';
+ ctx.lineWidth = 1.2;
+ ctx.stroke();
ctx.restore();
}
@@ -4722,6 +5078,30 @@ function idealTextColor(hex) {
return ((r*299 + g*587 + b*114) / 1000) >= 140 ? '#000' : '#fff';
}
+function shadeColor(color, percent) {
+ if (!color || typeof color !== 'string') return '#666666';
+ let hex = color.trim();
+ if (!hex.startsWith('#')) return hex;
+ let num = parseInt(hex.slice(1), 16);
+ let R = 0, G = 0, B = 0;
+ if (hex.length === 4) {
+ R = (num >> 8) * 17;
+ G = ((num >> 4) & 0x0F) * 17;
+ B = (num & 0x0F) * 17;
+ } else {
+ R = num >> 16;
+ G = (num >> 8) & 0x00FF;
+ B = num & 0x0000FF;
+ }
+ R = Math.min(255, Math.max(0, parseInt(R * (100 + percent) / 100)));
+ G = Math.min(255, Math.max(0, parseInt(G * (100 + percent) / 100)));
+ B = Math.min(255, Math.max(0, parseInt(B * (100 + percent) / 100)));
+ const RR = (R.toString(16).length === 1 ? "0" + R.toString(16) : R.toString(16));
+ const GG = (G.toString(16).length === 1 ? "0" + G.toString(16) : G.toString(16));
+ const BB = (B.toString(16).length === 1 ? "0" + B.toString(16) : B.toString(16));
+ return "#" + RR + GG + BB;
+}
+
function getCookie(name) {
if (!document?.cookie) return null;
const cookie = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith(name + '='));
diff --git a/loko/interventions/tests/test_calendar.py b/loko/interventions/tests/test_calendar.py
index 172c58f..b5df389 100644
--- a/loko/interventions/tests/test_calendar.py
+++ b/loko/interventions/tests/test_calendar.py
@@ -13,6 +13,7 @@ from contracts.models import Company, CompanyTeam, CompanyMember, Contract, Cont
from assets.models import AssetCategory
from interventions.models import (
Intervention,
+ InterventionTimeLine,
InterventionContractPost,
InterventionPlanificationTimeLine,
InterventionRealizationTimeLine,
@@ -52,6 +53,77 @@ class CalendarEventsTests(TestCase):
self.client.login(username='calendar-user', password='pwd')
+ def test_timeline_events_status_history_and_milestones(self):
+ now = timezone.now()
+ itv = Intervention.objects.create(
+ title='Lifecycle Intervention',
+ status='in_progress',
+ assigned_member=self.member,
+ expected_begin_time=now - timedelta(hours=5),
+ expected_end_time=now + timedelta(hours=10),
+ planned_begin_time=now + timedelta(hours=1),
+ planned_end_time=now + timedelta(hours=4),
+ )
+ # Create history events
+ InterventionTimeLine.objects.create(
+ intervention=itv,
+ event_type='status_change',
+ from_status='in_preparation',
+ to_status='to_be_planned',
+ event_time=now - timedelta(hours=4),
+ event_user=self.user,
+ )
+ InterventionTimeLine.objects.create(
+ intervention=itv,
+ event_type='status_change',
+ from_status='to_be_planned',
+ to_status='in_progress',
+ event_time=now - timedelta(hours=2),
+ event_user=self.user,
+ )
+
+ url = reverse('interventions:interventions_events')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ events = response.json()
+ itv_event = next((e for e in events if e['id'] == itv.id), None)
+ self.assertIsNotNone(itv_event)
+
+ ext = itv_event.get('extendedProps', {})
+ self.assertIn('status_history', ext)
+ self.assertIn('milestones', ext)
+
+ history = ext['status_history']
+ self.assertGreaterEqual(len(history), 2)
+ status_codes = [h['status_code'] for h in history]
+ self.assertIn('to_be_planned', status_codes)
+ self.assertIn('in_progress', status_codes)
+
+ milestones = ext['milestones']
+ milestone_types = [m['type'] for m in milestones]
+ self.assertIn('expected_begin', milestone_types)
+ self.assertIn('expected_end', milestone_types)
+ self.assertIn('planned_begin', milestone_types)
+ self.assertIn('planned_end', milestone_types)
+
+ def test_timeline_events_fallback_when_no_history(self):
+ itv = Intervention.objects.create(
+ title='No History Intervention',
+ status='to_be_approved',
+ )
+ url = reverse('interventions:interventions_events')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ events = response.json()
+ itv_event = next((e for e in events if e['id'] == itv.id), None)
+ self.assertIsNotNone(itv_event)
+
+ ext = itv_event.get('extendedProps', {})
+ self.assertIn('status_history', ext)
+ history = ext['status_history']
+ self.assertEqual(len(history), 1)
+ self.assertEqual(history[0]['status_code'], 'to_be_approved')
+
def test_calendar_events_view_mode_members_no_duplicates(self):
# Request calendar events in member view mode
url = reverse('interventions:interventions_events') + '?view_mode=members'
diff --git a/loko/interventions/views/calendar.py b/loko/interventions/views/calendar.py
index 4750146..6a93442 100644
--- a/loko/interventions/views/calendar.py
+++ b/loko/interventions/views/calendar.py
@@ -482,8 +482,87 @@ def interventions_events(request):
)
# Default: timeline view
+ interventions = interventions.prefetch_related(
+ Prefetch(
+ 'events',
+ queryset=InterventionTimeLine.objects.filter(event_type='status_change').select_related('event_user').order_by('event_time'),
+ to_attr='status_events',
+ )
+ )
+
events = []
for itv in interventions:
+ status_events = getattr(itv, 'status_events', None)
+ status_history = []
+ if status_events:
+ first_evt = status_events[0]
+ if first_evt.from_status and itv.creation_time and itv.creation_time < first_evt.event_time:
+ status_history.append({
+ 'status_code': first_evt.from_status,
+ 'status_label': str(STATUS_CHOICES.get(first_evt.from_status, first_evt.from_status)),
+ 'event_time': itv.creation_time.isoformat(),
+ 'user_name': None,
+ })
+ for evt in status_events:
+ code = evt.to_status or itv.status
+ user_name = get_short_name(evt.event_user) if evt.event_user else None
+ status_history.append({
+ 'status_code': code,
+ 'status_label': str(STATUS_CHOICES.get(code, code)),
+ 'event_time': evt.event_time.isoformat() if evt.event_time else None,
+ 'user_name': user_name,
+ })
+ else:
+ ref_time = itv.begin_time or itv.creation_time or itv.effective_begin
+ status_history.append({
+ 'status_code': itv.status,
+ 'status_label': itv.get_status_display(),
+ 'event_time': ref_time.isoformat() if ref_time else None,
+ 'user_name': None,
+ })
+
+ milestones = []
+ if itv.expected_begin_time:
+ milestones.append({
+ 'type': 'expected_begin',
+ 'label': str(_('Début prévu')),
+ 'time': itv.expected_begin_time.isoformat(),
+ 'shape': 'square',
+ 'dashed': True,
+ })
+ if itv.expected_end_time:
+ milestones.append({
+ 'type': 'expected_end',
+ 'label': str(_('Fin prévue')),
+ 'time': itv.expected_end_time.isoformat(),
+ 'shape': 'square',
+ 'dashed': True,
+ })
+ if itv.planned_begin_time:
+ milestones.append({
+ 'type': 'planned_begin',
+ 'label': str(_('Début planifié')),
+ 'time': itv.planned_begin_time.isoformat(),
+ 'shape': 'square',
+ 'dashed': True,
+ })
+ if itv.planned_end_time:
+ milestones.append({
+ 'type': 'planned_end',
+ 'label': str(_('Fin planifiée')),
+ 'time': itv.planned_end_time.isoformat(),
+ 'shape': 'square',
+ 'dashed': True,
+ })
+ if itv.end_time:
+ milestones.append({
+ 'type': 'actual_end',
+ 'label': str(_('Fin réelle')),
+ 'time': itv.end_time.isoformat(),
+ 'shape': 'circle',
+ 'dashed': False,
+ })
+
events.append({
'id': itv.id,
'title': itv.title,
@@ -494,6 +573,8 @@ def interventions_events(request):
'code': itv.code,
'status': itv.get_status_display(),
'status_code': itv.status, # utile pour la couleur dans le Gantt
+ 'status_history': status_history,
+ 'milestones': milestones,
'prep_state': itv.prep_state,
'priority': itv.get_priority_display(),
'priority_code': itv.priority,