feat: add status history and milestone visualization with interactive tooltips to the intervention timeline
This commit is contained in:
parent
a50bebe427
commit
04a118d04d
4 changed files with 612 additions and 54 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 + '='));
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue