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