feat: enable multi-team support for company members via updated history service and synchronization logic

This commit is contained in:
kdeterme 2026-09-09 15:14:25 +02:00
parent 5856acf5ed
commit 79697f392c
6 changed files with 588 additions and 112 deletions

View file

@ -18,10 +18,11 @@ from contracts.models import (
) )
def get_member_team_for_date(member: CompanyMember, target_date: date) -> Optional[CompanyTeam]: def get_member_teams_for_date(member: CompanyMember, target_date: date) -> List[CompanyTeam]:
""" """
Retourne l'équipe à laquelle appartient le membre à la date donnée (selon CompanyMemberHistory). Retourne la liste des équipes auxquelles appartient le membre à la date donnée (selon CompanyMemberHistory).
Retourne None si le membre n'est dans aucune équipe ce jour-là. Retourne [] si le membre n'est dans aucune équipe ce jour-là.
Prend en compte la continuité (report théorique si leave_date is None).
""" """
histories = list( histories = list(
CompanyMemberHistory.objects CompanyMemberHistory.objects
@ -31,15 +32,24 @@ def get_member_team_for_date(member: CompanyMember, target_date: date) -> Option
.order_by('-join_date', '-id') .order_by('-join_date', '-id')
) )
if not histories: if not histories:
return None return []
# Parmi les historiques ayant la date de début la plus récente (join_date maximal)
max_join = histories[0].join_date teams = []
candidates = [h for h in histories if h.join_date == max_join] seen_team_ids = set()
if len(candidates) > 1: for h in histories:
continuing = [h for h in candidates if h.leave_date is None or h.leave_date > target_date] if h.team and h.team_id not in seen_team_ids:
if continuing: seen_team_ids.add(h.team_id)
return continuing[0].team teams.append(h.team)
return candidates[0].team return teams
def get_member_team_for_date(member: CompanyMember, target_date: date) -> Optional[CompanyTeam]:
"""
Retourne la première équipe active à laquelle appartient le membre à la date donnée.
Conservé pour rétro-compatibilité. Utiliser get_member_teams_for_date pour le support multi-équipes.
"""
teams = get_member_teams_for_date(member, target_date)
return teams[0] if teams else None
@transaction.atomic @transaction.atomic
@ -49,25 +59,47 @@ def set_member_team_schedule(
target_date: date, target_date: date,
changed_by=None, changed_by=None,
sync_current: bool = True, sync_current: bool = True,
action: str = 'move',
unassign_team_id: Optional[int] = None,
) -> Optional[CompanyMemberHistory]: ) -> Optional[CompanyMemberHistory]:
""" """
Planifie l'appartenance d'un membre à une équipe à partir de `target_date`. Planifie l'appartenance d'un membre à une équipe à partir de `target_date`.
- Nettoie tout enregistrement existant commençant à `target_date`. - Si team_id is None :
- Clôture les historiques antérieurs la veille (`target_date - 1`). - si unassign_team_id est fourni : désassigne uniquement de cette équipe à target_date.
- Crée le nouvel enregistrement pour `target_date` jusqu'au prochain jalon futur. - sinon : désassigne de toutes les équipes à target_date.
- Si team_id est None, le membre est désassigné pour target_date. - Si team_id is not None :
- action='move' (défaut) : clôture/retire les autres équipes à target_date et affecte team_id.
- action='add' : ajoute team_id sans impacter les autres équipes existantes (multi-équipes).
""" """
today = timezone.now().date() today = timezone.now().date()
if target_date > today:
# 1. Date future : supprimer tout enregistrement existant débutant à target_date
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).delete()
else:
# 1. Aujourd'hui ou passé : clôturer l'enregistrement commençant à target_date
if team_id is None: if team_id is None:
# Désassignation
if unassign_team_id is not None:
# Désassignation ciblée d'une seule équipe
if target_date > today:
CompanyMemberHistory.objects.filter(
member=member,
team_id=unassign_team_id,
join_date=target_date,
).delete()
else:
CompanyMemberHistory.objects.filter(
member=member,
team_id=unassign_team_id,
join_date=target_date,
).delete()
prev_leave = target_date - timedelta(days=1)
CompanyMemberHistory.objects.filter(
member=member,
team_id=unassign_team_id,
join_date__lt=target_date,
).filter(
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
).update(leave_date=prev_leave)
else:
# Désassignation globale de toutes les équipes
if target_date > today:
CompanyMemberHistory.objects.filter( CompanyMemberHistory.objects.filter(
member=member, member=member,
join_date=target_date, join_date=target_date,
@ -76,9 +108,7 @@ def set_member_team_schedule(
CompanyMemberHistory.objects.filter( CompanyMemberHistory.objects.filter(
member=member, member=member,
join_date=target_date, join_date=target_date,
).exclude(team_id=team_id).update(leave_date=target_date) ).delete()
# 2. Clôturer les enregistrements antérieurs encore actifs à target_date
prev_leave = target_date - timedelta(days=1) prev_leave = target_date - timedelta(days=1)
CompanyMemberHistory.objects.filter( CompanyMemberHistory.objects.filter(
member=member, member=member,
@ -87,18 +117,49 @@ def set_member_team_schedule(
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date) Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
).update(leave_date=prev_leave) ).update(leave_date=prev_leave)
# 3. Trouver le prochain jalon futur _sync_teams_if_needed(member, target_date, today, sync_current)
return None
# team_id is not None
if action == 'move':
# En mode 'move', on remplace/clôture les affectations des autres équipes
if target_date > today:
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).exclude(team_id=team_id).delete()
else:
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).exclude(team_id=team_id).delete()
prev_leave = target_date - timedelta(days=1)
CompanyMemberHistory.objects.filter(
member=member,
join_date__lt=target_date,
).exclude(team_id=team_id).filter(
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
).update(leave_date=prev_leave)
# Vérifier s'il existe déjà un enregistrement pour cette équipe débutant à target_date
existing_hist = CompanyMemberHistory.objects.filter(
member=member,
team_id=team_id,
join_date=target_date,
).first()
if existing_hist:
new_history = existing_hist
else:
# Trouver le prochain jalon futur pour cette équipe
next_hist = ( next_hist = (
CompanyMemberHistory.objects CompanyMemberHistory.objects
.filter(member=member, join_date__gt=target_date) .filter(member=member, team_id=team_id, join_date__gt=target_date)
.order_by('join_date') .order_by('join_date')
.first() .first()
) )
next_leave_bound = (next_hist.join_date - timedelta(days=1)) if next_hist else None next_leave_bound = (next_hist.join_date - timedelta(days=1)) if next_hist else None
# 4. Créer le nouvel enregistrement s'il y a une équipe
new_history = None
if team_id is not None:
new_history = CompanyMemberHistory.objects.create( new_history = CompanyMemberHistory.objects.create(
member=member, member=member,
team_id=team_id, team_id=team_id,
@ -119,7 +180,7 @@ def _sync_teams_if_needed(member: CompanyMember, target_date: date, today: date,
def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] = None): def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] = None):
""" """
Synchronise le champ ManyToMany member.teams avec l'équipe active Synchronise le champ ManyToMany member.teams avec l'ensemble des équipes actives
dans CompanyMemberHistory pour la date spécifiée (par défaut aujourd'hui). dans CompanyMemberHistory pour la date spécifiée (par défaut aujourd'hui).
""" """
if for_date is None: if for_date is None:
@ -127,8 +188,6 @@ def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] =
has_history = CompanyMemberHistory.objects.filter(member=member).exists() has_history = CompanyMemberHistory.objects.filter(member=member).exists()
if not has_history: if not has_history:
# Si le membre n'a aucun historique d'assignation mais a des équipes configurées dans member.teams,
# on initialise son historique pour préserver ses affectations.
current_teams = list(member.teams.all()) current_teams = list(member.teams.all())
if current_teams: if current_teams:
for t in current_teams: for t in current_teams:
@ -140,19 +199,18 @@ def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] =
) )
return return
active_team = get_member_team_for_date(member, for_date) active_teams = get_member_teams_for_date(member, for_date)
current_team_ids = set(member.teams.values_list('id', flat=True)) current_team_ids = set(member.teams.values_list('id', flat=True))
target_team_ids = {active_team.id} if active_team else set() target_team_ids = {t.id for t in active_teams}
if current_team_ids != target_team_ids: if current_team_ids != target_team_ids:
# Utiliser .set() pour synchroniser
member.teams.set(list(target_team_ids)) member.teams.set(list(target_team_ids))
def sync_all_members_current_teams(for_date: Optional[date] = None) -> int: def sync_all_members_current_teams(for_date: Optional[date] = None) -> int:
""" """
Synchronise le champ ManyToMany member.teams pour tous les membres Synchronise le champ ManyToMany member.teams pour tous les membres
selon l'équipe active dans CompanyMemberHistory à la date donnée (aujourd'hui par défaut). selon les équipes actives dans CompanyMemberHistory à la date donnée (aujourd'hui par défaut).
Retourne le nombre de membres synchronisés. Retourne le nombre de membres synchronisés.
""" """
if for_date is None: if for_date is None:
@ -175,7 +233,8 @@ def get_teams_composition_for_period(
Calcule la composition journalière de chaque équipe sur une période donnée [start_date, end_date]. Calcule la composition journalière de chaque équipe sur une période donnée [start_date, end_date].
- Pour aujourd'hui et les jours passés : utilise les affectations actives courantes et historiques. - Pour aujourd'hui et les jours passés : utilise les affectations actives courantes et historiques.
- Pour les jours futurs : n'affiche que les journées qui ont été explicitement planifiées ou dupliquées (join_date == d). - Pour les jours futurs : n'affiche que les journées qui ont été explicitement planifiées ou dupliquées (join_date == d).
Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id}] Prend en charge la multi-appartenance (un membre peut être dans plusieurs équipes le même jour).
Retourne un dict: (team_id, date) -> [{'id': member_id, 'name': member_name, 'user_id': user_id, 'is_multi_team': bool, 'other_teams': [{'id': t_id, 'name': t_name}]}]
""" """
today = timezone.now().date() today = timezone.now().date()
histories = ( histories = (
@ -203,6 +262,8 @@ def get_teams_composition_for_period(
num_days = (end_date - start_date).days + 1 num_days = (end_date - start_date).days + 1
team_compositions_sets = defaultdict(set) team_compositions_sets = defaultdict(set)
member_teams_by_day = defaultdict(lambda: defaultdict(set))
teams_map = {t.id: t.name for t in CompanyTeam.objects.filter(id__in=team_ids)}
for i in range(num_days): for i in range(num_days):
d = start_date + timedelta(days=i) d = start_date + timedelta(days=i)
@ -222,26 +283,37 @@ def get_teams_composition_for_period(
if not active: if not active:
continue continue
# En cas de multiplicité, privilégier un historique continuant (leave_date > d ou None) # Grouper par équipe pour éliminer les doublons au sein d'une même équipe
if len(active) > 1: active_by_team = defaultdict(list)
continuing = [h for h in active if h['leave_date'] is None or h['leave_date'] > d]
if continuing:
active = continuing
active.sort(key=lambda x: (x['join_date'], x.get('id', 0)), reverse=True)
active = [active[0]]
for h in active: for h in active:
team_compositions_sets[(h['team_id'], d)].add( active_by_team[h['team_id']].append(h)
for t_id in active_by_team.keys():
team_compositions_sets[(t_id, d)].add(
(member.id, member.name, getattr(member.user, 'id', None)) (member.id, member.name, getattr(member.user, 'id', None))
) )
member_teams_by_day[d][member.id].add(t_id)
result = defaultdict(list) result = defaultdict(list)
for (t_id, d), m_set in team_compositions_sets.items(): for (t_id, d), m_set in team_compositions_sets.items():
sorted_members = sorted(list(m_set), key=lambda x: x[1]) sorted_members = sorted(list(m_set), key=lambda x: x[1])
result[(t_id, d)] = [ day_members = []
{'id': mid, 'name': mname, 'user_id': uid} for mid, mname, uid in sorted_members:
for mid, mname, uid in sorted_members all_teams_for_m = member_teams_by_day[d].get(mid, set())
is_multi = len(all_teams_for_m) > 1
other_teams = [
{'id': other_id, 'name': teams_map.get(other_id, '')}
for other_id in all_teams_for_m
if other_id != t_id
] ]
day_members.append({
'id': mid,
'name': mname,
'user_id': uid,
'is_multi_team': is_multi,
'other_teams': other_teams,
})
result[(t_id, d)] = day_members
return result return result
@ -319,22 +391,27 @@ def duplicate_day_schedule(
absences_count = 0 absences_count = 0
for member in all_members: for member in all_members:
team = get_member_team_for_date(member, source_date) source_teams = get_member_teams_for_date(member, source_date)
if team: if allowed_team_ids is not None:
if allowed_team_ids is not None and team.id not in allowed_team_ids: source_teams = [t for t in source_teams if t.id in allowed_team_ids]
continue
if source_teams:
for idx, team in enumerate(source_teams):
# La première équipe réinitialise les anciennes affectations, les suivantes s'ajoutent
action = 'move' if idx == 0 else 'add'
set_member_team_schedule( set_member_team_schedule(
member=member, member=member,
team_id=team.id, team_id=team.id,
target_date=target_date, target_date=target_date,
changed_by=changed_by, changed_by=changed_by,
sync_current=False, sync_current=False,
action=action,
) )
assigned_count += 1 assigned_count += 1
else: else:
# Membre non assigné le jour source : le désassigner aussi sur le jour cible s'il était dans une équipe autorisée # Membre non assigné le jour source : le désassigner aussi sur le jour cible s'il était dans une équipe autorisée
target_team = get_member_team_for_date(member, target_date) target_teams = get_member_teams_for_date(member, target_date)
if target_team and (allowed_team_ids is None or target_team.id in allowed_team_ids): if any(t.id in allowed_team_ids for t in target_teams) if allowed_team_ids else bool(target_teams):
set_member_team_schedule( set_member_team_schedule(
member=member, member=member,
team_id=None, team_id=None,
@ -348,7 +425,7 @@ def duplicate_day_schedule(
if allowed_team_ids is not None: if allowed_team_ids is not None:
allowed_member_ids = { allowed_member_ids = {
m.id for m in all_members m.id for m in all_members
if (get_member_team_for_date(m, source_date) and get_member_team_for_date(m, source_date).id in allowed_team_ids) if any(t.id in allowed_team_ids for t in get_member_teams_for_date(m, source_date))
} }
source_absences = source_absences.filter(member_id__in=allowed_member_ids) source_absences = source_absences.filter(member_id__in=allowed_member_ids)

View file

@ -589,7 +589,14 @@
<i class="bi bi-grip-vertical drag-handle fs-5"></i> <i class="bi bi-grip-vertical drag-handle fs-5"></i>
{% endif %} {% endif %}
<div class="member-info"> <div class="member-info">
<div class="d-flex align-items-center flex-wrap gap-1">
<strong>{{ member.name }}</strong> <strong>{{ member.name }}</strong>
{% if member.teams.count > 1 %}
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-1 py-0" style="font-size: 0.65rem;" title="{% trans 'Affecté à plusieurs équipes' %} : {{ member.teams_display }}">
<i class="bi bi-diagram-3 me-1"></i>{% trans "Multi-équipes" %}
</span>
{% endif %}
</div>
{% if member.user %} {% if member.user %}
<small>{{ member.user.get_full_name|default:member.user.username }}</small> <small>{{ member.user.get_full_name|default:member.user.username }}</small>
{% endif %} {% endif %}
@ -597,15 +604,23 @@
{% if can_edit %} {% if can_edit %}
<div class="member-actions"> <div class="member-actions">
<button class="btn btn-sm btn-outline-secondary" <button class="btn btn-sm btn-outline-secondary"
title="{% trans 'Déplacer vers une autre équipe' %}" title="{% trans 'Modifier ou déplacer ce membre' %}"
onclick="openMoveMemberModal({{ member.id }}, '{{ member.name|escapejs }}', {{ team.id }})"> onclick="openMoveMemberModal({{ member.id }}, '{{ member.name|escapejs }}', {{ team.id }})">
<i class="bi bi-arrow-left-right"></i> <i class="bi bi-arrow-left-right"></i>
</button> </button>
{% if member.teams.count > 1 %}
<button class="btn btn-sm btn-outline-warning"
title="{% trans 'Retirer de cette équipe' %}"
onclick="removeMemberFromTeam({{ member.id }}, {{ team.id }}, '{{ member.name|escapejs }}', '{{ team.name|escapejs }}')">
<i class="bi bi-x-circle"></i>
</button>
{% else %}
<button class="btn btn-sm btn-outline-danger" <button class="btn btn-sm btn-outline-danger"
title="{% trans 'Supprimer ce membre' %}" title="{% trans 'Supprimer ce membre' %}"
onclick="deleteMember({{ member.id }}, '{{ member.name|escapejs }}')"> onclick="deleteMember({{ member.id }}, '{{ member.name|escapejs }}')">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</li> </li>
@ -766,26 +781,68 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header border-bottom-0 pb-0"> <div class="modal-header border-bottom-0 pb-0">
<h5 class="modal-title text-warning"> <h5 class="modal-title text-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans "Changement d'équipe immédiat" %} <i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans "Affectation d'équipe immédiate" %}
</h5> </h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body py-3"> <div class="modal-body py-3">
<p class="mb-2"> <p class="mb-2">
{% trans "Ce déplacement modifie l'équipe de" %} <strong id="immediateMoveMemberName"></strong> {% trans "immédiatement pour aujourd'hui." %} {% trans "Vous modifiez l'affectation de" %} <strong id="immediateMoveMemberName"></strong> {% trans "pour aujourd'hui." %}
</p> </p>
<div class="alert alert-warning py-2 px-3 small mb-0"> <div class="alert alert-warning py-2 px-3 small mb-0">
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>
{% trans "Pour planifier des affectations d'équipes pour demain ou les jours futurs sans impacter la journée en cours, utilisez l'onglet" %} <strong>« {% trans "Planification des équipes" %} »</strong>. {% trans "Pour planifier des affectations d'équipes pour demain ou les jours futurs sans impacter la journée en cours, utilisez l'onglet" %} <strong>« {% trans "Planification des équipes" %} »</strong>.
</div> </div>
</div> </div>
<div class="modal-footer border-top-0 pt-0"> <div class="modal-footer border-top-0 pt-0 d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnCancelImmediateMove" data-bs-dismiss="modal"> <button type="button" class="btn btn-sm btn-outline-secondary" id="btnCancelImmediateMove" data-bs-dismiss="modal">
{% trans "Annuler" %} {% trans "Annuler" %}
</button> </button>
<button type="button" class="btn btn-sm btn-warning fw-semibold" id="btnConfirmImmediateMove"> <div class="d-flex gap-2">
<i class="bi bi-check2 me-1"></i>{% trans "Confirmer le changement immédiat" %} <button type="button" class="btn btn-sm btn-outline-primary fw-semibold" id="btnConfirmAddImmediate">
<i class="bi bi-diagram-3 me-1"></i>{% trans "Ajouter aussi (multi-équipes)" %}
</button> </button>
<button type="button" class="btn btn-sm btn-warning fw-semibold" id="btnConfirmImmediateMove">
<i class="bi bi-arrow-right me-1"></i>{% trans "Déplacer" %}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modale 1b : Confirmation Multi-équipes (Planning - Onglet 2) -->
<div class="modal fade" id="planningMultiTeamConfirmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-bottom-0 pb-0">
<h5 class="modal-title text-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans "Membre déjà planifié ce jour" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-3">
<p class="mb-2">
<strong id="multiTeamMemberName"></strong> {% trans "est déjà planifié(e) le" %} <strong id="multiTeamDateDisplay"></strong> {% trans "dans l'équipe" %} :
<span class="badge bg-secondary" id="multiTeamExistingTeamName"></span>.
</p>
<p class="mb-0 text-muted small">
{% trans "Vous tentez de l'assigner à l'équipe" %} : <strong id="multiTeamTargetTeamName"></strong>.
{% trans "Souhaitez-vous déplacer ce membre ou l'affecter aux deux équipes ?" %}
</p>
</div>
<div class="modal-footer border-top-0 pt-0 d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">
{% trans "Annuler" %}
</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-primary fw-semibold" id="btnPlanningAddMultiTeam">
<i class="bi bi-diagram-3 me-1"></i>{% trans "Affecter aussi à cette équipe" %}
</button>
<button type="button" class="btn btn-sm btn-warning fw-semibold" id="btnPlanningMoveTeam">
<i class="bi bi-arrow-right me-1"></i>{% trans "Déplacer vers cette équipe" %}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -981,6 +1038,19 @@
</optgroup> </optgroup>
</select> </select>
</div> </div>
<div class="mb-3 d-none" id="moveActionContainer">
<label class="form-label fw-semibold small">{% trans "Type d'affectation" %}</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="moveActionRadio" id="moveActionMove" value="move" checked>
<label class="form-check-label small" for="moveActionMove">{% trans "Déplacer (remplace l'ancienne équipe)" %}</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="moveActionRadio" id="moveActionAdd" value="add">
<label class="form-check-label small" for="moveActionAdd">{% trans "Ajouter aussi (multi-équipes)" %}</label>
</div>
</div>
</div>
<div id="moveMemberError" class="alert alert-danger d-none"></div> <div id="moveMemberError" class="alert alert-danger d-none"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@ -1123,7 +1193,7 @@ $(function () {
}); });
$('.sortable-members').disableSelection(); $('.sortable-members').disableSelection();
// Bouton Confirmer de la modale d'avertissement // Bouton Déplacer de la modale d'avertissement immédiat
$('#btnConfirmImmediateMove').on('click', function() { $('#btnConfirmImmediateMove').on('click', function() {
if (!_pendingMove) return; if (!_pendingMove) return;
const moveData = _pendingMove; const moveData = _pendingMove;
@ -1132,10 +1202,29 @@ $(function () {
postJSON( postJSON(
`/contracts/teams/members/${moveData.memberId}/move/`, `/contracts/teams/members/${moveData.memberId}/move/`,
{ team_id: moveData.teamIdPayload, old_team_id: moveData.oldTeamIdPayload }, { team_id: moveData.teamIdPayload, old_team_id: moveData.oldTeamIdPayload, action: 'move' },
function(resp) { function(resp) {
updateCounters(); location.reload();
updateEmptyPlaceholders(); },
function(errMsg) {
alert(errMsg);
location.reload();
}
);
});
// Bouton Ajouter aussi (multi-équipes) de la modale d'avertissement immédiat
$('#btnConfirmAddImmediate').on('click', function() {
if (!_pendingMove) return;
const moveData = _pendingMove;
_pendingMove = null;
bootstrap.Modal.getInstance(document.getElementById('confirmImmediateMoveModal')).hide();
postJSON(
`/contracts/teams/members/${moveData.memberId}/move/`,
{ team_id: moveData.teamIdPayload, old_team_id: moveData.oldTeamIdPayload, action: 'add' },
function(resp) {
location.reload();
}, },
function(errMsg) { function(errMsg) {
alert(errMsg); alert(errMsg);
@ -1153,6 +1242,17 @@ $(function () {
} }
}); });
// Écouter le changement d'équipe dans la modale d'édition pour afficher l'option multi-équipes
$('#moveToTeamId').on('change', function() {
const val = $(this).val();
const oldTeamId = $('#moveOldTeamId').val();
if (val && val !== 'unassign' && oldTeamId && oldTeamId !== val) {
$('#moveActionContainer').removeClass('d-none');
} else {
$('#moveActionContainer').addClass('d-none');
}
});
// Auto-fill member name when user is selected // Auto-fill member name when user is selected
$('#newMemberUser').on('change', function() { $('#newMemberUser').on('change', function() {
const text = $(this).find('option:selected').text().trim(); const text = $(this).find('option:selected').text().trim();
@ -1199,6 +1299,8 @@ function openMoveMemberModal(memberId, memberName, currentTeamId) {
opt.disabled = (opt.value !== '' && opt.value == currentTeamId); opt.disabled = (opt.value !== '' && opt.value == currentTeamId);
} }
} }
$('#moveActionContainer').addClass('d-none');
$('#moveActionMove').prop('checked', true);
document.getElementById('moveMemberError').classList.add('d-none'); document.getElementById('moveMemberError').classList.add('d-none');
new bootstrap.Modal(document.getElementById('moveMemberModal')).show(); new bootstrap.Modal(document.getElementById('moveMemberModal')).show();
} }
@ -1219,6 +1321,10 @@ function confirmMoveMember() {
if (selected !== '') { if (selected !== '') {
payload.team_id = selected === 'unassign' ? null : parseInt(selected); payload.team_id = selected === 'unassign' ? null : parseInt(selected);
} }
const action = (!$('#moveActionContainer').hasClass('d-none'))
? ($('input[name="moveActionRadio"]:checked').val() || 'move')
: 'move';
payload.action = action;
bootstrap.Modal.getInstance(document.getElementById('moveMemberModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('moveMemberModal')).hide();
@ -1230,6 +1336,22 @@ function confirmMoveMember() {
); );
} }
function removeMemberFromTeam(memberId, teamId, memberName, teamName) {
if (!confirm(`{% trans "Voulez-vous retirer"|escapejs %} ${memberName} {% trans "de l'équipe"|escapejs %} ${teamName} ? {% trans "(Le membre restera dans ses éventuelles autres équipes)"|escapejs %}`)) {
return;
}
postJSON(
`/contracts/teams/members/${memberId}/move/`,
{ team_id: null, old_team_id: teamId },
function(resp) {
location.reload();
},
function(errMsg) {
alert(errMsg);
}
);
}
function openAddMemberModal(teamId, teamName, teamCompanyId) { function openAddMemberModal(teamId, teamName, teamCompanyId) {
document.getElementById('addMemberTeamId').value = teamId !== null ? teamId : ''; document.getElementById('addMemberTeamId').value = teamId !== null ? teamId : '';
const title = teamId !== null const title = teamId !== null
@ -1480,6 +1602,27 @@ document.addEventListener('DOMContentLoaded', function() {
$('#btn-open-duplicate-modal').on('click', function() { $('#btn-open-duplicate-modal').on('click', function() {
openDuplicateModal(); openDuplicateModal();
}); });
// Boutons de la modale de confirmation multi-équipes pour la planification
$('#btnPlanningAddMultiTeam').on('click', function() {
if (!_pendingPlanningAssign) return;
const data = _pendingPlanningAssign;
_pendingPlanningAssign = null;
bootstrap.Modal.getInstance(document.getElementById('planningMultiTeamConfirmModal')).hide();
executePlanningAssign(data.memberId, data.targetTeamId, data.targetDate, 'add');
});
$('#btnPlanningMoveTeam').on('click', function() {
if (!_pendingPlanningAssign) return;
const data = _pendingPlanningAssign;
_pendingPlanningAssign = null;
bootstrap.Modal.getInstance(document.getElementById('planningMultiTeamConfirmModal')).hide();
executePlanningAssign(data.memberId, data.targetTeamId, data.targetDate, 'move');
});
$('#planningMultiTeamConfirmModal').on('hidden.bs.modal', function () {
_pendingPlanningAssign = null;
});
}); });
function navigatePlanningDate(direction) { function navigatePlanningDate(direction) {
@ -1695,6 +1838,13 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
absenceBadge = `<span class="badge bg-danger text-white me-1" style="font-size:0.65rem;" title="${escapeHtml(member.absence?.comment || '')}"><i class="bi bi-person-x-fill me-1"></i>${escapeHtml(reasonDisplay)}</span>`; absenceBadge = `<span class="badge bg-danger text-white me-1" style="font-size:0.65rem;" title="${escapeHtml(member.absence?.comment || '')}"><i class="bi bi-person-x-fill me-1"></i>${escapeHtml(reasonDisplay)}</span>`;
} }
let multiTeamBadge = '';
if (member.is_multi_team) {
const otherNames = (member.other_teams || []).map(t => t.name).join(', ');
const multiTitle = otherNames ? `{% trans "Affecté à plusieurs équipes ce jour"|escapejs %} (${escapeHtml(otherNames)})` : `{% trans "Affecté à plusieurs équipes ce jour"|escapejs %}`;
multiTeamBadge = `<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-1 py-0 me-1" style="font-size:0.65rem;" title="${multiTitle}"><i class="bi bi-diagram-3 me-1"></i>{% trans "Multi-équipes"|escapejs %}</span>`;
}
const titleAbsence = member.is_absent ? "{% trans "Modifier ou annuler l'absence"|escapejs %}" : "{% trans "Marquer absent"|escapejs %}"; const titleAbsence = member.is_absent ? "{% trans "Modifier ou annuler l'absence"|escapejs %}" : "{% trans "Marquer absent"|escapejs %}";
const titleUnassign = "{% trans 'Désassigner de cette équipe'|escapejs %}"; const titleUnassign = "{% trans 'Désassigner de cette équipe'|escapejs %}";
@ -1705,7 +1855,7 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
<button type="button" class="btn btn-sm btn-link p-0 text-muted btn-absence-toggle" title="${titleAbsence}" onclick="openAbsenceModal(${member.id}, '${escapeHtml(member.name)}', '${dateStr}', ${member.is_absent ? 'true' : 'false'}, '${escapeHtml(member.absence?.reason || 'conge')}', '${escapeHtml(member.absence?.comment || '')}')"> <button type="button" class="btn btn-sm btn-link p-0 text-muted btn-absence-toggle" title="${titleAbsence}" onclick="openAbsenceModal(${member.id}, '${escapeHtml(member.name)}', '${dateStr}', ${member.is_absent ? 'true' : 'false'}, '${escapeHtml(member.absence?.reason || 'conge')}', '${escapeHtml(member.absence?.comment || '')}')">
<i class="bi ${member.is_absent ? 'bi-person-x-fill text-danger' : 'bi-person-check text-muted'}"></i> <i class="bi ${member.is_absent ? 'bi-person-x-fill text-danger' : 'bi-person-check text-muted'}"></i>
</button> </button>
<button type="button" class="btn btn-sm btn-link p-0 text-muted" title="${titleUnassign}" onclick="unassignMemberFromDate(${member.id}, '${dateStr}')"> <button type="button" class="btn btn-sm btn-link p-0 text-muted" title="${titleUnassign}" onclick="unassignMemberFromDate(${member.id}, '${dateStr}', ${teamId})">
<i class="bi bi-x fs-6"></i> <i class="bi bi-x fs-6"></i>
</button> </button>
</div>`; </div>`;
@ -1719,6 +1869,7 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
card.innerHTML = ` card.innerHTML = `
<div class="planning-member-info" title="${escapeHtml(member.name)}"> <div class="planning-member-info" title="${escapeHtml(member.name)}">
<span class="member-name-text fw-semibold">${escapeHtml(member.name)}</span> <span class="member-name-text fw-semibold">${escapeHtml(member.name)}</span>
${multiTeamBadge}
${absenceBadge} ${absenceBadge}
</div> </div>
${actionsHtml} ${actionsHtml}
@ -1727,6 +1878,26 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
return card; return card;
} }
let _pendingPlanningAssign = null;
function executePlanningAssign(memberId, targetTeamId, targetDate, action) {
postJSON(
'/contracts/teams/planning/assign/',
{
member_id: parseInt(memberId),
team_id: parseInt(targetTeamId),
date: targetDate,
action: action || 'move',
},
function(resp) {
loadPlanningData();
},
function(err) {
alert(err);
}
);
}
function initPlanningDragAndDrop() { function initPlanningDragAndDrop() {
if (!CAN_EDIT_TEAMS) return; if (!CAN_EDIT_TEAMS) return;
@ -1760,26 +1931,60 @@ function initPlanningDragAndDrop() {
hoverClass: 'drag-over', hoverClass: 'drag-over',
tolerance: 'pointer', tolerance: 'pointer',
drop: function(event, ui) { drop: function(event, ui) {
const memberId = ui.draggable.data('memberId'); const memberId = parseInt(ui.draggable.data('memberId') || ui.draggable.data('member-id'));
const targetTeamId = $(this).data('teamId'); const memberName = ui.draggable.data('memberName') || ui.draggable.data('member-name') || ui.draggable.find('.member-name-text, .fw-semibold').first().text().trim();
const targetTeamId = parseInt($(this).data('teamId') || $(this).data('team-id'));
const targetDate = $(this).data('date'); const targetDate = $(this).data('date');
if (!memberId || !targetTeamId || !targetDate) return; if (!memberId || !targetTeamId || !targetDate) return;
postJSON( // Vérifier si le membre est déjà planifié dans des équipes ce jour-là
'/contracts/teams/planning/assign/', const otherAssignedTeams = [];
{ let alreadyInTargetTeam = false;
member_id: parseInt(memberId), if (_planningState && _planningState.data && _planningState.data.teams) {
team_id: parseInt(targetTeamId), _planningState.data.teams.forEach(t => {
date: targetDate, if (t.days && t.days[targetDate]) {
}, const found = t.days[targetDate].some(m => m.id === memberId);
function(resp) { if (found) {
loadPlanningData(); if (t.id === targetTeamId) {
}, alreadyInTargetTeam = true;
function(err) { } else {
alert(err); otherAssignedTeams.push(t);
}
}
}
});
}
if (alreadyInTargetTeam) {
// Déjà dans cette équipe ce jour-là
return;
}
if (otherAssignedTeams.length > 0) {
// Membre déjà dans une autre équipe : avertir et proposer Déplacer ou Ajouter aussi
const otherNames = otherAssignedTeams.map(t => t.name).join(', ');
const targetTeamObj = (_planningState.data.teams || []).find(t => t.id === targetTeamId);
const targetName = targetTeamObj ? targetTeamObj.name : `Équipe #${targetTeamId}`;
$('#multiTeamMemberName').text(memberName);
$('#multiTeamDateDisplay').text(targetDate);
$('#multiTeamExistingTeamName').text(otherNames);
$('#multiTeamTargetTeamName').text(targetName);
_pendingPlanningAssign = {
memberId: memberId,
targetTeamId: targetTeamId,
targetDate: targetDate
};
const modalEl = document.getElementById('planningMultiTeamConfirmModal');
const modal = new bootstrap.Modal(modalEl);
modal.show();
} else {
// Pas encore assigné ce jour-là -> assignation directe
executePlanningAssign(memberId, targetTeamId, targetDate, 'move');
} }
);
} }
}); });
} }
@ -1857,13 +2062,14 @@ function filterSidebarMembers() {
initPlanningDragAndDrop(); initPlanningDragAndDrop();
} }
function unassignMemberFromDate(memberId, dateStr) { function unassignMemberFromDate(memberId, dateStr, teamId) {
postJSON( postJSON(
'/contracts/teams/planning/assign/', '/contracts/teams/planning/assign/',
{ {
member_id: parseInt(memberId), member_id: parseInt(memberId),
team_id: null, team_id: null,
date: dateStr, date: dateStr,
unassign_team_id: teamId ? parseInt(teamId) : null,
}, },
function(resp) { function(resp) {
loadPlanningData(); loadPlanningData();

View file

@ -8,6 +8,7 @@ from django.utils import timezone
from contracts.models import Company, CompanyTeam, CompanyMember, CompanyMemberHistory, CompanyMemberAbsence from contracts.models import Company, CompanyTeam, CompanyMember, CompanyMemberHistory, CompanyMemberAbsence
from contracts.services_teams import ( from contracts.services_teams import (
get_member_team_for_date, get_member_team_for_date,
get_member_teams_for_date,
set_member_team_schedule, set_member_team_schedule,
get_teams_composition_for_period, get_teams_composition_for_period,
get_member_absences_for_period, get_member_absences_for_period,
@ -115,6 +116,65 @@ class TeamPlanningServicesTests(TestCase):
# Vérifier que le mercredi de la semaine 2, Alice est bien en Team A # Vérifier que le mercredi de la semaine 2, Alice est bien en Team A
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 9, 16)), self.team_a) self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 9, 16)), self.team_a)
def test_multi_team_scheduling_same_day(self):
"""Tester l'affectation d'un membre à deux équipes le même jour avec action='add'."""
target_date = date(2026, 9, 2)
# 1. Affecter Alice à Team A
set_member_team_schedule(self.member_alice, self.team_a.id, target_date)
teams = get_member_teams_for_date(self.member_alice, target_date)
self.assertEqual(len(teams), 1)
self.assertEqual(teams[0], self.team_a)
# 2. Affecter aussi Alice à Team B avec action='add'
set_member_team_schedule(self.member_alice, self.team_b.id, target_date, action='add')
teams = get_member_teams_for_date(self.member_alice, target_date)
self.assertEqual(len(teams), 2)
self.assertIn(self.team_a, teams)
self.assertIn(self.team_b, teams)
# 3. Vérifier les compositions pour la matrice
comp = get_teams_composition_for_period({self.team_a.id, self.team_b.id}, target_date, target_date)
team_a_members = comp.get((self.team_a.id, target_date), [])
team_b_members = comp.get((self.team_b.id, target_date), [])
alice_in_a = next((m for m in team_a_members if m['id'] == self.member_alice.id), None)
alice_in_b = next((m for m in team_b_members if m['id'] == self.member_alice.id), None)
self.assertIsNotNone(alice_in_a)
self.assertIsNotNone(alice_in_b)
self.assertTrue(alice_in_a['is_multi_team'])
self.assertTrue(alice_in_b['is_multi_team'])
self.assertEqual(alice_in_a['other_teams'][0]['id'], self.team_b.id)
self.assertEqual(alice_in_b['other_teams'][0]['id'], self.team_a.id)
def test_multi_team_unassign_specific_team(self):
"""Tester la désassignation ciblée d'une seule équipe lorsqu'un membre en a plusieurs."""
target_date = date(2026, 9, 2)
set_member_team_schedule(self.member_alice, self.team_a.id, target_date)
set_member_team_schedule(self.member_alice, self.team_b.id, target_date, action='add')
# Désassigner Alice uniquement de Team A
set_member_team_schedule(self.member_alice, None, target_date, unassign_team_id=self.team_a.id)
teams = get_member_teams_for_date(self.member_alice, target_date)
self.assertEqual(len(teams), 1)
self.assertEqual(teams[0], self.team_b)
def test_multi_team_duplicate_day_schedule(self):
"""Tester que la duplication d'une journée duplique toutes les équipes d'un membre multi-équipes."""
day1 = date(2026, 9, 7)
day2 = date(2026, 9, 8)
set_member_team_schedule(self.member_alice, self.team_a.id, day1)
set_member_team_schedule(self.member_alice, self.team_b.id, day1, action='add')
res = duplicate_day_schedule(day1, day2)
self.assertGreaterEqual(res['assigned_count'], 2)
teams_day2 = get_member_teams_for_date(self.member_alice, day2)
self.assertEqual(len(teams_day2), 2)
self.assertIn(self.team_a, teams_day2)
self.assertIn(self.team_b, teams_day2)
class TeamPlanningAjaxViewsTests(TestCase): class TeamPlanningAjaxViewsTests(TestCase):
def setUp(self): def setUp(self):
@ -347,3 +407,79 @@ class TeamPlanningAjaxViewsTests(TestCase):
comp2 = get_teams_composition_for_period({self.team_a.id}, target_d, target_d) comp2 = get_teams_composition_for_period({self.team_a.id}, target_d, target_d)
self.assertNotIn(self.member_alice.id, [m['id'] for m in comp2.get((self.team_a.id, target_d), [])]) self.assertNotIn(self.member_alice.id, [m['id'] for m in comp2.get((self.team_a.id, target_d), [])])
def test_multi_team_planning_assign_member_ajax_add_and_move(self):
"""Tester l'endpoint AJAX de planification avec action='add' puis action='move'."""
today = timezone.now().date()
target_date = today + timedelta(days=2)
# 1. Affecter à Team A
url = reverse('contracts:team_planning_assign_member_ajax')
resp = self.client.post(url, data=json.dumps({
'member_id': self.member_alice.id,
'team_id': self.team_a.id,
'date': target_date.isoformat(),
'action': 'move',
}), content_type='application/json')
self.assertEqual(resp.status_code, 200)
# 2. Affecter aussi à Team B avec action='add'
resp = self.client.post(url, data=json.dumps({
'member_id': self.member_alice.id,
'team_id': self.team_b.id,
'date': target_date.isoformat(),
'action': 'add',
}), content_type='application/json')
self.assertEqual(resp.status_code, 200)
teams = get_member_teams_for_date(self.member_alice, target_date)
self.assertEqual(len(teams), 2)
self.assertIn(self.team_a, teams)
self.assertIn(self.team_b, teams)
# 3. Déplacer vers Team A avec action='move' -> retire Team B
resp = self.client.post(url, data=json.dumps({
'member_id': self.member_alice.id,
'team_id': self.team_a.id,
'date': target_date.isoformat(),
'action': 'move',
}), content_type='application/json')
self.assertEqual(resp.status_code, 200)
teams_after = get_member_teams_for_date(self.member_alice, target_date)
self.assertEqual(len(teams_after), 1)
self.assertEqual(teams_after[0], self.team_a)
def test_multi_team_teams_move_member_immediate(self):
"""Tester le déplacement immédiat en multi-équipes via teams_move_member."""
today = timezone.now().date()
url = reverse('contracts:teams_move_member', args=[self.member_alice.id])
# Assigner à Team A
self.client.post(url, data=json.dumps({
'team_id': self.team_a.id,
'old_team_id': None,
'action': 'move',
}), content_type='application/json')
# Assigner aussi à Team B
self.client.post(url, data=json.dumps({
'team_id': self.team_b.id,
'old_team_id': self.team_a.id,
'action': 'add',
}), content_type='application/json')
self.member_alice.refresh_from_db()
self.assertEqual(self.member_alice.teams.count(), 2)
self.assertIn(self.team_a, self.member_alice.teams.all())
self.assertIn(self.team_b, self.member_alice.teams.all())
# Retirer de Team A uniquement
self.client.post(url, data=json.dumps({
'team_id': None,
'old_team_id': self.team_a.id,
}), content_type='application/json')
self.member_alice.refresh_from_db()
self.assertEqual(self.member_alice.teams.count(), 1)
self.assertEqual(self.member_alice.teams.first(), self.team_b)

View file

@ -154,8 +154,24 @@ def teams_move_member(request, member_id):
if old_team_id is not None: if old_team_id is not None:
old_team = get_object_or_404(CompanyTeam, id=old_team_id) old_team = get_object_or_404(CompanyTeam, id=old_team_id)
action = data.get("action", "move")
today = timezone.now().date() today = timezone.now().date()
set_member_team_schedule(member, new_team_id, today, changed_by=request.user) if new_team_id is None and old_team_id is not None:
set_member_team_schedule(
member=member,
team_id=None,
target_date=today,
changed_by=request.user,
unassign_team_id=old_team_id,
)
else:
set_member_team_schedule(
member=member,
team_id=new_team_id,
target_date=today,
changed_by=request.user,
action=action,
)
member.save() member.save()
@ -334,7 +350,23 @@ def teams_update_member(request, member_id):
# Effectuer le transfert via le service de planification (date d'aujourd'hui) # Effectuer le transfert via le service de planification (date d'aujourd'hui)
today = timezone.now().date() today = timezone.now().date()
set_member_team_schedule(member, new_team_id, today, changed_by=request.user) action = data.get("action", "move")
if new_team_id is None and old_team_id is not None:
set_member_team_schedule(
member,
None,
today,
changed_by=request.user,
unassign_team_id=old_team_id,
)
else:
set_member_team_schedule(
member,
new_team_id,
today,
changed_by=request.user,
action=action,
)
member.name = new_name member.name = new_name
member.save() member.save()
@ -493,6 +525,8 @@ def team_planning_data_ajax(request):
'user_id': m.get('user_id'), 'user_id': m.get('user_id'),
'is_absent': abs_info is not None, 'is_absent': abs_info is not None,
'absence': abs_info, 'absence': abs_info,
'is_multi_team': m.get('is_multi_team', False),
'other_teams': m.get('other_teams', []),
}) })
days_dict[d_str] = members_on_day days_dict[d_str] = members_on_day
@ -583,6 +617,10 @@ def team_planning_assign_member_ajax(request):
if not user_can_edit_teams(request.user): if not user_can_edit_teams(request.user):
raise PermissionDenied("Droits d'édition insuffisants.") raise PermissionDenied("Droits d'édition insuffisants.")
action = data.get("action", "move")
raw_unassign_team_id = data.get("unassign_team_id")
unassign_team_id = int(raw_unassign_team_id) if raw_unassign_team_id is not None else None
# Effectuer la planification # Effectuer la planification
set_member_team_schedule( set_member_team_schedule(
member=member, member=member,
@ -590,6 +628,8 @@ def team_planning_assign_member_ajax(request):
target_date=target_date, target_date=target_date,
changed_by=request.user, changed_by=request.user,
sync_current=True, sync_current=True,
action=action,
unassign_team_id=unassign_team_id,
) )
return JsonResponse({ return JsonResponse({

View file

@ -533,11 +533,18 @@ document.addEventListener('DOMContentLoaded', function () {
const id = typeof m === 'object' && m ? m.id : null; const id = typeof m === 'object' && m ? m.id : null;
const isAbsent = typeof m === 'object' && m ? Boolean(m.is_absent) : false; const isAbsent = typeof m === 'object' && m ? Boolean(m.is_absent) : false;
const absenceReason = typeof m === 'object' && m ? (m.absence_reason || '') : ''; const absenceReason = typeof m === 'object' && m ? (m.absence_reason || '') : '';
const isMultiTeam = typeof m === 'object' && m ? Boolean(m.is_multi_team) : false;
const otherTeams = typeof m === 'object' && m && m.other_teams ? m.other_teams : [];
let memberNameHtml = escapeHtml(name); let memberNameHtml = escapeHtml(name);
if (isMemberHistoryAllowed && id) { if (isMemberHistoryAllowed && id) {
memberNameHtml = `<a href="#" class="member-history-link text-decoration-none ${isAbsent ? 'text-danger' : 'text-muted fw-semibold'}" data-member-id="${id}">${escapeHtml(name)}</a>`; memberNameHtml = `<a href="#" class="member-history-link text-decoration-none ${isAbsent ? 'text-danger' : 'text-muted fw-semibold'}" data-member-id="${id}">${escapeHtml(name)}</a>`;
} }
if (isMultiTeam) {
const otherNames = otherTeams.map(t => t.name).join(', ');
const tooltip = otherNames ? `${gettext('Planifié aussi dans')} : ${otherNames}` : gettext('Planifié dans plusieurs équipes ce jour');
memberNameHtml += ` <i class="bi bi-diagram-3 text-warning ms-1" style="font-size:0.75rem;" title="${escapeHtml(tooltip)}"></i>`;
}
if (isAbsent) { if (isAbsent) {
return `<span class="text-decoration-line-through text-danger opacity-75" title="${escapeHtml(absenceReason || gettext('Absent'))}">${memberNameHtml}</span> <span class="badge text-bg-danger p-0 px-1 font-monospace" style="font-size:0.6rem;">${escapeHtml(absenceReason || gettext('Absent'))}</span>`; return `<span class="text-decoration-line-through text-danger opacity-75" title="${escapeHtml(absenceReason || gettext('Absent'))}">${memberNameHtml}</span> <span class="badge text-bg-danger p-0 px-1 font-monospace" style="font-size:0.6rem;">${escapeHtml(absenceReason || gettext('Absent'))}</span>`;
} }

View file

@ -965,6 +965,7 @@ def _interventions_events_by_teams_daily(
# Map of (team_id, date) -> set of member names # Map of (team_id, date) -> set of member names
team_compositions_sets = defaultdict(set) team_compositions_sets = defaultdict(set)
member_teams_by_day = defaultdict(lambda: defaultdict(set))
# For each day of the week # For each day of the week
today = timezone.now().date() today = timezone.now().date()
@ -997,6 +998,7 @@ def _interventions_events_by_teams_daily(
# Add to team_compositions_sets for the remaining team(s) # Add to team_compositions_sets for the remaining team(s)
for h in active: for h in active:
team_compositions_sets[(h['team_id'], d)].add((m_id, member_name)) team_compositions_sets[(h['team_id'], d)].add((m_id, member_name))
member_teams_by_day[d][m_id].add(h['team_id'])
# 5.b Récupérer les absences pour cette semaine # 5.b Récupérer les absences pour cette semaine
absences_map = { absences_map = {
@ -1004,6 +1006,8 @@ def _interventions_events_by_teams_daily(
for a in CompanyMemberAbsence.objects.filter(date__range=(start_date, end_date)) for a in CompanyMemberAbsence.objects.filter(date__range=(start_date, end_date))
} }
teams_name_map = {t.id: t.name for t in allowed_teams if t.id is not None}
# Convert sets to sorted lists of dicts for consistency and default to empty list on missing access # Convert sets to sorted lists of dicts for consistency and default to empty list on missing access
team_compositions = defaultdict(list) team_compositions = defaultdict(list)
for (t_id, d_val), members_set in team_compositions_sets.items(): for (t_id, d_val), members_set in team_compositions_sets.items():
@ -1013,6 +1017,12 @@ def _interventions_events_by_teams_daily(
'name': mname, 'name': mname,
'is_absent': (mid, d_val) in absences_map, 'is_absent': (mid, d_val) in absences_map,
'absence_reason': absences_map.get((mid, d_val)), 'absence_reason': absences_map.get((mid, d_val)),
'is_multi_team': len(member_teams_by_day[d_val][mid]) > 1,
'other_teams': [
{'id': ot_id, 'name': teams_name_map.get(ot_id, '')}
for ot_id in member_teams_by_day[d_val][mid]
if ot_id != t_id
],
} }
for mid, mname in sorted(list(members_set), key=lambda x: x[1]) for mid, mname in sorted(list(members_set), key=lambda x: x[1])
] ]