This commit is contained in:
Louis Mazin 2026-10-02 15:54:40 +02:00
commit 09ff6dd430
43 changed files with 1709 additions and 0 deletions

23
.env.example Normal file
View 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
View 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
View 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
View 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
View File

4
backend/core/apps.py Normal file
View File

@ -0,0 +1,4 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "core"

View File

View 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}"))

View 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")

View 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")),
]

View File

34
backend/core/models.py Normal file
View 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")]

View 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

View 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
View 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
View 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
View 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
View 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)

View File

@ -0,0 +1,2 @@
import pymysql
pymysql.install_as_MySQLdb()

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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,
};

View 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>
);
}

View 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>;
}

View 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
View 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
View 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>
);

View 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>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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
View 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
View 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' } },
});