feat(interventions): afficher les statuts de la chronologie sous forme de cercles numérotés

- Reprend le code visuel du stepper supérieur pour les transitions de statut dans l'historique
- Affiche une boule colorée avec le numéro canonique (1 à 15) et le libellé sous chaque statut
- Aligne la flèche de transition au centre des cercles
- Ajuste les contrastes de texte (noir/blanc) selon la luminosité du statut
This commit is contained in:
kdeterme 2026-09-25 22:35:29 +02:00
parent fd794737a8
commit 2df4621e5b
5 changed files with 184 additions and 9 deletions

View file

@ -58,6 +58,10 @@ STATUS_ORDERS = {
'canceled': 200, 'canceled': 200,
} }
# Numérotation 1-15 des statuts dans l'ordre canonique du stepper
STATUS_STEP_NUMBERS = {key: idx + 1 for idx, key in enumerate(STATUS_CHOICES.keys())}
# Couleurs canoniques des statuts (hex). Source de vérité partagée avec status_colors.js. # Couleurs canoniques des statuts (hex). Source de vérité partagée avec status_colors.js.
STATUS_COLORS = { STATUS_COLORS = {
'in_preparation': '#757575', 'in_preparation': '#757575',
@ -1328,6 +1332,32 @@ class InterventionTimeLine(models.Model):
def get_to_status_color(self): def get_to_status_color(self):
return STATUS_COLOR_MAP.get(self.to_status, "secondary") return STATUS_COLOR_MAP.get(self.to_status, "secondary")
def get_from_status_number(self):
return STATUS_STEP_NUMBERS.get(self.from_status, '')
def get_to_status_number(self):
return STATUS_STEP_NUMBERS.get(self.to_status, '')
def get_from_status_hex(self):
return STATUS_COLORS.get(self.from_status, '#757575')
def get_to_status_hex(self):
return STATUS_COLORS.get(self.to_status, '#757575')
def get_from_status_text_color(self):
return '#000000' if self.from_status in STATUS_COLORS_DARK_TEXT else '#ffffff'
def get_to_status_text_color(self):
return '#000000' if self.to_status in STATUS_COLORS_DARK_TEXT else '#ffffff'
from_status_number = property(get_from_status_number)
to_status_number = property(get_to_status_number)
from_status_hex = property(get_from_status_hex)
to_status_hex = property(get_to_status_hex)
from_status_text_color = property(get_from_status_text_color)
to_status_text_color = property(get_to_status_text_color)
def get_badge_class(self): def get_badge_class(self):
return { return {
'creation': 'bg-success', 'creation': 'bg-success',

View file

@ -261,4 +261,66 @@
color: #ffffff !important; color: #ffffff !important;
} }
/* Transition de statut : boules de couleur avec numéro et libellé en-dessous */
.history-status-transition {
display: inline-flex;
align-items: flex-start;
}
.history-status-step {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 90px;
text-align: center;
}
.history-status-circle {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
line-height: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
margin-bottom: 3px;
flex-shrink: 0;
user-select: none;
}
.history-status-arrow {
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 1.15rem;
padding: 0 6px;
flex-shrink: 0;
}
.history-status-label {
font-size: 11px;
font-weight: 500;
color: #334155;
line-height: 1.2;
text-align: center;
word-break: normal;
overflow-wrap: break-word;
}
.history-status-change-badge {
align-self: flex-start;
margin-top: 3px;
}
.history-status-desc {
margin-top: 5px;
font-style: italic;
}

View file

@ -55,14 +55,28 @@
<!-- Event Info --> <!-- Event Info -->
<div class="d-flex flex-wrap align-items-center gap-2"> <div class="d-flex flex-wrap align-items-center gap-2">
{% if event.event_type == 'status_change' %} {% if event.event_type == 'status_change' %}
<span class="badge bg-purple history-event-badge">{% translate "Statut" %}</span> <span class="badge bg-purple history-event-badge history-status-change-badge">{% translate "Statut" %}</span>
<div class="history-status-transition d-inline-flex align-items-start">
{% if event.from_status %} {% if event.from_status %}
<span class="badge bg-{{ event.get_from_status_color }} history-status-badge" title="{{ event.get_from_status_display }}">{{ event.get_from_status_display }}</span> <div class="history-status-step" title="{{ event.get_from_status_display }}">
<i class="bi bi-arrow-right text-muted small"></i> <div class="history-status-circle" style="background-color: {{ event.from_status_hex }}; color: {{ event.from_status_text_color }};">
{{ event.from_status_number }}
</div>
<div class="history-status-label">{{ event.get_from_status_display }}</div>
</div>
<div class="history-status-arrow">
<i class="bi bi-arrow-right"></i>
</div>
{% endif %} {% endif %}
<span class="badge bg-{{ event.get_to_status_color }} history-status-badge" title="{{ event.get_to_status_display }}">{{ event.get_to_status_display }}</span> <div class="history-status-step" title="{{ event.get_to_status_display }}">
<div class="history-status-circle" style="background-color: {{ event.to_status_hex }}; color: {{ event.to_status_text_color }};">
{{ event.to_status_number }}
</div>
<div class="history-status-label">{{ event.get_to_status_display }}</div>
</div>
</div>
{% if event.event_description %} {% if event.event_description %}
<span class="small text-muted ms-1">({{ event.event_description }})</span> <span class="small text-muted ms-2 align-self-start history-status-desc">({{ event.event_description }})</span>
{% endif %} {% endif %}
{% elif event.event_type == 'assignment' %} {% elif event.event_type == 'assignment' %}
<span class="badge bg-info text-dark history-event-badge">{% translate "Assignation" %}</span> <span class="badge bg-info text-dark history-event-badge">{% translate "Assignation" %}</span>

View file

@ -606,4 +606,65 @@
background-color: #6f42c1 !important; background-color: #6f42c1 !important;
color: #ffffff !important; color: #ffffff !important;
} }
/* Transition de statut : boules de couleur avec numéro et libellé en-dessous */
.history-status-transition {
display: inline-flex;
align-items: flex-start;
}
.history-status-step {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 90px;
text-align: center;
}
.history-status-circle {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
line-height: 1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
margin-bottom: 3px;
flex-shrink: 0;
user-select: none;
}
.history-status-arrow {
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 1.15rem;
padding: 0 6px;
flex-shrink: 0;
}
.history-status-label {
font-size: 11px;
font-weight: 500;
color: #334155;
line-height: 1.2;
text-align: center;
word-break: normal;
overflow-wrap: break-word;
}
.history-status-change-badge {
align-self: flex-start;
margin-top: 3px;
}
.history-status-desc {
margin-top: 5px;
font-style: italic;
}
</style> </style>

View file

@ -81,7 +81,7 @@ class TimelineHistoryTests(TestCase):
event_time=timezone.now(), event_time=timezone.now(),
event_type='status_change', event_type='status_change',
from_status='in_preparation', from_status='in_preparation',
to_status='planned', to_status='to_be_planned',
event_description='Passage en planifié' event_description='Passage en planifié'
) )
self.assertTrue(event_status.is_status_change) self.assertTrue(event_status.is_status_change)
@ -90,6 +90,12 @@ class TimelineHistoryTests(TestCase):
self.assertFalse(event_status.is_assignment) self.assertFalse(event_status.is_assignment)
self.assertEqual(event_status.get_badge_class(), 'bg-purple') self.assertEqual(event_status.get_badge_class(), 'bg-purple')
self.assertEqual(event_status.get_icon(), 'bi-arrow-repeat') self.assertEqual(event_status.get_icon(), 'bi-arrow-repeat')
self.assertEqual(event_status.from_status_number, 1)
self.assertEqual(event_status.to_status_number, 3)
self.assertEqual(event_status.from_status_hex, '#757575')
self.assertEqual(event_status.to_status_hex, '#ff8a80')
self.assertEqual(event_status.from_status_text_color, '#ffffff')
self.assertEqual(event_status.to_status_text_color, '#000000')
# Assignment event # Assignment event
event_assign = InterventionTimeLine.objects.create( event_assign = InterventionTimeLine.objects.create(
@ -450,6 +456,7 @@ class TimelineHistoryTests(TestCase):
def test_detail_view_shows_all_in_full_history(self): def test_detail_view_shows_all_in_full_history(self):
"""Test that notes, documents, and links appear as detailed events in timeline.""" """Test that notes, documents, and links appear as detailed events in timeline."""
record_timeline_event(self.intervention, self.user, event_type='status_change', from_status='in_preparation', to_status='to_be_processed')
record_note_event(self.intervention, self.user, action='create', note_type='comment', content='Note test') record_note_event(self.intervention, self.user, action='create', note_type='comment', content='Note test')
record_document_event(self.intervention, self.user, action='upload', filenames=['doc_test.png']) record_document_event(self.intervention, self.user, action='upload', filenames=['doc_test.png'])
record_relation_link_event(self.intervention, self.user, relation_label='Intervention', target_labels=['I999999']) record_relation_link_event(self.intervention, self.user, relation_label='Intervention', target_labels=['I999999'])
@ -467,7 +474,8 @@ class TimelineHistoryTests(TestCase):
self.assertContains(response, 'data-event-type="document"') self.assertContains(response, 'data-event-type="document"')
self.assertContains(response, 'data-event-type="link"') self.assertContains(response, 'data-event-type="link"')
self.assertContains(response, 'history-event-badge') self.assertContains(response, 'history-event-badge')
self.assertContains(response, 'history-status-badge') self.assertContains(response, 'history-status-circle')
self.assertContains(response, 'history-status-transition')
self.assertContains(response, 'bg-purple') self.assertContains(response, 'bg-purple')