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 None si le membre n'est dans aucune équipe ce jour-là.
Retourne la liste des équipes auxquelles appartient le membre à la date donnée (selon CompanyMemberHistory).
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(
CompanyMemberHistory.objects
@ -31,15 +32,24 @@ def get_member_team_for_date(member: CompanyMember, target_date: date) -> Option
.order_by('-join_date', '-id')
)
if not histories:
return None
# Parmi les historiques ayant la date de début la plus récente (join_date maximal)
max_join = histories[0].join_date
candidates = [h for h in histories if h.join_date == max_join]
if len(candidates) > 1:
continuing = [h for h in candidates if h.leave_date is None or h.leave_date > target_date]
if continuing:
return continuing[0].team
return candidates[0].team
return []
teams = []
seen_team_ids = set()
for h in histories:
if h.team and h.team_id not in seen_team_ids:
seen_team_ids.add(h.team_id)
teams.append(h.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
@ -49,25 +59,47 @@ def set_member_team_schedule(
target_date: date,
changed_by=None,
sync_current: bool = True,
action: str = 'move',
unassign_team_id: Optional[int] = None,
) -> Optional[CompanyMemberHistory]:
"""
Planifie l'appartenance d'un membre à une équipe à partir de `target_date`.
- Nettoie tout enregistrement existant commençant à `target_date`.
- Clôture les historiques antérieurs la veille (`target_date - 1`).
- Crée le nouvel enregistrement pour `target_date` jusqu'au prochain jalon futur.
- Si team_id est None, le membre est désassigné pour target_date.
- Si team_id is None :
- si unassign_team_id est fourni : désassigne uniquement de cette équipe à target_date.
- sinon : désassigne de toutes les équipes à 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()
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:
# 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(
member=member,
join_date=target_date,
@ -76,9 +108,7 @@ def set_member_team_schedule(
CompanyMemberHistory.objects.filter(
member=member,
join_date=target_date,
).exclude(team_id=team_id).update(leave_date=target_date)
# 2. Clôturer les enregistrements antérieurs encore actifs à target_date
).delete()
prev_leave = target_date - timedelta(days=1)
CompanyMemberHistory.objects.filter(
member=member,
@ -87,18 +117,49 @@ def set_member_team_schedule(
Q(leave_date__isnull=True) | Q(leave_date__gte=target_date)
).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 = (
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')
.first()
)
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(
member=member,
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):
"""
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).
"""
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()
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())
if current_teams:
for t in current_teams:
@ -140,19 +199,18 @@ def sync_member_current_teams(member: CompanyMember, for_date: Optional[date] =
)
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))
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:
# Utiliser .set() pour synchroniser
member.teams.set(list(target_team_ids))
def sync_all_members_current_teams(for_date: Optional[date] = None) -> int:
"""
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.
"""
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].
- 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).
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()
histories = (
@ -203,6 +262,8 @@ def get_teams_composition_for_period(
num_days = (end_date - start_date).days + 1
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):
d = start_date + timedelta(days=i)
@ -222,26 +283,37 @@ def get_teams_composition_for_period(
if not active:
continue
# En cas de multiplicité, privilégier un historique continuant (leave_date > d ou None)
if len(active) > 1:
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]]
# Grouper par équipe pour éliminer les doublons au sein d'une même équipe
active_by_team = defaultdict(list)
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_teams_by_day[d][member.id].add(t_id)
result = defaultdict(list)
for (t_id, d), m_set in team_compositions_sets.items():
sorted_members = sorted(list(m_set), key=lambda x: x[1])
result[(t_id, d)] = [
{'id': mid, 'name': mname, 'user_id': uid}
for mid, mname, uid in sorted_members
day_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
@ -319,22 +391,27 @@ def duplicate_day_schedule(
absences_count = 0
for member in all_members:
team = get_member_team_for_date(member, source_date)
if team:
if allowed_team_ids is not None and team.id not in allowed_team_ids:
continue
source_teams = get_member_teams_for_date(member, source_date)
if allowed_team_ids is not None:
source_teams = [t for t in source_teams if t.id in allowed_team_ids]
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(
member=member,
team_id=team.id,
target_date=target_date,
changed_by=changed_by,
sync_current=False,
action=action,
)
assigned_count += 1
else:
# 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)
if target_team and (allowed_team_ids is None or target_team.id in allowed_team_ids):
target_teams = get_member_teams_for_date(member, target_date)
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(
member=member,
team_id=None,
@ -348,7 +425,7 @@ def duplicate_day_schedule(
if allowed_team_ids is not None:
allowed_member_ids = {
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)

View file

@ -589,7 +589,14 @@
<i class="bi bi-grip-vertical drag-handle fs-5"></i>
{% endif %}
<div class="member-info">
<div class="d-flex align-items-center flex-wrap gap-1">
<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 %}
<small>{{ member.user.get_full_name|default:member.user.username }}</small>
{% endif %}
@ -597,15 +604,23 @@
{% if can_edit %}
<div class="member-actions">
<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 }})">
<i class="bi bi-arrow-left-right"></i>
</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"
title="{% trans 'Supprimer ce membre' %}"
onclick="deleteMember({{ member.id }}, '{{ member.name|escapejs }}')">
<i class="bi bi-trash"></i>
</button>
{% endif %}
</div>
{% endif %}
</li>
@ -766,26 +781,68 @@
<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 "Changement d'équipe immédiat" %}
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% trans "Affectation d'équipe immédiate" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-3">
<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>
<div class="alert alert-warning py-2 px-3 small mb-0">
<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>.
</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">
{% trans "Annuler" %}
</button>
<button type="button" class="btn btn-sm btn-warning fw-semibold" id="btnConfirmImmediateMove">
<i class="bi bi-check2 me-1"></i>{% trans "Confirmer le changement immédiat" %}
<div class="d-flex gap-2">
<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 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>
@ -981,6 +1038,19 @@
</optgroup>
</select>
</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>
<div class="modal-footer">
@ -1123,7 +1193,7 @@ $(function () {
});
$('.sortable-members').disableSelection();
// Bouton Confirmer de la modale d'avertissement
// Bouton Déplacer de la modale d'avertissement immédiat
$('#btnConfirmImmediateMove').on('click', function() {
if (!_pendingMove) return;
const moveData = _pendingMove;
@ -1132,10 +1202,29 @@ $(function () {
postJSON(
`/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) {
updateCounters();
updateEmptyPlaceholders();
location.reload();
},
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) {
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
$('#newMemberUser').on('change', function() {
const text = $(this).find('option:selected').text().trim();
@ -1199,6 +1299,8 @@ function openMoveMemberModal(memberId, memberName, currentTeamId) {
opt.disabled = (opt.value !== '' && opt.value == currentTeamId);
}
}
$('#moveActionContainer').addClass('d-none');
$('#moveActionMove').prop('checked', true);
document.getElementById('moveMemberError').classList.add('d-none');
new bootstrap.Modal(document.getElementById('moveMemberModal')).show();
}
@ -1219,6 +1321,10 @@ function confirmMoveMember() {
if (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();
@ -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) {
document.getElementById('addMemberTeamId').value = teamId !== null ? teamId : '';
const title = teamId !== null
@ -1480,6 +1602,27 @@ document.addEventListener('DOMContentLoaded', function() {
$('#btn-open-duplicate-modal').on('click', function() {
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) {
@ -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>`;
}
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 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 || '')}')">
<i class="bi ${member.is_absent ? 'bi-person-x-fill text-danger' : 'bi-person-check text-muted'}"></i>
</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>
</button>
</div>`;
@ -1719,6 +1869,7 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
card.innerHTML = `
<div class="planning-member-info" title="${escapeHtml(member.name)}">
<span class="member-name-text fw-semibold">${escapeHtml(member.name)}</span>
${multiTeamBadge}
${absenceBadge}
</div>
${actionsHtml}
@ -1727,6 +1878,26 @@ function createPlanningMemberCard(member, teamId, dateStr, canEdit, isPast) {
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() {
if (!CAN_EDIT_TEAMS) return;
@ -1760,26 +1931,60 @@ function initPlanningDragAndDrop() {
hoverClass: 'drag-over',
tolerance: 'pointer',
drop: function(event, ui) {
const memberId = ui.draggable.data('memberId');
const targetTeamId = $(this).data('teamId');
const memberId = parseInt(ui.draggable.data('memberId') || ui.draggable.data('member-id'));
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');
if (!memberId || !targetTeamId || !targetDate) return;
postJSON(
'/contracts/teams/planning/assign/',
{
member_id: parseInt(memberId),
team_id: parseInt(targetTeamId),
date: targetDate,
},
function(resp) {
loadPlanningData();
},
function(err) {
alert(err);
// Vérifier si le membre est déjà planifié dans des équipes ce jour-là
const otherAssignedTeams = [];
let alreadyInTargetTeam = false;
if (_planningState && _planningState.data && _planningState.data.teams) {
_planningState.data.teams.forEach(t => {
if (t.days && t.days[targetDate]) {
const found = t.days[targetDate].some(m => m.id === memberId);
if (found) {
if (t.id === targetTeamId) {
alreadyInTargetTeam = true;
} else {
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();
}
function unassignMemberFromDate(memberId, dateStr) {
function unassignMemberFromDate(memberId, dateStr, teamId) {
postJSON(
'/contracts/teams/planning/assign/',
{
member_id: parseInt(memberId),
team_id: null,
date: dateStr,
unassign_team_id: teamId ? parseInt(teamId) : null,
},
function(resp) {
loadPlanningData();

View file

@ -8,6 +8,7 @@ from django.utils import timezone
from contracts.models import Company, CompanyTeam, CompanyMember, CompanyMemberHistory, CompanyMemberAbsence
from contracts.services_teams import (
get_member_team_for_date,
get_member_teams_for_date,
set_member_team_schedule,
get_teams_composition_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
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):
def setUp(self):
@ -347,3 +407,79 @@ class TeamPlanningAjaxViewsTests(TestCase):
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), [])])
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:
old_team = get_object_or_404(CompanyTeam, id=old_team_id)
action = data.get("action", "move")
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()
@ -334,7 +350,23 @@ def teams_update_member(request, member_id):
# Effectuer le transfert via le service de planification (date d'aujourd'hui)
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.save()
@ -493,6 +525,8 @@ def team_planning_data_ajax(request):
'user_id': m.get('user_id'),
'is_absent': abs_info is not None,
'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
@ -583,6 +617,10 @@ def team_planning_assign_member_ajax(request):
if not user_can_edit_teams(request.user):
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
set_member_team_schedule(
member=member,
@ -590,6 +628,8 @@ def team_planning_assign_member_ajax(request):
target_date=target_date,
changed_by=request.user,
sync_current=True,
action=action,
unassign_team_id=unassign_team_id,
)
return JsonResponse({

View file

@ -533,11 +533,18 @@ document.addEventListener('DOMContentLoaded', function () {
const id = typeof m === 'object' && m ? m.id : null;
const isAbsent = typeof m === 'object' && m ? Boolean(m.is_absent) : false;
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);
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>`;
}
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) {
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
team_compositions_sets = defaultdict(set)
member_teams_by_day = defaultdict(lambda: defaultdict(set))
# For each day of the week
today = timezone.now().date()
@ -997,6 +998,7 @@ def _interventions_events_by_teams_daily(
# Add to team_compositions_sets for the remaining team(s)
for h in active:
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
absences_map = {
@ -1004,6 +1006,8 @@ def _interventions_events_by_teams_daily(
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
team_compositions = defaultdict(list)
for (t_id, d_val), members_set in team_compositions_sets.items():
@ -1013,6 +1017,12 @@ def _interventions_events_by_teams_daily(
'name': mname,
'is_absent': (mid, d_val) in absences_map,
'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])
]