feat(interventions): distinguer les interventions 'à traiter' déjà assignées en rose (#d81b60)

- Ajout de la couleur 'to_be_processed_assigned' (#d81b60) dans STATUS_COLORS (Python & JS).
- Ajout de la propriété is_assigned sur le modèle Intervention.
- Prise en compte de la couleur dans les vues calendrier (membres, timeline, backlog, équipes) et sur la carte MapLibre du modal calendrier.
- Enrichissement des popups carte et des badges du backlog avec l'équipe/membre assigné.
- Ajout de tests unitaires validant la couleur et la sérialisation is_assigned.
This commit is contained in:
kdeterme 2026-09-28 14:45:17 +02:00
parent 0bbe267498
commit 37c1016b57
9 changed files with 295 additions and 41 deletions

View file

@ -312,6 +312,11 @@ label,
color: #ffffff !important; color: #ffffff !important;
} }
.badge.status-to_be_processed_assigned {
background-color: #d81b60 !important;
color: #ffffff !important;
}
.badge.status-assigned { .badge.status-assigned {
background-color: #63b3ed !important; background-color: #63b3ed !important;
color: #000000 !important; color: #000000 !important;

View file

@ -68,6 +68,7 @@ STATUS_COLORS = {
'to_be_approved': '#ffb74d', 'to_be_approved': '#ffb74d',
'to_be_planned': '#ff8a80', 'to_be_planned': '#ff8a80',
'to_be_processed': '#f4511e', 'to_be_processed': '#f4511e',
'to_be_processed_assigned': '#d81b60',
'assigned': '#63b3ed', 'assigned': '#63b3ed',
'in_progress': '#3182ce', 'in_progress': '#3182ce',
'on_pause': '#6b226e', 'on_pause': '#6b226e',
@ -928,8 +929,14 @@ class Intervention(models.Model):
return os.path.basename(self.intervention_sheet.name) return os.path.basename(self.intervention_sheet.name)
return None return None
@property
def is_assigned(self):
return bool(self.assigned_team_id or self.assigned_member_id)
@property @property
def status_color_hex(self): def status_color_hex(self):
if self.status == 'to_be_processed' and self.is_assigned:
return STATUS_COLORS.get('to_be_processed_assigned', '#d81b60')
return STATUS_COLORS.get(self.status, '#757575') return STATUS_COLORS.get(self.status, '#757575')
@property @property

View file

@ -69,6 +69,15 @@
color: #ffffff !important; color: #ffffff !important;
} }
.vis-item.status-to_be_processed.is-assigned,
.vis-item.status-to_be_processed_assigned { background-color: #d81b60; border-color: #b51750; color: #fff; }
.badge.status-to_be_processed_assigned,
.badge[class*="status-to_be_processed_assigned"] {
background-color: #d81b60 !important;
color: #ffffff !important;
}
.canvas-timeline { .canvas-timeline {
border: 1px solid var(--bs-border-color, #e2e8f0); border: 1px solid var(--bs-border-color, #e2e8f0);

View file

@ -2,6 +2,32 @@
// --- Status colors mapping (réfère à la constante de status_colors.js) // --- Status colors mapping (réfère à la constante de status_colors.js)
const STATUS_COLORS = INTERVENTION_STATUS_COLORS; const STATUS_COLORS = INTERVENTION_STATUS_COLORS;
/**
* Retourne la couleur d'une intervention en tenant compte du statut
* et de l'assignation (pour to_be_processed assigné -> #d81b60).
*/
function getInterventionColor(itvOrCode, isAssigned = false) {
let statusCode = '';
let assigned = isAssigned;
if (typeof itvOrCode === 'object' && itvOrCode !== null) {
statusCode = itvOrCode.status_code || itvOrCode.status || itvOrCode.statusCode || '';
if (itvOrCode.is_assigned !== undefined) {
assigned = Boolean(itvOrCode.is_assigned);
} else if (itvOrCode.member_name || itvOrCode.memberName || itvOrCode.team_name || itvOrCode.teamName) {
assigned = true;
}
} else {
statusCode = String(itvOrCode || '');
}
if (typeof getInterventionStatusColor === 'function') {
return getInterventionStatusColor(statusCode, assigned);
}
if (statusCode === 'to_be_processed' && assigned) {
return STATUS_COLORS.to_be_processed_assigned || '#d81b60';
}
return STATUS_COLORS[statusCode] || DEFAULT_STATUS_COLOR;
}
const DEFAULT_STATUS_COLOR = '#888888'; const DEFAULT_STATUS_COLOR = '#888888';
const EDITABLE_STATUSES = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned']; const EDITABLE_STATUSES = ['in_preparation', 'to_be_approved', 'to_be_planned', 'to_be_processed', 'assigned'];
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
@ -562,7 +588,8 @@ document.addEventListener('DOMContentLoaded', function () {
let itvsHTML = '<div class="teams-daily-interventions-stack">'; let itvsHTML = '<div class="teams-daily-interventions-stack">';
if (dayData.interventions && dayData.interventions.length > 0) { if (dayData.interventions && dayData.interventions.length > 0) {
dayData.interventions.forEach(itv => { dayData.interventions.forEach(itv => {
const cardColor = STATUS_COLORS[itv.status] || DEFAULT_STATUS_COLOR; const isAssigned = Boolean(itv.is_assigned || (team && team.id !== null) || itv.member_name);
const cardColor = getInterventionColor(itv.status, isAssigned);
const textCol = idealTextColor(cardColor); const textCol = idealTextColor(cardColor);
let isEditable = canMoveTimelineItems && EDITABLE_STATUSES.includes(itv.status); let isEditable = canMoveTimelineItems && EDITABLE_STATUSES.includes(itv.status);
@ -961,6 +988,14 @@ document.addEventListener('DOMContentLoaded', function () {
}; };
}).filter(Boolean); }).filter(Boolean);
const isAssigned = Boolean(
source.extendedProps?.is_assigned !== undefined
? source.extendedProps.is_assigned
: (source.is_assigned !== undefined
? source.is_assigned
: (memberName || source.extendedProps?.team_name || source.team_name))
);
return { return {
id: source.id ?? `event-${index}`, id: source.id ?? `event-${index}`,
code, code,
@ -970,6 +1005,8 @@ document.addEventListener('DOMContentLoaded', function () {
statusHistory, statusHistory,
milestones, milestones,
memberName, memberName,
teamName: source.extendedProps?.team_name || source.team_name || '',
isAssigned,
prepState, prepState,
maintainTypeLabel: source.extendedProps?.maintain_type_label || source.maintain_type_label || '', maintainTypeLabel: source.extendedProps?.maintain_type_label || source.maintain_type_label || '',
startDate, startDate,
@ -1671,7 +1708,9 @@ document.addEventListener('DOMContentLoaded', function () {
const sphereRadius = 8; const sphereRadius = 8;
for (let i = 0; i < historyPoints.length; i++) { for (let i = 0; i < historyPoints.length; i++) {
const sh = historyPoints[i]; const sh = historyPoints[i];
const sphereColor = STATUS_COLORS[sh.statusCode] || DEFAULT_STATUS_COLOR; const sphereColor = (sh.statusCode === 'to_be_processed' && (sh.isCurrent ? item.isAssigned : false))
? (STATUS_COLORS.to_be_processed_assigned || '#d81b60')
: (STATUS_COLORS[sh.statusCode] || DEFAULT_STATUS_COLOR);
// Halo sur le statut actuel // Halo sur le statut actuel
if (sh.isCurrent) { if (sh.isCurrent) {
@ -1707,6 +1746,7 @@ document.addEventListener('DOMContentLoaded', function () {
date: sh.date, date: sh.date,
userName: sh.userName, userName: sh.userName,
isCurrent: sh.isCurrent, isCurrent: sh.isCurrent,
itemIsAssigned: item.isAssigned,
itemCode: item.code, itemCode: item.code,
itemTitle: item.title, itemTitle: item.title,
x: sh.x - 10, x: sh.x - 10,
@ -1933,7 +1973,8 @@ document.addEventListener('DOMContentLoaded', function () {
const hit = hitsList[0]; const hit = hitsList[0];
const dateStr = formatItemDate(hit.date); const dateStr = formatItemDate(hit.date);
if (hit.kind === 'status') { if (hit.kind === 'status') {
const color = STATUS_COLORS[hit.statusCode] || DEFAULT_STATUS_COLOR; const isAssigned = Boolean(hit.itemIsAssigned || anchorHit?.itemIsAssigned);
const color = getInterventionColor(hit.statusCode, hit.isCurrent ? isAssigned : false);
const currentBadge = hit.isCurrent ? ` <span class="badge bg-light text-dark ms-1" style="font-size:0.65rem;">${gettext('Actuel')}</span>` : ''; 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>` : ''; const userStr = hit.userName ? `<div style="font-size:0.7rem;opacity:0.85;margin-top:2px;">${escapeHtml(hit.userName)}</div>` : '';
content = ` content = `
@ -1966,7 +2007,8 @@ document.addEventListener('DOMContentLoaded', function () {
const dateStr = formatItemDate(hit.date); const dateStr = formatItemDate(hit.date);
const isBorder = idx < hitsList.length - 1 ? 'border-bottom border-light border-opacity-10 pb-1 mb-1' : ''; const isBorder = idx < hitsList.length - 1 ? 'border-bottom border-light border-opacity-10 pb-1 mb-1' : '';
if (hit.kind === 'status') { if (hit.kind === 'status') {
const color = STATUS_COLORS[hit.statusCode] || DEFAULT_STATUS_COLOR; const isAssigned = Boolean(hit.itemIsAssigned || anchorHit?.itemIsAssigned);
const color = getInterventionColor(hit.statusCode, hit.isCurrent ? isAssigned : false);
const currentBadge = hit.isCurrent ? ` <span class="badge bg-light text-dark ms-1" style="font-size:0.6rem;">${gettext('Actuel')}</span>` : ''; const currentBadge = hit.isCurrent ? ` <span class="badge bg-light text-dark ms-1" style="font-size:0.6rem;">${gettext('Actuel')}</span>` : '';
const userStr = hit.userName ? `<span class="text-white-50 ms-1" style="font-size:0.68rem;">(${escapeHtml(hit.userName)})</span>` : ''; const userStr = hit.userName ? `<span class="text-white-50 ms-1" style="font-size:0.68rem;">(${escapeHtml(hit.userName)})</span>` : '';
return ` return `
@ -2265,7 +2307,9 @@ document.addEventListener('DOMContentLoaded', function () {
const sphereRadius = 8; const sphereRadius = 8;
for (let i = 0; i < historyPoints.length; i++) { for (let i = 0; i < historyPoints.length; i++) {
const sh = historyPoints[i]; const sh = historyPoints[i];
const sphereColor = STATUS_COLORS[sh.statusCode] || DEFAULT_STATUS_COLOR; const sphereColor = (sh.statusCode === 'to_be_processed' && sh.isCurrent)
? (STATUS_COLORS.to_be_processed_assigned || '#d81b60')
: (STATUS_COLORS[sh.statusCode] || DEFAULT_STATUS_COLOR);
if (sh.isCurrent) { if (sh.isCurrent) {
ctx.save(); ctx.save();
@ -3094,7 +3138,7 @@ document.addEventListener('DOMContentLoaded', function () {
const barLeft = hasLayout ? layout.barLeft : Math.max(startX, MEMBERS_LABEL_WIDTH - 60); const barLeft = hasLayout ? layout.barLeft : Math.max(startX, MEMBERS_LABEL_WIDTH - 60);
const barRight = hasLayout ? layout.barRight : Math.min(endX, MEMBERS_LABEL_WIDTH + visibleWidth + 60); const barRight = hasLayout ? layout.barRight : Math.min(endX, MEMBERS_LABEL_WIDTH + visibleWidth + 60);
const barWidth = Math.max(6, hasLayout ? layout.barWidth : (barRight - barLeft)); const barWidth = Math.max(6, hasLayout ? layout.barWidth : (barRight - barLeft));
const color = STATUS_COLORS[item.statusCode] || DEFAULT_STATUS_COLOR; const color = getInterventionColor(item.statusCode, true);
const shouldDim = isDraggingThisItem && dragState?.hasMoved; const shouldDim = isDraggingThisItem && dragState?.hasMoved;
ctx.save(); ctx.save();
@ -3212,7 +3256,7 @@ document.addEventListener('DOMContentLoaded', function () {
const barLeft = hasLayout ? layout.barLeft : Math.max(startX, MEMBERS_LABEL_WIDTH - 60); const barLeft = hasLayout ? layout.barLeft : Math.max(startX, MEMBERS_LABEL_WIDTH - 60);
const barRight = hasLayout ? layout.barRight : Math.min(endX, MEMBERS_LABEL_WIDTH + visibleWidth + 60); const barRight = hasLayout ? layout.barRight : Math.min(endX, MEMBERS_LABEL_WIDTH + visibleWidth + 60);
const barWidth = Math.max(6, hasLayout ? layout.barWidth : (barRight - barLeft)); const barWidth = Math.max(6, hasLayout ? layout.barWidth : (barRight - barLeft));
const color = STATUS_COLORS[item.statusCode] || DEFAULT_STATUS_COLOR; const color = getInterventionColor(item.statusCode, true);
const shouldDim = isDraggingThisItem && dragState?.hasMoved; const shouldDim = isDraggingThisItem && dragState?.hasMoved;
ctx.save(); ctx.save();
@ -3310,7 +3354,7 @@ document.addEventListener('DOMContentLoaded', function () {
const laneBaseY = rowTop + MEMBERS_ROW_VERTICAL_PADDING; const laneBaseY = rowTop + MEMBERS_ROW_VERTICAL_PADDING;
const laneStep = TIMELINE_BAR_HEIGHT + MEMBERS_LANE_VERTICAL_SPACING; const laneStep = TIMELINE_BAR_HEIGHT + MEMBERS_LANE_VERTICAL_SPACING;
const barY = laneBaseY + laneIndex * laneStep; const barY = laneBaseY + laneIndex * laneStep;
const color = STATUS_COLORS[drag.item.statusCode] || DEFAULT_STATUS_COLOR; const color = getInterventionColor(drag.item.statusCode, true);
ctx.save(); ctx.save();
ctx.globalAlpha = 0.85; ctx.globalAlpha = 0.85;
@ -4568,7 +4612,8 @@ document.addEventListener('DOMContentLoaded', function () {
item.draggable = true; item.draggable = true;
item.dataset.interventionId = intervention.id; item.dataset.interventionId = intervention.id;
const color = STATUS_COLORS[intervention.status_code] || DEFAULT_STATUS_COLOR; const isAssigned = Boolean(intervention.is_assigned || intervention.member_name || intervention.team_name);
const color = getInterventionColor(intervention.status_code, isAssigned);
const textColor = idealTextColor(color); const textColor = idealTextColor(color);
const titleRow = document.createElement('div'); const titleRow = document.createElement('div');
titleRow.className = 'd-flex justify-content-between align-items-start gap-2'; titleRow.className = 'd-flex justify-content-between align-items-start gap-2';
@ -4619,6 +4664,12 @@ document.addEventListener('DOMContentLoaded', function () {
assigneeBadge.style.fontSize = '0.7rem'; assigneeBadge.style.fontSize = '0.7rem';
assigneeBadge.textContent = intervention.member_name; assigneeBadge.textContent = intervention.member_name;
badgesColumn.appendChild(assigneeBadge); badgesColumn.appendChild(assigneeBadge);
} else if (intervention.team_name) {
const assigneeBadge = document.createElement('span');
assigneeBadge.className = 'badge rounded-pill text-bg-light text-dark border border-secondary fw-bold';
assigneeBadge.style.fontSize = '0.7rem';
assigneeBadge.textContent = intervention.team_name;
badgesColumn.appendChild(assigneeBadge);
} }
titleRow.appendChild(titleSpan); titleRow.appendChild(titleSpan);
@ -4756,7 +4807,8 @@ document.addEventListener('DOMContentLoaded', function () {
const status = record.status || record.extendedProps?.status || ''; const status = record.status || record.extendedProps?.status || '';
const statusCode = record.status_code || record.extendedProps?.status_code || ''; const statusCode = record.status_code || record.extendedProps?.status_code || '';
const color = STATUS_COLORS[statusCode] || DEFAULT_STATUS_COLOR; const isAssigned = Boolean(record.is_assigned || record.extendedProps?.is_assigned || record.member_name || record.extendedProps?.member_name || record.team_name || record.extendedProps?.team_name);
const color = getInterventionColor(statusCode, isAssigned);
const textColor = idealTextColor(color); const textColor = idealTextColor(color);
modalInterventionStatus.textContent = status; modalInterventionStatus.textContent = status;
@ -5265,7 +5317,7 @@ function openMemberHistoryModal(memberId) {
<td><a href="${itv.detail_url}" target="_blank" class="fw-bold text-primary text-decoration-none">${escapeHtml(itv.code)}</a></td> <td><a href="${itv.detail_url}" target="_blank" class="fw-bold text-primary text-decoration-none">${escapeHtml(itv.code)}</a></td>
<td class="text-truncate" style="max-width: 180px;" title="${escapeHtml(itv.title)}">${escapeHtml(itv.title)}</td> <td class="text-truncate" style="max-width: 180px;" title="${escapeHtml(itv.title)}">${escapeHtml(itv.title)}</td>
<td>${escapeHtml(itv.team_name)}</td> <td>${escapeHtml(itv.team_name)}</td>
<td><span class="badge rounded-pill" style="background-color: ${STATUS_COLORS[itv.status_code] || DEFAULT_STATUS_COLOR}; color: ${idealTextColor(STATUS_COLORS[itv.status_code] || DEFAULT_STATUS_COLOR)};">${escapeHtml(itv.status_display)}</span></td> <td><span class="badge rounded-pill" style="background-color: ${getInterventionColor(itv.status_code, true)}; color: ${idealTextColor(getInterventionColor(itv.status_code, true))};">${escapeHtml(itv.status_display)}</span></td>
</tr> </tr>
`; `;
}).join(''); }).join('');

View file

@ -14,6 +14,7 @@ const INTERVENTION_STATUS_COLORS = {
to_be_approved: '#ffb74d', to_be_approved: '#ffb74d',
to_be_planned: '#ff8a80', to_be_planned: '#ff8a80',
to_be_processed: '#f4511e', to_be_processed: '#f4511e',
to_be_processed_assigned: '#d81b60',
assigned: '#63b3ed', assigned: '#63b3ed',
in_progress: '#3182ce', in_progress: '#3182ce',
on_pause: '#6b226e', on_pause: '#6b226e',
@ -31,14 +32,42 @@ const INTERVENTION_STATUS_COLORS = {
const INTERVENTION_DEFAULT_STATUS_COLOR = '#6c757d'; const INTERVENTION_DEFAULT_STATUS_COLOR = '#6c757d';
/** /**
* Expression MapLibre « match » prête à l'emploi dans les couches circle/fill. * Expression MapLibre prête à l'emploi dans les couches circle/fill.
* Générée automatiquement depuis INTERVENTION_STATUS_COLORS. * Générée automatiquement depuis INTERVENTION_STATUS_COLORS,
* avec prise en compte du statut 'à traiter' assigné (#d81b60).
*/ */
const INTERVENTION_STATUS_COLOR_EXPR = (() => { const INTERVENTION_STATUS_COLOR_EXPR = (() => {
const expr = ['match', ['get', 'status']]; const expr = ['match', ['get', 'status']];
for (const [status, color] of Object.entries(INTERVENTION_STATUS_COLORS)) { for (const [status, color] of Object.entries(INTERVENTION_STATUS_COLORS)) {
if (status === 'to_be_processed_assigned') continue;
expr.push(status, color); expr.push(status, color);
} }
expr.push(INTERVENTION_DEFAULT_STATUS_COLOR); // valeur par défaut expr.push(INTERVENTION_DEFAULT_STATUS_COLOR); // valeur par défaut
return expr;
return [
'case',
[
'all',
['==', ['get', 'status'], 'to_be_processed'],
[
'any',
['boolean', ['get', 'is_assigned'], false],
['!=', ['coalesce', ['get', 'team'], ''], ''],
['!=', ['coalesce', ['get', 'member'], ''], '']
]
],
INTERVENTION_STATUS_COLORS.to_be_processed_assigned,
expr
];
})(); })();
/**
* Retourne la couleur hex du statut en tenant compte de l'assignation.
* Pour 'to_be_processed' assigné, retourne '#d81b60' (rose framboise).
*/
function getInterventionStatusColor(status, isAssigned = false) {
if (status === 'to_be_processed' && isAssigned) {
return INTERVENTION_STATUS_COLORS.to_be_processed_assigned;
}
return INTERVENTION_STATUS_COLORS[status] || INTERVENTION_DEFAULT_STATUS_COLOR;
}

View file

@ -948,7 +948,22 @@
// ---- Carte des interventions (modal) ---- // ---- Carte des interventions (modal) ----
(function () { (function () {
const MAPTILER_STYLE = window.MAP_STYLE_LIGHT; const MAPTILER_STYLE = window.MAP_STYLE_LIGHT;
const STATUS_COLOR_EXPR = [ const STATUS_COLOR_EXPR = (typeof INTERVENTION_STATUS_COLOR_EXPR !== 'undefined')
? INTERVENTION_STATUS_COLOR_EXPR
: [
'case',
[
'all',
['==', ['get', 'status'], 'to_be_processed'],
[
'any',
['boolean', ['get', 'is_assigned'], false],
['!=', ['coalesce', ['get', 'team'], ''], ''],
['!=', ['coalesce', ['get', 'member'], ''], '']
]
],
'#d81b60',
[
'match', ['get', 'status'], 'match', ['get', 'status'],
'in_preparation', '#6c757d', 'in_preparation', '#6c757d',
'to_be_approved', '#ffc107', 'to_be_approved', '#ffc107',
@ -966,6 +981,7 @@
'closed', '#343a40', 'closed', '#343a40',
'canceled', '#6c757d', 'canceled', '#6c757d',
'#888888' '#888888'
]
]; ];
const DRAW_STYLES = [{id:'gl-draw-polygon-fill',type:'fill',filter:['all',['==','$type','Polygon']],paint:{'fill-color':['case',['==',['get','active'],'true'],'orange','#0066cc'],'fill-opacity':.15}},{id:'gl-draw-lines',type:'line',filter:['any',['==','$type','LineString'],['==','$type','Polygon']],layout:{'line-cap':'round','line-join':'round'},paint:{'line-color':['case',['==',['get','active'],'true'],'orange','#0066cc'],'line-width':2}},{id:'gl-draw-point-outer',type:'circle',filter:['all',['==','$type','Point'],['==','meta','feature']],paint:{'circle-radius':['case',['==',['get','active'],'true'],7,5],'circle-color':'white'}},{id:'gl-draw-point-inner',type:'circle',filter:['all',['==','$type','Point'],['==','meta','feature']],paint:{'circle-radius':['case',['==',['get','active'],'true'],5,3],'circle-color':['case',['==',['get','active'],'true'],'orange','#0066cc']}},{id:'gl-draw-vertex-outer',type:'circle',filter:['all',['==','$type','Point'],['==','meta','vertex'],['!=','mode','simple_select']],paint:{'circle-radius':['case',['==',['get','active'],'true'],7,5],'circle-color':'white'}},{id:'gl-draw-vertex-inner',type:'circle',filter:['all',['==','$type','Point'],['==','meta','vertex'],['!=','mode','simple_select']],paint:{'circle-radius':['case',['==',['get','active'],'true'],5,3],'circle-color':'orange'}},{id:'gl-draw-midpoint',type:'circle',filter:['all',['==','meta','midpoint']],paint:{'circle-radius':3,'circle-color':'orange'}}]; const DRAW_STYLES = [{id:'gl-draw-polygon-fill',type:'fill',filter:['all',['==','$type','Polygon']],paint:{'fill-color':['case',['==',['get','active'],'true'],'orange','#0066cc'],'fill-opacity':.15}},{id:'gl-draw-lines',type:'line',filter:['any',['==','$type','LineString'],['==','$type','Polygon']],layout:{'line-cap':'round','line-join':'round'},paint:{'line-color':['case',['==',['get','active'],'true'],'orange','#0066cc'],'line-width':2}},{id:'gl-draw-point-outer',type:'circle',filter:['all',['==','$type','Point'],['==','meta','feature']],paint:{'circle-radius':['case',['==',['get','active'],'true'],7,5],'circle-color':'white'}},{id:'gl-draw-point-inner',type:'circle',filter:['all',['==','$type','Point'],['==','meta','feature']],paint:{'circle-radius':['case',['==',['get','active'],'true'],5,3],'circle-color':['case',['==',['get','active'],'true'],'orange','#0066cc']}},{id:'gl-draw-vertex-outer',type:'circle',filter:['all',['==','$type','Point'],['==','meta','vertex'],['!=','mode','simple_select']],paint:{'circle-radius':['case',['==',['get','active'],'true'],7,5],'circle-color':'white'}},{id:'gl-draw-vertex-inner',type:'circle',filter:['all',['==','$type','Point'],['==','meta','vertex'],['!=','mode','simple_select']],paint:{'circle-radius':['case',['==',['get','active'],'true'],5,3],'circle-color':'orange'}},{id:'gl-draw-midpoint',type:'circle',filter:['all',['==','meta','midpoint']],paint:{'circle-radius':3,'circle-color':'orange'}}];
@ -1090,9 +1106,30 @@
if (!e.features.length) return; if (!e.features.length) return;
const props = e.features[0].properties; const props = e.features[0].properties;
if (calPopup) calPopup.remove(); if (calPopup) calPopup.remove();
function _esc(s) {
if (!s) return '';
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
const isAssigned = Boolean(props.is_assigned || props.team || props.member);
const statusColor = (props.status === 'to_be_processed' && isAssigned)
? '#d81b60'
: (typeof getInterventionStatusColor === 'function'
? getInterventionStatusColor(props.status)
: (props.status === 'to_be_processed' ? '#dc3545' : '#6c757d'));
const statusLabel = props.status_display || props.status || '';
const assignee = props.member || props.team || '';
const assigneeHtml = assignee ? `<div class="mt-1 text-muted small"><i class="bi bi-person-badge me-1"></i>${_esc(assignee)}</div>` : '';
const badgeHtml = statusLabel ? `<span class="badge" style="background-color: ${statusColor}; color: #fff; font-size: 0.7rem;">${_esc(statusLabel)}</span>` : '';
calPopup = new maplibregl.Popup({ maxWidth: '280px' }) calPopup = new maplibregl.Popup({ maxWidth: '280px' })
.setLngLat(e.lngLat) .setLngLat(e.lngLat)
.setHTML(`<strong><a href="/interventions/${props.id}/" target="_blank">${props.code || ''}</a></strong><br><span class="text-muted small">${props.title || ''}</span>`) .setHTML(`
<div class="d-flex align-items-center justify-content-between gap-2 mb-1">
<strong><a href="/interventions/${props.id}/" target="_blank">${_esc(props.code || '')}</a></strong>
${badgeHtml}
</div>
<span class="text-muted small">${_esc(props.title || '')}</span>
${assigneeHtml}
`)
.addTo(calMap); .addTo(calMap);
}); });
calMap.on('mouseenter', 'cal-ivt-circles', () => { calMap.getCanvas().style.cursor = 'pointer'; }); calMap.on('mouseenter', 'cal-ivt-circles', () => { calMap.getCanvas().style.cursor = 'pointer'; });

View file

@ -1140,6 +1140,111 @@ class ExternalManagerScheduleUpdateTests(TestCase):
self.assertIn("assigner", res.json().get('error', '').lower()) self.assertIn("assigner", res.json().get('error', '').lower())
class AssignedToBeProcessedColorTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username='calendar-color-user', password='pwd')
self.role_admin = Role.objects.create(name='admin')
self.user_config = UserConfig.objects.create(user=self.user, is_intern=True)
self.user_config.roles.add(self.role_admin)
self.company = Company.objects.create(name='Test Company')
self.team = CompanyTeam.objects.create(name='Team Alpha', company=self.company)
self.member = CompanyMember.objects.create(user=self.user, name='Alice Tech')
self.member.teams.add(self.team)
self.client.login(username='calendar-color-user', password='pwd')
def test_model_status_color_hex(self):
# Unassigned to_be_processed
itv_unassigned = Intervention.objects.create(
title='Unassigned',
status='to_be_processed',
)
self.assertFalse(itv_unassigned.is_assigned)
self.assertEqual(itv_unassigned.status_color_hex, '#f4511e')
# Assigned to member
itv_member = Intervention.objects.create(
title='Assigned to member',
status='to_be_processed',
assigned_member=self.member,
)
self.assertTrue(itv_member.is_assigned)
self.assertEqual(itv_member.status_color_hex, '#d81b60')
# Assigned to team only
itv_team = Intervention.objects.create(
title='Assigned to team',
status='to_be_processed',
assigned_team=self.team,
)
self.assertTrue(itv_team.is_assigned)
self.assertEqual(itv_team.status_color_hex, '#d81b60')
# Other status assigned
itv_progress = Intervention.objects.create(
title='In progress',
status='in_progress',
assigned_member=self.member,
)
self.assertEqual(itv_progress.status_color_hex, '#3182ce')
def test_calendar_events_extended_props_is_assigned(self):
now = timezone.now()
itv_unassigned = Intervention.objects.create(
title='Unassigned ITV',
status='to_be_processed',
planned_begin_time=now + timedelta(hours=1),
planned_end_time=now + timedelta(hours=2),
)
itv_assigned = Intervention.objects.create(
title='Assigned ITV',
status='to_be_processed',
assigned_member=self.member,
planned_begin_time=now + timedelta(hours=3),
planned_end_time=now + timedelta(hours=4),
)
url = reverse('interventions:interventions_events')
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
events = res.json()
ev_unassigned = next((e for e in events if e['id'] == itv_unassigned.id), None)
ev_assigned = next((e for e in events if e['id'] == itv_assigned.id), None)
self.assertIsNotNone(ev_unassigned)
self.assertFalse(ev_unassigned['extendedProps']['is_assigned'])
self.assertIsNotNone(ev_assigned)
self.assertTrue(ev_assigned['extendedProps']['is_assigned'])
def test_interventions_geojson_is_assigned(self):
itv_unassigned = Intervention.objects.create(
title='Geo Unassigned',
status='to_be_processed',
)
itv_assigned = Intervention.objects.create(
title='Geo Assigned',
status='to_be_processed',
assigned_team=self.team,
)
url = reverse('interventions:interventions_geojson')
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
features = res.json()['features']
f_unassigned = next((f for f in features if f['id'] == itv_unassigned.id), None)
f_assigned = next((f for f in features if f['id'] == itv_assigned.id), None)
self.assertIsNotNone(f_unassigned)
self.assertFalse(f_unassigned['properties']['is_assigned'])
self.assertIsNotNone(f_assigned)
self.assertTrue(f_assigned['properties']['is_assigned'])
self.assertEqual(f_assigned['properties']['team'], 'Team Alpha')

View file

@ -395,7 +395,7 @@ def interventions_events(request):
interventions = ( interventions = (
interventions interventions
.select_related('assigned_member') .select_related('assigned_member', 'assigned_team')
.prefetch_related('assigned_member__teams', 'assigned_member__teams__company') .prefetch_related('assigned_member__teams', 'assigned_member__teams__company')
.order_by('effective_begin') # croissant: plus proche d'aujourd'hui au milieu/vers le bas .order_by('effective_begin') # croissant: plus proche d'aujourd'hui au milieu/vers le bas
) )
@ -608,7 +608,8 @@ def interventions_events(request):
'actual_end': itv.end_time.isoformat() if itv.end_time else None, 'actual_end': itv.end_time.isoformat() if itv.end_time else None,
'link': reverse('interventions:intervention_detail', args=[itv.id]), 'link': reverse('interventions:intervention_detail', args=[itv.id]),
'member_name': itv.assigned_member.name if itv.assigned_member else None, 'member_name': itv.assigned_member.name if itv.assigned_member else None,
'team_name': ", ".join(t.name for t in itv.assigned_member.teams.all()) if itv.assigned_member else None, 'team_name': ", ".join(t.name for t in itv.assigned_member.teams.all()) if itv.assigned_member else (itv.assigned_team.name if itv.assigned_team else None),
'is_assigned': bool(itv.assigned_team_id or itv.assigned_member_id),
} }
}) })
@ -708,6 +709,7 @@ def _interventions_events_by_members(
'team_id': team.id if team else (member.teams.all()[0].id if member and member.teams.all() else None), 'team_id': team.id if team else (member.teams.all()[0].id if member and member.teams.all() else None),
'team_name': team.name if team else (member.teams.all()[0].name if member and member.teams.all() else None), 'team_name': team.name if team else (member.teams.all()[0].name if member and member.teams.all() else None),
'assigned_to_team_only': bool(team and not member), 'assigned_to_team_only': bool(team and not member),
'is_assigned': bool(team or member),
'duration_ms': duration_ms, 'duration_ms': duration_ms,
'link': reverse('interventions:intervention_detail', args=[itv.id]), 'link': reverse('interventions:intervention_detail', args=[itv.id]),
} }
@ -1056,6 +1058,7 @@ def _interventions_events_by_teams_daily(
'status': assign.status or itv.status, 'status': assign.status or itv.status,
'status_display': itv.get_status_display() if not assign.status else dict(STATUS_CHOICES).get(assign.status, assign.status), 'status_display': itv.get_status_display() if not assign.status else dict(STATUS_CHOICES).get(assign.status, assign.status),
'status_color': itv.get_status_color() if not assign.status else assign.status, 'status_color': itv.get_status_color() if not assign.status else assign.status,
'is_assigned': True,
'link': reverse('interventions:intervention_detail', args=[itv.id]), 'link': reverse('interventions:intervention_detail', args=[itv.id]),
'member_name': assign.member.name if assign.member else None, 'member_name': assign.member.name if assign.member else None,
}) })
@ -1092,6 +1095,7 @@ def _interventions_events_by_teams_daily(
'status': itv.status, 'status': itv.status,
'status_display': itv.get_status_display(), 'status_display': itv.get_status_display(),
'status_color': itv.get_status_color(), 'status_color': itv.get_status_color(),
'is_assigned': bool(itv.assigned_team_id or itv.assigned_member_id),
'link': reverse('interventions:intervention_detail', args=[itv.id]), 'link': reverse('interventions:intervention_detail', args=[itv.id]),
'member_name': None, 'member_name': None,
}) })
@ -1186,6 +1190,7 @@ def _interventions_events_by_teams_daily(
'team_id': team.id if team else (member.teams.all()[0].id if member and member.teams.all() else None), 'team_id': team.id if team else (member.teams.all()[0].id if member and member.teams.all() else None),
'team_name': team.name if team else (member.teams.all()[0].name if member and member.teams.all() else None), 'team_name': team.name if team else (member.teams.all()[0].name if member and member.teams.all() else None),
'assigned_to_team_only': bool(team and not member), 'assigned_to_team_only': bool(team and not member),
'is_assigned': bool(team or member),
'duration_ms': duration_ms, 'duration_ms': duration_ms,
'link': reverse('interventions:intervention_detail', args=[itv.id]), 'link': reverse('interventions:intervention_detail', args=[itv.id]),
} }

View file

@ -743,7 +743,7 @@ def interventions_geojson(request):
# qs = qs.order_by('custom_priority', F('effective_begin').desc(nulls_last=True), '-id')\ # qs = qs.order_by('custom_priority', F('effective_begin').desc(nulls_last=True), '-id')\
qs = qs.order_by('-code')\ qs = qs.order_by('-code')\
.select_related('assigned_provider', 'contract', 'contract__company', 'assigned_team')\ .select_related('assigned_provider', 'contract', 'contract__company', 'assigned_team', 'assigned_member')\
.values( .values(
'id', 'code', 'title', 'location_code', 'id', 'code', 'title', 'location_code',
'status', 'maintain_type', 'origin_type_label', 'prep_state', 'status', 'maintain_type', 'origin_type_label', 'prep_state',
@ -753,7 +753,10 @@ def interventions_geojson(request):
'contract__company__name', 'contract__company__name',
'contract__is_blocked', 'contract__is_blocked',
'is_blocked_due_to_contract', 'is_blocked_due_to_contract',
'assigned_team_id',
'assigned_team__name', 'assigned_team__name',
'assigned_member_id',
'assigned_member__name',
source_category_field, source_category_field,
)[:MAX_FEATURES] )[:MAX_FEATURES]
@ -761,6 +764,7 @@ def interventions_geojson(request):
features = [] features = []
for r in qs: for r in qs:
geometry = json.loads(r['geom_geojson']) if r['geom_geojson'] else None geometry = json.loads(r['geom_geojson']) if r['geom_geojson'] else None
is_assigned = bool(r.get('assigned_team_id') or r.get('assigned_member_id'))
features.append({ features.append({
"type": "Feature", "type": "Feature",
"id": r['id'], "id": r['id'],
@ -772,8 +776,7 @@ def interventions_geojson(request):
"location_code": r['location_code'], "location_code": r['location_code'],
"begin_time": r['effective_begin'], "begin_time": r['effective_begin'],
"status": r['status'], "status": r['status'],
# If you need the display label, either map it here or annotate a Case() for it "status_display": str(STATUS_CHOICES.get(r['status'], r['status'])),
# "status_display": r['status'],
"maintain_type": r['maintain_type'], "maintain_type": r['maintain_type'],
"prep_state": r['prep_state'], "prep_state": r['prep_state'],
"assigned_provider": r['assigned_provider__name'], "assigned_provider": r['assigned_provider__name'],
@ -783,6 +786,8 @@ def interventions_geojson(request):
"origin_type": r['origin_type_label'], "origin_type": r['origin_type_label'],
"source_category": r[source_category_field] if r[source_category_field] else '', "source_category": r[source_category_field] if r[source_category_field] else '',
"team": r['assigned_team__name'] or '', "team": r['assigned_team__name'] or '',
"member": r['assigned_member__name'] or '',
"is_assigned": is_assigned,
}, },
}) })