init
This commit is contained in:
commit
09ff6dd430
23
.env.example
Normal file
23
.env.example
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Database Configuration
|
||||||
|
MYSQL_HOST=172.18.0.1
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_DATABASE=planning
|
||||||
|
MYSQL_USER=planning
|
||||||
|
MYSQL_PASSWORD=change-me
|
||||||
|
MYSQL_ROOT_PASSWORD=change-me
|
||||||
|
|
||||||
|
# Django
|
||||||
|
SECRET_KEY=change-me
|
||||||
|
DEBUG=False
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
|
ADMIN_FIRST_NAME=Admin
|
||||||
|
ADMIN_LAST_NAME=
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,192.168.1.19,planning.example.com
|
||||||
|
CSRF_TRUSTED_ORIGINS=http://localhost:1025,http://localhost:1031,http://192.168.1.19:1025,http://192.168.1.19:1031,https://planning.example.com
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:1025,http://192.168.1.19:1025,https://planning.example.com
|
||||||
|
PUBLIC_SITE_URL=https://planning.example.com
|
||||||
|
|
||||||
|
# Notifications (optional)
|
||||||
|
NTFY_SERVER=https://ntfy.sh
|
||||||
|
NTFY_TOPIC=
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Secrets / environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
127
README.md
Normal file
127
README.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# Planning
|
||||||
|
|
||||||
|
Petit site de planning public + demandes de disponibilité, avec une administration séparée.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
### Public
|
||||||
|
- consulter le planning semaine par semaine ;
|
||||||
|
- voir l'état prévu : **Disponible**, **Occupé** ou **Indisponible** ;
|
||||||
|
- envoyer une demande de disponibilité immédiate sans compte ;
|
||||||
|
- suivre la réponse automatiquement avec un lien/token privé ;
|
||||||
|
- voir une durée de disponibilité si l'admin en indique une.
|
||||||
|
|
||||||
|
### Administration
|
||||||
|
- connexion par compte admin ;
|
||||||
|
- créer une plage sur un jour ou plusieurs jours ;
|
||||||
|
- modifier/supprimer les plages directement depuis le planning ;
|
||||||
|
- recevoir et traiter les demandes ;
|
||||||
|
- accepter pour 15 min, 30 min, 1 h, une durée personnalisée, ou sans durée ;
|
||||||
|
- refuser avec un message optionnel ;
|
||||||
|
- actualisation automatique des demandes toutes les 15 secondes.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Frontend : React + Vite + Tailwind CSS
|
||||||
|
- Backend : Django + Django REST Framework + JWT
|
||||||
|
- Base : MariaDB/MySQL externe
|
||||||
|
- Reverse proxy du frontend : Nginx
|
||||||
|
- Notifications optionnelles : ntfy
|
||||||
|
|
||||||
|
## Ports
|
||||||
|
- Frontend : `1025`
|
||||||
|
- Backend : `1031`
|
||||||
|
|
||||||
|
Le frontend proxy automatiquement `/api/` vers le backend. Pour le domaine public, le reverse proxy principal peut donc pointer uniquement sur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:1025
|
||||||
|
```
|
||||||
|
|
||||||
|
Le backend reste aussi directement disponible sur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:1031
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lancement
|
||||||
|
|
||||||
|
Le fichier `.env` fourni au déploiement contient la configuration réelle. Il est volontairement ignoré par Git.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f backend
|
||||||
|
docker compose logs -f frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Mise à jour :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compte admin
|
||||||
|
|
||||||
|
Au démarrage du backend, les variables suivantes créent ou mettent à jour automatiquement le compte admin :
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_USERNAME=...
|
||||||
|
ADMIN_PASSWORD=...
|
||||||
|
ADMIN_FIRST_NAME=...
|
||||||
|
ADMIN_LAST_NAME=...
|
||||||
|
```
|
||||||
|
|
||||||
|
L'administration est accessible sur :
|
||||||
|
|
||||||
|
```text
|
||||||
|
/admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notifications ntfy
|
||||||
|
|
||||||
|
Le site fonctionne même sans notification externe.
|
||||||
|
|
||||||
|
Pour activer les notifications :
|
||||||
|
1. installer l'app **ntfy** sur le téléphone ;
|
||||||
|
2. choisir un nom de topic difficile à deviner ;
|
||||||
|
3. s'abonner à ce topic dans l'app ;
|
||||||
|
4. renseigner ce même topic dans `.env` :
|
||||||
|
|
||||||
|
```env
|
||||||
|
NTFY_SERVER=https://ntfy.sh
|
||||||
|
NTFY_TOPIC=mon-topic-prive
|
||||||
|
```
|
||||||
|
|
||||||
|
5. relancer le backend :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Une nouvelle demande publique déclenche alors une notification avec un lien vers `/admin/requests`.
|
||||||
|
|
||||||
|
## Gitea
|
||||||
|
|
||||||
|
Dépôt prévu :
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://gitea.louismazin.ovh/LouisMazin/Planning
|
||||||
|
```
|
||||||
|
|
||||||
|
Premier push depuis le dossier du projet :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
git branch -M main
|
||||||
|
git remote add origin https://gitea.louismazin.ovh/LouisMazin/Planning.git
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial planning app"
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
> `.env` n'est pas envoyé sur Git grâce au `.gitignore`. Garde-le uniquement sur le serveur.
|
||||||
9
backend/Dockerfile
Normal file
9
backend/Dockerfile
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
0
backend/core/__init__.py
Normal file
0
backend/core/__init__.py
Normal file
4
backend/core/apps.py
Normal file
4
backend/core/apps.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "core"
|
||||||
0
backend/core/management/__init__.py
Normal file
0
backend/core/management/__init__.py
Normal file
0
backend/core/management/commands/__init__.py
Normal file
0
backend/core/management/commands/__init__.py
Normal file
16
backend/core/management/commands/ensure_admin.py
Normal file
16
backend/core/management/commands/ensure_admin.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import os
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
User = get_user_model()
|
||||||
|
username = os.getenv("ADMIN_USERNAME", "admin")
|
||||||
|
user, _ = User.objects.get_or_create(username=username)
|
||||||
|
user.first_name = os.getenv("ADMIN_FIRST_NAME", "Admin")
|
||||||
|
user.last_name = os.getenv("ADMIN_LAST_NAME", "")
|
||||||
|
user.is_staff = True
|
||||||
|
user.is_superuser = True
|
||||||
|
user.is_active = True
|
||||||
|
user.set_password(os.getenv("ADMIN_PASSWORD", "admin123"))
|
||||||
|
user.save()
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Admin ready: {username}"))
|
||||||
14
backend/core/management/commands/wait_for_db.py
Normal file
14
backend/core/management/commands/wait_for_db.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import time
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import connections
|
||||||
|
from django.db.utils import OperationalError
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
for _ in range(60):
|
||||||
|
try:
|
||||||
|
connections["default"].cursor()
|
||||||
|
self.stdout.write(self.style.SUCCESS("Database available"))
|
||||||
|
return
|
||||||
|
except OperationalError:
|
||||||
|
time.sleep(2)
|
||||||
|
raise OperationalError("Database unavailable after 120 seconds")
|
||||||
35
backend/core/migrations/0001_initial.py
Normal file
35
backend/core/migrations/0001_initial.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import uuid
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
dependencies = []
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(name="ScheduleEntry", fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("title", models.CharField(blank=True, max_length=120)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
("start_date", models.DateField()),
|
||||||
|
("end_date", models.DateField()),
|
||||||
|
("start_time", models.TimeField()),
|
||||||
|
("end_time", models.TimeField()),
|
||||||
|
("status", models.CharField(choices=[("busy","Occupé"),("free","Disponible"),("unavailable","Indisponible")], default="busy", max_length=20)),
|
||||||
|
("color", models.CharField(blank=True, default="", max_length=16)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
], options={"ordering":["start_date","start_time"]}),
|
||||||
|
migrations.CreateModel(name="AvailabilityRequest", fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("public_token", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
("guest_name", models.CharField(max_length=80)),
|
||||||
|
("message", models.CharField(blank=True, max_length=500)),
|
||||||
|
("status", models.CharField(choices=[("pending","En attente"),("accepted","Acceptée"),("refused","Refusée")], default="pending", max_length=20)),
|
||||||
|
("duration_minutes", models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
("available_until", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("response_message", models.CharField(blank=True, max_length=500)),
|
||||||
|
("requested_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("responded_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("requester_ip", models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
], options={"ordering":["-requested_at"]}),
|
||||||
|
migrations.AddIndex(model_name="scheduleentry", index=models.Index(fields=["start_date","end_date"], name="core_sched_dates_idx")),
|
||||||
|
migrations.AddIndex(model_name="availabilityrequest", index=models.Index(fields=["status","requested_at"], name="core_req_status_idx")),
|
||||||
|
]
|
||||||
0
backend/core/migrations/__init__.py
Normal file
0
backend/core/migrations/__init__.py
Normal file
34
backend/core/models.py
Normal file
34
backend/core/models.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
class ScheduleEntry(models.Model):
|
||||||
|
STATUS_CHOICES = [("busy", "Occupé"), ("free", "Disponible"), ("unavailable", "Indisponible")]
|
||||||
|
title = models.CharField(max_length=120, blank=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField()
|
||||||
|
start_time = models.TimeField()
|
||||||
|
end_time = models.TimeField()
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="busy")
|
||||||
|
color = models.CharField(max_length=16, blank=True, default="")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
class Meta:
|
||||||
|
ordering = ["start_date", "start_time"]
|
||||||
|
indexes = [models.Index(fields=["start_date", "end_date"], name="core_sched_dates_idx")]
|
||||||
|
|
||||||
|
class AvailabilityRequest(models.Model):
|
||||||
|
STATUS_CHOICES = [("pending", "En attente"), ("accepted", "Acceptée"), ("refused", "Refusée")]
|
||||||
|
public_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
guest_name = models.CharField(max_length=80)
|
||||||
|
message = models.CharField(max_length=500, blank=True)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
|
||||||
|
duration_minutes = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
available_until = models.DateTimeField(null=True, blank=True)
|
||||||
|
response_message = models.CharField(max_length=500, blank=True)
|
||||||
|
requested_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
responded_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
requester_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-requested_at"]
|
||||||
|
indexes = [models.Index(fields=["status", "requested_at"], name="core_req_status_idx")]
|
||||||
16
backend/core/notifications.py
Normal file
16
backend/core/notifications.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import os, requests
|
||||||
|
|
||||||
|
def send_ntfy_notification(title, message, click_url=None):
|
||||||
|
topic = os.getenv("NTFY_TOPIC", "").strip()
|
||||||
|
if not topic:
|
||||||
|
return False
|
||||||
|
server = os.getenv("NTFY_SERVER", "https://ntfy.sh").rstrip("/")
|
||||||
|
headers = {"Title": title, "Priority": "high", "Tags": "calendar,question"}
|
||||||
|
if click_url:
|
||||||
|
headers["Click"] = click_url
|
||||||
|
try:
|
||||||
|
r = requests.post(f"{server}/{topic}", data=message.encode("utf-8"), headers=headers, timeout=8)
|
||||||
|
r.raise_for_status()
|
||||||
|
return True
|
||||||
|
except requests.RequestException:
|
||||||
|
return False
|
||||||
44
backend/core/serializers.py
Normal file
44
backend/core/serializers.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import AvailabilityRequest, ScheduleEntry
|
||||||
|
|
||||||
|
class ScheduleEntrySerializer(serializers.ModelSerializer):
|
||||||
|
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||||||
|
class Meta:
|
||||||
|
model = ScheduleEntry
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = ["created_at", "updated_at"]
|
||||||
|
def validate(self, attrs):
|
||||||
|
sd = attrs.get("start_date", getattr(self.instance, "start_date", None))
|
||||||
|
ed = attrs.get("end_date", getattr(self.instance, "end_date", None))
|
||||||
|
st = attrs.get("start_time", getattr(self.instance, "start_time", None))
|
||||||
|
et = attrs.get("end_time", getattr(self.instance, "end_time", None))
|
||||||
|
if sd and ed and ed < sd:
|
||||||
|
raise serializers.ValidationError({"end_date": "La date de fin doit être après la date de début."})
|
||||||
|
if st and et and et <= st:
|
||||||
|
raise serializers.ValidationError({"end_time": "L'heure de fin doit être après l'heure de début."})
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
class AvailabilityRequestCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = AvailabilityRequest
|
||||||
|
fields = ["guest_name", "message"]
|
||||||
|
def validate_guest_name(self, value):
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) < 2:
|
||||||
|
raise serializers.ValidationError("Indique ton prénom ou ton nom.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class AvailabilityRequestPublicSerializer(serializers.ModelSerializer):
|
||||||
|
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
||||||
|
class Meta:
|
||||||
|
model = AvailabilityRequest
|
||||||
|
fields = ["public_token", "guest_name", "message", "status", "status_label", "duration_minutes", "available_until", "response_message", "requested_at", "responded_at"]
|
||||||
|
|
||||||
|
class AvailabilityRequestAdminSerializer(AvailabilityRequestPublicSerializer):
|
||||||
|
class Meta(AvailabilityRequestPublicSerializer.Meta):
|
||||||
|
fields = ["id"] + AvailabilityRequestPublicSerializer.Meta.fields
|
||||||
|
|
||||||
|
class AvailabilityResponseSerializer(serializers.Serializer):
|
||||||
|
status = serializers.ChoiceField(choices=["accepted", "refused"])
|
||||||
|
duration_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=1, max_value=1440)
|
||||||
|
response_message = serializers.CharField(required=False, allow_blank=True, max_length=500)
|
||||||
14
backend/core/urls.py
Normal file
14
backend/core/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import *
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"admin/schedule", AdminScheduleViewSet, basename="admin-schedule")
|
||||||
|
router.register(r"admin/requests", AdminAvailabilityRequestViewSet, basename="admin-requests")
|
||||||
|
urlpatterns = [
|
||||||
|
path("public/schedule/", PublicScheduleView.as_view()),
|
||||||
|
path("public/requests/", PublicAvailabilityRequestCreateView.as_view()),
|
||||||
|
path("public/requests/<uuid:token>/", PublicAvailabilityRequestDetailView.as_view()),
|
||||||
|
path("admin/me/", AdminMeView.as_view()),
|
||||||
|
path("admin/requests/<int:pk>/respond/", AdminAvailabilityRequestViewSet.as_view({"post": "respond"})),
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
98
backend/core/views.py
Normal file
98
backend/core/views.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
import os
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.exceptions import Throttled
|
||||||
|
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
from .models import AvailabilityRequest, ScheduleEntry
|
||||||
|
from .notifications import send_ntfy_notification
|
||||||
|
from .serializers import *
|
||||||
|
|
||||||
|
def client_ip(request):
|
||||||
|
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
return forwarded.split(",")[0].strip() if forwarded else request.META.get("REMOTE_ADDR")
|
||||||
|
|
||||||
|
class AdminTokenSerializer(TokenObtainPairSerializer):
|
||||||
|
def validate(self, attrs):
|
||||||
|
data = super().validate(attrs)
|
||||||
|
if not self.user.is_staff:
|
||||||
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
raise AuthenticationFailed("Compte administrateur requis.")
|
||||||
|
data["user"] = {"username": self.user.username, "first_name": self.user.first_name, "last_name": self.user.last_name, "is_staff": True}
|
||||||
|
return data
|
||||||
|
|
||||||
|
class AdminTokenView(TokenObtainPairView):
|
||||||
|
serializer_class = AdminTokenSerializer
|
||||||
|
|
||||||
|
class PublicScheduleView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
def get(self, request):
|
||||||
|
qs = ScheduleEntry.objects.all()
|
||||||
|
start, end = request.query_params.get("start"), request.query_params.get("end")
|
||||||
|
if start and end:
|
||||||
|
qs = qs.filter(start_date__lte=end, end_date__gte=start)
|
||||||
|
return Response(ScheduleEntrySerializer(qs, many=True).data)
|
||||||
|
|
||||||
|
class PublicAvailabilityRequestCreateView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
def post(self, request):
|
||||||
|
ip = client_ip(request)
|
||||||
|
if ip and AvailabilityRequest.objects.filter(requester_ip=ip, requested_at__gte=timezone.now()-timedelta(seconds=45)).exists():
|
||||||
|
raise Throttled(detail="Une demande vient déjà d'être envoyée. Réessaie dans quelques secondes.")
|
||||||
|
s = AvailabilityRequestCreateSerializer(data=request.data)
|
||||||
|
s.is_valid(raise_exception=True)
|
||||||
|
item = s.save(requester_ip=ip)
|
||||||
|
site = os.getenv("PUBLIC_SITE_URL", "").rstrip("/")
|
||||||
|
msg = f"{item.guest_name} demande si tu es disponible maintenant."
|
||||||
|
if item.message:
|
||||||
|
msg += f"\n{item.message}"
|
||||||
|
send_ntfy_notification("Nouvelle demande de disponibilité", msg, f"{site}/admin/requests" if site else None)
|
||||||
|
return Response(AvailabilityRequestPublicSerializer(item).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
class PublicAvailabilityRequestDetailView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
def get(self, request, token):
|
||||||
|
try:
|
||||||
|
item = AvailabilityRequest.objects.get(public_token=token)
|
||||||
|
except AvailabilityRequest.DoesNotExist:
|
||||||
|
return Response({"detail": "Demande introuvable."}, status=404)
|
||||||
|
return Response(AvailabilityRequestPublicSerializer(item).data)
|
||||||
|
|
||||||
|
class AdminScheduleViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = ScheduleEntrySerializer
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = ScheduleEntry.objects.all()
|
||||||
|
start, end = self.request.query_params.get("start"), self.request.query_params.get("end")
|
||||||
|
return qs.filter(start_date__lte=end, end_date__gte=start) if start and end else qs
|
||||||
|
|
||||||
|
class AdminAvailabilityRequestViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = AvailabilityRequestAdminSerializer
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = AvailabilityRequest.objects.all()
|
||||||
|
status_filter = self.request.query_params.get("status")
|
||||||
|
return qs.filter(status=status_filter) if status_filter else qs
|
||||||
|
def respond(self, request, pk=None):
|
||||||
|
item = self.get_object()
|
||||||
|
s = AvailabilityResponseSerializer(data=request.data)
|
||||||
|
s.is_valid(raise_exception=True)
|
||||||
|
new_status = s.validated_data["status"]
|
||||||
|
duration = s.validated_data.get("duration_minutes")
|
||||||
|
item.status = new_status
|
||||||
|
item.duration_minutes = duration if new_status == "accepted" else None
|
||||||
|
item.available_until = timezone.now()+timedelta(minutes=duration) if new_status == "accepted" and duration else None
|
||||||
|
item.response_message = s.validated_data.get("response_message", "")
|
||||||
|
item.responded_at = timezone.now()
|
||||||
|
item.save()
|
||||||
|
return Response(AvailabilityRequestAdminSerializer(item).data)
|
||||||
|
|
||||||
|
class AdminMeView(APIView):
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
def get(self, request):
|
||||||
|
u = request.user
|
||||||
|
return Response({"username": u.username, "first_name": u.first_name, "last_name": u.last_name, "is_staff": u.is_staff})
|
||||||
6
backend/entrypoint.sh
Normal file
6
backend/entrypoint.sh
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
python manage.py wait_for_db
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py ensure_admin
|
||||||
|
exec gunicorn planning.wsgi:application --bind 0.0.0.0:8000 --workers 2 --threads 4 --timeout 60 --access-logfile - --error-logfile -
|
||||||
6
backend/manage.py
Normal file
6
backend/manage.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os, sys
|
||||||
|
if __name__ == "__main__":
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "planning.settings")
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
2
backend/planning/__init__.py
Normal file
2
backend/planning/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import pymysql
|
||||||
|
pymysql.install_as_MySQLdb()
|
||||||
72
backend/planning/settings.py
Normal file
72
backend/planning/settings.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from datetime import timedelta
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret")
|
||||||
|
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
|
||||||
|
|
||||||
|
def csv_env(name, default=""):
|
||||||
|
return [x.strip() for x in os.getenv(name, default).split(",") if x.strip()]
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = csv_env("ALLOWED_HOSTS", "localhost,127.0.0.1")
|
||||||
|
CSRF_TRUSTED_ORIGINS = csv_env("CSRF_TRUSTED_ORIGINS")
|
||||||
|
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions",
|
||||||
|
"django.contrib.messages", "django.contrib.staticfiles", "corsheaders",
|
||||||
|
"rest_framework", "core",
|
||||||
|
]
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
ROOT_URLCONF = "planning.urls"
|
||||||
|
TEMPLATES = [{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [], "APP_DIRS": True,
|
||||||
|
"OPTIONS": {"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
]},
|
||||||
|
}]
|
||||||
|
WSGI_APPLICATION = "planning.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = {"default": {
|
||||||
|
"ENGINE": "django.db.backends.mysql",
|
||||||
|
"NAME": os.getenv("MYSQL_DATABASE", "planning"),
|
||||||
|
"USER": os.getenv("MYSQL_USER", "planning"),
|
||||||
|
"PASSWORD": os.getenv("MYSQL_PASSWORD", ""),
|
||||||
|
"HOST": os.getenv("MYSQL_HOST", "127.0.0.1"),
|
||||||
|
"PORT": os.getenv("MYSQL_PORT", "3306"),
|
||||||
|
"OPTIONS": {"charset": "utf8mb4", "init_command": "SET sql_mode='STRICT_TRANS_TABLES'"},
|
||||||
|
}}
|
||||||
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
LANGUAGE_CODE = "fr-fr"
|
||||||
|
TIME_ZONE = "Europe/Paris"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||||
|
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||||
|
}
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
"ACCESS_TOKEN_LIFETIME": timedelta(hours=12),
|
||||||
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=14),
|
||||||
|
"ROTATE_REFRESH_TOKENS": True,
|
||||||
|
}
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
SESSION_COOKIE_SECURE = not DEBUG
|
||||||
|
CSRF_COOKIE_SECURE = not DEBUG
|
||||||
8
backend/planning/urls.py
Normal file
8
backend/planning/urls.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
|
from core.views import AdminTokenView
|
||||||
|
urlpatterns = [
|
||||||
|
path("api/auth/token/", AdminTokenView.as_view()),
|
||||||
|
path("api/auth/token/refresh/", TokenRefreshView.as_view()),
|
||||||
|
path("api/", include("core.urls")),
|
||||||
|
]
|
||||||
4
backend/planning/wsgi.py
Normal file
4
backend/planning/wsgi.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "planning.settings")
|
||||||
|
application = get_wsgi_application()
|
||||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Django==5.2.7
|
||||||
|
djangorestframework==3.16.1
|
||||||
|
djangorestframework-simplejwt==5.5.1
|
||||||
|
django-cors-headers==4.9.0
|
||||||
|
gunicorn==23.0.0
|
||||||
|
PyMySQL==1.1.2
|
||||||
|
requests==2.32.5
|
||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: planning-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "1031:8000"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: planning-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "1025:80"
|
||||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<title>Planning</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
frontend/nginx.conf
Normal file
25
frontend/nginx.conf
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
map $http_x_forwarded_proto $planning_forwarded_proto {
|
||||||
|
default $http_x_forwarded_proto;
|
||||||
|
"" $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $planning_forwarded_proto;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "planning-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^7.9.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"vite": "^7.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
frontend/src/App.jsx
Normal file
64
frontend/src/App.jsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
|
import { authApi } from './api';
|
||||||
|
import { AdminLayout } from './components/layout';
|
||||||
|
import PublicPage from './pages/PublicPage';
|
||||||
|
import AdminLoginPage from './pages/AdminLoginPage';
|
||||||
|
import AdminPlanningPage from './pages/AdminPlanningPage';
|
||||||
|
import AdminRequestsPage from './pages/AdminRequestsPage';
|
||||||
|
|
||||||
|
function RequireAdmin({ children }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const [state, setState] = React.useState({ loading: true, ok: false });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (!localStorage.getItem('planning_access')) {
|
||||||
|
setState({ loading: false, ok: false });
|
||||||
|
return () => { active = false; };
|
||||||
|
}
|
||||||
|
|
||||||
|
authApi.me()
|
||||||
|
.then(() => active && setState({ loading: false, ok: true }))
|
||||||
|
.catch(() => {
|
||||||
|
authApi.logout();
|
||||||
|
if (active) setState({ loading: false, ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<span className="size-5 animate-spin rounded-full border-2 border-slate-300 border-t-amber-500" />
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.ok ? children : <Navigate to="/admin" replace state={{ from: location.pathname }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminRoute({ children }) {
|
||||||
|
return (
|
||||||
|
<RequireAdmin>
|
||||||
|
<AdminLayout>{children}</AdminLayout>
|
||||||
|
</RequireAdmin>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<PublicPage />} />
|
||||||
|
<Route path="/request/:token" element={<PublicPage />} />
|
||||||
|
<Route path="/admin" element={<AdminLoginPage />} />
|
||||||
|
<Route path="/admin/planning" element={<AdminRoute><AdminPlanningPage /></AdminRoute>} />
|
||||||
|
<Route path="/admin/requests" element={<AdminRoute><AdminRequestsPage /></AdminRoute>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/api/client.js
Normal file
23
frontend/src/api/client.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
const client = axios.create({ baseURL:'/api', timeout:15000 });
|
||||||
|
client.interceptors.request.use((config)=>{
|
||||||
|
const token=localStorage.getItem('planning_access');
|
||||||
|
if(token) config.headers.Authorization=`Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
client.interceptors.response.use(r=>r, async(error)=>{
|
||||||
|
const original=error.config;
|
||||||
|
if(error.response?.status===401 && !original?._retry && localStorage.getItem('planning_refresh')){
|
||||||
|
original._retry=true;
|
||||||
|
try{
|
||||||
|
const r=await axios.post('/api/auth/token/refresh/',{refresh:localStorage.getItem('planning_refresh')});
|
||||||
|
localStorage.setItem('planning_access',r.data.access);
|
||||||
|
original.headers.Authorization=`Bearer ${r.data.access}`;
|
||||||
|
return client(original);
|
||||||
|
}catch{
|
||||||
|
localStorage.removeItem('planning_access'); localStorage.removeItem('planning_refresh');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
export default client;
|
||||||
19
frontend/src/api/index.js
Normal file
19
frontend/src/api/index.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import client from './client';
|
||||||
|
export const publicApi={
|
||||||
|
schedule: async(start,end)=>(await client.get('/public/schedule/',{params:{start,end}})).data,
|
||||||
|
createRequest: async(payload)=>(await client.post('/public/requests/',payload)).data,
|
||||||
|
requestStatus: async(token)=>(await client.get(`/public/requests/${token}/`)).data,
|
||||||
|
};
|
||||||
|
export const authApi={
|
||||||
|
login: async(username,password)=>{const {data}=await client.post('/auth/token/',{username,password}); localStorage.setItem('planning_access',data.access); localStorage.setItem('planning_refresh',data.refresh); return data;},
|
||||||
|
me: async()=>(await client.get('/admin/me/')).data,
|
||||||
|
logout: ()=>{localStorage.removeItem('planning_access'); localStorage.removeItem('planning_refresh');},
|
||||||
|
};
|
||||||
|
export const adminApi={
|
||||||
|
schedule: async(start,end)=>(await client.get('/admin/schedule/',{params:{start,end}})).data,
|
||||||
|
createSchedule: async(p)=>(await client.post('/admin/schedule/',p)).data,
|
||||||
|
updateSchedule: async(id,p)=>(await client.put(`/admin/schedule/${id}/`,p)).data,
|
||||||
|
deleteSchedule: async(id)=>client.delete(`/admin/schedule/${id}/`),
|
||||||
|
requests: async()=>(await client.get('/admin/requests/')).data,
|
||||||
|
respond: async(id,p)=>(await client.post(`/admin/requests/${id}/respond/`,p)).data,
|
||||||
|
};
|
||||||
181
frontend/src/components/ScheduleWeek.jsx
Normal file
181
frontend/src/components/ScheduleWeek.jsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Badge, Card } from './ui';
|
||||||
|
import { dateKey } from '../utils';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
busy: {
|
||||||
|
label: 'Occupé',
|
||||||
|
tone: 'amber',
|
||||||
|
bar: 'bg-amber-500/85 border-amber-600/30 text-slate-950',
|
||||||
|
},
|
||||||
|
free: {
|
||||||
|
label: 'Disponible',
|
||||||
|
tone: 'green',
|
||||||
|
bar: 'bg-emerald-500/85 border-emerald-600/30 text-white',
|
||||||
|
},
|
||||||
|
unavailable: {
|
||||||
|
label: 'Indisponible',
|
||||||
|
tone: 'red',
|
||||||
|
bar: 'bg-red-500/85 border-red-600/30 text-white',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mins = (time) => {
|
||||||
|
const [h, m] = String(time).split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applies = (entry, day) =>
|
||||||
|
entry.start_date <= dateKey(day) && entry.end_date >= dateKey(day);
|
||||||
|
|
||||||
|
const ft = (time) => String(time).slice(0, 5);
|
||||||
|
|
||||||
|
export default function ScheduleWeek({ days, entries, editable = false, onEntryClick }) {
|
||||||
|
const { startHour, endHour } = React.useMemo(() => {
|
||||||
|
const visible = entries.filter((entry) => days.some((day) => applies(entry, day)));
|
||||||
|
if (!visible.length) return { startHour: 6, endHour: 22 };
|
||||||
|
|
||||||
|
const earliest = Math.min(...visible.map((entry) => Math.floor(mins(entry.start_time) / 60)));
|
||||||
|
const latest = Math.max(...visible.map((entry) => Math.ceil(mins(entry.end_time) / 60)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
startHour: Math.max(0, Math.min(6, earliest - 1)),
|
||||||
|
endHour: Math.min(24, Math.max(22, latest + 1)),
|
||||||
|
};
|
||||||
|
}, [days, entries]);
|
||||||
|
|
||||||
|
const hourCount = Math.max(endHour - startHour, 1);
|
||||||
|
const totalMinutes = hourCount * 60;
|
||||||
|
const desktopHeight = Math.max(hourCount * 45, 540);
|
||||||
|
|
||||||
|
const position = (entry) => {
|
||||||
|
const start = Math.max(mins(entry.start_time), startHour * 60);
|
||||||
|
const end = Math.min(mins(entry.end_time), endHour * 60);
|
||||||
|
return {
|
||||||
|
top: `${((start - startHour * 60) / totalMinutes) * 100}%`,
|
||||||
|
height: `${Math.max(((end - start) / totalMinutes) * 100, 2.2)}%`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="hidden min-w-[820px] grid-cols-[64px_repeat(7,minmax(0,1fr))] border-b border-slate-200 dark:border-slate-800 md:grid">
|
||||||
|
<div />
|
||||||
|
{days.map((day) => (
|
||||||
|
<div key={dateKey(day)} className="border-l border-slate-200 p-3 text-center dark:border-slate-800">
|
||||||
|
<div className="text-xs font-medium uppercase text-slate-400">
|
||||||
|
{day.toLocaleDateString('fr-FR', { weekday: 'short' })}
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold">
|
||||||
|
{day.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden min-w-[820px] grid-cols-[64px_repeat(7,minmax(0,1fr))] md:grid">
|
||||||
|
<div className="relative" style={{ height: `${desktopHeight}px` }}>
|
||||||
|
{Array.from({ length: hourCount + 1 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute right-2 -translate-y-1/2 text-[11px] text-slate-400"
|
||||||
|
style={{ top: `${(i / hourCount) * 100}%` }}
|
||||||
|
>
|
||||||
|
{String(startHour + i).padStart(2, '0')}:00
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{days.map((day) => (
|
||||||
|
<div
|
||||||
|
key={dateKey(day)}
|
||||||
|
className="relative border-l border-slate-200 dark:border-slate-800"
|
||||||
|
style={{
|
||||||
|
height: `${desktopHeight}px`,
|
||||||
|
backgroundImage: 'linear-gradient(to bottom, transparent calc(100% - 1px), rgba(148,163,184,0.16) calc(100% - 1px))',
|
||||||
|
backgroundSize: `100% ${100 / hourCount}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entries.filter((entry) => applies(entry, day)).map((entry) => {
|
||||||
|
const m = meta[entry.status] || meta.busy;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${entry.id}-${dateKey(day)}`}
|
||||||
|
disabled={!editable}
|
||||||
|
onClick={() => editable && onEntryClick?.(entry)}
|
||||||
|
className={clsx(
|
||||||
|
'absolute left-1.5 right-1.5 overflow-hidden rounded-lg border px-2 py-1 text-left text-xs shadow-sm',
|
||||||
|
m.bar,
|
||||||
|
editable && 'cursor-pointer hover:brightness-105',
|
||||||
|
)}
|
||||||
|
style={position(entry)}
|
||||||
|
title={`${entry.title || m.label} · ${ft(entry.start_time)}–${ft(entry.end_time)}`}
|
||||||
|
>
|
||||||
|
<div className="truncate font-semibold">{entry.title || m.label}</div>
|
||||||
|
<div className="truncate opacity-85">{ft(entry.start_time)}–{ft(entry.end_time)}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-200 dark:divide-slate-800 md:hidden">
|
||||||
|
{days.map((day) => {
|
||||||
|
const list = entries
|
||||||
|
.filter((entry) => applies(entry, day))
|
||||||
|
.sort((a, b) => a.start_time.localeCompare(b.start_time));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={dateKey(day)} className="p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold capitalize">
|
||||||
|
{day.toLocaleDateString('fr-FR', { weekday: 'long' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{day.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge>{list.length} plage{list.length > 1 ? 's' : ''}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!list.length ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-200 p-4 text-sm text-slate-400 dark:border-slate-800">
|
||||||
|
Rien de renseigné.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{list.map((entry) => {
|
||||||
|
const m = meta[entry.status] || meta.busy;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
disabled={!editable}
|
||||||
|
onClick={() => editable && onEntryClick?.(entry)}
|
||||||
|
className={clsx(
|
||||||
|
'flex w-full items-center justify-between gap-3 rounded-xl border border-slate-200 p-3 text-left dark:border-slate-800',
|
||||||
|
editable && 'hover:bg-slate-50 dark:hover:bg-slate-900',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{entry.title || m.label}</div>
|
||||||
|
{entry.description && <div className="truncate text-xs text-slate-500">{entry.description}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<Badge tone={m.tone}>{m.label}</Badge>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">{ft(entry.start_time)}–{ft(entry.end_time)}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/components/layout.jsx
Normal file
16
frontend/src/components/layout.jsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
import { CalendarDays, ClipboardList, LogOut, Menu, Wheat, X } from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Button } from './ui';
|
||||||
|
import { authApi } from '../api';
|
||||||
|
|
||||||
|
export function PublicHeader(){return <header className="border-b border-slate-200/70 bg-white/85 backdrop-blur dark:border-slate-800 dark:bg-slate-950/85"><div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4"><Link to="/" className="flex items-center gap-3"><span className="flex size-10 items-center justify-center rounded-xl bg-amber-500 text-slate-950"><Wheat className="size-5"/></span><div><div className="font-semibold leading-tight">Planning</div><div className="text-xs text-slate-500">Disponibilités</div></div></Link><Link to="/admin" className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800">Administration</Link></div></header>}
|
||||||
|
|
||||||
|
export function AdminLayout({children}){
|
||||||
|
const [open,setOpen]=React.useState(false); const navigate=useNavigate();
|
||||||
|
const logout=()=>{authApi.logout(); navigate('/admin')};
|
||||||
|
const nav=[['/admin/planning','Planning',CalendarDays],['/admin/requests','Demandes',ClipboardList]];
|
||||||
|
const sidebar=<div className="flex h-full flex-col"><div className="flex h-16 items-center gap-3 border-b border-slate-200 px-4 dark:border-slate-800"><span className="flex size-9 items-center justify-center rounded-xl bg-amber-500 text-slate-950"><Wheat className="size-5"/></span><div><div className="font-semibold">Planning</div><div className="text-xs text-slate-500">Administration</div></div></div><nav className="flex-1 space-y-1 p-3">{nav.map(([to,label,Icon])=><NavLink key={to} to={to} onClick={()=>setOpen(false)} className={({isActive})=>clsx('flex h-11 items-center gap-3 rounded-xl px-3 text-sm font-medium transition',isActive?'bg-amber-500 text-slate-950':'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800')}><Icon className="size-5"/>{label}</NavLink>)}</nav><div className="border-t border-slate-200 p-3 dark:border-slate-800"><Link to="/" className="mb-1 flex h-11 items-center gap-3 rounded-xl px-3 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"><Wheat className="size-5"/>Voir le site</Link><button onClick={logout} className="flex h-11 w-full items-center gap-3 rounded-xl px-3 text-sm font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30"><LogOut className="size-5"/>Déconnexion</button></div></div>;
|
||||||
|
return <div className="min-h-screen md:flex"><aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950 md:block">{sidebar}</aside>{open&&<div className="fixed inset-0 z-50 md:hidden"><button className="absolute inset-0 bg-slate-950/50" onClick={()=>setOpen(false)}/><aside className="relative h-full w-72 bg-white shadow-2xl dark:bg-slate-950"><button onClick={()=>setOpen(false)} className="absolute right-3 top-3 rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-800"><X className="size-5"/></button>{sidebar}</aside></div>}<main className="min-w-0 flex-1 md:ml-64"><div className="sticky top-0 z-30 flex h-14 items-center border-b border-slate-200 bg-white/90 px-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90 md:hidden"><Button variant="ghost" size="icon" onClick={()=>setOpen(true)}><Menu className="size-5"/></Button><span className="ml-2 font-semibold">Administration</span></div><div className="mx-auto max-w-7xl p-4 md:p-6">{children}</div></main></div>;
|
||||||
|
}
|
||||||
23
frontend/src/components/ui.jsx
Normal file
23
frontend/src/components/ui.jsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Button({children,className,variant='primary',size='md',type='button',...props}){
|
||||||
|
const v={
|
||||||
|
primary:'bg-amber-500 text-slate-950 hover:bg-amber-400 border-amber-500',
|
||||||
|
secondary:'bg-white text-slate-800 hover:bg-slate-50 border-slate-200 dark:bg-slate-900 dark:text-slate-100 dark:border-slate-700 dark:hover:bg-slate-800',
|
||||||
|
danger:'bg-red-600 text-white hover:bg-red-500 border-red-600',
|
||||||
|
ghost:'bg-transparent text-slate-700 hover:bg-slate-100 border-transparent dark:text-slate-200 dark:hover:bg-slate-800',
|
||||||
|
success:'bg-emerald-600 text-white hover:bg-emerald-500 border-emerald-600',
|
||||||
|
};
|
||||||
|
const s={sm:'h-9 px-3 text-sm',md:'h-10 px-4 text-sm',lg:'h-12 px-5 text-base',icon:'size-10 p-0'};
|
||||||
|
return <button type={type} className={clsx('inline-flex items-center justify-center gap-2 rounded-lg border font-medium transition disabled:pointer-events-none disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-amber-500/40',v[variant],s[size],className)} {...props}>{children}</button>;
|
||||||
|
}
|
||||||
|
export function Card({children,className}){return <div className={clsx('rounded-2xl border border-slate-200/80 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950',className)}>{children}</div>}
|
||||||
|
export function Badge({children,tone='slate'}){const t={slate:'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200',amber:'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300',green:'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300',red:'bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-300'}; return <span className={clsx('inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium',t[tone])}>{children}</span>}
|
||||||
|
export function Input({className,...props}){return <input className={clsx('h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-500/15 dark:border-slate-700 dark:bg-slate-900',className)} {...props}/>}
|
||||||
|
export function Textarea({className,...props}){return <textarea className={clsx('min-h-24 w-full resize-y rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-500/15 dark:border-slate-700 dark:bg-slate-900',className)} {...props}/>}
|
||||||
|
export function Select({className,...props}){return <select className={clsx('h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm outline-none focus:border-amber-500 dark:border-slate-700 dark:bg-slate-900',className)} {...props}/>}
|
||||||
|
export function Field({label,hint,children}){return <label className="grid gap-1.5 text-sm"><span className="font-medium text-slate-700 dark:text-slate-200">{label}</span>{children}{hint&&<span className="text-xs text-slate-500">{hint}</span>}</label>}
|
||||||
|
export function Modal({open,onClose,title,description,children}){if(!open)return null; return <div className="fixed inset-0 z-50 flex items-center justify-center p-4"><button className="absolute inset-0 bg-slate-950/55 backdrop-blur-[2px]" onClick={onClose}/><Card className="relative z-10 max-h-[92vh] w-full max-w-lg overflow-auto shadow-2xl"><div className="flex items-start justify-between gap-4 border-b border-slate-200 p-5 dark:border-slate-800"><div><h2 className="text-lg font-semibold">{title}</h2>{description&&<p className="mt-1 text-sm text-slate-500">{description}</p>}</div><Button variant="ghost" size="icon" onClick={onClose}><X className="size-5"/></Button></div><div className="p-5">{children}</div></Card></div>}
|
||||||
|
export function EmptyState({icon:Icon,title,description}){return <div className="flex min-h-44 flex-col items-center justify-center px-6 text-center">{Icon&&<Icon className="mb-3 size-8 text-slate-400"/>}<p className="font-medium">{title}</p>{description&&<p className="mt-1 max-w-md text-sm text-slate-500">{description}</p>}</div>}
|
||||||
20
frontend/src/index.css
Normal file
20
frontend/src/index.css
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #root { min-height: 100%; margin: 0; }
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
background: radial-gradient(circle at top left, rgba(245,158,11,.08), transparent 30rem), #f8fafc;
|
||||||
|
}
|
||||||
|
button, input, textarea, select { font: inherit; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { color: #e2e8f0; background: #020617; }
|
||||||
|
body { background: radial-gradient(circle at top left, rgba(245,158,11,.09), transparent 30rem), #020617; }
|
||||||
|
}
|
||||||
8
frontend/src/main.jsx
Normal file
8
frontend/src/main.jsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>
|
||||||
|
);
|
||||||
57
frontend/src/pages/AdminLoginPage.jsx
Normal file
57
frontend/src/pages/AdminLoginPage.jsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { KeyRound, LoaderCircle, LockKeyhole, UserRound, Wheat } from 'lucide-react';
|
||||||
|
import { authApi } from '../api';
|
||||||
|
import { apiError } from '../utils';
|
||||||
|
import { Button, Card, Field, Input } from '../components/ui';
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [form, setForm] = React.useState({ username: '', password: '' });
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState('');
|
||||||
|
|
||||||
|
if (localStorage.getItem('planning_access')) return <Navigate to="/admin/planning" replace />;
|
||||||
|
|
||||||
|
const submit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await authApi.login(form.username, form.password);
|
||||||
|
navigate(location.state?.from || '/admin/planning', { replace: true });
|
||||||
|
} catch (e) {
|
||||||
|
setError(apiError(e, 'Identifiants incorrects.'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="mb-5 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex size-14 items-center justify-center rounded-2xl bg-amber-500 text-slate-950 shadow-sm"><Wheat className="size-7" /></div>
|
||||||
|
<h1 className="text-2xl font-bold">Administration</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Planning & demandes de disponibilité</p>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<form onSubmit={submit} className="space-y-4 p-5 sm:p-6">
|
||||||
|
{error && <div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">{error}</div>}
|
||||||
|
<Field label="Nom d'utilisateur">
|
||||||
|
<div className="relative"><UserRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /><Input className="pl-9" autoComplete="username" required value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Mot de passe">
|
||||||
|
<div className="relative"><LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" /><Input className="pl-9" type="password" autoComplete="current-password" required value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /></div>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" size="lg" className="w-full" disabled={loading}>
|
||||||
|
{loading ? <LoaderCircle className="size-5 animate-spin" /> : <KeyRound className="size-5" />} Connexion
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
<button onClick={() => navigate('/')} className="mt-4 w-full text-center text-sm text-slate-500 hover:text-slate-800 dark:hover:text-slate-200">← Retour au planning public</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
frontend/src/pages/AdminPlanningPage.jsx
Normal file
168
frontend/src/pages/AdminPlanningPage.jsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
CalendarPlus,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
LoaderCircle,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { adminApi } from '../api';
|
||||||
|
import ScheduleWeek from '../components/ScheduleWeek';
|
||||||
|
import { Button, Card, Field, Input, Modal, Select, Textarea } from '../components/ui';
|
||||||
|
import { addDays, apiError, dateKey, startOfWeek, weekDays } from '../utils';
|
||||||
|
|
||||||
|
const emptyForm = (date) => ({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
start_time: '08:00',
|
||||||
|
end_time: '12:00',
|
||||||
|
status: 'busy',
|
||||||
|
color: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AdminPlanningPage() {
|
||||||
|
const [weekStart, setWeekStart] = React.useState(() => startOfWeek());
|
||||||
|
const days = React.useMemo(() => weekDays(weekStart), [weekStart]);
|
||||||
|
const [entries, setEntries] = React.useState([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState('');
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState(null);
|
||||||
|
const [form, setForm] = React.useState(() => emptyForm(dateKey(new Date())));
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
setEntries(await adminApi.schedule(dateKey(days[0]), dateKey(days[6])));
|
||||||
|
} catch (e) {
|
||||||
|
setError(apiError(e, 'Impossible de charger le planning.'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
React.useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
const today = new Date();
|
||||||
|
const defaultDate = today >= days[0] && today <= addDays(days[6], 1) ? dateKey(today) : dateKey(days[0]);
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm(defaultDate));
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (entry) => {
|
||||||
|
setEditing(entry);
|
||||||
|
setForm({
|
||||||
|
title: entry.title || '',
|
||||||
|
description: entry.description || '',
|
||||||
|
start_date: entry.start_date,
|
||||||
|
end_date: entry.end_date,
|
||||||
|
start_time: String(entry.start_time).slice(0, 5),
|
||||||
|
end_time: String(entry.end_time).slice(0, 5),
|
||||||
|
status: entry.status,
|
||||||
|
color: entry.color || '',
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
if (editing) await adminApi.updateSchedule(editing.id, form);
|
||||||
|
else await adminApi.createSchedule(form);
|
||||||
|
setDialogOpen(false);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(apiError(e, 'Impossible d’enregistrer cette plage.'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!editing || !window.confirm('Supprimer cette plage du planning ?')) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await adminApi.deleteSchedule(editing.id);
|
||||||
|
setDialogOpen(false);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError(apiError(e, 'Impossible de supprimer cette plage.'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-300"><CalendarPlus className="size-4" /> Administration</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Emploi du temps</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Ajoute une plage sur un seul jour ou sur plusieurs jours d’un coup.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}><Plus className="size-4" /> Ajouter une plage</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">{error}</div>}
|
||||||
|
|
||||||
|
<Card className="mb-4">
|
||||||
|
<div className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Semaine affichée</p>
|
||||||
|
<p className="text-sm text-slate-500">{days[0].toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })} → {days[6].toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" size="icon" onClick={() => setWeekStart((d) => addDays(d, -7))}><ChevronLeft className="size-5" /></Button>
|
||||||
|
<Button variant="secondary" onClick={() => setWeekStart(startOfWeek())}>Aujourd’hui</Button>
|
||||||
|
<Button variant="secondary" size="icon" onClick={() => setWeekStart((d) => addDays(d, 7))}><ChevronRight className="size-5" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card><div className="flex min-h-[520px] items-center justify-center"><LoaderCircle className="size-7 animate-spin text-amber-500" /></div></Card>
|
||||||
|
) : (
|
||||||
|
<ScheduleWeek days={days} entries={entries} editable onEntryClick={openEdit} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500"><Pencil className="size-3.5" /> Clique sur une plage pour la modifier.</div>
|
||||||
|
|
||||||
|
<Modal open={dialogOpen} onClose={() => !saving && setDialogOpen(false)} title={editing ? 'Modifier la plage' : 'Ajouter une plage'} description="La même plage sera répétée chaque jour entre la date de début et la date de fin.">
|
||||||
|
<form onSubmit={save} className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Du"><Input type="date" required value={form.start_date} onChange={(e) => setForm({ ...form, start_date: e.target.value, end_date: form.end_date < e.target.value ? e.target.value : form.end_date })} /></Field>
|
||||||
|
<Field label="Au"><Input type="date" required min={form.start_date} value={form.end_date} onChange={(e) => setForm({ ...form, end_date: e.target.value })} /></Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="De"><Input type="time" required value={form.start_time} onChange={(e) => setForm({ ...form, start_time: e.target.value })} /></Field>
|
||||||
|
<Field label="À"><Input type="time" required value={form.end_time} onChange={(e) => setForm({ ...form, end_time: e.target.value })} /></Field>
|
||||||
|
</div>
|
||||||
|
<Field label="État">
|
||||||
|
<Select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
|
||||||
|
<option value="busy">Occupé</option>
|
||||||
|
<option value="free">Disponible</option>
|
||||||
|
<option value="unavailable">Indisponible</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Titre" hint="Optionnel. Si vide, l’état sera utilisé comme titre."><Input maxLength={120} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="Ex. Travail, rendez-vous, déplacement…" /></Field>
|
||||||
|
<Field label="Description" hint="Optionnelle et visible publiquement."><Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Informations complémentaires" /></Field>
|
||||||
|
<div className="flex flex-wrap justify-between gap-2 pt-2">
|
||||||
|
<div>{editing && <Button variant="danger" onClick={remove} disabled={saving}><Trash2 className="size-4" /> Supprimer</Button>}</div>
|
||||||
|
<div className="flex gap-2"><Button variant="secondary" onClick={() => setDialogOpen(false)} disabled={saving}>Annuler</Button><Button type="submit" disabled={saving}>{saving && <LoaderCircle className="size-4 animate-spin" />}{editing ? 'Enregistrer' : 'Ajouter'}</Button></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
frontend/src/pages/AdminRequestsPage.jsx
Normal file
165
frontend/src/pages/AdminRequestsPage.jsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
Inbox,
|
||||||
|
LoaderCircle,
|
||||||
|
MessageSquareText,
|
||||||
|
RefreshCw,
|
||||||
|
TimerReset,
|
||||||
|
UserRound,
|
||||||
|
X,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { adminApi } from '../api';
|
||||||
|
import { Badge, Button, Card, EmptyState, Field, Input, Modal, Textarea } from '../components/ui';
|
||||||
|
import { apiError, formatDateTime } from '../utils';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
pending: { label: 'En attente', tone: 'amber', icon: Clock3 },
|
||||||
|
accepted: { label: 'Acceptée', tone: 'green', icon: CheckCircle2 },
|
||||||
|
refused: { label: 'Refusée', tone: 'red', icon: XCircle },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminRequestsPage() {
|
||||||
|
const [items, setItems] = React.useState([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState('');
|
||||||
|
const [selected, setSelected] = React.useState(null);
|
||||||
|
const [responding, setResponding] = React.useState(false);
|
||||||
|
const [duration, setDuration] = React.useState('30');
|
||||||
|
const [message, setMessage] = React.useState('');
|
||||||
|
|
||||||
|
const load = React.useCallback(async (silent = false) => {
|
||||||
|
if (!silent) setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try { setItems(await adminApi.requests()); }
|
||||||
|
catch (e) { setError(apiError(e, 'Impossible de charger les demandes.')); }
|
||||||
|
finally { if (!silent) setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
load();
|
||||||
|
const timer = setInterval(() => load(true), 15000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const open = (item) => {
|
||||||
|
setSelected(item);
|
||||||
|
setDuration(item.duration_minutes ? String(item.duration_minutes) : '30');
|
||||||
|
setMessage(item.response_message || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const respond = async (status, customDuration = undefined) => {
|
||||||
|
if (!selected) return;
|
||||||
|
setResponding(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload = { status, response_message: message };
|
||||||
|
if (status === 'accepted') {
|
||||||
|
const value = customDuration === null ? null : Number(customDuration ?? duration);
|
||||||
|
payload.duration_minutes = Number.isFinite(value) && value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
await adminApi.respond(selected.id, payload);
|
||||||
|
setSelected(null);
|
||||||
|
await load(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(apiError(e, 'Impossible d’enregistrer la réponse.'));
|
||||||
|
} finally {
|
||||||
|
setResponding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = items.filter((x) => x.status === 'pending');
|
||||||
|
const history = items.filter((x) => x.status !== 'pending');
|
||||||
|
|
||||||
|
const RequestCard = ({ item }) => {
|
||||||
|
const m = meta[item.status];
|
||||||
|
const Icon = m.icon;
|
||||||
|
return (
|
||||||
|
<button onClick={() => open(item)} className="w-full text-left">
|
||||||
|
<Card className="transition hover:border-amber-400/70 hover:shadow-md">
|
||||||
|
<div className="p-4 sm:p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-900"><UserRound className="size-5" /></div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2"><p className="font-semibold">{item.guest_name}</p><Badge tone={m.tone}><Icon className="mr-1 size-3" />{m.label}</Badge></div>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{formatDateTime(item.requested_at)}</p>
|
||||||
|
{item.message && <p className="mt-3 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">{item.message}</p>}
|
||||||
|
{item.status === 'accepted' && <p className="mt-2 text-xs font-medium text-emerald-700 dark:text-emerald-300">{item.duration_minutes ? `Disponible ${item.duration_minutes} min` : 'Disponible sans durée précisée'}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-300"><Inbox className="size-4" /> Administration</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Demandes de disponibilité</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Les nouvelles demandes s’actualisent automatiquement toutes les 15 secondes.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={() => load()} disabled={loading}><RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} /> Actualiser</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card><div className="flex min-h-64 items-center justify-center"><LoaderCircle className="size-7 animate-spin text-amber-500" /></div></Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-7">
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex items-center gap-2"><h2 className="text-lg font-semibold">À traiter</h2><Badge tone={pending.length ? 'amber' : 'slate'}>{pending.length}</Badge></div>
|
||||||
|
{pending.length ? <div className="grid gap-3 lg:grid-cols-2">{pending.map((item) => <RequestCard key={item.id} item={item} />)}</div> : <Card><EmptyState icon={CheckCircle2} title="Aucune demande en attente" description="Les prochaines demandes apparaîtront ici automatiquement." /></Card>}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex items-center gap-2"><h2 className="text-lg font-semibold">Historique</h2><Badge>{history.length}</Badge></div>
|
||||||
|
{history.length ? <div className="grid gap-3 lg:grid-cols-2">{history.map((item) => <RequestCard key={item.id} item={item} />)}</div> : <Card><EmptyState icon={Inbox} title="Pas encore d’historique" /></Card>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal open={!!selected} onClose={() => !responding && setSelected(null)} title={selected?.guest_name || 'Demande'} description={selected ? `Envoyée le ${formatDateTime(selected.requested_at)}` : ''}>
|
||||||
|
{selected && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-xl bg-slate-50 p-4 dark:bg-slate-900">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-medium"><MessageSquareText className="size-4" /> Message</div>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300">{selected.message || 'Aucun message.'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.status === 'pending' ? (
|
||||||
|
<>
|
||||||
|
<Field label="Message de réponse" hint="Optionnel — visible par la personne qui a fait la demande."><Textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Ex. Oui, appelle-moi." /></Field>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Accepter pour combien de temps ?</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{[15, 30, 60].map((value) => <Button key={value} variant="secondary" onClick={() => respond('accepted', value)} disabled={responding}><TimerReset className="size-4" />{value} min</Button>)}
|
||||||
|
<Button variant="secondary" onClick={() => respond('accepted', null)} disabled={responding}>Sans limite</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-end gap-2">
|
||||||
|
<Field label="Durée personnalisée (min)"><Input type="number" min="1" max="1440" value={duration} onChange={(e) => setDuration(e.target.value)} /></Field>
|
||||||
|
<Button variant="success" onClick={() => respond('accepted')} disabled={responding || !Number(duration)}><Check className="size-4" /> Accepter</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-200 pt-4 dark:border-slate-800">
|
||||||
|
<Button variant="danger" className="w-full" onClick={() => respond('refused')} disabled={responding}><X className="size-4" /> Refuser</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2"><Badge tone={meta[selected.status].tone}>{meta[selected.status].label}</Badge>{selected.duration_minutes && <span className="text-sm text-slate-500">{selected.duration_minutes} min</span>}</div>
|
||||||
|
{selected.response_message && <div className="rounded-xl border border-slate-200 p-3 text-sm dark:border-slate-800">{selected.response_message}</div>}
|
||||||
|
<p className="text-xs text-slate-500">Réponse : {formatDateTime(selected.responded_at)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
292
frontend/src/pages/PublicPage.jsx
Normal file
292
frontend/src/pages/PublicPage.jsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
|
HelpCircle,
|
||||||
|
LoaderCircle,
|
||||||
|
MessageSquareText,
|
||||||
|
Send,
|
||||||
|
TimerReset,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { publicApi } from '../api';
|
||||||
|
import { PublicHeader } from '../components/layout';
|
||||||
|
import ScheduleWeek from '../components/ScheduleWeek';
|
||||||
|
import { Badge, Button, Card, Field, Input, Modal, Textarea } from '../components/ui';
|
||||||
|
import { addDays, apiError, dateKey, startOfWeek, weekDays } from '../utils';
|
||||||
|
|
||||||
|
const statusMeta = {
|
||||||
|
busy: { label: 'Occupé', tone: 'amber' },
|
||||||
|
free: { label: 'Disponible', tone: 'green' },
|
||||||
|
unavailable: { label: 'Indisponible', tone: 'red' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestMeta = {
|
||||||
|
pending: {
|
||||||
|
icon: LoaderCircle,
|
||||||
|
title: 'Demande envoyée',
|
||||||
|
text: 'Ta demande est en attente de réponse.',
|
||||||
|
tone: 'amber',
|
||||||
|
},
|
||||||
|
accepted: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
title: 'Disponible',
|
||||||
|
text: 'Ta demande a été acceptée.',
|
||||||
|
tone: 'green',
|
||||||
|
},
|
||||||
|
refused: {
|
||||||
|
icon: XCircle,
|
||||||
|
title: 'Pas disponible',
|
||||||
|
text: 'Ta demande a été refusée.',
|
||||||
|
tone: 'red',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function currentSchedule(entries) {
|
||||||
|
const now = new Date();
|
||||||
|
const today = dateKey(now);
|
||||||
|
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
return entries.find((entry) => {
|
||||||
|
const [sh, sm] = entry.start_time.split(':').map(Number);
|
||||||
|
const [eh, em] = entry.end_time.split(':').map(Number);
|
||||||
|
return entry.start_date <= today && entry.end_date >= today && minutes >= sh * 60 + sm && minutes < eh * 60 + em;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestStatus({ request, onClose }) {
|
||||||
|
const meta = requestMeta[request.status] || requestMeta.pending;
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const until = request.available_until ? new Date(request.available_until) : null;
|
||||||
|
const durationLabel = request.status === 'accepted' && request.duration_minutes
|
||||||
|
? `Pendant ${request.duration_minutes} min`
|
||||||
|
: request.status === 'accepted'
|
||||||
|
? 'Sans durée précisée'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="p-5 sm:p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`flex size-12 shrink-0 items-center justify-center rounded-2xl ${
|
||||||
|
meta.tone === 'green' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300' :
|
||||||
|
meta.tone === 'red' ? 'bg-red-100 text-red-700 dark:bg-red-950/60 dark:text-red-300' :
|
||||||
|
'bg-amber-100 text-amber-700 dark:bg-amber-950/60 dark:text-amber-300'
|
||||||
|
}`}>
|
||||||
|
<Icon className={`size-6 ${request.status === 'pending' ? 'animate-spin' : ''}`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">{meta.title}</h2>
|
||||||
|
<Badge tone={meta.tone}>{request.status_label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{meta.text}</p>
|
||||||
|
{durationLabel && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<TimerReset className="size-4 text-slate-400" />
|
||||||
|
{durationLabel}
|
||||||
|
{until && <span className="font-normal text-slate-500">· jusqu’à {until.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{request.response_message && (
|
||||||
|
<div className="mt-4 rounded-xl bg-slate-50 p-3 text-sm dark:bg-slate-900">
|
||||||
|
<div className="mb-1 flex items-center gap-2 font-medium"><MessageSquareText className="size-4" /> Réponse</div>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300">{request.response_message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="text-xs text-slate-400 hover:text-slate-700 dark:hover:text-slate-200" onClick={onClose}>Masquer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { token } = useParams();
|
||||||
|
const [weekStart, setWeekStart] = React.useState(() => startOfWeek());
|
||||||
|
const days = React.useMemo(() => weekDays(weekStart), [weekStart]);
|
||||||
|
const [entries, setEntries] = React.useState([]);
|
||||||
|
const [currentEntries, setCurrentEntries] = React.useState([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState('');
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [form, setForm] = React.useState({ guest_name: '', message: '' });
|
||||||
|
const [sending, setSending] = React.useState(false);
|
||||||
|
const [request, setRequest] = React.useState(null);
|
||||||
|
|
||||||
|
const loadSchedule = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await publicApi.schedule(dateKey(days[0]), dateKey(days[6]));
|
||||||
|
setEntries(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(apiError(e, 'Impossible de charger le planning.'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
React.useEffect(() => { loadSchedule(); }, [loadSchedule]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let stopped = false;
|
||||||
|
let timer;
|
||||||
|
const loadCurrent = async () => {
|
||||||
|
try {
|
||||||
|
const today = dateKey(new Date());
|
||||||
|
const data = await publicApi.schedule(today, today);
|
||||||
|
if (!stopped) setCurrentEntries(data);
|
||||||
|
} catch {
|
||||||
|
// Le planning hebdomadaire affiche déjà les erreurs réseau utiles.
|
||||||
|
}
|
||||||
|
if (!stopped) timer = setTimeout(loadCurrent, 60000);
|
||||||
|
};
|
||||||
|
loadCurrent();
|
||||||
|
return () => { stopped = true; if (timer) clearTimeout(timer); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const saved = token || localStorage.getItem('planning_last_request');
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
let timer;
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const data = await publicApi.requestStatus(saved);
|
||||||
|
if (stopped) return;
|
||||||
|
setRequest(data);
|
||||||
|
if (data.status === 'pending') timer = setTimeout(refresh, 5000);
|
||||||
|
} catch {
|
||||||
|
if (!token) localStorage.removeItem('planning_last_request');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
refresh();
|
||||||
|
return () => { stopped = true; if (timer) clearTimeout(timer); };
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const sendRequest = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSending(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await publicApi.createRequest(form);
|
||||||
|
localStorage.setItem('planning_last_request', data.public_token);
|
||||||
|
setRequest(data);
|
||||||
|
setDialogOpen(false);
|
||||||
|
setForm((f) => ({ ...f, message: '' }));
|
||||||
|
navigate(`/request/${data.public_token}`, { replace: true });
|
||||||
|
} catch (e) {
|
||||||
|
setError(apiError(e, 'Impossible d’envoyer la demande.'));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const active = currentSchedule(currentEntries);
|
||||||
|
const activeMeta = active ? statusMeta[active.status] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<PublicHeader />
|
||||||
|
<main className="mx-auto max-w-7xl space-y-5 px-4 py-5 sm:py-8">
|
||||||
|
<section className="grid gap-4 lg:grid-cols-[1.5fr_.8fr]">
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="p-5 sm:p-7">
|
||||||
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
<Clock3 className="size-4" /> Disponibilité actuelle
|
||||||
|
</div>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">{active.title || activeMeta.label}</h1>
|
||||||
|
<Badge tone={activeMeta.tone}>{activeMeta.label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
{String(active.start_time).slice(0, 5)} → {String(active.end_time).slice(0, 5)}
|
||||||
|
{active.description ? ` · ${active.description}` : ''}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Aucune information en cours</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">Tu peux quand même envoyer une demande pour savoir si je suis disponible maintenant.</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button size="lg" onClick={() => setDialogOpen(true)} className="shrink-0">
|
||||||
|
<HelpCircle className="size-5" /> Demander si je suis dispo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="flex h-full flex-col justify-center p-5 sm:p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-900"><CalendarDays className="size-5" /></div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Planning public</p>
|
||||||
|
<p className="text-sm text-slate-500">Consulte mes créneaux prévus avant de m’envoyer une demande.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{request && <RequestStatus request={request} onClose={() => { setRequest(null); localStorage.removeItem('planning_last_request'); navigate('/', { replace: true }); }} />}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Mon emploi du temps</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Semaine du {days[0].toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })} au {days[6].toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" size="icon" onClick={() => setWeekStart((d) => addDays(d, -7))} aria-label="Semaine précédente"><ChevronLeft className="size-5" /></Button>
|
||||||
|
<Button variant="secondary" onClick={() => setWeekStart(startOfWeek())}>Aujourd’hui</Button>
|
||||||
|
<Button variant="secondary" size="icon" onClick={() => setWeekStart((d) => addDays(d, 7))} aria-label="Semaine suivante"><ChevronRight className="size-5" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<Card><div className="flex min-h-80 items-center justify-center"><LoaderCircle className="size-7 animate-spin text-amber-500" /></div></Card>
|
||||||
|
) : (
|
||||||
|
<ScheduleWeek days={days} entries={entries} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Modal open={dialogOpen} onClose={() => !sending && setDialogOpen(false)} title="Demander ma disponibilité" description="La demande concerne ma disponibilité maintenant.">
|
||||||
|
<form onSubmit={sendRequest} className="space-y-4">
|
||||||
|
<Field label="Ton prénom ou ton nom">
|
||||||
|
<Input autoFocus required maxLength={80} value={form.guest_name} onChange={(e) => setForm({ ...form, guest_name: e.target.value })} placeholder="Ex. Thomas" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Message" hint="Optionnel — indique pourquoi tu souhaites me joindre.">
|
||||||
|
<Textarea maxLength={500} value={form.message} onChange={(e) => setForm({ ...form, message: e.target.value })} placeholder="Ex. Tu peux m'appeler 5 minutes ?" />
|
||||||
|
</Field>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={() => setDialogOpen(false)} disabled={sending}>Annuler</Button>
|
||||||
|
<Button type="submit" disabled={sending || form.guest_name.trim().length < 2}>
|
||||||
|
{sending ? <LoaderCircle className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||||
|
Envoyer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/src/utils.js
Normal file
17
frontend/src/utils.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export const dateKey = (date) => {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth()+1).padStart(2,'0');
|
||||||
|
const d = String(date.getDate()).padStart(2,'0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
};
|
||||||
|
export const startOfWeek = (date=new Date()) => {
|
||||||
|
const d = new Date(date); d.setHours(12,0,0,0);
|
||||||
|
const day = d.getDay(); d.setDate(d.getDate() + (day===0 ? -6 : 1-day)); return d;
|
||||||
|
};
|
||||||
|
export const addDays = (date, n) => { const d = new Date(date); d.setDate(d.getDate()+n); return d; };
|
||||||
|
export const weekDays = (start) => Array.from({length:7}, (_,i)=>addDays(start,i));
|
||||||
|
export const formatDateTime = (v) => v ? new Date(v).toLocaleString('fr-FR',{dateStyle:'short',timeStyle:'short'}) : '';
|
||||||
|
export const apiError = (e, fallback='Une erreur est survenue.') => {
|
||||||
|
const d=e?.response?.data; if(!d) return fallback; if(typeof d==='string') return d; if(d.detail) return d.detail;
|
||||||
|
const k=Object.keys(d)[0]; if(!k) return fallback; return Array.isArray(d[k]) ? d[k].join(' ') : String(d[k]);
|
||||||
|
};
|
||||||
7
frontend/vite.config.js
Normal file
7
frontend/vite.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: { host: '0.0.0.0', port: 5173, proxy: { '/api': 'http://localhost:1031' } },
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user