feat: enable multi-team support for company members via updated history service and synchronization logic
This commit is contained in:
parent
5856acf5ed
commit
79697f392c
6 changed files with 588 additions and 112 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue