feat: add status history and milestone visualization with interactive tooltips to the intervention timeline

This commit is contained in:
kdeterme 2026-08-24 18:32:40 +02:00
parent a50bebe427
commit 04a118d04d
4 changed files with 612 additions and 54 deletions

View file

@ -139,6 +139,31 @@
opacity: 1; 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 { .canvas-scrollbar.is-hidden {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;

View file

@ -891,12 +891,48 @@ document.addEventListener('DOMContentLoaded', function () {
isEditable = managerAllowedStatuses.includes(statusCode); 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 { return {
id: source.id ?? `event-${index}`, id: source.id ?? `event-${index}`,
code, code,
title, title,
statusCode, statusCode,
statusLabel, statusLabel,
statusHistory,
milestones,
memberName, memberName,
prepState, prepState,
maintainTypeLabel: source.extendedProps?.maintain_type_label || source.maintain_type_label || '', maintainTypeLabel: source.extendedProps?.maintain_type_label || source.maintain_type_label || '',
@ -936,6 +972,24 @@ document.addEventListener('DOMContentLoaded', function () {
if (Number.isFinite(end) && end > max) { if (Number.isFinite(end) && end > max) {
max = end; 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)) { if (!Number.isFinite(min) || !Number.isFinite(max)) {
return null; return null;
@ -972,6 +1026,7 @@ document.addEventListener('DOMContentLoaded', function () {
<div class="canvas-scrollbar canvas-scrollbar--horizontal"> <div class="canvas-scrollbar canvas-scrollbar--horizontal">
<div class="canvas-scrollbar__thumb"></div> <div class="canvas-scrollbar__thumb"></div>
</div> </div>
<div class="canvas-timeline__tooltip is-hidden"></div>
<div class="canvas-timeline__empty">${gettext(TIMELINE_EMPTY_MESSAGE)}</div> <div class="canvas-timeline__empty">${gettext(TIMELINE_EMPTY_MESSAGE)}</div>
</div> </div>
</div> </div>
@ -981,6 +1036,7 @@ document.addEventListener('DOMContentLoaded', function () {
const bodyCanvas = container.querySelector('.canvas-timeline__body canvas'); const bodyCanvas = container.querySelector('.canvas-timeline__body canvas');
const bodyEl = container.querySelector('.canvas-timeline__body'); const bodyEl = container.querySelector('.canvas-timeline__body');
const emptyEl = container.querySelector('.canvas-timeline__empty'); const emptyEl = container.querySelector('.canvas-timeline__empty');
const tooltipEl = container.querySelector('.canvas-timeline__tooltip');
const verticalScrollbar = container.querySelector('.canvas-scrollbar--vertical'); const verticalScrollbar = container.querySelector('.canvas-scrollbar--vertical');
const horizontalScrollbar = container.querySelector('.canvas-scrollbar--horizontal'); const horizontalScrollbar = container.querySelector('.canvas-scrollbar--horizontal');
@ -991,6 +1047,7 @@ document.addEventListener('DOMContentLoaded', function () {
bodyCtx: bodyCanvas.getContext('2d'), bodyCtx: bodyCanvas.getContext('2d'),
bodyEl, bodyEl,
emptyEl, emptyEl,
tooltipEl,
verticalScrollbar, verticalScrollbar,
verticalThumb: verticalScrollbar?.querySelector('.canvas-scrollbar__thumb') || null, verticalThumb: verticalScrollbar?.querySelector('.canvas-scrollbar__thumb') || null,
horizontalScrollbar, horizontalScrollbar,
@ -1035,6 +1092,7 @@ document.addEventListener('DOMContentLoaded', function () {
body.classList.add('is-panning'); body.classList.add('is-panning');
body.classList.remove('is-hovering-item'); body.classList.remove('is-hovering-item');
timelineState.hoverCoords = null; timelineState.hoverCoords = null;
hideTimelineTooltip();
}; };
const handlePointerMove = event => { const handlePointerMove = event => {
@ -1090,6 +1148,7 @@ document.addEventListener('DOMContentLoaded', function () {
body.classList.remove('is-panning'); body.classList.remove('is-panning');
body.classList.remove('is-hovering-item'); body.classList.remove('is-hovering-item');
timelineState.hoverCoords = null; timelineState.hoverCoords = null;
hideTimelineTooltip();
} }
}; };
@ -1122,6 +1181,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (!timelineState.pointer.active && !timelineState.drag?.active) { if (!timelineState.pointer.active && !timelineState.drag?.active) {
body.classList.remove('is-hovering-item'); body.classList.remove('is-hovering-item');
timelineState.hoverCoords = null; timelineState.hoverCoords = null;
hideTimelineTooltip();
} }
}; };
@ -1445,71 +1505,201 @@ document.addEventListener('DOMContentLoaded', function () {
function drawTimelineBar(ctx, item, rowTop, width, index) { function drawTimelineBar(ctx, item, rowTop, width, index) {
const viewStart = timelineState.viewStartMs || 0; const viewStart = timelineState.viewStartMs || 0;
const startX = (item.startDate.getTime() - viewStart) / timelineState.msPerPixel; const msPerPixel = timelineState.msPerPixel || MIN_MS_PER_PIXEL;
const endX = (item.endDate.getTime() - viewStart) / timelineState.msPerPixel; const trackY = rowTop + 33;
if (endX < -120 || startX > width + 120) { 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; 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 dragState = timelineState.drag;
const isDraggingThisItem = dragState?.active && dragState.itemIndex === index; const isDraggingThisItem = dragState?.active && dragState.itemIndex === index;
const shouldDim = isDraggingThisItem && dragState.hasMoved; const shouldDim = isDraggingThisItem && dragState.hasMoved;
ctx.save(); ctx.save();
if (shouldDim) { 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 labelParts = [item.code, item.title].filter(Boolean);
const label = labelParts.join(' · '); const label = labelParts.join(' · ');
if (label) { if (label) {
const textColor = idealTextColor(color);
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif'; ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const availableWidth = Math.max(0, barWidth - 24); ctx.fillStyle = 'rgba(15, 23, 42, 0.88)';
const labelWidth = ctx.measureText(label).width; 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) { // 2. Lignes en pointillés vers les jalons futurs / projetés (carrés)
ctx.fillStyle = textColor; 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.save();
ctx.beginPath(); ctx.strokeStyle = '#94a3b8';
ctx.rect(barLeft + 8, barY, Math.max(0, barWidth - 16), TIMELINE_BAR_HEIGHT); ctx.lineWidth = 2;
ctx.clip(); if (m.dashed) {
ctx.fillText(label, barLeft + 12, barY + TIMELINE_BAR_HEIGHT / 2); ctx.setLineDash([4, 4]);
ctx.restore(); }
} else { ctx.beginPath();
const outsideSpacing = 10; ctx.moveTo(currentPoint.x, trackY);
const barRight = barLeft + barWidth; ctx.lineTo(m.x, trackY);
const rightSpace = width - barRight - outsideSpacing; ctx.stroke();
const leftSpace = barLeft - outsideSpacing; ctx.restore();
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);
} }
} }
// 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(); ctx.restore();
timelineState.hitRegions.push({ timelineState.hitRegions.push({
index, index,
x: barLeft, isMilestone: true,
y: barY, kind: 'milestone',
width: barWidth, milestoneType: m.type,
height: TIMELINE_BAR_HEIGHT 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.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();
}
// 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,
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; const dom = timelineState.dom;
if (!dom || timelineState.pointer.active || timelineState.drag?.active) { if (!dom || timelineState.pointer.active || timelineState.drag?.active) {
dom?.bodyEl?.classList.remove('is-hovering-item'); dom?.bodyEl?.classList.remove('is-hovering-item');
hideTimelineTooltip();
return; return;
} }
let coords = event ? getCanvasCoordinates(event) : timelineState.hoverCoords; let coords = event ? getCanvasCoordinates(event) : timelineState.hoverCoords;
@ -1644,18 +1835,89 @@ document.addEventListener('DOMContentLoaded', function () {
if (!coords) { if (!coords) {
dom.bodyEl.classList.remove('is-hovering-item'); dom.bodyEl.classList.remove('is-hovering-item');
timelineState.hoverCoords = null; timelineState.hoverCoords = null;
hideTimelineTooltip();
return; return;
} }
const hit = timelineState.hitRegions.some(region =>
const milestoneHit = timelineState.hitRegions.find(region =>
region.isMilestone &&
coords.x >= region.x && coords.x >= region.x &&
coords.x <= region.x + region.width && coords.x <= region.x + region.width &&
coords.y >= region.y && coords.y >= region.y &&
coords.y <= region.y + region.height coords.y <= region.y + region.height
); );
if (hit) {
if (milestoneHit) {
dom.bodyEl.classList.add('is-hovering-item'); 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 { } else {
dom.bodyEl.classList.remove('is-hovering-item'); 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 ? ` <span class="badge bg-light text-dark ms-1" style="font-size:0.65rem;">${gettext('Actuel')}</span>` : '';
const userStr = hit.userName ? `<div style="font-size:0.7rem;opacity:0.85;margin-top:2px;">${escapeHtml(hit.userName)}</div>` : '';
content = `
<div class="d-flex align-items-center gap-1 mb-1">
<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background-color:${color};flex-shrink:0;"></span>
<strong>${escapeHtml(hit.statusLabel || hit.statusCode)}</strong>${currentBadge}
</div>
<div style="font-size:0.75rem;opacity:0.9;">${escapeHtml(dateStr)}</div>
${userStr}
`;
} else if (hit.kind === 'milestone') {
content = `
<div class="d-flex align-items-center gap-1 mb-1">
<i class="bi bi-calendar-event"></i>
<strong>${escapeHtml(hit.label || hit.milestoneType)}</strong>
</div>
<div style="font-size:0.75rem;opacity:0.9;">${escapeHtml(dateStr)}</div>
`;
}
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; return;
} }
const viewStart = timelineState.viewStartMs || 0; const viewStart = timelineState.viewStartMs || 0;
const startX = (drag.preview.startMs - viewStart) / timelineState.msPerPixel; const msPerPixel = timelineState.msPerPixel || MIN_MS_PER_PIXEL;
const endX = (drag.preview.endMs - viewStart) / timelineState.msPerPixel; const deltaMs = drag.preview.startMs - drag.startMs;
const barY = rowTop + (TIMELINE_ROW_HEIGHT - TIMELINE_BAR_HEIGHT) / 2; const trackY = rowTop + 33;
const barWidth = Math.max(6, endX - startX); const labelY = rowTop + 14;
const color = STATUS_COLORS[drag.item?.statusCode] || DEFAULT_STATUS_COLOR;
const label = [drag.item?.code, drag.item?.title].filter(Boolean).join(' · '); 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.save();
ctx.globalAlpha = 0.95; 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) { if (label) {
const textColor = idealTextColor(color);
ctx.font = '600 12px "Inter", "Segoe UI", sans-serif'; ctx.font = '600 12px "Inter", "Segoe UI", sans-serif';
ctx.textBaseline = 'middle'; 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.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.save();
ctx.beginPath(); ctx.beginPath();
ctx.rect(startX + 8, barY, Math.max(0, barWidth - 16), TIMELINE_BAR_HEIGHT); ctx.arc(sh.x, trackY, sphereRadius + 4.5, 0, Math.PI * 2);
ctx.clip(); ctx.strokeStyle = sphereColor;
ctx.fillText(label, startX + 12, barY + TIMELINE_BAR_HEIGHT / 2); 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(); ctx.restore();
} }
@ -4722,6 +5078,30 @@ function idealTextColor(hex) {
return ((r*299 + g*587 + b*114) / 1000) >= 140 ? '#000' : '#fff'; 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) { function getCookie(name) {
if (!document?.cookie) return null; if (!document?.cookie) return null;
const cookie = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith(name + '=')); const cookie = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith(name + '='));

View file

@ -13,6 +13,7 @@ from contracts.models import Company, CompanyTeam, CompanyMember, Contract, Cont
from assets.models import AssetCategory from assets.models import AssetCategory
from interventions.models import ( from interventions.models import (
Intervention, Intervention,
InterventionTimeLine,
InterventionContractPost, InterventionContractPost,
InterventionPlanificationTimeLine, InterventionPlanificationTimeLine,
InterventionRealizationTimeLine, InterventionRealizationTimeLine,
@ -52,6 +53,77 @@ class CalendarEventsTests(TestCase):
self.client.login(username='calendar-user', password='pwd') 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): def test_calendar_events_view_mode_members_no_duplicates(self):
# Request calendar events in member view mode # Request calendar events in member view mode
url = reverse('interventions:interventions_events') + '?view_mode=members' url = reverse('interventions:interventions_events') + '?view_mode=members'

View file

@ -482,8 +482,87 @@ def interventions_events(request):
) )
# Default: timeline view # 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 = [] events = []
for itv in interventions: 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({ events.append({
'id': itv.id, 'id': itv.id,
'title': itv.title, 'title': itv.title,
@ -494,6 +573,8 @@ def interventions_events(request):
'code': itv.code, 'code': itv.code,
'status': itv.get_status_display(), 'status': itv.get_status_display(),
'status_code': itv.status, # utile pour la couleur dans le Gantt 'status_code': itv.status, # utile pour la couleur dans le Gantt
'status_history': status_history,
'milestones': milestones,
'prep_state': itv.prep_state, 'prep_state': itv.prep_state,
'priority': itv.get_priority_display(), 'priority': itv.get_priority_display(),
'priority_code': itv.priority, 'priority_code': itv.priority,