big maj
This commit is contained in:
parent
12d3a95811
commit
4db822aebe
@ -47,6 +47,7 @@ INSTALLED_APPS = [
|
|||||||
# Local apps
|
# Local apps
|
||||||
'users',
|
'users',
|
||||||
'time_tracking',
|
'time_tracking',
|
||||||
|
'planning',
|
||||||
'events',
|
'events',
|
||||||
'notes',
|
'notes',
|
||||||
'file', # Ajout du module de gestion des fichiers
|
'file', # Ajout du module de gestion des fichiers
|
||||||
|
|||||||
@ -26,6 +26,7 @@ urlpatterns = [
|
|||||||
path('api/auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
path('api/auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
path('api/users/', include('users.urls')),
|
path('api/users/', include('users.urls')),
|
||||||
|
path('api/planning/', include('planning.urls')),
|
||||||
path('api/time/', include('time_tracking.urls')),
|
path('api/time/', include('time_tracking.urls')),
|
||||||
path('api/event/', include('events.urls')),
|
path('api/event/', include('events.urls')),
|
||||||
path('api/notes/', include('notes.urls')),
|
path('api/notes/', include('notes.urls')),
|
||||||
|
|||||||
32
backend/events/migrations/0005_eventprofile.py
Normal file
32
backend/events/migrations/0005_eventprofile.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
19
backend/events/migrations/0006_remove_event_descriptions.py
Normal file
19
backend/events/migrations/0006_remove_event_descriptions.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -16,10 +16,34 @@ class Event(models.Model):
|
|||||||
end_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)
|
color = models.CharField(max_length=7, null=True, blank=True)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True)
|
|
||||||
is_public = models.BooleanField(default=True)
|
is_public = models.BooleanField(default=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Event
|
|
||||||
|
from users.permissions import is_admin
|
||||||
|
|
||||||
|
from .models import Event, EventProfile
|
||||||
|
|
||||||
|
|
||||||
class EventSerializer(serializers.ModelSerializer):
|
class EventSerializer(serializers.ModelSerializer):
|
||||||
@ -8,7 +11,7 @@ class EventSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'created_by', 'start_date', 'end_date',
|
'id', 'created_by', 'start_date', 'end_date',
|
||||||
'start_time', 'end_time', 'color', 'name',
|
'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']
|
read_only_fields = ['id', 'created_by', 'created_at', 'updated_at']
|
||||||
|
|
||||||
@ -23,12 +26,12 @@ class EventCreateSerializer(serializers.ModelSerializer):
|
|||||||
model = Event
|
model = Event
|
||||||
fields = [
|
fields = [
|
||||||
'start_date', 'end_date', 'start_time', 'end_time',
|
'start_date', 'end_date', 'start_time', 'end_time',
|
||||||
'color', 'name', 'description', 'is_public',
|
'color', 'name', 'is_public',
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
request = self.context.get('request')
|
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.
|
# En création comme en PATCH, les événements non-admin restent privés.
|
||||||
attrs['is_public'] = False
|
attrs['is_public'] = False
|
||||||
|
|
||||||
@ -52,3 +55,29 @@ class EventCreateSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class EventUpdateSerializer(EventCreateSerializer):
|
class EventUpdateSerializer(EventCreateSerializer):
|
||||||
pass
|
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
|
||||||
|
|||||||
@ -3,6 +3,8 @@ from . import views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.EventListView.as_view(), name='event-list'),
|
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('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'),
|
||||||
|
]
|
||||||
|
|||||||
@ -3,8 +3,14 @@ from rest_framework import generics, status
|
|||||||
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS
|
from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from .models import Event
|
from .models import Event, EventProfile
|
||||||
from .serializers import EventSerializer, EventCreateSerializer, EventUpdateSerializer
|
from users.permissions import require_permission
|
||||||
|
from .serializers import (
|
||||||
|
EventSerializer,
|
||||||
|
EventCreateSerializer,
|
||||||
|
EventUpdateSerializer,
|
||||||
|
EventProfileSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IsEventCreatorOrReadOnly(BasePermission):
|
class IsEventCreatorOrReadOnly(BasePermission):
|
||||||
@ -26,6 +32,7 @@ class EventListView(VisibleEventsMixin, generics.ListCreateAPIView):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
require_permission(request.user, 'create_personal_event')
|
||||||
serializer = EventCreateSerializer(
|
serializer = EventCreateSerializer(
|
||||||
data=request.data, context=self.get_serializer_context(),
|
data=request.data, context=self.get_serializer_context(),
|
||||||
)
|
)
|
||||||
@ -66,3 +73,42 @@ class EventDetailView(VisibleEventsMixin, generics.RetrieveUpdateDestroyAPIView)
|
|||||||
class EventCreateView(EventListView):
|
class EventCreateView(EventListView):
|
||||||
http_method_names = ['post', 'options']
|
http_method_names = ['post', 'options']
|
||||||
serializer_class = EventCreateSerializer
|
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)
|
||||||
|
|||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
backend/notes/migrations/0005_normalize_polls_public.py
Normal file
20
backend/notes/migrations/0005_normalize_polls_public.py
Normal 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),
|
||||||
|
]
|
||||||
@ -8,6 +8,7 @@ class Note(models.Model):
|
|||||||
INFO = 'info', 'Information'
|
INFO = 'info', 'Information'
|
||||||
MEMO = 'memo', 'Mémo'
|
MEMO = 'memo', 'Mémo'
|
||||||
SUGGESTION = 'sugg', 'Suggestion'
|
SUGGESTION = 'sugg', 'Suggestion'
|
||||||
|
POLL = 'poll', 'Sondage'
|
||||||
|
|
||||||
title = models.CharField(
|
title = models.CharField(
|
||||||
max_length=160,
|
max_length=160,
|
||||||
@ -77,4 +78,39 @@ class Note(models.Model):
|
|||||||
return self.completed_at is not None
|
return self.completed_at is not None
|
||||||
|
|
||||||
def __str__(self):
|
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')]
|
||||||
|
|||||||
61
backend/notes/poll_views.py
Normal file
61
backend/notes/poll_views.py
Normal 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
111
backend/notes/polls.py
Normal 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'])])
|
||||||
@ -1,7 +1,9 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import Note
|
from .models import Note
|
||||||
|
from .polls import PollSettingsSerializer, poll_representation, validate_poll_note, configure_poll
|
||||||
from .permissions import (
|
from .permissions import (
|
||||||
can_complete_note,
|
can_complete_note,
|
||||||
can_edit_note,
|
can_edit_note,
|
||||||
@ -29,6 +31,12 @@ class NoteUserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class NoteSerializer(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(
|
author = NoteUserSerializer(
|
||||||
read_only=True,
|
read_only=True,
|
||||||
)
|
)
|
||||||
@ -71,6 +79,8 @@ class NoteSerializer(serializers.ModelSerializer):
|
|||||||
'updated_at',
|
'updated_at',
|
||||||
'can_edit',
|
'can_edit',
|
||||||
'can_complete',
|
'can_complete',
|
||||||
|
'poll',
|
||||||
|
'poll_settings',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@ -93,6 +103,11 @@ class NoteSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate(self, attrs):
|
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 = attrs.get(
|
||||||
'is_public',
|
'is_public',
|
||||||
self.instance.is_public
|
self.instance.is_public
|
||||||
@ -120,26 +135,25 @@ class NoteSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def update(
|
@transaction.atomic
|
||||||
self,
|
def create(self, validated_data):
|
||||||
instance,
|
poll_settings = validated_data.pop('poll_settings', None)
|
||||||
validated_data,
|
note = super().create(validated_data)
|
||||||
):
|
configure_poll(note, poll_settings)
|
||||||
if (
|
return note
|
||||||
validated_data.get(
|
|
||||||
'kind',
|
|
||||||
instance.kind,
|
|
||||||
)
|
|
||||||
!= Note.Kind.TASK
|
|
||||||
):
|
|
||||||
validated_data[
|
|
||||||
'completed_at'
|
|
||||||
] = None
|
|
||||||
|
|
||||||
return super().update(
|
@transaction.atomic
|
||||||
instance,
|
def update(self, instance, validated_data):
|
||||||
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(
|
class NoteCompletionSerializer(
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -2,7 +2,11 @@ from django.urls import path
|
|||||||
|
|
||||||
from .views import NoteCompletionView, NoteDetailView, NoteListView, NoteRecipientListView
|
from .views import NoteCompletionView, NoteDetailView, NoteListView, NoteRecipientListView
|
||||||
|
|
||||||
|
from .poll_views import PollVoteView, PollCloseView
|
||||||
|
|
||||||
urlpatterns = [
|
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('', NoteListView.as_view(), name='note-list'),
|
||||||
path('recipients/', NoteRecipientListView.as_view(), name='note-recipients'),
|
path('recipients/', NoteRecipientListView.as_view(), name='note-recipients'),
|
||||||
path('<int:pk>/', NoteDetailView.as_view(), name='note-detail'),
|
path('<int:pk>/', NoteDetailView.as_view(), name='note-detail'),
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .models import Note
|
from .models import Note
|
||||||
|
from users.permissions import require_permission
|
||||||
from .permissions import (
|
from .permissions import (
|
||||||
can_complete_note,
|
can_complete_note,
|
||||||
can_edit_note,
|
can_edit_note,
|
||||||
@ -40,8 +41,8 @@ def visible_notes(user):
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
Note.objects
|
Note.objects
|
||||||
.select_related('author')
|
.select_related('author', 'poll')
|
||||||
.prefetch_related('recipients')
|
.prefetch_related('recipients', 'poll__options', 'poll__votes__options', 'poll__votes__user')
|
||||||
.filter(
|
.filter(
|
||||||
Q(is_public=True)
|
Q(is_public=True)
|
||||||
| Q(author=user)
|
| Q(author=user)
|
||||||
@ -169,6 +170,7 @@ class NoteListView(
|
|||||||
self,
|
self,
|
||||||
serializer,
|
serializer,
|
||||||
):
|
):
|
||||||
|
require_permission(self.request.user, 'create_publication')
|
||||||
serializer.save(
|
serializer.save(
|
||||||
author=self.request.user,
|
author=self.request.user,
|
||||||
)
|
)
|
||||||
@ -218,6 +220,7 @@ class NoteDetailView(
|
|||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
|
Note.objects.select_for_update().get(pk=self.get_object().pk)
|
||||||
return super().update(
|
return super().update(
|
||||||
request,
|
request,
|
||||||
*args,
|
*args,
|
||||||
|
|||||||
0
backend/planning/__init__.py
Normal file
0
backend/planning/__init__.py
Normal file
6
backend/planning/apps.py
Normal file
6
backend/planning/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'planning'
|
||||||
124
backend/planning/migrations/0001_initial.py
Normal file
124
backend/planning/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/planning/migrations/__init__.py
Normal file
0
backend/planning/migrations/__init__.py
Normal file
115
backend/planning/models.py
Normal file
115
backend/planning/models.py
Normal 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')]
|
||||||
194
backend/planning/serializers.py
Normal file
194
backend/planning/serializers.py
Normal 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
|
||||||
356
backend/planning/services.py
Normal file
356
backend/planning/services.py
Normal 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
301
backend/planning/tests.py
Normal 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
15
backend/planning/urls.py
Normal 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
340
backend/planning/views.py
Normal 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)
|
||||||
@ -119,12 +119,17 @@ class TimeEntryAPITests(APITestCase):
|
|||||||
self.assertEqual(response.status_code, 409, response.data)
|
self.assertEqual(response.status_code, 409, response.data)
|
||||||
self.assertEqual(TimeEntry.objects.count(), 1)
|
self.assertEqual(TimeEntry.objects.count(), 1)
|
||||||
conflict = response.data
|
conflict = response.data
|
||||||
|
self.assertEqual(conflict['entry_date'], '2026-09-23')
|
||||||
response = self.client.post('/api/time/entries/scan/', {
|
response = self.client.post('/api/time/entries/scan/', {
|
||||||
'code': 'enter-test', 'resolution': conflict['resolution'], 'time': '06:00',
|
'code': 'enter-test', 'resolution': conflict['resolution'], 'time': '23:00',
|
||||||
'scan_date': conflict['scan_date'], 'entry_id': conflict['entry_id'],
|
'scan_date': conflict['scan_date'], 'correction_date': conflict['entry_date'],
|
||||||
|
'entry_id': conflict['entry_id'],
|
||||||
}, format='json')
|
}, format='json')
|
||||||
self.assertEqual(response.status_code, 200, response.data)
|
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())
|
self.assertFalse(TimeEntry.objects.filter(end_time__isnull=True).exists())
|
||||||
|
|
||||||
def test_scan_missing_entry_requires_original_scan_and_rejects_replay(self):
|
def test_scan_missing_entry_requires_original_scan_and_rejects_replay(self):
|
||||||
|
|||||||
@ -7,6 +7,8 @@ from .views import (
|
|||||||
TimeEntryUpdateView,
|
TimeEntryUpdateView,
|
||||||
TimeEntryGroupView,
|
TimeEntryGroupView,
|
||||||
WeeklySummaryView,
|
WeeklySummaryView,
|
||||||
|
TimeStatsView,
|
||||||
|
MonthlyRaceView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -18,4 +20,6 @@ urlpatterns = [
|
|||||||
path('entries/<int:entry_id>/update/', TimeEntryUpdateView.as_view(), name='time-entry-update'),
|
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/', WeeklySummaryView.as_view(), name='user-weekly-summary'),
|
||||||
path('weekly-summary/<int:user_id>/', WeeklySummaryView.as_view(), name='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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from datetime import timedelta
|
from calendar import monthrange
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from decouple import config
|
from decouple import config
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
@ -17,10 +18,32 @@ from .serializers import (
|
|||||||
TimeEntryUpdateSerializer, WeeklySummarySerializer,
|
TimeEntryUpdateSerializer, WeeklySummarySerializer,
|
||||||
)
|
)
|
||||||
from .services import lock_user, save_period
|
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()
|
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):
|
def visible_entries(user):
|
||||||
entries = TimeEntry.objects.select_related('user')
|
entries = TimeEntry.objects.select_related('user')
|
||||||
return entries if user.is_staff or user.is_superuser else entries.filter(user=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',
|
'missing_time': 'end_time' if kind == 'open_entry' else 'start_time',
|
||||||
'scan_time': selected_time.strftime('%H:%M'),
|
'scan_time': selected_time.strftime('%H:%M'),
|
||||||
'scan_date': today.isoformat(),
|
'scan_date': today.isoformat(),
|
||||||
|
'entry_date': entry.start_date.isoformat() if entry else None,
|
||||||
'entry_id': entry.pk if entry else None,
|
'entry_id': entry.pk if entry else None,
|
||||||
}, status=status.HTTP_409_CONFLICT)
|
}, status=status.HTTP_409_CONFLICT)
|
||||||
|
|
||||||
@ -161,6 +185,14 @@ class TimeEntryScanView(APIView):
|
|||||||
raise ValidationError({'error': 'Résolution de conflit inconnue.'})
|
raise ValidationError({'error': 'Résolution de conflit inconnue.'})
|
||||||
if resolution and request.data.get('scan_date') != today.isoformat():
|
if resolution and request.data.get('scan_date') != today.isoformat():
|
||||||
raise ValidationError({'error': 'Le jour a changé. Scannez à nouveau le code.'})
|
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)
|
lock_user(request.user)
|
||||||
open_entries = list(TimeEntry.objects.select_for_update().filter(
|
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)
|
'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):
|
if resolution and str(request.data.get('entry_id')) != str(open_entry.pk):
|
||||||
raise ValidationError({'error': 'Le pointage a changé. Scannez à nouveau le code.'})
|
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(
|
entry = save_period(request.user, dict(
|
||||||
start_date=open_entry.start_date, start_time=open_entry.start_time,
|
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])
|
), [open_entry])
|
||||||
action = 'exit'
|
action = 'exit'
|
||||||
else:
|
else:
|
||||||
@ -241,3 +275,173 @@ class WeeklySummaryView(APIView):
|
|||||||
raise PermissionDenied('Vous ne pouvez pas consulter ce résumé.')
|
raise PermissionDenied('Vous ne pouvez pas consulter ce résumé.')
|
||||||
user = get_object_or_404(User, pk=user_id)
|
user = get_object_or_404(User, pk=user_id)
|
||||||
return Response(WeeklySummarySerializer(build_weekly_summary(user)).data)
|
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,
|
||||||
|
})
|
||||||
|
|||||||
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
27
backend/users/migrations/0003_delegatedpermission.py
Normal file
27
backend/users/migrations/0003_delegatedpermission.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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):
|
class Invitation(models.Model):
|
||||||
token = models.UUIDField(
|
token = models.UUIDField(
|
||||||
default=uuid.uuid4,
|
default=uuid.uuid4,
|
||||||
@ -40,4 +89,4 @@ class Invitation(models.Model):
|
|||||||
return (
|
return (
|
||||||
self.used_at is None
|
self.used_at is None
|
||||||
and self.expires_at > timezone.now()
|
and self.expires_at > timezone.now()
|
||||||
)
|
)
|
||||||
|
|||||||
158
backend/users/permission_views.py
Normal file
158
backend/users/permission_views.py
Normal 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())
|
||||||
105
backend/users/permissions.py
Normal file
105
backend/users/permissions.py
Normal 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.')
|
||||||
@ -6,6 +6,7 @@ from django.utils import timezone
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import Invitation
|
from .models import Invitation
|
||||||
|
from .permissions import default_permissions, effective_permissions, management_permissions
|
||||||
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@ -13,6 +14,8 @@ User = get_user_model()
|
|||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
is_admin = serializers.SerializerMethodField()
|
is_admin = serializers.SerializerMethodField()
|
||||||
|
permissions = serializers.SerializerMethodField()
|
||||||
|
management_permissions = serializers.SerializerMethodField()
|
||||||
color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False)
|
color = serializers.RegexField(r'^#[0-9a-fA-F]{6}$', required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -27,6 +30,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
'is_staff',
|
'is_staff',
|
||||||
'is_superuser',
|
'is_superuser',
|
||||||
'is_admin',
|
'is_admin',
|
||||||
|
'permissions',
|
||||||
|
'management_permissions',
|
||||||
'date_joined',
|
'date_joined',
|
||||||
'color',
|
'color',
|
||||||
]
|
]
|
||||||
@ -35,6 +40,14 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
def get_is_admin(self, obj):
|
def get_is_admin(self, obj):
|
||||||
return obj.is_superuser or obj.is_staff
|
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):
|
def validate(self, attrs):
|
||||||
actor = self.context['request'].user
|
actor = self.context['request'].user
|
||||||
if not actor.is_superuser:
|
if not actor.is_superuser:
|
||||||
|
|||||||
136
backend/users/test_permissions.py
Normal file
136
backend/users/test_permissions.py
Normal 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)
|
||||||
@ -1,7 +1,9 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
from .permission_views import PermissionConfigurationView
|
||||||
from .views import InvitationCreateView, RegisterView, UserListView, UserMeView, UserDetailView, UserPromoteView, UserDeleteView, ChangePasswordView
|
from .views import InvitationCreateView, RegisterView, UserListView, UserMeView, UserDetailView, UserPromoteView, UserDeleteView, ChangePasswordView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('permissions/', PermissionConfigurationView.as_view(), name='permission-configuration'),
|
||||||
path('register/', RegisterView.as_view(), name='register'),
|
path('register/', RegisterView.as_view(), name='register'),
|
||||||
path('me/', UserMeView.as_view(), name='me'),
|
path('me/', UserMeView.as_view(), name='me'),
|
||||||
path('', UserListView.as_view(), name='user-list'),
|
path('', UserListView.as_view(), name='user-list'),
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import WeeklySummaryPage from './pages/WeeklySummaryPage';
|
|||||||
import CalendarPage from './pages/CalendarPage';
|
import CalendarPage from './pages/CalendarPage';
|
||||||
import NotesPage from './pages/NotesPage';
|
import NotesPage from './pages/NotesPage';
|
||||||
import ManageUsersPage from './pages/ManageUsersPage';
|
import ManageUsersPage from './pages/ManageUsersPage';
|
||||||
|
import UserStatisticsPage from './pages/UserStatisticsPage';
|
||||||
|
import ManagementRoute from './components/ManagementRoute';
|
||||||
import { useAuth } from './context/useAuth';
|
import { useAuth } from './context/useAuth';
|
||||||
|
|
||||||
// Composant personnalisé pour le trigger mobile avec icône menu
|
// 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
|
<Route
|
||||||
path="/users/manage"
|
path="/users/manage"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<ManagementRoute permission={["manage_schedules", "approve_requests"]}>
|
||||||
<ManageUsersPage />
|
<ManageUsersPage />
|
||||||
</AdminRoute>
|
</ManagementRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -6,4 +6,9 @@ export const eventApi = {
|
|||||||
updateEvent: async (id, data) => (await api.patch(`/event/${id}/`, data)).data,
|
updateEvent: async (id, data) => (await api.patch(`/event/${id}/`, data)).data,
|
||||||
deleteEvent: async (id) => (await api.delete(`/event/${id}/`)).data,
|
deleteEvent: async (id) => (await api.delete(`/event/${id}/`)).data,
|
||||||
getEvent: async (id) => (await api.get(`/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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import api from './axios.js';
|
import api from './axios.js';
|
||||||
|
|
||||||
export const notesApi = {
|
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,
|
list: async (params = {}, options = {}) => (await api.get('/notes/', { ...options, params })).data,
|
||||||
recipients: async (options = {}) => (await api.get('/notes/recipients/', options)).data,
|
recipients: async (options = {}) => (await api.get('/notes/recipients/', options)).data,
|
||||||
create: async (data) => (await api.post('/notes/', data)).data,
|
create: async (data) => (await api.post('/notes/', data)).data,
|
||||||
|
|||||||
15
frontend/src/api/planning.js
Normal file
15
frontend/src/api/planning.js
Normal 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,
|
||||||
|
};
|
||||||
@ -12,6 +12,8 @@ export const timeApi = {
|
|||||||
deleteTimeEntryGroup: async (id) => (await api.delete(`/time/groups/${id}/`)).data,
|
deleteTimeEntryGroup: async (id) => (await api.delete(`/time/groups/${id}/`)).data,
|
||||||
getWeeklySummary: async () => (await api.get('/time/weekly-summary/')).data,
|
getWeeklySummary: async () => (await api.get('/time/weekly-summary/')).data,
|
||||||
getUserWeeklySummary: async (id) => (await api.get(`/time/weekly-summary/${id}/`)).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 = {}) => {
|
scanTimeEntry: async (code, options = {}) => {
|
||||||
try {
|
try {
|
||||||
return (await api.post('/time/entries/scan/', { code, ...options })).data;
|
return (await api.post('/time/entries/scan/', { code, ...options })).data;
|
||||||
@ -22,6 +24,7 @@ export const timeApi = {
|
|||||||
error.missingTime = data.missing_time;
|
error.missingTime = data.missing_time;
|
||||||
error.scanTime = data.scan_time;
|
error.scanTime = data.scan_time;
|
||||||
error.scanDate = data.scan_date;
|
error.scanDate = data.scan_date;
|
||||||
|
error.entryDate = data.entry_date;
|
||||||
error.entryId = data.entry_id;
|
error.entryId = data.entry_id;
|
||||||
error.code = error.response?.status;
|
error.code = error.response?.status;
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import api from './axios';
|
import api from './axios';
|
||||||
|
|
||||||
export const userApi = {
|
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,
|
getUsers: async () => (await api.get('/users/')).data,
|
||||||
deleteUser: async (id) => (await api.delete(`/users/${id}/`)).data,
|
deleteUser: async (id) => (await api.delete(`/users/${id}/`)).data,
|
||||||
promoteUser: async (id) => (await api.post(`/users/${id}/promote/`)).data,
|
promoteUser: async (id) => (await api.post(`/users/${id}/promote/`)).data,
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import {
|
|||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
@ -58,7 +57,7 @@ const ConfirmDialog = ({
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<AlertDialogFooter>
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<AlertDialogCancel onClick={handleCancel}>
|
<AlertDialogCancel onClick={handleCancel}>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
@ -66,7 +65,7 @@ const ConfirmDialog = ({
|
|||||||
<AlertDialogAction onClick={handleConfirm}>
|
<AlertDialogAction onClick={handleConfirm}>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
|
|||||||
21
frontend/src/components/ManagementRoute.jsx
Normal file
21
frontend/src/components/ManagementRoute.jsx
Normal 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;
|
||||||
|
}
|
||||||
@ -5,7 +5,6 @@ import { useAuth } from '../context/useAuth';
|
|||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
@ -126,23 +125,26 @@ const NavBar = () => {
|
|||||||
/*
|
/*
|
||||||
* Administration
|
* 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 = [
|
const userNavigation = [
|
||||||
{
|
...(user.is_admin ? [{
|
||||||
title: 'Liste',
|
title: 'Liste',
|
||||||
href: '/users',
|
href: '/users',
|
||||||
icon: List,
|
icon: List,
|
||||||
end: true,
|
end: true,
|
||||||
},
|
}] : []),
|
||||||
{
|
...(canViewStatistics ? [{
|
||||||
title: 'Statistiques',
|
title: 'Statistiques',
|
||||||
href: '/users/statistics',
|
href: '/users/statistics',
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
},
|
}] : []),
|
||||||
{
|
...(canManageTeam ? [{
|
||||||
title: 'Gestion',
|
title: 'Gestion',
|
||||||
href: '/users/manage',
|
href: '/users/manage',
|
||||||
icon: UserCog,
|
icon: UserCog,
|
||||||
},
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const menuItemClass =
|
const menuItemClass =
|
||||||
@ -306,7 +308,7 @@ const NavBar = () => {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
{/* Administration */}
|
{/* Administration */}
|
||||||
{user.is_admin && (
|
{userNavigation.length > 0 && (
|
||||||
<SidebarGroup className="p-2">
|
<SidebarGroup className="p-2">
|
||||||
<GroupHeader>
|
<GroupHeader>
|
||||||
Administration
|
Administration
|
||||||
@ -393,7 +395,7 @@ const NavBar = () => {
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
{/* Déconnexion */}
|
{/* Déconnexion */}
|
||||||
<SidebarFooter>
|
<div className="mt-auto p-2">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem className={menuItemClass}>
|
<SidebarMenuItem className={menuItemClass}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
@ -416,7 +418,7 @@ const NavBar = () => {
|
|||||||
/>
|
/>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarFooter>
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
253
frontend/src/components/PermissionManager.jsx
Normal file
253
frontend/src/components/PermissionManager.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { Calendar } from '@/components/ui/calendar';
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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';
|
import { fr } from 'date-fns/locale';
|
||||||
|
|
||||||
const parseDateValue = (value) => {
|
const parseDateValue = (value) => {
|
||||||
@ -91,7 +91,10 @@ const DesktopDatePicker = ({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
min,
|
min,
|
||||||
|
max,
|
||||||
placeholder = 'Choisir une date',
|
placeholder = 'Choisir une date',
|
||||||
|
disabled = false,
|
||||||
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] =
|
const [open, setOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -102,6 +105,9 @@ const DesktopDatePicker = ({
|
|||||||
const minDate =
|
const minDate =
|
||||||
parseDateValue(min);
|
parseDateValue(min);
|
||||||
|
|
||||||
|
const maxDate =
|
||||||
|
parseDateValue(max);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
@ -112,7 +118,8 @@ const DesktopDatePicker = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
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()
|
new Date()
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
minDate
|
minDate || maxDate
|
||||||
? {
|
? (date) => (
|
||||||
before: minDate,
|
(minDate && date < minDate) ||
|
||||||
}
|
(maxDate && date > maxDate)
|
||||||
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onSelect={(date) => {
|
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
|
TIME PICKER
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
||||||
const DesktopTimePicker = ({
|
const DesktopTimePicker = ({
|
||||||
value,
|
value,
|
||||||
onChange
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const timeRef = useRef(value);
|
const timeRef = useRef(value);
|
||||||
@ -330,7 +571,8 @@ const DesktopTimePicker = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
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.
|
// Les champs natifs restent pratiques sur téléphone pour les dates et les heures.
|
||||||
// Sur ordinateur, on conserve le calendrier et la molette de TimeClock.
|
// Les mois et années utilisent le même DatePicker partout pour garder une UI cohérente.
|
||||||
export const DatePicker = ({ value, onChange, min, placeholder, id }) => (
|
export const DatePicker = ({
|
||||||
<>
|
value,
|
||||||
<Input
|
onChange,
|
||||||
id={id}
|
min,
|
||||||
type="date"
|
max,
|
||||||
min={min}
|
placeholder,
|
||||||
value={value || ''}
|
id,
|
||||||
onChange={(event) => onChange(event.target.value)}
|
mode = 'date',
|
||||||
className="block w-full min-w-0 md:hidden"
|
disabled = false,
|
||||||
/>
|
required = false,
|
||||||
<div className="hidden md:block">
|
className = '',
|
||||||
<DesktopDatePicker value={value} onChange={onChange} min={min} placeholder={placeholder} />
|
ariaLabel,
|
||||||
</div>
|
}) => {
|
||||||
</>
|
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
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
|
aria-label={ariaLabel}
|
||||||
type="time"
|
type="time"
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
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">
|
<div className="hidden md:block">
|
||||||
<DesktopTimePicker value={value} onChange={onChange} />
|
<DesktopTimePicker
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
56
frontend/src/components/notes/PollCard.jsx
Normal file
56
frontend/src/components/notes/PollCard.jsx
Normal 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>;
|
||||||
|
}
|
||||||
21
frontend/src/components/notes/PollEditor.jsx
Normal file
21
frontend/src/components/notes/PollEditor.jsx
Normal 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>;
|
||||||
|
}
|
||||||
2
frontend/src/components/notes/pollUtils.js
Normal file
2
frontend/src/components/notes/pollUtils.js
Normal 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();
|
||||||
17
frontend/src/components/planning/ChangePreview.jsx
Normal file
17
frontend/src/components/planning/ChangePreview.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
386
frontend/src/components/planning/MyRequestsCard.jsx
Normal file
386
frontend/src/components/planning/MyRequestsCard.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/src/components/planning/PlannedDay.jsx
Normal file
18
frontend/src/components/planning/PlannedDay.jsx
Normal 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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
69
frontend/src/components/planning/RequestForm.jsx
Normal file
69
frontend/src/components/planning/RequestForm.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/src/components/planning/RequestsList.jsx
Normal file
127
frontend/src/components/planning/RequestsList.jsx
Normal 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>;
|
||||||
|
}
|
||||||
172
frontend/src/components/planning/ScheduleGrid.jsx
Normal file
172
frontend/src/components/planning/ScheduleGrid.jsx
Normal 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>;
|
||||||
|
}
|
||||||
287
frontend/src/components/planning/ScheduleManager.jsx
Normal file
287
frontend/src/components/planning/ScheduleManager.jsx
Normal 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>;
|
||||||
|
}
|
||||||
31
frontend/src/components/planning/SlotsEditor.jsx
Normal file
31
frontend/src/components/planning/SlotsEditor.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/planning/gridUtils.js
Normal file
51
frontend/src/components/planning/gridUtils.js
Normal 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);
|
||||||
|
}
|
||||||
12
frontend/src/components/planning/utils.js
Normal file
12
frontend/src/components/planning/utils.js
Normal 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';
|
||||||
280
frontend/src/components/statistics/MonthlyHoursRace.jsx
Normal file
280
frontend/src/components/statistics/MonthlyHoursRace.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
frontend/src/components/statistics/PersonalStatistics.jsx
Normal file
146
frontend/src/components/statistics/PersonalStatistics.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -43,8 +43,17 @@ export const AuthProvider = ({ children }) => {
|
|||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshVisible = () => { if (!document.hidden) checkAuth(); };
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
window.addEventListener('focus', refreshVisible);
|
||||||
|
window.addEventListener('auth:permissions-changed', refreshVisible);
|
||||||
|
document.addEventListener('visibilitychange', refreshVisible);
|
||||||
|
const refreshTimer = window.setInterval(refreshVisible, 60000);
|
||||||
return () => {
|
return () => {
|
||||||
|
window.clearInterval(refreshTimer);
|
||||||
|
window.removeEventListener('focus', refreshVisible);
|
||||||
|
window.removeEventListener('auth:permissions-changed', refreshVisible);
|
||||||
|
document.removeEventListener('visibilitychange', refreshVisible);
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.removeEventListener('auth:logout', onLogout);
|
window.removeEventListener('auth:logout', onLogout);
|
||||||
};
|
};
|
||||||
|
|||||||
1
frontend/src/lib/permissions.js
Normal file
1
frontend/src/lib/permissions.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const can = (user, permission) => Boolean(user && (user.is_admin || user.permissions?.[permission] === true));
|
||||||
@ -21,12 +21,13 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { can } from '@/lib/permissions';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
|
||||||
@ -36,7 +37,12 @@ import { Input } from '@/components/ui/input';
|
|||||||
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@ -106,6 +112,7 @@ const EVENT_LEFT_PADDING = 3;
|
|||||||
|
|
||||||
const CalendarPage = () => {
|
const CalendarPage = () => {
|
||||||
const { user, isAdmin } = useAuth();
|
const { user, isAdmin } = useAuth();
|
||||||
|
const canCreate = can(user, 'create_personal_event');
|
||||||
const canManageEvent = (event) => {
|
const canManageEvent = (event) => {
|
||||||
const creatorId = event?.created_by?.id ?? event?.created_by;
|
const creatorId = event?.created_by?.id ?? event?.created_by;
|
||||||
return user?.id != null && creatorId != null &&
|
return user?.id != null && creatorId != null &&
|
||||||
@ -128,6 +135,10 @@ const CalendarPage = () => {
|
|||||||
useState([]);
|
useState([]);
|
||||||
const [loadingEvents, setLoadingEvents] =
|
const [loadingEvents, setLoadingEvents] =
|
||||||
useState(true);
|
useState(true);
|
||||||
|
const [eventProfiles, setEventProfiles] = useState([]);
|
||||||
|
const [loadingProfiles, setLoadingProfiles] = useState(false);
|
||||||
|
const [selectedProfileId, setSelectedProfileId] = useState('manual');
|
||||||
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
const [hoveredEvent, setHoveredEvent] =
|
const [hoveredEvent, setHoveredEvent] =
|
||||||
useState(null);
|
useState(null);
|
||||||
const [hoverPosition, setHoverPosition] =
|
const [hoverPosition, setHoverPosition] =
|
||||||
@ -164,7 +175,6 @@ const CalendarPage = () => {
|
|||||||
const [eventForm, setEventForm] =
|
const [eventForm, setEventForm] =
|
||||||
useState({
|
useState({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
@ -344,7 +354,7 @@ const CalendarPage = () => {
|
|||||||
isSelectingRef.current = false;
|
isSelectingRef.current = false;
|
||||||
};
|
};
|
||||||
const beginSelection = (pointerEvent, date) => {
|
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();
|
pointerEvent.preventDefault();
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
setHoveredEvent(null);
|
setHoveredEvent(null);
|
||||||
@ -399,8 +409,26 @@ const CalendarPage = () => {
|
|||||||
setLoadingEvents(false);
|
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(() => {
|
useEffect(() => {
|
||||||
loadEvents();
|
loadEvents();
|
||||||
|
loadEventProfiles();
|
||||||
}, []);
|
}, []);
|
||||||
/*
|
/*
|
||||||
* ============================================================
|
* ============================================================
|
||||||
@ -560,6 +588,7 @@ const CalendarPage = () => {
|
|||||||
) => {
|
) => {
|
||||||
mouseEvent.preventDefault();
|
mouseEvent.preventDefault();
|
||||||
mouseEvent.stopPropagation();
|
mouseEvent.stopPropagation();
|
||||||
|
if (!canCreate) return;
|
||||||
setHoveredEvent(null);
|
setHoveredEvent(null);
|
||||||
setHoverPosition(null);
|
setHoverPosition(null);
|
||||||
const selection = getOrderedSelection();
|
const selection = getOrderedSelection();
|
||||||
@ -633,7 +662,6 @@ const CalendarPage = () => {
|
|||||||
const resetEventForm = () => {
|
const resetEventForm = () => {
|
||||||
setEventForm({
|
setEventForm({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
|
||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
@ -641,16 +669,18 @@ const CalendarPage = () => {
|
|||||||
color: DEFAULT_EVENT_COLOR,
|
color: DEFAULT_EVENT_COLOR,
|
||||||
is_public: Boolean(isAdmin),
|
is_public: Boolean(isAdmin),
|
||||||
});
|
});
|
||||||
|
setSelectedProfileId('manual');
|
||||||
};
|
};
|
||||||
const openCreateDialog = (
|
const openCreateDialog = (
|
||||||
startDate = '',
|
startDate = '',
|
||||||
endDate = startDate,
|
endDate = startDate,
|
||||||
) => {
|
) => {
|
||||||
|
if (!canCreate) return;
|
||||||
setRequestError('');
|
setRequestError('');
|
||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
|
setSelectedProfileId('manual');
|
||||||
setEventForm({
|
setEventForm({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
|
||||||
start_date: startDate,
|
start_date: startDate,
|
||||||
end_date: endDate || startDate,
|
end_date: endDate || startDate,
|
||||||
start_time: '',
|
start_time: '',
|
||||||
@ -668,10 +698,9 @@ const CalendarPage = () => {
|
|||||||
if (!canManageEvent(event)) return;
|
if (!canManageEvent(event)) return;
|
||||||
setRequestError('');
|
setRequestError('');
|
||||||
setEditingEvent(event);
|
setEditingEvent(event);
|
||||||
|
setSelectedProfileId('manual');
|
||||||
setEventForm({
|
setEventForm({
|
||||||
name: event?.name ?? '',
|
name: event?.name ?? '',
|
||||||
description:
|
|
||||||
event?.description ?? '',
|
|
||||||
start_date:
|
start_date:
|
||||||
event?.start_date ?? '',
|
event?.start_date ?? '',
|
||||||
end_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 (
|
const handleSaveEvent = async (
|
||||||
submitEvent,
|
submitEvent,
|
||||||
) => {
|
) => {
|
||||||
setRequestError('');
|
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 (savingEvent || (editingEvent && !canManageEvent(editingEvent))) return;
|
||||||
if (!eventForm.name.trim()) {
|
if (!eventForm.name.trim()) {
|
||||||
return;
|
return;
|
||||||
@ -765,8 +877,6 @@ const CalendarPage = () => {
|
|||||||
setSavingEvent(true);
|
setSavingEvent(true);
|
||||||
const payload = {
|
const payload = {
|
||||||
name: eventForm.name.trim(),
|
name: eventForm.name.trim(),
|
||||||
description:
|
|
||||||
eventForm.description.trim(),
|
|
||||||
start_date:
|
start_date:
|
||||||
eventForm.start_date,
|
eventForm.start_date,
|
||||||
end_date:
|
end_date:
|
||||||
@ -967,9 +1077,6 @@ const CalendarPage = () => {
|
|||||||
{hoveredEvent && (
|
{hoveredEvent && (
|
||||||
<HoverInfo
|
<HoverInfo
|
||||||
title={hoveredEvent.name}
|
title={hoveredEvent.name}
|
||||||
description={
|
|
||||||
hoveredEvent.description
|
|
||||||
}
|
|
||||||
startTime={
|
startTime={
|
||||||
hoveredEvent.start_time
|
hoveredEvent.start_time
|
||||||
}
|
}
|
||||||
@ -1018,7 +1125,7 @@ const CalendarPage = () => {
|
|||||||
mouseEvent.stopPropagation();
|
mouseEvent.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(contextMenu.type === 'day' ||
|
{canCreate && (contextMenu.type === 'day' ||
|
||||||
contextMenu.type === 'selection') && (
|
contextMenu.type === 'selection') && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1133,18 +1240,70 @@ const CalendarPage = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[550px]">
|
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[550px]">
|
||||||
<DialogHeader>
|
<DialogHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||||
<DialogTitle>
|
<div className="min-w-0 space-y-1.5">
|
||||||
{editingEvent
|
<DialogTitle>
|
||||||
? 'Modifier l’événement'
|
{editingEvent
|
||||||
: 'Créer un événement'}
|
? 'Modifier l’événement'
|
||||||
</DialogTitle>
|
: 'Créer un événement'}
|
||||||
<DialogDescription>
|
</DialogTitle>
|
||||||
{editingEvent
|
<DialogDescription>
|
||||||
? 'Modifie les informations de cet événement.'
|
{editingEvent
|
||||||
: 'Ajoute un nouvel événement au calendrier.'}
|
? 'Modifie les informations de cet événement.'
|
||||||
</DialogDescription>
|
: '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>
|
</DialogHeader>
|
||||||
{requestError && (
|
{requestError && (
|
||||||
<Alert variant="destructive"><AlertDescription>{requestError}</AlertDescription></Alert>
|
<Alert variant="destructive"><AlertDescription>{requestError}</AlertDescription></Alert>
|
||||||
@ -1172,28 +1331,6 @@ const CalendarPage = () => {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* DATES */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@ -1303,25 +1440,7 @@ const CalendarPage = () => {
|
|||||||
<div className="grid shrink-0 gap-2">
|
<div className="grid shrink-0 gap-2">
|
||||||
<Label htmlFor="event-is-public">Visibilité</Label>
|
<Label htmlFor="event-is-public">Visibilité</Label>
|
||||||
<div className="flex h-10 items-center gap-2">
|
<div className="flex h-10 items-center gap-2">
|
||||||
<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 }))} />
|
||||||
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>
|
|
||||||
<Label htmlFor="event-is-public" className="w-10 cursor-pointer">
|
<Label htmlFor="event-is-public" className="w-10 cursor-pointer">
|
||||||
{eventForm.is_public ? 'Public' : 'Privé'}
|
{eventForm.is_public ? 'Public' : 'Privé'}
|
||||||
</Label>
|
</Label>
|
||||||
@ -1335,7 +1454,32 @@ const CalendarPage = () => {
|
|||||||
: 'Visible uniquement par toi dans ton calendrier.'}
|
: 'Visible uniquement par toi dans ton calendrier.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -1371,7 +1515,7 @@ const CalendarPage = () => {
|
|||||||
? 'Enregistrer'
|
? 'Enregistrer'
|
||||||
: 'Créer'}
|
: 'Créer'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
{/* ======================================================
|
{/* ======================================================
|
||||||
@ -1429,6 +1573,14 @@ const CalendarPage = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<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 && (
|
{requestError && (
|
||||||
<Alert variant="destructive" className="mb-3">
|
<Alert variant="destructive" className="mb-3">
|
||||||
<AlertDescription>{requestError}</AlertDescription>
|
<AlertDescription>{requestError}</AlertDescription>
|
||||||
|
|||||||
@ -171,9 +171,20 @@ const HomePage = () => {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const displayName = user?.first_name?.trim() || user?.username;
|
const displayName = user?.first_name?.trim() || user?.username;
|
||||||
|
|
||||||
const visibleSections = sections.filter(
|
const canViewStatistics = user?.is_admin || user?.management_permissions?.view_statistics?.enabled;
|
||||||
(section) => !section.adminOnly || user?.is_admin,
|
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 (
|
return (
|
||||||
<MainCard title="Accueil" icon={House}>
|
<MainCard title="Accueil" icon={House}>
|
||||||
|
|||||||
@ -1,187 +1,93 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { CalendarClock, ClipboardCheck, ShieldCheck, Users } from 'lucide-react';
|
||||||
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 MainCard from '@/components/MainCard';
|
import MainCard from '@/components/MainCard';
|
||||||
import {
|
import { Button } from '@/components/ui/button';
|
||||||
AlertDialog,
|
import ScheduleManager from '@/components/planning/ScheduleManager';
|
||||||
AlertDialogAction,
|
import PermissionManager from '@/components/PermissionManager';
|
||||||
AlertDialogCancel,
|
import RequestsList from '@/components/planning/RequestsList';
|
||||||
AlertDialogContent,
|
import { inputClass, personLabel } from '@/components/planning/utils';
|
||||||
AlertDialogDescription,
|
import { planningApi } from '@/api/planning';
|
||||||
AlertDialogFooter,
|
import { useAuth } from '@/context/useAuth';
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
const allowedPeople = (people, user, permission) => {
|
||||||
} from '@/components/ui/alert-dialog';
|
if (user?.is_admin) return people;
|
||||||
import { Users} from 'lucide-react';
|
const rule = user?.management_permissions?.[permission];
|
||||||
// Composant pour la gestion des utilisateurs
|
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() {
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [reload, setReload] = useState(0);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const schedulePeople = useMemo(() => allowedPeople(people, user, 'manage_schedules'), [people, user]);
|
||||||
const [userToDelete, setUserToDelete] = useState(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchUsers = async () => {
|
const controller = new AbortController();
|
||||||
try {
|
setLoading(true);
|
||||||
// Ici, vous devrez implémenter l'appel API réel
|
setError('');
|
||||||
// Pour l'exemple, on utilise des données statiques
|
planningApi.people(controller.signal)
|
||||||
const mockUsers = [
|
.then((data) => setPeople(Array.isArray(data) ? data : []))
|
||||||
{ id: 1, name: 'Jean Dupont', email: 'jean.dupont@example.com', role: 'user', status: 'active' },
|
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
|
||||||
{ id: 2, name: 'Marie Martin', email: 'marie.martin@example.com', role: 'admin', status: 'active' },
|
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||||
{ id: 3, name: 'Pierre Durand', email: 'pierre.durand@example.com', role: 'user', status: 'inactive' },
|
return () => controller.abort();
|
||||||
];
|
}, [reload]);
|
||||||
setUsers(mockUsers);
|
|
||||||
setLoading(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Erreur lors de la récupération des utilisateurs');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUsers();
|
useEffect(() => {
|
||||||
}, []);
|
if (tabs.length && !tabs.includes(tab)) setTab(tabs[0]);
|
||||||
|
}, [tabs, tab]);
|
||||||
|
|
||||||
const handleDeleteUser = (user) => {
|
useEffect(() => {
|
||||||
setUserToDelete(user);
|
const available = tab === 'schedules' ? schedulePeople : requestPeople;
|
||||||
setDeleteDialogOpen(true);
|
setSelectedUserId((current) => (
|
||||||
};
|
available.some((person) => String(person.id) === String(current))
|
||||||
|
? String(current)
|
||||||
const confirmDeleteUser = async () => {
|
: String(available[0]?.id || '')
|
||||||
if (!userToDelete) return;
|
));
|
||||||
|
}, [tab, schedulePeople, requestPeople]);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainCard title="Gestion des utilisateurs" icon={Users}>
|
<MainCard title="Gestion de l’équipe" icon={Users}>
|
||||||
<Card>
|
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
<CardHeader>
|
<div className="mx-auto max-w-6xl space-y-5">
|
||||||
<CardTitle>Recherche d'utilisateurs</CardTitle>
|
<div className="flex flex-wrap gap-2" aria-label="Sections de gestion">
|
||||||
<CardDescription>
|
{tabs.includes('schedules') && <Button variant={tab === 'schedules' ? 'default' : 'outline'} aria-pressed={tab === 'schedules'} onClick={() => setTab('schedules')}><CalendarClock className="size-4" />Emplois du temps</Button>}
|
||||||
Recherchez des utilisateurs par nom ou email
|
{tabs.includes('requests') && <Button variant={tab === 'requests' ? 'default' : 'outline'} aria-pressed={tab === 'requests'} onClick={() => setTab('requests')}><ClipboardCheck className="size-4" />Demandes</Button>}
|
||||||
</CardDescription>
|
{tabs.includes('permissions') && <Button variant={tab === 'permissions' ? 'default' : 'outline'} aria-pressed={tab === 'permissions'} onClick={() => setTab('permissions')}><ShieldCheck className="size-4" />Autorisations</Button>}
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<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>}
|
||||||
<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>
|
|
||||||
|
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
{loading ? <p role="status" className="text-sm text-muted-foreground">Chargement des salariés…</p> : !error && (
|
||||||
<AlertDialogContent>
|
tab === 'permissions' ? <PermissionManager /> : tab === 'requests' ? (
|
||||||
<AlertDialogHeader>
|
<RequestsList user={user} admin people={requestPeople} />
|
||||||
<AlertDialogTitle>Êtes-vous sûr ?</AlertDialogTitle>
|
) : tab === 'schedules' ? (
|
||||||
<AlertDialogDescription>
|
<>
|
||||||
Cette action supprimera définitivement l'utilisateur {userToDelete?.name} du système.
|
<label className="grid max-w-md gap-1.5 text-sm font-medium">
|
||||||
Cette action ne peut pas être annulée.
|
Salarié
|
||||||
</AlertDialogDescription>
|
<select aria-label="Salarié" className={inputClass} value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
|
||||||
</AlertDialogHeader>
|
{schedulePeople.map((person) => <option key={person.id} value={person.id}>{personLabel(person)}</option>)}
|
||||||
<AlertDialogFooter>
|
</select>
|
||||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
</label>
|
||||||
<AlertDialogAction onClick={confirmDeleteUser}>
|
{selectedUserId ? <ScheduleManager key={selectedUserId} userId={selectedUserId} /> : <p className="text-sm text-muted-foreground">Aucun salarié n’est associé à cette autorisation.</p>}
|
||||||
Supprimer
|
</>
|
||||||
</AlertDialogAction>
|
) : <p className="text-sm text-muted-foreground">Aucune responsabilité de gestion ne t’a été attribuée.</p>
|
||||||
</AlertDialogFooter>
|
)}
|
||||||
</AlertDialogContent>
|
</div>
|
||||||
</AlertDialog>
|
</div>
|
||||||
</MainCard>
|
</MainCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import LoadingCard from '@/components/LoadingCard';
|
import LoadingCard from '@/components/LoadingCard';
|
||||||
import MainCard from '@/components/MainCard';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@ -244,6 +246,12 @@ const ProfileContent = ({ user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<MyRequestsCard user={user} />
|
||||||
|
|
||||||
|
<PersonalStatistics />
|
||||||
|
|
||||||
|
<MonthlyHoursRace />
|
||||||
|
|
||||||
<div className="grid items-start gap-5 lg:grid-cols-2">
|
<div className="grid items-start gap-5 lg:grid-cols-2">
|
||||||
<div className="min-w-0 space-y-5">
|
<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">
|
<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>}
|
{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="button" variant="outline" onClick={() => handlePasswordDialogChange(false)} disabled={savingPassword}>Annuler</Button>
|
||||||
<Button type="submit" disabled={savingPassword}>
|
<Button type="submit" disabled={savingPassword}>
|
||||||
{savingPassword && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
|
{savingPassword && <Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" />}
|
||||||
{savingPassword ? 'Modification…' : 'Enregistrer'}
|
{savingPassword ? 'Modification…' : 'Enregistrer'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useAuth } from '@/context/useAuth';
|
||||||
|
import { can } from '@/lib/permissions';
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
BarChart3,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Globe2,
|
Globe2,
|
||||||
@ -16,6 +19,9 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { notesApi } from '../api/notes';
|
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 MainCard from '@/components/MainCard';
|
||||||
import LoadingCard from '@/components/LoadingCard';
|
import LoadingCard from '@/components/LoadingCard';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
@ -34,7 +40,6 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@ -52,6 +57,7 @@ import {
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
const kinds = {
|
const kinds = {
|
||||||
|
poll: { label: 'Sondage', icon: BarChart3, color: 'text-cyan-700 bg-cyan-50 dark:text-cyan-300 dark:bg-cyan-950/40' },
|
||||||
task: {
|
task: {
|
||||||
label: 'Tâche',
|
label: 'Tâche',
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
@ -81,6 +87,7 @@ const emptyForm = () => ({
|
|||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
kind: 'memo',
|
kind: 'memo',
|
||||||
|
poll_settings: emptyPoll(),
|
||||||
recipient_ids: [],
|
recipient_ids: [],
|
||||||
is_public: true,
|
is_public: true,
|
||||||
});
|
});
|
||||||
@ -98,6 +105,8 @@ const dateLabel = (value) =>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
export default function NotesPage() {
|
export default function NotesPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canCreate = can(user, 'create_publication');
|
||||||
const [result, setResult] = useState({
|
const [result, setResult] = useState({
|
||||||
count: 0,
|
count: 0,
|
||||||
results: [],
|
results: [],
|
||||||
@ -277,6 +286,7 @@ export default function NotesPage() {
|
|||||||
const openEditor = (
|
const openEditor = (
|
||||||
note = null,
|
note = null,
|
||||||
) => {
|
) => {
|
||||||
|
if (!note && !canCreate) return;
|
||||||
setEditing(note);
|
setEditing(note);
|
||||||
setForm(
|
setForm(
|
||||||
note
|
note
|
||||||
@ -284,6 +294,7 @@ export default function NotesPage() {
|
|||||||
title: note.title,
|
title: note.title,
|
||||||
content: note.content,
|
content: note.content,
|
||||||
kind: note.kind,
|
kind: note.kind,
|
||||||
|
poll_settings: pollForm(note.poll),
|
||||||
recipient_ids:
|
recipient_ids:
|
||||||
note.recipients?.map(
|
note.recipients?.map(
|
||||||
(person) =>
|
(person) =>
|
||||||
@ -368,13 +379,15 @@ export default function NotesPage() {
|
|||||||
*/
|
*/
|
||||||
const save = async (event) => {
|
const save = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!editing && !canCreate) { setFormError('La création de publications n’est pas activée pour ton compte.'); return; }
|
||||||
if (
|
if (
|
||||||
mutationInFlight.current
|
mutationInFlight.current
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!form.is_public
|
form.kind !== 'poll'
|
||||||
|
&& !form.is_public
|
||||||
&& form.recipient_ids.length === 0
|
&& form.recipient_ids.length === 0
|
||||||
) {
|
) {
|
||||||
setFormError(
|
setFormError(
|
||||||
@ -392,6 +405,13 @@ export default function NotesPage() {
|
|||||||
content:
|
content:
|
||||||
form.content.trim(),
|
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 {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await notesApi.update(
|
await notesApi.update(
|
||||||
@ -483,9 +503,10 @@ export default function NotesPage() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<MainCard
|
<MainCard
|
||||||
|
className="max-md:[&>[data-slot=card-header]]:flex-wrap"
|
||||||
title="Espace d'échanges"
|
title="Espace d'échanges"
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
headerContent={
|
headerContent={canCreate &&
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@ -500,6 +521,7 @@ export default function NotesPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CardContent className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
<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 && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
@ -748,6 +770,7 @@ export default function NotesPage() {
|
|||||||
{note.content}
|
{note.content}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{note.kind === 'poll' && <PollCard note={note} onChange={(updated) => setResult((current) => ({ ...current, results: current.results.map((item) => item.id === updated.id ? updated : item) }))} />}
|
||||||
{/* Destinataires */}
|
{/* Destinataires */}
|
||||||
<div className="flex items-start gap-2 text-sm">
|
<div className="flex items-start gap-2 text-sm">
|
||||||
<Users className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
<Users className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||||
@ -939,9 +962,9 @@ export default function NotesPage() {
|
|||||||
: 'Nouvelle publication'}
|
: 'Nouvelle publication'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Choisissez les personnes concernées, puis la visibilité.
|
{form.kind === 'poll'
|
||||||
Une publication publique reste visible par toute l’équipe,
|
? 'Le sondage est automatiquement public et accessible à toute l’équipe.'
|
||||||
même lorsqu’elle est destinée à certaines personnes.
|
: '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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{formError && (
|
{formError && (
|
||||||
@ -951,7 +974,7 @@ export default function NotesPage() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{recipientsError && (
|
{form.kind !== 'poll' && recipientsError && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Annuaire indisponible :{' '}
|
Annuaire indisponible :{' '}
|
||||||
@ -968,14 +991,16 @@ export default function NotesPage() {
|
|||||||
id="note-kind"
|
id="note-kind"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={form.kind}
|
value={form.kind}
|
||||||
disabled={saving}
|
disabled={saving || Boolean(editing?.poll?.is_locked)}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
|
const kind = event.target.value;
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
kind:
|
kind,
|
||||||
event.target.value,
|
...(kind === 'poll' ? { is_public: true, recipient_ids: [] } : {}),
|
||||||
})
|
});
|
||||||
}
|
if (kind === 'poll') setRecipientPopoverOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{Object.entries(
|
{Object.entries(
|
||||||
kinds,
|
kinds,
|
||||||
@ -992,7 +1017,7 @@ export default function NotesPage() {
|
|||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
</div>
|
</div>
|
||||||
{/* Destinataires */}
|
{/* Destinataires */}
|
||||||
<div className="grid gap-1.5">
|
<div className={form.kind === 'poll' ? 'hidden' : 'grid gap-1.5'}>
|
||||||
<Label>
|
<Label>
|
||||||
Destinataires
|
Destinataires
|
||||||
</Label>
|
</Label>
|
||||||
@ -1004,11 +1029,11 @@ export default function NotesPage() {
|
|||||||
setRecipientPopoverOpen
|
setRecipientPopoverOpen
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger render={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={saving}
|
disabled={saving || Boolean(editing?.poll?.is_locked)}
|
||||||
className="w-full justify-between font-normal"
|
className="w-full justify-between font-normal"
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
@ -1016,7 +1041,7 @@ export default function NotesPage() {
|
|||||||
</span>
|
</span>
|
||||||
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
} />
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
align="start"
|
align="start"
|
||||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
@ -1046,7 +1071,7 @@ export default function NotesPage() {
|
|||||||
checked={
|
checked={
|
||||||
checked
|
checked
|
||||||
}
|
}
|
||||||
disabled={saving}
|
disabled={saving || Boolean(editing?.poll?.is_locked)}
|
||||||
onCheckedChange={() => toggleRecipient(person.id)}
|
onCheckedChange={() => toggleRecipient(person.id)}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@ -1080,7 +1105,7 @@ export default function NotesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Public / privé */}
|
{/* 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">
|
<div className="space-y-1">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="note-public"
|
htmlFor="note-public"
|
||||||
@ -1108,7 +1133,7 @@ export default function NotesPage() {
|
|||||||
checked={
|
checked={
|
||||||
form.is_public
|
form.is_public
|
||||||
}
|
}
|
||||||
disabled={saving}
|
disabled={saving || Boolean(editing?.poll?.is_locked)}
|
||||||
onCheckedChange={
|
onCheckedChange={
|
||||||
changeVisibility
|
changeVisibility
|
||||||
}
|
}
|
||||||
@ -1124,7 +1149,7 @@ export default function NotesPage() {
|
|||||||
required
|
required
|
||||||
maxLength={160}
|
maxLength={160}
|
||||||
value={form.title}
|
value={form.title}
|
||||||
disabled={saving}
|
disabled={saving || Boolean(editing?.poll?.is_locked)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
@ -1159,7 +1184,8 @@ export default function NotesPage() {
|
|||||||
placeholder="Précisions, consignes ou informations utiles…"
|
placeholder="Précisions, consignes ou informations utiles…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -1176,7 +1202,8 @@ export default function NotesPage() {
|
|||||||
saving
|
saving
|
||||||
|| !form.title.trim()
|
|| !form.title.trim()
|
||||||
|| (
|
|| (
|
||||||
!form.is_public
|
form.kind !== 'poll'
|
||||||
|
&& !form.is_public
|
||||||
&& form.recipient_ids
|
&& form.recipient_ids
|
||||||
.length === 0
|
.length === 0
|
||||||
)
|
)
|
||||||
@ -1188,7 +1215,7 @@ export default function NotesPage() {
|
|||||||
? 'Enregistrer'
|
? 'Enregistrer'
|
||||||
: 'Publier'}
|
: 'Publier'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -1216,7 +1216,9 @@ const TimeClockPage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setConflictDate(
|
setConflictDate(
|
||||||
scanError.scanDate || getTodayValue(),
|
scanError.conflict === 'open_entry'
|
||||||
|
? (scanError.entryDate || scanError.scanDate || getTodayValue())
|
||||||
|
: (scanError.scanDate || getTodayValue()),
|
||||||
);
|
);
|
||||||
|
|
||||||
setScanConflict({
|
setScanConflict({
|
||||||
@ -1232,6 +1234,7 @@ const TimeClockPage = () => {
|
|||||||
scanTime:
|
scanTime:
|
||||||
scanError.scanTime,
|
scanError.scanTime,
|
||||||
scanDate: scanError.scanDate,
|
scanDate: scanError.scanDate,
|
||||||
|
entryDate: scanError.entryDate,
|
||||||
entryId: scanError.entryId,
|
entryId: scanError.entryId,
|
||||||
step: 'confirm',
|
step: 'confirm',
|
||||||
});
|
});
|
||||||
@ -1371,7 +1374,8 @@ const TimeClockPage = () => {
|
|||||||
resolution:
|
resolution:
|
||||||
config.resolution,
|
config.resolution,
|
||||||
time: conflictTime,
|
time: conflictTime,
|
||||||
scan_date: conflictDate,
|
scan_date: scanConflict.scanDate,
|
||||||
|
correction_date: conflictDate,
|
||||||
entry_id: scanConflict.entryId,
|
entry_id: scanConflict.entryId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
178
frontend/src/pages/UserStatisticsPage.jsx
Normal file
178
frontend/src/pages/UserStatisticsPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,9 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { timeApi } from '../api/time';
|
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 { useAuth } from '../context/useAuth';
|
||||||
import LoadingCard from '@/components/LoadingCard';
|
import LoadingCard from '@/components/LoadingCard';
|
||||||
import MainCard from '@/components/MainCard';
|
import MainCard from '@/components/MainCard';
|
||||||
@ -32,7 +35,8 @@ const expandOvernightAttendances = (entries) => {
|
|||||||
if (entry.start_time && !entry.end_time) {
|
if (entry.start_time && !entry.end_time) {
|
||||||
return {
|
return {
|
||||||
...entry,
|
...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 [attendances, setAttendances] = useState([]);
|
||||||
const [users, setUsers] = 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 [selectedUserId, setSelectedUserId] = useState('');
|
||||||
const [showAllUsers, setShowAllUsers] = useState(false);
|
const [showAllUsers, setShowAllUsers] = useState(false);
|
||||||
|
|
||||||
@ -397,6 +404,34 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
loadAttendances();
|
loadAttendances();
|
||||||
}, [isAdmin, userId]);
|
}, [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
|
* Responsive
|
||||||
@ -530,6 +565,8 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
currentWeek,
|
currentWeek,
|
||||||
selectedDayIndex,
|
selectedDayIndex,
|
||||||
attendances,
|
attendances,
|
||||||
|
planningLoading,
|
||||||
|
planningError,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -630,7 +667,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
minutes,
|
minutes,
|
||||||
) => {
|
) => {
|
||||||
if (minutes === 1440) {
|
if (minutes === 1440) {
|
||||||
return '23h59';
|
return '24h00';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${String(
|
return `${String(
|
||||||
@ -771,7 +808,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const times =
|
const times =
|
||||||
visibleAttendances
|
[...visibleAttendances, ...plannedSegments.filter((segment) => visibleDateKeys.has(segment.date))]
|
||||||
.flatMap(
|
.flatMap(
|
||||||
(attendance) => [
|
(attendance) => [
|
||||||
getMinutes(
|
getMinutes(
|
||||||
@ -1152,14 +1189,99 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
color: user.color,
|
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 (
|
return (
|
||||||
<MainCard
|
<MainCard
|
||||||
icon={CalendarDays}
|
icon={CalendarDays}
|
||||||
className={
|
className="overflow-hidden max-md:[&>[data-slot=card-header]]:flex-wrap"
|
||||||
isMobile
|
|
||||||
? 'overflow-hidden'
|
|
||||||
: 'overflow-hidden'
|
|
||||||
}
|
|
||||||
title={
|
title={
|
||||||
isAdmin ? (
|
isAdmin ? (
|
||||||
<>
|
<>
|
||||||
@ -1225,6 +1347,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigateDay(-1)}
|
onClick={() => navigateDay(-1)}
|
||||||
|
aria-label="Jour précédent"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@ -1232,7 +1355,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
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(
|
{weekDays[selectedDayIndex].toLocaleDateString(
|
||||||
'fr-FR',
|
'fr-FR',
|
||||||
@ -1248,6 +1371,7 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigateDay(1)}
|
onClick={() => navigateDay(1)}
|
||||||
|
aria-label="Jour suivant"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@ -1324,6 +1448,44 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
</div>
|
</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 */}
|
{/* Légende */}
|
||||||
{isAdmin &&
|
{isAdmin &&
|
||||||
showAllUsers &&
|
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 */}
|
{/* Tous les utilisateurs */}
|
||||||
{isAdmin &&
|
{isAdmin &&
|
||||||
showAllUsers ? (
|
showAllUsers ? (
|
||||||
@ -1624,13 +1788,14 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
],
|
],
|
||||||
)}-${start}-${end}-${index}`
|
)}-${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={
|
||||||
style
|
style
|
||||||
}
|
}
|
||||||
onMouseEnter={(event) =>
|
onMouseEnter={(event) =>
|
||||||
showHoverInfo({
|
showHoverInfo({
|
||||||
event,
|
event,
|
||||||
|
description: attendance.display_incomplete ? 'Pointage en cours ou sortie non renseignée' : 'Heures pointées',
|
||||||
title:
|
title:
|
||||||
getUserLabel(
|
getUserLabel(
|
||||||
attendanceUser,
|
attendanceUser,
|
||||||
@ -1693,20 +1858,12 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
|
|
||||||
{/* Jours */}
|
{/* Jours */}
|
||||||
{weekDays.map(
|
{weekDays.map(
|
||||||
(
|
(day) => (
|
||||||
day,
|
|
||||||
dayIndex,
|
|
||||||
) => (
|
|
||||||
<div
|
<div
|
||||||
key={formatDateKey(
|
key={formatDateKey(
|
||||||
day,
|
day,
|
||||||
)}
|
)}
|
||||||
className={`flex items-center justify-center border-b border-r bg-background p-2 text-center font-medium ${
|
className={`flex items-center justify-center border-b border-r bg-background p-2 text-center font-medium ${formatDateKey(day) === dateKey() ? 'bg-muted' : ''}`}
|
||||||
dayIndex ===
|
|
||||||
selectedDayIndex
|
|
||||||
? 'bg-muted'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
height: `${SCHEDULE_HEADER_HEIGHT}px`,
|
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 */}
|
{/* Tous les utilisateurs */}
|
||||||
{isAdmin &&
|
{isAdmin &&
|
||||||
showAllUsers ? (
|
showAllUsers ? (
|
||||||
@ -1944,13 +2103,14 @@ const WeeklySummaryPage = ({ userId }) => {
|
|||||||
day,
|
day,
|
||||||
)}-${start}-${end}-${index}`
|
)}-${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={
|
||||||
style
|
style
|
||||||
}
|
}
|
||||||
onMouseEnter={(event) =>
|
onMouseEnter={(event) =>
|
||||||
showHoverInfo({
|
showHoverInfo({
|
||||||
event,
|
event,
|
||||||
|
description: attendance.display_incomplete ? 'Pointage en cours ou sortie non renseignée' : 'Heures pointées',
|
||||||
title:
|
title:
|
||||||
getUserLabel(
|
getUserLabel(
|
||||||
attendanceUser,
|
attendanceUser,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user