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