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;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
|
|||
|
|
@ -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 () {
|
|||
<div class="canvas-scrollbar canvas-scrollbar--horizontal">
|
||||
<div class="canvas-scrollbar__thumb"></div>
|
||||
</div>
|
||||
<div class="canvas-timeline__tooltip is-hidden"></div>
|
||||
<div class="canvas-timeline__empty">${gettext(TIMELINE_EMPTY_MESSAGE)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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.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.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();
|
||||
}
|
||||
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();
|
||||
|
||||
timelineState.hitRegions.push({
|
||||
index,
|
||||
x: barLeft,
|
||||
y: barY,
|
||||
width: barWidth,
|
||||
height: TIMELINE_BAR_HEIGHT
|
||||
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.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;
|
||||
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 ? ` <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;
|
||||
}
|
||||
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.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.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.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 + '='));
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue