This commit is contained in:
Louis Mazin 2026-10-05 17:52:28 +02:00
parent 12d3a95811
commit 4db822aebe
78 changed files with 6025 additions and 377 deletions

View File

@ -47,6 +47,7 @@ INSTALLED_APPS = [
# Local apps
'users',
'time_tracking',
'planning',
'events',
'notes',
'file', # Ajout du module de gestion des fichiers

View File

@ -26,6 +26,7 @@ urlpatterns = [
path('api/auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/users/', include('users.urls')),
path('api/planning/', include('planning.urls')),
path('api/time/', include('time_tracking.urls')),
path('api/event/', include('events.urls')),
path('api/notes/', include('notes.urls')),

View File

@ -0,0 +1,32 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('events', '0004_remove_event_linked_user_remove_event_user_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='EventProfile',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('start_time', models.TimeField(blank=True, null=True)),
('end_time', models.TimeField(blank=True, null=True)),
('color', models.CharField(blank=True, max_length=7, null=True)),
('is_public', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='event_profiles', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name', 'start_time', 'pk'],
},
),
]

View File

@ -0,0 +1,19 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('events', '0005_eventprofile'),
]
operations = [
migrations.RemoveField(
model_name='event',
name='description',
),
migrations.RemoveField(
model_name='eventprofile',
name='description',
),
]

View File

@ -16,10 +16,34 @@ class Event(models.Model):
end_time = models.TimeField(null=True, blank=True)
color = models.CharField(max_length=7, null=True, blank=True)
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
is_public = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class EventProfile(models.Model):
"""Reusable event defaults owned by one user."""
id = models.AutoField(primary_key=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='event_profiles',
editable=False,
)
name = models.CharField(max_length=255)
start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField(null=True, blank=True)
color = models.CharField(max_length=7, null=True, blank=True)
is_public = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name', 'start_time', 'pk']
def __str__(self):
return self.name

View File

@ -1,5 +1,8 @@
from rest_framework import serializers
from .models import Event
from users.permissions import is_admin
from .models import Event, EventProfile
class EventSerializer(serializers.ModelSerializer):
@ -8,7 +11,7 @@ class EventSerializer(serializers.ModelSerializer):
fields = [
'id', 'created_by', 'start_date', 'end_date',
'start_time', 'end_time', 'color', 'name',
'description', 'is_public', 'created_at', 'updated_at',
'is_public', 'created_at', 'updated_at',
]
read_only_fields = ['id', 'created_by', 'created_at', 'updated_at']
@ -23,12 +26,12 @@ class EventCreateSerializer(serializers.ModelSerializer):
model = Event
fields = [
'start_date', 'end_date', 'start_time', 'end_time',
'color', 'name', 'description', 'is_public',
'color', 'name', 'is_public',
]
def validate(self, attrs):
request = self.context.get('request')
if not getattr(getattr(request, 'user', None), 'is_admin', False):
if request is None or not is_admin(request.user):
# En création comme en PATCH, les événements non-admin restent privés.
attrs['is_public'] = False
@ -52,3 +55,29 @@ class EventCreateSerializer(serializers.ModelSerializer):
class EventUpdateSerializer(EventCreateSerializer):
pass
class EventProfileSerializer(serializers.ModelSerializer):
color = serializers.RegexField(
r'^#[0-9a-fA-F]{6}$', required=False,
allow_null=True, allow_blank=True,
)
class Meta:
model = EventProfile
fields = [
'id', 'created_by', 'name',
'start_time', 'end_time', 'color', 'is_public',
'created_at', 'updated_at',
]
read_only_fields = ['id', 'created_by', 'created_at', 'updated_at']
def validate(self, attrs):
request = self.context.get('request')
if request is None or not is_admin(request.user):
# Un utilisateur non-admin ne peut pas mémoriser un profil public.
attrs['is_public'] = False
# Les dates ne font volontairement pas partie d'un profil. On ne peut donc
# pas conclure qu'une heure de fin plus petite correspond à une erreur :
# le profil peut être utilisé pour un événement qui se termine le lendemain.
return attrs

View File

@ -3,6 +3,8 @@ from . import views
urlpatterns = [
path('', views.EventListView.as_view(), name='event-list'),
path('<int:id>/', views.EventDetailView.as_view(), name='event-detail'),
path('create/', views.EventCreateView.as_view(), name='event-create'),
]
path('profiles/', views.EventProfileListCreateView.as_view(), name='event-profile-list'),
path('profiles/<int:id>/', views.EventProfileDetailView.as_view(), name='event-profile-detail'),
path('<int:id>/', views.EventDetailView.as_view(), name='event-detail'),
]

View File

@ -3,8 +3,14 @@ from rest_framework import generics, status
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS
from rest_framework.response import Response
from .models import Event
from .serializers import EventSerializer, EventCreateSerializer, EventUpdateSerializer
from .models import Event, EventProfile
from users.permissions import require_permission
from .serializers import (
EventSerializer,
EventCreateSerializer,
EventUpdateSerializer,
EventProfileSerializer,
)
class IsEventCreatorOrReadOnly(BasePermission):
@ -26,6 +32,7 @@ class EventListView(VisibleEventsMixin, generics.ListCreateAPIView):
permission_classes = [IsAuthenticated]
def create(self, request, *args, **kwargs):
require_permission(request.user, 'create_personal_event')
serializer = EventCreateSerializer(
data=request.data, context=self.get_serializer_context(),
)
@ -66,3 +73,42 @@ class EventDetailView(VisibleEventsMixin, generics.RetrieveUpdateDestroyAPIView)
class EventCreateView(EventListView):
http_method_names = ['post', 'options']
serializer_class = EventCreateSerializer
class EventProfileListCreateView(generics.ListCreateAPIView):
serializer_class = EventProfileSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return EventProfile.objects.filter(created_by=self.request.user)
def list(self, request, *args, **kwargs):
require_permission(request.user, 'create_personal_event')
return super().list(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
require_permission(request.user, 'create_personal_event')
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
profile = serializer.save(created_by=request.user)
return Response(
self.get_serializer(profile).data,
status=status.HTTP_201_CREATED,
)
class EventProfileDetailView(generics.RetrieveUpdateDestroyAPIView):
serializer_class = EventProfileSerializer
permission_classes = [IsAuthenticated]
lookup_url_kwarg = 'id'
def get_queryset(self):
return EventProfile.objects.filter(created_by=self.request.user)
def update(self, request, *args, **kwargs):
require_permission(request.user, 'create_personal_event')
return super().update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
require_permission(request.user, 'create_personal_event')
return super().destroy(request, *args, **kwargs)

View File

@ -0,0 +1,65 @@
# Generated by Django 5.2.17 on 2026-09-30 15:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notes', '0003_remove_note_recipient_note_recipients'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='note',
name='kind',
field=models.CharField(choices=[('task', 'Tâche'), ('info', 'Information'), ('memo', 'Mémo'), ('sugg', 'Suggestion'), ('poll', 'Sondage')], default='memo', max_length=4),
),
migrations.CreateModel(
name='Poll',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('vote_mode', models.CharField(choices=[('single', 'Un seul choix'), ('multiple', 'Plusieurs choix libres'), ('exact', 'Nombre précis de choix')], default='single', max_length=8)),
('exact_choices', models.PositiveSmallIntegerField(default=1)),
('is_anonymous', models.BooleanField(default=True)),
('closes_at', models.DateTimeField(blank=True, null=True)),
('closed_at', models.DateTimeField(blank=True, null=True)),
('locked_at', models.DateTimeField(blank=True, null=True)),
('note', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='poll', to='notes.note')),
],
),
migrations.CreateModel(
name='PollOption',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.CharField(max_length=240)),
('position', models.PositiveSmallIntegerField()),
('poll', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='notes.poll')),
],
options={
'ordering': ['position', 'pk'],
},
),
migrations.CreateModel(
name='PollVote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('options', models.ManyToManyField(related_name='votes', to='notes.polloption')),
('poll', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='notes.poll')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poll_votes', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='polloption',
constraint=models.UniqueConstraint(fields=('poll', 'position'), name='poll_option_position_unique'),
),
migrations.AddConstraint(
model_name='pollvote',
constraint=models.UniqueConstraint(fields=('poll', 'user'), name='poll_user_vote_unique'),
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations
def normalize_polls(apps, schema_editor):
Note = apps.get_model('notes', 'Note')
Poll = apps.get_model('notes', 'Poll')
polls = Note.objects.filter(kind='poll')
polls.update(is_public=True)
for note in polls.iterator():
note.recipients.clear()
Poll.objects.update(closes_at=None)
class Migration(migrations.Migration):
dependencies = [('notes', '0004_alter_note_kind_poll_polloption_pollvote_and_more')]
operations = [
migrations.RunPython(normalize_polls, migrations.RunPython.noop),
]

View File

@ -8,6 +8,7 @@ class Note(models.Model):
INFO = 'info', 'Information'
MEMO = 'memo', 'Mémo'
SUGGESTION = 'sugg', 'Suggestion'
POLL = 'poll', 'Sondage'
title = models.CharField(
max_length=160,
@ -77,4 +78,39 @@ class Note(models.Model):
return self.completed_at is not None
def __str__(self):
return self.title
return self.title
class Poll(models.Model):
class VoteMode(models.TextChoices):
SINGLE = 'single', 'Un seul choix'
MULTIPLE = 'multiple', 'Plusieurs choix libres'
EXACT = 'exact', 'Nombre précis de choix'
note = models.OneToOneField(Note, on_delete=models.CASCADE, related_name='poll')
vote_mode = models.CharField(max_length=8, choices=VoteMode.choices, default=VoteMode.SINGLE)
exact_choices = models.PositiveSmallIntegerField(default=1)
is_anonymous = models.BooleanField(default=True)
closes_at = models.DateTimeField(null=True, blank=True)
closed_at = models.DateTimeField(null=True, blank=True)
locked_at = models.DateTimeField(null=True, blank=True)
class PollOption(models.Model):
poll = models.ForeignKey(Poll, on_delete=models.CASCADE, related_name='options')
label = models.CharField(max_length=240)
position = models.PositiveSmallIntegerField()
class Meta:
ordering = ['position', 'pk']
constraints = [models.UniqueConstraint(fields=['poll', 'position'], name='poll_option_position_unique')]
class PollVote(models.Model):
poll = models.ForeignKey(Poll, on_delete=models.CASCADE, related_name='votes')
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='poll_votes')
options = models.ManyToManyField(PollOption, related_name='votes')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [models.UniqueConstraint(fields=['poll', 'user'], name='poll_user_vote_unique')]

View File

@ -0,0 +1,61 @@
from django.db import transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Note, Poll, PollVote
from .permissions import can_edit_note
from .polls import can_vote, is_closed
from .serializers import NoteSerializer
from .views import visible_notes
class PollVoteView(APIView):
permission_classes = [IsAuthenticated]
@transaction.atomic
def put(self, request, pk):
note = get_object_or_404(visible_notes(request.user), pk=pk, kind='poll')
get_object_or_404(Note.objects.select_for_update(), pk=note.pk)
# Relire après le verrou : règles, droits et clôture peuvent avoir changé.
note = get_object_or_404(visible_notes(request.user), pk=pk, kind='poll')
poll = get_object_or_404(Poll, note=note)
if is_closed(poll):
raise ValidationError('Ce sondage est clôturé.')
if not can_vote(request.user, note):
raise PermissionDenied('Ce sondage est réservé aux destinataires indiqués.')
choices = serializers.ListField(child=serializers.IntegerField(min_value=1), min_length=1, max_length=30).run_validation(request.data.get('option_ids'))
if len(set(choices)) != len(choices) or poll.options.filter(pk__in=choices).count() != len(choices):
raise ValidationError('Choisissez des réponses distinctes appartenant à ce sondage.')
expected = 1 if poll.vote_mode == 'single' else poll.exact_choices if poll.vote_mode == 'exact' else None
if expected is not None and len(choices) != expected:
raise ValidationError(f'Sélectionnez exactement {expected} réponse(s).')
vote, _ = PollVote.objects.get_or_create(poll=poll, user=request.user)
vote.options.set(choices)
vote.save()
if not poll.locked_at:
poll.locked_at = timezone.now()
poll.save(update_fields=['locked_at'])
fresh = visible_notes(request.user).get(pk=pk)
return Response(NoteSerializer(fresh, context={'request': request}).data)
class PollCloseView(APIView):
permission_classes = [IsAuthenticated]
@transaction.atomic
def post(self, request, pk):
note = get_object_or_404(visible_notes(request.user), pk=pk, kind='poll')
get_object_or_404(Note.objects.select_for_update(), pk=note.pk)
note = get_object_or_404(visible_notes(request.user), pk=pk, kind='poll')
if not can_edit_note(request.user, note):
raise PermissionDenied('Seuls l’auteur et les administrateurs autorisés peuvent clôturer ce sondage.')
poll = get_object_or_404(Poll, note=note)
if not poll.closed_at:
poll.closed_at = timezone.now()
poll.save(update_fields=['closed_at'])
return Response(NoteSerializer(visible_notes(request.user).get(pk=pk), context={'request': request}).data)

111
backend/notes/polls.py Normal file
View File

@ -0,0 +1,111 @@
"""Règles des sondages. L'anonymat est appliqué avant toute sérialisation API."""
from rest_framework import serializers
from .models import Poll, PollOption
class PollSettingsSerializer(serializers.Serializer):
vote_mode = serializers.ChoiceField(choices=Poll.VoteMode.choices, default='single')
exact_choices = serializers.IntegerField(min_value=1, max_value=30, default=1)
is_anonymous = serializers.BooleanField(default=True)
# Compatibilité API : une ancienne app peut encore envoyer closes_at, mais il est ignoré.
closes_at = serializers.DateTimeField(allow_null=True, required=False, write_only=True)
options = serializers.ListField(child=serializers.CharField(max_length=240, trim_whitespace=True), min_length=2, max_length=30)
def validate(self, attrs):
attrs.pop('closes_at', None)
# DRF n'applique pas les valeurs par défaut imbriquées lors d'un PATCH.
existing = getattr(getattr(self.root, 'instance', None), 'poll', None)
defaults = dict(vote_mode='single', exact_choices=1, is_anonymous=True)
if existing:
defaults = {key: getattr(existing, key) for key in defaults}
defaults['options'] = [option.label for option in existing.options.all()]
attrs = {**defaults, **attrs}
if 'options' not in attrs:
raise serializers.ValidationError({'options': 'Ajoutez au moins deux réponses.'})
labels = [label.casefold() for label in attrs['options']]
if len(set(labels)) != len(labels):
raise serializers.ValidationError({'options': 'Chaque réponse proposée doit être différente.'})
if attrs['vote_mode'] == 'exact' and attrs['exact_choices'] > len(labels):
raise serializers.ValidationError({'exact_choices': 'Le nombre de choix ne peut pas dépasser le nombre de réponses proposées.'})
if attrs['vote_mode'] != 'exact':
attrs['exact_choices'] = 1
return attrs
def is_closed(poll):
return bool(poll.closed_at)
def can_vote(user, note):
recipients = list(note.recipients.all())
return bool(user.is_authenticated and user.is_active and
(any(person.pk == user.pk for person in recipients) if recipients else note.is_public))
def person(user):
if user is None:
return {'id': None, 'username': 'Compte supprimé', 'first_name': '', 'last_name': ''}
return {key: getattr(user, key) for key in ['id', 'username', 'first_name', 'last_name']}
def poll_representation(note, user):
if note.kind != 'poll':
return None
try:
poll = note.poll
except Poll.DoesNotExist:
return None
votes = list(poll.votes.all())
total = len(votes)
selections = {vote.pk: {option.pk for option in vote.options.all()} for vote in votes}
own = next((vote for vote in votes if vote.user_id == user.pk), None)
options = []
for option in poll.options.all():
selected = [vote for vote in votes if option.pk in selections[vote.pk]]
item = dict(id=option.pk, label=option.label, count=len(selected), percent=round(len(selected) * 100 / total, 1) if total else 0)
if not poll.is_anonymous:
item['voters'] = [person(vote.user) for vote in selected]
options.append(item)
return dict(vote_mode=poll.vote_mode, exact_choices=poll.exact_choices, is_anonymous=poll.is_anonymous,
closes_at=None, is_closed=is_closed(poll), is_locked=bool(poll.locked_at),
can_vote=can_vote(user, note) and not is_closed(poll), total_voters=total,
my_option_ids=sorted(selections[own.pk]) if own else [], options=options)
def validate_poll_note(instance, attrs):
kind = attrs.get('kind', instance.kind if instance else 'memo')
data = attrs.get('poll_settings')
poll = getattr(instance, 'poll', None) if instance else None
if kind == 'poll' and not poll and data is None:
raise serializers.ValidationError({'poll_settings': 'Renseignez les paramètres et les réponses du sondage.'})
if kind != 'poll' and data is not None:
raise serializers.ValidationError({'poll_settings': 'Ces paramètres sont réservés aux sondages.'})
if poll and poll.locked_at:
if kind != 'poll' or attrs.get('title', instance.title) != instance.title:
raise serializers.ValidationError('La question et le type sont figés après le premier vote.')
if attrs.get('is_public', instance.is_public) != instance.is_public:
raise serializers.ValidationError('La visibilité est figée après le premier vote.')
if 'recipients' in attrs and {u.pk for u in attrs['recipients']} != {u.pk for u in instance.recipients.all()}:
raise serializers.ValidationError('Les participants sont figés après le premier vote.')
if data is not None:
for key in ['vote_mode', 'exact_choices', 'is_anonymous']:
if data[key] != getattr(poll, key):
raise serializers.ValidationError('Les règles et l’anonymat sont figés après le premier vote.')
if data['options'] != [o.label for o in poll.options.all()]:
raise serializers.ValidationError('Les réponses proposées sont figées après le premier vote.')
def configure_poll(note, data):
if note.kind != 'poll':
Poll.objects.filter(note=note).delete()
return
if data is None:
return
values = {key: data[key] for key in ['vote_mode', 'exact_choices', 'is_anonymous']}
values['closes_at'] = None
poll, _ = Poll.objects.update_or_create(note=note, defaults=values)
current = [o.label for o in poll.options.all()]
if current != data['options']:
poll.options.all().delete()
PollOption.objects.bulk_create([PollOption(poll=poll, label=label, position=i) for i, label in enumerate(data['options'])])

View File

@ -1,7 +1,9 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from rest_framework import serializers
from .models import Note
from .polls import PollSettingsSerializer, poll_representation, validate_poll_note, configure_poll
from .permissions import (
can_complete_note,
can_edit_note,
@ -29,6 +31,12 @@ class NoteUserSerializer(serializers.ModelSerializer):
class NoteSerializer(serializers.ModelSerializer):
poll_settings = PollSettingsSerializer(required=False, write_only=True)
poll = serializers.SerializerMethodField()
def get_poll(self, obj):
return poll_representation(obj, self.context['request'].user)
author = NoteUserSerializer(
read_only=True,
)
@ -71,6 +79,8 @@ class NoteSerializer(serializers.ModelSerializer):
'updated_at',
'can_edit',
'can_complete',
'poll',
'poll_settings',
]
read_only_fields = [
@ -93,6 +103,11 @@ class NoteSerializer(serializers.ModelSerializer):
)
def validate(self, attrs):
kind = attrs.get('kind', self.instance.kind if self.instance else Note.Kind.MEMO)
if kind == Note.Kind.POLL:
attrs['is_public'] = True
attrs['recipients'] = []
validate_poll_note(self.instance, attrs)
is_public = attrs.get(
'is_public',
self.instance.is_public
@ -120,26 +135,25 @@ class NoteSerializer(serializers.ModelSerializer):
return attrs
def update(
self,
instance,
validated_data,
):
if (
validated_data.get(
'kind',
instance.kind,
)
!= Note.Kind.TASK
):
validated_data[
'completed_at'
] = None
@transaction.atomic
def create(self, validated_data):
poll_settings = validated_data.pop('poll_settings', None)
note = super().create(validated_data)
configure_poll(note, poll_settings)
return note
return super().update(
instance,
validated_data,
)
@transaction.atomic
def update(self, instance, validated_data):
poll_settings = validated_data.pop('poll_settings', None)
if validated_data.get('kind', instance.kind) != Note.Kind.TASK:
validated_data['completed_at'] = None
note = super().update(instance, validated_data)
configure_poll(note, poll_settings)
# Éviter une représentation basée sur les anciennes options préchargées.
note._prefetched_objects_cache = {}
if 'poll' in note._state.fields_cache:
del note._state.fields_cache['poll']
return note
class NoteCompletionSerializer(

View File

@ -1,3 +1,123 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APITestCase
# Create your tests here.
from .models import Note, Poll, PollVote
class PollAPITests(APITestCase):
def setUp(self):
self.author = get_user_model().objects.create_user(username='author')
self.voter = get_user_model().objects.create_user(username='voter')
self.other = get_user_model().objects.create_user(username='other')
self.admin = get_user_model().objects.create_user(username='admin', is_staff=True)
self.client.force_authenticate(self.author)
def create_poll(self, **settings):
payload = dict(title='Question ?', content='', kind='poll', is_public=True, recipient_ids=[],
poll_settings=dict(vote_mode='single', exact_choices=1, is_anonymous=True, closes_at=None, options=['A', 'B', 'C']))
payload['poll_settings'].update(settings)
response = self.client.post('/api/notes/', payload, format='json')
self.assertEqual(response.status_code, 201, response.data)
return response.data
def vote(self, note, choices, user=None):
self.client.force_authenticate(user or self.voter)
return self.client.put(f"/api/notes/{note['id']}/vote/", {'option_ids': [note['poll']['options'][i]['id'] for i in choices]}, format='json')
def test_single_vote_is_replaced_and_results_stay_anonymous(self):
note = self.create_poll()
self.assertEqual(self.vote(note, [0, 1]).status_code, 400)
result = self.vote(note, [0])
self.assertEqual(result.status_code, 200, result.data)
result = self.vote(note, [1])
self.assertEqual(result.data['poll']['total_voters'], 1)
self.assertEqual([o['count'] for o in result.data['poll']['options']], [0, 1, 0])
for viewer in [self.author, self.admin, self.other]:
self.client.force_authenticate(viewer)
data = self.client.get(f"/api/notes/{note['id']}/").data['poll']
self.assertEqual(data['total_voters'], 1)
self.assertFalse(any('voters' in option for option in data['options']))
def test_anonymous_api_never_contains_voter_identity(self):
note = self.create_poll()
self.vote(note, [0])
for viewer in [self.author, self.admin, self.other]:
self.client.force_authenticate(viewer)
poll = self.client.get(f"/api/notes/{note['id']}/").data['poll']
self.assertEqual(poll['my_option_ids'], [])
for option in poll['options']:
self.assertNotIn('voters', option)
self.assertNotIn('user', option)
self.assertNotIn('username', str(poll))
def test_named_multiple_votes(self):
note = self.create_poll(vote_mode='multiple', is_anonymous=False)
result = self.vote(note, [0, 2])
self.assertEqual(result.status_code, 200, result.data)
self.assertEqual(result.data['poll']['options'][0]['voters'][0]['id'], self.voter.pk)
self.assertEqual(result.data['poll']['options'][0]['percent'], 100)
self.assertEqual(result.data['poll']['total_voters'], 1)
def test_exact_count_and_cross_poll_option_validation(self):
note = self.create_poll(vote_mode='exact', exact_choices=2)
self.assertEqual(self.vote(note, [0]).status_code, 400)
self.assertEqual(self.vote(note, [0, 1, 2]).status_code, 400)
self.assertEqual(self.vote(note, [0, 1]).status_code, 200)
self.client.force_authenticate(self.author)
other = self.create_poll()
self.client.force_authenticate(self.voter)
result = self.client.put(f"/api/notes/{note['id']}/vote/", {'option_ids': [note['poll']['options'][0]['id'], other['poll']['options'][0]['id']]}, format='json')
self.assertEqual(result.status_code, 400)
def test_rules_question_and_visibility_lock_after_first_vote(self):
note = self.create_poll()
self.vote(note, [0])
self.client.force_authenticate(self.author)
for payload in [dict(kind='info'), dict(title='Autre question'),
dict(poll_settings=dict(vote_mode='single', exact_choices=1, is_anonymous=False, closes_at=None, options=['A', 'B', 'C']))]:
result = self.client.patch(f"/api/notes/{note['id']}/", payload, format='json')
self.assertEqual(result.status_code, 400, result.data)
self.assertTrue(Poll.objects.get().is_anonymous)
self.assertEqual(Note.objects.get().kind, 'poll')
self.assertEqual(self.client.patch(f"/api/notes/{note['id']}/", {'content': 'Précision'}, format='json').status_code, 200)
def test_closed_and_deadline_polls_reject_votes(self):
note = self.create_poll()
self.client.force_authenticate(self.other)
self.assertEqual(self.client.post(f"/api/notes/{note['id']}/close/").status_code, 403)
self.client.force_authenticate(self.author)
self.assertEqual(self.client.post(f"/api/notes/{note['id']}/close/").status_code, 200)
self.assertEqual(self.vote(note, [0]).status_code, 400)
self.client.force_authenticate(self.author)
expired = self.create_poll(closes_at='2020-01-01T00:00:00Z')
self.assertEqual(self.vote(expired, [0]).status_code, 200)
def test_poll_scope_and_recipients_are_always_public_for_everyone(self):
note = self.create_poll()
response = self.client.patch(f"/api/notes/{note['id']}/", {'is_public': False, 'recipient_ids': [self.voter.pk]}, format='json')
self.assertEqual(response.status_code, 200, response.data)
self.assertTrue(response.data['is_public'])
self.assertEqual(response.data['recipients'], [])
self.client.force_authenticate(self.other)
self.assertEqual(self.client.get(f"/api/notes/{note['id']}/").status_code, 200)
self.assertEqual(self.vote(note, [0], self.other).status_code, 200)
def test_new_user_can_participate_in_public_poll_for_everyone(self):
note = self.create_poll()
new_user = get_user_model().objects.create_user(username='new')
self.assertEqual(self.vote(note, [1], new_user).status_code, 200)
def test_convert_existing_memo_to_poll_and_back_before_votes(self):
note = Note.objects.create(author=self.author, title='Mémo')
result = self.client.patch(f'/api/notes/{note.pk}/', dict(kind='poll', poll_settings=dict(options=['Oui', 'Non'])), format='json')
self.assertEqual(result.status_code, 200, result.data)
self.assertEqual(len(result.data['poll']['options']), 2)
result = self.client.patch(f'/api/notes/{note.pk}/', dict(kind='memo'), format='json')
self.assertEqual(result.status_code, 200)
self.assertIsNone(result.data['poll'])
self.assertFalse(Poll.objects.exists())
def test_invalid_poll_does_not_leave_note_behind(self):
result = self.client.post('/api/notes/', dict(kind='poll', title='Test', poll_settings=dict(options=['A', 'A'])), format='json')
self.assertEqual(result.status_code, 400)
self.assertFalse(Note.objects.exists())

View File

@ -2,7 +2,11 @@ from django.urls import path
from .views import NoteCompletionView, NoteDetailView, NoteListView, NoteRecipientListView
from .poll_views import PollVoteView, PollCloseView
urlpatterns = [
path('<int:pk>/vote/', PollVoteView.as_view(), name='poll-vote'),
path('<int:pk>/close/', PollCloseView.as_view(), name='poll-close'),
path('', NoteListView.as_view(), name='note-list'),
path('recipients/', NoteRecipientListView.as_view(), name='note-recipients'),
path('<int:pk>/', NoteDetailView.as_view(), name='note-detail'),

View File

@ -15,6 +15,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Note
from users.permissions import require_permission
from .permissions import (
can_complete_note,
can_edit_note,
@ -40,8 +41,8 @@ def visible_notes(user):
return (
Note.objects
.select_related('author')
.prefetch_related('recipients')
.select_related('author', 'poll')
.prefetch_related('recipients', 'poll__options', 'poll__votes__options', 'poll__votes__user')
.filter(
Q(is_public=True)
| Q(author=user)
@ -169,6 +170,7 @@ class NoteListView(
self,
serializer,
):
require_permission(self.request.user, 'create_publication')
serializer.save(
author=self.request.user,
)
@ -218,6 +220,7 @@ class NoteDetailView(
*args,
**kwargs,
):
Note.objects.select_for_update().get(pk=self.get_object().pk)
return super().update(
request,
*args,

View File

6
backend/planning/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PlanningConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'planning'

View File

@ -0,0 +1,124 @@
# Generated by Django 5.2.17 on 2026-09-30 15:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DayChangeRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(choices=[('leave', 'Congé / absence'), ('hours', 'Modification d’horaires'), ('swap', 'Inversion de journées')], max_length=8)),
('start_date', models.DateField()),
('end_date', models.DateField()),
('target_date', models.DateField(blank=True, null=True)),
('reason', models.TextField(blank=True, max_length=2000)),
('proposed_slots', models.JSONField(default=list)),
('before', models.JSONField(default=list)),
('after', models.JSONField(default=list)),
('status', models.CharField(choices=[('pending', 'En attente'), ('approved', 'Acceptée'), ('rejected', 'Refusée'), ('cancelled', 'Annulée')], default='pending', max_length=10)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('response', models.TextField(blank=True, max_length=2000)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('target_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='received_swaps', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='day_requests', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
},
),
migrations.CreateModel(
name='PlanningState',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('revision', models.PositiveIntegerField(default=0)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ScheduleException',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.CharField(blank=True, max_length=160)),
('kind', models.CharField(choices=[('work', 'Horaires exceptionnels'), ('rest', 'Repos'), ('absence', 'Congé / absence')], default='work', max_length=8)),
('start_date', models.DateField()),
('end_date', models.DateField(blank=True, null=True)),
('recurring', models.BooleanField(default=False)),
('weekdays', models.JSONField(default=list)),
('every_weeks', models.PositiveSmallIntegerField(default=1)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('request', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applied_exceptions', to='planning.daychangerequest')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedule_exceptions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-pk'],
},
),
migrations.CreateModel(
name='ExceptionSlot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('start_time', models.TimeField()),
('end_time', models.TimeField()),
('end_day_offset', models.PositiveSmallIntegerField(default=0)),
('exception', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='planning.scheduleexception')),
],
options={
'ordering': ['start_time'],
},
),
migrations.CreateModel(
name='WorkSchedule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('valid_from', models.DateField()),
('valid_to', models.DateField(blank=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='work_schedules', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-valid_from', '-pk'],
},
),
migrations.CreateModel(
name='WorkScheduleSlot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('weekday', models.PositiveSmallIntegerField()),
('start_time', models.TimeField()),
('end_time', models.TimeField()),
('end_day_offset', models.PositiveSmallIntegerField(default=0)),
('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slots', to='planning.workschedule')),
],
options={
'ordering': ['weekday', 'start_time'],
},
),
migrations.AddIndex(
model_name='daychangerequest',
index=models.Index(fields=['status', 'user'], name='request_status_user_idx'),
),
migrations.AddIndex(
model_name='scheduleexception',
index=models.Index(fields=['user', 'start_date'], name='exception_user_date_idx'),
),
migrations.AddConstraint(
model_name='workschedule',
constraint=models.UniqueConstraint(fields=('user', 'valid_from'), name='schedule_user_start_unique'),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.2.17 on 2026-09-30 23:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('planning', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='workscheduleslot',
options={'ordering': ['week_index', 'weekday', 'start_time']},
),
migrations.AddField(
model_name='workschedule',
name='cycle_weeks',
field=models.PositiveSmallIntegerField(default=1),
),
migrations.AddField(
model_name='workscheduleslot',
name='week_index',
field=models.PositiveSmallIntegerField(default=0),
),
]

View File

@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [('planning', '0002_alter_workscheduleslot_options_and_more')]
operations = [
migrations.AlterField(
model_name='daychangerequest',
name='kind',
field=models.CharField(choices=[('leave', 'Congé / absence'), ('hours', 'Modification d’horaires'), ('move', 'Déplacement de journée'), ('swap', 'Échange de journée')], max_length=8),
),
]

View File

@ -0,0 +1,50 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('planning', '0003_alter_daychangerequest_kind'),
]
operations = [
migrations.AddField(
model_name='workschedule',
name='mode',
field=models.CharField(
choices=[
('precise', 'Horaires précis'),
('daily_hours', 'Nombre d’heures par jour'),
('weekly_hours', 'Nombre d’heures par semaine'),
],
default='precise',
max_length=16,
),
),
migrations.AddField(
model_name='workschedule',
name='day_targets',
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name='workschedule',
name='week_targets',
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name='scheduleexception',
name='mode',
field=models.CharField(
choices=[
('precise', 'Horaires précis'),
('daily_hours', 'Nombre d’heures à effectuer'),
],
default='precise',
max_length=16,
),
),
migrations.AddField(
model_name='scheduleexception',
name='target_minutes',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
]

View File

115
backend/planning/models.py Normal file
View File

@ -0,0 +1,115 @@
from django.conf import settings
from django.db import models
class PlanningState(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
revision = models.PositiveIntegerField(default=0)
class WorkSchedule(models.Model):
class Mode(models.TextChoices):
PRECISE = 'precise', 'Horaires précis'
DAILY_HOURS = 'daily_hours', 'Nombre d’heures par jour'
WEEKLY_HOURS = 'weekly_hours', 'Nombre d’heures par semaine'
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='work_schedules')
valid_from = models.DateField()
cycle_weeks = models.PositiveSmallIntegerField(default=1)
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.PRECISE)
day_targets = models.JSONField(default=list, blank=True)
week_targets = models.JSONField(default=list, blank=True)
valid_to = models.DateField(null=True, blank=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='+')
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-valid_from', '-pk']
constraints = [models.UniqueConstraint(fields=['user', 'valid_from'], name='schedule_user_start_unique')]
class WorkScheduleSlot(models.Model):
schedule = models.ForeignKey(WorkSchedule, on_delete=models.CASCADE, related_name='slots')
weekday = models.PositiveSmallIntegerField() # lundi = 0
week_index = models.PositiveSmallIntegerField(default=0)
start_time = models.TimeField()
end_time = models.TimeField()
end_day_offset = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ['week_index', 'weekday', 'start_time']
class ScheduleException(models.Model):
class Mode(models.TextChoices):
PRECISE = 'precise', 'Horaires précis'
DAILY_HOURS = 'daily_hours', 'Nombre d’heures à effectuer'
class Kind(models.TextChoices):
WORK = 'work', 'Horaires exceptionnels'
REST = 'rest', 'Repos'
ABSENCE = 'absence', 'Congé / absence'
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='schedule_exceptions')
label = models.CharField(max_length=160, blank=True)
kind = models.CharField(max_length=8, choices=Kind.choices, default=Kind.WORK)
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.PRECISE)
target_minutes = models.PositiveSmallIntegerField(null=True, blank=True)
start_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
recurring = models.BooleanField(default=False)
weekdays = models.JSONField(default=list)
every_weeks = models.PositiveSmallIntegerField(default=1)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='+')
request = models.ForeignKey('DayChangeRequest', null=True, blank=True, on_delete=models.SET_NULL, related_name='applied_exceptions')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-pk']
indexes = [models.Index(fields=['user', 'start_date'], name='exception_user_date_idx')]
class ExceptionSlot(models.Model):
exception = models.ForeignKey(ScheduleException, on_delete=models.CASCADE, related_name='slots')
start_time = models.TimeField()
end_time = models.TimeField()
end_day_offset = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ['start_time']
class DayChangeRequest(models.Model):
class Kind(models.TextChoices):
LEAVE = 'leave', 'Congé / absence'
HOURS = 'hours', 'Modification d’horaires'
MOVE = 'move', 'Déplacement de journée'
SWAP = 'swap', 'Échange de journée'
class Status(models.TextChoices):
PENDING = 'pending', 'En attente'
APPROVED = 'approved', 'Acceptée'
REJECTED = 'rejected', 'Refusée'
CANCELLED = 'cancelled', 'Annulée'
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='day_requests')
kind = models.CharField(max_length=8, choices=Kind.choices)
start_date = models.DateField()
end_date = models.DateField()
target_date = models.DateField(null=True, blank=True)
target_user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='received_swaps')
reason = models.TextField(blank=True, max_length=2000)
proposed_slots = models.JSONField(default=list)
before = models.JSONField(default=list)
after = models.JSONField(default=list)
status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING)
reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='+')
reviewed_at = models.DateTimeField(null=True, blank=True)
response = models.TextField(blank=True, max_length=2000)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at', '-pk']
indexes = [models.Index(fields=['status', 'user'], name='request_status_user_idx')]

View File

@ -0,0 +1,194 @@
from datetime import date
from django.contrib.auth import get_user_model
from rest_framework import serializers
from notes.serializers import NoteUserSerializer
from users.permissions import require_permission
from .models import WorkSchedule, WorkScheduleSlot, ScheduleException, ExceptionSlot, DayChangeRequest
def checked_dates(start, end):
if start < date(2000, 1, 1) or start > date(2099, 1, 1) or (end and (end < start or end > date(2099, 12, 31))):
raise serializers.ValidationError('Choisissez des dates valides, dans l’ordre, entre 2000 et 2099.')
class SlotInput(serializers.Serializer):
start_time = serializers.TimeField(input_formats=['%H:%M', '%H:%M:%S'])
end_time = serializers.TimeField(input_formats=['%H:%M', '%H:%M:%S'])
end_day_offset = serializers.IntegerField(min_value=0, max_value=1, default=0)
def validate(self, attrs):
start, end = attrs['start_time'], attrs['end_time']
if start.second or end.second or start.microsecond or end.microsecond:
raise serializers.ValidationError('Les horaires doivent être précis à la minute.')
duration = (end.hour * 60 + end.minute) + attrs['end_day_offset'] * 1440 - (start.hour * 60 + start.minute)
if not 0 < duration <= 1440:
raise serializers.ValidationError('Une plage de travail doit durer entre une minute et 24 heures. Vérifiez le jour et l’heure de fin.')
return attrs
class WeeklySlotInput(SlotInput):
weekday = serializers.IntegerField(min_value=0, max_value=6)
week_index = serializers.IntegerField(min_value=0, max_value=51, default=0)
class DayTargetInput(serializers.Serializer):
weekday = serializers.IntegerField(min_value=0, max_value=6)
week_index = serializers.IntegerField(min_value=0, max_value=51, default=0)
minutes = serializers.IntegerField(min_value=0, max_value=1440)
class WeekTargetInput(serializers.Serializer):
week_index = serializers.IntegerField(min_value=0, max_value=51)
minutes = serializers.IntegerField(min_value=0, max_value=10080)
class ScheduleInput(serializers.Serializer):
user_id = serializers.PrimaryKeyRelatedField(source='user', queryset=get_user_model().objects.filter(is_active=True))
valid_from = serializers.DateField()
cycle_weeks = serializers.IntegerField(min_value=1, max_value=52, default=1)
mode = serializers.ChoiceField(choices=WorkSchedule.Mode.choices, default=WorkSchedule.Mode.PRECISE)
day_targets = DayTargetInput(many=True, allow_empty=True, max_length=364, default=list)
week_targets = WeekTargetInput(many=True, allow_empty=True, max_length=52, default=list)
expected_revision = serializers.IntegerField(min_value=0)
slots = WeeklySlotInput(many=True, allow_empty=True, max_length=3640)
def validate(self, attrs):
checked_dates(attrs['valid_from'], None)
counts = {}
for slot in attrs['slots']:
if slot['week_index'] >= attrs['cycle_weeks']:
raise serializers.ValidationError('Une plage appartient à une semaine absente du cycle.')
key = (slot['week_index'], slot['weekday'])
counts[key] = counts.get(key, 0) + 1
if counts[key] > 10:
raise serializers.ValidationError('Une journée peut contenir au maximum 10 plages de travail.')
day_keys = set()
for target in attrs['day_targets']:
if target['week_index'] >= attrs['cycle_weeks']:
raise serializers.ValidationError({'day_targets': 'Un objectif journalier appartient à une semaine absente du cycle.'})
key = (target['week_index'], target['weekday'])
if key in day_keys:
raise serializers.ValidationError({'day_targets': 'Un jour ne peut avoir qu’un seul nombre d’heures prévu.'})
day_keys.add(key)
week_keys = set()
for target in attrs['week_targets']:
if target['week_index'] >= attrs['cycle_weeks']:
raise serializers.ValidationError({'week_targets': 'Un objectif hebdomadaire appartient à une semaine absente du cycle.'})
if target['week_index'] in week_keys:
raise serializers.ValidationError({'week_targets': 'Une semaine ne peut avoir qu’un seul total d’heures prévu.'})
week_keys.add(target['week_index'])
return attrs
class SlotSerializer(serializers.ModelSerializer):
class Meta:
model = WorkScheduleSlot
fields = ['week_index', 'weekday', 'start_time', 'end_time', 'end_day_offset']
class ScheduleSerializer(serializers.ModelSerializer):
slots = SlotSerializer(many=True, read_only=True)
class Meta:
model = WorkSchedule
fields = ['id', 'user_id', 'valid_from', 'valid_to', 'cycle_weeks', 'mode', 'day_targets', 'week_targets', 'slots', 'updated_at']
class ExceptionSlotSerializer(serializers.ModelSerializer):
class Meta:
model = ExceptionSlot
fields = ['start_time', 'end_time', 'end_day_offset']
class ExceptionSerializer(serializers.ModelSerializer):
slots = ExceptionSlotSerializer(many=True, read_only=True)
class Meta:
model = ScheduleException
fields = ['id', 'user_id', 'label', 'kind', 'mode', 'target_minutes', 'start_date', 'end_date', 'recurring', 'weekdays', 'every_weeks', 'slots', 'request_id']
class ExceptionInput(serializers.Serializer):
user_id = serializers.PrimaryKeyRelatedField(source='user', queryset=get_user_model().objects.filter(is_active=True))
expected_revision = serializers.IntegerField(min_value=0)
label = serializers.CharField(max_length=160, allow_blank=True, default='')
kind = serializers.ChoiceField(choices=ScheduleException.Kind.choices)
start_date = serializers.DateField()
end_date = serializers.DateField(allow_null=True, default=None)
recurring = serializers.BooleanField(default=False)
weekdays = serializers.ListField(child=serializers.IntegerField(min_value=0, max_value=6), max_length=7, default=list)
every_weeks = serializers.IntegerField(min_value=1, max_value=4, default=1)
slots = SlotInput(many=True, allow_empty=True, max_length=10, default=list)
def validate(self, attrs):
if not attrs['recurring'] and not attrs['end_date']:
attrs['end_date'] = attrs['start_date']
checked_dates(attrs['start_date'], attrs['end_date'])
if attrs['recurring'] and not attrs['weekdays']:
raise serializers.ValidationError({'weekdays': 'Choisissez au moins un jour de répétition.'})
attrs['weekdays'] = sorted(set(attrs['weekdays'])) if attrs['recurring'] else []
if attrs['kind'] == 'work' and not attrs['slots']:
raise serializers.ValidationError({'slots': 'Ajoutez au moins un créneau.'})
if attrs['kind'] != 'work' and attrs['slots']:
raise serializers.ValidationError({'slots': 'Un repos ou un congé ne comporte aucun créneau de travail.'})
return attrs
class RequestInput(serializers.Serializer):
expected_before = serializers.JSONField(required=False, write_only=True)
kind = serializers.ChoiceField(choices=DayChangeRequest.Kind.choices)
start_date = serializers.DateField()
end_date = serializers.DateField(required=False)
target_date = serializers.DateField(required=False, allow_null=True)
target_user_id = serializers.PrimaryKeyRelatedField(source='target_user', queryset=get_user_model().objects.filter(is_active=True), required=False, allow_null=True)
reason = serializers.CharField(max_length=2000, allow_blank=True, default='')
proposed_slots = SlotInput(many=True, allow_empty=True, max_length=10, default=list)
def validate(self, attrs):
if attrs['kind'] == 'leave':
require_permission(self.context['request'].user, 'request_leave')
attrs['end_date'] = attrs.get('end_date') or attrs['start_date']
checked_dates(attrs['start_date'], attrs['end_date'])
if (attrs['end_date'] - attrs['start_date']).days > 365:
raise serializers.ValidationError('Une demande peut couvrir au maximum 366 jours.')
if attrs['kind'] == 'swap':
if attrs['end_date'] != attrs['start_date']:
raise serializers.ValidationError('Un échange concerne une seule journée de votre planning.')
other = attrs.get('target_user')
if not other or other.pk == self.context['request'].user.pk:
raise serializers.ValidationError({'target_user_id': 'Choisissez un autre employé pour l’échange.'})
if attrs.get('target_date'):
checked_dates(attrs['target_date'], None)
attrs['target_user'] = other
elif attrs['kind'] == 'move':
if attrs['end_date'] != attrs['start_date'] or not attrs.get('target_date'):
raise serializers.ValidationError('Un déplacement concerne une journée de départ et une journée d’arrivée précises.')
checked_dates(attrs['target_date'], None)
if attrs['target_date'] == attrs['start_date']:
raise serializers.ValidationError({'target_date': 'Choisissez un autre jour pour déplacer ces heures.'})
if attrs.get('target_user'):
raise serializers.ValidationError({'target_user_id': 'Un déplacement reste sur votre propre planning.'})
elif attrs.get('target_date') or attrs.get('target_user'):
raise serializers.ValidationError('La seconde journée est réservée aux échanges et déplacements.')
if attrs['kind'] in ('hours', 'move') and not attrs['proposed_slots']:
raise serializers.ValidationError({'proposed_slots': 'Ajoutez les horaires souhaités.'})
if attrs['kind'] not in ('hours', 'move') and attrs['proposed_slots']:
raise serializers.ValidationError('Ces créneaux ne sont pas utilisés pour ce type de demande.')
# Le JSON stocké utilise les mêmes horaires locaux que le planning.
attrs['proposed_slots'] = [dict(start_time=s['start_time'].strftime('%H:%M'), end_time=s['end_time'].strftime('%H:%M'), end_day_offset=s['end_day_offset']) for s in attrs['proposed_slots']]
return attrs
class RequestSerializer(serializers.ModelSerializer):
user = NoteUserSerializer(read_only=True)
target_user = NoteUserSerializer(read_only=True)
reviewed_by = NoteUserSerializer(read_only=True)
class Meta:
model = DayChangeRequest
fields = ['id', 'user', 'kind', 'start_date', 'end_date', 'target_date', 'target_user', 'reason', 'proposed_slots', 'before', 'after', 'status', 'reviewed_by', 'reviewed_at', 'response', 'created_at', 'updated_at']
read_only_fields = fields

View File

@ -0,0 +1,356 @@
"""Calcul unique des horaires prévus, partagé par la gestion et le récapitulatif."""
from copy import deepcopy
from datetime import date, datetime, time, timedelta
from math import lcm
from django.contrib.auth import get_user_model
from django.db import transaction
from rest_framework.exceptions import APIException, ValidationError
from .models import PlanningState, WorkSchedule, WorkScheduleSlot, ScheduleException, ExceptionSlot
class PlanningConflict(APIException):
status_code = 409
default_detail = 'Le planning a changé. Actualisez avant de réessayer.'
def lock_users(ids):
# Ordre stable pour un échange entre deux salariés (évite les interblocages).
list(get_user_model().objects.select_for_update().filter(pk__in=sorted(set(ids))).order_by('pk'))
def state_for(user_id):
return PlanningState.objects.get_or_create(user_id=user_id)[0]
def check_revision(user_id, expected):
state = state_for(user_id)
if state.revision != expected:
raise PlanningConflict()
return state
def bump_revision(user_id):
state = state_for(user_id)
state.revision += 1
state.save(update_fields=['revision'])
def slot_data(slot):
return dict(start_time=slot.start_time.strftime('%H:%M'), end_time=slot.end_time.strftime('%H:%M'), end_day_offset=slot.end_day_offset)
def slot_minutes(slot):
start_minutes = int(slot['start_time'][:2]) * 60 + int(slot['start_time'][3:5])
end_minutes = int(slot['end_time'][:2]) * 60 + int(slot['end_time'][3:5]) + int(slot.get('end_day_offset', 0)) * 1440
return end_minutes - start_minutes
def date_range(start, end):
while start <= end:
yield start
start += timedelta(days=1)
def load_plan(user_id):
return (
list(WorkSchedule.objects.filter(user_id=user_id).prefetch_related('slots')),
list(ScheduleException.objects.filter(user_id=user_id).prefetch_related('slots')),
)
def rule_matches(rule, day):
if day < rule.start_date or (rule.end_date and day > rule.end_date):
return False
if not rule.recurring:
return True
anchor = rule.start_date - timedelta(days=rule.start_date.weekday())
return day.weekday() in rule.weekdays and ((day - anchor).days // 7) % rule.every_weeks == 0
def schedule_week_index(schedule, day):
anchor = schedule.valid_from - timedelta(days=schedule.valid_from.weekday())
return ((day - anchor).days // 7) % schedule.cycle_weeks
def target_for_day(schedule, week_index, weekday):
row = next((item for item in schedule.day_targets if int(item.get('week_index', 0)) == week_index and int(item.get('weekday', -1)) == weekday), None)
return int(row.get('minutes', 0)) if row else 0
def target_for_week(schedule, week_index):
row = next((item for item in schedule.week_targets if int(item.get('week_index', -1)) == week_index), None)
return int(row.get('minutes', 0)) if row else 0
def effective_day(user_id, day, plan=None):
schedules, rules = plan if plan is not None else load_plan(user_id)
matches = [r for r in rules if rule_matches(r, day)]
# Ponctuel > récurrent > semaine type ; à priorité égale, dernière création.
rule = max(matches, key=lambda r: (not r.recurring, r.pk), default=None)
if rule:
source = 'exception'
week_target_minutes = None
if rule.kind == ScheduleException.Kind.WORK and rule.mode == ScheduleException.Mode.DAILY_HOURS:
target_minutes = int(rule.target_minutes or 0)
kind = 'work' if target_minutes > 0 else 'rest'
label = rule.label or ('Heures à effectuer' if target_minutes else 'Repos')
slots = []
schedule_mode = WorkSchedule.Mode.DAILY_HOURS
else:
kind = rule.kind
label = rule.label or rule.get_kind_display()
slots = [slot_data(s) for s in rule.slots.all()] if kind == ScheduleException.Kind.WORK else []
target_minutes = sum(slot_minutes(slot) for slot in slots) if slots else 0
schedule_mode = WorkSchedule.Mode.PRECISE
else:
schedule = next((s for s in schedules if s.valid_from <= day and (not s.valid_to or s.valid_to >= day)), None)
if not schedule:
return dict(
user_id=user_id,
date=day.isoformat(),
kind='undefined',
label='Emploi du temps non défini',
source='none',
schedule_mode='none',
target_minutes=None,
week_target_minutes=None,
slots=[],
)
source = 'schedule'
week_index = schedule_week_index(schedule, day)
if schedule.mode == WorkSchedule.Mode.DAILY_HOURS:
target_minutes = target_for_day(schedule, week_index, day.weekday())
week_target_minutes = sum(target_for_day(schedule, week_index, weekday) for weekday in range(7))
kind = 'work' if target_minutes > 0 else 'rest'
label = 'Heures à effectuer' if target_minutes else 'Repos'
slots = []
schedule_mode = WorkSchedule.Mode.DAILY_HOURS
elif schedule.mode == WorkSchedule.Mode.WEEKLY_HOURS:
week_target_minutes = target_for_week(schedule, week_index)
target_minutes = None
kind = 'weekly_target' if week_target_minutes > 0 else 'rest'
label = 'Total d’heures à effectuer cette semaine' if week_target_minutes > 0 else 'Repos'
slots = []
schedule_mode = WorkSchedule.Mode.WEEKLY_HOURS
else:
slots = [slot_data(s) for s in schedule.slots.all() if s.weekday == day.weekday() and s.week_index == week_index]
kind = 'work' if slots else 'rest'
label = 'Emploi du temps habituel' if slots else 'Repos'
target_minutes = sum(slot_minutes(slot) for slot in slots)
week_target_minutes = None
schedule_mode = WorkSchedule.Mode.PRECISE
return dict(
user_id=user_id,
date=day.isoformat(),
kind=kind,
label=label,
source=source,
schedule_mode=schedule_mode,
target_minutes=target_minutes,
week_target_minutes=week_target_minutes,
slots=slots,
)
def period(slot, day):
return (datetime.combine(day, time.fromisoformat(slot['start_time'])),
datetime.combine(day + timedelta(days=slot.get('end_day_offset', 0)), time.fromisoformat(slot['end_time'])))
def validate_calendar(user_id):
"""Vérifie une période complète de répétition entre chaque changement de règle.
Le PPCM combine le cycle actif et les exceptions récurrentes. On contrôle
aussi la jonction avec le jour précédent et le cycle suivant.
"""
plan = load_plan(user_id)
boundaries = set()
for schedule in plan[0]:
boundaries.add(schedule.valid_from)
if schedule.valid_to:
boundaries.add(schedule.valid_to + timedelta(days=1))
for rule in plan[1]:
boundaries.add(rule.start_date)
if rule.end_date:
boundaries.add(rule.end_date + timedelta(days=1))
days = set()
ordered = sorted(boundaries)
horizon = date(2100, 1, 1)
for index, boundary in enumerate(ordered):
following = ordered[index + 1] if index + 1 < len(ordered) else horizon
active = next((s for s in plan[0] if s.valid_from <= boundary and (not s.valid_to or s.valid_to >= boundary)), None)
cycle = active.cycle_weeks if active else 1
for rule in plan[1]:
if rule.recurring and rule.start_date <= boundary and (not rule.end_date or rule.end_date >= boundary):
cycle = lcm(cycle, rule.every_weeks)
finish = min(following, boundary + timedelta(weeks=cycle))
days.update(date_range(boundary - timedelta(days=1), finish))
cache = {}
def periods(day):
if day not in cache:
cache[day] = [period(s, day) for s in effective_day(user_id, day, plan)['slots']]
return cache[day]
for day in sorted(days):
intervals = sorted(periods(day) + periods(day + timedelta(days=1)))
for previous, current in zip(intervals, intervals[1:]):
if current[0] < previous[1]:
raise ValidationError({'slots': f'Chevauchement d’horaires le {current[0]:%d/%m/%Y}. Vérifiez aussi la veille et les horaires après minuit.'})
def effective_period(user_id, start, end):
plan = load_plan(user_id)
days = [effective_day(user_id, day, plan) for day in date_range(start, end)]
segments = []
# Une vacation commencée la veille peut continuer dans la période demandée.
for day in date_range(start - timedelta(days=1), end):
data = effective_day(user_id, day, plan)
for index, slot in enumerate(data['slots']):
begin, finish = period(slot, day)
cursor = begin
while cursor < finish:
boundary = datetime.combine(cursor.date() + timedelta(days=1), time.min)
stop = min(finish, boundary)
if start <= cursor.date() <= end:
segments.append(dict(
id=f'{user_id}-{day}-{index}-{cursor.date()}', date=cursor.date().isoformat(),
start_time=cursor.strftime('%H:%M'), end_time='24:00' if stop == boundary else stop.strftime('%H:%M'),
label=data['label'], source=data['source'], user_id=user_id,
))
cursor = stop
return dict(user_id=user_id, days=days, segments=segments)
@transaction.atomic
def save_schedule(actor, data):
user = data['user']
lock_users([user.pk])
check_revision(user.pk, data['expected_revision'])
start = data['valid_from']
schedule = WorkSchedule.objects.filter(user=user, valid_from=start).first()
if schedule is None:
previous = WorkSchedule.objects.filter(user=user, valid_from__lt=start).order_by('-valid_from').first()
following = WorkSchedule.objects.filter(user=user, valid_from__gt=start).order_by('valid_from').first()
if previous:
previous.valid_to = start - timedelta(days=1)
previous.save(update_fields=['valid_to', 'updated_at'])
schedule = WorkSchedule.objects.create(user=user, valid_from=start, valid_to=following.valid_from - timedelta(days=1) if following else None, created_by=actor)
schedule.slots.all().delete()
schedule.cycle_weeks = data.get('cycle_weeks', 1)
schedule.mode = data.get('mode', WorkSchedule.Mode.PRECISE)
schedule.day_targets = data.get('day_targets', [])
schedule.week_targets = data.get('week_targets', [])
WorkScheduleSlot.objects.bulk_create([WorkScheduleSlot(schedule=schedule, **slot) for slot in data['slots']])
schedule.save()
validate_calendar(user.pk)
bump_revision(user.pk)
return schedule
@transaction.atomic
def save_exception(actor, data, instance=None):
user = instance.user if instance else data['user']
lock_users([user.pk])
check_revision(user.pk, data['expected_revision'])
values = {key: data[key] for key in ['label', 'kind', 'start_date', 'end_date', 'recurring', 'weekdays', 'every_weeks']}
# Les changements créés manuellement dans la gestion utilisent des plages précises.
values['mode'] = ScheduleException.Mode.PRECISE
values['target_minutes'] = None
if instance:
instance = ScheduleException.objects.select_for_update().get(pk=instance.pk)
for key, value in values.items():
setattr(instance, key, value)
instance.save()
else:
instance = ScheduleException.objects.create(user=user, created_by=actor, **values)
instance.slots.all().delete()
ExceptionSlot.objects.bulk_create([ExceptionSlot(exception=instance, **slot) for slot in data['slots']])
validate_calendar(user.pk)
bump_revision(user.pk)
return instance
def snapshot(user_id, day):
result = effective_day(user_id, day)
# L'identité de la règle ne change pas le sens de la demande.
return {key: result[key] for key in ['user_id', 'date', 'kind', 'schedule_mode', 'target_minutes', 'week_target_minutes', 'slots']}
def request_changes(user, data):
def ensure_no_overlap(slots):
periods = []
for slot in slots:
start_minutes = int(slot['start_time'][:2]) * 60 + int(slot['start_time'][3:5])
end_minutes = start_minutes + slot_minutes(slot)
periods.append((start_minutes, end_minutes))
periods.sort()
if any(current[0] < previous[1] for previous, current in zip(periods, periods[1:])):
raise ValidationError({'proposed_slots': 'Les horaires ajoutés chevauchent des horaires déjà prévus ce jour-là.'})
def worked_minutes(row):
if row.get('schedule_mode') == WorkSchedule.Mode.DAILY_HOURS:
return int(row.get('target_minutes') or 0)
return sum(slot_minutes(slot) for slot in row.get('slots', []))
def swappable(row):
return row['kind'] not in ('absence', 'undefined', 'weekly_target')
before = [snapshot(user.pk, day) for day in date_range(data['start_date'], data['end_date'])]
if data['kind'] == 'swap':
source = before[0]
if not swappable(source):
raise ValidationError('Cette journée ne peut pas être échangée : elle doit être définie dans le planning.')
target_date = data.get('target_date')
if not target_date:
# L’employé choisit uniquement le collègue. Le responsable choisira
# la journée précise de ce collègue au moment de la validation.
return before, []
other = data['target_user']
target = snapshot(other.pk, target_date)
before.append(target)
if not all(swappable(row) for row in before):
raise ValidationError('Un échange nécessite deux journées définies, travaillées ou de repos.')
if not any(worked_minutes(row) > 0 for row in before):
raise ValidationError('Au moins une des deux journées doit être travaillée.')
after = deepcopy(before)
transferable = ['kind', 'slots', 'schedule_mode', 'target_minutes']
for key in transferable:
after[0][key] = deepcopy(before[1].get(key))
after[1][key] = deepcopy(before[0].get(key))
after[0]['week_target_minutes'] = None
after[1]['week_target_minutes'] = None
elif data['kind'] == 'move':
source = before[0]
target = snapshot(user.pk, data['target_date'])
before.append(target)
if source['kind'] != 'work' or worked_minutes(source) <= 0:
raise ValidationError('Choisissez une journée travaillée à déplacer.')
if target['kind'] in ('absence', 'undefined', 'weekly_target'):
raise ValidationError('Les heures ne peuvent pas être déplacées vers un jour en congé ou sans journée définie.')
if target.get('schedule_mode') == WorkSchedule.Mode.DAILY_HOURS and worked_minutes(target) > 0:
raise ValidationError('Le jour d’arrivée contient un nombre d’heures sans plage précise. Choisissez un jour de repos ou un jour avec des horaires précis.')
required = worked_minutes(source)
added = sum(slot_minutes(slot) for slot in data['proposed_slots'])
if required != added:
difference = required - added
raise ValidationError({'proposed_slots': f'Il reste {abs(difference)} minute(s) à {"ajouter" if difference > 0 else "retirer"}. Le total déplacé doit rester identique.'})
combined = [*target['slots'], *data['proposed_slots']]
if len(combined) > 10:
raise ValidationError({'proposed_slots': 'La journée d’arrivée ne peut pas contenir plus de 10 plages de travail.'})
ensure_no_overlap(combined)
after = deepcopy(before)
after[0].update(kind='rest', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=0, week_target_minutes=None, slots=[])
after[1].update(kind='work', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=sum(slot_minutes(slot) for slot in combined), week_target_minutes=None, slots=combined)
else:
after = deepcopy(before)
for row in after:
if data['kind'] == 'leave':
row.update(kind='absence', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=0, week_target_minutes=None, slots=[])
else:
row.update(kind='work', schedule_mode=WorkSchedule.Mode.PRECISE, target_minutes=sum(slot_minutes(slot) for slot in data['proposed_slots']), week_target_minutes=None, slots=data['proposed_slots'])
return before, after

301
backend/planning/tests.py Normal file
View File

@ -0,0 +1,301 @@
from datetime import date
from django.contrib.auth import get_user_model
from rest_framework.test import APITestCase
from time_tracking.models import TimeEntry
from .models import PlanningState, WorkSchedule, ScheduleException, DayChangeRequest
from .services import effective_period
class PlanningAPITests(APITestCase):
def setUp(self):
self.admin = get_user_model().objects.create_user(username='admin', is_staff=True)
self.alice = get_user_model().objects.create_user(username='alice')
self.bob = get_user_model().objects.create_user(username='bob')
self.stranger = get_user_model().objects.create_user(username='other')
self.client.force_authenticate(self.admin)
def slot(self, start='08:00', end='12:00', weekday=None, offset=0):
result = dict(start_time=start, end_time=end, end_day_offset=offset)
if weekday is not None:
result['weekday'] = weekday
return result
def schedule(self, user, slots=None, start='2030-01-07'):
state = PlanningState.objects.filter(user=user).first()
return self.client.post('/api/planning/schedules/', dict(user_id=user.pk, valid_from=start,
expected_revision=state.revision if state else 0, slots=slots if slots is not None else [self.slot(weekday=0)]), format='json')
def exception(self, **values):
state = PlanningState.objects.filter(user=self.alice).first()
data = dict(user_id=self.alice.pk, expected_revision=state.revision if state else 0,
label='Exception', kind='work', start_date='2030-01-07', end_date=None,
recurring=True, weekdays=[0], every_weeks=2, slots=[self.slot('10:00', '14:00')])
data.update(values)
return self.client.post('/api/planning/exceptions/', data, format='json')
def request(self, **values):
self.client.force_authenticate(self.alice)
data = dict(kind='leave', start_date='2030-01-07', end_date='2030-01-07', reason='Test', proposed_slots=[])
data.update(values)
return self.client.post('/api/planning/requests/', data, format='json')
def decide(self, row_id, action='approve', target_date=None):
self.client.force_authenticate(self.admin)
payload = {'action': action, 'response': 'Réponse admin'}
if target_date:
payload['target_date'] = target_date
return self.client.post(f'/api/planning/requests/{row_id}/decision/', payload, format='json')
def test_permissions_and_minimal_people_directory(self):
self.client.force_authenticate(self.alice)
self.assertEqual(self.client.get('/api/planning/config/').status_code, 403)
self.assertEqual(self.client.get('/api/planning/effective/', {'user_id': self.bob.pk}).status_code, 403)
self.assertEqual(self.schedule(self.alice).status_code, 403)
directory = self.client.get('/api/planning/people/').data
self.assertNotIn('email', directory[0])
self.assertNotIn('is_staff', directory[0])
def test_new_effective_date_preserves_history_and_inserts_between_versions(self):
self.assertEqual(self.schedule(self.alice).status_code, 201)
self.assertEqual(self.schedule(self.alice, [self.slot('10:00', '14:00', 0)], '2030-02-04').status_code, 201)
self.assertEqual(self.schedule(self.alice, [self.slot('09:00', '13:00', 0)], '2030-01-21').status_code, 201)
for day, expected in [('2030-01-14', '08:00'), ('2030-01-28', '09:00'), ('2030-02-11', '10:00')]:
result = effective_period(self.alice.pk, date.fromisoformat(day), date.fromisoformat(day))
self.assertEqual(result['segments'][0]['start_time'], expected)
self.assertEqual(WorkSchedule.objects.get(user=self.alice, valid_from='2030-01-07').valid_to.isoformat(), '2030-01-20')
def test_revision_rejects_lost_admin_update(self):
self.schedule(self.alice)
result = self.client.post('/api/planning/schedules/', dict(user_id=self.alice.pk, valid_from='2030-01-07', expected_revision=0, slots=[]), format='json')
self.assertEqual(result.status_code, 409)
self.assertEqual(WorkSchedule.objects.get(user=self.alice).slots.count(), 1)
def test_recurring_exceptions_and_priority(self):
self.schedule(self.alice)
self.assertEqual(self.exception().status_code, 201)
self.assertEqual(self.exception(recurring=False, start_date='2030-01-21', end_date='2030-01-21', kind='absence', slots=[]).status_code, 201)
for day, expected in [('2030-01-07', '10:00'), ('2030-01-14', '08:00'), ('2030-01-28', '08:00'), ('2030-02-04', '10:00')]:
data = effective_period(self.alice.pk, date.fromisoformat(day), date.fromisoformat(day))
self.assertEqual(data['segments'][0]['start_time'], expected)
data = effective_period(self.alice.pk, date(2030, 1, 21), date(2030, 1, 21))
self.assertEqual(data['days'][0]['kind'], 'absence')
self.assertEqual(data['segments'], [])
def test_overnight_previous_day_and_week_boundary(self):
self.assertEqual(self.schedule(self.alice, [self.slot('22:00', '06:00', 6, 1)]).status_code, 201)
data = effective_period(self.alice.pk, date(2030, 1, 14), date(2030, 1, 14))
self.assertEqual([(s['start_time'], s['end_time']) for s in data['segments']], [('00:00', '06:00')])
result = self.schedule(self.alice, [self.slot('22:00', '06:00', 6, 1), self.slot('05:00', '09:00', 0)])
self.assertEqual(result.status_code, 400)
self.assertEqual(WorkSchedule.objects.get(user=self.alice).slots.count(), 1)
def test_far_future_recurring_conflict_rolls_back(self):
self.schedule(self.alice, [self.slot('22:00', '06:00', 6, 1)])
result = self.exception(start_date='2037-01-01', weekdays=[0], every_weeks=3, slots=[self.slot('04:00', '08:00')])
self.assertEqual(result.status_code, 400)
self.assertFalse(ScheduleException.objects.exists())
def test_leave_approval_preserves_real_time_entries_and_prevents_replay(self):
self.schedule(self.alice)
actual = TimeEntry.objects.create(user=self.alice, start_date='2030-01-07', end_date='2030-01-07', start_time='08:03', end_time='12:01')
row = self.request()
self.assertEqual(row.status_code, 201, row.data)
self.assertEqual(self.decide(row.data['id']).status_code, 200)
actual.refresh_from_db()
self.assertEqual(str(actual.start_time), '08:03:00')
self.assertEqual(self.decide(row.data['id']).status_code, 409)
self.assertEqual(ScheduleException.objects.count(), 1)
self.assertEqual(effective_period(self.alice.pk, date(2030, 1, 7), date(2030, 1, 7))['days'][0]['kind'], 'absence')
def test_stale_request_is_not_applied(self):
self.schedule(self.alice)
row = self.request()
self.client.force_authenticate(self.admin)
self.schedule(self.alice, [self.slot('09:00', '13:00', 0)])
self.assertEqual(self.decide(row.data['id']).status_code, 409)
self.assertFalse(ScheduleException.objects.exists())
self.assertEqual(DayChangeRequest.objects.get().status, 'pending')
def test_move_requires_same_total_and_moves_work_to_another_day(self):
self.schedule(self.alice)
row = self.request(kind='move', target_date='2030-01-08', proposed_slots=[self.slot('13:00', '17:00')])
self.assertEqual(row.status_code, 201, row.data)
self.assertEqual(self.decide(row.data['id']).status_code, 200)
days = effective_period(self.alice.pk, date(2030, 1, 7), date(2030, 1, 8))['days']
self.assertEqual([d['kind'] for d in days], ['rest', 'work'])
self.assertEqual(days[1]['slots'][0]['start_time'], '13:00')
self.client.force_authenticate(self.alice)
wrong_total = self.client.post('/api/planning/requests/', dict(
kind='move', start_date='2030-01-14', end_date='2030-01-14', target_date='2030-01-15',
proposed_slots=[self.slot('13:00', '16:00')],
), format='json')
self.assertEqual(wrong_total.status_code, 400)
def test_two_employee_swap_and_request_visibility(self):
self.schedule(self.alice)
self.schedule(self.bob, [self.slot('13:00', '18:00', 0)])
row = self.request(kind='swap', target_user_id=self.bob.pk)
self.assertEqual(row.status_code, 201, row.data)
self.assertIsNone(row.data['target_date'])
self.assertEqual(row.data['after'], [])
self.client.force_authenticate(self.bob)
self.assertEqual(self.client.get('/api/planning/requests/').data['count'], 1)
self.assertEqual(self.client.post(f"/api/planning/requests/{row.data['id']}/decision/", {'action': 'cancel'}, format='json').status_code, 403)
self.client.force_authenticate(self.stranger)
self.assertEqual(self.client.get('/api/planning/requests/').data['count'], 0)
# Le responsable choisit une autre journée du collègue au moment de valider.
self.assertEqual(self.decide(row.data['id'], target_date='2030-01-14').status_code, 200)
self.assertEqual(effective_period(self.alice.pk, date(2030, 1, 7), date(2030, 1, 7))['segments'][0]['start_time'], '13:00')
self.assertEqual(effective_period(self.bob.pk, date(2030, 1, 14), date(2030, 1, 14))['segments'][0]['start_time'], '08:00')
def test_swap_target_is_chosen_by_admin_and_move_rejects_overlap(self):
self.schedule(self.alice, [self.slot('08:00', '12:00', 0), self.slot('13:00', '18:00', 1)])
self.schedule(self.bob, [self.slot('13:00', '18:00', 0)])
row = self.request(kind='swap', target_user_id=self.bob.pk)
self.assertEqual(row.status_code, 201, row.data)
# Avant le début du planning de Bob : journée non définie, donc impossible à échanger.
self.assertEqual(self.decide(row.data['id'], target_date='2030-01-06').status_code, 400)
self.assertEqual(DayChangeRequest.objects.get(pk=row.data['id']).status, 'pending')
self.client.force_authenticate(self.alice)
overlap = self.client.post('/api/planning/requests/', dict(
kind='move', start_date='2030-01-08', end_date='2030-01-08', target_date='2030-01-09',
proposed_slots=[self.slot('14:00', '18:00')],
), format='json')
self.assertEqual(overlap.status_code, 400)
def test_duplicate_cancel_and_reject_do_not_modify_schedule(self):
self.schedule(self.alice)
row = self.request()
self.assertEqual(self.request().status_code, 409)
self.assertEqual(self.client.post(f"/api/planning/requests/{row.data['id']}/decision/", {'action': 'approve'}, format='json').status_code, 403)
self.assertEqual(self.client.post(f"/api/planning/requests/{row.data['id']}/decision/", {'action': 'cancel'}, format='json').status_code, 200)
row = self.request()
self.assertEqual(self.decide(row.data['id'], 'reject').status_code, 200)
self.assertFalse(ScheduleException.objects.exists())
def test_preview_changes_are_rechecked_when_request_is_submitted(self):
self.schedule(self.alice)
self.client.force_authenticate(self.alice)
payload = dict(kind='leave', start_date='2030-01-07', end_date='2030-01-07')
preview = self.client.post('/api/planning/requests/preview/', payload, format='json')
self.assertEqual(preview.status_code, 200)
self.client.force_authenticate(self.admin)
self.schedule(self.alice, [self.slot('10:00', '14:00', 0)])
self.client.force_authenticate(self.alice)
payload['expected_before'] = preview.data['before']
response = self.client.post('/api/planning/requests/', payload, format='json')
self.assertEqual(response.status_code, 409)
self.assertFalse(DayChangeRequest.objects.exists())
def test_weekly_slots_validate_order_and_dates(self):
response = self.schedule(self.alice, [self.slot('12:00', '08:00', 0)])
self.assertEqual(response.status_code, 400)
self.assertFalse(WorkSchedule.objects.exists())
self.assertEqual(self.exception(weekdays=[], recurring=True).status_code, 400)
def test_daily_and_weekly_hour_targets_are_exposed_by_effective_api(self):
daily = self.client.post('/api/planning/schedules/', dict(
user_id=self.alice.pk,
valid_from='2030-01-07',
cycle_weeks=1,
mode='daily_hours',
day_targets=[
{'week_index': 0, 'weekday': 0, 'minutes': 450},
{'week_index': 0, 'weekday': 1, 'minutes': 360},
],
week_targets=[],
slots=[],
expected_revision=0,
), format='json')
self.assertEqual(daily.status_code, 201, daily.data)
result = self.client.get('/api/planning/effective/', dict(user_id=self.alice.pk, start='2030-01-07', end='2030-01-08')).data
self.assertEqual(result['days'][0]['schedule_mode'], 'daily_hours')
self.assertEqual(result['days'][0]['target_minutes'], 450)
self.assertEqual(result['days'][0]['week_target_minutes'], 810)
self.assertEqual(result['days'][0]['slots'], [])
self.assertEqual(result['days'][1]['target_minutes'], 360)
revision = PlanningState.objects.get(user=self.alice).revision
weekly = self.client.post('/api/planning/schedules/', dict(
user_id=self.alice.pk,
valid_from='2030-01-14',
cycle_weeks=1,
mode='weekly_hours',
day_targets=[],
week_targets=[{'week_index': 0, 'minutes': 2100}],
slots=[],
expected_revision=revision,
), format='json')
self.assertEqual(weekly.status_code, 201, weekly.data)
result = self.client.get('/api/planning/effective/', dict(user_id=self.alice.pk, start='2030-01-14', end='2030-01-20')).data
self.assertTrue(all(day['schedule_mode'] == 'weekly_hours' for day in result['days']))
self.assertTrue(all(day['week_target_minutes'] == 2100 for day in result['days']))
self.assertEqual(result['segments'], [])
def rotating_schedule(self, weeks, slots, start='2030-01-07'):
state = PlanningState.objects.filter(user=self.alice).first()
return self.client.post('/api/planning/schedules/', dict(user_id=self.alice.pk, valid_from=start,
cycle_weeks=weeks, expected_revision=state.revision if state else 0, slots=slots), format='json')
def cycle_slot(self, week, weekday=0, start='08:00', end='12:00', offset=0):
return {**self.slot(start, end, weekday, offset), 'week_index': week}
def test_two_weeks_alternate_in_effective_api(self):
result = self.rotating_schedule(2, [self.cycle_slot(0), self.cycle_slot(1, start='13:00', end='17:00')])
self.assertEqual(result.status_code, 201, result.data)
self.assertEqual(result.data['cycle_weeks'], 2)
for day, hour in [('2030-01-07', '08:00'), ('2030-01-14', '13:00'), ('2030-01-21', '08:00')]:
response = self.client.get('/api/planning/effective/', dict(user_id=self.alice.pk, start=day, end=day))
self.assertEqual(response.data['segments'][0]['start_time'], hour)
def test_three_week_cycle_includes_empty_rest_week(self):
self.assertEqual(self.rotating_schedule(3, [self.cycle_slot(0), self.cycle_slot(2, start='10:00', end='14:00')]).status_code, 201)
days = effective_period(self.alice.pk, date(2030, 1, 7), date(2030, 1, 28))['days']
self.assertEqual([days[index]['kind'] for index in [0, 7, 14, 21]], ['work', 'rest', 'work', 'work'])
self.assertEqual(days[14]['slots'][0]['start_time'], '10:00')
def test_midweek_effective_date_anchors_a_to_containing_monday(self):
self.rotating_schedule(2, [self.cycle_slot(0, 2), self.cycle_slot(1, 0, '13:00', '17:00')], start='2030-01-09')
days = effective_period(self.alice.pk, date(2030, 1, 8), date(2030, 1, 14))['days']
self.assertEqual(days[0]['kind'], 'undefined')
self.assertEqual(days[1]['slots'][0]['start_time'], '08:00')
self.assertEqual(days[-1]['slots'][0]['start_time'], '13:00')
def test_cycle_change_preserves_previous_period(self):
self.rotating_schedule(2, [self.cycle_slot(0), self.cycle_slot(1, start='13:00', end='17:00')])
self.rotating_schedule(3, [self.cycle_slot(0, start='09:00', end='12:00')], start='2030-01-21')
self.assertEqual(effective_period(self.alice.pk, date(2030, 1, 14), date(2030, 1, 14))['segments'][0]['start_time'], '13:00')
self.assertEqual(effective_period(self.alice.pk, date(2030, 1, 21), date(2030, 1, 21))['segments'][0]['start_time'], '09:00')
def test_cycle_wrap_rejects_overnight_collision_and_keeps_previous_version(self):
slots = [self.cycle_slot(2, 6, '22:00', '06:00', 1), self.cycle_slot(0, 0, '07:00', '12:00')]
self.assertEqual(self.rotating_schedule(3, slots).status_code, 201)
data = effective_period(self.alice.pk, date(2030, 1, 28), date(2030, 1, 28))
self.assertEqual([(s['start_time'], s['end_time']) for s in data['segments']], [('00:00', '06:00'), ('07:00', '12:00')])
slots[1]['start_time'] = '05:00'
self.assertEqual(self.rotating_schedule(3, slots).status_code, 400)
self.assertEqual(WorkSchedule.objects.get().slots.get(week_index=0).start_time.hour, 7)
def test_cycle_and_exception_conflict_after_52_weeks_is_detected(self):
self.assertEqual(self.rotating_schedule(13, [self.cycle_slot(12, 6, '22:00', '06:00', 1)]).status_code, 201)
result = self.exception(every_weeks=4, weekdays=[0], slots=[self.slot('04:00', '08:00')])
self.assertEqual(result.status_code, 400, result.data)
self.assertFalse(ScheduleException.objects.exists())
def test_cycle_validation_rejects_nonexistent_weeks(self):
self.assertEqual(self.rotating_schedule(2, [self.cycle_slot(2)]).status_code, 400)
self.assertEqual(self.rotating_schedule(0, []).status_code, 400)
self.assertEqual(self.rotating_schedule(53, []).status_code, 400)
self.assertFalse(WorkSchedule.objects.exists())
def test_approved_leave_overrides_only_one_day_in_cycle(self):
self.rotating_schedule(2, [self.cycle_slot(0), self.cycle_slot(1, start='13:00', end='17:00')])
row = self.request(start_date='2030-01-14', end_date='2030-01-14')
self.assertEqual(self.decide(row.data['id']).status_code, 200)
days = effective_period(self.alice.pk, date(2030, 1, 14), date(2030, 1, 28))['days']
self.assertEqual(days[0]['kind'], 'absence')
self.assertEqual(days[-1]['slots'][0]['start_time'], '13:00')

15
backend/planning/urls.py Normal file
View File

@ -0,0 +1,15 @@
from django.urls import path
from .views import PeopleView, ConfigurationView, EffectiveView, ScheduleView, ExceptionView, RequestListView, RequestPreviewView, RequestDecisionView, RequestSwapTargetDayView
urlpatterns = [
path('people/', PeopleView.as_view()),
path('config/', ConfigurationView.as_view()),
path('effective/', EffectiveView.as_view()),
path('schedules/', ScheduleView.as_view()),
path('exceptions/', ExceptionView.as_view()),
path('exceptions/<int:pk>/', ExceptionView.as_view()),
path('requests/', RequestListView.as_view()),
path('requests/preview/', RequestPreviewView.as_view()),
path('requests/<int:pk>/swap-target-day/', RequestSwapTargetDayView.as_view()),
path('requests/<int:pk>/decision/', RequestDecisionView.as_view()),
]

340
backend/planning/views.py Normal file
View File

@ -0,0 +1,340 @@
from datetime import date, timedelta
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import generics, serializers, status
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from users.permissions import (
has_management_permission, is_admin, management_target_ids,
require_management_permission,
)
from notes.serializers import NoteUserSerializer
from .models import PlanningState, WorkSchedule, ScheduleException, ExceptionSlot, DayChangeRequest
from .serializers import ScheduleInput, ScheduleSerializer, ExceptionInput, ExceptionSerializer, RequestInput, RequestSerializer, checked_dates
from .services import (PlanningConflict, lock_users, check_revision, bump_revision, save_schedule, save_exception,
effective_period, request_changes, snapshot, validate_calendar)
def admin_only(user):
if not is_admin(user):
raise PermissionDenied('Cette action est réservée aux administrateurs.')
def requested_user(request, admin=False, management_permission=None):
if admin:
admin_only(request.user)
raw = request.query_params.get('user_id', request.user.pk)
user_id = serializers.IntegerField(min_value=1).run_validation(raw)
if user_id != request.user.pk and not is_admin(request.user):
if management_permission:
require_management_permission(
request.user, management_permission, user_id,
'Vous n’avez pas accès à l’emploi du temps de ce salarié.',
)
else:
raise PermissionDenied('Vous pouvez uniquement consulter votre emploi du temps.')
return get_object_or_404(get_user_model(), pk=user_id)
def snapshot_matches(stored, current):
"""Compare aussi les demandes créées avant l'ajout des nouveaux champs de planning."""
return all(current.get(key) == value for key, value in stored.items())
def snapshot_list_matches(stored, current):
return len(stored) == len(current) and all(snapshot_matches(old, new) for old, new in zip(stored, current))
class PeopleView(generics.ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = NoteUserSerializer
queryset = get_user_model().objects.filter(is_active=True).order_by('first_name', 'last_name', 'username')
pagination_class = None
class ConfigurationView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
user = requested_user(request, management_permission='manage_schedules')
state = PlanningState.objects.filter(user=user).first()
return Response(dict(user=NoteUserSerializer(user).data, revision=state.revision if state else 0,
schedules=ScheduleSerializer(WorkSchedule.objects.filter(user=user).prefetch_related('slots'), many=True).data,
exceptions=ExceptionSerializer(ScheduleException.objects.filter(user=user).prefetch_related('slots'), many=True).data))
class EffectiveView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
user = requested_user(request)
field = serializers.DateField()
start = field.run_validation(request.query_params.get('start', timezone.localdate().isoformat()))
end = field.run_validation(request.query_params.get('end', (start + timedelta(days=6)).isoformat()))
checked_dates(start, end)
if (end - start).days > 365:
raise ValidationError('La période demandée dépasse 366 jours.')
return Response(effective_period(user.pk, start, end))
class ScheduleView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
data = ScheduleInput(data=request.data)
data.is_valid(raise_exception=True)
require_management_permission(
request.user, 'manage_schedules', data.validated_data['user'].pk,
'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.',
)
result = save_schedule(request.user, data.validated_data)
return Response(ScheduleSerializer(result).data, status=status.HTTP_201_CREATED)
class ExceptionView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
data = ExceptionInput(data=request.data)
data.is_valid(raise_exception=True)
require_management_permission(
request.user, 'manage_schedules', data.validated_data['user'].pk,
'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.',
)
result = save_exception(request.user, data.validated_data)
return Response(ExceptionSerializer(result).data, status=status.HTTP_201_CREATED)
def put(self, request, pk):
instance = get_object_or_404(ScheduleException, pk=pk)
require_management_permission(
request.user, 'manage_schedules', instance.user_id,
'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.',
)
data = ExceptionInput(data=request.data)
data.is_valid(raise_exception=True)
if data.validated_data['user'].pk != instance.user_id:
raise ValidationError('Une exception ne peut pas changer de salarié.')
result = save_exception(request.user, data.validated_data, instance)
return Response(ExceptionSerializer(result).data)
@transaction.atomic
def delete(self, request, pk):
instance = get_object_or_404(ScheduleException, pk=pk)
require_management_permission(
request.user, 'manage_schedules', instance.user_id,
'Vous n’avez pas l’autorisation de modifier le planning de ce salarié.',
)
lock_users([instance.user_id])
revision = serializers.IntegerField(min_value=0).run_validation(request.data.get('expected_revision'))
check_revision(instance.user_id, revision)
user_id = instance.user_id
instance.delete()
validate_calendar(user_id)
bump_revision(user_id)
return Response(status=status.HTTP_204_NO_CONTENT)
class RequestPagination(PageNumberPagination):
page_size = 20
class RequestListView(generics.ListCreateAPIView):
permission_classes = [IsAuthenticated]
serializer_class = RequestSerializer
pagination_class = RequestPagination
def get_queryset(self):
rows = DayChangeRequest.objects.select_related('user', 'target_user', 'reviewed_by')
if not is_admin(self.request.user):
approval_targets = management_target_ids(self.request.user, 'approve_requests')
review_mode = self.request.query_params.get('review') in ('1', 'true', 'yes')
personal = Q(user=self.request.user) | Q(target_user=self.request.user)
if review_mode:
if approval_targets is None:
pass
elif approval_targets:
rows = rows.filter(user_id__in=approval_targets)
else:
rows = rows.none()
elif approval_targets is None:
pass
elif approval_targets:
rows = rows.filter(personal | Q(user_id__in=approval_targets))
else:
rows = rows.filter(personal)
state = self.request.query_params.get('status')
kind = self.request.query_params.get('kind')
user_id = self.request.query_params.get('user_id')
if state:
state = serializers.ChoiceField(choices=DayChangeRequest.Status.choices).run_validation(state)
rows = rows.filter(status=state)
if kind:
kind = serializers.ChoiceField(choices=DayChangeRequest.Kind.choices).run_validation(kind)
rows = rows.filter(kind=kind)
if user_id:
user_id = serializers.IntegerField(min_value=1).run_validation(user_id)
rows = rows.filter(Q(user_id=user_id) | Q(target_user_id=user_id))
return rows
@transaction.atomic
def create(self, request, *args, **kwargs):
data = RequestInput(data=request.data, context={'request': request})
data.is_valid(raise_exception=True)
values = data.validated_data
ids = [request.user.pk]
if values.get('target_user'):
ids.append(values['target_user'].pk)
lock_users(ids)
expected_before = values.pop('expected_before', None)
before, after = request_changes(request.user, values)
if expected_before is not None and expected_before != before:
raise PlanningConflict('Le planning a changé depuis l’aperçu. Vérifiez à nouveau votre demande.')
duplicate = DayChangeRequest.objects.filter(
user=request.user,
status='pending',
kind=values['kind'],
before=before,
after=after,
target_user=values.get('target_user'),
target_date=values.get('target_date'),
)
if duplicate.exists():
raise PlanningConflict('Une demande identique est déjà en attente.')
row = DayChangeRequest.objects.create(user=request.user, before=before, after=after, **values)
return Response(RequestSerializer(row).data, status=status.HTTP_201_CREATED)
class RequestPreviewView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
data = RequestInput(data=request.data, context={'request': request})
data.is_valid(raise_exception=True)
before, after = request_changes(request.user, data.validated_data)
return Response(dict(before=before, after=after))
class RequestSwapTargetDayView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, pk):
row = get_object_or_404(
DayChangeRequest.objects.select_related('target_user'),
pk=pk,
kind=DayChangeRequest.Kind.SWAP,
)
if not has_management_permission(request.user, 'approve_requests', row.user_id):
raise PermissionDenied('Vous n’avez pas l’autorisation de traiter cette demande.')
if not row.target_user_id:
raise ValidationError('Le collègue de cet échange n’est plus disponible.')
day = serializers.DateField().run_validation(request.query_params.get('date'))
checked_dates(day, None)
return Response(effective_period(row.target_user_id, day, day))
class RequestDecisionView(APIView):
permission_classes = [IsAuthenticated]
@transaction.atomic
def post(self, request, pk):
action = serializers.ChoiceField(choices=['approve', 'reject', 'cancel']).run_validation(request.data.get('action'))
response = serializers.CharField(max_length=2000, allow_blank=True).run_validation(request.data.get('response', ''))
row = get_object_or_404(DayChangeRequest, pk=pk)
if action == 'cancel' and row.user_id != request.user.pk:
raise PermissionDenied('Seul l’auteur peut annuler cette demande.')
if action != 'cancel' and not has_management_permission(request.user, 'approve_requests', row.user_id):
raise PermissionDenied('Vous n’avez pas l’autorisation de traiter les demandes de ce salarié.')
ids = {row.user_id}
if row.target_user_id:
ids.add(row.target_user_id)
ids.update(item['user_id'] for item in row.before)
lock_users(ids)
row = DayChangeRequest.objects.select_for_update().get(pk=pk)
if row.status != 'pending':
raise PlanningConflict('Cette demande a déjà été traitée.')
if action == 'approve':
before = row.before
after = row.after
if row.kind == DayChangeRequest.Kind.SWAP:
raw_target_date = request.data.get('target_date') or row.target_date
if not raw_target_date:
raise ValidationError({'target_date': 'Choisissez la journée de l’autre employé à échanger avant d’accepter la demande.'})
target_date = serializers.DateField().run_validation(raw_target_date)
checked_dates(target_date, None)
if not row.target_user_id:
raise ValidationError('Le collègue concerné par cet échange n’est plus disponible.')
# La journée demandée par le salarié doit être restée identique depuis l’envoi.
current_source = snapshot(row.user_id, row.start_date)
if not row.before or not snapshot_matches(row.before[0], current_source):
raise PlanningConflict('Le planning du salarié a changé depuis cette demande. Faites annuler puis renouveler la demande.')
values = dict(
kind=DayChangeRequest.Kind.SWAP,
start_date=row.start_date,
end_date=row.end_date,
target_date=target_date,
target_user=row.target_user,
reason=row.reason,
proposed_slots=[],
)
before, after = request_changes(row.user, values)
row.target_date = target_date
row.before = before
row.after = after
ids = {item['user_id'] for item in before}
else:
current = [snapshot(item['user_id'], date.fromisoformat(item['date'])) for item in row.before]
if not snapshot_list_matches(row.before, current):
raise PlanningConflict('Le planning a changé depuis cette demande. Faites annuler puis renouveler la demande avec les horaires actuels.')
ids = {item['user_id'] for item in row.before}
if get_user_model().objects.filter(pk__in=ids, is_active=True).count() != len(ids):
raise ValidationError('Un des salariés concernés n’est plus actif.')
for item in after:
schedule_mode = item.get('schedule_mode', 'precise')
target_minutes = item.get('target_minutes')
exception_mode = (
ScheduleException.Mode.DAILY_HOURS
if item['kind'] == 'work' and schedule_mode == 'daily_hours' and not item.get('slots')
else ScheduleException.Mode.PRECISE
)
exception = ScheduleException.objects.create(
user_id=item['user_id'],
start_date=item['date'],
end_date=item['date'],
kind=item['kind'],
mode=exception_mode,
target_minutes=target_minutes if exception_mode == ScheduleException.Mode.DAILY_HOURS else None,
label=row.get_kind_display(),
request=row,
created_by=request.user,
)
ExceptionSlot.objects.bulk_create([ExceptionSlot(exception=exception, **slot) for slot in item.get('slots', [])])
for user_id in sorted(ids):
validate_calendar(user_id)
bump_revision(user_id)
row.status = 'approved'
else:
row.status = 'rejected' if action == 'reject' else 'cancelled'
row.reviewed_by = request.user
row.reviewed_at = timezone.now()
row.response = response
update_fields = ['status', 'reviewed_by', 'reviewed_at', 'response', 'updated_at']
if action == 'approve' and row.kind == DayChangeRequest.Kind.SWAP:
update_fields.extend(['target_date', 'before', 'after'])
row.save(update_fields=update_fields)
return Response(RequestSerializer(row).data)

View File

@ -119,12 +119,17 @@ class TimeEntryAPITests(APITestCase):
self.assertEqual(response.status_code, 409, response.data)
self.assertEqual(TimeEntry.objects.count(), 1)
conflict = response.data
self.assertEqual(conflict['entry_date'], '2026-09-23')
response = self.client.post('/api/time/entries/scan/', {
'code': 'enter-test', 'resolution': conflict['resolution'], 'time': '06:00',
'scan_date': conflict['scan_date'], 'entry_id': conflict['entry_id'],
'code': 'enter-test', 'resolution': conflict['resolution'], 'time': '23:00',
'scan_date': conflict['scan_date'], 'correction_date': conflict['entry_date'],
'entry_id': conflict['entry_id'],
}, format='json')
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(TimeEntry.objects.count(), 2)
self.assertEqual(TimeEntry.objects.count(), 1)
entry = TimeEntry.objects.get()
self.assertEqual(entry.end_date.isoformat(), '2026-09-23')
self.assertEqual(str(entry.end_time), '23:00:00')
self.assertFalse(TimeEntry.objects.filter(end_time__isnull=True).exists())
def test_scan_missing_entry_requires_original_scan_and_rejects_replay(self):

View File

@ -7,6 +7,8 @@ from .views import (
TimeEntryUpdateView,
TimeEntryGroupView,
WeeklySummaryView,
TimeStatsView,
MonthlyRaceView,
)
urlpatterns = [
@ -18,4 +20,6 @@ urlpatterns = [
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
path('weekly-summary/', WeeklySummaryView.as_view(), name='user-weekly-summary'),
path('weekly-summary/<int:user_id>/', WeeklySummaryView.as_view(), name='weekly-summary'),
path('stats/', TimeStatsView.as_view(), name='time-stats'),
path('monthly-race/', MonthlyRaceView.as_view(), name='monthly-race'),
]

View File

@ -1,4 +1,5 @@
from datetime import timedelta
from calendar import monthrange
from datetime import date, timedelta
from decouple import config
from django.contrib.auth import get_user_model
@ -17,10 +18,32 @@ from .serializers import (
TimeEntryUpdateSerializer, WeeklySummarySerializer,
)
from .services import lock_user, save_period
from users.permissions import is_admin, require_management_permission
from planning.services import effective_day, load_plan
User = get_user_model()
def planned_minutes_for_period(user_id, start, end):
"""Total planifié sur la période.
Les objectifs hebdomadaires n'imposent aucun jour précis : pour les
statistiques, leur total est réparti uniformément sur les sept jours afin
qu'un jour, un mois et une année restent comparables et additionnables.
"""
plan = load_plan(user_id)
total = 0.0
day = start
while day <= end:
planned = effective_day(user_id, day, plan)
if planned.get('schedule_mode') == 'weekly_hours':
total += float(planned.get('week_target_minutes') or 0) / 7
else:
total += float(planned.get('target_minutes') or 0)
day += timedelta(days=1)
return round(total)
def visible_entries(user):
entries = TimeEntry.objects.select_related('user')
return entries if user.is_staff or user.is_superuser else entries.filter(user=user)
@ -138,6 +161,7 @@ class TimeEntryScanView(APIView):
'missing_time': 'end_time' if kind == 'open_entry' else 'start_time',
'scan_time': selected_time.strftime('%H:%M'),
'scan_date': today.isoformat(),
'entry_date': entry.start_date.isoformat() if entry else None,
'entry_id': entry.pk if entry else None,
}, status=status.HTTP_409_CONFLICT)
@ -161,6 +185,14 @@ class TimeEntryScanView(APIView):
raise ValidationError({'error': 'Résolution de conflit inconnue.'})
if resolution and request.data.get('scan_date') != today.isoformat():
raise ValidationError({'error': 'Le jour a changé. Scannez à nouveau le code.'})
correction_date = today
if resolution:
from rest_framework import serializers
correction_date = serializers.DateField().run_validation(
request.data.get('correction_date', today.isoformat())
)
if correction_date > today:
raise ValidationError({'correction_date': 'La date corrigée ne peut pas être dans le futur.'})
lock_user(request.user)
open_entries = list(TimeEntry.objects.select_for_update().filter(
@ -189,9 +221,11 @@ class TimeEntryScanView(APIView):
'Aucune entrée ouverte. Renseignez l’heure d’entrée.', selected_time, today)
if resolution and str(request.data.get('entry_id')) != str(open_entry.pk):
raise ValidationError({'error': 'Le pointage a changé. Scannez à nouveau le code.'})
if correction_date < open_entry.start_date:
raise ValidationError({'correction_date': "La sortie ne peut pas être antérieure à l’entrée."})
entry = save_period(request.user, dict(
start_date=open_entry.start_date, start_time=open_entry.start_time,
end_date=today, end_time=selected_time,
end_date=correction_date, end_time=selected_time,
), [open_entry])
action = 'exit'
else:
@ -241,3 +275,173 @@ class WeeklySummaryView(APIView):
raise PermissionDenied('Vous ne pouvez pas consulter ce résumé.')
user = get_object_or_404(User, pk=user_id)
return Response(WeeklySummarySerializer(build_weekly_summary(user)).data)
class TimeStatsView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
period = request.query_params.get('period', 'month')
if period not in ('day', 'month', 'year'):
raise ValidationError({'period': 'Vue inconnue. Choisissez jour, mois ou année.'})
anchor_raw = request.query_params.get('anchor') or timezone.localdate().isoformat()
try:
anchor = date.fromisoformat(anchor_raw[:10])
except (TypeError, ValueError):
raise ValidationError({'anchor': 'Date invalide.'})
raw_user_id = request.query_params.get('user_id') or request.user.pk
try:
user_id = int(raw_user_id)
except (TypeError, ValueError):
raise ValidationError({'user_id': 'Utilisateur invalide.'})
user = get_object_or_404(User, pk=user_id, is_active=True)
if user.pk != request.user.pk and not is_admin(request.user):
require_management_permission(
request.user,
'view_statistics',
user.pk,
'Vous n’avez pas l’autorisation de consulter les statistiques de ce salarié.',
)
if period == 'day':
start = end = anchor
elif period == 'month':
start = anchor.replace(day=1)
end = anchor.replace(day=monthrange(anchor.year, anchor.month)[1])
else:
start = date(anchor.year, 1, 1)
end = date(anchor.year, 12, 31)
entries = list(
TimeEntry.objects.filter(user=user, start_date__range=(start, end))
.order_by('start_date', 'start_time', 'pk')
)
total_minutes = 0
sunday_minutes = 0
sunday_dates = set()
incomplete = 0
logical_trips = set()
daily = {}
for entry in entries:
duration = entry.duration
minutes = round(duration * 60) if duration is not None else 0
day = entry.start_date
row = daily.setdefault(day, {'minutes': 0, 'trip_keys': set(), 'incomplete': 0})
if duration is None:
incomplete += 1
row['incomplete'] += 1
continue
total_minutes += minutes
row['minutes'] += minutes
trip_key = f'g:{entry.group_id}' if entry.group_id else f'e:{entry.pk}'
logical_trips.add(trip_key)
row['trip_keys'].add(trip_key)
if day.weekday() == 6:
sunday_minutes += minutes
sunday_dates.add(day)
planned_minutes = planned_minutes_for_period(user.pk, start, end)
breakdown = [
{
'date': day.isoformat(),
'minutes': row['minutes'],
'round_trips': len(row['trip_keys']),
'incomplete_entries': row['incomplete'],
'is_sunday': day.weekday() == 6,
}
for day, row in sorted(daily.items())
]
return Response({
'user': {
'id': user.pk,
'username': user.username,
'first_name': user.first_name,
'last_name': user.last_name,
'color': user.color,
},
'period': period,
'anchor': anchor.isoformat(),
'start': start.isoformat(),
'end': end.isoformat(),
'total_minutes': total_minutes,
'planned_minutes': planned_minutes,
'round_trips': len(logical_trips),
'sundays_worked': len(sunday_dates),
'sunday_minutes': sunday_minutes,
'incomplete_entries': incomplete,
'breakdown': breakdown,
})
class MonthlyRaceView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
current_month_start = timezone.localdate().replace(day=1)
end = current_month_start - timedelta(days=1)
start = end.replace(day=1)
days_count = end.day
users = list(
User.objects.filter(is_active=True)
.order_by('first_name', 'last_name', 'username', 'pk')
)
totals = {user.pk: 0 for user in users}
daily_totals = {user.pk: [0] * days_count for user in users}
entries = TimeEntry.objects.filter(
user_id__in=totals.keys(),
start_date__range=(start, end),
).only(
'id', 'user_id', 'start_date', 'start_time', 'end_date', 'end_time',
'group_id',
)
for entry in entries:
duration = entry.duration
if duration is None:
continue
minutes = round(duration * 60)
totals[entry.user_id] = totals.get(entry.user_id, 0) + minutes
day_index = entry.start_date.day - 1
if 0 <= day_index < days_count:
daily_totals[entry.user_id][day_index] += minutes
rows = [
{
'id': user.pk,
'username': user.username,
'first_name': user.first_name,
'last_name': user.last_name,
'color': user.color,
'total_minutes': totals.get(user.pk, 0),
'daily_minutes': daily_totals.get(user.pk, [0] * days_count),
}
for user in users
]
rows.sort(key=lambda row: (
-row['total_minutes'],
(row['first_name'] or '').lower(),
(row['last_name'] or '').lower(),
(row['username'] or '').lower(),
row['id'],
))
for index, row in enumerate(rows, start=1):
row['rank'] = index
return Response({
'start': start.isoformat(),
'end': end.isoformat(),
'month': start.strftime('%Y-%m'),
'days_count': days_count,
'participants': rows,
})

View File

@ -0,0 +1,38 @@
# Generated by Django 5.2.17 on 2026-09-30 23:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PermissionDefaults',
fields=[
('id', models.PositiveSmallIntegerField(default=1, editable=False, primary_key=True, serialize=False)),
('request_leave', models.BooleanField(default=True)),
('create_personal_event', models.BooleanField(default=True)),
('create_publication', models.BooleanField(default=True)),
('revision', models.PositiveIntegerField(default=0)),
],
options={
'constraints': [models.CheckConstraint(condition=models.Q(('id', 1)), name='single_permission_defaults')],
},
),
migrations.CreateModel(
name='UserPermissions',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('request_leave', models.BooleanField(blank=True, default=None, null=True)),
('create_personal_event', models.BooleanField(blank=True, default=None, null=True)),
('create_publication', models.BooleanField(blank=True, default=None, null=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feature_permissions', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,27 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0002_permissiondefaults_userpermissions'),
]
operations = [
migrations.CreateModel(
name='DelegatedPermission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(choices=[('approve_requests', 'Approuver des demandes'), ('manage_schedules', 'Gérer des emplois du temps'), ('view_statistics', 'Consulter des statistiques')], max_length=32)),
('all_users', models.BooleanField(default=False)),
('targets', models.ManyToManyField(blank=True, related_name='delegated_permission_targets', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='delegated_permissions', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddConstraint(
model_name='delegatedpermission',
constraint=models.UniqueConstraint(fields=('user', 'code'), name='unique_delegated_permission'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-05 14:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_delegatedpermission'),
]
operations = [
migrations.AlterField(
model_name='delegatedpermission',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -11,6 +11,55 @@ class User(AbstractUser):
)
class PermissionDefaults(models.Model):
id = models.PositiveSmallIntegerField(primary_key=True, default=1, editable=False)
request_leave = models.BooleanField(default=True)
create_personal_event = models.BooleanField(default=True)
create_publication = models.BooleanField(default=True)
revision = models.PositiveIntegerField(default=0)
class Meta:
constraints = [models.CheckConstraint(condition=models.Q(id=1), name='single_permission_defaults')]
class UserPermissions(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='feature_permissions')
# None : appliquer la règle de l'équipe. False : refuser. True : autoriser.
request_leave = models.BooleanField(null=True, blank=True, default=None)
create_personal_event = models.BooleanField(null=True, blank=True, default=None)
create_publication = models.BooleanField(null=True, blank=True, default=None)
class DelegatedPermission(models.Model):
"""Responsabilité de gestion accordée à un utilisateur sur un périmètre précis."""
class Code(models.TextChoices):
APPROVE_REQUESTS = 'approve_requests', 'Approuver des demandes'
MANAGE_SCHEDULES = 'manage_schedules', 'Gérer des emplois du temps'
VIEW_STATISTICS = 'view_statistics', 'Consulter des statistiques'
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='delegated_permissions',
)
code = models.CharField(max_length=32, choices=Code.choices)
all_users = models.BooleanField(default=False)
targets = models.ManyToManyField(
User,
blank=True,
related_name='delegated_permission_targets',
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['user', 'code'],
name='unique_delegated_permission',
),
]
class Invitation(models.Model):
token = models.UUIDField(
default=uuid.uuid4,
@ -40,4 +89,4 @@ class Invitation(models.Model):
return (
self.used_at is None
and self.expires_at > timezone.now()
)
)

View File

@ -0,0 +1,158 @@
from django.db import transaction
from django.shortcuts import get_object_or_404
from rest_framework import serializers
from rest_framework.exceptions import APIException, PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import DelegatedPermission, PermissionDefaults, UserPermissions, User
from .permissions import (
MANAGEMENT_PERMISSION_NAMES,
PERMISSION_NAMES,
default_permissions,
effective_permissions,
is_admin,
management_permissions,
)
class PermissionValues(serializers.Serializer):
request_leave = serializers.BooleanField(allow_null=True)
create_personal_event = serializers.BooleanField(allow_null=True)
create_publication = serializers.BooleanField(allow_null=True)
class ResponsibilityValue(serializers.Serializer):
enabled = serializers.BooleanField(default=False)
all_users = serializers.BooleanField(default=False)
user_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=True,
default=list,
)
def validate(self, attrs):
if attrs['enabled'] and not attrs['all_users'] and not attrs['user_ids']:
raise serializers.ValidationError('Choisissez au moins un salarié ou activez « Toute l’équipe ».')
return attrs
class ResponsibilityValues(serializers.Serializer):
approve_requests = ResponsibilityValue(required=False)
manage_schedules = ResponsibilityValue(required=False)
view_statistics = ResponsibilityValue(required=False)
class PermissionInput(serializers.Serializer):
scope = serializers.ChoiceField(choices=['team', 'user'])
user_id = serializers.IntegerField(min_value=1, required=False)
expected_revision = serializers.IntegerField(min_value=0)
permissions = PermissionValues()
responsibilities = ResponsibilityValues(required=False)
reset_individual = serializers.BooleanField(default=False)
def validate(self, attrs):
if attrs['scope'] == 'team' and any(v is None for v in attrs['permissions'].values()):
raise serializers.ValidationError('Choisissez Autorisé ou Non autorisé pour chaque règle de l’équipe.')
if attrs['scope'] == 'user' and (not attrs.get('user_id') or attrs['reset_individual']):
raise serializers.ValidationError('Choisissez le salarié concerné.')
if attrs['scope'] == 'team' and attrs.get('responsibilities'):
raise serializers.ValidationError('Les responsabilités de gestion sont attribuées individuellement.')
return attrs
class PermissionConflict(APIException):
status_code = 409
default_detail = 'Les autorisations ont changé. Actualisez cette page avant d’enregistrer.'
def configuration():
defaults = default_permissions()
state = PermissionDefaults.objects.filter(pk=1).first()
users = []
for user in User.objects.select_related('feature_permissions').order_by('first_name', 'last_name', 'username'):
try:
override = user.feature_permissions
except UserPermissions.DoesNotExist:
override = None
users.append(dict(
id=user.pk,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
is_admin=is_admin(user),
is_active=user.is_active,
overrides={key: getattr(override, key, None) for key in PERMISSION_NAMES},
permissions=effective_permissions(user, defaults),
responsibilities=management_permissions(user),
))
return dict(defaults=defaults, revision=state.revision if state else 0, users=users)
class PermissionConfigurationView(APIView):
permission_classes = [IsAuthenticated]
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
if not is_admin(request.user):
raise PermissionDenied('Seuls les administrateurs peuvent gérer les autorisations.')
@transaction.atomic
def get(self, request):
PermissionDefaults.objects.get_or_create(pk=1)
PermissionDefaults.objects.select_for_update().get(pk=1)
return Response(configuration())
@transaction.atomic
def put(self, request):
serializer = PermissionInput(data=request.data)
serializer.is_valid(raise_exception=True)
values = serializer.validated_data
PermissionDefaults.objects.get_or_create(pk=1)
state = PermissionDefaults.objects.select_for_update().get(pk=1)
if state.revision != values['expected_revision']:
raise PermissionConflict()
if values['scope'] == 'team':
for key, value in values['permissions'].items():
setattr(state, key, value)
if values['reset_individual']:
UserPermissions.objects.all().update(**dict.fromkeys(PERMISSION_NAMES, None))
else:
user = get_object_or_404(User, pk=values['user_id'])
if is_admin(user):
raise serializers.ValidationError('Les administrateurs ont déjà tous les accès de gestion.')
UserPermissions.objects.update_or_create(user=user, defaults=values['permissions'])
responsibilities = values.get('responsibilities', {})
for code in MANAGEMENT_PERMISSION_NAMES:
if code not in responsibilities:
continue
rule = responsibilities[code]
if not rule['enabled']:
DelegatedPermission.objects.filter(user=user, code=code).delete()
continue
target_ids = set(rule.get('user_ids', []))
if not rule['all_users']:
existing_ids = set(User.objects.filter(pk__in=target_ids, is_active=True).values_list('pk', flat=True))
missing = target_ids - existing_ids
if missing:
raise serializers.ValidationError({
'responsibilities': f'Un salarié sélectionné pour « {code} » est introuvable ou désactivé.'
})
row, _created = DelegatedPermission.objects.update_or_create(
user=user,
code=code,
defaults={'all_users': rule['all_users']},
)
if rule['all_users']:
row.targets.clear()
else:
row.targets.set(sorted(target_ids))
state.revision += 1
state.save()
return Response(configuration())

View File

@ -0,0 +1,105 @@
from rest_framework.exceptions import PermissionDenied
from .models import DelegatedPermission, PermissionDefaults, UserPermissions
PERMISSION_NAMES = ('request_leave', 'create_personal_event', 'create_publication')
MANAGEMENT_PERMISSION_NAMES = tuple(value for value, _label in DelegatedPermission.Code.choices)
DENIAL_MESSAGES = {
'request_leave': 'Les demandes de congés ne sont pas activées pour ton compte. Contacte un responsable.',
'create_personal_event': 'L’ajout d’événements personnels n’est pas activé pour ton compte. Contacte un responsable.',
'create_publication': 'La création de publications n’est pas activée pour ton compte. Contacte un responsable.',
}
def is_admin(user):
return bool(user.is_authenticated and user.is_active and (user.is_staff or user.is_superuser))
def default_permissions():
row = PermissionDefaults.objects.filter(pk=1).first()
return {key: getattr(row, key, True) for key in PERMISSION_NAMES}
def effective_permissions(user, defaults=None):
if not user.is_authenticated or not user.is_active:
return dict.fromkeys(PERMISSION_NAMES, False)
if is_admin(user):
return dict.fromkeys(PERMISSION_NAMES, True)
values = dict(defaults) if defaults is not None else default_permissions()
# Ne pas réutiliser une relation mise en cache après un changement de droits.
overrides = UserPermissions.objects.filter(user_id=user.pk).values(*PERMISSION_NAMES).first()
if overrides:
for key in PERMISSION_NAMES:
value = overrides[key]
if value is not None:
values[key] = value
return values
def require_permission(user, name):
if not effective_permissions(user)[name]:
raise PermissionDenied(DENIAL_MESSAGES[name])
def management_permissions(user):
"""Retourne les responsabilités de gestion exposées au frontend."""
if not getattr(user, 'is_authenticated', False) or not user.is_active:
return {
key: {'enabled': False, 'all_users': False, 'user_ids': []}
for key in MANAGEMENT_PERMISSION_NAMES
}
if is_admin(user):
return {
key: {'enabled': True, 'all_users': True, 'user_ids': []}
for key in MANAGEMENT_PERMISSION_NAMES
}
rows = {
row.code: row
for row in DelegatedPermission.objects.filter(user_id=user.pk).prefetch_related('targets')
}
result = {}
for key in MANAGEMENT_PERMISSION_NAMES:
row = rows.get(key)
user_ids = sorted(target.pk for target in row.targets.all()) if row and not row.all_users else []
result[key] = {
'enabled': bool(row and (row.all_users or user_ids)),
'all_users': bool(row and row.all_users),
'user_ids': user_ids,
}
return result
def management_target_ids(user, name):
"""
None = accès illimité (admin ou délégation « toute l'équipe »).
set() = aucun accès.
Sinon, ids des utilisateurs autorisés.
"""
if name not in MANAGEMENT_PERMISSION_NAMES:
raise ValueError(f'Permission de gestion inconnue : {name}')
if not getattr(user, 'is_authenticated', False) or not user.is_active:
return set()
if is_admin(user):
return None
row = DelegatedPermission.objects.filter(user_id=user.pk, code=name).first()
if row is None:
return set()
if row.all_users:
return None
return set(row.targets.values_list('pk', flat=True))
def has_management_permission(user, name, target_user_id=None):
targets = management_target_ids(user, name)
if targets is None:
return True
if target_user_id is None:
return bool(targets)
return int(target_user_id) in targets
def require_management_permission(user, name, target_user_id=None, message=None):
if not has_management_permission(user, name, target_user_id):
raise PermissionDenied(message or 'Vous n’avez pas l’autorisation d’effectuer cette action.')

View File

@ -6,6 +6,7 @@ from django.utils import timezone
from rest_framework import serializers
from .models import Invitation
from .permissions import default_permissions, effective_permissions, management_permissions
User = get_user_model()
@ -13,6 +14,8 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
is_admin = serializers.SerializerMethodField()
permissions = serializers.SerializerMethodField()
management_permissions = serializers.SerializerMethodField()
color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False)
class Meta:
@ -27,6 +30,8 @@ class UserSerializer(serializers.ModelSerializer):
'is_staff',
'is_superuser',
'is_admin',
'permissions',
'management_permissions',
'date_joined',
'color',
]
@ -35,6 +40,14 @@ class UserSerializer(serializers.ModelSerializer):
def get_is_admin(self, obj):
return obj.is_superuser or obj.is_staff
def get_permissions(self, obj):
if not hasattr(self, '_permission_defaults'):
self._permission_defaults = default_permissions()
return effective_permissions(obj, self._permission_defaults)
def get_management_permissions(self, obj):
return management_permissions(obj)
def validate(self, attrs):
actor = self.context['request'].user
if not actor.is_superuser:

View File

@ -0,0 +1,136 @@
from django.contrib.auth import get_user_model
from rest_framework.test import APITestCase
from .models import PermissionDefaults, UserPermissions
from .permissions import PERMISSION_NAMES
class PermissionAPITests(APITestCase):
def setUp(self):
self.admin = get_user_model().objects.create_user(username='manager', is_staff=True)
self.alice = get_user_model().objects.create_user(username='alice')
self.bob = get_user_model().objects.create_user(username='bob')
self.event = dict(name='Rendez-vous', start_date='2030-01-07', end_date='2030-01-07', is_public=False)
self.note = dict(title='Information', content='', kind='info', is_public=True, recipient_ids=[])
self.leave = dict(kind='leave', start_date='2030-01-07', proposed_slots=[])
def edit_permissions(self, scope='team', user=None, reset=False, **values):
self.client.force_authenticate(self.admin)
data = self.client.get('/api/users/permissions/').data
settings = dict(data['defaults']) if scope == 'team' else dict.fromkeys(PERMISSION_NAMES, None)
settings.update(values)
return self.client.put('/api/users/permissions/', dict(scope=scope, user_id=(user or self.alice).pk,
expected_revision=data['revision'], reset_individual=reset, permissions=settings), format='json')
def test_current_and_future_users_inherit_team_rules(self):
self.assertEqual(self.edit_permissions(request_leave=False, create_personal_event=False, create_publication=False).status_code, 200)
newcomer = get_user_model().objects.create_user(username='new')
for person in [self.alice, newcomer]:
self.client.force_authenticate(person)
self.assertEqual(self.client.get('/api/users/me/').data['permissions'], dict.fromkeys(PERMISSION_NAMES, False))
def test_individual_allow_deny_and_return_to_team(self):
self.edit_permissions(request_leave=False)
self.edit_permissions(scope='user', request_leave=True, create_publication=False)
self.client.force_authenticate(self.alice)
self.assertEqual(self.client.get('/api/users/me/').data['permissions'], dict(request_leave=True, create_personal_event=True, create_publication=False))
self.edit_permissions(scope='user')
self.client.force_authenticate(self.alice)
self.assertFalse(self.client.get('/api/users/me/').data['permissions']['request_leave'])
self.assertTrue(self.client.get('/api/users/me/').data['permissions']['create_publication'])
def test_team_change_preserves_individual_overrides_unless_reset(self):
self.edit_permissions(scope='user', create_publication=True)
self.edit_permissions(create_publication=False)
self.client.force_authenticate(self.alice)
self.assertTrue(self.client.get('/api/users/me/').data['permissions']['create_publication'])
self.edit_permissions(reset=True, create_publication=False)
self.client.force_authenticate(self.alice)
self.assertFalse(self.client.get('/api/users/me/').data['permissions']['create_publication'])
self.assertIsNone(UserPermissions.objects.get(user=self.alice).create_publication)
def test_employee_cannot_manage_permissions(self):
self.client.force_authenticate(self.alice)
self.assertEqual(self.client.get('/api/users/permissions/').status_code, 403)
self.assertEqual(self.client.put('/api/users/permissions/', {}, format='json').status_code, 403)
self.assertEqual(self.client.patch('/api/users/me/', {'permissions': {'create_publication': True}}, format='json').status_code, 405)
def test_admin_always_allowed_and_permissions_not_mass_assignable(self):
self.edit_permissions(request_leave=False, create_personal_event=False, create_publication=False)
self.client.force_authenticate(self.admin)
self.assertEqual(self.client.get('/api/users/me/').data['permissions'], dict.fromkeys(PERMISSION_NAMES, True))
self.assertEqual(self.client.post('/api/notes/', self.note, format='json').status_code, 201)
result = self.client.patch(f'/api/users/{self.alice.pk}/', {'permissions': dict.fromkeys(PERMISSION_NAMES, True)}, format='json')
self.assertEqual(result.status_code, 200)
self.assertFalse(result.data['permissions']['create_publication'])
def test_stale_permission_update_returns_conflict(self):
result = self.edit_permissions(request_leave=False)
old_revision = result.data['revision']
self.edit_permissions(request_leave=True)
result = self.client.put('/api/users/permissions/', dict(scope='team', expected_revision=old_revision, permissions=dict.fromkeys(PERMISSION_NAMES, False)), format='json')
self.assertEqual(result.status_code, 409)
self.assertTrue(PermissionDefaults.objects.get().request_leave)
def test_team_cannot_inherit_and_invalid_requests_do_not_save(self):
result = self.edit_permissions(request_leave=None)
self.assertEqual(result.status_code, 400)
self.assertTrue(PermissionDefaults.objects.get().request_leave)
def test_leave_creation_and_preview_blocked_but_hours_allowed(self):
self.edit_permissions(request_leave=False)
self.client.force_authenticate(self.alice)
for path in ['/api/planning/requests/', '/api/planning/requests/preview/']:
self.assertEqual(self.client.post(path, self.leave, format='json').status_code, 403)
request = dict(kind='hours', start_date='2030-01-07', proposed_slots=[dict(start_time='08:00', end_time='12:00', end_day_offset=0)])
self.assertEqual(self.client.post('/api/planning/requests/', request, format='json').status_code, 201)
def test_pending_leave_can_be_read_and_cancelled_after_revocation(self):
self.client.force_authenticate(self.alice)
request = self.client.post('/api/planning/requests/', self.leave, format='json')
self.assertEqual(request.status_code, 201)
self.edit_permissions(request_leave=False)
self.client.force_authenticate(self.alice)
self.assertEqual(self.client.get('/api/planning/requests/').data['count'], 1)
self.assertEqual(self.client.post(f"/api/planning/requests/{request.data['id']}/decision/", {'action': 'cancel'}, format='json').status_code, 200)
def test_both_event_creation_routes_enforce_permission(self):
self.edit_permissions(create_personal_event=False)
self.client.force_authenticate(self.alice)
for path in ['/api/event/', '/api/event/create/']:
self.assertEqual(self.client.post(path, self.event, format='json').status_code, 403)
self.edit_permissions(scope='user', create_personal_event=True)
self.client.force_authenticate(self.alice)
self.assertEqual(self.client.post('/api/event/', self.event, format='json').status_code, 201)
def test_employee_events_stay_private_and_admin_can_publish(self):
self.client.force_authenticate(self.alice)
result = self.client.post('/api/event/', {**self.event, 'is_public': True}, format='json')
self.assertEqual(result.status_code, 201)
self.assertFalse(result.data['is_public'])
self.client.force_authenticate(self.admin)
result = self.client.post('/api/event/', {**self.event, 'is_public': True}, format='json')
self.assertTrue(result.data['is_public'])
def test_existing_personal_event_can_be_edited_after_creation_revoked(self):
self.client.force_authenticate(self.alice)
event = self.client.post('/api/event/', self.event, format='json').data
self.edit_permissions(create_personal_event=False)
self.client.force_authenticate(self.alice)
self.assertEqual(self.client.patch(f"/api/event/{event['id']}/", {'name': 'Rendez-vous modifié'}, format='json').status_code, 200)
def test_publications_blocked_but_reading_and_voting_still_allowed(self):
self.client.force_authenticate(self.admin)
poll = self.client.post('/api/notes/', dict(title='Question', kind='poll', is_public=True, recipient_ids=[], poll_settings=dict(vote_mode='single', options=['Oui', 'Non'])), format='json')
self.assertEqual(poll.status_code, 201, poll.data)
self.edit_permissions(create_publication=False)
self.client.force_authenticate(self.alice)
self.assertEqual(self.client.post('/api/notes/', self.note, format='json').status_code, 403)
self.assertEqual(self.client.get('/api/notes/').status_code, 200)
result = self.client.put(f"/api/notes/{poll.data['id']}/vote/", {'option_ids': [poll.data['poll']['options'][0]['id']]}, format='json')
self.assertEqual(result.status_code, 200, result.data)
def test_default_permissions_preserve_existing_behaviour(self):
self.client.force_authenticate(self.alice)
self.assertEqual(self.client.get('/api/users/me/').data['permissions'], dict.fromkeys(PERMISSION_NAMES, True))
self.assertEqual(self.client.post('/api/notes/', self.note, format='json').status_code, 201)

View File

@ -1,7 +1,9 @@
from django.urls import path
from .permission_views import PermissionConfigurationView
from .views import InvitationCreateView, RegisterView, UserListView, UserMeView, UserDetailView, UserPromoteView, UserDeleteView, ChangePasswordView
urlpatterns = [
path('permissions/', PermissionConfigurationView.as_view(), name='permission-configuration'),
path('register/', RegisterView.as_view(), name='register'),
path('me/', UserMeView.as_view(), name='me'),
path('', UserListView.as_view(), name='user-list'),

View File

@ -25,6 +25,8 @@ import WeeklySummaryPage from './pages/WeeklySummaryPage';
import CalendarPage from './pages/CalendarPage';
import NotesPage from './pages/NotesPage';
import ManageUsersPage from './pages/ManageUsersPage';
import UserStatisticsPage from './pages/UserStatisticsPage';
import ManagementRoute from './components/ManagementRoute';
import { useAuth } from './context/useAuth';
// Composant personnalisé pour le trigger mobile avec icône menu
@ -121,12 +123,21 @@ function App() {
}
/>
<Route
path="/users/statistics"
element={
<ManagementRoute permission="view_statistics">
<UserStatisticsPage />
</ManagementRoute>
}
/>
<Route
path="/users/manage"
element={
<AdminRoute>
<ManagementRoute permission={["manage_schedules", "approve_requests"]}>
<ManageUsersPage />
</AdminRoute>
</ManagementRoute>
}
/>

View File

@ -6,4 +6,9 @@ export const eventApi = {
updateEvent: async (id, data) => (await api.patch(`/event/${id}/`, data)).data,
deleteEvent: async (id) => (await api.delete(`/event/${id}/`)).data,
getEvent: async (id) => (await api.get(`/event/${id}/`)).data,
getProfiles: async () => (await api.get('/event/profiles/')).data,
createProfile: async (data) => (await api.post('/event/profiles/', data)).data,
updateProfile: async (id, data) => (await api.patch(`/event/profiles/${id}/`, data)).data,
deleteProfile: async (id) => (await api.delete(`/event/profiles/${id}/`)).data,
};

View File

@ -1,6 +1,8 @@
import api from './axios.js';
export const notesApi = {
vote: async (id, optionIds) => (await api.put(`/notes/${id}/vote/`, { option_ids: optionIds })).data,
closePoll: async (id) => (await api.post(`/notes/${id}/close/`)).data,
list: async (params = {}, options = {}) => (await api.get('/notes/', { ...options, params })).data,
recipients: async (options = {}) => (await api.get('/notes/recipients/', options)).data,
create: async (data) => (await api.post('/notes/', data)).data,

View File

@ -0,0 +1,15 @@
import api from './axios';
export const planningApi = {
people: async (signal) => (await api.get('/planning/people/', { signal })).data,
config: async (userId, signal) => (await api.get('/planning/config/', { params: { user_id: userId }, signal })).data,
effective: async (userId, start, end, signal) => (await api.get('/planning/effective/', { params: { user_id: userId, start, end }, signal })).data,
saveSchedule: async (data) => (await api.post('/planning/schedules/', data)).data,
saveException: async (data, id) => (await (id ? api.put(`/planning/exceptions/${id}/`, data) : api.post('/planning/exceptions/', data))).data,
removeException: async (id, revision) => api.delete(`/planning/exceptions/${id}/`, { data: { expected_revision: revision } }),
requests: async (params, signal) => (await api.get('/planning/requests/', { params, signal })).data,
preview: async (data) => (await api.post('/planning/requests/preview/', data)).data,
createRequest: async (data) => (await api.post('/planning/requests/', data)).data,
swapTargetDay: async (id, date, signal) => (await api.get(`/planning/requests/${id}/swap-target-day/`, { params: { date }, signal })).data,
decide: async (id, action, response = '', extra = {}) => (await api.post(`/planning/requests/${id}/decision/`, { action, response, ...extra })).data,
};

View File

@ -12,6 +12,8 @@ export const timeApi = {
deleteTimeEntryGroup: async (id) => (await api.delete(`/time/groups/${id}/`)).data,
getWeeklySummary: async () => (await api.get('/time/weekly-summary/')).data,
getUserWeeklySummary: async (id) => (await api.get(`/time/weekly-summary/${id}/`)).data,
getStats: async (params, signal) => (await api.get('/time/stats/', { params, signal })).data,
getMonthlyRace: async (signal) => (await api.get('/time/monthly-race/', { signal })).data,
scanTimeEntry: async (code, options = {}) => {
try {
return (await api.post('/time/entries/scan/', { code, ...options })).data;
@ -22,6 +24,7 @@ export const timeApi = {
error.missingTime = data.missing_time;
error.scanTime = data.scan_time;
error.scanDate = data.scan_date;
error.entryDate = data.entry_date;
error.entryId = data.entry_id;
error.code = error.response?.status;
throw error;

View File

@ -1,6 +1,12 @@
import api from './axios';
export const userApi = {
getPermissions: async (signal) => (await api.get('/users/permissions/', { signal })).data,
savePermissions: async (data) => {
const result = (await api.put('/users/permissions/', data)).data;
window.dispatchEvent(new Event('auth:permissions-changed'));
return result;
},
getUsers: async () => (await api.get('/users/')).data,
deleteUser: async (id) => (await api.delete(`/users/${id}/`)).data,
promoteUser: async (id) => (await api.post(`/users/${id}/promote/`)).data,

View File

@ -5,7 +5,6 @@ import {
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
@ -58,7 +57,7 @@ const ConfirmDialog = ({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<div className="flex flex-wrap items-center justify-end gap-2">
<AlertDialogCancel onClick={handleCancel}>
{cancelText}
</AlertDialogCancel>
@ -66,7 +65,7 @@ const ConfirmDialog = ({
<AlertDialogAction onClick={handleConfirm}>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</div>
</AlertDialogContent>
</AlertDialog>
);

View File

@ -0,0 +1,21 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/context/useAuth';
const hasPermission = (user, permission) => {
if (!user) return false;
if (user.is_admin) return true;
const permissions = Array.isArray(permission) ? permission : [permission];
return permissions.some((key) => user.management_permissions?.[key]?.enabled);
};
export default function ManagementRoute({ permission, children }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <Skeleton className="h-16 w-full" />;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
if (!hasPermission(user, permission)) return <Navigate to="/" replace />;
return children;
}

View File

@ -5,7 +5,6 @@ import { useAuth } from '../context/useAuth';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
@ -126,23 +125,26 @@ const NavBar = () => {
/*
* Administration
*/
const canViewStatistics = user.is_admin || user.management_permissions?.view_statistics?.enabled;
const canManageTeam = user.is_admin || user.management_permissions?.manage_schedules?.enabled || user.management_permissions?.approve_requests?.enabled;
const userNavigation = [
{
...(user.is_admin ? [{
title: 'Liste',
href: '/users',
icon: List,
end: true,
},
{
}] : []),
...(canViewStatistics ? [{
title: 'Statistiques',
href: '/users/statistics',
icon: BarChart3,
},
{
}] : []),
...(canManageTeam ? [{
title: 'Gestion',
href: '/users/manage',
icon: UserCog,
},
}] : []),
];
const menuItemClass =
@ -306,7 +308,7 @@ const NavBar = () => {
</SidebarGroup>
{/* Administration */}
{user.is_admin && (
{userNavigation.length > 0 && (
<SidebarGroup className="p-2">
<GroupHeader>
Administration
@ -393,7 +395,7 @@ const NavBar = () => {
</SidebarContent>
{/* Déconnexion */}
<SidebarFooter>
<div className="mt-auto p-2">
<SidebarMenu>
<SidebarMenuItem className={menuItemClass}>
<SidebarMenuButton
@ -416,7 +418,7 @@ const NavBar = () => {
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</div>
</Sidebar>
);
};

View File

@ -0,0 +1,253 @@
import { useEffect, useState } from 'react';
import { RefreshCw, Save, ShieldCheck, UserRoundCog } from 'lucide-react';
import { userApi } from '@/api/users';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { inputClass, personLabel } from '@/components/planning/utils';
const RULES = [
{ key: 'request_leave', title: 'Demander un congé', description: 'Envoyer une demande de congé ou d’absence depuis Mon espace. Les changements d’horaires et échanges de journées restent disponibles.' },
{ key: 'create_personal_event', title: 'Ajouter un événement personnel', description: 'Ajouter un rendez-vous ou un rappel dans son calendrier. Seul le salarié voit ses événements personnels.' },
{ key: 'create_publication', title: 'Créer une publication', description: 'Publier une information, une tâche, un mémo, une suggestion ou un sondage.' },
];
const RESPONSIBILITIES = [
{ key: 'approve_requests', title: 'Approuver les demandes', description: 'Accepter ou refuser les congés, échanges et déplacements envoyés par les salariés sélectionnés.' },
{ key: 'manage_schedules', title: 'Gérer les emplois du temps', description: 'Créer et modifier le planning habituel et les exceptions des salariés sélectionnés.' },
{ key: 'view_statistics', title: 'Consulter les statistiques', description: 'Voir les heures, aller-retours et dimanches travaillés des salariés sélectionnés.' },
];
const emptyResponsibility = () => ({ enabled: false, all_users: false, user_ids: [] });
export default function PermissionManager() {
const [config, setConfig] = useState(null);
const [scope, setScope] = useState('team');
const [values, setValues] = useState({});
const [responsibilities, setResponsibilities] = useState({});
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [reload, setReload] = useState(0);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [resetOpen, setResetOpen] = useState(false);
const applyScope = (data, value) => {
if (value === 'team') {
setValues(data.defaults);
setResponsibilities({});
return;
}
const selected = data.users.find((row) => String(row.id) === String(value));
setValues(selected?.overrides || {});
setResponsibilities(selected?.responsibilities || {});
};
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError('');
userApi.getPermissions(controller.signal)
.then((data) => {
setConfig(data);
setScope('team');
applyScope(data, 'team');
})
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [reload]);
const person = config?.users.find((row) => String(row.id) === String(scope));
const targetPeople = (config?.users || []).filter((row) => row.is_active && String(row.id) !== String(scope));
const select = (value) => {
setScope(value);
setError('');
setNotice('');
applyScope(config, value);
};
const updateResponsibility = (key, patch) => {
setResponsibilities((old) => ({
...old,
[key]: { ...(old[key] || emptyResponsibility()), ...patch },
}));
setNotice('');
};
const toggleTarget = (key, userId, checked) => {
const current = responsibilities[key] || emptyResponsibility();
const ids = new Set((current.user_ids || []).map(Number));
if (checked) ids.add(Number(userId));
else ids.delete(Number(userId));
updateResponsibility(key, { user_ids: [...ids].sort((a, b) => a - b) });
};
const save = async (reset = false) => {
if (busy) return;
setBusy(true);
setError('');
setNotice('');
try {
const payload = {
scope: scope === 'team' ? 'team' : 'user',
...(scope !== 'team' ? { user_id: Number(scope), responsibilities } : {}),
permissions: values,
expected_revision: config.revision,
reset_individual: reset,
};
const updated = await userApi.savePermissions(payload);
setConfig(updated);
applyScope(updated, scope);
setResetOpen(false);
setNotice(
reset
? 'Les mêmes autorisations s’appliquent maintenant à tous les salariés, actuels et futurs.'
: scope === 'team'
? 'Les autorisations de l’équipe sont enregistrées.'
: `Les autorisations de ${personLabel(person)} sont enregistrées.`,
);
} catch (e) {
setError(e.message);
setResetOpen(false);
} finally {
setBusy(false);
}
};
return (
<section className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="flex items-center gap-2 text-lg font-semibold"><ShieldCheck className="size-5" />Autorisations</h2>
<p className="max-w-3xl text-sm text-muted-foreground">Choisis ce que chaque salarié peut faire, puis éventuellement les personnes dont il peut gérer les demandes, plannings ou statistiques.</p>
</div>
<Button variant="ghost" size="icon" disabled={busy || loading} aria-label="Actualiser les autorisations" onClick={() => setReload((n) => n + 1)}>
<RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{error && <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
{notice && <p role="status" className="rounded-lg bg-primary/10 p-3 text-sm">{notice}</p>}
{loading ? <p className="text-sm text-muted-foreground">Chargement des autorisations…</p> : config && (
<>
<label className="grid max-w-md gap-1.5 text-sm font-medium">
Régler les autorisations pour
<select className={inputClass} value={scope} disabled={busy} onChange={(e) => select(e.target.value)}>
<option value="team">Toute l’équipe</option>
{config.users.map((row) => (
<option key={row.id} value={row.id}>{personLabel(row)}{row.is_admin ? ' · administrateur' : !row.is_active ? ' · compte désactivé' : ''}</option>
))}
</select>
</label>
<p className="rounded-lg border bg-muted/30 p-3 text-sm text-muted-foreground">
{scope === 'team'
? 'Ces règles générales s’appliquent aux salariés actuels et aux nouveaux comptes. Les responsabilités de gestion se règlent ensuite salarié par salarié.'
: person?.is_admin
? 'Ce compte est administrateur : il possède déjà toutes les autorisations et peut gérer toute l’équipe.'
: 'Les autorisations ci-dessous concernent ce salarié. Les responsabilités permettent de lui confier une partie de la gestion sans le rendre administrateur.'}
</p>
<div className="space-y-3">
{RULES.map(({ key, title, description }) => {
const allowed = person?.is_admin || (values[key] ?? config.defaults[key]);
return (
<div key={key} className="flex flex-col gap-4 rounded-xl border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<label id={`permission-label-${key}`} htmlFor={`permission-${key}`} className="font-medium">{title}</label>
<p className="max-w-xl text-sm text-muted-foreground">{description}</p>
</div>
<div className="flex shrink-0 items-center gap-3">
{scope === 'team' ? (
<>
<span className="text-sm">{values[key] ? 'Autorisé' : 'Non autorisé'}</span>
<Switch id={`permission-${key}`} aria-labelledby={`permission-label-${key}`} checked={Boolean(values[key])} disabled={busy} onCheckedChange={(checked) => { setValues((old) => ({ ...old, [key]: checked })); setNotice(''); }} />
</>
) : person?.is_admin ? <Badge>Autorisé</Badge> : (
<div className="space-y-1">
<select id={`permission-${key}`} className={inputClass} value={values[key] === null ? 'inherit' : values[key] ? 'allow' : 'deny'} disabled={busy} onChange={(e) => { setValues((old) => ({ ...old, [key]: e.target.value === 'inherit' ? null : e.target.value === 'allow' })); setNotice(''); }}>
<option value="inherit">Comme toute l’équipe</option>
<option value="allow">Autoriser</option>
<option value="deny">Ne pas autoriser</option>
</select>
<p className="text-xs text-muted-foreground">{allowed ? 'Autorisé' : 'Non autorisé'}{values[key] === null ? ' par la règle de l’équipe' : ' pour ce salarié'}</p>
</div>
)}
</div>
</div>
);
})}
</div>
{scope !== 'team' && !person?.is_admin && (
<section className="space-y-3 rounded-xl border bg-muted/20 p-4">
<div className="space-y-1">
<h3 className="flex items-center gap-2 font-semibold"><UserRoundCog className="size-4" />Responsabilités de gestion</h3>
<p className="text-sm text-muted-foreground">Active un droit puis choisis exactement quels salariés sont concernés. « Toute l’équipe » inclura aussi les futurs salariés.</p>
</div>
{RESPONSIBILITIES.map(({ key, title, description }) => {
const rule = responsibilities[key] || emptyResponsibility();
return (
<div key={key} className="space-y-3 rounded-lg border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<label htmlFor={`responsibility-${key}`} className="font-medium">{title}</label>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<Switch id={`responsibility-${key}`} checked={Boolean(rule.enabled)} disabled={busy} onCheckedChange={(checked) => updateResponsibility(key, { enabled: checked, ...(checked ? {} : { all_users: false, user_ids: [] }) })} />
</div>
{rule.enabled && (
<div className="space-y-3 border-t pt-3">
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium">
<input type="checkbox" className="size-4 accent-primary" checked={Boolean(rule.all_users)} disabled={busy} onChange={(e) => updateResponsibility(key, { all_users: e.target.checked, ...(e.target.checked ? { user_ids: [] } : {}) })} />
Toute l’équipe
</label>
{!rule.all_users && (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{targetPeople.length ? targetPeople.map((target) => (
<label key={target.id} className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted/40">
<input type="checkbox" className="size-4 shrink-0 accent-primary" checked={(rule.user_ids || []).map(Number).includes(Number(target.id))} disabled={busy} onChange={(e) => toggleTarget(key, target.id, e.target.checked)} />
<span className="min-w-0 truncate">{personLabel(target)}</span>
</label>
)) : <p className="text-sm text-muted-foreground">Aucun autre salarié actif.</p>}
</div>
)}
</div>
)}
</div>
);
})}
</section>
)}
{!person?.is_admin && (
<div className="flex flex-wrap justify-end gap-2">
{scope === 'team' && <Button variant="outline" disabled={busy} onClick={() => setResetOpen(true)}>Appliquer à tous les salariés</Button>}
<Button disabled={busy} onClick={() => save()}><Save className="size-4" />{busy ? 'Enregistrement…' : 'Enregistrer les autorisations'}</Button>
</div>
)}
</>
)}
<Dialog open={resetOpen} onOpenChange={(open) => { if (!busy) setResetOpen(open); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Appliquer ces règles à tout le monde ?</DialogTitle>
<DialogDescription>Les réglages individuels des trois autorisations générales seront remplacés par les règles affichées. Les responsabilités de gestion restent inchangées.</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" disabled={busy} onClick={() => setResetOpen(false)}>Annuler</Button>
<Button disabled={busy} onClick={() => save(true)}>{busy ? 'Enregistrement…' : 'Appliquer à tous'}</Button>
</div>
</DialogContent>
</Dialog>
</section>
);
}

View File

@ -3,7 +3,7 @@ import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CalendarDays, Clock } from 'lucide-react';
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { fr } from 'date-fns/locale';
const parseDateValue = (value) => {
@ -91,7 +91,10 @@ const DesktopDatePicker = ({
value,
onChange,
min,
max,
placeholder = 'Choisir une date',
disabled = false,
className = '',
}) => {
const [open, setOpen] =
useState(false);
@ -102,6 +105,9 @@ const DesktopDatePicker = ({
const minDate =
parseDateValue(min);
const maxDate =
parseDateValue(max);
return (
<Popover
open={open}
@ -112,7 +118,8 @@ const DesktopDatePicker = ({
<Button
type="button"
variant="outline"
className="w-full justify-start gap-2 font-normal"
disabled={disabled}
className={`w-full justify-start gap-2 font-normal ${className}`}
/>
}
>
@ -146,10 +153,11 @@ const DesktopDatePicker = ({
new Date()
}
disabled={
minDate
? {
before: minDate,
}
minDate || maxDate
? (date) => (
(minDate && date < minDate) ||
(maxDate && date > maxDate)
)
: undefined
}
onSelect={(date) => {
@ -168,13 +176,246 @@ const DesktopDatePicker = ({
};
const MONTH_NAMES = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre',
];
const parseMonthValue = (value) => {
if (!value) return null;
const [year, month] = String(value).split('-').map(Number);
if (!year || !month || month < 1 || month > 12) return null;
return { year, month: month - 1 };
};
const formatMonthValue = (year, month) => `${year}-${String(month + 1).padStart(2, '0')}`;
const monthIndex = (year, month) => year * 12 + month;
const yearFromBound = (value, fallback) => {
const year = Number(String(value || '').slice(0, 4));
return Number.isFinite(year) && year > 0 ? year : fallback;
};
const DesktopMonthPicker = ({
value,
onChange,
min,
max,
placeholder = 'Choisir un mois',
disabled = false,
className = '',
}) => {
const selected = parseMonthValue(value);
const current = new Date();
const [open, setOpen] = useState(false);
const [displayYear, setDisplayYear] = useState(selected?.year || current.getFullYear());
const minMonth = parseMonthValue(min);
const maxMonth = parseMonthValue(max);
const minIndex = minMonth ? monthIndex(minMonth.year, minMonth.month) : null;
const maxIndex = maxMonth ? monthIndex(maxMonth.year, maxMonth.month) : null;
const isAllowed = (year, month) => {
const index = monthIndex(year, month);
return (minIndex === null || index >= minIndex) && (maxIndex === null || index <= maxIndex);
};
const canShowYear = (year) => Array.from({ length: 12 }, (_, month) => isAllowed(year, month)).some(Boolean);
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) setDisplayYear(selected?.year || current.getFullYear());
}}
>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
disabled={disabled}
className={`w-full justify-start gap-2 font-normal ${className}`}
/>
}
>
<CalendarDays className="size-4 text-muted-foreground" />
<span className={selected ? 'capitalize' : 'text-muted-foreground'}>
{selected
? new Date(selected.year, selected.month, 1).toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' })
: placeholder}
</span>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-3" align="start">
<div className="mb-3 flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={!canShowYear(displayYear - 1)}
onClick={() => setDisplayYear((year) => year - 1)}
aria-label="Année précédente"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm font-semibold tabular-nums">{displayYear}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={!canShowYear(displayYear + 1)}
onClick={() => setDisplayYear((year) => year + 1)}
aria-label="Année suivante"
>
<ChevronRight className="size-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-1.5">
{MONTH_NAMES.map((name, month) => {
const active = selected?.year === displayYear && selected?.month === month;
return (
<Button
key={name}
type="button"
size="sm"
variant={active ? 'default' : 'ghost'}
className="justify-center px-2 text-xs"
disabled={!isAllowed(displayYear, month)}
onClick={() => {
onChange(formatMonthValue(displayYear, month));
setOpen(false);
}}
>
{name.slice(0, 4)}
</Button>
);
})}
</div>
</PopoverContent>
</Popover>
);
};
const DesktopYearPicker = ({
value,
onChange,
min,
max,
placeholder = 'Choisir une année',
disabled = false,
className = '',
}) => {
const currentYear = new Date().getFullYear();
const selectedYear = Number(value) || null;
const minYear = yearFromBound(min, 1900);
const maxYear = yearFromBound(max, 2100);
const baseYear = selectedYear || Math.min(Math.max(currentYear, minYear), maxYear);
const [open, setOpen] = useState(false);
const [pageStart, setPageStart] = useState(baseYear - (baseYear % 12));
const years = Array.from({ length: 12 }, (_, index) => pageStart + index);
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (nextOpen) {
const year = selectedYear || Math.min(Math.max(currentYear, minYear), maxYear);
setPageStart(year - (year % 12));
}
}}
>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
disabled={disabled}
className={`w-full justify-start gap-2 font-normal ${className}`}
/>
}
>
<CalendarDays className="size-4 text-muted-foreground" />
<span className={selectedYear ? 'tabular-nums' : 'text-muted-foreground'}>
{selectedYear || placeholder}
</span>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-3" align="start">
<div className="mb-3 flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={pageStart <= minYear}
onClick={() => setPageStart((year) => year - 12)}
aria-label="Années précédentes"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm font-semibold tabular-nums">
{Math.max(pageStart, minYear)} – {Math.min(pageStart + 11, maxYear)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
disabled={pageStart + 11 >= maxYear}
onClick={() => setPageStart((year) => year + 12)}
aria-label="Années suivantes"
>
<ChevronRight className="size-4" />
</Button>
</div>
<div className="grid grid-cols-3 gap-1.5">
{years.map((year) => (
<Button
key={year}
type="button"
size="sm"
variant={selectedYear === year ? 'default' : 'ghost'}
disabled={year < minYear || year > maxYear}
onClick={() => {
onChange(String(year));
setOpen(false);
}}
>
{year}
</Button>
))}
</div>
</PopoverContent>
</Popover>
);
};
/* =========================================================
TIME PICKER
========================================================= */
const DesktopTimePicker = ({
value,
onChange
onChange,
disabled = false,
className = '',
}) => {
const [open, setOpen] = useState(false);
const timeRef = useRef(value);
@ -330,7 +571,8 @@ const DesktopTimePicker = ({
<Button
type="button"
variant="outline"
className="w-full justify-start gap-2 font-normal"
disabled={disabled}
className={`w-full justify-start gap-2 font-normal ${className}`}
/>
}
>
@ -503,35 +745,105 @@ const DesktopTimePicker = ({
};
// Les champs natifs ouvrent le sélecteur du téléphone.
// Sur ordinateur, on conserve le calendrier et la molette de TimeClock.
export const DatePicker = ({ value, onChange, min, placeholder, id }) => (
<>
<Input
id={id}
type="date"
min={min}
value={value || ''}
onChange={(event) => onChange(event.target.value)}
className="block w-full min-w-0 md:hidden"
/>
<div className="hidden md:block">
<DesktopDatePicker value={value} onChange={onChange} min={min} placeholder={placeholder} />
</div>
</>
);
// Les champs natifs restent pratiques sur téléphone pour les dates et les heures.
// Les mois et années utilisent le même DatePicker partout pour garder une UI cohérente.
export const DatePicker = ({
value,
onChange,
min,
max,
placeholder,
id,
mode = 'date',
disabled = false,
required = false,
className = '',
ariaLabel,
}) => {
if (mode === 'month') {
return (
<DesktopMonthPicker
value={value}
onChange={onChange}
min={min}
max={max}
placeholder={placeholder}
disabled={disabled}
className={className}
/>
);
}
export const TimePicker = ({ value, onChange, id }) => (
if (mode === 'year') {
return (
<DesktopYearPicker
value={value}
onChange={onChange}
min={min}
max={max}
placeholder={placeholder}
disabled={disabled}
className={className}
/>
);
}
return (
<>
<Input
id={id}
aria-label={ariaLabel}
type="date"
min={min}
max={max}
value={value || ''}
required={required}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className={`block w-full min-w-0 md:hidden ${className}`}
/>
<div className="hidden md:block">
<DesktopDatePicker
value={value}
onChange={onChange}
min={min}
max={max}
placeholder={placeholder}
disabled={disabled}
className={className}
/>
</div>
</>
);
};
export const TimePicker = ({
value,
onChange,
id,
disabled = false,
required = false,
className = '',
ariaLabel,
}) => (
<>
<Input
id={id}
aria-label={ariaLabel}
type="time"
value={value || ''}
required={required}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="block w-full min-w-0 md:hidden"
className={`block w-full min-w-0 md:hidden ${className}`}
/>
<div className="hidden md:block">
<DesktopTimePicker value={value} onChange={onChange} />
<DesktopTimePicker
value={value}
onChange={onChange}
disabled={disabled}
className={className}
/>
</div>
</>
);

View File

@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react';
import { BarChart3, Check, LockKeyhole, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import ConfirmDialog from '@/components/ConfirmDialog';
import { notesApi } from '@/api/notes';
import { personLabel } from '@/components/planning/utils';
export default function PollCard({ note, onChange }) {
const poll = note.poll;
const [selected, setSelected] = useState(poll?.my_option_ids || []);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const ownKey = JSON.stringify(poll?.my_option_ids || []);
useEffect(() => { setSelected(JSON.parse(ownKey)); }, [ownKey]);
if (!poll) return <p className="text-sm text-destructive">Les données du sondage sont indisponibles.</p>;
const required = poll.vote_mode === 'single' ? 1 : poll.vote_mode === 'exact' ? poll.exact_choices : null;
const closed = poll.is_closed || Boolean(poll.closes_at && new Date(poll.closes_at) <= new Date());
const canVote = poll.can_vote && !closed;
const valid = required === null ? selected.length > 0 : selected.length === required;
const changed = [...selected].sort((a, b) => a - b).join(',') !== [...poll.my_option_ids].sort((a, b) => a - b).join(',');
const toggle = (id) => {
setError(''); setNotice('');
setSelected((current) => poll.vote_mode === 'single' ? [id] : current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
};
const mutate = async (action) => {
if (pending.current) return; pending.current = true; setBusy(true); setError(''); setNotice('');
try { const updated = action === 'vote' ? await notesApi.vote(note.id, selected) : await notesApi.closePoll(note.id); onChange(updated); setNotice(action === 'vote' ? 'Ton vote est enregistré.' : 'Sondage clôturé.'); }
catch (e) { setError(e.message); } finally { pending.current = false; setBusy(false); }
};
return <div className="space-y-3 border-t pt-3">
<div className="flex flex-wrap items-center gap-1.5"><Badge variant="secondary">{poll.is_anonymous ? <LockKeyhole className="size-3" /> : <Users className="size-3" />}{poll.is_anonymous ? 'Anonyme' : 'Noms visibles'}</Badge><Badge variant="outline">{closed ? 'Clôturé' : required === 1 ? '1 choix' : required ? `${required} choix exactement` : 'Choix multiples'}</Badge></div>
{(() => {
const choices = poll.options.map((option) => <div key={option.id} className={`relative overflow-hidden rounded-lg border transition-colors ${selected.includes(option.id) ? 'border-primary/50 bg-primary/5' : 'border-border hover:bg-muted/40'}`}>
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-0 bg-primary/10 transition-[width]" style={{ width: `${option.percent}%` }} />
<label className={`relative flex items-start gap-2 p-3 text-sm ${canVote ? 'cursor-pointer' : ''}`}>
{poll.vote_mode === 'single'
? <RadioGroupItem value={String(option.id)} className="mt-0.5" aria-labelledby={`poll-${note.id}-option-${option.id}-text`} />
: <Checkbox className="mt-0.5" aria-labelledby={`poll-${note.id}-option-${option.id}-text`} checked={selected.includes(option.id)} disabled={busy || !canVote} onCheckedChange={() => toggle(option.id)} />}
<span id={`poll-${note.id}-option-${option.id}-text`} className="min-w-0 flex-1 break-words">{option.label}</span><span className="shrink-0 tabular-nums text-xs text-muted-foreground">{option.count} · {option.percent}%</span>
</label>
{!poll.is_anonymous && option.voters?.length > 0 && <p className="relative break-words px-3 pb-2 text-xs text-muted-foreground">{option.voters.map(personLabel).join(', ')}</p>}
</div>);
return poll.vote_mode === 'single'
? <RadioGroup name={`poll-${note.id}`} aria-label="Choisir une réponse" value={selected[0] ? String(selected[0]) : ''} disabled={busy || !canVote} onValueChange={(value) => toggle(Number(value))}>{choices}</RadioGroup>
: <div role="group" aria-label="Choisir les réponses" className="space-y-2">{choices}</div>;
})()}
<div className="space-y-1 text-xs text-muted-foreground"><p className="flex items-center gap-1.5"><BarChart3 className="size-3.5" />{poll.total_voters} participant{poll.total_voters > 1 ? 's' : ''} · part des votants ayant choisi chaque réponse</p>{poll.closes_at && <p>Clôture : {new Date(poll.closes_at).toLocaleString('fr-FR')}</p>}{canVote && <p>{required ? `${selected.length} / ${required} choix sélectionné(s).` : `${selected.length} choix sélectionné(s).`} Tu peux modifier ton vote jusqu’à la clôture.</p>}{!canVote && !closed && <p>Ce sondage s’adresse à d’autres personnes. Tu peux consulter les résultats.</p>}</div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700 dark:text-emerald-300">{notice}</p>}
<div className="flex flex-wrap gap-2">{canVote && <Button type="button" size="sm" disabled={busy || !valid || !changed} onClick={() => mutate('vote')}><Check className="size-4" />{busy ? 'Enregistrement…' : poll.my_option_ids.length ? 'Modifier mon vote' : 'Voter'}</Button>}{note.can_edit && !closed && <ConfirmDialog title="Clôturer le sondage" description="Les résultats resteront visibles. Aucun nouveau vote ni changement de réponse ne sera possible." confirmText="Clôturer" onConfirm={() => mutate('close')}><Button type="button" variant="outline" size="sm" disabled={busy}>Clôturer</Button></ConfirmDialog>}</div>
</div>;
}

View File

@ -0,0 +1,21 @@
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { inputClass } from '@/components/planning/utils';
export default function PollEditor({ value, onChange, disabled, locked = false }) {
const update = (change) => onChange({ ...value, ...change });
return <div className="space-y-4 rounded-lg border p-3">
<p className="font-medium">Préparer le sondage</p>
{locked && <p className="text-xs text-muted-foreground">Quelqu’un a déjà voté. La question, les réponses et les règles ne peuvent plus être changées.</p>}
<fieldset disabled={disabled || locked} className="space-y-4">
<div className="space-y-2"><p className="text-sm font-medium">Combien de réponses peut-on choisir ?</p><RadioGroup aria-label="Nombre de réponses par personne" value={value.vote_mode} disabled={disabled || locked} onValueChange={(vote_mode) => update({ vote_mode })}>{[['single', 'Une seule réponse'], ['multiple', 'Plusieurs réponses, au choix'], ['exact', 'Un nombre précis de réponses']].map(([mode, label]) => <label key={mode} className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-sm ${value.vote_mode === mode ? 'border-primary/50 bg-primary/5' : ''}`}><RadioGroupItem value={mode} />{label}</label>)}</RadioGroup></div>
{value.vote_mode === 'exact' && <label className="grid gap-1.5 text-sm">Nombre de réponses à choisir<input type="number" required min={1} max={value.options.length} className={inputClass} value={value.exact_choices} onChange={(e) => update({ exact_choices: Number(e.target.value) })} /></label>}
<div className="rounded-lg border p-3"><label className="flex cursor-pointer items-center justify-between gap-3 text-sm font-medium"><span id="poll-anonymous-label">Garder les votes anonymes</span><Switch aria-labelledby="poll-anonymous-label" checked={value.is_anonymous} disabled={disabled || locked} onCheckedChange={(is_anonymous) => update({ is_anonymous })} /></label><p className="mt-2 text-xs text-muted-foreground">{value.is_anonymous ? 'Les noms ne seront pas affichés avec les réponses.' : 'Les noms seront affichés à côté des réponses choisies.'}</p></div>
<div className="space-y-2"><p className="text-sm">Réponses proposées</p>{value.options.map((option, i) => <div key={i} className="flex items-center gap-2"><input aria-label={`Réponse ${i + 1}`} className={inputClass} required maxLength={240} placeholder={`Réponse ${i + 1}`} value={option} onChange={(e) => update({ options: value.options.map((text, index) => index === i ? e.target.value : text) })} /><Button type="button" variant="ghost" size="icon" aria-label={`Supprimer la réponse ${i + 1}`} disabled={disabled || locked || value.options.length <= 2} onClick={() => update({ options: value.options.filter((_, index) => index !== i) })}><Trash2 className="size-4" /></Button></div>)}<Button type="button" variant="outline" size="sm" disabled={disabled || locked || value.options.length >= 30} onClick={() => update({ options: [...value.options, ''] })}><Plus className="size-4" />Ajouter une réponse</Button></div>
</fieldset>
<p className="text-xs leading-relaxed text-muted-foreground">Le sondage est toujours public : toute l’équipe peut voter. Le vote reste modifiable tant qu’un responsable ne clôture pas le sondage.</p>
</div>;
}

View File

@ -0,0 +1,2 @@
export const emptyPoll = () => ({ vote_mode: 'single', exact_choices: 2, is_anonymous: true, options: ['', ''] });
export const pollForm = (poll) => poll ? { vote_mode: poll.vote_mode, exact_choices: poll.exact_choices, is_anonymous: poll.is_anonymous, options: poll.options.map((o) => o.label) } : emptyPoll();

View File

@ -0,0 +1,17 @@
import { dateLabel, dayLabel, personLabel } from './utils';
export default function ChangePreview({ before = [], after = [], people = [] }) {
return (
<div className="space-y-2">
{before.map((day, index) => (
<div key={`${day.user_id}-${day.date}`} className="rounded-lg border p-3 text-sm">
<p className="font-medium">{personLabel(people.find((p) => String(p.id) === String(day.user_id)))} · {dateLabel(day.date)}</p>
<dl className="mt-2 grid grid-cols-[54px_1fr] gap-x-2 gap-y-1">
<dt className="text-muted-foreground">Avant</dt><dd className="break-words">{dayLabel(day)}</dd>
<dt className="text-muted-foreground">Après</dt><dd className="break-words font-medium">{dayLabel(after[index] || day)}</dd>
</dl>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,386 @@
import { useEffect, useMemo, useState } from 'react';
import {
ArrowLeftRight,
CalendarClock,
CalendarOff,
ChevronLeft,
ChevronRight,
MoveRight,
} from 'lucide-react';
import { planningApi } from '@/api/planning';
import { can } from '@/lib/permissions';
import { useIsMobile } from '@/hooks/use-mobile';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ResponsivePickers';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import ChangePreview from './ChangePreview';
import RequestsList from './RequestsList';
import SlotsEditor from './SlotsEditor';
import { dateKey, dateLabel, inputClass, personLabel, slotLabel } from './utils';
const DAY_MS = 86400000;
const parseDate = (value) => new Date(`${value}T12:00:00`);
const addDays = (value, amount) => dateKey(new Date(parseDate(value).getTime() + amount * DAY_MS));
const mondayOf = (value) => {
const date = parseDate(value);
const offset = (date.getDay() + 6) % 7;
return dateKey(new Date(date.getTime() - offset * DAY_MS));
};
const longDate = (value) => parseDate(value).toLocaleDateString('fr-FR', {
weekday: 'long', day: 'numeric', month: 'long',
});
const shortDate = (value) => parseDate(value).toLocaleDateString('fr-FR', {
day: 'numeric', month: 'short',
});
const slotMinutes = (slot) => {
const [sh, sm] = slot.start_time.slice(0, 5).split(':').map(Number);
const [eh, em] = slot.end_time.slice(0, 5).split(':').map(Number);
return Math.max(0, (eh * 60 + em + Number(slot.end_day_offset || 0) * 1440) - (sh * 60 + sm));
};
const dayMinutes = (day) => day?.schedule_mode === 'daily_hours' ? Number(day?.target_minutes || 0) : (day?.slots || []).reduce((total, slot) => total + slotMinutes(slot), 0);
const formatMinutes = (minutes) => {
const sign = minutes < 0 ? '−' : '';
const absolute = Math.abs(minutes);
const hours = Math.floor(absolute / 60);
const mins = absolute % 60;
return `${sign}${hours} h${mins ? ` ${String(mins).padStart(2, '0')}` : ''}`;
};
export default function MyRequestsCard({ user }) {
const mobile = useIsMobile();
const [cursor, setCursor] = useState(dateKey());
const [schedule, setSchedule] = useState({ days: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [reload, setReload] = useState(0);
const [people, setPeople] = useState([user]);
const [peopleError, setPeopleError] = useState('');
const [selected, setSelected] = useState(null);
const [mode, setMode] = useState('choice');
const [targetUserId, setTargetUserId] = useState('');
const [targetDate, setTargetDate] = useState(addDays(dateKey(), 1));
const [targetDay, setTargetDay] = useState(null);
const [targetLoading, setTargetLoading] = useState(false);
const [proposedSlots, setProposedSlots] = useState([]);
const [reason, setReason] = useState('');
const [preview, setPreview] = useState(null);
const [requestError, setRequestError] = useState('');
const [busy, setBusy] = useState(false);
const range = useMemo(() => {
const start = mobile ? cursor : mondayOf(cursor);
return { start, end: mobile ? start : addDays(start, 6) };
}, [cursor, mobile]);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError('');
planningApi.effective(user.id, range.start, range.end, controller.signal)
.then(setSchedule)
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [user.id, range.start, range.end, reload]);
useEffect(() => {
const controller = new AbortController();
planningApi.people(controller.signal)
.then((rows) => setPeople(rows))
.catch((e) => { if (!controller.signal.aborted) setPeopleError(e.message); });
return () => controller.abort();
}, []);
useEffect(() => {
if (!selected || mode !== 'move' || !targetDate) {
setTargetDay(null);
setTargetLoading(false);
return undefined;
}
const controller = new AbortController();
setTargetLoading(true);
planningApi.effective(user.id, targetDate, targetDate, controller.signal)
.then((result) => setTargetDay(result.days?.[0] || null))
.catch(() => { if (!controller.signal.aborted) setTargetDay(null); })
.finally(() => { if (!controller.signal.aborted) setTargetLoading(false); });
return () => controller.abort();
}, [selected, mode, targetDate, user.id]);
const otherPeople = people.filter((person) => String(person.id) !== String(user.id));
const sourceMinutes = dayMinutes(selected);
const addedMinutes = proposedSlots.reduce((total, slot) => total + slotMinutes(slot), 0);
const remainingMinutes = sourceMinutes - addedMinutes;
const targetUnavailable = mode === 'move' && targetDay && (['absence', 'undefined', 'weekly_target'].includes(targetDay.kind) || (targetDay.schedule_mode === 'daily_hours' && dayMinutes(targetDay) > 0));
const resetRequest = (day = null) => {
setSelected(day);
setMode('choice');
setTargetUserId(otherPeople[0] ? String(otherPeople[0].id) : '');
setTargetDate(day ? addDays(day.date, 1) : addDays(dateKey(), 1));
setTargetDay(null);
setProposedSlots(day?.slots?.map((slot) => ({
start_time: slot.start_time.slice(0, 5),
end_time: slot.end_time.slice(0, 5),
end_day_offset: Number(slot.end_day_offset || 0),
})) || []);
setReason('');
setPreview(null);
setRequestError('');
};
const changeMode = (value) => {
if (value === 'swap' && !targetUserId && otherPeople[0]) {
setTargetUserId(String(otherPeople[0].id));
}
setMode(value);
setPreview(null);
setRequestError('');
};
const payload = () => {
const base = {
kind: mode,
start_date: selected.date,
end_date: selected.date,
reason,
proposed_slots: mode === 'move' ? proposedSlots : [],
};
if (mode === 'swap') {
return { ...base, target_user_id: Number(targetUserId) };
}
if (mode === 'move') {
return { ...base, target_date: targetDate };
}
return base;
};
const submit = async () => {
if (busy || !selected || mode === 'choice') return;
if (mode === 'move' && targetDate === selected.date) {
setRequestError('Choisis un autre jour pour déplacer cette journée.');
return;
}
if (mode === 'move' && targetUnavailable) {
setRequestError('Choisis un jour de repos ou un jour avec des horaires précis.');
return;
}
if (mode === 'move' && remainingMinutes !== 0) {
setRequestError('Répartis exactement le même nombre d’heures avant de continuer.');
return;
}
setBusy(true);
setRequestError('');
try {
if (mode === 'swap') {
await planningApi.createRequest(payload());
resetRequest(null);
setReload((value) => value + 1);
} else if (!preview) {
setPreview(await planningApi.preview(payload()));
} else {
await planningApi.createRequest({ ...payload(), expected_before: preview.before });
resetRequest(null);
setReload((value) => value + 1);
}
} catch (e) {
setRequestError(e.message);
if (e.response?.status === 409) setPreview(null);
} finally {
setBusy(false);
}
};
const days = schedule.days || [];
const weeklyTarget = Number(days.find((day) => day.schedule_mode === 'weekly_hours')?.week_target_minutes || 0);
return (
<section aria-labelledby="my-planning-title" className="min-w-0 rounded-xl border border-border bg-card p-4 shadow-sm sm:p-5">
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 id="my-planning-title" className="flex items-center gap-2 text-lg font-semibold">
<CalendarClock className="size-5" />Mon planning
</h2>
<p className="text-sm text-muted-foreground">
{mobile
? 'Parcours ton planning jour par jour et touche une journée travaillée pour faire une demande.'
: 'Parcours ton planning semaine par semaine et clique sur une journée travaillée pour faire une demande.'}
</p>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="icon" aria-label={mobile ? 'Jour précédent' : 'Semaine précédente'} onClick={() => setCursor((value) => addDays(value, mobile ? -1 : -7))}>
<ChevronLeft className="size-4" />
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setCursor(dateKey())}>Aujourd’hui</Button>
<Button type="button" variant="outline" size="icon" aria-label={mobile ? 'Jour suivant' : 'Semaine suivante'} onClick={() => setCursor((value) => addDays(value, mobile ? 1 : 7))}>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
<div className="text-center text-sm font-medium">
{mobile ? longDate(range.start) : `${shortDate(range.start)} – ${shortDate(range.end)}`}
</div>
{error && <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
{!loading && !error && weeklyTarget > 0 && <div className="rounded-xl border border-primary/30 bg-primary/5 p-4"><p className="text-sm text-muted-foreground">Objectif de la semaine</p><p className="mt-1 text-2xl font-semibold tabular-nums">{formatMinutes(weeklyTarget)}</p><p className="mt-1 text-xs text-muted-foreground">Les heures ne sont pas réparties sur des jours précis, donc les demandes d’échange ou de déplacement se font uniquement sur les journées définies autrement.</p></div>}
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Chargement du planning…</p>
) : !error && (
<div className={mobile ? 'grid gap-3' : 'grid grid-cols-7 gap-2'}>
{days.map((day) => {
const worked = day.kind === 'work' && dayMinutes(day) > 0;
const dailyTarget = day.schedule_mode === 'daily_hours' && Number(day.target_minutes || 0) > 0;
return (
<button
key={day.date}
type="button"
disabled={!worked}
onClick={() => resetRequest(day)}
className={`min-h-32 rounded-xl border p-3 text-left transition-colors ${worked ? 'cursor-pointer hover:border-primary/50 hover:bg-primary/5' : 'cursor-default bg-muted/25 text-muted-foreground'}`}
>
<span className="block text-sm font-semibold capitalize">{parseDate(day.date).toLocaleDateString('fr-FR', { weekday: 'short' })}</span>
<span className="block text-xs text-muted-foreground">{shortDate(day.date)}</span>
<span className="mt-4 block space-y-1 text-sm">
{dailyTarget
? <span className="block font-medium">{formatMinutes(day.target_minutes)} à faire</span>
: worked
? day.slots.map((slot, index) => <span key={index} className="block font-medium">{slotLabel(slot)}</span>)
: <span>{day.kind === 'absence' ? 'Congé / absence' : day.kind === 'undefined' ? 'Planning non défini' : day.kind === 'weekly_target' ? 'Heures définies au total de la semaine' : 'Repos'}</span>}
</span>
{worked && <span className="mt-3 block text-xs text-muted-foreground">{formatMinutes(dayMinutes(day))} · Cliquer pour demander</span>}
</button>
);
})}
</div>
)}
<div className="border-t pt-5">
<h3 className="mb-3 font-medium">Mes demandes</h3>
<RequestsList user={user} refreshKey={reload} />
</div>
</div>
<Dialog open={Boolean(selected)} onOpenChange={(open) => { if (!open && !busy) resetRequest(null); }}>
<DialogContent className="max-h-[92svh] overflow-y-auto sm:max-w-2xl" showCloseButton={!busy}>
{selected && (
<>
<DialogHeader>
<DialogTitle>{mode === 'choice' ? `Que veux-tu faire le ${dateLabel(selected.date)} ?` : mode === 'leave' ? 'Demander un congé' : mode === 'swap' ? 'Échanger cette journée' : 'Déplacer cette journée'}</DialogTitle>
<DialogDescription>{selected.slots?.length ? `${selected.slots.map(slotLabel).join(' · ')} · ` : selected.schedule_mode === 'daily_hours' ? 'Horaires non imposés · ' : ''}{formatMinutes(sourceMinutes)}</DialogDescription>
</DialogHeader>
{requestError && <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{requestError}</p>}
{mode === 'choice' ? (
<div className="grid gap-3">
{can(user, 'request_leave') && (
<Button type="button" variant="outline" className="h-auto min-h-[88px] w-full min-w-0 items-start justify-start gap-3 whitespace-normal p-4 text-left" onClick={() => changeMode('leave')}>
<CalendarOff className="mt-0.5 size-5 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block font-medium">Demander ce jour</span>
<span className="mt-1 block whitespace-normal break-words text-xs font-normal leading-relaxed text-muted-foreground">Passer cette journée en congé / absence.</span>
</span>
</Button>
)}
<Button type="button" variant="outline" className="h-auto min-h-[88px] w-full min-w-0 items-start justify-start gap-3 whitespace-normal p-4 text-left" disabled={!otherPeople.length} onClick={() => changeMode('swap')}>
<ArrowLeftRight className="mt-0.5 size-5 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block font-medium">Échanger avec un collègue</span>
<span className="mt-1 block whitespace-normal break-words text-xs font-normal leading-relaxed text-muted-foreground">Choisis le collègue. Le responsable choisira la journée de ce collègue à échanger au moment de valider.</span>
</span>
</Button>
<Button type="button" variant="outline" className="h-auto min-h-[88px] w-full min-w-0 items-start justify-start gap-3 whitespace-normal p-4 text-left" onClick={() => changeMode('move')}>
<MoveRight className="mt-0.5 size-5 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block font-medium">Déplacer mes heures</span>
<span className="mt-1 block whitespace-normal break-words text-xs font-normal leading-relaxed text-muted-foreground">Retire cette journée et répartis exactement ses heures sur un autre jour.</span>
</span>
</Button>
{!can(user, 'request_leave') && <p className="text-xs text-muted-foreground">La demande de congé n’est pas activée pour ton compte.</p>}
{peopleError && <p className="text-xs text-destructive">Annuaire indisponible : {peopleError}</p>}
</div>
) : (
<div className="space-y-4">
{mode === 'swap' && (
<div className="space-y-3">
<label className="grid gap-1.5 text-sm">Échanger avec
<select className={inputClass} value={targetUserId} disabled={busy} onChange={(e) => { setTargetUserId(e.target.value); setPreview(null); }}>
{otherPeople.map((person) => <option key={person.id} value={person.id}>{personLabel(person)}</option>)}
</select>
</label>
<p className="rounded-lg bg-muted/50 p-3 text-sm text-muted-foreground">Tu demandes à échanger ta journée du <strong className="text-foreground">{dateLabel(selected.date)}</strong> avec ce collègue. Tu n’as pas à choisir sa journée : ce sera fait par le responsable lors de la validation.</p>
</div>
)}
{mode === 'move' && (
<>
<label className="grid gap-1.5 text-sm">Déplacer vers le
<DatePicker value={targetDate} disabled={busy} onChange={(value) => { setTargetDate(value); setPreview(null); setRequestError(''); }} />
</label>
<div className="rounded-lg bg-muted/40 p-3 text-sm">
<p className="font-medium">Déjà prévu ce jour-là</p>
<p className="mt-1 text-muted-foreground">
{targetLoading
? 'Chargement…'
: targetDay?.slots?.length
? targetDay.slots.map(slotLabel).join(' · ')
: targetDay?.kind === 'absence'
? 'Congé / absence'
: targetDay?.kind === 'undefined'
? 'Planning non défini'
: targetDay?.kind === 'weekly_target'
? `Objectif hebdomadaire : ${formatMinutes(targetDay.week_target_minutes || 0)}`
: targetDay?.schedule_mode === 'daily_hours' && dayMinutes(targetDay) > 0
? `${formatMinutes(dayMinutes(targetDay))} à faire, sans plage précise`
: 'Repos'}
</p>
</div>
{targetUnavailable && <p className="text-sm text-destructive">Choisis un jour de repos ou un jour avec des horaires précis. Un objectif hebdomadaire ou un nombre d’heures déjà prévu sans plage précise ne peut pas recevoir ce déplacement.</p>}
<div className="space-y-2">
<p className="text-sm font-medium">Horaires à ajouter ce jour-là</p>
<SlotsEditor slots={proposedSlots} onChange={(slots) => { setProposedSlots(slots); setPreview(null); setRequestError(''); }} />
</div>
<div className={`rounded-lg border p-3 text-sm ${remainingMinutes === 0 ? 'border-primary/40 bg-primary/5' : ''}`}>
<div className="flex justify-between gap-3"><span>À déplacer</span><strong>{formatMinutes(sourceMinutes)}</strong></div>
<div className="mt-1 flex justify-between gap-3"><span>Ajouté</span><strong>{formatMinutes(addedMinutes)}</strong></div>
<div className="mt-2 flex justify-between gap-3 border-t pt-2"><span>{remainingMinutes === 0 ? 'Répartition complète' : remainingMinutes > 0 ? 'Encore à ajouter' : 'À retirer'}</span><strong>{formatMinutes(Math.abs(remainingMinutes))}</strong></div>
</div>
</>
)}
<label className="grid gap-1.5 text-sm">Commentaire <span className="text-xs text-muted-foreground">(facultatif)</span>
<textarea className={`${inputClass} h-20 py-2`} maxLength={2000} value={reason} disabled={busy} onChange={(e) => { setReason(e.target.value); setPreview(null); }} placeholder="Précision utile pour le responsable…" />
</label>
{preview && (
<div className="space-y-2">
<p className="text-sm font-medium">Vérifie la modification</p>
<ChangePreview before={preview.before} after={preview.after} people={people} />
</div>
)}
</div>
)}
{mode !== 'choice' && (
<div className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => changeMode('choice')}>Retour</Button>
<Button type="button" disabled={busy || (mode === 'move' && (targetLoading || targetUnavailable || remainingMinutes !== 0 || targetDate === selected.date)) || (mode === 'swap' && !targetUserId)} onClick={submit}>
{busy ? 'Chargement…' : mode === 'swap' ? 'Envoyer la demande' : preview ? 'Envoyer la demande' : 'Vérifier ma demande'}
</Button>
</div>
)}
</>
)}
</DialogContent>
</Dialog>
</section>
);
}

View File

@ -0,0 +1,18 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
const formatMinutes = (minutes = 0) => `${Math.floor(Number(minutes || 0) / 60)} h${Number(minutes || 0) % 60 ? ` ${String(Number(minutes || 0) % 60).padStart(2, '0')}` : ''}`;
export default function PlannedDay({ date, days, segments, getStyle, onHover, onMove, onLeave }) {
const [selected, setSelected] = useState(null);
const day = days.find((item) => item.date === date);
const rows = segments.filter((item) => item.date === date);
return <>
{rows.map((row) => <button key={row.id} type="button" aria-label={`Prévu : ${row.start_time} à ${row.end_time}. ${row.label}`} className="absolute left-0 right-[12%] z-10 flex flex-col items-start justify-start overflow-hidden rounded-sm border border-dashed border-emerald-600/70 bg-emerald-500/15 text-left text-emerald-800 dark:text-emerald-200" style={getStyle(row)} onMouseEnter={(event) => onHover?.({ event, title: 'Horaire prévu', description: row.label, startTime: row.start_time, endTime: row.end_time, color: '#10b981' })} onMouseMove={onMove} onMouseLeave={onLeave} onClick={() => { onLeave?.(); setSelected(row); }}>
<span className="block truncate px-1 text-[10px] leading-4">{row.start_time}–{row.end_time}</span>
</button>)}
{day?.schedule_mode === 'daily_hours' && Number(day.target_minutes || 0) > 0 && <span className="pointer-events-none absolute inset-x-1 top-1 z-30 w-fit max-w-[calc(100%-0.5rem)] rounded border border-emerald-600/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:text-emerald-200">{formatMinutes(day.target_minutes)} à faire</span>}
{day && ['rest', 'absence', 'undefined'].includes(day.kind) && <span className="pointer-events-none absolute inset-x-1 top-1 z-30 w-fit max-w-[calc(100%-0.5rem)] rounded bg-background/90 px-1.5 py-0.5 text-[10px] text-muted-foreground">{day.kind === 'absence' ? 'Congé / absence' : day.kind === 'rest' ? 'Repos prévu' : 'Planning non défini'}</span>}
<Dialog open={Boolean(selected)} onOpenChange={(open) => { if (!open) setSelected(null); }}><DialogContent><DialogHeader><DialogTitle>Horaire prévu</DialogTitle><DialogDescription>{selected?.label}</DialogDescription></DialogHeader><p className="text-sm">{selected?.start_time} → {selected?.end_time}</p></DialogContent></Dialog>
</>;
}

View File

@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ResponsivePickers';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { planningApi } from '@/api/planning';
import { can } from '@/lib/permissions';
import SlotsEditor from './SlotsEditor';
import ChangePreview from './ChangePreview';
import { dateKey, inputClass, newSlot, personLabel, REQUEST_KINDS } from './utils';
const initialForm = (userId, canRequestLeave) => ({ kind: canRequestLeave ? 'leave' : 'hours', start_date: dateKey(), end_date: dateKey(), target_date: dateKey(), target_user_id: String(userId), reason: '', proposed_slots: [newSlot()] });
export default function RequestForm({ open, onOpenChange, user, onCreated }) {
const userId = user.id;
const canRequestLeave = can(user, 'request_leave');
const [form, setForm] = useState(() => initialForm(userId, canRequestLeave));
const [people, setPeople] = useState([user]);
const [preview, setPreview] = useState(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [peopleError, setPeopleError] = useState('');
useEffect(() => {
if (!open) return;
setForm(initialForm(userId, canRequestLeave)); setPreview(null); setError(''); setPeopleError('');
const controller = new AbortController();
planningApi.people(controller.signal).then(setPeople).catch((e) => { if (!controller.signal.aborted) setPeopleError(e.message); });
return () => controller.abort();
}, [open, userId, canRequestLeave]);
const change = (values) => { setForm((old) => ({ ...old, ...values })); setPreview(null); setError(''); };
const payload = () => ({ kind: form.kind, start_date: form.start_date, end_date: form.kind === 'swap' ? form.start_date : form.end_date, reason: form.reason,
...(form.kind === 'swap' ? { target_date: form.target_date, target_user_id: Number(form.target_user_id) } : {}),
proposed_slots: form.kind === 'hours' ? form.proposed_slots : [],
});
const submit = async (event) => {
event.preventDefault(); if (busy) return;
setBusy(true); setError('');
try {
if (!preview) setPreview(await planningApi.preview(payload()));
else { await planningApi.createRequest({ ...payload(), expected_before: preview.before }); onCreated(); onOpenChange(false); }
} catch (e) { setError(e.message); if (e.response?.status === 409) setPreview(null); } finally { setBusy(false); }
};
return (
<Dialog open={open} onOpenChange={(value) => { if (!busy) onOpenChange(value); }}>
<DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-xl" showCloseButton={!busy}>
<DialogHeader><DialogTitle>Nouvelle demande</DialogTitle><DialogDescription>Propose un changement. Un responsable te répondra dans Mon espace.</DialogDescription></DialogHeader>
<form onSubmit={submit} className="space-y-4" aria-busy={busy}>
{error && <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
<fieldset disabled={busy} className="space-y-4">
<label className="grid gap-1.5 text-sm">Type de demande<select className={inputClass} value={form.kind} onChange={(e) => change({ kind: e.target.value })}>{Object.entries(REQUEST_KINDS).filter(([key]) => key !== 'leave' || can(user, 'request_leave')).map(([key, value]) => <option key={key} value={key}>{value}</option>)}</select></label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm">{form.kind === 'swap' ? 'Ma journée à échanger' : 'Du'}<DatePicker required value={form.start_date} onChange={(value) => change({ start_date: value, end_date: form.end_date < value ? value : form.end_date })} /></label>
{form.kind !== 'swap' && <label className="grid gap-1.5 text-sm">Au (inclus)<DatePicker required min={form.start_date} value={form.end_date} onChange={(value) => change({ end_date: value })} /></label>}
</div>
{form.kind === 'swap' && <div className="grid gap-3 rounded-lg border p-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm">Échanger avec<select className={inputClass} value={form.target_user_id} onChange={(e) => change({ target_user_id: e.target.value })}>{people.map((p) => <option key={p.id} value={p.id}>{p.id === user.id ? 'Moi-même (déplacer mes jours)' : personLabel(p)}</option>)}</select></label>
<label className="grid gap-1.5 text-sm">Autre journée<DatePicker required value={form.target_date} onChange={(value) => change({ target_date: value })} /></label>
{peopleError && <p className="text-sm text-destructive sm:col-span-2">Annuaire indisponible : {peopleError}</p>}
<p className="text-xs text-muted-foreground sm:col-span-2">Les horaires complets des deux journées seront échangés, après accord d’un responsable.</p>
</div>}
{form.kind === 'hours' && <div className="space-y-2"><p className="text-sm font-medium">Horaires souhaités pour chaque journée sélectionnée</p><SlotsEditor slots={form.proposed_slots} onChange={(slots) => change({ proposed_slots: slots })} /></div>}
<label className="grid gap-1.5 text-sm">Commentaire (facultatif)<textarea className={`${inputClass} h-20 py-2`} maxLength={2000} value={form.reason} onChange={(e) => change({ reason: e.target.value })} placeholder="Précise ta demande…" /></label>
</fieldset>
{preview && <div className="space-y-2"><p className="text-sm font-medium">Vérifie le changement proposé</p><div className="max-h-64 overflow-y-auto"><ChangePreview {...preview} people={people} /></div></div>}
<div className="flex flex-wrap items-center justify-end gap-2"><Button type="button" variant="outline" disabled={busy} onClick={() => onOpenChange(false)}>Annuler</Button><Button type="submit" disabled={busy}>{busy ? 'Chargement…' : preview ? 'Envoyer la demande' : 'Vérifier ma demande'}</Button></div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,127 @@
import { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ResponsivePickers';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { planningApi } from '@/api/planning';
import ChangePreview from './ChangePreview';
import { dateLabel, dayLabel, inputClass, personLabel, REQUEST_KINDS, REQUEST_STATES } from './utils';
export default function RequestsList({ user, admin = false, refreshKey = 0, people = [], onDecision }) {
const [filters, setFilters] = useState({ status: admin ? 'pending' : '', kind: '', user_id: admin ? '' : String(user.id) });
const [page, setPage] = useState(1);
const [reload, setReload] = useState(0);
const [result, setResult] = useState({ results: [], count: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selected, setSelected] = useState(null);
const [response, setResponse] = useState('');
const [busy, setBusy] = useState(false);
const [decisionError, setDecisionError] = useState('');
const [notice, setNotice] = useState('');
const [swapTargetDate, setSwapTargetDate] = useState('');
const [swapTargetDay, setSwapTargetDay] = useState(null);
const [swapTargetLoading, setSwapTargetLoading] = useState(false);
useEffect(() => {
const controller = new AbortController(); setLoading(true); setError('');
planningApi.requests({ ...filters, page, ...(admin ? { review: 1 } : {}) }, controller.signal).then(setResult).catch((e) => { if (!controller.signal.aborted) setError(e.message); }).finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [filters, page, reload, refreshKey]);
useEffect(() => {
if (!admin || selected?.kind !== 'swap' || selected.status !== 'pending' || !selected.target_user?.id || !swapTargetDate) {
setSwapTargetDay(null);
setSwapTargetLoading(false);
return undefined;
}
const controller = new AbortController();
setSwapTargetLoading(true);
setDecisionError('');
planningApi.swapTargetDay(selected.id, swapTargetDate, controller.signal)
.then((data) => { if (!controller.signal.aborted) setSwapTargetDay(data.days?.[0] || null); })
.catch((e) => { if (!controller.signal.aborted) { setSwapTargetDay(null); setDecisionError(e.message); } })
.finally(() => { if (!controller.signal.aborted) setSwapTargetLoading(false); });
return () => controller.abort();
}, [admin, selected, swapTargetDate]);
const changeFilters = (values) => { setFilters((current) => ({ ...current, ...values })); setPage(1); };
const swapTargetInvalid = selected?.kind === 'swap' && selected.status === 'pending' && (!swapTargetDay || ['absence', 'undefined', 'weekly_target'].includes(swapTargetDay.kind));
const openRequest = (row) => {
setSelected(row);
setResponse('');
setDecisionError('');
setSwapTargetDate(row.kind === 'swap' ? (row.target_date || '') : '');
setSwapTargetDay(null);
};
const decide = async (action) => {
if (busy) return;
if (action === 'approve' && selected?.kind === 'swap' && !swapTargetDate) {
setDecisionError('Choisis d’abord la journée de l’autre employé à échanger.');
return;
}
if (action === 'approve' && selected?.kind === 'swap' && swapTargetInvalid) {
setDecisionError('La journée choisie doit être une journée définie, travaillée ou de repos.');
return;
}
setBusy(true); setDecisionError('');
try {
const extra = action === 'approve' && selected?.kind === 'swap' ? { target_date: swapTargetDate } : {};
await planningApi.decide(selected.id, action, response, extra);
setSelected(null);
setNotice(action === 'approve' ? 'Demande acceptée. Le planning a été mis à jour.' : action === 'reject' ? 'Demande refusée. La réponse est disponible dans Mon espace.' : 'Demande annulée.');
setPage(1); setReload((n) => n + 1); onDecision?.();
} catch (e) { setDecisionError(e.message); } finally { setBusy(false); }
};
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<select aria-label="État des demandes" className={`${inputClass} w-auto max-w-full`} value={filters.status} onChange={(e) => changeFilters({ status: e.target.value })}><option value="">Tous les états</option>{Object.entries(REQUEST_STATES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select>
{admin && <><select aria-label="Type des demandes" className={`${inputClass} w-auto max-w-full`} value={filters.kind} onChange={(e) => changeFilters({ kind: e.target.value })}><option value="">Tous les types</option>{Object.entries(REQUEST_KINDS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select><select aria-label="Salarié des demandes" className={`${inputClass} w-auto max-w-full`} value={filters.user_id} onChange={(e) => changeFilters({ user_id: e.target.value })}><option value="">Tous les salariés</option>{people.map((p) => <option key={p.id} value={p.id}>{personLabel(p)}</option>)}</select></>}
<Button className="ml-auto" variant="ghost" size="icon" aria-label="Actualiser les demandes" disabled={loading} onClick={() => setReload((n) => n + 1)}><RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} /></Button>
</div>
{notice && <p role="status" className="text-sm text-emerald-700 dark:text-emerald-300">{notice}</p>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{loading ? <p role="status" className="py-5 text-sm text-muted-foreground">Chargement des demandes…</p> : !error && !result.results.length ? <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">Aucune demande pour ces filtres.</p> : !error && <div className="divide-y rounded-lg border">{result.results.map((row) => <button key={row.id} type="button" onClick={() => openRequest(row)} className="flex w-full items-center justify-between gap-3 p-3 text-left transition-colors hover:bg-muted/50">
<span className="min-w-0 space-y-1"><span className="block text-sm font-medium">{REQUEST_KINDS[row.kind]}</span><span className="block text-xs text-muted-foreground">{personLabel(row.user)} · {dateLabel(row.start_date)}{row.end_date !== row.start_date ? ` → ${dateLabel(row.end_date)}` : ''}</span>{row.kind === 'swap' && row.target_user && <span className="block text-xs text-muted-foreground">Avec {personLabel(row.target_user)} · {row.target_date ? dateLabel(row.target_date) : 'journée à choisir lors de la validation'}</span>}{row.kind === 'move' && row.target_date && <span className="block text-xs text-muted-foreground">Déplacée vers le {dateLabel(row.target_date)}</span>}</span>
<Badge variant={row.status === 'approved' ? 'default' : 'secondary'} className="shrink-0">{REQUEST_STATES[row.status]}</Badge>
</button>)}</div>}
{result.count > 20 && <div className="flex items-center justify-between gap-2 text-sm"><Button variant="outline" size="sm" disabled={page === 1 || loading} onClick={() => setPage((n) => n - 1)}><ChevronLeft className="size-4" />Précédent</Button><span>Page {page} / {Math.ceil(result.count / 20)}</span><Button variant="outline" size="sm" disabled={!result.next || loading} onClick={() => setPage((n) => n + 1)}>Suivant<ChevronRight className="size-4" /></Button></div>}
<Dialog open={Boolean(selected)} onOpenChange={(open) => { if (!open && !busy) setSelected(null); }}><DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-xl" showCloseButton={!busy}>
{selected && <><DialogHeader><DialogTitle>{REQUEST_KINDS[selected.kind]}</DialogTitle><DialogDescription>Demande de {personLabel(selected.user)} · {REQUEST_STATES[selected.status]}</DialogDescription></DialogHeader>
{selected.reason && <p className="whitespace-pre-wrap break-words rounded-lg bg-muted p-3 text-sm">{selected.reason}</p>}
{selected.kind === 'swap' && selected.status === 'pending' && admin ? <div className="space-y-4">
<div className="rounded-lg border p-3 text-sm">
<p className="font-medium">Journée proposée par {personLabel(selected.user)}</p>
<p className="mt-1 text-muted-foreground">{dateLabel(selected.start_date)} · {selected.before?.[0] ? dayLabel(selected.before[0]) : 'Planning à vérifier'}</p>
</div>
<label className="grid gap-1.5 text-sm">Journée de {personLabel(selected.target_user)} à échanger
<DatePicker value={swapTargetDate} disabled={busy} onChange={(value) => { setSwapTargetDate(value); setSwapTargetDay(null); setDecisionError(''); }} />
</label>
<div className="rounded-lg bg-muted/40 p-3 text-sm">
<p className="font-medium">Planning de {personLabel(selected.target_user)} ce jour-là</p>
<p className={`mt-1 ${swapTargetInvalid && swapTargetDate && !swapTargetLoading ? 'text-destructive' : 'text-muted-foreground'}`}>
{!swapTargetDate ? 'Choisis une date.' : swapTargetLoading ? 'Chargement…' : swapTargetDay ? dayLabel(swapTargetDay) : 'Planning indisponible.'}
</p>
</div>
<p className="text-xs text-muted-foreground">En acceptant, les deux journées seront réellement inversées. Le serveur vérifie à nouveau les deux plannings juste avant d’appliquer l’échange.</p>
</div> : selected.kind === 'swap' && selected.status === 'pending' && !selected.target_date ? <div className="space-y-3">
<div className="rounded-lg border p-3 text-sm"><p className="font-medium">Ta journée du {dateLabel(selected.start_date)}</p><p className="mt-1 text-muted-foreground">{selected.before?.[0] ? dayLabel(selected.before[0]) : 'Planning à vérifier'}</p></div>
<p className="rounded-lg bg-muted/40 p-3 text-sm text-muted-foreground">Échange demandé avec <strong className="text-foreground">{personLabel(selected.target_user)}</strong>. Le responsable choisira la journée de ce collègue au moment d’accepter la demande.</p>
</div> : <ChangePreview before={selected.before} after={selected.after} people={[selected.user, selected.target_user].filter(Boolean)} />}
{selected.reviewed_at && <div className="space-y-1 border-t pt-3 text-sm"><p className="text-muted-foreground">{REQUEST_STATES[selected.status]} le {new Date(selected.reviewed_at).toLocaleString('fr-FR')} par {personLabel(selected.reviewed_by)}</p>{selected.response && <p className="whitespace-pre-wrap break-words">{selected.response}</p>}</div>}
{admin && selected.status === 'pending' && <label className="grid gap-1.5 text-sm">Réponse au salarié (facultative)<textarea className={`${inputClass} h-20 py-2`} value={response} disabled={busy} maxLength={2000} onChange={(e) => setResponse(e.target.value)} /></label>}
{decisionError && <p role="alert" className="text-sm text-destructive">{decisionError}</p>}
<div className="flex flex-wrap items-center justify-end gap-2">
{selected.status === 'pending' && admin && <><Button variant="outline" disabled={busy} onClick={() => decide('reject')}>Refuser</Button><Button disabled={busy || (selected.kind === 'swap' && (swapTargetLoading || !swapTargetDate || swapTargetInvalid))} onClick={() => decide('approve')}>{busy ? 'Traitement…' : selected.kind === 'swap' ? 'Accepter cet échange' : 'Accepter et appliquer'}</Button></>}
{selected.status === 'pending' && !admin && selected.user.id === user.id && <Button variant="destructive" disabled={busy} onClick={() => decide('cancel')}>{busy ? 'Annulation…' : 'Annuler ma demande'}</Button>}
</div></>}
</DialogContent></Dialog>
</div>;
}

View File

@ -0,0 +1,172 @@
import { useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Clock3, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TimePicker } from '@/components/ResponsivePickers';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useIsMobile } from '@/hooks/use-mobile';
import { DAY_MINUTES, duration, gridSegments, hasOverlap, toMinutes, toTime, weekName } from './gridUtils';
import { inputClass, WEEKDAYS } from './utils';
const HOURS = Array.from({ length: 25 }, (_, i) => i);
const HEIGHT = 576;
export default function ScheduleGrid({ slots, onChange, week = 0, cycleWeeks = 1, disabled = false, dayOnly = false }) {
const mobile = useIsMobile();
const [mobileDay, setMobileDay] = useState(0);
const [selection, setSelection] = useState(null);
const [editing, setEditing] = useState(null);
const [error, setError] = useState('');
const body = useRef(null);
const gesture = useRef(null);
const touchTap = useRef(null);
const days = dayOnly ? [0] : mobile ? [mobileDay] : [0, 1, 2, 3, 4, 5, 6];
const normalized = dayOnly ? slots.map((slot) => ({ ...slot, weekday: 0, week_index: 0 })) : slots;
const segments = gridSegments(normalized, week, cycleWeeks);
const startEdit = (slot, index = null) => {
setError('');
setEditing({ ...slot, index, start_time: slot.start_time.slice(0, 5), end_time: slot.end_time.slice(0, 5) });
};
const add = () => startEdit({ week_index: week, weekday: days[0], start_time: '08:00', end_time: '12:00', end_day_offset: 0 });
const position = (event, frame) => {
const rect = frame?.rect || body.current.getBoundingClientRect();
const visibleDays = frame?.days || days;
const column = Math.min(visibleDays.length - 1, Math.max(0, Math.floor((event.clientX - rect.left) / rect.width * visibleDays.length)));
const minute = Math.max(0, Math.min(DAY_MINUTES, Math.round((event.clientY - rect.top) / rect.height * DAY_MINUTES / 15) * 15));
return visibleDays[column] * DAY_MINUTES + minute;
};
const begin = (event) => {
if (disabled || event.button !== 0 || event.target.closest('button')) return;
const point = Math.min(position(event), (days.at(-1) + 1) * DAY_MINUTES - 15);
const frame = { rect: body.current.getBoundingClientRect(), days };
if (event.pointerType === 'touch') {
touchTap.current = { start: point, x: event.clientX, y: event.clientY, frame };
return;
}
event.preventDefault();
// Le menu latéral peut se replier pendant le geste : garder le même repère.
gesture.current = { start: point, end: point, frame };
setSelection(gesture.current);
event.currentTarget.setPointerCapture(event.pointerId);
};
const move = (event) => {
if (!gesture.current) return;
gesture.current = { ...gesture.current, end: position(event, gesture.current.frame) };
setSelection(gesture.current);
};
const finish = (event) => {
if (event.pointerType === 'touch') {
const tap = touchTap.current;
touchTap.current = null;
if (!tap || Math.hypot(event.clientX - tap.x, event.clientY - tap.y) > 10) return;
gesture.current = { start: tap.start, end: tap.start, frame: tap.frame };
}
if (!gesture.current) return;
const { start, end } = gesture.current;
gesture.current = null;
setSelection(null);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
const from = Math.min(start, end);
const to = start === end ? from + 30 : Math.max(start, end);
if (to - from > DAY_MINUTES) {
setError('Sélectionne une plage de 24 heures maximum. Pour plusieurs jours de travail, ajoute une plage par jour.');
return;
}
const weekday = Math.floor(from / DAY_MINUTES);
startEdit({ week_index: week, weekday, start_time: toTime(from % DAY_MINUTES), end_time: toTime(to % DAY_MINUTES), end_day_offset: Math.floor(to / DAY_MINUTES) - weekday });
};
const cancel = () => { gesture.current = null; touchTap.current = null; setSelection(null); };
const change = (values) => { setEditing((old) => ({ ...old, ...values })); setError(''); };
const save = (event) => {
event.preventDefault();
event.stopPropagation();
const { index, ...slot } = editing;
if (duration(slot) <= 0 || duration(slot) > DAY_MINUTES) {
setError('Vérifie le jour et l’heure de fin. Une plage doit durer entre une minute et 24 heures.');
return;
}
const next = index === null ? [...normalized, slot] : normalized.map((old, i) => i === index ? slot : old);
if (next.filter((item) => item.week_index === slot.week_index && item.weekday === slot.weekday).length > 10) {
setError('Une journée peut contenir au maximum 10 plages de travail.');
return;
}
if (hasOverlap(next, cycleWeeks)) {
setError('Cette plage recouvre des horaires déjà renseignés. Vérifie aussi les heures après minuit.');
return;
}
onChange(dayOnly ? next.map(({ start_time, end_time, end_day_offset }) => ({ start_time, end_time, end_day_offset })) : next);
setEditing(null);
setError('');
};
const remove = () => {
onChange(slots.filter((_, index) => index !== editing.index));
setEditing(null);
setError('');
};
const selectedRange = selection && {
start: Math.min(selection.start, selection.end),
end: selection.start === selection.end ? selection.start + 30 : Math.max(selection.start, selection.end),
};
return <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-xl text-sm text-muted-foreground">{mobile ? 'Touche une heure pour ajouter des horaires. Touche une plage pour la modifier.' : 'Clique et glisse du début à la fin du travail. Clique sur une plage pour la modifier.'}</p>
<Button type="button" size="sm" variant="outline" disabled={disabled} onClick={add}><Plus className="size-4" />Ajouter des horaires</Button>
</div>
{mobile && !dayOnly && <div className="flex items-center gap-2">
<Button type="button" variant="outline" size="icon" aria-label="Jour précédent dans la grille" disabled={mobileDay === 0} onClick={() => setMobileDay((d) => d - 1)}><ChevronLeft className="size-4" /></Button>
<select className={inputClass} aria-label="Jour affiché" value={mobileDay} onChange={(e) => setMobileDay(Number(e.target.value))}>{WEEKDAYS.map((day, i) => <option key={day} value={i}>{day}</option>)}</select>
<Button type="button" variant="outline" size="icon" aria-label="Jour suivant dans la grille" disabled={mobileDay === 6} onClick={() => setMobileDay((d) => d + 1)}><ChevronRight className="size-4" /></Button>
</div>}
{error && !editing && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="overflow-hidden rounded-xl border border-border bg-background">
<div className="grid border-b bg-muted/50" style={{ gridTemplateColumns: `44px repeat(${days.length}, minmax(0, 1fr))` }}>
<div className="flex items-center justify-center"><Clock3 aria-hidden="true" className="size-4 text-muted-foreground" /></div>
{days.map((day) => <div key={day} className="border-l px-1 py-3 text-center text-sm font-medium">{dayOnly ? 'Journée de travail' : WEEKDAYS[day]}</div>)}
</div>
<div className="flex" style={{ height: HEIGHT }}>
<div className="relative w-11 shrink-0 bg-muted/20" aria-hidden="true">
{HOURS.map((hour) => <span key={hour} className={`absolute right-1 text-[10px] tabular-nums text-muted-foreground ${hour === 0 ? '' : hour === 24 ? '-translate-y-full' : '-translate-y-1/2'}`} style={{ top: `${hour / 24 * 100}%` }}>{String(hour).padStart(2, '0')}:00</span>)}
</div>
<div ref={body} role="group" aria-label="Grille des horaires" className={`relative grid min-w-0 flex-1 select-none ${disabled ? 'opacity-60' : 'touch-pan-y cursor-crosshair'}`} style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }} onPointerDown={begin} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancel} onLostPointerCapture={cancel} onKeyDown={(e) => { if (e.key === 'Escape') cancel(); }}>
{days.map((day) => {
const selectedStart = selectedRange ? Math.max(0, selectedRange.start - day * DAY_MINUTES) : 0;
const selectedEnd = selectedRange ? Math.min(DAY_MINUTES, selectedRange.end - day * DAY_MINUTES) : 0;
const daySegments = segments.filter((segment) => segment.day === day);
return <div key={day} data-weekday={day} className="relative min-w-0 border-l border-border">
{HOURS.slice(1, 24).map((hour) => <div key={hour} className="pointer-events-none absolute inset-x-0 border-t border-border/65" style={{ top: `${hour / 24 * 100}%` }} />)}
{HOURS.slice(0, 24).map((hour) => <div key={hour} className="pointer-events-none absolute inset-x-0 border-t border-dashed border-border/30" style={{ top: `${(hour + 0.5) / 24 * 100}%` }} />)}
{!daySegments.length && <span className="pointer-events-none absolute inset-x-0 top-1/2 text-center text-xs text-muted-foreground/60">Repos</span>}
{daySegments.map((segment) => <button key={`${segment.index}-${segment.start}`} type="button" disabled={disabled} aria-label={`Modifier ${dayOnly ? 'les horaires' : WEEKDAYS[day]} : ${toTime(segment.start)} à ${segment.end === DAY_MINUTES ? 'minuit' : toTime(segment.end)}${segment.continuation ? ', suite de la veille' : ''}`} onPointerDown={(e) => e.stopPropagation()} onClick={() => startEdit(normalized[segment.index], segment.index)} className="absolute inset-x-0.5 z-10 flex cursor-pointer flex-col items-start justify-start overflow-hidden rounded-md border border-primary/45 bg-primary/15 p-1 text-left text-xs font-medium text-foreground transition-colors hover:bg-primary/25 focus-visible:z-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" style={{ top: `${segment.start / DAY_MINUTES * 100}%`, height: `${(segment.end - segment.start) / DAY_MINUTES * 100}%`, minHeight: 7 }}>
<span className="whitespace-nowrap tabular-nums">{toTime(segment.start)} – {segment.end === DAY_MINUTES ? '00:00' : toTime(segment.end)}</span>
{segment.continuation && <span className="text-[10px] font-normal text-muted-foreground">Suite de la veille</span>}
</button>)}
{selectedEnd > selectedStart && <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 z-20 rounded border-2 border-primary bg-primary/25" style={{ top: `${selectedStart / DAY_MINUTES * 100}%`, height: `${(selectedEnd - selectedStart) / DAY_MINUTES * 100}%` }} />}
</div>;
})}
</div>
</div>
</div>
{dayOnly && slots.some((slot) => slot.end_day_offset) && <p className="text-xs text-muted-foreground">Une partie du travail se poursuit le jour suivant : {slots.filter((slot) => slot.end_day_offset).map((slot) => `jusqu’à ${slot.end_time.slice(0, 5)}`).join(', ')}.</p>}
<Dialog open={Boolean(editing)} onOpenChange={(open) => { if (!open) { setEditing(null); setError(''); } }}>
<DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-lg">
{editing && <><DialogHeader><DialogTitle>{editing.index === null ? 'Ajouter des horaires' : 'Modifier les horaires'}</DialogTitle><DialogDescription>{dayOnly ? 'Indique le début et la fin du travail.' : `${weekName(editing.week_index)} · Choisis le jour et l’heure de début, puis de fin.`}</DialogDescription></DialogHeader>
<form onSubmit={save} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{!dayOnly && <label className="grid gap-1.5 text-sm">Jour de début<select aria-label="Jour de début" className={inputClass} value={editing.weekday} onChange={(e) => change({ weekday: Number(e.target.value) })}>{WEEKDAYS.map((day, i) => <option key={day} value={i}>{day}</option>)}</select></label>}
<label className="grid gap-1.5 text-sm">Heure de début<TimePicker required value={editing.start_time} onChange={(start_time) => change({ start_time })} /></label>
<label className="grid gap-1.5 text-sm">Jour de fin<select aria-label="Jour de fin" className={inputClass} value={editing.end_day_offset} onChange={(e) => change({ end_day_offset: Number(e.target.value) })}><option value={0}>{dayOnly ? 'Le même jour' : WEEKDAYS[editing.weekday]}</option><option value={1}>{dayOnly ? 'Le jour suivant' : `${WEEKDAYS[(editing.weekday + 1) % 7]}${editing.weekday === 6 ? ` — ${weekName((editing.week_index + 1) % cycleWeeks).toLowerCase()}` : ''}`}</option></select></label>
<label className="grid gap-1.5 text-sm">Heure de fin<TimePicker required value={editing.end_time} onChange={(end_time) => change({ end_time, end_day_offset: end_time && toMinutes(end_time) <= toMinutes(editing.start_time) ? 1 : editing.end_day_offset })} /></label>
</div>
<p className="rounded-lg bg-muted p-3 text-sm">De {editing.start_time} à {editing.end_time}{editing.end_day_offset ? ' le jour suivant' : ''}.{duration(editing) > 0 && duration(editing) <= DAY_MINUTES && ` Durée : ${Math.floor(duration(editing) / 60)} h ${String(duration(editing) % 60).padStart(2, '0')}.`}</p>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="flex flex-wrap items-center justify-end gap-2 flex-wrap">
{editing.index !== null && <Button type="button" variant="destructive" onClick={remove}><Trash2 className="size-4" />Supprimer</Button>}
<Button type="button" variant="outline" onClick={() => { setEditing(null); setError(''); }}>Annuler</Button>
<Button type="submit">{editing.index === null ? 'Ajouter cette plage' : 'Valider les horaires'}</Button>
</div>
</form>
</>}
</DialogContent>
</Dialog>
</div>;
}

View File

@ -0,0 +1,287 @@
import { useEffect, useState } from 'react';
import { CalendarPlus, ChevronLeft, ChevronRight, Copy, Pencil, Plus, RefreshCw, Save, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ResponsivePickers';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { planningApi } from '@/api/planning';
import ScheduleGrid from './ScheduleGrid';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { duration, weekName } from './gridUtils';
import { cleanSlots, dateKey, dateLabel, inputClass, newSlot, slotLabel, WEEKDAYS } from './utils';
const MODES = {
precise: {
label: 'Horaires précis',
description: 'Indique les heures de début et de fin pour chaque jour.',
},
daily_hours: {
label: 'Heures par jour',
description: 'Indique uniquement combien d’heures doivent être faites chaque jour.',
},
weekly_hours: {
label: 'Total par semaine',
description: 'Indique uniquement le nombre total d’heures à faire dans la semaine.',
},
};
const emptyException = () => ({ label: '', kind: 'work', start_date: dateKey(), end_date: dateKey(), recurring: false, weekdays: [(new Date().getDay() + 6) % 7], every_weeks: 1, slots: [newSlot()] });
const activeSchedule = (config, day) => config.schedules.find((s) => s.valid_from <= day && (!s.valid_to || s.valid_to >= day));
const scheduleSlots = (schedule) => schedule?.slots.map((slot) => ({ ...cleanSlots([slot])[0], weekday: slot.weekday, week_index: slot.week_index || 0 })) || [];
const formatMinutes = (minutes = 0) => `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${String(minutes % 60).padStart(2, '0')}` : ''}`;
function DurationInput({ minutes = 0, onChange, maxHours = 24, disabled = false, ariaLabel }) {
const hours = Math.floor(Number(minutes || 0) / 60);
const mins = Number(minutes || 0) % 60;
const update = (nextHours, nextMinutes) => {
const safeHours = Math.max(0, Math.min(maxHours, Number(nextHours) || 0));
const safeMinutes = Math.max(0, Math.min(59, Number(nextMinutes) || 0));
const total = Math.min(maxHours * 60, safeHours * 60 + safeMinutes);
onChange(total);
};
return (
<div className="flex items-center gap-1.5" aria-label={ariaLabel}>
<input
type="number"
min="0"
max={maxHours}
inputMode="numeric"
disabled={disabled}
className={`${inputClass} w-20 text-right tabular-nums`}
value={hours}
onChange={(e) => update(e.target.value, mins)}
aria-label={`${ariaLabel || 'Durée'} en heures`}
/>
<span className="text-sm text-muted-foreground">h</span>
<input
type="number"
min="0"
max="59"
inputMode="numeric"
disabled={disabled || hours >= maxHours}
className={`${inputClass} w-20 text-right tabular-nums`}
value={hours >= maxHours ? 0 : mins}
onChange={(e) => update(hours, e.target.value)}
aria-label={`${ariaLabel || 'Durée'} en minutes`}
/>
<span className="text-sm text-muted-foreground">min</span>
</div>
);
}
export default function ScheduleManager({ userId }) {
const [config, setConfig] = useState(null);
const [validFrom, setValidFrom] = useState(dateKey());
const [slots, setSlots] = useState([]);
const [mode, setMode] = useState('precise');
const [dayTargets, setDayTargets] = useState([]);
const [weekTargets, setWeekTargets] = useState([]);
const [cycleWeeks, setCycleWeeks] = useState(1);
const [week, setWeek] = useState(0);
const [dirty, setDirty] = useState(false);
const [removeWeekOpen, setRemoveWeekOpen] = useState(false);
const [versionToLoad, setVersionToLoad] = useState(null);
const [reload, setReload] = useState(0);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [exception, setException] = useState(null);
const [exceptionError, setExceptionError] = useState('');
const [toDelete, setToDelete] = useState(null);
const loadSchedule = (schedule) => {
setSlots(scheduleSlots(schedule));
setMode(schedule?.mode || 'precise');
setDayTargets(schedule?.day_targets || []);
setWeekTargets(schedule?.week_targets || []);
setCycleWeeks(schedule?.cycle_weeks || 1);
};
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError('');
planningApi.config(userId, controller.signal)
.then((data) => {
setConfig(data);
const current = activeSchedule(data, validFrom);
loadSchedule(current);
setWeek((old) => Math.min(old, (current?.cycle_weeks || 1) - 1));
setValidFrom(current?.valid_from || validFrom);
setDirty(false);
})
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
// La date d'effet édite le brouillon local ; seuls le salarié et l'actualisation rechargent la configuration.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, reload]);
const markDirty = () => { setDirty(true); setNotice(''); };
const saveWeek = async (event) => {
event?.preventDefault();
if (busy) return;
setBusy(true); setError(''); setNotice('');
try {
await planningApi.saveSchedule({
user_id: Number(userId),
valid_from: validFrom,
cycle_weeks: cycleWeeks,
mode,
slots,
day_targets: dayTargets,
week_targets: weekTargets,
expected_revision: config.revision,
});
setNotice(`Emploi du temps enregistré à partir du ${dateLabel(validFrom)}.`);
setReload((n) => n + 1);
} catch (e) { setError(e.message); } finally { setBusy(false); }
};
const saveException = async (event) => {
event.preventDefault(); if (busy) return; setBusy(true); setExceptionError('');
try { await planningApi.saveException({ ...exception, user_id: Number(userId), expected_revision: config.revision, end_date: exception.end_date || null, slots: exception.kind === 'work' ? exception.slots : [] }, exception.id); setException(null); setNotice('Changement enregistré. Le planning est à jour.'); setReload((n) => n + 1); }
catch (e) { setExceptionError(e.message); } finally { setBusy(false); }
};
const deleteException = async () => {
if (busy) return; setBusy(true); setExceptionError('');
try { await planningApi.removeException(toDelete.id, config.revision); setToDelete(null); setNotice('Changement supprimé. Le planning est à jour.'); setReload((n) => n + 1); }
catch (e) { setExceptionError(e.message); } finally { setBusy(false); }
};
const editException = (row) => { setExceptionError(''); setException(row ? { ...row, slots: cleanSlots(row.slots), end_date: row.end_date || '' } : emptyException()); };
const updateException = (values) => setException((old) => ({ ...old, ...values }));
const changeSlots = (values) => { setSlots(values); markDirty(); };
const setDayMinutes = (weekday, minutes) => {
setDayTargets((current) => [
...current.filter((item) => !(Number(item.week_index) === week && Number(item.weekday) === weekday)),
{ week_index: week, weekday, minutes },
]);
markDirty();
};
const setWeekMinutes = (minutes) => {
setWeekTargets((current) => [
...current.filter((item) => Number(item.week_index) !== week),
{ week_index: week, minutes },
]);
markDirty();
};
const getDayMinutes = (weekday) => Number(dayTargets.find((item) => Number(item.week_index) === week && Number(item.weekday) === weekday)?.minutes || 0);
const getWeekTarget = () => Number(weekTargets.find((item) => Number(item.week_index) === week)?.minutes || 0);
const addWeek = (copy = false) => {
if (cycleWeeks >= 52) return;
if (copy) {
setSlots((current) => [...current, ...current.filter((s) => s.week_index === week).map((s) => ({ ...s, week_index: cycleWeeks }))]);
setDayTargets((current) => [...current, ...current.filter((item) => Number(item.week_index) === week).map((item) => ({ ...item, week_index: cycleWeeks }))]);
const currentWeekTarget = weekTargets.find((item) => Number(item.week_index) === week);
if (currentWeekTarget) setWeekTargets((current) => [...current, { ...currentWeekTarget, week_index: cycleWeeks }]);
}
setCycleWeeks((n) => n + 1); setWeek(cycleWeeks); markDirty();
};
const removeWeek = () => {
const shift = (item) => ({ ...item, week_index: Number(item.week_index) > week ? Number(item.week_index) - 1 : Number(item.week_index) });
setSlots((current) => current.filter((s) => s.week_index !== week).map(shift));
setDayTargets((current) => current.filter((item) => Number(item.week_index) !== week).map(shift));
setWeekTargets((current) => current.filter((item) => Number(item.week_index) !== week).map(shift));
setCycleWeeks((n) => n - 1); setWeek((n) => Math.max(0, n - 1)); markDirty(); setRemoveWeekOpen(false);
};
const loadVersion = (version) => {
loadSchedule(version); setValidFrom(version.valid_from); setWeek(0); setDirty(false); setVersionToLoad(null); setNotice('');
};
const preciseWeekMinutes = slots.filter((s) => s.week_index === week).reduce((sum, s) => sum + duration(s), 0);
const dailyWeekMinutes = WEEKDAYS.reduce((sum, _day, weekday) => sum + getDayMinutes(weekday), 0);
const weekMinutes = mode === 'daily_hours' ? dailyWeekMinutes : mode === 'weekly_hours' ? getWeekTarget() : preciseWeekMinutes;
return <div className="space-y-5">
<div className="flex items-center justify-between gap-2"><p className="text-sm text-muted-foreground">Prépare les horaires du salarié et les changements prévus.</p><Button type="button" variant="ghost" size="icon" disabled={loading || busy} aria-label="Actualiser les emplois du temps" onClick={() => setReload((n) => n + 1)}><RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} /></Button></div>
{error && <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
{notice && <p role="status" className="rounded-lg bg-emerald-500/10 p-3 text-sm text-emerald-700 dark:text-emerald-300">{notice}</p>}
{loading ? <p className="py-8 text-center text-sm text-muted-foreground">Chargement de l’emploi du temps…</p> : config && <>
<section className="space-y-5 rounded-xl border p-3 sm:p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="space-y-1"><h2 className="text-lg font-semibold">Emploi du temps habituel</h2><p className="text-sm text-muted-foreground">Choisis le niveau de précision utile pour ce salarié, puis remplis la semaine A. Tu peux ajouter B, C… si le planning tourne sur plusieurs semaines.</p></div>
{dirty && <Badge variant="outline">Modifications à enregistrer</Badge>}
</div>
<div className="grid items-end gap-3 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm">Appliquer ce planning à partir du<DatePicker required disabled={busy} value={validFrom} onChange={(value) => { setValidFrom(value); markDirty(); }} /></label>
<label className="grid gap-1.5 text-sm">Reprendre un planning enregistré<select disabled={busy} className={inputClass} value="" onChange={(e) => { const version = config.schedules.find((s) => String(s.id) === e.target.value); if (version) { if (dirty) setVersionToLoad(version); else loadVersion(version); } }}><option value="">Choisir une période…</option>{config.schedules.map((s) => <option key={s.id} value={s.id}>Depuis le {dateLabel(s.valid_from)} · {MODES[s.mode || 'precise'].label} · {s.cycle_weeks || 1} semaine(s)</option>)}</select></label>
</div>
<p className="text-xs leading-relaxed text-muted-foreground">La semaine A est celle de la date choisie, du lundi au dimanche. Pour changer le planning à partir d’une nouvelle date tout en gardant l’historique, change cette date avant d’enregistrer.</p>
<div className="grid gap-2 sm:grid-cols-3" role="group" aria-label="Type de planning">
{Object.entries(MODES).map(([key, item]) => <Button key={key} type="button" variant={mode === key ? 'default' : 'outline'} className="h-auto min-h-20 items-start justify-start whitespace-normal p-3 text-left" disabled={busy} onClick={() => { setMode(key); markDirty(); }}><span><span className="block font-medium">{item.label}</span><span className={`mt-1 block text-xs font-normal ${mode === key ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>{item.description}</span></span></Button>)}
</div>
<div className="space-y-3 rounded-lg bg-muted/40 p-3">
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="icon" aria-label="Semaine précédente du cycle" disabled={busy || week === 0} onClick={() => setWeek((n) => n - 1)}><ChevronLeft className="size-4" /></Button>
<select aria-label="Semaine à remplir" className={`${inputClass.replace('w-full', 'w-auto')} min-w-32 flex-1 sm:flex-none`} value={week} disabled={busy} onChange={(e) => setWeek(Number(e.target.value))}>{Array.from({ length: cycleWeeks }, (_, i) => <option key={i} value={i}>{weekName(i)}</option>)}</select>
<Button type="button" variant="outline" size="icon" aria-label="Semaine suivante du cycle" disabled={busy || week === cycleWeeks - 1} onClick={() => setWeek((n) => n + 1)}><ChevronRight className="size-4" /></Button>
<span className="text-sm font-medium tabular-nums sm:ml-auto">{formatMinutes(weekMinutes)} prévu{weekMinutes !== 0 ? 'es' : ''}</span>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={busy || cycleWeeks >= 52} onClick={() => addWeek()}><Plus className="size-4" />Ajouter une semaine</Button>
<Button type="button" variant="outline" size="sm" disabled={busy || cycleWeeks >= 52} onClick={() => addWeek(true)}><Copy className="size-4" />Copier cette semaine</Button>
<Button type="button" variant="ghost" size="sm" disabled={busy || cycleWeeks === 1} onClick={() => setRemoveWeekOpen(true)}><Trash2 className="size-4" />Retirer cette semaine</Button>
</div>
<p className="text-sm">{cycleWeeks === 1 ? 'La semaine A se répète chaque semaine.' : `Ces ${cycleWeeks} semaines se répètent dans l’ordre. Après la ${weekName(cycleWeeks - 1).toLowerCase()}, le planning reprend à la semaine A.`}</p>
</div>
{mode === 'precise' && <ScheduleGrid slots={slots} onChange={changeSlots} week={week} cycleWeeks={cycleWeeks} disabled={busy} />}
{mode === 'daily_hours' && <div className="overflow-hidden rounded-xl border">
<div className="border-b bg-muted/50 px-4 py-3"><p className="font-medium">Nombre d’heures à faire chaque jour</p><p className="text-xs text-muted-foreground">Laisse 0 h pour un jour de repos. Aucun horaire de début ou de fin n’est imposé.</p></div>
<div className="divide-y">{WEEKDAYS.map((day, weekday) => <div key={day} className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-sm font-medium">{day}</p><p className="text-xs text-muted-foreground">{getDayMinutes(weekday) ? `${formatMinutes(getDayMinutes(weekday))} à effectuer` : 'Repos'}</p></div><DurationInput minutes={getDayMinutes(weekday)} onChange={(value) => setDayMinutes(weekday, value)} disabled={busy} ariaLabel={`Durée du ${day}`} /></div>)}</div>
</div>}
{mode === 'weekly_hours' && <div className="rounded-xl border p-4 sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="font-medium">Total d’heures de la {weekName(week).toLowerCase()}</p><p className="mt-1 text-sm text-muted-foreground">Le salarié verra uniquement ce total pour la semaine, sans répartition par jour ni plage horaire.</p></div>
<DurationInput minutes={getWeekTarget()} onChange={setWeekMinutes} maxHours={168} disabled={busy} ariaLabel={`Total de la ${weekName(week).toLowerCase()}`} />
</div>
</div>}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">{mode === 'precise' ? 'Sans horaires renseignés, la journée est un jour de repos.' : mode === 'daily_hours' ? 'Un jour à 0 h est considéré comme un jour de repos.' : 'Le total hebdomadaire ne fixe aucun jour ni aucune heure précise.'}</p>
<Button type="button" onClick={saveWeek} disabled={busy || !validFrom}><Save className="size-4" />{busy ? 'Enregistrement…' : 'Enregistrer le planning'}</Button>
</div>
</section>
<section className="space-y-4 rounded-xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-3"><div className="space-y-1"><h2 className="text-lg font-semibold">Changements prévus</h2><p className="text-sm text-muted-foreground">Une absence, un jour de repos ou des horaires différents.</p></div><Button disabled={busy || dirty} variant="outline" onClick={() => editException(null)}><Plus className="size-4" />Ajouter un changement</Button></div>
<p className="text-xs leading-relaxed text-muted-foreground">Un changement sur une période précise passe avant un changement qui se répète. Sinon, le planning habituel s’applique. Si deux changements de même type se croisent, le dernier ajouté est utilisé.</p>
{dirty && <p className="text-sm text-muted-foreground">Enregistre le planning ci-dessus avant de modifier les changements prévus.</p>}
{!config.exceptions.length ? <p className="py-5 text-sm text-muted-foreground">Aucun changement prévu.</p> : <div className="divide-y">{config.exceptions.map((row) => <div key={row.id} className="flex items-start justify-between gap-2 py-3">
<div className="min-w-0 space-y-1"><p className="break-words text-sm font-medium">{row.label || ({ work: 'Horaires différents', rest: 'Repos', absence: 'Congé / absence' }[row.kind])}</p><p className="text-xs text-muted-foreground">{dateLabel(row.start_date)} → {dateLabel(row.end_date)}</p>{row.recurring && <p className="text-xs text-muted-foreground">{row.weekdays.map((d) => WEEKDAYS[d]).join(', ')} · toutes les {row.every_weeks} semaine{row.every_weeks > 1 ? 's' : ''}</p>}<p className="text-xs">{row.mode === 'daily_hours' ? `${formatMinutes(row.target_minutes || 0)} à effectuer` : row.slots.map(slotLabel).join(' · ')}</p><div className="flex flex-wrap gap-1"><Badge variant="secondary">{row.recurring ? 'Se répète' : 'Période précise'}</Badge>{row.request_id && <Badge variant="outline">Demande #{row.request_id}</Badge>}</div></div>
<div className="flex shrink-0 gap-1"><Button variant="ghost" size="icon" aria-label="Modifier ce changement" disabled={busy || dirty} onClick={() => editException(row)}><Pencil className="size-4" /></Button><Button variant="ghost" size="icon" aria-label="Supprimer ce changement" disabled={busy || dirty} onClick={() => { setExceptionError(''); setToDelete(row); }}><Trash2 className="size-4" /></Button></div>
</div>)}</div>}
</section>
</>}
<Dialog open={Boolean(exception)} onOpenChange={(open) => { if (!open && !busy) setException(null); }}><DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-2xl" showCloseButton={!busy}>{exception && <>
<DialogHeader><DialogTitle className="flex items-center gap-2"><CalendarPlus className="size-5" />{exception.id ? 'Modifier un changement' : 'Ajouter un changement'}</DialogTitle><DialogDescription>Pour les jours choisis, ces horaires remplacent le planning habituel.</DialogDescription></DialogHeader>
<form onSubmit={saveException} className="space-y-4"><fieldset disabled={busy} className="space-y-4">
<label className="grid gap-1.5 text-sm">Motif (facultatif)<input className={inputClass} maxLength={160} value={exception.label} onChange={(e) => updateException({ label: e.target.value })} /></label>
<label className="grid gap-1.5 text-sm">Type<select className={inputClass} value={exception.kind} onChange={(e) => updateException({ kind: e.target.value })}><option value="work">Horaires différents</option><option value="rest">Repos</option><option value="absence">Congé / absence</option></select></label>
<label className="flex items-center gap-2 text-sm"><Switch checked={exception.recurring} onCheckedChange={(checked) => updateException({ recurring: checked })} />Répéter ce changement</label>
<div className="grid gap-3 sm:grid-cols-2"><label className="grid gap-1.5 text-sm">À partir du<DatePicker required value={exception.start_date} onChange={(value) => updateException({ start_date: value })} /></label><label className="grid gap-1.5 text-sm">{exception.recurring ? 'Jusqu’au (facultatif)' : 'Jusqu’au (inclus)'}<DatePicker required={!exception.recurring} min={exception.start_date} value={exception.end_date} onChange={(value) => updateException({ end_date: value })} /></label></div>
{exception.recurring && <div className="space-y-3 rounded-lg bg-muted/40 p-3"><label className="grid gap-1.5 text-sm">Répétition<select className={inputClass} value={exception.every_weeks} onChange={(e) => updateException({ every_weeks: Number(e.target.value) })}>{[1, 2, 3, 4].map((n) => <option key={n} value={n}>{n === 1 ? 'Chaque semaine' : `Toutes les ${n} semaines`}</option>)}</select></label><div className="flex flex-wrap gap-2">{WEEKDAYS.map((day, i) => <label key={day} className="flex items-center gap-1.5 text-xs"><Checkbox checked={exception.weekdays.includes(i)} onCheckedChange={(checked) => updateException({ weekdays: checked ? [...exception.weekdays, i] : exception.weekdays.filter((d) => d !== i) })} />{day}</label>)}</div></div>}
{exception.kind === 'work' && <ScheduleGrid dayOnly slots={exception.slots} onChange={(values) => updateException({ slots: values })} disabled={busy} />}
</fieldset>{exceptionError && <p role="alert" className="text-sm text-destructive">{exceptionError}</p>}<div className="flex flex-wrap items-center justify-end gap-2"><Button type="button" variant="outline" disabled={busy} onClick={() => setException(null)}>Annuler</Button><Button type="submit" disabled={busy}>{busy ? 'Enregistrement…' : 'Enregistrer'}</Button></div></form>
</>}</DialogContent></Dialog>
<Dialog open={Boolean(toDelete)} onOpenChange={(open) => { if (!open && !busy) setToDelete(null); }}><DialogContent><DialogHeader><DialogTitle>Supprimer ce changement ?</DialogTitle><DialogDescription>Les autres changements prévus, ou le planning habituel, s’appliqueront à nouveau. La demande associée reste dans l’historique.</DialogDescription></DialogHeader>{exceptionError && <p role="alert" className="text-sm text-destructive">{exceptionError}</p>}<div className="flex flex-wrap items-center justify-end gap-2"><Button variant="outline" disabled={busy} onClick={() => setToDelete(null)}>Conserver</Button><Button variant="destructive" disabled={busy} onClick={deleteException}>Supprimer</Button></div></DialogContent></Dialog>
<Dialog open={removeWeekOpen} onOpenChange={setRemoveWeekOpen}><DialogContent><DialogHeader><DialogTitle>Retirer la {weekName(week).toLowerCase()} ?</DialogTitle><DialogDescription>Ses informations seront retirées du planning. Les semaines restantes se répéteront dans leur nouvel ordre. Il faudra enregistrer le planning pour appliquer ce changement.</DialogDescription></DialogHeader><div className="flex flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => setRemoveWeekOpen(false)}>Garder cette semaine</Button><Button variant="destructive" onClick={removeWeek}>Retirer la semaine</Button></div></DialogContent></Dialog>
<Dialog open={Boolean(versionToLoad)} onOpenChange={(open) => { if (!open) setVersionToLoad(null); }}><DialogContent><DialogHeader><DialogTitle>Reprendre ce planning ?</DialogTitle><DialogDescription>Les changements que tu n’as pas encore enregistrés seront remplacés.</DialogDescription></DialogHeader><div className="flex flex-wrap items-center justify-end gap-2"><Button variant="outline" onClick={() => setVersionToLoad(null)}>Continuer mes modifications</Button><Button onClick={() => loadVersion(versionToLoad)}>Reprendre le planning</Button></div></DialogContent></Dialog>
</div>;
}

View File

@ -0,0 +1,31 @@
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TimePicker } from '@/components/ResponsivePickers';
import { inputClass, newSlot } from './utils';
export default function SlotsEditor({ slots, onChange, disabled = false, name = 'Créneaux' }) {
const update = (index, change) => onChange(slots.map((slot, i) => i === index ? { ...slot, ...change } : slot));
return (
<div className="space-y-2">
{slots.map((slot, index) => (
<div key={index} className="flex flex-wrap items-end gap-2 rounded-lg bg-muted/40 p-2">
<label className="min-w-[105px] flex-1 space-y-1 text-xs text-muted-foreground">
<span>Début</span>
<TimePicker ariaLabel={`${name} ${index + 1} début`} required value={slot.start_time.slice(0, 5)} disabled={disabled} onChange={(start_time) => update(index, { start_time })} />
</label>
<label className="min-w-[105px] flex-1 space-y-1 text-xs text-muted-foreground">
<span>Fin</span>
<TimePicker ariaLabel={`${name} ${index + 1} fin`} required value={slot.end_time.slice(0, 5)} disabled={disabled} onChange={(end_time) => update(index, { end_time, end_day_offset: end_time && end_time <= slot.start_time.slice(0, 5) ? 1 : slot.end_day_offset })} />
</label>
<label className="min-w-[135px] flex-1 space-y-1 text-xs text-muted-foreground">
<span>Jour de fin</span>
<select aria-label={`${name} ${index + 1} jour de fin`} className={inputClass} value={slot.end_day_offset} disabled={disabled} onChange={(e) => update(index, { end_day_offset: Number(e.target.value) })}><option value={0}>Le même jour</option><option value={1}>Le jour suivant</option></select>
</label>
<Button type="button" variant="ghost" size="icon" disabled={disabled} aria-label={`Supprimer ${name.toLowerCase()} ${index + 1}`} onClick={() => onChange(slots.filter((_, i) => i !== index))}><Trash2 className="size-4" /></Button>
</div>
))}
{!slots.length && <p className="text-sm text-muted-foreground">Aucun horaire renseigné.</p>}
<Button type="button" variant="outline" size="sm" disabled={disabled || slots.length >= 10} onClick={() => onChange([...slots, newSlot()])}><Plus className="size-3.5" />Ajouter des horaires</Button>
</div>
);
}

View File

@ -0,0 +1,51 @@
export const DAY_MINUTES = 1440;
export const WEEK_MINUTES = 7 * DAY_MINUTES;
export const toMinutes = (time) => {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
};
export const toTime = (minutes) => `${String(Math.floor(minutes / 60) % 24).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
export const duration = (slot) => toMinutes(slot.end_time) + Number(slot.end_day_offset || 0) * DAY_MINUTES - toMinutes(slot.start_time);
export const weekName = (index) => {
let value = index + 1;
let label = '';
while (value > 0) {
value -= 1;
label = String.fromCharCode(65 + value % 26) + label;
value = Math.floor(value / 26);
}
return `Semaine ${label}`;
};
export function slotStart(slot) {
return (Number(slot.week_index || 0) * 7 + Number(slot.weekday || 0)) * DAY_MINUTES + toMinutes(slot.start_time);
}
export function gridSegments(slots, week, cycleWeeks) {
const cycle = cycleWeeks * WEEK_MINUTES;
const startOfWeek = week * WEEK_MINUTES;
const segments = [];
slots.forEach((slot, index) => {
const start = slotStart(slot);
for (const shift of [-cycle, 0, cycle]) {
const shifted = start + shift;
const finish = shifted + duration(slot);
for (let day = 0; day < 7; day += 1) {
const dayStart = startOfWeek + day * DAY_MINUTES;
const begin = Math.max(dayStart, shifted);
const end = Math.min(dayStart + DAY_MINUTES, finish);
if (end > begin) segments.push({ index, day, start: begin - dayStart, end: end - dayStart, continuation: shifted < dayStart });
}
}
});
return segments;
}
export function hasOverlap(slots, cycleWeeks) {
const cycle = cycleWeeks * WEEK_MINUTES;
const intervals = slots.flatMap((slot, index) => {
const start = slotStart(slot);
return [-cycle, 0, cycle].map((shift) => ({ start: start + shift, end: start + shift + duration(slot), index }));
}).sort((a, b) => a.start - b.start);
return intervals.some((entry, index) => index > 0 && entry.start < intervals[index - 1].end);
}

View File

@ -0,0 +1,12 @@
export const WEEKDAYS = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'];
export const REQUEST_KINDS = { leave: 'Congé / absence', hours: 'Modification d’horaires', move: 'Déplacement de journée', swap: 'Échange de journée' };
export const REQUEST_STATES = { pending: 'En attente', approved: 'Acceptée', rejected: 'Refusée', cancelled: 'Annulée' };
export const personLabel = (user) => user ? [user.first_name, user.last_name].filter(Boolean).join(' ') || user.username : 'Compte supprimé';
export const dateKey = (date = new Date()) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
export const dateLabel = (value) => value ? new Date(`${value.slice(0, 10)}T12:00:00`).toLocaleDateString('fr-FR') : 'Sans limite';
export const newSlot = () => ({ start_time: '08:00', end_time: '12:00', end_day_offset: 0 });
export const cleanSlots = (slots) => slots.map((s) => ({ start_time: s.start_time.slice(0, 5), end_time: s.end_time.slice(0, 5), end_day_offset: Number(s.end_day_offset || 0) }));
export const slotLabel = (s) => `${s.start_time.slice(0, 5)} – ${s.end_time.slice(0, 5)}${s.end_day_offset ? ' le jour suivant' : ''}`;
export const formatMinutes = (minutes = 0) => `${Math.floor(Number(minutes || 0) / 60)}h${Number(minutes || 0) % 60 ? String(Number(minutes || 0) % 60).padStart(2, '0') : ''}`;
export const dayLabel = (day) => day.slots?.length ? day.slots.map(slotLabel).join(' · ') : day.schedule_mode === 'daily_hours' && Number(day.target_minutes || 0) > 0 ? `${formatMinutes(day.target_minutes)} à effectuer` : day.schedule_mode === 'weekly_hours' && Number(day.week_target_minutes || 0) > 0 ? `${formatMinutes(day.week_target_minutes)} à effectuer dans la semaine` : ({ rest: 'Repos', absence: 'Congé / absence', undefined: 'Non défini', weekly_target: 'Objectif hebdomadaire' }[day.kind] || 'Repos');
export const inputClass = 'h-9 w-full min-w-0 rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50';

View File

@ -0,0 +1,280 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FlagTriangleRight, Play, RotateCcw, Trophy } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { timeApi } from '@/api/time';
import { formatMinutes, personLabel } from '@/components/planning/utils';
import { useAuth } from '@/context/useAuth';
const RACE_DURATION_MS = 20000;
const monthLabel = (value) => {
if (!value) return '';
return new Date(`${value}-01T12:00:00`).toLocaleDateString('fr-FR', {
month: 'long',
year: 'numeric',
});
};
const dateLabel = (value) => {
if (!value) return '';
return new Date(`${value}T12:00:00`).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
});
};
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
export default function MonthlyHoursRace() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [race, setRace] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [progress, setProgress] = useState(0);
const [runId, setRunId] = useState(0);
const frameRef = useRef(null);
const loadRace = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await timeApi.getMonthlyRace();
setRace(data);
setProgress(0);
setRunId((value) => value + 1);
} catch (e) {
setError(e.response?.data?.detail || e.message || 'Impossible de charger la course du mois.');
} finally {
setLoading(false);
}
}, []);
const handleOpenChange = (nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
setProgress(0);
}
};
const openRace = async () => {
setOpen(true);
await loadRace();
};
const replay = () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
setProgress(0);
setRunId((value) => value + 1);
};
useEffect(() => {
if (!open || !race || loading || !race.participants?.length) return undefined;
let startTime = null;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion) {
setProgress(1);
return undefined;
}
const tick = (timestamp) => {
if (startTime === null) startTime = timestamp;
const nextProgress = clamp((timestamp - startTime) / RACE_DURATION_MS, 0, 1);
setProgress(nextProgress);
if (nextProgress < 1) {
frameRef.current = requestAnimationFrame(tick);
} else {
frameRef.current = null;
}
};
frameRef.current = requestAnimationFrame(tick);
return () => {
if (frameRef.current) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
};
}, [open, race, loading, runId]);
const participants = race?.participants || [];
const daysCount = race?.days_count || participants[0]?.daily_minutes?.length || 0;
const maxFinalMinutes = useMemo(
() => Math.max(0, ...participants.map((row) => Number(row.total_minutes || 0))),
[participants],
);
const laneRows = useMemo(() => (
[...participants].sort((a, b) => personLabel(a).localeCompare(personLabel(b), 'fr', { sensitivity: 'base' }))
), [participants]);
const currentState = useMemo(() => {
if (!daysCount) return { dayIndex: 0, dayFraction: 0, currentDate: race?.start || null };
const rawDay = progress * daysCount;
const dayIndex = Math.min(daysCount - 1, Math.floor(rawDay));
const dayFraction = progress >= 1 ? 1 : rawDay - dayIndex;
const start = race?.start ? new Date(`${race.start}T12:00:00`) : null;
if (start) start.setDate(start.getDate() + dayIndex);
return {
dayIndex,
dayFraction,
currentDate: start ? start.toISOString().slice(0, 10) : race?.start || null,
};
}, [daysCount, progress, race?.start]);
const animatedRows = useMemo(() => laneRows.map((participant) => {
const daily = Array.isArray(participant.daily_minutes) ? participant.daily_minutes : [];
const completedBefore = daily
.slice(0, currentState.dayIndex)
.reduce((sum, value) => sum + Number(value || 0), 0);
const currentDayMinutes = Number(daily[currentState.dayIndex] || 0) * currentState.dayFraction;
const animatedMinutes = progress >= 1
? Number(participant.total_minutes || 0)
: completedBefore + currentDayMinutes;
const percent = maxFinalMinutes > 0 ? clamp((animatedMinutes / maxFinalMinutes) * 100, 0, 100) : 0;
return { ...participant, animatedMinutes, percent };
}), [laneRows, currentState, progress, maxFinalMinutes]);
const finished = progress >= 1;
const podium = [...participants].sort((a, b) => Number(a.rank) - Number(b.rank)).slice(0, 3);
const winner = podium[0];
return (
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" onClick={openRace}>
<Play className="size-4" aria-hidden="true" />
Voir la course du mois
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="capitalize">
{race?.month ? `Course de ${monthLabel(race.month)}` : 'Course du mois'}
</DialogTitle>
</DialogHeader>
{loading && (
<div className="flex min-h-56 items-center justify-center text-sm text-muted-foreground">
Préparation de la course…
</div>
)}
{error && (
<p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>
)}
{!loading && !error && race && (participants.length === 0 || maxFinalMinutes === 0) && (
<p className="py-10 text-center text-sm text-muted-foreground">Aucune heure pointée sur le mois précédent.</p>
)}
{!loading && !error && race && participants.length > 0 && maxFinalMinutes > 0 && (
<div className="space-y-5">
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span className="font-medium capitalize">
{finished ? 'Arrivée' : dateLabel(currentState.currentDate)}
</span>
<span className="tabular-nums text-muted-foreground">{Math.round(progress * 100)} %</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full bg-primary transition-[width] duration-100" style={{ width: `${progress * 100}%` }} />
</div>
</div>
<div className="space-y-3" aria-label="Course des heures du mois précédent">
{animatedRows.map((participant) => {
const isMe = String(participant.id) === String(user?.id);
return (
<div key={participant.id} className={`rounded-lg border p-3 ${isMe ? 'ring-1 ring-primary/40' : ''}`}>
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<span className="truncate font-medium">{personLabel(participant)}</span>
{isMe && <span className="ml-2 text-xs text-muted-foreground">toi</span>}
</div>
<span className="shrink-0 font-medium tabular-nums">{formatMinutes(Math.round(participant.animatedMinutes))}</span>
</div>
<div className="relative h-10 overflow-hidden rounded-full bg-muted/70">
<div
className="absolute inset-y-0 left-0 rounded-full opacity-30"
style={{
width: `${participant.percent}%`,
backgroundColor: participant.color || 'var(--primary)',
}}
/>
<span
className="absolute top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 text-2xl transition-[left] duration-100"
style={{ left: `${clamp(participant.percent, 2.5, 97.5)}%` }}
aria-hidden="true"
>
🏃
</span>
<FlagTriangleRight className="absolute right-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
</div>
</div>
);
})}
</div>
{finished && podium.length > 0 && (
<section className="space-y-4 rounded-xl border bg-muted/20 p-4" aria-label="Podium final">
{winner && Number(winner.total_minutes || 0) > 0 && (
<div className="relative overflow-hidden rounded-xl border bg-primary/5 px-4 py-5 text-center">
<div className="absolute left-4 top-3 animate-bounce text-xl motion-reduce:animate-none" aria-hidden="true">🎉</div>
<div className="absolute right-4 top-5 animate-pulse text-xl motion-reduce:animate-none" aria-hidden="true">✨</div>
<div className="text-5xl animate-bounce motion-reduce:animate-none" aria-hidden="true">🏆</div>
<p className="mt-2 text-sm text-muted-foreground">Premier du mois</p>
<p className="mt-1 text-xl font-semibold">{personLabel(winner)}</p>
<p className="mt-1 font-medium tabular-nums">{formatMinutes(winner.total_minutes)}</p>
</div>
)}
<div className="flex items-center gap-2">
<Trophy className="size-4 text-primary" aria-hidden="true" />
<h3 className="font-semibold">Podium</h3>
</div>
<div className="grid grid-cols-3 items-end gap-2 sm:gap-3">
{[1, 0, 2].map((index) => {
const participant = podium[index];
if (!participant) return <div key={index} />;
const rank = index + 1;
const height = rank === 1 ? 'min-h-36' : rank === 2 ? 'min-h-28' : 'min-h-24';
return (
<div key={participant.id} className={`flex ${height} min-w-0 flex-col justify-end rounded-t-xl border border-b-0 bg-background p-2 text-center sm:p-3`}>
<div className={rank === 1 ? 'animate-bounce text-2xl motion-reduce:animate-none' : 'text-xl'} aria-hidden="true">
{rank === 1 ? '🥇' : rank === 2 ? '🥈' : '🥉'}
</div>
<p className="mt-1 truncate text-sm font-semibold">{personLabel(participant)}</p>
<p className="text-xs tabular-nums text-muted-foreground">{formatMinutes(participant.total_minutes)}</p>
</div>
);
})}
</div>
</section>
)}
</div>
)}
<div className="flex flex-wrap items-center justify-end gap-2 gap-2 sm:gap-0">
{race && participants.length > 0 && maxFinalMinutes > 0 && !loading && !error && (
<Button type="button" variant="outline" onClick={replay}>
<RotateCcw className="size-4" aria-hidden="true" />
Rejouer
</Button>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,146 @@
import { useEffect, useState } from 'react';
import { BarChart3, CalendarDays, CarFront, Clock3, RefreshCw, Sun } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ResponsivePickers';
import { timeApi } from '@/api/time';
import { dateKey, formatMinutes } from '@/components/planning/utils';
const PERIODS = [
{ key: 'day', label: 'Jour' },
{ key: 'month', label: 'Mois' },
{ key: 'year', label: 'Année' },
];
const StatCard = ({ icon: Icon, label, value }) => (
<div className="rounded-xl border bg-background/50 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
</div>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-4" aria-hidden="true" />
</span>
</div>
</div>
);
const rangeLabel = (stats) => {
if (!stats) return '';
const start = new Date(`${stats.start}T12:00:00`);
if (stats.period === 'day') {
return start.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
if (stats.period === 'month') {
return start.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
}
return String(start.getFullYear());
};
export default function PersonalStatistics() {
const [period, setPeriod] = useState('month');
const [anchor, setAnchor] = useState(dateKey());
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [reload, setReload] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError('');
timeApi.getStats({ period, anchor }, controller.signal)
.then((data) => {
if (!controller.signal.aborted) setStats(data);
})
.catch((e) => {
if (!controller.signal.aborted) {
setError(e.response?.data?.detail || e.message || 'Impossible de charger les statistiques.');
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [period, anchor, reload]);
const monthValue = anchor.slice(0, 7);
const yearValue = anchor.slice(0, 4);
return (
<section aria-labelledby="personal-statistics-title" className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<BarChart3 className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 id="personal-statistics-title" className="text-lg font-semibold tracking-tight">Mes statistiques</h2>
<p className="mt-1 text-sm text-muted-foreground">Retrouve tes heures et tes pointages.</p>
</div>
</div>
<Button type="button" variant="ghost" size="icon" disabled={loading} aria-label="Actualiser les statistiques" onClick={() => setReload((value) => value + 1)}>
<RefreshCw className={`size-4 ${loading ? 'animate-spin motion-reduce:animate-none' : ''}`} aria-hidden="true" />
</Button>
</div>
<div className="mt-4 flex flex-col gap-3 rounded-lg border bg-muted/20 p-3 lg:flex-row lg:items-end">
<div className="grid gap-1.5 text-sm font-medium">
Période
<div className="flex flex-wrap gap-2">
{PERIODS.map((item) => (
<Button key={item.key} type="button" size="sm" variant={period === item.key ? 'default' : 'outline'} onClick={() => setPeriod(item.key)}>
{item.label}
</Button>
))}
</div>
</div>
<label className="grid min-w-0 flex-1 gap-1.5 text-sm font-medium lg:max-w-60">
{period === 'day' ? 'Date' : period === 'month' ? 'Mois' : 'Année'}
{period === 'day' && (
<DatePicker value={anchor} onChange={setAnchor} />
)}
{period === 'month' && (
<DatePicker mode="month" value={monthValue} onChange={(value) => setAnchor(`${value}-01`)} />
)}
{period === 'year' && (
<DatePicker mode="year" min="2020" max="2100" value={yearValue} onChange={(value) => setAnchor(`${value}-01-01`)} />
)}
</label>
</div>
{error && (
<p role="alert" className="mt-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>
)}
{stats && (
<div className={`mt-4 space-y-4 ${loading ? 'opacity-60' : ''}`} aria-busy={loading}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold capitalize">{rangeLabel(stats)}</h3>
{stats.incomplete_entries > 0 && (
<Badge variant="outline">{stats.incomplete_entries} pointage(s) incomplet(s)</Badge>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Clock3} label="Travaillées / prévues" value={`${formatMinutes(stats.total_minutes)} / ${formatMinutes(stats.planned_minutes || 0)}`} />
<StatCard icon={CarFront} label="Aller-retours" value={stats.round_trips} />
<StatCard icon={Sun} label="Dimanches travaillés" value={stats.sundays_worked} />
<StatCard icon={CalendarDays} label="Heures le dimanche" value={formatMinutes(stats.sunday_minutes)} />
</div>
</div>
)}
</section>
);
}

View File

@ -43,8 +43,17 @@ export const AuthProvider = ({ children }) => {
if (!cancelled) setLoading(false);
};
const refreshVisible = () => { if (!document.hidden) checkAuth(); };
checkAuth();
window.addEventListener('focus', refreshVisible);
window.addEventListener('auth:permissions-changed', refreshVisible);
document.addEventListener('visibilitychange', refreshVisible);
const refreshTimer = window.setInterval(refreshVisible, 60000);
return () => {
window.clearInterval(refreshTimer);
window.removeEventListener('focus', refreshVisible);
window.removeEventListener('auth:permissions-changed', refreshVisible);
document.removeEventListener('visibilitychange', refreshVisible);
cancelled = true;
window.removeEventListener('auth:logout', onLogout);
};

View File

@ -0,0 +1 @@
export const can = (user, permission) => Boolean(user && (user.is_admin || user.permissions?.[permission] === true));

View File

@ -21,12 +21,13 @@ import {
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { can } from '@/lib/permissions';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@ -36,7 +37,12 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import {
ChevronLeft,
@ -106,6 +112,7 @@ const EVENT_LEFT_PADDING = 3;
const CalendarPage = () => {
const { user, isAdmin } = useAuth();
const canCreate = can(user, 'create_personal_event');
const canManageEvent = (event) => {
const creatorId = event?.created_by?.id ?? event?.created_by;
return user?.id != null && creatorId != null &&
@ -128,6 +135,10 @@ const CalendarPage = () => {
useState([]);
const [loadingEvents, setLoadingEvents] =
useState(true);
const [eventProfiles, setEventProfiles] = useState([]);
const [loadingProfiles, setLoadingProfiles] = useState(false);
const [selectedProfileId, setSelectedProfileId] = useState('manual');
const [savingProfile, setSavingProfile] = useState(false);
const [hoveredEvent, setHoveredEvent] =
useState(null);
const [hoverPosition, setHoverPosition] =
@ -164,7 +175,6 @@ const CalendarPage = () => {
const [eventForm, setEventForm] =
useState({
name: '',
description: '',
start_date: '',
end_date: '',
start_time: '',
@ -344,7 +354,7 @@ const CalendarPage = () => {
isSelectingRef.current = false;
};
const beginSelection = (pointerEvent, date) => {
if (isMobile || pointerEvent.pointerType === 'touch' || pointerEvent.button !== 0) return;
if (!canCreate || isMobile || pointerEvent.pointerType === 'touch' || pointerEvent.button !== 0) return;
pointerEvent.preventDefault();
closeContextMenu();
setHoveredEvent(null);
@ -399,8 +409,26 @@ const CalendarPage = () => {
setLoadingEvents(false);
}
};
const loadEventProfiles = async () => {
if (!canCreate) {
setEventProfiles([]);
return;
}
try {
setLoadingProfiles(true);
const data = await eventApi.getProfiles();
setEventProfiles(Array.isArray(data) ? data : data?.results ?? []);
} catch (error) {
console.error('Erreur lors du chargement des profils d’événement :', error);
setEventProfiles([]);
} finally {
setLoadingProfiles(false);
}
};
useEffect(() => {
loadEvents();
loadEventProfiles();
}, []);
/*
* ============================================================
@ -560,6 +588,7 @@ const CalendarPage = () => {
) => {
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
if (!canCreate) return;
setHoveredEvent(null);
setHoverPosition(null);
const selection = getOrderedSelection();
@ -633,7 +662,6 @@ const CalendarPage = () => {
const resetEventForm = () => {
setEventForm({
name: '',
description: '',
start_date: '',
end_date: '',
start_time: '',
@ -641,16 +669,18 @@ const CalendarPage = () => {
color: DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin),
});
setSelectedProfileId('manual');
};
const openCreateDialog = (
startDate = '',
endDate = startDate,
) => {
if (!canCreate) return;
setRequestError('');
setEditingEvent(null);
setSelectedProfileId('manual');
setEventForm({
name: '',
description: '',
start_date: startDate,
end_date: endDate || startDate,
start_time: '',
@ -668,10 +698,9 @@ const CalendarPage = () => {
if (!canManageEvent(event)) return;
setRequestError('');
setEditingEvent(event);
setSelectedProfileId('manual');
setEventForm({
name: event?.name ?? '',
description:
event?.description ?? '',
start_date:
event?.start_date ?? '',
end_date:
@ -734,10 +763,93 @@ const CalendarPage = () => {
},
);
};
const formatProfileTime = (value) => {
if (!value) return '';
return String(value).slice(0, 5);
};
const handleProfileChange = (value) => {
setSelectedProfileId(value);
};
const handleLoadProfile = () => {
if (selectedProfileId === 'manual') return;
const profile = eventProfiles.find(
(item) => String(item.id) === String(selectedProfileId),
);
if (!profile) return;
setEventForm((current) => ({
...current,
name: profile.name ?? '',
start_time: formatProfileTime(profile.start_time),
end_time: formatProfileTime(profile.end_time),
color: profile.color || DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin) && profile.is_public === true,
}));
};
const getProfilePayload = () => ({
name: eventForm.name.trim(),
start_time: eventForm.start_time || null,
end_time: eventForm.end_time || null,
color: eventForm.color || DEFAULT_EVENT_COLOR,
is_public: Boolean(isAdmin) && eventForm.is_public,
});
const handleSaveProfile = async () => {
if (savingProfile || !eventForm.name.trim()) return;
try {
setRequestError('');
setSavingProfile(true);
const payload = getProfilePayload();
let savedProfile;
if (selectedProfileId !== 'manual') {
savedProfile = await eventApi.updateProfile(selectedProfileId, payload);
} else {
savedProfile = await eventApi.createProfile(payload);
}
await loadEventProfiles();
setSelectedProfileId(String(savedProfile.id));
} catch (error) {
setRequestError(error.message);
console.error('Erreur lors de l’enregistrement du profil d’événement :', error);
} finally {
setSavingProfile(false);
}
};
const handleDeleteProfile = async () => {
if (savingProfile || selectedProfileId === 'manual') return;
const profile = eventProfiles.find((item) => String(item.id) === String(selectedProfileId));
if (!profile) return;
const confirmed = window.confirm(`Supprimer le profil « ${profile.name} » ?`);
if (!confirmed) return;
try {
setRequestError('');
setSavingProfile(true);
await eventApi.deleteProfile(profile.id);
setSelectedProfileId('manual');
await loadEventProfiles();
} catch (error) {
setRequestError(error.message);
console.error('Erreur lors de la suppression du profil d’événement :', error);
} finally {
setSavingProfile(false);
}
};
const handleSaveEvent = async (
submitEvent,
) => {
setRequestError('');
if (!editingEvent && !canCreate) { setRequestError('L’ajout d’événements n’est pas activé pour ton compte.'); return; }
if (savingEvent || (editingEvent && !canManageEvent(editingEvent))) return;
if (!eventForm.name.trim()) {
return;
@ -765,8 +877,6 @@ const CalendarPage = () => {
setSavingEvent(true);
const payload = {
name: eventForm.name.trim(),
description:
eventForm.description.trim(),
start_date:
eventForm.start_date,
end_date:
@ -967,9 +1077,6 @@ const CalendarPage = () => {
{hoveredEvent && (
<HoverInfo
title={hoveredEvent.name}
description={
hoveredEvent.description
}
startTime={
hoveredEvent.start_time
}
@ -1018,7 +1125,7 @@ const CalendarPage = () => {
mouseEvent.stopPropagation();
}}
>
{(contextMenu.type === 'day' ||
{canCreate && (contextMenu.type === 'day' ||
contextMenu.type === 'selection') && (
<button
type="button"
@ -1133,18 +1240,70 @@ const CalendarPage = () => {
}
}}
>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>
{editingEvent
? 'Modifier l’événement'
: 'Créer un événement'}
</DialogTitle>
<DialogDescription>
{editingEvent
? 'Modifie les informations de cet événement.'
: 'Ajoute un nouvel événement au calendrier.'}
</DialogDescription>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[550px]">
<DialogHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div className="min-w-0 space-y-1.5">
<DialogTitle>
{editingEvent
? 'Modifier l’événement'
: 'Créer un événement'}
</DialogTitle>
<DialogDescription>
{editingEvent
? 'Modifie les informations de cet événement.'
: 'Ajoute un nouvel événement au calendrier.'}
</DialogDescription>
</div>
{!editingEvent && canCreate && (
<div className="flex w-full min-w-0 items-center gap-2 sm:w-auto sm:max-w-[310px]">
<Select
value={selectedProfileId}
onValueChange={handleProfileChange}
disabled={loadingProfiles || savingProfile}
>
<SelectTrigger
id="event-profile"
aria-label="Profil d’événement"
className="min-w-0 flex-1 sm:w-[210px]"
>
<span
className={`min-w-0 flex-1 truncate text-left ${
selectedProfileId === 'manual'
? 'text-muted-foreground'
: ''
}`}
>
{selectedProfileId === 'manual'
? 'Sélectionner'
: eventProfiles.find(
(profile) => String(profile.id) === selectedProfileId
)?.name || 'Sélectionner'}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Aucun profil</SelectItem>
{eventProfiles.map((profile) => (
<SelectItem key={profile.id} value={String(profile.id)}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
disabled={
loadingProfiles ||
savingProfile ||
selectedProfileId === 'manual'
}
onClick={handleLoadProfile}
>
Charger
</Button>
</div>
)}
</DialogHeader>
{requestError && (
<Alert variant="destructive"><AlertDescription>{requestError}</AlertDescription></Alert>
@ -1172,28 +1331,6 @@ const CalendarPage = () => {
autoFocus
/>
</div>
{/* DESCRIPTION */}
<div className="grid gap-2">
<Label htmlFor="event-description">
Description
</Label>
<Textarea
id="event-description"
value={
eventForm.description
}
onChange={(
event,
) =>
handleFormChange(
'description',
event.target.value,
)
}
placeholder="Description de l'événement..."
className="min-h-[90px] resize-none"
/>
</div>
{/* DATES */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-2">
@ -1303,25 +1440,7 @@ const CalendarPage = () => {
<div className="grid shrink-0 gap-2">
<Label htmlFor="event-is-public">Visibilité</Label>
<div className="flex h-10 items-center gap-2">
<button
id="event-is-public"
type="button"
role="switch"
aria-label="Événement public"
aria-checked={eventForm.is_public}
aria-describedby="event-visibility-help"
disabled={savingEvent}
onClick={() => setEventForm((current) => ({
...current,
is_public: !current.is_public,
}))}
className={`inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 ${eventForm.is_public ? 'bg-primary' : 'bg-input'}`}
>
<span
aria-hidden="true"
className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${eventForm.is_public ? 'translate-x-5' : 'translate-x-0'}`}
/>
</button>
<Switch id="event-is-public" aria-label="Événement public" aria-describedby="event-visibility-help" checked={eventForm.is_public} disabled={savingEvent} onCheckedChange={(checked) => setEventForm((current) => ({ ...current, is_public: checked }))} />
<Label htmlFor="event-is-public" className="w-10 cursor-pointer">
{eventForm.is_public ? 'Public' : 'Privé'}
</Label>
@ -1335,7 +1454,32 @@ const CalendarPage = () => {
: 'Visible uniquement par toi dans ton calendrier.'}
</p>
</div>
<DialogFooter>
<div className="flex flex-wrap items-center justify-end gap-2">
{!editingEvent && canCreate && (
<div className="flex items-center gap-2 sm:mr-auto">
<Button
type="button"
variant="secondary"
disabled={savingProfile || !eventForm.name.trim()}
onClick={handleSaveProfile}
>
{savingProfile ? 'Enregistrement...' : 'Sauvegarder le profil'}
</Button>
{selectedProfileId !== 'manual' && (
<Button
type="button"
size="icon"
variant="outline"
disabled={savingProfile}
onClick={handleDeleteProfile}
aria-label="Supprimer le profil sélectionné"
title="Supprimer le profil sélectionné"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
)}
<Button
type="button"
variant="outline"
@ -1371,7 +1515,7 @@ const CalendarPage = () => {
? 'Enregistrer'
: 'Créer'}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
{/* ======================================================
@ -1429,6 +1573,14 @@ const CalendarPage = () => {
}
>
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-2">
{!isAdmin && (
<p className="text-xs text-muted-foreground">
{canCreate ? 'Tes événements personnels sont visibles uniquement par toi.' : 'Pour ajouter un événement personnel, demande l’accès à un responsable.'}
</p>
)}
{canCreate && <Button className="ml-auto" size="sm" onClick={() => { const now = new Date(); const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; openCreateDialog(day); }}><Plus className="size-4" />Ajouter un événement</Button>}
</div>
{requestError && (
<Alert variant="destructive" className="mb-3">
<AlertDescription>{requestError}</AlertDescription>

View File

@ -171,9 +171,20 @@ const HomePage = () => {
const { user } = useAuth();
const displayName = user?.first_name?.trim() || user?.username;
const visibleSections = sections.filter(
(section) => !section.adminOnly || user?.is_admin,
);
const canViewStatistics = user?.is_admin || user?.management_permissions?.view_statistics?.enabled;
const canManageTeam = user?.is_admin || user?.management_permissions?.manage_schedules?.enabled || user?.management_permissions?.approve_requests?.enabled;
const visibleSections = sections
.map((section) => {
if (section.id !== 'administration') return section;
const items = section.items.filter((item) => (
item.path === '/users' ? user?.is_admin
: item.path === '/users/statistics' ? canViewStatistics
: item.path === '/users/manage' ? canManageTeam
: false
));
return { ...section, items };
})
.filter((section) => !section.adminOnly || section.items.length > 0);
return (
<MainCard title="Accueil" icon={House}>

View File

@ -1,187 +1,93 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import LoadingCard from '@/components/LoadingCard';
import { useEffect, useMemo, useState } from 'react';
import { CalendarClock, ClipboardCheck, ShieldCheck, Users } from 'lucide-react';
import MainCard from '@/components/MainCard';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Users} from 'lucide-react';
// Composant pour la gestion des utilisateurs
import { Button } from '@/components/ui/button';
import ScheduleManager from '@/components/planning/ScheduleManager';
import PermissionManager from '@/components/PermissionManager';
import RequestsList from '@/components/planning/RequestsList';
import { inputClass, personLabel } from '@/components/planning/utils';
import { planningApi } from '@/api/planning';
import { useAuth } from '@/context/useAuth';
const allowedPeople = (people, user, permission) => {
if (user?.is_admin) return people;
const rule = user?.management_permissions?.[permission];
if (!rule?.enabled) return [];
if (rule.all_users) return people;
const ids = new Set((rule.user_ids || []).map(String));
return people.filter((person) => ids.has(String(person.id)));
};
export default function ManageUsersPage() {
const [users, setUsers] = useState([]);
const { user } = useAuth();
const [tab, setTab] = useState('schedules');
const [people, setPeople] = useState([]);
const [selectedUserId, setSelectedUserId] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState(null);
const [reload, setReload] = useState(0);
const schedulePeople = useMemo(() => allowedPeople(people, user, 'manage_schedules'), [people, user]);
const requestPeople = useMemo(() => allowedPeople(people, user, 'approve_requests'), [people, user]);
const tabs = useMemo(() => [
...(user?.is_admin || schedulePeople.length ? ['schedules'] : []),
...(user?.is_admin || requestPeople.length ? ['requests'] : []),
...(user?.is_admin ? ['permissions'] : []),
], [user, schedulePeople.length, requestPeople.length]);
// Récupération des utilisateurs
useEffect(() => {
const fetchUsers = async () => {
try {
// Ici, vous devrez implémenter l'appel API réel
// Pour l'exemple, on utilise des données statiques
const mockUsers = [
{ id: 1, name: 'Jean Dupont', email: 'jean.dupont@example.com', role: 'user', status: 'active' },
{ id: 2, name: 'Marie Martin', email: 'marie.martin@example.com', role: 'admin', status: 'active' },
{ id: 3, name: 'Pierre Durand', email: 'pierre.durand@example.com', role: 'user', status: 'inactive' },
];
setUsers(mockUsers);
setLoading(false);
} catch (err) {
setError('Erreur lors de la récupération des utilisateurs');
setLoading(false);
}
};
const controller = new AbortController();
setLoading(true);
setError('');
planningApi.people(controller.signal)
.then((data) => setPeople(Array.isArray(data) ? data : []))
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [reload]);
fetchUsers();
}, []);
useEffect(() => {
if (tabs.length && !tabs.includes(tab)) setTab(tabs[0]);
}, [tabs, tab]);
const handleDeleteUser = (user) => {
setUserToDelete(user);
setDeleteDialogOpen(true);
};
const confirmDeleteUser = async () => {
if (!userToDelete) return;
try {
// Ici, vous devrez implémenter l'appel API pour supprimer l'utilisateur
// Pour l'exemple, on simule la suppression
setUsers(users.filter(u => u.id !== userToDelete.id));
setDeleteDialogOpen(false);
setUserToDelete(null);
} catch (err) {
setError('Erreur lors de la suppression de l\'utilisateur');
}
};
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<LoadingCard
icon={Users}
title="Gestion des Utilisateurs"
message="Chargement du module de gestion des utilisateurs..."
/>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<p className="text-red-500">{error}</p>
</div>
</div>
);
}
useEffect(() => {
const available = tab === 'schedules' ? schedulePeople : requestPeople;
setSelectedUserId((current) => (
available.some((person) => String(person.id) === String(current))
? String(current)
: String(available[0]?.id || '')
));
}, [tab, schedulePeople, requestPeople]);
return (
<MainCard title="Gestion des utilisateurs" icon={Users}>
<Card>
<CardHeader>
<CardTitle>Recherche d'utilisateurs</CardTitle>
<CardDescription>
Recherchez des utilisateurs par nom ou email
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Rechercher par nom ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="max-w-md"
/>
<MainCard title="Gestion de l’équipe" icon={Users}>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
<div className="mx-auto max-w-6xl space-y-5">
<div className="flex flex-wrap gap-2" aria-label="Sections de gestion">
{tabs.includes('schedules') && <Button variant={tab === 'schedules' ? 'default' : 'outline'} aria-pressed={tab === 'schedules'} onClick={() => setTab('schedules')}><CalendarClock className="size-4" />Emplois du temps</Button>}
{tabs.includes('requests') && <Button variant={tab === 'requests' ? 'default' : 'outline'} aria-pressed={tab === 'requests'} onClick={() => setTab('requests')}><ClipboardCheck className="size-4" />Demandes</Button>}
{tabs.includes('permissions') && <Button variant={tab === 'permissions' ? 'default' : 'outline'} aria-pressed={tab === 'permissions'} onClick={() => setTab('permissions')}><ShieldCheck className="size-4" />Autorisations</Button>}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Liste des utilisateurs</CardTitle>
<CardDescription>
Tous les utilisateurs inscrits dans le système
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
{user.role}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === 'active' ? 'success' : 'destructive'}>
{user.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm">Modifier</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteUser(user)}
>
Supprimer
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{error && <div role="alert" className="space-y-2 text-sm text-destructive"><p>{error}</p><Button variant="outline" onClick={() => setReload((n) => n + 1)}>Réessayer</Button></div>}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action supprimera définitivement l'utilisateur {userToDelete?.name} du système.
Cette action ne peut pas être annulée.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteUser}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{loading ? <p role="status" className="text-sm text-muted-foreground">Chargement des salariés…</p> : !error && (
tab === 'permissions' ? <PermissionManager /> : tab === 'requests' ? (
<RequestsList user={user} admin people={requestPeople} />
) : tab === 'schedules' ? (
<>
<label className="grid max-w-md gap-1.5 text-sm font-medium">
Salarié
<select aria-label="Salarié" className={inputClass} value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
{schedulePeople.map((person) => <option key={person.id} value={person.id}>{personLabel(person)}</option>)}
</select>
</label>
{selectedUserId ? <ScheduleManager key={selectedUserId} userId={selectedUserId} /> : <p className="text-sm text-muted-foreground">Aucun salarié n’est associé à cette autorisation.</p>}
</>
) : <p className="text-sm text-muted-foreground">Aucune responsabilité de gestion ne t’a été attribuée.</p>
)}
</div>
</div>
</MainCard>
);
}
}

View File

@ -1,13 +1,15 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard';
import MyRequestsCard from '@/components/planning/MyRequestsCard';
import PersonalStatistics from '@/components/statistics/PersonalStatistics';
import MonthlyHoursRace from '@/components/statistics/MonthlyHoursRace';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
@ -244,6 +246,12 @@ const ProfileContent = ({ user }) => {
</div>
</header>
<MyRequestsCard user={user} />
<PersonalStatistics />
<MonthlyHoursRace />
<div className="grid items-start gap-5 lg:grid-cols-2">
<div className="min-w-0 space-y-5">
<section aria-labelledby="profile-details-title" className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
@ -384,13 +392,13 @@ const ProfileContent = ({ user }) => {
{passwordError && <p role="alert" className="text-sm text-destructive">{passwordError}</p>}
<DialogFooter>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handlePasswordDialogChange(false)} disabled={savingPassword}>Annuler</Button>
<Button type="submit" disabled={savingPassword}>
{savingPassword && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
{savingPassword ? 'Modification…' : 'Enregistrer'}
</Button>
</DialogFooter>
</div>
</form>
</DialogContent>
</Dialog>

View File

@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useAuth } from '@/context/useAuth';
import { can } from '@/lib/permissions';
import {
Check,
BarChart3,
ChevronDown,
ClipboardList,
Globe2,
@ -16,6 +19,9 @@ import {
Users,
} from 'lucide-react';
import { notesApi } from '../api/notes';
import PollCard from '@/components/notes/PollCard';
import PollEditor from '@/components/notes/PollEditor';
import { emptyPoll, pollForm } from '@/components/notes/pollUtils';
import MainCard from '@/components/MainCard';
import LoadingCard from '@/components/LoadingCard';
import ConfirmDialog from '@/components/ConfirmDialog';
@ -34,7 +40,6 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
@ -52,6 +57,7 @@ import {
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
const kinds = {
poll: { label: 'Sondage', icon: BarChart3, color: 'text-cyan-700 bg-cyan-50 dark:text-cyan-300 dark:bg-cyan-950/40' },
task: {
label: 'Tâche',
icon: ClipboardList,
@ -81,6 +87,7 @@ const emptyForm = () => ({
title: '',
content: '',
kind: 'memo',
poll_settings: emptyPoll(),
recipient_ids: [],
is_public: true,
});
@ -98,6 +105,8 @@ const dateLabel = (value) =>
},
);
export default function NotesPage() {
const { user } = useAuth();
const canCreate = can(user, 'create_publication');
const [result, setResult] = useState({
count: 0,
results: [],
@ -277,6 +286,7 @@ export default function NotesPage() {
const openEditor = (
note = null,
) => {
if (!note && !canCreate) return;
setEditing(note);
setForm(
note
@ -284,6 +294,7 @@ export default function NotesPage() {
title: note.title,
content: note.content,
kind: note.kind,
poll_settings: pollForm(note.poll),
recipient_ids:
note.recipients?.map(
(person) =>
@ -368,13 +379,15 @@ export default function NotesPage() {
*/
const save = async (event) => {
event.preventDefault();
if (!editing && !canCreate) { setFormError('La création de publications n’est pas activée pour ton compte.'); return; }
if (
mutationInFlight.current
) {
return;
}
if (
!form.is_public
form.kind !== 'poll'
&& !form.is_public
&& form.recipient_ids.length === 0
) {
setFormError(
@ -392,6 +405,13 @@ export default function NotesPage() {
content:
form.content.trim(),
};
if (form.kind === 'poll') {
payload.is_public = true;
payload.recipient_ids = [];
payload.poll_settings = { ...form.poll_settings, options: form.poll_settings.options.map((option) => option.trim()) };
} else {
delete payload.poll_settings;
}
try {
if (editing) {
await notesApi.update(
@ -483,9 +503,10 @@ export default function NotesPage() {
}
return (
<MainCard
className="max-md:[&>[data-slot=card-header]]:flex-wrap"
title="Espace d'échanges"
icon={ClipboardList}
headerContent={
headerContent={canCreate &&
<Button
size="sm"
onClick={() =>
@ -500,6 +521,7 @@ export default function NotesPage() {
}
>
<CardContent className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
{!canCreate && <p className="text-sm text-muted-foreground">Tu peux consulter les publications et répondre aux sondages. Pour publier, demande l’accès à un responsable.</p>}
{error && (
<Alert variant="destructive">
<AlertDescription>
@ -748,6 +770,7 @@ export default function NotesPage() {
{note.content}
</p>
)}
{note.kind === 'poll' && <PollCard note={note} onChange={(updated) => setResult((current) => ({ ...current, results: current.results.map((item) => item.id === updated.id ? updated : item) }))} />}
{/* Destinataires */}
<div className="flex items-start gap-2 text-sm">
<Users className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
@ -939,9 +962,9 @@ export default function NotesPage() {
: 'Nouvelle publication'}
</DialogTitle>
<DialogDescription>
Choisissez les personnes concernées, puis la visibilité.
Une publication publique reste visible par toute l’équipe,
même lorsqu’elle est destinée à certaines personnes.
{form.kind === 'poll'
? 'Le sondage est automatiquement public et accessible à toute l’équipe.'
: 'Choisis à qui tu t’adresses et qui peut voir la publication. Une publication publique reste visible par toute l’équipe, même lorsqu’elle est destinée à certaines personnes.'}
</DialogDescription>
</DialogHeader>
{formError && (
@ -951,7 +974,7 @@ export default function NotesPage() {
</AlertDescription>
</Alert>
)}
{recipientsError && (
{form.kind !== 'poll' && recipientsError && (
<Alert variant="destructive">
<AlertDescription>
Annuaire indisponible :{' '}
@ -968,14 +991,16 @@ export default function NotesPage() {
id="note-kind"
className="w-full"
value={form.kind}
disabled={saving}
onChange={(event) =>
disabled={saving || Boolean(editing?.poll?.is_locked)}
onChange={(event) => {
const kind = event.target.value;
setForm({
...form,
kind:
event.target.value,
})
}
kind,
...(kind === 'poll' ? { is_public: true, recipient_ids: [] } : {}),
});
if (kind === 'poll') setRecipientPopoverOpen(false);
}}
>
{Object.entries(
kinds,
@ -992,7 +1017,7 @@ export default function NotesPage() {
</NativeSelect>
</div>
{/* Destinataires */}
<div className="grid gap-1.5">
<div className={form.kind === 'poll' ? 'hidden' : 'grid gap-1.5'}>
<Label>
Destinataires
</Label>
@ -1004,11 +1029,11 @@ export default function NotesPage() {
setRecipientPopoverOpen
}
>
<PopoverTrigger asChild>
<PopoverTrigger render={
<Button
type="button"
variant="outline"
disabled={saving}
disabled={saving || Boolean(editing?.poll?.is_locked)}
className="w-full justify-between font-normal"
>
<span className="truncate">
@ -1016,7 +1041,7 @@ export default function NotesPage() {
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
} />
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
@ -1046,7 +1071,7 @@ export default function NotesPage() {
checked={
checked
}
disabled={saving}
disabled={saving || Boolean(editing?.poll?.is_locked)}
onCheckedChange={() => toggleRecipient(person.id)}
/>
<div className="min-w-0 flex-1">
@ -1080,7 +1105,7 @@ export default function NotesPage() {
</p>
</div>
{/* Public / privé */}
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className={form.kind === 'poll' ? 'hidden' : 'flex items-center justify-between gap-4 rounded-lg border p-3'}>
<div className="space-y-1">
<Label
htmlFor="note-public"
@ -1108,7 +1133,7 @@ export default function NotesPage() {
checked={
form.is_public
}
disabled={saving}
disabled={saving || Boolean(editing?.poll?.is_locked)}
onCheckedChange={
changeVisibility
}
@ -1124,7 +1149,7 @@ export default function NotesPage() {
required
maxLength={160}
value={form.title}
disabled={saving}
disabled={saving || Boolean(editing?.poll?.is_locked)}
onChange={(event) =>
setForm({
...form,
@ -1159,7 +1184,8 @@ export default function NotesPage() {
placeholder="Précisions, consignes ou informations utiles…"
/>
</div>
<DialogFooter>
{form.kind === 'poll' && <PollEditor value={form.poll_settings} onChange={(poll_settings) => setForm((current) => ({ ...current, poll_settings }))} disabled={saving} locked={Boolean(editing?.poll?.is_locked)} />}
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
@ -1176,7 +1202,8 @@ export default function NotesPage() {
saving
|| !form.title.trim()
|| (
!form.is_public
form.kind !== 'poll'
&& !form.is_public
&& form.recipient_ids
.length === 0
)
@ -1188,7 +1215,7 @@ export default function NotesPage() {
? 'Enregistrer'
: 'Publier'}
</Button>
</DialogFooter>
</div>
</form>
</DialogContent>
</Dialog>

View File

@ -1216,7 +1216,9 @@ const TimeClockPage = () => {
);
setConflictDate(
scanError.scanDate || getTodayValue(),
scanError.conflict === 'open_entry'
? (scanError.entryDate || scanError.scanDate || getTodayValue())
: (scanError.scanDate || getTodayValue()),
);
setScanConflict({
@ -1232,6 +1234,7 @@ const TimeClockPage = () => {
scanTime:
scanError.scanTime,
scanDate: scanError.scanDate,
entryDate: scanError.entryDate,
entryId: scanError.entryId,
step: 'confirm',
});
@ -1371,7 +1374,8 @@ const TimeClockPage = () => {
resolution:
config.resolution,
time: conflictTime,
scan_date: conflictDate,
scan_date: scanConflict.scanDate,
correction_date: conflictDate,
entry_id: scanConflict.entryId,
};

View File

@ -0,0 +1,178 @@
import { useEffect, useMemo, useState } from 'react';
import { BarChart3, CalendarDays, CarFront, Clock3, RefreshCw, Sun } from 'lucide-react';
import MainCard from '@/components/MainCard';
import LoadingCard from '@/components/LoadingCard';
import { DatePicker } from '@/components/ResponsivePickers';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { timeApi } from '@/api/time';
import { planningApi } from '@/api/planning';
import { useAuth } from '@/context/useAuth';
import { dateKey, formatMinutes, personLabel, inputClass } from '@/components/planning/utils';
const PERIODS = [
{ key: 'day', label: 'Jour' },
{ key: 'month', label: 'Mois' },
{ key: 'year', label: 'Année' },
];
const StatCard = ({ icon: Icon, label, value, detail }) => (
<div className="rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
{detail && <p className="mt-1 text-xs text-muted-foreground">{detail}</p>}
</div>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-4" />
</span>
</div>
</div>
);
const rangeLabel = (stats) => {
if (!stats) return '';
const start = new Date(`${stats.start}T12:00:00`);
const end = new Date(`${stats.end}T12:00:00`);
if (stats.period === 'day') {
return start.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
if (stats.period === 'month') {
return start.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
}
return String(start.getFullYear());
};
export default function UserStatisticsPage() {
const { user } = useAuth();
const [people, setPeople] = useState([]);
const [selectedUserId, setSelectedUserId] = useState('');
const [period, setPeriod] = useState('month');
const [anchor, setAnchor] = useState(dateKey());
const [stats, setStats] = useState(null);
const [loadingPeople, setLoadingPeople] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [reload, setReload] = useState(0);
const statsPermission = user?.management_permissions?.view_statistics;
const allowedIds = useMemo(() => {
if (user?.is_admin || statsPermission?.all_users) return null;
return new Set((statsPermission?.user_ids || []).map(String));
}, [user, statsPermission]);
useEffect(() => {
const controller = new AbortController();
setLoadingPeople(true);
setError('');
planningApi.people(controller.signal)
.then((rows) => {
const visible = (Array.isArray(rows) ? rows : []).filter((person) => (
user?.is_admin || allowedIds === null || allowedIds.has(String(person.id))
));
setPeople(visible);
setSelectedUserId((current) => (
visible.some((person) => String(person.id) === String(current))
? String(current)
: String(visible[0]?.id || '')
));
})
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
.finally(() => { if (!controller.signal.aborted) setLoadingPeople(false); });
return () => controller.abort();
}, [user, allowedIds]);
useEffect(() => {
if (!selectedUserId) {
setStats(null);
return undefined;
}
const controller = new AbortController();
setLoading(true);
setError('');
timeApi.getStats({ user_id: selectedUserId, period, anchor }, controller.signal)
.then((data) => { if (!controller.signal.aborted) setStats(data); })
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [selectedUserId, period, anchor, reload]);
if (loadingPeople) {
return <LoadingCard icon={BarChart3} title="Statistiques" message="Chargement des salariés…" />;
}
const monthValue = anchor.slice(0, 7);
const yearValue = anchor.slice(0, 4);
return (
<MainCard title="Statistiques" icon={BarChart3}>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
<div className="mx-auto max-w-6xl space-y-5">
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 lg:flex-row lg:items-end">
<label className="grid min-w-0 flex-1 gap-1.5 text-sm font-medium">
Salarié
<select className={inputClass} value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
{people.length ? people.map((person) => (
<option key={person.id} value={person.id}>{personLabel(person)}</option>
)) : <option value="">Aucun salarié autorisé</option>}
</select>
</label>
<div className="grid gap-1.5 text-sm font-medium">
Période
<div className="flex flex-wrap gap-2">
{PERIODS.map((item) => (
<Button key={item.key} type="button" size="sm" variant={period === item.key ? 'default' : 'outline'} onClick={() => setPeriod(item.key)}>
{item.label}
</Button>
))}
</div>
</div>
<label className="grid gap-1.5 text-sm font-medium lg:w-52">
{period === 'day' ? 'Date' : period === 'month' ? 'Mois' : 'Année'}
{period === 'day' && (
<DatePicker value={anchor} onChange={setAnchor} />
)}
{period === 'month' && (
<DatePicker mode="month" value={monthValue} onChange={(value) => setAnchor(`${value}-01`)} />
)}
{period === 'year' && (
<DatePicker mode="year" min="2020" max="2100" value={yearValue} onChange={(value) => setAnchor(`${value}-01-01`)} />
)}
</label>
<Button type="button" variant="outline" size="icon" disabled={loading} aria-label="Actualiser" onClick={() => setReload((value) => value + 1)}>
<RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{error && <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
{stats && (
<>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold capitalize">{rangeLabel(stats)}</h2>
<p className="text-sm text-muted-foreground">{personLabel(stats.user)}</p>
</div>
{stats.incomplete_entries > 0 && (
<Badge variant="outline">{stats.incomplete_entries} pointage(s) incomplet(s) non compté(s)</Badge>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Clock3} label="Travaillées / prévues" value={`${formatMinutes(stats.total_minutes)} / ${formatMinutes(stats.planned_minutes || 0)}`} />
<StatCard icon={CarFront} label="Aller-retours" value={stats.round_trips} />
<StatCard icon={Sun} label="Dimanches travaillés" value={stats.sundays_worked} />
<StatCard icon={CalendarDays} label="Heures le dimanche" value={formatMinutes(stats.sunday_minutes)} />
</div>
</>
)}
</div>
</div>
</MainCard>
);
}

View File

@ -5,6 +5,9 @@ import React, {
useState,
} from 'react';
import { timeApi } from '../api/time';
import { planningApi } from '../api/planning';
import PlannedDay from '@/components/planning/PlannedDay';
import { dateKey, formatMinutes as formatPlannedMinutes } from '@/components/planning/utils';
import { useAuth } from '../context/useAuth';
import LoadingCard from '@/components/LoadingCard';
import MainCard from '@/components/MainCard';
@ -32,7 +35,8 @@ const expandOvernightAttendances = (entries) => {
if (entry.start_time && !entry.end_time) {
return {
...entry,
end_time: '24:00:00',
end_time: String(entry.start_date).slice(0, 10) === dateKey() ? `${String(new Date().getHours()).padStart(2, '0')}:${String(new Date().getMinutes()).padStart(2, '0')}:00` : '24:00:00',
display_incomplete: true,
};
}
@ -52,6 +56,9 @@ const WeeklySummaryPage = ({ userId }) => {
const [attendances, setAttendances] = useState([]);
const [users, setUsers] = useState([]);
const [planned, setPlanned] = useState({ days: [], segments: [], key: '' });
const [planningError, setPlanningError] = useState('');
const [planningLoading, setPlanningLoading] = useState(false);
const [selectedUserId, setSelectedUserId] = useState('');
const [showAllUsers, setShowAllUsers] = useState(false);
@ -397,6 +404,34 @@ const WeeklySummaryPage = ({ userId }) => {
loadAttendances();
}, [isAdmin, userId]);
const plannedUserId = isAdmin ? selectedUserId : getCurrentUserId();
const weekStart = new Date(currentWeek);
weekStart.setDate(weekStart.getDate() - (weekStart.getDay() + 6) % 7);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
const plannedStart = dateKey(weekStart);
const plannedEnd = dateKey(weekEnd);
const plannedKey = `${plannedUserId}-${plannedStart}`;
const individualView = !(isAdmin && showAllUsers);
const plannedDays = individualView && planned.key === plannedKey ? planned.days : [];
const plannedSegments = individualView && planned.key === plannedKey ? planned.segments : [];
const plannedMode = plannedDays.some((day) => day.schedule_mode === 'weekly_hours') ? 'weekly_hours' : plannedDays.some((day) => day.schedule_mode === 'daily_hours') ? 'daily_hours' : plannedDays.some((day) => day.schedule_mode === 'precise') ? 'precise' : 'none';
const plannedWeeklyTarget = Number(plannedDays.find((day) => day.schedule_mode === 'weekly_hours')?.week_target_minutes || 0);
useEffect(() => {
const controller = new AbortController();
setPlanningError('');
if (!individualView || !plannedUserId) { setPlanningLoading(false); return () => controller.abort(); }
setPlanningLoading(true);
const load = () => planningApi.effective(plannedUserId, plannedStart, plannedEnd, controller.signal)
.then((data) => { if (!controller.signal.aborted) setPlanned({ ...data, key: plannedKey }); })
.catch((error) => { if (!controller.signal.aborted) { setPlanned({ days: [], segments: [], key: plannedKey }); setPlanningError(error.message); } })
.finally(() => { if (!controller.signal.aborted) setPlanningLoading(false); });
load();
window.addEventListener('focus', load);
return () => { controller.abort(); window.removeEventListener('focus', load); };
}, [plannedUserId, plannedStart, plannedEnd, plannedKey, individualView]);
/*
* ------------------------------------------------------------
* Responsive
@ -530,6 +565,8 @@ const WeeklySummaryPage = ({ userId }) => {
currentWeek,
selectedDayIndex,
attendances,
planningLoading,
planningError,
]);
/*
@ -630,7 +667,7 @@ const WeeklySummaryPage = ({ userId }) => {
minutes,
) => {
if (minutes === 1440) {
return '23h59';
return '24h00';
}
return `${String(
@ -771,7 +808,7 @@ const WeeklySummaryPage = ({ userId }) => {
);
const times =
visibleAttendances
[...visibleAttendances, ...plannedSegments.filter((segment) => visibleDateKeys.has(segment.date))]
.flatMap(
(attendance) => [
getMinutes(
@ -1152,14 +1189,99 @@ const WeeklySummaryPage = ({ userId }) => {
color: user.color,
}));
const weekDateKeys = new Set(weekDays.map(formatDateKey));
const individualWeekAttendances = getFilteredAttendances(
expandOvernightAttendances(attendances),
).filter((entry) => weekDateKeys.has(String(entry.start_date).slice(0, 10)));
const attendanceMinutes = (entry) => {
const duration = Number(entry.duration);
if (Number.isFinite(duration) && duration >= 0) {
return Math.round(duration * 60);
}
const start = getMinutes(getTimeValue(entry, 'start_time'));
const end = getMinutes(getTimeValue(entry, 'end_time'));
return start !== null && end !== null && end >= start ? end - start : 0;
};
const pointedWeekMinutes = individualWeekAttendances.reduce(
(sum, entry) => sum + attendanceMinutes(entry),
0,
);
const precisePlannedMinutes = plannedSegments.reduce((sum, segment) => {
const start = getMinutes(getTimeValue(segment, 'start_time'));
const end = getMinutes(getTimeValue(segment, 'end_time'));
return sum + (start !== null && end !== null && end >= start ? end - start : 0);
}, 0);
const dailyPlannedMinutes = plannedDays.reduce(
(sum, day) => sum + Number(day.target_minutes || 0),
0,
);
const plannedWeekMinutes = plannedMode === 'weekly_hours'
? plannedWeeklyTarget
: plannedMode === 'daily_hours'
? dailyPlannedMinutes
: plannedMode === 'precise'
? precisePlannedMinutes
: 0;
const hasWeekPlanned = plannedWeekMinutes > 0 || plannedSegments.length > 0;
const hasWeekPointed = individualWeekAttendances.length > 0;
// Sur mobile, tout le récapitulatif doit correspondre uniquement au jour affiché.
// Un objectif purement hebdomadaire n'a volontairement pas de valeur journalière
// inventée : on affiche alors « — » pour les heures prévues.
const selectedMobileDate = weekDays[selectedDayIndex];
const selectedMobileDateKey = selectedMobileDate
? formatDateKey(selectedMobileDate)
: '';
const selectedMobileDay = plannedDays.find(
(day) => day.date === selectedMobileDateKey,
);
const selectedMobileSegments = plannedSegments.filter(
(segment) => segment.date === selectedMobileDateKey,
);
const individualDayAttendances = selectedMobileDate
? getSelectedUserAttendances(getAttendancesForDay(selectedMobileDate))
: [];
const pointedDayMinutes = individualDayAttendances.reduce(
(sum, entry) => sum + attendanceMinutes(entry),
0,
);
const precisePlannedDayMinutes = selectedMobileSegments.reduce((sum, segment) => {
const start = getMinutes(getTimeValue(segment, 'start_time'));
const end = getMinutes(getTimeValue(segment, 'end_time'));
return sum + (start !== null && end !== null && end >= start ? end - start : 0);
}, 0);
const plannedDayMinutes = selectedMobileDay?.schedule_mode === 'daily_hours'
? Number(selectedMobileDay.target_minutes || 0)
: selectedMobileDay?.schedule_mode === 'precise'
? precisePlannedDayMinutes
: null;
const hasDayPlanned = plannedDayMinutes !== null && (
plannedDayMinutes > 0 || selectedMobileSegments.length > 0
);
const hasDayPointed = individualDayAttendances.length > 0;
const hasDayIncomplete = individualDayAttendances.some(
(entry) => entry.display_incomplete || !entry.end_time,
);
const summaryHasPlanned = isMobile ? hasDayPlanned : hasWeekPlanned;
const summaryHasPointed = isMobile ? hasDayPointed : hasWeekPointed;
const summaryHasIncomplete = isMobile
? hasDayIncomplete
: individualWeekAttendances.some(
(entry) => entry.display_incomplete || !entry.end_time,
);
const summaryPlannedMinutes = isMobile ? plannedDayMinutes : plannedWeekMinutes;
const summaryPointedMinutes = isMobile ? pointedDayMinutes : pointedWeekMinutes;
const summaryPlannedMode = isMobile
? selectedMobileDay?.schedule_mode || 'none'
: plannedMode;
return (
<MainCard
icon={CalendarDays}
className={
isMobile
? 'overflow-hidden'
: 'overflow-hidden'
}
className="overflow-hidden max-md:[&>[data-slot=card-header]]:flex-wrap"
title={
isAdmin ? (
<>
@ -1225,6 +1347,7 @@ const WeeklySummaryPage = ({ userId }) => {
variant="outline"
size="sm"
onClick={() => navigateDay(-1)}
aria-label="Jour précédent"
className="shrink-0"
>
<ChevronLeft className="h-4 w-4" />
@ -1232,7 +1355,7 @@ const WeeklySummaryPage = ({ userId }) => {
<Badge
variant="secondary"
className="h-7 w-48 shrink-0 justify-center whitespace-nowrap text-sm tabular-nums"
className="h-7 w-40 shrink-0 justify-center whitespace-nowrap text-sm tabular-nums"
>
{weekDays[selectedDayIndex].toLocaleDateString(
'fr-FR',
@ -1248,6 +1371,7 @@ const WeeklySummaryPage = ({ userId }) => {
variant="outline"
size="sm"
onClick={() => navigateDay(1)}
aria-label="Jour suivant"
className="shrink-0"
>
<ChevronRight className="h-4 w-4" />
@ -1324,6 +1448,44 @@ const WeeklySummaryPage = ({ userId }) => {
</div>
)}
{individualView && (
<div className="mb-3 flex shrink-0 flex-wrap items-center justify-between gap-x-5 gap-y-2 rounded-lg border bg-muted/30 px-3 py-2 text-xs">
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
<span className="font-medium text-foreground">Légende :</span>
{summaryHasPlanned && (
<span className="flex items-center gap-1.5">
<span className="h-3 w-5 rounded-sm border border-dashed border-emerald-600/70 bg-emerald-500/15" />
{summaryPlannedMode === 'daily_hours' ? 'Heures à faire' : summaryPlannedMode === 'weekly_hours' ? 'Objectif hebdomadaire' : 'Prévu'}
</span>
)}
{summaryHasPointed && (
<span className="flex items-center gap-1.5">
<span className="h-3 w-5 rounded-sm bg-blue-500/80" />
Pointé
</span>
)}
{!summaryHasPlanned && !summaryHasPointed && !planningLoading && (
<span className="text-muted-foreground">
{isMobile ? 'Aucun élément sur cette journée.' : 'Aucun élément sur cette semaine.'}
</span>
)}
{planningLoading && <span role="status" className="text-muted-foreground">Chargement des prévisions…</span>}
{planningError && <span role="alert" className="text-destructive">Prévisions indisponibles : {planningError}</span>}
{summaryHasIncomplete && (
<span className="text-muted-foreground">Un pointage est en cours ou sa sortie manque.</span>
)}
</div>
<div className="ml-auto flex shrink-0 items-center gap-3 border-l pl-3 text-sm tabular-nums max-sm:w-full max-sm:border-l-0 max-sm:border-t max-sm:pt-2 max-sm:pl-0">
<span>
<span className="text-muted-foreground">{isMobile ? 'Prévues' : 'À faire'} :</span>{' '}
<strong>{summaryPlannedMinutes === null ? '—' : formatPlannedMinutes(summaryPlannedMinutes)}</strong>
</span>
<span><span className="text-muted-foreground">Faites :</span> <strong>{formatPlannedMinutes(summaryPointedMinutes)}</strong></span>
</div>
</div>
)}
{/* Légende */}
{isAdmin &&
showAllUsers &&
@ -1467,6 +1629,8 @@ const WeeklySummaryPage = ({ userId }) => {
),
)}
{individualView && <PlannedDay date={formatDateKey(weekDays[selectedDayIndex])} days={plannedDays} segments={plannedSegments} getStyle={(row) => getAttendanceStyle(getMinutes(row.start_time), getMinutes(row.end_time), startHour, endHour, hourHeight)} onHover={showHoverInfo} onMove={moveHoverInfo} onLeave={hideHoverInfo} />}
{/* Tous les utilisateurs */}
{isAdmin &&
showAllUsers ? (
@ -1624,13 +1788,14 @@ const WeeklySummaryPage = ({ userId }) => {
],
)}-${start}-${end}-${index}`
}
className="absolute left-0 right-0 overflow-hidden rounded-sm border border-blue-600/30 bg-blue-500/80"
className="absolute left-[24%] right-0 z-20 overflow-hidden rounded-sm border border-blue-600/30 bg-blue-500/80"
style={
style
}
onMouseEnter={(event) =>
showHoverInfo({
event,
description: attendance.display_incomplete ? 'Pointage en cours ou sortie non renseignée' : 'Heures pointées',
title:
getUserLabel(
attendanceUser,
@ -1693,20 +1858,12 @@ const WeeklySummaryPage = ({ userId }) => {
{/* Jours */}
{weekDays.map(
(
day,
dayIndex,
) => (
(day) => (
<div
key={formatDateKey(
day,
)}
className={`flex items-center justify-center border-b border-r bg-background p-2 text-center font-medium ${
dayIndex ===
selectedDayIndex
? 'bg-muted'
: ''
}`}
className={`flex items-center justify-center border-b border-r bg-background p-2 text-center font-medium ${formatDateKey(day) === dateKey() ? 'bg-muted' : ''}`}
style={{
height: `${SCHEDULE_HEADER_HEIGHT}px`,
}}
@ -1791,6 +1948,8 @@ const WeeklySummaryPage = ({ userId }) => {
),
)}
{individualView && <PlannedDay date={formatDateKey(day)} days={plannedDays} segments={plannedSegments} getStyle={(row) => getAttendanceStyle(getMinutes(row.start_time), getMinutes(row.end_time), startHour, endHour, hourHeight)} onHover={showHoverInfo} onMove={moveHoverInfo} onLeave={hideHoverInfo} />}
{/* Tous les utilisateurs */}
{isAdmin &&
showAllUsers ? (
@ -1944,13 +2103,14 @@ const WeeklySummaryPage = ({ userId }) => {
day,
)}-${start}-${end}-${index}`
}
className="absolute left-2 right-2 overflow-hidden rounded-md border border-blue-600/30 bg-blue-500/80"
className="absolute left-[24%] right-1 z-20 overflow-hidden rounded-md border border-blue-600/30 bg-blue-500/80"
style={
style
}
onMouseEnter={(event) =>
showHoverInfo({
event,
description: attendance.display_incomplete ? 'Pointage en cours ou sortie non renseignée' : 'Heures pointées',
title:
getUserLabel(
attendanceUser,