mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-06 13:11:25 +00:00
Compare commits
36 Commits
7464b19de5
...
1.0.0-RC1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d97023cae | |||
| cb782fa109 | |||
| 92c3183153 | |||
| a81b76ecea | |||
| 4431c428d3 | |||
| 2ef71f99c3 | |||
| f9c0585b30 | |||
| 12939fca85 | |||
| 1f2a1b88ac | |||
| 762dede0af | |||
| ccdbae1c08 | |||
| 2a223fe3dd | |||
| 409cf05f1a | |||
| b0e04e3adc | |||
| 3c7266608d | |||
| 5bbbcb9dc1 | |||
| 053140c8be | |||
| 90b0d14418 | |||
| ae06b6fef7 | |||
| e37aee2abc | |||
| 2d678b732f | |||
| 4c56cb6474 | |||
| 79e14a23fe | |||
| 269866fb1c | |||
| f091fa0432 | |||
| a3291262d8 | |||
| 5f6c015d02 | |||
| 09b1541dc8 | |||
| cb76a23d02 | |||
| 2579af9b8b | |||
| 3a132ae0bd | |||
| 905fa5dbfb | |||
| edb9ace6ae | |||
| 4e50a0696f | |||
| 4248a589c5 | |||
| 6fb3c5cdb4 |
20
.github/copilot-instructions.md
vendored
20
.github/copilot-instructions.md
vendored
@ -52,8 +52,28 @@ Pour le front-end, les exigences de qualité sont les suivantes :
|
||||
- Documentation en français pour les nouvelles fonctionnalités (si applicable)
|
||||
- Référence : [documentation guidelines](./instructions/documentation.instruction.md)
|
||||
|
||||
## Design System
|
||||
|
||||
Le projet utilise un design system défini. Toujours s'y conformer lors de toute modification de l'interface.
|
||||
|
||||
- Référence complète : [design system](../docs/design-system.md)
|
||||
- Règles Copilot : [design system instructions](./instructions/design-system.instruction.md)
|
||||
|
||||
### Résumé des tokens obligatoires
|
||||
|
||||
| Token Tailwind | Hex | Usage |
|
||||
|----------------|-----------|-------------------------------|
|
||||
| `primary` | `#059669` | Boutons, CTA, éléments actifs |
|
||||
| `secondary` | `#064E3B` | Hover, accents sombres |
|
||||
| `tertiary` | `#10B981` | Badges, icônes |
|
||||
| `neutral` | `#F8FAFC` | Fonds de page, surfaces |
|
||||
|
||||
- Polices : `font-headline` (Manrope) pour les titres, `font-body`/`font-label` (Inter) pour le reste
|
||||
- **Ne jamais** utiliser `emerald-*` pour les éléments interactifs
|
||||
|
||||
## Références
|
||||
|
||||
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
|
||||
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
|
||||
- **Tests** : [run tests](./instructions/run-tests.instruction.md)
|
||||
- **Design System** : [design system instructions](./instructions/design-system.instruction.md)
|
||||
|
||||
116
.github/instructions/design-system.instruction.md
vendored
Normal file
116
.github/instructions/design-system.instruction.md
vendored
Normal file
@ -0,0 +1,116 @@
|
||||
---
|
||||
applyTo: "Front-End/src/**"
|
||||
---
|
||||
|
||||
# Design System — Règles Copilot
|
||||
|
||||
Référence complète : [`docs/design-system.md`](../../docs/design-system.md)
|
||||
|
||||
## Couleurs — tokens Tailwind obligatoires
|
||||
|
||||
Utiliser **toujours** ces tokens pour les éléments interactifs :
|
||||
|
||||
| Token | Hex | Remplace |
|
||||
|-------------|-----------|-----------------------------------|
|
||||
| `primary` | `#059669` | `emerald-600`, `emerald-500` |
|
||||
| `secondary` | `#064E3B` | `emerald-700`, `emerald-800` |
|
||||
| `tertiary` | `#10B981` | `emerald-400`, `emerald-500` |
|
||||
| `neutral` | `#F8FAFC` | Fonds neutres |
|
||||
|
||||
**Ne jamais écrire** `bg-emerald-*`, `text-emerald-*`, `border-emerald-*` pour des éléments interactifs.
|
||||
|
||||
### Patterns corrects
|
||||
|
||||
```jsx
|
||||
// Bouton
|
||||
<button className="bg-primary hover:bg-secondary text-white px-4 py-2 rounded">
|
||||
|
||||
// Texte actif
|
||||
<span className="text-primary">
|
||||
|
||||
// Badge
|
||||
<span className="bg-tertiary/10 text-tertiary text-xs px-2 py-0.5 rounded">
|
||||
|
||||
// Fond de page
|
||||
<div className="bg-neutral">
|
||||
```
|
||||
|
||||
## Typographie
|
||||
|
||||
```jsx
|
||||
// Titre de section
|
||||
<h1 className="font-headline text-2xl font-bold">
|
||||
|
||||
// Sous-titre
|
||||
<h2 className="font-headline text-xl font-semibold">
|
||||
|
||||
// Label de formulaire
|
||||
<label className="font-label text-sm font-medium text-gray-700">
|
||||
```
|
||||
|
||||
> `font-body` est le défaut sur `<body>` — inutile de l'ajouter sur les `<p>`.
|
||||
|
||||
## Arrondi
|
||||
|
||||
- Par défaut : `rounded` (4px)
|
||||
- Cards / modales : `rounded-md` (6px)
|
||||
- Grandes surfaces : `rounded-lg` (8px)
|
||||
- **Éviter** `rounded-xl` sauf avatars ou indicateurs circulaires
|
||||
|
||||
## Espacement
|
||||
|
||||
- Grille 4px/8px : `p-1`=4px, `p-2`=8px, `p-3`=12px, `p-4`=16px
|
||||
- **Pas** de valeurs arbitraires `p-[13px]`
|
||||
|
||||
## Mode
|
||||
|
||||
Interface **light uniquement** — ne pas ajouter `dark:` prefixes.
|
||||
|
||||
---
|
||||
|
||||
## Icônes
|
||||
|
||||
Utiliser **uniquement** `lucide-react`. Jamais d'autres bibliothèques d'icônes.
|
||||
|
||||
```jsx
|
||||
import { Home, Plus, ChevronRight } from 'lucide-react';
|
||||
|
||||
<Home size={20} className="text-primary" />
|
||||
<button className="flex items-center gap-2"><Plus size={16} />Ajouter</button>
|
||||
```
|
||||
|
||||
- Taille par défaut : `size={20}` inline, `size={24}` boutons standalone
|
||||
- Couleur via `className="text-*"` uniquement — jamais le prop `color`
|
||||
- Icône seule : ajouter `aria-label` pour l'accessibilité
|
||||
|
||||
---
|
||||
|
||||
## Responsive & PWA
|
||||
|
||||
**Mobile-first** : les styles de base ciblent le mobile, on étend avec `sm:` / `md:` / `lg:`.
|
||||
|
||||
```jsx
|
||||
// Layout
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
|
||||
|
||||
// Grille
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
// Bouton full-width mobile
|
||||
<button className="w-full sm:w-auto bg-primary text-white px-4 py-2 rounded">
|
||||
```
|
||||
|
||||
- Touch targets ≥ 44px : `min-h-[44px]` sur tous les éléments interactifs
|
||||
- Pas d'interactions uniquement au `:hover` — prévoir une alternative tactile
|
||||
- Tableaux sur mobile : utiliser la classe utilitaire `responsive-table` (définie dans `tailwind.css`)
|
||||
|
||||
---
|
||||
|
||||
## Réutilisation des composants
|
||||
|
||||
**Toujours chercher un composant existant dans `Front-End/src/components/` avant d'en créer un.**
|
||||
|
||||
Composants clés disponibles : `AlertMessage`, `Modal`, `Pagination`, `SectionHeader`, `ProgressStep`, `EventCard`, `Calendar/*`, `Chat/*`, `Evaluation/*`, `Grades/*`, `Form/*`, `Admin/*`, `Charts/*`.
|
||||
|
||||
- Étendre via des props (`variant`, `size`, `className`) plutôt que de dupliquer
|
||||
- Appliquer les tokens du design system dans tout composant modifié ou créé
|
||||
@ -1,5 +1,5 @@
|
||||
La documentation doit être en français et claire pour les utilisateurs francophones.
|
||||
Toutes la documentation doit être dans le dossier docs/
|
||||
Toutes la documentation doit être dans le dossier docs/ à la racine.
|
||||
Seul les fichiers README.md, CHANGELOG.md doivent être à la racine.
|
||||
La documentation doit être conscise et pertinente, sans répétitions inutiles entre les documents.
|
||||
Tout ce qui concerne la gestion de projet, roadmap ne doit pas apparaître dans la documentation.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ node_modules/
|
||||
hardcoded-strings-report.md
|
||||
backend.env
|
||||
*.log
|
||||
.claude/worktrees/*
|
||||
1
.husky/commit-msg
Normal file → Executable file
1
.husky/commit-msg
Normal file → Executable file
@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
npx --no -- commitlint --edit $1
|
||||
1
.husky/pre-commit
Normal file → Executable file
1
.husky/pre-commit
Normal file → Executable file
@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
cd $(dirname "$0")/../Front-End/ && npm run lint-light
|
||||
1
.husky/prepare-commit-msg
Normal file → Executable file
1
.husky/prepare-commit-msg
Normal file → Executable file
@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
#node scripts/prepare-commit-msg.js "$1" "$2"
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
|
||||
@ -14,7 +14,7 @@ class ProfileSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'username', 'roles', 'roleIndexLoginDefault']
|
||||
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'username', 'roles', 'roleIndexLoginDefault', 'first_name', 'last_name']
|
||||
extra_kwargs = {'password': {'write_only': True}}
|
||||
|
||||
def get_roles(self, obj):
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -3,6 +3,7 @@ from django.urls import path, re_path
|
||||
from .views import (
|
||||
DomainListCreateView, DomainDetailView,
|
||||
CategoryListCreateView, CategoryDetailView,
|
||||
ServeFileView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@ -11,4 +12,6 @@ urlpatterns = [
|
||||
|
||||
re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"),
|
||||
re_path(r'^categories/(?P<id>[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"),
|
||||
|
||||
path('serve-file/', ServeFileView.as_view(), name="serve_file"),
|
||||
]
|
||||
@ -1,3 +1,8 @@
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse
|
||||
from django.http.response import JsonResponse
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||
from django.utils.decorators import method_decorator
|
||||
@ -117,3 +122,61 @@ class CategoryDetailView(APIView):
|
||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||
except Category.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class ServeFileView(APIView):
|
||||
"""Sert les fichiers media de manière sécurisée avec authentification JWT."""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
file_path = request.query_params.get('path', '')
|
||||
|
||||
if not file_path:
|
||||
return JsonResponse(
|
||||
{'error': 'Le paramètre "path" est requis'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Nettoyer les prefixes media usuels si presents
|
||||
if file_path.startswith('/media/'):
|
||||
file_path = file_path[len('/media/'):]
|
||||
elif file_path.startswith('media/'):
|
||||
file_path = file_path[len('media/'):]
|
||||
|
||||
# Nettoyer le préfixe /data/ si présent
|
||||
if file_path.startswith('/data/'):
|
||||
file_path = file_path[len('/data/'):]
|
||||
elif file_path.startswith('data/'):
|
||||
file_path = file_path[len('data/'):]
|
||||
|
||||
# Construire le chemin absolu et le résoudre pour éliminer les traversals
|
||||
absolute_path = os.path.realpath(
|
||||
os.path.join(settings.MEDIA_ROOT, file_path)
|
||||
)
|
||||
|
||||
# Protection contre le path traversal
|
||||
media_root = os.path.realpath(settings.MEDIA_ROOT)
|
||||
if not absolute_path.startswith(media_root + os.sep) and absolute_path != media_root:
|
||||
return JsonResponse(
|
||||
{'error': 'Accès non autorisé'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if not os.path.isfile(absolute_path):
|
||||
return JsonResponse(
|
||||
{'error': 'Fichier introuvable'},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(absolute_path)
|
||||
if content_type is None:
|
||||
content_type = 'application/octet-stream'
|
||||
|
||||
response = FileResponse(
|
||||
open(absolute_path, 'rb'),
|
||||
content_type=content_type,
|
||||
)
|
||||
response['Content-Disposition'] = (
|
||||
f'inline; filename="{os.path.basename(absolute_path)}"'
|
||||
)
|
||||
return response
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:04
|
||||
|
||||
import Establishment.models
|
||||
import django.contrib.postgres.fields
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
SendEmailView, search_recipients
|
||||
SendEmailView, search_recipients, SendFeedbackView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('send-email/', SendEmailView.as_view(), name='send_email'),
|
||||
path('search-recipients/', search_recipients, name='search_recipients'),
|
||||
path('send-feedback/', SendFeedbackView.as_view(), name='send_feedback'),
|
||||
]
|
||||
@ -5,6 +5,7 @@ from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from Auth.models import Profile, ProfileRole
|
||||
|
||||
import N3wtSchool.mailManager as mailer
|
||||
@ -119,3 +120,84 @@ def search_recipients(request):
|
||||
})
|
||||
|
||||
return JsonResponse(results, safe=False)
|
||||
|
||||
|
||||
class SendFeedbackView(APIView):
|
||||
"""
|
||||
API pour envoyer un feedback au support (EMAIL_HOST_USER).
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
data = request.data
|
||||
category = data.get('category', '')
|
||||
subject = data.get('subject', 'Feedback')
|
||||
message = data.get('message', '')
|
||||
user_email = data.get('user_email', '')
|
||||
user_name = data.get('user_name', '')
|
||||
establishment = data.get('establishment', {})
|
||||
|
||||
logger.info(f"Feedback received - Category: {category}, Subject: {subject}")
|
||||
|
||||
if not message or not subject or not category:
|
||||
return Response(
|
||||
{'error': 'La catégorie, le sujet et le message sont requis.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
# Construire le message formaté
|
||||
category_labels = {
|
||||
'bug': 'Signalement de bug',
|
||||
'feature': 'Proposition de fonctionnalité',
|
||||
'question': 'Question',
|
||||
'other': 'Autre'
|
||||
}
|
||||
category_label = category_labels.get(category, category)
|
||||
|
||||
# Construire les infos établissement
|
||||
establishment_id = establishment.get('id', 'N/A')
|
||||
establishment_name = establishment.get('name', 'N/A')
|
||||
establishment_capacity = establishment.get('total_capacity', 'N/A')
|
||||
establishment_frequency = establishment.get('evaluation_frequency', 'N/A')
|
||||
|
||||
formatted_message = f"""
|
||||
<h2>Nouveau Feedback - {category_label}</h2>
|
||||
<p><strong>De:</strong> {user_name} ({user_email})</p>
|
||||
<h3>Établissement</h3>
|
||||
<ul>
|
||||
<li><strong>ID:</strong> {establishment_id}</li>
|
||||
<li><strong>Nom:</strong> {establishment_name}</li>
|
||||
<li><strong>Capacité:</strong> {establishment_capacity}</li>
|
||||
<li><strong>Fréquence d'évaluation:</strong> {establishment_frequency}</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<p><strong>Sujet:</strong> {subject}</p>
|
||||
<div>
|
||||
<strong>Message:</strong><br>
|
||||
{message}
|
||||
</div>
|
||||
"""
|
||||
|
||||
formatted_subject = f"[N3WT School Feedback] [{category_label}] {subject}"
|
||||
|
||||
# Envoyer à EMAIL_HOST_USER avec la configuration SMTP par défaut
|
||||
result = mailer.sendMail(
|
||||
subject=formatted_subject,
|
||||
message=formatted_message,
|
||||
recipients=[settings.EMAIL_HOST_USER],
|
||||
cc=[],
|
||||
bcc=[],
|
||||
attachments=[],
|
||||
connection=None # Utilise la configuration SMTP par défaut
|
||||
)
|
||||
|
||||
logger.info("Feedback envoyé avec succès")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'envoi du feedback: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{'error': "Erreur lors de l'envoi du feedback"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@ -72,11 +72,11 @@ class ChatConsumer(AsyncWebsocketConsumer):
|
||||
if presence:
|
||||
await self.broadcast_presence_update(self.user_id, 'online')
|
||||
|
||||
await self.accept()
|
||||
|
||||
# Envoyer les statuts de présence existants des autres utilisateurs connectés
|
||||
await self.send_existing_user_presences()
|
||||
|
||||
await self.accept()
|
||||
|
||||
logger.info(f"User {self.user_id} connected to chat")
|
||||
|
||||
async def send_existing_user_presences(self):
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -9,6 +9,7 @@ from .models import Planning, Events, RecursionType
|
||||
from .serializers import PlanningSerializer, EventsSerializer
|
||||
|
||||
from N3wtSchool import bdd
|
||||
from Subscriptions.util import getCurrentSchoolYear
|
||||
|
||||
|
||||
class PlanningView(APIView):
|
||||
@ -17,6 +18,7 @@ class PlanningView(APIView):
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
planning_mode = request.GET.get('planning_mode', None)
|
||||
current_school_year = getCurrentSchoolYear()
|
||||
|
||||
plannings = bdd.getAllObjects(Planning)
|
||||
|
||||
@ -25,7 +27,10 @@ class PlanningView(APIView):
|
||||
|
||||
# Filtrer en fonction du planning_mode
|
||||
if planning_mode == "classSchedule":
|
||||
plannings = plannings.filter(school_class__isnull=False)
|
||||
plannings = plannings.filter(
|
||||
school_class__isnull=False,
|
||||
school_class__school_year=current_school_year,
|
||||
)
|
||||
elif planning_mode == "planning":
|
||||
plannings = plannings.filter(school_class__isnull=True)
|
||||
|
||||
@ -79,6 +84,7 @@ class EventsView(APIView):
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
planning_mode = request.GET.get('planning_mode', None)
|
||||
current_school_year = getCurrentSchoolYear()
|
||||
filterParams = {}
|
||||
plannings=[]
|
||||
events = Events.objects.all()
|
||||
@ -86,6 +92,8 @@ class EventsView(APIView):
|
||||
filterParams['establishment'] = establishment_id
|
||||
if planning_mode is not None:
|
||||
filterParams['school_class__isnull'] = (planning_mode!="classSchedule")
|
||||
if planning_mode == "classSchedule":
|
||||
filterParams['school_class__school_year'] = current_school_year
|
||||
if filterParams:
|
||||
plannings = Planning.objects.filter(**filterParams)
|
||||
events = Events.objects.filter(planning__in=plannings)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:05
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
@ -99,6 +99,7 @@ class Migration(migrations.Migration):
|
||||
('number_of_students', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('teaching_language', models.CharField(blank=True, max_length=255)),
|
||||
('school_year', models.CharField(blank=True, max_length=9)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)),
|
||||
('time_range', models.JSONField(default=list)),
|
||||
@ -126,6 +127,26 @@ class Migration(migrations.Migration):
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specialities', to='Establishment.establishment')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Evaluation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('period', models.CharField(help_text='Période ex: T1_2025-2026, S1_2025-2026, A_2025-2026', max_length=20)),
|
||||
('date', models.DateField(blank=True, null=True)),
|
||||
('max_score', models.DecimalField(decimal_places=2, default=20, max_digits=5)),
|
||||
('coefficient', models.DecimalField(decimal_places=2, default=1, max_digits=3)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='Establishment.establishment')),
|
||||
('school_class', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='School.schoolclass')),
|
||||
('speciality', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='School.speciality')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Teacher',
|
||||
fields=[
|
||||
|
||||
@ -48,6 +48,7 @@ class SchoolClass(models.Model):
|
||||
number_of_students = models.PositiveIntegerField(null=True, blank=True)
|
||||
teaching_language = models.CharField(max_length=255, blank=True)
|
||||
school_year = models.CharField(max_length=9, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
teachers = models.ManyToManyField(Teacher, blank=True)
|
||||
levels = models.ManyToManyField('Common.Level', blank=True, related_name='school_classes')
|
||||
@ -156,3 +157,26 @@ class EstablishmentCompetency(models.Model):
|
||||
if self.competency:
|
||||
return f"{self.establishment.name} - {self.competency.name}"
|
||||
return f"{self.establishment.name} - {self.custom_name} (custom)"
|
||||
|
||||
|
||||
class Evaluation(models.Model):
|
||||
"""
|
||||
Définition d'une évaluation (contrôle, examen, etc.) associée à une matière et une classe.
|
||||
"""
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
speciality = models.ForeignKey(Speciality, on_delete=models.CASCADE, related_name='evaluations')
|
||||
school_class = models.ForeignKey(SchoolClass, on_delete=models.CASCADE, related_name='evaluations')
|
||||
period = models.CharField(max_length=20, help_text="Période ex: T1_2025-2026, S1_2025-2026, A_2025-2026")
|
||||
date = models.DateField(null=True, blank=True)
|
||||
max_score = models.DecimalField(max_digits=5, decimal_places=2, default=20)
|
||||
coefficient = models.DecimalField(max_digits=3, decimal_places=2, default=1)
|
||||
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='evaluations')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.speciality.name} ({self.school_class.atmosphere_name})"
|
||||
@ -10,10 +10,11 @@ from .models import (
|
||||
PaymentPlan,
|
||||
PaymentMode,
|
||||
EstablishmentCompetency,
|
||||
Competency
|
||||
Competency,
|
||||
Evaluation
|
||||
)
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Subscriptions.models import Student
|
||||
from Subscriptions.models import Student, StudentEvaluation
|
||||
from Establishment.models import Establishment
|
||||
from Auth.serializers import ProfileRoleSerializer
|
||||
from N3wtSchool import settings
|
||||
@ -182,12 +183,17 @@ class SchoolClassSerializer(serializers.ModelSerializer):
|
||||
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
|
||||
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
|
||||
teachers_details = serializers.SerializerMethodField()
|
||||
students = StudentDetailSerializer(many=True, read_only=True)
|
||||
students = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = SchoolClass
|
||||
fields = '__all__'
|
||||
|
||||
def get_students(self, obj):
|
||||
# Filtrer uniquement les étudiants dont le dossier est validé (status = 5)
|
||||
validated_students = obj.students.filter(registrationform__status=5)
|
||||
return StudentDetailSerializer(validated_students, many=True).data
|
||||
|
||||
def create(self, validated_data):
|
||||
teachers_data = validated_data.pop('teachers', [])
|
||||
levels_data = validated_data.pop('levels', [])
|
||||
@ -300,3 +306,31 @@ class PaymentModeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PaymentMode
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class EvaluationSerializer(serializers.ModelSerializer):
|
||||
speciality_name = serializers.CharField(source='speciality.name', read_only=True)
|
||||
speciality_color = serializers.CharField(source='speciality.color_code', read_only=True)
|
||||
school_class_name = serializers.CharField(source='school_class.atmosphere_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Evaluation
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class StudentEvaluationSerializer(serializers.ModelSerializer):
|
||||
student_name = serializers.SerializerMethodField()
|
||||
student_first_name = serializers.CharField(source='student.first_name', read_only=True)
|
||||
student_last_name = serializers.CharField(source='student.last_name', read_only=True)
|
||||
evaluation_name = serializers.CharField(source='evaluation.name', read_only=True)
|
||||
max_score = serializers.DecimalField(source='evaluation.max_score', read_only=True, max_digits=5, decimal_places=2)
|
||||
speciality_name = serializers.CharField(source='evaluation.speciality.name', read_only=True)
|
||||
speciality_color = serializers.CharField(source='evaluation.speciality.color', read_only=True)
|
||||
period = serializers.CharField(source='evaluation.period', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = StudentEvaluation
|
||||
fields = '__all__'
|
||||
|
||||
def get_student_name(self, obj):
|
||||
return f"{obj.student.last_name} {obj.student.first_name}"
|
||||
@ -11,6 +11,9 @@ from .views import (
|
||||
PaymentModeListCreateView, PaymentModeDetailView,
|
||||
CompetencyListCreateView, CompetencyDetailView,
|
||||
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
|
||||
EvaluationListCreateView, EvaluationDetailView,
|
||||
StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView,
|
||||
SchoolYearsListView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@ -43,4 +46,16 @@ urlpatterns = [
|
||||
|
||||
re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"),
|
||||
re_path(r'^establishmentCompetencies/(?P<id>[0-9]+)$', EstablishmentCompetencyDetailView.as_view(), name="establishment_competency_detail"),
|
||||
|
||||
# Evaluations
|
||||
re_path(r'^evaluations$', EvaluationListCreateView.as_view(), name="evaluation_list_create"),
|
||||
re_path(r'^evaluations/(?P<id>[0-9]+)$', EvaluationDetailView.as_view(), name="evaluation_detail"),
|
||||
|
||||
# Student Evaluations
|
||||
re_path(r'^studentEvaluations$', StudentEvaluationListView.as_view(), name="student_evaluation_list"),
|
||||
re_path(r'^studentEvaluations/bulk$', StudentEvaluationBulkUpdateView.as_view(), name="student_evaluation_bulk"),
|
||||
re_path(r'^studentEvaluations/(?P<id>[0-9]+)$', StudentEvaluationDetailView.as_view(), name="student_evaluation_detail"),
|
||||
|
||||
# History / School Years
|
||||
re_path(r'^schoolYears$', SchoolYearsListView.as_view(), name="school_years_list"),
|
||||
]
|
||||
@ -16,7 +16,8 @@ from .models import (
|
||||
PaymentPlan,
|
||||
PaymentMode,
|
||||
EstablishmentCompetency,
|
||||
Competency
|
||||
Competency,
|
||||
Evaluation
|
||||
)
|
||||
from .serializers import (
|
||||
TeacherSerializer,
|
||||
@ -28,19 +29,43 @@ from .serializers import (
|
||||
PaymentPlanSerializer,
|
||||
PaymentModeSerializer,
|
||||
EstablishmentCompetencySerializer,
|
||||
CompetencySerializer
|
||||
CompetencySerializer,
|
||||
EvaluationSerializer,
|
||||
StudentEvaluationSerializer
|
||||
)
|
||||
from Common.models import Domain, Category
|
||||
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
||||
from django.db.models import Q
|
||||
from collections import defaultdict
|
||||
from Subscriptions.models import Student, StudentCompetency
|
||||
from Subscriptions.util import getCurrentSchoolYear
|
||||
from Subscriptions.models import Student, StudentCompetency, StudentEvaluation
|
||||
from Subscriptions.util import getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears
|
||||
import logging
|
||||
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SchoolYearsListView(APIView):
|
||||
"""
|
||||
Liste les années scolaires disponibles pour l'historique.
|
||||
Retourne l'année en cours, la suivante, et les années historiques.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
current_year = getCurrentSchoolYear()
|
||||
next_year = getNextSchoolYear()
|
||||
historical_years = getHistoricalYears(5)
|
||||
|
||||
return JsonResponse({
|
||||
'current_year': current_year,
|
||||
'next_year': next_year,
|
||||
'historical_years': historical_years,
|
||||
'all_years': [next_year, current_year] + historical_years
|
||||
}, safe=False)
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SpecialityListCreateView(APIView):
|
||||
@ -183,12 +208,33 @@ class SchoolClassListCreateView(APIView):
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
school_year = request.GET.get('school_year', None)
|
||||
year_filter = request.GET.get('year_filter', None) # 'current_year', 'next_year', 'historical'
|
||||
|
||||
if establishment_id is None:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
school_classes_list = getAllObjects(SchoolClass)
|
||||
if school_classes_list:
|
||||
school_classes_list = school_classes_list.filter(establishment=establishment_id).distinct()
|
||||
school_classes_list = school_classes_list.filter(establishment=establishment_id)
|
||||
|
||||
# Filtrage par année scolaire
|
||||
if school_year:
|
||||
school_classes_list = school_classes_list.filter(school_year=school_year)
|
||||
elif year_filter:
|
||||
current_year = getCurrentSchoolYear()
|
||||
next_year = getNextSchoolYear()
|
||||
historical_years = getHistoricalYears(5)
|
||||
|
||||
if year_filter == 'current_year':
|
||||
school_classes_list = school_classes_list.filter(school_year=current_year)
|
||||
elif year_filter == 'next_year':
|
||||
school_classes_list = school_classes_list.filter(school_year=next_year)
|
||||
elif year_filter == 'historical':
|
||||
school_classes_list = school_classes_list.filter(school_year__in=historical_years)
|
||||
|
||||
school_classes_list = school_classes_list.distinct()
|
||||
|
||||
classes_serializer = SchoolClassSerializer(school_classes_list, many=True)
|
||||
return JsonResponse(classes_serializer.data, safe=False)
|
||||
|
||||
@ -785,3 +831,179 @@ class EstablishmentCompetencyDetailView(APIView):
|
||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||
except EstablishmentCompetency.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
# ===================== EVALUATIONS =====================
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EvaluationListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
school_class_id = request.GET.get('school_class')
|
||||
period = request.GET.get('period')
|
||||
|
||||
if not establishment_id:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
evaluations = Evaluation.objects.filter(establishment_id=establishment_id)
|
||||
|
||||
if school_class_id:
|
||||
evaluations = evaluations.filter(school_class_id=school_class_id)
|
||||
if period:
|
||||
evaluations = evaluations.filter(period=period)
|
||||
|
||||
serializer = EvaluationSerializer(evaluations, many=True)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
def post(self, request):
|
||||
data = JSONParser().parse(request)
|
||||
serializer = EvaluationSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
|
||||
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EvaluationDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
evaluation = Evaluation.objects.get(id=id)
|
||||
serializer = EvaluationSerializer(evaluation)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
except Evaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def put(self, request, id):
|
||||
try:
|
||||
evaluation = Evaluation.objects.get(id=id)
|
||||
except Evaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
data = JSONParser().parse(request)
|
||||
serializer = EvaluationSerializer(evaluation, data=data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, id):
|
||||
try:
|
||||
evaluation = Evaluation.objects.get(id=id)
|
||||
evaluation.delete()
|
||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||
except Evaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
# ===================== STUDENT EVALUATIONS =====================
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class StudentEvaluationListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
student_id = request.GET.get('student_id')
|
||||
evaluation_id = request.GET.get('evaluation_id')
|
||||
period = request.GET.get('period')
|
||||
school_class_id = request.GET.get('school_class_id')
|
||||
|
||||
student_evals = StudentEvaluation.objects.all()
|
||||
|
||||
if student_id:
|
||||
student_evals = student_evals.filter(student_id=student_id)
|
||||
if evaluation_id:
|
||||
student_evals = student_evals.filter(evaluation_id=evaluation_id)
|
||||
if period:
|
||||
student_evals = student_evals.filter(evaluation__period=period)
|
||||
if school_class_id:
|
||||
student_evals = student_evals.filter(evaluation__school_class_id=school_class_id)
|
||||
|
||||
serializer = StudentEvaluationSerializer(student_evals, many=True)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class StudentEvaluationBulkUpdateView(APIView):
|
||||
"""
|
||||
Mise à jour en masse des notes des élèves pour une évaluation.
|
||||
Attendu dans le body :
|
||||
[
|
||||
{ "student_id": 1, "evaluation_id": 1, "score": 15.5, "comment": "", "is_absent": false },
|
||||
...
|
||||
]
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def put(self, request):
|
||||
data = JSONParser().parse(request)
|
||||
if not isinstance(data, list):
|
||||
data = [data]
|
||||
|
||||
updated = []
|
||||
errors = []
|
||||
|
||||
for item in data:
|
||||
student_id = item.get('student_id')
|
||||
evaluation_id = item.get('evaluation_id')
|
||||
|
||||
if not student_id or not evaluation_id:
|
||||
errors.append({'error': 'student_id et evaluation_id sont requis', 'item': item})
|
||||
continue
|
||||
|
||||
try:
|
||||
student_eval, created = StudentEvaluation.objects.update_or_create(
|
||||
student_id=student_id,
|
||||
evaluation_id=evaluation_id,
|
||||
defaults={
|
||||
'score': item.get('score'),
|
||||
'comment': item.get('comment', ''),
|
||||
'is_absent': item.get('is_absent', False)
|
||||
}
|
||||
)
|
||||
updated.append(StudentEvaluationSerializer(student_eval).data)
|
||||
except Exception as e:
|
||||
errors.append({'error': str(e), 'item': item})
|
||||
|
||||
return JsonResponse({'updated': updated, 'errors': errors}, safe=False)
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class StudentEvaluationDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
student_eval = StudentEvaluation.objects.get(id=id)
|
||||
serializer = StudentEvaluationSerializer(student_eval)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
except StudentEvaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def put(self, request, id):
|
||||
try:
|
||||
student_eval = StudentEvaluation.objects.get(id=id)
|
||||
except StudentEvaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
data = JSONParser().parse(request)
|
||||
serializer = StudentEvaluationSerializer(student_eval, data=data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, id):
|
||||
try:
|
||||
student_eval = StudentEvaluation.objects.get(id=id)
|
||||
student_eval.delete()
|
||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||
except StudentEvaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
# Generated by Django 5.1.3 on 2026-04-05 14:04
|
||||
|
||||
import Subscriptions.models
|
||||
import django.db.models.deletion
|
||||
@ -40,6 +40,8 @@ class Migration(migrations.Migration):
|
||||
('birth_place', models.CharField(blank=True, default='', max_length=200)),
|
||||
('birth_postal_code', models.IntegerField(blank=True, default=0)),
|
||||
('attending_physician', models.CharField(blank=True, default='', max_length=200)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True, null=True)),
|
||||
('associated_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='School.schoolclass')),
|
||||
],
|
||||
),
|
||||
@ -51,7 +53,9 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
|
||||
('formTemplateData', models.JSONField(blank=True, default=list, null=True)),
|
||||
('isValidated', models.BooleanField(default=False)),
|
||||
('isValidated', models.BooleanField(blank=True, default=None, null=True)),
|
||||
('electronic_signature', models.TextField(blank=True, help_text='Signature électronique encodée en base64', null=True)),
|
||||
('electronic_signature_date', models.DateTimeField(blank=True, help_text='Date de la signature électronique', null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -90,6 +94,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='Subscriptions.student')),
|
||||
('status', models.IntegerField(choices=[(0, "Pas de dossier d'inscription"), (1, "Dossier d'inscription initialisé"), (2, "Dossier d'inscription envoyé"), (3, "Dossier d'inscription en cours de validation"), (4, "Dossier d'inscription à relancer"), (5, "Dossier d'inscription validé"), (6, "Dossier d'inscription archivé"), (7, 'Mandat SEPA envoyé'), (8, 'Mandat SEPA à envoyer')], default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_update', models.DateTimeField(auto_now=True)),
|
||||
('school_year', models.CharField(blank=True, default='', max_length=9)),
|
||||
('notes', models.CharField(blank=True, max_length=200)),
|
||||
@ -166,6 +171,7 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('is_required', models.BooleanField(default=False)),
|
||||
('requires_electronic_signature', models.BooleanField(default=False, help_text='Si activé, le parent devra signer électroniquement ce document')),
|
||||
('formMasterData', models.JSONField(blank=True, default=list, null=True)),
|
||||
('file', models.FileField(blank=True, help_text='Fichier du formulaire existant (PDF, DOC, etc.)', null=True, upload_to=Subscriptions.models.registration_school_file_master_upload_to)),
|
||||
('establishment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='school_file_masters', to='Establishment.establishment')),
|
||||
@ -197,7 +203,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_parent_file_upload_to)),
|
||||
('isValidated', models.BooleanField(default=False)),
|
||||
('isValidated', models.BooleanField(blank=True, default=None, null=True)),
|
||||
('master', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationparentfilemaster')),
|
||||
('registration_form', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationform')),
|
||||
],
|
||||
@ -209,6 +215,8 @@ class Migration(migrations.Migration):
|
||||
('score', models.IntegerField(blank=True, null=True)),
|
||||
('comment', models.TextField(blank=True, null=True)),
|
||||
('period', models.CharField(blank=True, default='', help_text="Période d'évaluation ex: T1-2024_2025, S1-2024_2025, A-2024_2025", max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True, null=True)),
|
||||
('establishment_competency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.establishmentcompetency')),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competency_scores', to='Subscriptions.student')),
|
||||
],
|
||||
@ -216,4 +224,20 @@ class Migration(migrations.Migration):
|
||||
'unique_together': {('student', 'establishment_competency', 'period')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StudentEvaluation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||
('comment', models.TextField(blank=True)),
|
||||
('is_absent', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('evaluation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.evaluation')),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluation_scores', to='Subscriptions.student')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('student', 'evaluation')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -54,17 +54,38 @@ class Sibling(models.Model):
|
||||
return "SIBLING"
|
||||
|
||||
def registration_photo_upload_to(instance, filename):
|
||||
return f"registration_files/dossier_rf_{instance.pk}/parent/{filename}"
|
||||
"""
|
||||
Génère le chemin de stockage pour la photo élève.
|
||||
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
|
||||
"""
|
||||
register_form = getattr(instance, 'registrationform', None)
|
||||
if register_form and register_form.establishment:
|
||||
est_name = register_form.establishment.name
|
||||
elif instance.associated_class and instance.associated_class.establishment:
|
||||
est_name = instance.associated_class.establishment.name
|
||||
else:
|
||||
est_name = "unknown_establishment"
|
||||
|
||||
student_last = instance.last_name if instance and instance.last_name else "unknown"
|
||||
student_first = instance.first_name if instance and instance.first_name else "unknown"
|
||||
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
|
||||
|
||||
def registration_bilan_form_upload_to(instance, filename):
|
||||
# On récupère le RegistrationForm lié à l'élève
|
||||
"""
|
||||
Génère le chemin de stockage pour les bilans de compétences.
|
||||
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
|
||||
"""
|
||||
register_form = getattr(instance.student, 'registrationform', None)
|
||||
if register_form:
|
||||
pk = register_form.pk
|
||||
if register_form and register_form.establishment:
|
||||
est_name = register_form.establishment.name
|
||||
elif instance.student.associated_class and instance.student.associated_class.establishment:
|
||||
est_name = instance.student.associated_class.establishment.name
|
||||
else:
|
||||
# fallback sur l'id de l'élève si pas de registrationform
|
||||
pk = instance.student.pk
|
||||
return f"registration_files/dossier_rf_{pk}/bilan/{filename}"
|
||||
est_name = "unknown_establishment"
|
||||
|
||||
student_last = instance.student.last_name if instance.student else "unknown"
|
||||
student_first = instance.student.first_name if instance.student else "unknown"
|
||||
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
|
||||
|
||||
class BilanCompetence(models.Model):
|
||||
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='bilans')
|
||||
@ -130,6 +151,10 @@ class Student(models.Model):
|
||||
# One-to-Many Relationship
|
||||
associated_class = models.ForeignKey('School.SchoolClass', on_delete=models.SET_NULL, null=True, blank=True, related_name='students')
|
||||
|
||||
# Audit fields
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.last_name + "_" + self.first_name
|
||||
|
||||
@ -252,6 +277,7 @@ class RegistrationForm(models.Model):
|
||||
# One-to-One Relationship
|
||||
student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True)
|
||||
status = models.IntegerField(choices=RegistrationFormStatus, default=RegistrationFormStatus.RF_IDLE)
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
last_update = models.DateTimeField(auto_now=True)
|
||||
school_year = models.CharField(max_length=9, default="", blank=True)
|
||||
notes = models.CharField(max_length=200, blank=True)
|
||||
@ -358,6 +384,7 @@ class RegistrationSchoolFileMaster(models.Model):
|
||||
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
|
||||
name = models.CharField(max_length=255, default="")
|
||||
is_required = models.BooleanField(default=False)
|
||||
requires_electronic_signature = models.BooleanField(default=False, help_text="Si activé, le parent devra signer électroniquement ce document")
|
||||
formMasterData = models.JSONField(default=list, blank=True, null=True)
|
||||
file = models.FileField(
|
||||
upload_to=registration_school_file_master_upload_to,
|
||||
@ -398,7 +425,9 @@ class RegistrationSchoolFileMaster(models.Model):
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
):
|
||||
new_filename = f"{self.name}.pdf"
|
||||
# Si un fichier source est déjà présent, conserver son extension.
|
||||
extension = os.path.splitext(old_filename)[1] or '.pdf'
|
||||
new_filename = f"{self.name}{extension}"
|
||||
else:
|
||||
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
||||
extension = os.path.splitext(old_filename)[1]
|
||||
@ -433,16 +462,9 @@ class RegistrationSchoolFileMaster(models.Model):
|
||||
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||
pass
|
||||
|
||||
# --- Traitement PDF dynamique AVANT le super().save() ---
|
||||
if (
|
||||
self.formMasterData
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
):
|
||||
from Subscriptions.util import generate_form_json_pdf
|
||||
pdf_filename = f"{self.name}.pdf"
|
||||
pdf_file = generate_form_json_pdf(self, self.formMasterData)
|
||||
self.file.save(pdf_filename, pdf_file, save=False)
|
||||
# IMPORTANT: pour les formulaires dynamiques, le fichier du master doit
|
||||
# rester le document source uploadé (PDF/image). La génération du PDF final
|
||||
# est faite au niveau des templates (par élève), pas sur le master.
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -535,7 +557,11 @@ class RegistrationSchoolFileTemplate(models.Model):
|
||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
||||
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
||||
isValidated = models.BooleanField(default=False)
|
||||
# Tri-etat: None=en attente, True=valide, False=refuse
|
||||
isValidated = models.BooleanField(null=True, blank=True, default=None)
|
||||
# Signature électronique (base64 SVG ou PNG)
|
||||
electronic_signature = models.TextField(null=True, blank=True, help_text="Signature électronique encodée en base64")
|
||||
electronic_signature_date = models.DateTimeField(null=True, blank=True, help_text="Date de la signature électronique")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@ -578,6 +604,8 @@ class StudentCompetency(models.Model):
|
||||
default="",
|
||||
blank=True
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('student', 'establishment_competency', 'period')
|
||||
@ -589,12 +617,34 @@ class StudentCompetency(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.student} - {self.establishment_competency} - Score: {self.score} - Period: {self.period}"
|
||||
|
||||
|
||||
class StudentEvaluation(models.Model):
|
||||
"""
|
||||
Note d'un élève pour une évaluation.
|
||||
Déplacé depuis School pour éviter les dépendances circulaires.
|
||||
"""
|
||||
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='evaluation_scores')
|
||||
evaluation = models.ForeignKey('School.Evaluation', on_delete=models.CASCADE, related_name='student_scores')
|
||||
score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||
comment = models.TextField(blank=True)
|
||||
is_absent = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('student', 'evaluation')
|
||||
|
||||
def __str__(self):
|
||||
score_display = 'Absent' if self.is_absent else self.score
|
||||
return f"{self.student} - {self.evaluation.name}: {score_display}"
|
||||
|
||||
####### Parent files templates (par dossier d'inscription) #######
|
||||
class RegistrationParentFileTemplate(models.Model):
|
||||
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
|
||||
isValidated = models.BooleanField(default=False)
|
||||
# Tri-etat: None=en attente, True=valide, False=refuse
|
||||
isValidated = models.BooleanField(null=True, blank=True, default=None)
|
||||
|
||||
def __str__(self):
|
||||
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
||||
|
||||
@ -21,7 +21,6 @@ from N3wtSchool import settings
|
||||
from django.utils import timezone
|
||||
import pytz
|
||||
import Subscriptions.util as util
|
||||
from N3wtSchool.mailManager import sendRegisterForm
|
||||
|
||||
class AbsenceManagementSerializer(serializers.ModelSerializer):
|
||||
student_name = serializers.SerializerMethodField()
|
||||
@ -39,10 +38,15 @@ class AbsenceManagementSerializer(serializers.ModelSerializer):
|
||||
|
||||
class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RegistrationSchoolFileMaster
|
||||
fields = '__all__'
|
||||
|
||||
def get_file_url(self, obj):
|
||||
return obj.file.url if obj.file else None
|
||||
|
||||
class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
class Meta:
|
||||
@ -52,6 +56,9 @@ class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
||||
class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
master_file_url = serializers.SerializerMethodField()
|
||||
requires_electronic_signature = serializers.SerializerMethodField()
|
||||
is_electronically_signed = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RegistrationSchoolFileTemplate
|
||||
@ -61,6 +68,33 @@ class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||
# Retourne l'URL complète du fichier si disponible
|
||||
return obj.file.url if obj.file else None
|
||||
|
||||
def get_master_file_url(self, obj):
|
||||
# URL du fichier source du master (pour l'aperçu FileUpload côté parent)
|
||||
if obj.master and obj.master.file:
|
||||
return obj.master.file.url
|
||||
return None
|
||||
|
||||
def get_requires_electronic_signature(self, obj):
|
||||
# Retourne si le document nécessite une signature électronique
|
||||
if obj.master:
|
||||
return obj.master.requires_electronic_signature
|
||||
return False
|
||||
|
||||
def get_is_electronically_signed(self, obj):
|
||||
# Retourne True si le document a été signé électroniquement
|
||||
return bool(obj.electronic_signature)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Auto-remplir la date de signature si electronic_signature est fournie
|
||||
from django.utils import timezone
|
||||
if 'electronic_signature' in validated_data and validated_data['electronic_signature']:
|
||||
# Nouvelle signature ou re-signature : enregistrer la date
|
||||
validated_data['electronic_signature_date'] = timezone.now()
|
||||
# Si le document était refusé, le repasser en attente de validation
|
||||
if instance.isValidated == False:
|
||||
validated_data['isValidated'] = None
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class RegistrationParentFileTemplateSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
@ -216,14 +250,6 @@ class StudentSerializer(serializers.ModelSerializer):
|
||||
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
|
||||
profile_role_serializer.is_valid(raise_exception=True)
|
||||
profile_role = profile_role_serializer.save()
|
||||
# Envoi du mail d'inscription si un nouveau profil vient d'être créé
|
||||
email = None
|
||||
if profile_data and 'email' in profile_data:
|
||||
email = profile_data['email']
|
||||
elif profile_role and profile_role.profile:
|
||||
email = profile_role.profile.email
|
||||
if email:
|
||||
sendRegisterForm(email, establishment_id)
|
||||
elif profile_role:
|
||||
# Récupérer un ProfileRole existant par son ID
|
||||
profile_role = ProfileRole.objects.get(id=profile_role.id)
|
||||
@ -399,10 +425,11 @@ class RegistrationFormSerializer(serializers.ModelSerializer):
|
||||
class StudentByParentSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
associated_class_name = serializers.SerializerMethodField()
|
||||
associated_class_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name']
|
||||
fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name', 'associated_class_id']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(StudentByParentSerializer, self).__init__(*args, **kwargs)
|
||||
@ -412,6 +439,9 @@ class StudentByParentSerializer(serializers.ModelSerializer):
|
||||
def get_associated_class_name(self, obj):
|
||||
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
||||
|
||||
def get_associated_class_id(self, obj):
|
||||
return obj.associated_class.id if obj.associated_class else None
|
||||
|
||||
class RegistrationFormByParentSerializer(serializers.ModelSerializer):
|
||||
student = StudentByParentSerializer(many=False, required=True)
|
||||
|
||||
@ -452,11 +482,12 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
guardians = GuardianByDICreationSerializer(many=True, required=False)
|
||||
associated_class_name = serializers.SerializerMethodField()
|
||||
associated_class_id = serializers.SerializerMethodField()
|
||||
bilans = BilanCompetenceSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo', 'bilans']
|
||||
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'associated_class_id', 'photo', 'bilans']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
|
||||
@ -466,6 +497,9 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
|
||||
def get_associated_class_name(self, obj):
|
||||
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
||||
|
||||
def get_associated_class_id(self, obj):
|
||||
return obj.associated_class.id if obj.associated_class else None
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
notification_type_label = serializers.ReadOnlyField()
|
||||
|
||||
|
||||
@ -4,9 +4,22 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>Bilan de compétences</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 2em; }
|
||||
body { font-family: Arial, sans-serif; margin: 1.2em; color: #111827; }
|
||||
h1, h2 { color: #059669; }
|
||||
.student-info { margin-bottom: 2em; }
|
||||
.top-header { width: 100%; border-bottom: 2px solid #d1fae5; border-collapse: collapse; margin-bottom: 14px; }
|
||||
.top-header td { vertical-align: top; border: none; padding: 0; }
|
||||
.school-logo { width: 54px; height: 54px; object-fit: contain; margin-right: 8px; }
|
||||
.product-logo { width: 58px; }
|
||||
.title-row { margin: 8px 0 10px 0; }
|
||||
.student-info {
|
||||
margin-bottom: 1em;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.student-info table { width: 100%; border-collapse: collapse; font-size: 0.98em; }
|
||||
.student-info td { border: none; padding: 1px 0; }
|
||||
.domain-table { width: 100%; border-collapse: collapse; margin-bottom: 2em; }
|
||||
.domain-header th {
|
||||
background: #d1fae5;
|
||||
@ -25,16 +38,77 @@
|
||||
th, td { border: 1px solid #e5e7eb; padding: 0.5em; }
|
||||
th.competence-header { background: #d1fae5; }
|
||||
td.competence-nom { word-break: break-word; max-width: 320px; }
|
||||
.footer-note { margin-top: 32px; }
|
||||
.comment-space {
|
||||
min-height: 180px;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 78px;
|
||||
}
|
||||
.footer-grid { width: 100%; border-collapse: collapse; }
|
||||
.footer-grid td {
|
||||
border: none;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.field-line { border-bottom: 1px solid #9ca3af; height: 24px; margin-top: 6px; }
|
||||
.signature-line { border-bottom: 2px solid #059669; height: 30px; margin-top: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table class="top-header">
|
||||
<tr>
|
||||
<td style="width: 68%; padding-bottom: 8px;">
|
||||
<table style="border-collapse: collapse; width: 100%;">
|
||||
<tr>
|
||||
{% if establishment.logo_path %}
|
||||
<td style="width: 64px; border: none; vertical-align: top;">
|
||||
<img src="{{ establishment.logo_path }}" alt="Logo établissement" class="school-logo">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td style="border: none; vertical-align: top;">
|
||||
<div style="font-size: 1.25em; font-weight: 700; color: #065f46; margin-top: 2px;">{{ establishment.name }}</div>
|
||||
{% if establishment.address %}
|
||||
<div style="font-size: 0.9em; color: #4b5563; margin-top: 4px;">{{ establishment.address }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="width: 32%; text-align: right; padding-bottom: 8px;">
|
||||
<table style="border-collapse: collapse; width: 100%; margin-left: auto;">
|
||||
<tr>
|
||||
<td style="border: none; text-align: right;">
|
||||
<div style="font-size: 0.86em; color: #6b7280; margin-bottom: 4px;">Généré avec</div>
|
||||
{% if product.logo_path %}
|
||||
<div style="margin-bottom: 4px;"><img src="{{ product.logo_path }}" alt="Logo n3wt" class="product-logo"></div>
|
||||
{% endif %}
|
||||
<div style="font-size: 0.95em; font-weight: 700; color: #059669;">{{ product.name }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="title-row">
|
||||
<h1>Bilan de compétences</h1>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}<br>
|
||||
<strong>Niveau :</strong> {{ student.level }}<br>
|
||||
<strong>Classe :</strong> {{ student.class_name }}<br>
|
||||
<strong>Période :</strong> {{ period }}<br>
|
||||
<strong>Date :</strong> {{ date }}
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}</td>
|
||||
<td style="text-align: right;"><strong>Date :</strong> {{ date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Niveau :</strong> {{ student.level }}</td>
|
||||
<td style="text-align: right;"><strong>Période :</strong> {{ period }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Classe :</strong> {{ student.class_name }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% for domaine in domaines %}
|
||||
@ -72,41 +146,33 @@
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
<div style="margin-top: 60px; padding: 0; max-width: 700px;">
|
||||
<div style="
|
||||
min-height: 180px;
|
||||
background: #fff;
|
||||
border: 1.5px dashed #a7f3d0;
|
||||
border-radius: 12px;
|
||||
padding: 24px 24px 18px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
margin-bottom: 64px; /* Augmente l'espace après l'encadré */
|
||||
">
|
||||
<div style="font-weight: bold; color: #059669; font-size: 1.25em; display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span>Appréciation générale / Commentaire : </span>
|
||||
</div>
|
||||
<!-- Espace vide pour écrire -->
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 48px; margin-top: 32px;">
|
||||
<div>
|
||||
<span style="font-weight: bold; color: #059669;font-size: 1.25em;">Date :</span>
|
||||
<span style="display: inline-block; min-width: 120px; border-bottom: 1.5px solid #a7f3d0; margin-left: 8px;"> </span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-weight: bold; color: #059669;font-size: 1.25em;">Signature :</span>
|
||||
<span style="display: inline-block; min-width: 180px; border-bottom: 2px solid #059669; margin-left: 8px;"> </span>
|
||||
</div>
|
||||
<div class="footer-note">
|
||||
<div style="font-weight: 700; color: #059669; font-size: 1.1em;">
|
||||
Appréciation générale / Commentaire
|
||||
</div>
|
||||
<div class="comment-space"></div>
|
||||
|
||||
<table class="footer-grid">
|
||||
<tr>
|
||||
<td style="width: 45%;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="width: 70px; border: none; font-weight: 700; color: #059669;">Date :</td>
|
||||
<td style="border: none;"><div class="field-line"></div></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="width: 10%;"></td>
|
||||
<td style="width: 45%;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="width: 90px; border: none; font-weight: 700; color: #059669;">Signature :</td>
|
||||
<td style="border: none;"><div class="signature-line"></div></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
245
Back-End/Subscriptions/templates/pdfs/dynamic_form.html
Normal file
245
Back-End/Subscriptions/templates/pdfs/dynamic_form.html
Normal file
@ -0,0 +1,245 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{{ pdf_title }}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 1.4cm 1.6cm;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.35;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
border-left: 1px dashed #cbd5e1;
|
||||
border-right: 1px dashed #cbd5e1;
|
||||
padding: 0 22px 8px 22px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22pt;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin: 0 0 18px 0;
|
||||
}
|
||||
|
||||
.heading1 {
|
||||
font-size: 26pt;
|
||||
font-weight: bold;
|
||||
margin: 18px 0 10px 0;
|
||||
}
|
||||
|
||||
.heading2 {
|
||||
font-size: 18pt;
|
||||
font-weight: bold;
|
||||
margin: 14px 0 8px 0;
|
||||
}
|
||||
|
||||
.heading3 {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
margin: 12px 0 6px 0;
|
||||
}
|
||||
|
||||
.heading4,
|
||||
.heading5,
|
||||
.heading6 {
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
margin: 10px 0 6px 0;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0 0 12px 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.field-block {
|
||||
margin: 10px 0 14px 0;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
padding: 8px 10px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.input-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.phone-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.phone-prefix {
|
||||
width: 78px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f8fafc;
|
||||
padding: 8px 10px;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.phone-value {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 8px 10px;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f8fafc;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.preview-page {
|
||||
margin: 8px 0;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-page img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 8.5pt;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.empty-file {
|
||||
border: 2px dashed #94a3b8;
|
||||
background: #ffffff;
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.signature-box {
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
height: 84px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.signature-box img {
|
||||
max-width: 200px;
|
||||
max-height: 70px;
|
||||
margin: 6px auto;
|
||||
}
|
||||
|
||||
.field-meta {
|
||||
margin-top: 4px;
|
||||
color: #6b7280;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<h1 class="title">{{ pdf_title }}</h1>
|
||||
|
||||
{% for field in fields %}
|
||||
{% if field.kind == 'heading' %}
|
||||
{% if field.level == 'heading1' %}
|
||||
<div class="heading1">{{ field.text }}</div>
|
||||
{% elif field.level == 'heading2' %}
|
||||
<div class="heading2">{{ field.text }}</div>
|
||||
{% elif field.level == 'heading3' %}
|
||||
<div class="heading3">{{ field.text }}</div>
|
||||
{% elif field.level == 'heading4' %}
|
||||
<div class="heading4">{{ field.text }}</div>
|
||||
{% elif field.level == 'heading5' %}
|
||||
<div class="heading5">{{ field.text }}</div>
|
||||
{% else %}
|
||||
<div class="heading6">{{ field.text }}</div>
|
||||
{% endif %}
|
||||
{% elif field.kind == 'paragraph' %}
|
||||
<p class="paragraph">{{ field.text }}</p>
|
||||
{% elif field.kind == 'file' %}
|
||||
<div class="field-block">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="file-card">
|
||||
{% if field.has_preview %}
|
||||
{% for preview in field.preview_pages %}
|
||||
<div class="preview-page">
|
||||
<img src="{{ preview.src }}" alt="{{ preview.alt }}" width="{{ preview.width }}" height="{{ preview.height }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if field.total_pages > 1 %}
|
||||
<div class="preview-meta">Aperçu de la première page sur {{ field.total_pages }} pages</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty-file">Aucun document source fourni</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.kind == 'phone' %}
|
||||
<div class="field-block">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<table class="phone-table">
|
||||
<tr>
|
||||
<td class="phone-prefix">{{ field.prefix }}</td>
|
||||
<td class="phone-value">
|
||||
{% if field.value %}
|
||||
{{ field.value }}
|
||||
{% else %}
|
||||
<span class="input-placeholder"> </span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% elif field.kind == 'signature' %}
|
||||
<div class="field-block">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="signature-box">
|
||||
{% if field.signature_src %}
|
||||
<img src="{{ field.signature_src }}" alt="Signature" />
|
||||
{% else %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="field-block">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="input-box">
|
||||
{% if field.value %}
|
||||
{{ field.value }}
|
||||
{% else %}
|
||||
<span class="input-placeholder"> </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if field.field_type %}
|
||||
<div class="field-meta">Type: {{ field.field_type }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,228 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Fiche élève de {{ student.last_name }} {{ student.first_name }}</title>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>
|
||||
Fiche élève — {{ student.last_name }} {{ student.first_name }}
|
||||
</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
margin: 1.5cm 2cm;
|
||||
}
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
font-size: 12pt;
|
||||
color: #222;
|
||||
font-family: "Helvetica", "Arial", sans-serif;
|
||||
font-size: 10pt;
|
||||
color: #1e293b;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.container {
|
||||
|
||||
/* ── Header ── */
|
||||
.header-table {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
border: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
padding-bottom: 12px;
|
||||
position: relative;
|
||||
.header-table td {
|
||||
border: none;
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.header-left {
|
||||
width: 80%;
|
||||
}
|
||||
.header-right {
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
}
|
||||
.school-name {
|
||||
font-size: 10pt;
|
||||
color: #64748b;
|
||||
margin: 0 0 4px 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.title {
|
||||
font-size: 22pt;
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
color: #064e3b;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 11pt;
|
||||
color: #059669;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
.header-line {
|
||||
border: none;
|
||||
border-top: 3px solid #059669;
|
||||
margin: 12px 0 20px 0;
|
||||
}
|
||||
.photo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #4CAF50;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #059669;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Sections ── */
|
||||
.section {
|
||||
margin-bottom: 32px; /* Espacement augmenté entre les sections */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15pt;
|
||||
.section-header {
|
||||
background-color: #059669;
|
||||
color: #ffffff;
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 18px; /* Espacement sous le titre de section */
|
||||
border-bottom: 1px solid #4CAF50;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #bbb;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #f3f3f3;
|
||||
font-weight: bold;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
.label-cell {
|
||||
font-weight: bold;
|
||||
width: 30%;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.value-cell {
|
||||
width: 70%;
|
||||
}
|
||||
.signature {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
}
|
||||
.signature-text {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 0;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.subsection-title {
|
||||
font-size: 12pt;
|
||||
color: #333;
|
||||
margin: 8px 0 4px 0;
|
||||
font-size: 10pt;
|
||||
color: #064e3b;
|
||||
font-weight: bold;
|
||||
padding: 6px 0 2px 0;
|
||||
margin: 8px 0 4px 0;
|
||||
border-bottom: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
table.data {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
table.data td {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 10pt;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.data .label {
|
||||
font-weight: bold;
|
||||
color: #064e3b;
|
||||
background-color: #f0fdf4;
|
||||
width: 25%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.data .value {
|
||||
color: #1e293b;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
/* ── Paiement ── */
|
||||
table.payment {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.payment td {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 10pt;
|
||||
}
|
||||
table.payment .label {
|
||||
font-weight: bold;
|
||||
color: #064e3b;
|
||||
background-color: #f0fdf4;
|
||||
width: 35%;
|
||||
}
|
||||
table.payment .value {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
/* ── Footer / Signature ── */
|
||||
.signature-block {
|
||||
margin-top: 24px;
|
||||
padding: 10px 12px;
|
||||
background-color: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.signature-block p {
|
||||
margin: 0;
|
||||
font-size: 10pt;
|
||||
color: #475569;
|
||||
}
|
||||
.signature-date {
|
||||
font-weight: bold;
|
||||
color: #064e3b;
|
||||
}
|
||||
.footer-line {
|
||||
border: none;
|
||||
border-top: 2px solid #059669;
|
||||
margin: 20px 0 8px 0;
|
||||
}
|
||||
.footer-text {
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
{% load myTemplateTag %}
|
||||
<div class="container">
|
||||
<!-- Header Section -->
|
||||
<div class="header">
|
||||
<h1 class="title">Fiche élève de {{ student.last_name }} {{ student.first_name }}</h1>
|
||||
{% if student.photo %}
|
||||
<img src="{{ student.get_photo_url }}" alt="Photo de l'élève" class="photo" />
|
||||
{% else %}
|
||||
<img src="/static/img/default-photo.jpg" alt="Photo par défaut" class="photo" />
|
||||
|
||||
<!-- ═══════ HEADER ═══════ -->
|
||||
<table class="header-table">
|
||||
<tr>
|
||||
<td class="header-left">
|
||||
{% if establishment %}
|
||||
<p class="school-name">{{ establishment.name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="title">Fiche Élèves</h1>
|
||||
<!-- prettier-ignore -->
|
||||
<p class="subtitle">{{ student.last_name }} {{ student.first_name }}{% if school_year %} — {{ school_year }}{% endif %}</p>
|
||||
</td>
|
||||
<td class="header-right">
|
||||
{% if student.photo %}
|
||||
<img src="{{ student.get_photo_url }}" alt="Photo" class="photo" />
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr class="header-line" />
|
||||
|
||||
<!-- Élève -->
|
||||
<!-- ═══════ ÉLÈVE ═══════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">ÉLÈVE</div>
|
||||
<table>
|
||||
<div class="section-header">INFORMATIONS DE L'ÉLÈVE</div>
|
||||
<table class="data">
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ student.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ student.first_name }}</td>
|
||||
<td class="label">Nom</td>
|
||||
<td class="value">{{ student.last_name }}</td>
|
||||
<td class="label">Prénom</td>
|
||||
<td class="value">{{ student.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Adresse</td>
|
||||
<td class="value-cell" colspan="3">{{ student.address }}</td>
|
||||
<td class="label">Genre</td>
|
||||
<td class="value">{{ student|getStudentGender }}</td>
|
||||
<td class="label">Niveau</td>
|
||||
<td class="value">{{ student|getStudentLevel }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Genre</td>
|
||||
<td class="value-cell">{{ student|getStudentGender }}</td>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell">{{ student.birth_date }}</td>
|
||||
<td class="label">Date de naissance</td>
|
||||
<td class="value">{{ student.formatted_birth_date }}</td>
|
||||
<td class="label">Lieu de naissance</td>
|
||||
<!-- prettier-ignore -->
|
||||
<td class="value">{{ student.birth_place }}{% if student.birth_postal_code %} ({{ student.birth_postal_code }}){% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">À</td>
|
||||
<td class="value-cell">{{ student.birth_place }} ({{ student.birth_postal_code }})</td>
|
||||
<td class="label-cell">Nationalité</td>
|
||||
<td class="value-cell">{{ student.nationality }}</td>
|
||||
<td class="label">Nationalité</td>
|
||||
<td class="value">{{ student.nationality }}</td>
|
||||
<td class="label">Médecin traitant</td>
|
||||
<td class="value">{{ student.attending_physician }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Niveau</td>
|
||||
<td class="value-cell">{{ student|getStudentLevel }}</td>
|
||||
<td class="label-cell"></td>
|
||||
<td class="value-cell"></td>
|
||||
<td class="label">Adresse</td>
|
||||
<td class="value" colspan="3">{{ student.address }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Responsables -->
|
||||
<!-- ═══════ RESPONSABLES ═══════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">RESPONSABLES</div>
|
||||
<div class="section-header">RESPONSABLES LÉGAUX</div>
|
||||
{% for guardian in student.getGuardians %}
|
||||
<div>
|
||||
<div class="subsection-title">Responsable {{ forloop.counter }}</div>
|
||||
<table>
|
||||
<table class="data">
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ guardian.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ guardian.first_name }}</td>
|
||||
<td class="label">Nom</td>
|
||||
<td class="value">{{ guardian.last_name }}</td>
|
||||
<td class="label">Prénom</td>
|
||||
<td class="value">{{ guardian.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Adresse</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.address }}</td>
|
||||
<td class="label">Date de naissance</td>
|
||||
<td class="value">{{ guardian.birth_date }}</td>
|
||||
<td class="label">Téléphone</td>
|
||||
<td class="value">{{ guardian.phone|phone_format }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Email</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.email }}</td>
|
||||
<td class="label">Email</td>
|
||||
<td class="value" colspan="3">{{ guardian.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell">{{ guardian.birth_date }}</td>
|
||||
<td class="label-cell">Téléphone</td>
|
||||
<td class="value-cell">{{ guardian.phone|phone_format }}</td>
|
||||
<td class="label">Adresse</td>
|
||||
<td class="value" colspan="3">{{ guardian.address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Profession</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.profession }}</td>
|
||||
<td class="label">Profession</td>
|
||||
<td class="value" colspan="3">{{ guardian.profession }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p style="color: #94a3b8; font-style: italic; padding: 8px">
|
||||
Aucun responsable renseigné.
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Fratrie -->
|
||||
<!-- ═══════ FRATRIE ═══════ -->
|
||||
{% if student.getSiblings %}
|
||||
<div class="section">
|
||||
<div class="section-title">FRATRIE</div>
|
||||
<div class="section-header">FRATRIE</div>
|
||||
{% for sibling in student.getSiblings %}
|
||||
<div>
|
||||
<div class="subsection-title">Frère/Soeur {{ forloop.counter }}</div>
|
||||
<table>
|
||||
<div class="subsection-title">Frère / Sœur {{ forloop.counter }}</div>
|
||||
<table class="data">
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ sibling.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ sibling.first_name }}</td>
|
||||
<td class="label">Nom</td>
|
||||
<td class="value">{{ sibling.last_name }}</td>
|
||||
<td class="label">Prénom</td>
|
||||
<td class="value">{{ sibling.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell" colspan="3">{{ sibling.birth_date }}</td>
|
||||
<td class="label">Date de naissance</td>
|
||||
<td class="value" colspan="3">{{ sibling.birth_date }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Paiement -->
|
||||
<!-- ═══════ PAIEMENT ═══════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">MODALITÉS DE PAIEMENT</div>
|
||||
<table>
|
||||
<div class="section-header">MODALITÉS DE PAIEMENT</div>
|
||||
<table class="payment">
|
||||
<tr>
|
||||
<td class="label-cell">Frais d'inscription</td>
|
||||
<td class="value-cell">{{ student|getRegistrationPaymentMethod }} en {{ student|getRegistrationPaymentPlan }}</td>
|
||||
<td class="label">Frais d'inscription</td>
|
||||
<!-- prettier-ignore -->
|
||||
<td class="value">{{ student|getRegistrationPaymentMethod }} — {{ student|getRegistrationPaymentPlan }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Frais de scolarité</td>
|
||||
<td class="value-cell">{{ student|getTuitionPaymentMethod }} en {{ student|getTuitionPaymentPlan }}</td>
|
||||
<td class="label">Frais de scolarité</td>
|
||||
<!-- prettier-ignore -->
|
||||
<td class="value">{{ student|getTuitionPaymentMethod }} — {{ student|getTuitionPaymentPlan }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Signature -->
|
||||
<div class="signature">
|
||||
Fait le <span class="signature-text">{{ signatureDate }}</span> à <span class="signature-text">{{ signatureTime }}</span>
|
||||
<!-- ═══════ SIGNATURE ═══════ -->
|
||||
<div class="signature-block">
|
||||
<p>
|
||||
Document généré le
|
||||
<span class="signature-date">{{ signatureDate }}</span> à
|
||||
<span class="signature-date">{{ signatureTime }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<hr class="footer-line" />
|
||||
<p class="footer-text">
|
||||
Ce document est généré automatiquement et fait office de fiche
|
||||
d'inscription.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@ -3,7 +3,7 @@ from django.urls import path, re_path
|
||||
from . import views
|
||||
|
||||
# RF
|
||||
from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive
|
||||
from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, generate_registration_pdf
|
||||
# SubClasses
|
||||
from .views import StudentView, GuardianView, ChildrenListView, StudentListView, DissociateGuardianView
|
||||
# Files
|
||||
@ -30,6 +30,7 @@ from .views import (
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/pdf$', generate_registration_pdf, name="generate_registration_pdf"),
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/archive$', archive, name="archive"),
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/resend$', resend, name="resend"),
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/send$', send, name="send"),
|
||||
|
||||
@ -8,18 +8,22 @@ from N3wtSchool import renderers
|
||||
from N3wtSchool import bdd
|
||||
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.graphics import renderPDF
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files import File
|
||||
from pathlib import Path
|
||||
import os
|
||||
from enum import Enum
|
||||
from urllib.parse import unquote_to_bytes
|
||||
|
||||
import random
|
||||
import string
|
||||
from rest_framework.parsers import JSONParser
|
||||
from PyPDF2 import PdfMerger, PdfReader
|
||||
from PyPDF2 import PdfMerger, PdfReader, PdfWriter
|
||||
from PyPDF2.errors import PdfReadError
|
||||
|
||||
import shutil
|
||||
@ -29,9 +33,79 @@ import json
|
||||
from django.http import QueryDict
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from svglib.svglib import svg2rlg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _draw_signature_data_url(cnv, data_url, x, y, width, height):
|
||||
"""
|
||||
Dessine une signature issue d'un data URL dans un canvas ReportLab.
|
||||
Supporte les images raster (PNG/JPEG/...) et SVG.
|
||||
Retourne True si la signature a pu etre dessinee.
|
||||
"""
|
||||
if not isinstance(data_url, str) or not data_url.startswith("data:image"):
|
||||
return False
|
||||
|
||||
try:
|
||||
header, payload = data_url.split(',', 1)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
is_base64 = ';base64' in header
|
||||
mime_type = header.split(':', 1)[1].split(';', 1)[0] if ':' in header else ''
|
||||
|
||||
try:
|
||||
raw_bytes = base64.b64decode(payload) if is_base64 else unquote_to_bytes(payload)
|
||||
except Exception as e:
|
||||
logger.error(f"[_draw_signature_data_url] Decodage impossible: {e}")
|
||||
return False
|
||||
|
||||
# Support SVG via svglib (deja present dans requirements)
|
||||
if mime_type == 'image/svg+xml':
|
||||
try:
|
||||
drawing = svg2rlg(BytesIO(raw_bytes))
|
||||
if drawing is None:
|
||||
return False
|
||||
|
||||
src_w = float(getattr(drawing, 'width', 0) or 0)
|
||||
src_h = float(getattr(drawing, 'height', 0) or 0)
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return False
|
||||
|
||||
scale = min(width / src_w, height / src_h)
|
||||
draw_w = src_w * scale
|
||||
draw_h = src_h * scale
|
||||
offset_x = x + (width - draw_w) / 2
|
||||
offset_y = y + (height - draw_h) / 2
|
||||
|
||||
cnv.saveState()
|
||||
cnv.translate(offset_x, offset_y)
|
||||
cnv.scale(scale, scale)
|
||||
renderPDF.draw(drawing, cnv, 0, 0)
|
||||
cnv.restoreState()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[_draw_signature_data_url] Rendu SVG impossible: {e}")
|
||||
return False
|
||||
|
||||
# Support images raster classiques
|
||||
try:
|
||||
img_reader = ImageReader(BytesIO(raw_bytes))
|
||||
cnv.drawImage(
|
||||
img_reader,
|
||||
x,
|
||||
y,
|
||||
width=width,
|
||||
height=height,
|
||||
preserveAspectRatio=True,
|
||||
mask='auto',
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[_draw_signature_data_url] Rendu raster impossible: {e}")
|
||||
return False
|
||||
|
||||
def save_file_replacing_existing(file_field, filename, content, save=True):
|
||||
"""
|
||||
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
|
||||
@ -55,6 +129,42 @@ def save_file_replacing_existing(file_field, filename, content, save=True):
|
||||
# Sauvegarder le nouveau fichier
|
||||
file_field.save(filename, content, save=save)
|
||||
|
||||
def save_file_field_without_suffix(instance, field_name, filename, content, save=False):
|
||||
"""
|
||||
Sauvegarde un fichier dans un FileField Django en ecrasant le precedent,
|
||||
sans laisser Django generer de suffixe (_abc123).
|
||||
|
||||
Args:
|
||||
instance: instance Django portant le FileField
|
||||
field_name: nom du FileField (ex: 'file')
|
||||
filename: nom de fichier cible (basename)
|
||||
content: contenu fichier (ContentFile, File, etc.)
|
||||
save: si True, persiste immediatement l'instance
|
||||
"""
|
||||
file_field = getattr(instance, field_name)
|
||||
field = instance._meta.get_field(field_name)
|
||||
storage = file_field.storage
|
||||
|
||||
target_name = field.generate_filename(instance, filename)
|
||||
|
||||
# Supprimer le fichier actuellement reference si different
|
||||
if file_field and file_field.name and file_field.name != target_name:
|
||||
try:
|
||||
if storage.exists(file_field.name):
|
||||
storage.delete(file_field.name)
|
||||
except Exception as e:
|
||||
logger.error(f"[save_file_field_without_suffix] Erreur suppression ancien fichier ({file_field.name}): {e}")
|
||||
|
||||
# Supprimer explicitement la cible si elle existe deja
|
||||
try:
|
||||
if storage.exists(target_name):
|
||||
storage.delete(target_name)
|
||||
except Exception as e:
|
||||
logger.error(f"[save_file_field_without_suffix] Erreur suppression cible ({target_name}): {e}")
|
||||
|
||||
# Sauvegarde: la cible n'existe plus, donc pas de suffixe
|
||||
file_field.save(filename, content, save=save)
|
||||
|
||||
def build_payload_from_request(request):
|
||||
"""
|
||||
Normalise la request en payload prêt à être donné au serializer.
|
||||
@ -194,6 +304,91 @@ def create_templates_for_registration_form(register_form):
|
||||
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
||||
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
||||
|
||||
is_dynamic_master = (
|
||||
isinstance(m.formMasterData, dict)
|
||||
and bool(m.formMasterData.get("fields"))
|
||||
)
|
||||
|
||||
# Formulaire dynamique: toujours générer le PDF final depuis le JSON
|
||||
# (aperçu admin) au lieu de copier le fichier source brut (PNG/PDF).
|
||||
if is_dynamic_master:
|
||||
base_pdf_content = None
|
||||
base_file_ext = None
|
||||
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||
base_file_ext = os.path.splitext(m.file.name)[1].lower()
|
||||
try:
|
||||
m.file.open('rb')
|
||||
base_pdf_content = m.file.read()
|
||||
m.file.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture fichier source master dynamique: {e}")
|
||||
|
||||
try:
|
||||
generated_pdf = generate_form_json_pdf(
|
||||
register_form,
|
||||
m.formMasterData,
|
||||
base_pdf_content=base_pdf_content,
|
||||
base_file_ext=base_file_ext,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur génération PDF dynamique pour template: {e}")
|
||||
generated_pdf = None
|
||||
|
||||
if tmpl:
|
||||
try:
|
||||
if tmpl.file and tmpl.file.name:
|
||||
tmpl.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression ancien fichier template dynamique %s", getattr(tmpl, "pk", None))
|
||||
|
||||
tmpl.name = m.name or ""
|
||||
tmpl.slug = slug
|
||||
tmpl.formTemplateData = m.formMasterData or []
|
||||
if generated_pdf is not None:
|
||||
output_filename = os.path.basename(generated_pdf.name)
|
||||
save_file_field_without_suffix(
|
||||
tmpl,
|
||||
'file',
|
||||
output_filename,
|
||||
generated_pdf,
|
||||
save=False,
|
||||
)
|
||||
tmpl.save()
|
||||
created.append(tmpl)
|
||||
logger.info(
|
||||
"util.create_templates_for_registration_form - Regenerated dynamic school template %s from master %s for RF %s",
|
||||
tmpl.pk,
|
||||
m.pk,
|
||||
register_form.pk,
|
||||
)
|
||||
continue
|
||||
|
||||
tmpl = RegistrationSchoolFileTemplate(
|
||||
master=m,
|
||||
registration_form=register_form,
|
||||
name=m.name or "",
|
||||
formTemplateData=m.formMasterData or [],
|
||||
slug=slug,
|
||||
)
|
||||
if generated_pdf is not None:
|
||||
output_filename = os.path.basename(generated_pdf.name)
|
||||
save_file_field_without_suffix(
|
||||
tmpl,
|
||||
'file',
|
||||
output_filename,
|
||||
generated_pdf,
|
||||
save=False,
|
||||
)
|
||||
tmpl.save()
|
||||
created.append(tmpl)
|
||||
logger.info(
|
||||
"util.create_templates_for_registration_form - Created dynamic school template %s from master %s for RF %s",
|
||||
tmpl.pk,
|
||||
m.pk,
|
||||
register_form.pk,
|
||||
)
|
||||
continue
|
||||
|
||||
file_name = None
|
||||
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||
file_name = os.path.basename(m.file.name)
|
||||
@ -207,19 +402,21 @@ def create_templates_for_registration_form(register_form):
|
||||
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
|
||||
file_name = None
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
upload_rel_path = registration_school_file_upload_to(
|
||||
type("Tmp", (), {
|
||||
"registration_form": register_form,
|
||||
"establishment": getattr(register_form, "establishment", None),
|
||||
"student": getattr(register_form, "student", None)
|
||||
})(),
|
||||
file_name
|
||||
)
|
||||
abs_path = os.path.join(settings.MEDIA_ROOT, upload_rel_path)
|
||||
master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None
|
||||
|
||||
def _build_upload_path(template_pk):
|
||||
"""Génère le chemin relatif et absolu pour un template avec un pk connu."""
|
||||
rel = registration_school_file_upload_to(
|
||||
type("Tmp", (), {
|
||||
"registration_form": register_form,
|
||||
"pk": template_pk,
|
||||
})(),
|
||||
file_name,
|
||||
)
|
||||
return rel, os.path.join(settings.MEDIA_ROOT, rel)
|
||||
|
||||
if tmpl:
|
||||
upload_rel_path, abs_path = _build_upload_path(tmpl.pk)
|
||||
template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None
|
||||
master_file_changed = template_file_name != file_name
|
||||
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
|
||||
@ -254,7 +451,7 @@ def create_templates_for_registration_form(register_form):
|
||||
logger.info("util.create_templates_for_registration_form - Mise à jour school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||
continue
|
||||
|
||||
# Sinon, création du template comme avant
|
||||
# Sinon, création du template — sauvegarder d'abord pour obtenir un pk
|
||||
tmpl = RegistrationSchoolFileTemplate(
|
||||
master=m,
|
||||
registration_form=register_form,
|
||||
@ -262,8 +459,10 @@ def create_templates_for_registration_form(register_form):
|
||||
formTemplateData=m.formMasterData or [],
|
||||
slug=slug,
|
||||
)
|
||||
tmpl.save() # pk attribué ici
|
||||
if file_name:
|
||||
# Copier le fichier du master si besoin (form existant)
|
||||
upload_rel_path, abs_path = _build_upload_path(tmpl.pk)
|
||||
# Copier le fichier du master si besoin
|
||||
if master_file_path and not os.path.exists(abs_path):
|
||||
try:
|
||||
import shutil
|
||||
@ -453,6 +652,8 @@ def rfToPDF(registerForm, filename):
|
||||
'signatureDate': convertToStr(_now(), '%d-%m-%Y'),
|
||||
'signatureTime': convertToStr(_now(), '%H:%M'),
|
||||
'student': registerForm.student,
|
||||
'establishment': registerForm.establishment,
|
||||
'school_year': registerForm.school_year,
|
||||
}
|
||||
|
||||
# Générer le PDF
|
||||
@ -474,6 +675,24 @@ def rfToPDF(registerForm, filename):
|
||||
|
||||
return registerForm.registration_file
|
||||
|
||||
def generateRegistrationPDF(registerForm):
|
||||
"""
|
||||
Génère le PDF d'un dossier d'inscription à la volée et retourne le contenu binaire.
|
||||
Ne sauvegarde pas le fichier sur disque.
|
||||
"""
|
||||
data = {
|
||||
'pdf_title': f"Dossier d'inscription de {registerForm.student.first_name}",
|
||||
'signatureDate': convertToStr(_now(), '%d-%m-%Y'),
|
||||
'signatureTime': convertToStr(_now(), '%H:%M'),
|
||||
'student': registerForm.student,
|
||||
'establishment': registerForm.establishment,
|
||||
'school_year': registerForm.school_year,
|
||||
}
|
||||
pdf = renderers.render_to_pdf('pdfs/fiche_eleve.html', data)
|
||||
if not pdf:
|
||||
raise ValueError("Erreur lors de la génération du PDF.")
|
||||
return pdf.content
|
||||
|
||||
def delete_registration_files(registerForm):
|
||||
"""
|
||||
Supprime le fichier et le dossier associés à un RegistrationForm.
|
||||
@ -527,55 +746,196 @@ def getHistoricalYears(count=5):
|
||||
|
||||
return historical_years
|
||||
|
||||
def generate_form_json_pdf(register_form, form_json):
|
||||
def generate_form_json_pdf(register_form, form_json, base_pdf_content=None, base_file_ext=None, base_pdf_path=None):
|
||||
"""
|
||||
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
|
||||
et l'associe au RegistrationSchoolFileTemplate.
|
||||
Le PDF contient le titre, les labels et types de champs.
|
||||
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
|
||||
Génère un PDF composite du formulaire dynamique:
|
||||
- le document source uploadé (PDF/image) si présent,
|
||||
- puis un rendu du formulaire (similaire à l'aperçu),
|
||||
- avec overlay de signature(s) sur la dernière page du document source.
|
||||
"""
|
||||
|
||||
# Récupérer le nom du formulaire
|
||||
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
||||
filename = f"{form_name}.pdf"
|
||||
fields = form_json.get("fields", []) if isinstance(form_json, dict) else []
|
||||
|
||||
# Générer le PDF
|
||||
buffer = BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=A4)
|
||||
# Compatibilité ascendante : charger depuis un chemin si nécessaire
|
||||
if base_pdf_content is None and base_pdf_path and os.path.exists(base_pdf_path):
|
||||
base_file_ext = os.path.splitext(base_pdf_path)[1].lower()
|
||||
try:
|
||||
with open(base_pdf_path, 'rb') as f:
|
||||
base_pdf_content = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Lecture fichier source: {e}")
|
||||
|
||||
writer = PdfWriter()
|
||||
has_source_document = False
|
||||
source_is_image = False
|
||||
source_image_reader = None
|
||||
source_image_size = None
|
||||
|
||||
# 1) Charger le document source (PDF/image) si présent
|
||||
if base_pdf_content:
|
||||
try:
|
||||
image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'}
|
||||
ext = (base_file_ext or '').lower()
|
||||
|
||||
if ext in image_exts:
|
||||
# Pour les images, on les rend dans la section [fichier uploade]
|
||||
# au lieu de les ajouter comme page source separee.
|
||||
img_buffer = BytesIO(base_pdf_content)
|
||||
source_image_reader = ImageReader(img_buffer)
|
||||
source_image_size = source_image_reader.getSize()
|
||||
source_is_image = True
|
||||
else:
|
||||
source_reader = PdfReader(BytesIO(base_pdf_content))
|
||||
for page in source_reader.pages:
|
||||
writer.add_page(page)
|
||||
has_source_document = len(source_reader.pages) > 0
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Erreur chargement source: {e}")
|
||||
|
||||
# 2) Overlay des signatures sur la dernière page du document source
|
||||
# Desactive ici pour eviter les doublons: la signature est rendue
|
||||
# dans la section JSON du formulaire (et non plus en overlay source).
|
||||
signatures = []
|
||||
for field in fields:
|
||||
if field.get("type") == "signature":
|
||||
value = field.get("value")
|
||||
if isinstance(value, str) and value.startswith("data:image"):
|
||||
signatures.append(value)
|
||||
|
||||
enable_source_signature_overlay = False
|
||||
if signatures and len(writer.pages) > 0 and enable_source_signature_overlay:
|
||||
try:
|
||||
target_page = writer.pages[len(writer.pages) - 1]
|
||||
page_width = float(target_page.mediabox.width)
|
||||
page_height = float(target_page.mediabox.height)
|
||||
|
||||
packet = BytesIO()
|
||||
c_overlay = canvas.Canvas(packet, pagesize=(page_width, page_height))
|
||||
|
||||
sig_width = 170
|
||||
sig_height = 70
|
||||
margin = 36
|
||||
spacing = 10
|
||||
|
||||
for i, data_url in enumerate(signatures[:3]):
|
||||
try:
|
||||
x = page_width - sig_width - margin
|
||||
y = margin + i * (sig_height + spacing)
|
||||
_draw_signature_data_url(c_overlay, data_url, x, y, sig_width, sig_height)
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Signature ignorée: {e}")
|
||||
|
||||
c_overlay.save()
|
||||
packet.seek(0)
|
||||
overlay_pdf = PdfReader(packet)
|
||||
if overlay_pdf.pages:
|
||||
target_page.merge_page(overlay_pdf.pages[0])
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Erreur overlay signature: {e}")
|
||||
|
||||
# 3) Rendu JSON explicite du formulaire final (toujours genere).
|
||||
# Cela garantit la presence des sections H1 / FileUpload / Signature
|
||||
# dans le PDF final, meme si un document source est fourni.
|
||||
fields_to_render = fields
|
||||
|
||||
if fields_to_render:
|
||||
layout_buffer = BytesIO()
|
||||
c = canvas.Canvas(layout_buffer, pagesize=A4)
|
||||
y = 800
|
||||
|
||||
# Titre
|
||||
c.setFont("Helvetica-Bold", 20)
|
||||
c.drawString(100, y, form_json.get("title", "Formulaire"))
|
||||
y -= 40
|
||||
c.setFont("Helvetica-Bold", 18)
|
||||
c.drawString(60, y, form_json.get("title", "Formulaire"))
|
||||
y -= 35
|
||||
|
||||
# Champs
|
||||
c.setFont("Helvetica", 12)
|
||||
fields = form_json.get("fields", [])
|
||||
for field in fields:
|
||||
label = field.get("label", field.get("id", ""))
|
||||
c.setFont("Helvetica", 11)
|
||||
for field in fields_to_render:
|
||||
ftype = field.get("type", "")
|
||||
if ftype in {"heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "paragraph"}:
|
||||
text = field.get("text", "")
|
||||
if text:
|
||||
c.setFont("Helvetica-Bold" if ftype.startswith("heading") else "Helvetica", 11)
|
||||
c.drawString(60, y, text[:120])
|
||||
y -= 18
|
||||
c.setFont("Helvetica", 11)
|
||||
continue
|
||||
|
||||
label = field.get("label", field.get("id", "Champ"))
|
||||
value = field.get("value", "")
|
||||
# Afficher la valeur si elle existe
|
||||
if value not in (None, ""):
|
||||
c.drawString(100, y, f"{label} [{ftype}] : {value}")
|
||||
else:
|
||||
c.drawString(100, y, f"{label} [{ftype}]")
|
||||
y -= 25
|
||||
if y < 100:
|
||||
|
||||
if ftype == "file":
|
||||
c.drawString(60, y, f"{label}")
|
||||
y -= 18
|
||||
|
||||
if source_is_image and source_image_reader and source_image_size:
|
||||
img_w, img_h = source_image_size
|
||||
max_w = 420
|
||||
max_h = 260
|
||||
ratio = min(max_w / img_w, max_h / img_h)
|
||||
draw_w = img_w * ratio
|
||||
draw_h = img_h * ratio
|
||||
|
||||
if y - draw_h < 80:
|
||||
c.showPage()
|
||||
y = 800
|
||||
c.setFont("Helvetica", 11)
|
||||
|
||||
c.drawImage(
|
||||
source_image_reader,
|
||||
60,
|
||||
y - draw_h,
|
||||
width=draw_w,
|
||||
height=draw_h,
|
||||
preserveAspectRatio=True,
|
||||
mask='auto',
|
||||
)
|
||||
y -= draw_h + 14
|
||||
elif ftype == "signature":
|
||||
c.drawString(60, y, f"{label}")
|
||||
sig_drawn = False
|
||||
if isinstance(value, str) and value.startswith("data:image"):
|
||||
try:
|
||||
sig_drawn = _draw_signature_data_url(c, value, 260, y - 55, 170, 55)
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Signature render echoue: {e}")
|
||||
if not sig_drawn:
|
||||
c.rect(260, y - 55, 170, 55)
|
||||
y -= 70
|
||||
else:
|
||||
if value not in (None, ""):
|
||||
c.drawString(60, y, f"{label} [{ftype}] : {str(value)[:120]}")
|
||||
else:
|
||||
c.drawString(60, y, f"{label} [{ftype}]")
|
||||
y -= 18
|
||||
|
||||
if y < 80:
|
||||
c.showPage()
|
||||
y = 800
|
||||
c.setFont("Helvetica", 11)
|
||||
|
||||
c.save()
|
||||
buffer.seek(0)
|
||||
pdf_content = buffer.read()
|
||||
layout_buffer.seek(0)
|
||||
try:
|
||||
layout_reader = PdfReader(layout_buffer)
|
||||
for page in layout_reader.pages:
|
||||
writer.add_page(page)
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Erreur ajout rendu formulaire: {e}")
|
||||
|
||||
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
|
||||
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
|
||||
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
|
||||
if os.path.exists(existing_file_path):
|
||||
os.remove(existing_file_path)
|
||||
register_form.registration_file.delete(save=False)
|
||||
# 4) Fallback minimal si aucune page n'a été créée
|
||||
if len(writer.pages) == 0:
|
||||
fallback = BytesIO()
|
||||
c_fb = canvas.Canvas(fallback, pagesize=A4)
|
||||
c_fb.setFont("Helvetica-Bold", 16)
|
||||
c_fb.drawString(60, 800, form_json.get("title", "Formulaire"))
|
||||
c_fb.save()
|
||||
fallback.seek(0)
|
||||
fallback_reader = PdfReader(fallback)
|
||||
for page in fallback_reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
# Retourner le ContentFile avec uniquement le nom du fichier
|
||||
return ContentFile(pdf_content, name=os.path.basename(filename))
|
||||
out = BytesIO()
|
||||
writer.write(out)
|
||||
out.seek(0)
|
||||
return ContentFile(out.read(), name=os.path.basename(filename))
|
||||
|
||||
@ -5,7 +5,8 @@ from .register_form_views import (
|
||||
resend,
|
||||
archive,
|
||||
get_school_file_templates_by_rf,
|
||||
get_parent_file_templates_by_rf
|
||||
get_parent_file_templates_by_rf,
|
||||
generate_registration_pdf
|
||||
)
|
||||
from .registration_school_file_masters_views import (
|
||||
RegistrationSchoolFileMasterView,
|
||||
@ -48,6 +49,7 @@ __all__ = [
|
||||
'get_registration_files_by_group',
|
||||
'get_school_file_templates_by_rf',
|
||||
'get_parent_file_templates_by_rf',
|
||||
'generate_registration_pdf',
|
||||
'StudentView',
|
||||
'StudentListView',
|
||||
'ChildrenListView',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from django.http.response import JsonResponse
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework.views import APIView
|
||||
@ -411,6 +412,17 @@ class RegisterFormWithIdView(APIView):
|
||||
# Initialisation de la liste des fichiers à fusionner
|
||||
fileNames = []
|
||||
|
||||
# Régénérer la fiche élève avec le nouveau template avant fusion
|
||||
try:
|
||||
base_dir = os.path.join(settings.MEDIA_ROOT, f"registration_files/dossier_rf_{registerForm.pk}")
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
initial_pdf = f"{base_dir}/Inscription_{registerForm.student.last_name}_{registerForm.student.first_name}.pdf"
|
||||
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
|
||||
registerForm.save()
|
||||
logger.debug(f"[RF_VALIDATED] Fiche élève régénérée avant fusion")
|
||||
except Exception as e:
|
||||
logger.error(f"[RF_VALIDATED] Erreur lors de la régénération de la fiche élève: {e}")
|
||||
|
||||
# Ajout du fichier registration_file en première position
|
||||
if registerForm.registration_file:
|
||||
fileNames.append(registerForm.registration_file.path)
|
||||
@ -946,3 +958,26 @@ def get_parent_file_templates_by_rf(request, id):
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
except RegistrationParentFileTemplate.DoesNotExist:
|
||||
return JsonResponse({'error': 'Aucune pièce à fournir trouvée pour ce dossier d\'inscription'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='get',
|
||||
responses={200: openapi.Response('PDF file', schema=openapi.Schema(type=openapi.TYPE_FILE))},
|
||||
operation_description="Génère et retourne le PDF de la fiche élève à la volée",
|
||||
operation_summary="Télécharger la fiche élève (régénérée)"
|
||||
)
|
||||
@api_view(['GET'])
|
||||
def generate_registration_pdf(request, id):
|
||||
try:
|
||||
registerForm = RegistrationForm.objects.select_related('student', 'establishment').get(student__id=id)
|
||||
except RegistrationForm.DoesNotExist:
|
||||
return JsonResponse({"error": "Dossier d'inscription introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
pdf_content = util.generateRegistrationPDF(registerForm)
|
||||
except ValueError as e:
|
||||
return JsonResponse({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
filename = f"Inscription_{registerForm.student.last_name}_{registerForm.student.first_name}.pdf"
|
||||
response = HttpResponse(pdf_content, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'inline; filename="{filename}"'
|
||||
return response
|
||||
|
||||
@ -58,6 +58,8 @@ class RegistrationParentFileTemplateView(APIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
@ -82,11 +84,15 @@ class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp is not None:
|
||||
return resp
|
||||
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
|
||||
@ -125,6 +125,25 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
# Garde-fou: eviter d'ecraser un master dynamique existant avec un
|
||||
# formMasterData vide/malforme (cas observe en multipart).
|
||||
if 'formMasterData' in payload:
|
||||
incoming_form_data = payload.get('formMasterData')
|
||||
current_is_dynamic = (
|
||||
isinstance(master.formMasterData, dict)
|
||||
and bool(master.formMasterData.get('fields'))
|
||||
)
|
||||
incoming_is_dynamic = (
|
||||
isinstance(incoming_form_data, dict)
|
||||
and bool(incoming_form_data.get('fields'))
|
||||
)
|
||||
if current_is_dynamic and not incoming_is_dynamic:
|
||||
logger.warning(
|
||||
"formMasterData invalide recu pour master %s: conservation de la config dynamique existante",
|
||||
master.pk,
|
||||
)
|
||||
payload['formMasterData'] = master.formMasterData
|
||||
|
||||
|
||||
logger.info(f"payload for update serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||
|
||||
@ -16,6 +16,20 @@ import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_nested_responses(data, max_depth=8):
|
||||
"""Extrait le dictionnaire de reponses depuis des structures imbriquees."""
|
||||
current = data
|
||||
for _ in range(max_depth):
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
nested = current.get("responses")
|
||||
if isinstance(nested, dict):
|
||||
current = nested
|
||||
continue
|
||||
return current
|
||||
return current if isinstance(current, dict) else None
|
||||
|
||||
class RegistrationSchoolFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
||||
@ -95,15 +109,26 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
responses = None
|
||||
if "responses" in formTemplateData:
|
||||
resp = formTemplateData["responses"]
|
||||
if isinstance(resp, dict) and "responses" in resp:
|
||||
responses = resp["responses"]
|
||||
elif isinstance(resp, dict):
|
||||
responses = resp
|
||||
responses = _extract_nested_responses(resp)
|
||||
|
||||
# Nettoyer les meta-cles qui ne sont pas des reponses de champs
|
||||
if isinstance(responses, dict):
|
||||
cleaned = {
|
||||
key: value
|
||||
for key, value in responses.items()
|
||||
if key not in {"responses", "formId", "id", "templateId"}
|
||||
}
|
||||
responses = cleaned
|
||||
|
||||
if responses and "fields" in formTemplateData:
|
||||
for field in formTemplateData["fields"]:
|
||||
field_id = field.get("id")
|
||||
if field_id and field_id in responses:
|
||||
field["value"] = responses[field_id]
|
||||
|
||||
# Stocker les reponses aplaties pour eviter l'empilement responses.responses
|
||||
if isinstance(responses, dict):
|
||||
formTemplateData["responses"] = responses
|
||||
payload['formTemplateData'] = formTemplateData
|
||||
|
||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||
@ -137,7 +162,7 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
|
||||
|
||||
# Cas 2 : Formulaire dynamique (JSON)
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload)
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Régénérer le PDF si besoin
|
||||
@ -148,19 +173,50 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
and formTemplateData.get("fields")
|
||||
and hasattr(template, "file")
|
||||
):
|
||||
old_pdf_name = None
|
||||
if template.file and template.file.name:
|
||||
old_pdf_name = os.path.basename(template.file.name)
|
||||
# Lire le contenu du fichier source en mémoire AVANT suppression.
|
||||
# Priorité au fichier master (document source admin) pour éviter
|
||||
# de re-générer à partir d'un PDF template déjà enrichi.
|
||||
base_pdf_content = None
|
||||
base_file_ext = None
|
||||
if template.master and template.master.file and template.master.file.name:
|
||||
base_file_ext = os.path.splitext(template.master.file.name)[1].lower()
|
||||
try:
|
||||
template.master.file.open('rb')
|
||||
base_pdf_content = template.master.file.read()
|
||||
template.master.file.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture fichier source master: {e}")
|
||||
elif template.file and template.file.name:
|
||||
base_file_ext = os.path.splitext(template.file.name)[1].lower()
|
||||
try:
|
||||
template.file.open('rb')
|
||||
base_pdf_content = template.file.read()
|
||||
template.file.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture fichier source template: {e}")
|
||||
try:
|
||||
old_path = template.file.path
|
||||
template.file.delete(save=False)
|
||||
if os.path.exists(template.file.path):
|
||||
os.remove(template.file.path)
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
|
||||
from Subscriptions.util import generate_form_json_pdf
|
||||
pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData)
|
||||
pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf"
|
||||
template.file.save(pdf_filename, pdf_file, save=True)
|
||||
pdf_file = generate_form_json_pdf(
|
||||
template.registration_form,
|
||||
formTemplateData,
|
||||
base_pdf_content=base_pdf_content,
|
||||
base_file_ext=base_file_ext,
|
||||
)
|
||||
form_name = (formTemplateData.get("title") or template.name or f"formulaire_{template.id}").strip().replace(" ", "_")
|
||||
pdf_filename = f"{form_name}.pdf"
|
||||
util.save_file_field_without_suffix(
|
||||
template,
|
||||
'file',
|
||||
pdf_filename,
|
||||
pdf_file,
|
||||
save=True,
|
||||
)
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@ -176,12 +176,42 @@ class StudentCompetencyListCreateView(APIView):
|
||||
if domaine_dict["categories"]:
|
||||
result.append(domaine_dict)
|
||||
|
||||
establishment = None
|
||||
if student.associated_class and student.associated_class.establishment:
|
||||
establishment = student.associated_class.establishment
|
||||
else:
|
||||
try:
|
||||
establishment = student.registrationform.establishment
|
||||
except Exception:
|
||||
establishment = None
|
||||
|
||||
establishment_logo_path = None
|
||||
if establishment and establishment.logo:
|
||||
try:
|
||||
if establishment.logo.path and os.path.exists(establishment.logo.path):
|
||||
establishment_logo_path = establishment.logo.path
|
||||
except Exception:
|
||||
establishment_logo_path = None
|
||||
|
||||
n3wt_logo_path = os.path.join(settings.BASE_DIR, 'static', 'img', 'logo_min.svg')
|
||||
if not os.path.exists(n3wt_logo_path):
|
||||
n3wt_logo_path = None
|
||||
|
||||
context = {
|
||||
"student": {
|
||||
"first_name": student.first_name,
|
||||
"last_name": student.last_name,
|
||||
"level": student.level,
|
||||
"class_name": student.associated_class.atmosphere_name,
|
||||
"class_name": student.associated_class.atmosphere_name if student.associated_class else "Non assignée",
|
||||
},
|
||||
"establishment": {
|
||||
"name": establishment.name if establishment else "Établissement",
|
||||
"address": establishment.address if establishment else "",
|
||||
"logo_path": establishment_logo_path,
|
||||
},
|
||||
"product": {
|
||||
"name": "n3wt-school",
|
||||
"logo_path": n3wt_logo_path,
|
||||
},
|
||||
"period": period,
|
||||
"date": date.today().strftime("%d/%m/%Y"),
|
||||
|
||||
@ -54,6 +54,12 @@ class StudentListView(APIView):
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
),
|
||||
openapi.Parameter(
|
||||
'school_year', openapi.IN_QUERY,
|
||||
description="Année scolaire (ex: 2025-2026)",
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False
|
||||
)
|
||||
]
|
||||
)
|
||||
@ -61,6 +67,7 @@ class StudentListView(APIView):
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
status_filter = request.GET.get('status', None) # Nouveau filtre optionnel
|
||||
school_year_filter = request.GET.get('school_year', None) # Filtre année scolaire
|
||||
|
||||
if establishment_id is None:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
@ -70,6 +77,9 @@ class StudentListView(APIView):
|
||||
if status_filter:
|
||||
students_qs = students_qs.filter(registrationform__status=status_filter)
|
||||
|
||||
if school_year_filter:
|
||||
students_qs = students_qs.filter(registrationform__school_year=school_year_filter)
|
||||
|
||||
students_qs = students_qs.distinct()
|
||||
students_serializer = StudentByRFCreationSerializer(students_qs, many=True)
|
||||
return JsonResponse(students_serializer.data, safe=False)
|
||||
|
||||
@ -1 +1 @@
|
||||
__version__ = "0.0.3"
|
||||
__version__ = "0.0.5"
|
||||
|
||||
@ -11,9 +11,9 @@ def run_command(command):
|
||||
print(f"stderr: {stderr.decode()}")
|
||||
return process.returncode
|
||||
|
||||
test_mode = os.getenv('test_mode', 'false').lower() == 'true'
|
||||
flush_data = os.getenv('flush_data', 'false').lower() == 'true'
|
||||
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true'
|
||||
test_mode = os.getenv('TEST_MODE', 'false').lower() == 'true'
|
||||
flush_data = os.getenv('FLUSH_DATA', 'false').lower() == 'true'
|
||||
migrate_data = os.getenv('MIGRATE_DATA', 'false').lower() == 'true'
|
||||
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
||||
|
||||
collect_static_cmd = [
|
||||
@ -61,12 +61,6 @@ if __name__ == "__main__":
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if flush_data:
|
||||
for command in flush_data_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
|
||||
for command in migrate_commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
@ -75,6 +69,11 @@ if __name__ == "__main__":
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if flush_data:
|
||||
for command in flush_data_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if test_mode:
|
||||
for test_command in test_commands:
|
||||
if run_command(test_command) != 0:
|
||||
|
||||
7
Back-End/static/img/n3wt.svg
Normal file
7
Back-End/static/img/n3wt.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="120" viewBox="0 0 420 120" role="img" aria-label="n3wt-school">
|
||||
<rect width="420" height="120" rx="16" fill="#F0FDF4"/>
|
||||
<circle cx="56" cy="60" r="30" fill="#10B981"/>
|
||||
<path d="M42 60h28M56 46v28" stroke="#064E3B" stroke-width="8" stroke-linecap="round"/>
|
||||
<text x="104" y="70" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#064E3B">n3wt</text>
|
||||
<text x="245" y="70" font-family="Arial, sans-serif" font-size="30" font-weight="600" fill="#059669">school</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 561 B |
140
CHANGELOG.md
140
CHANGELOG.md
@ -2,6 +2,146 @@
|
||||
|
||||
Toutes les modifications notables apportées à ce projet seront documentées dans ce fichier.
|
||||
|
||||
### [0.0.5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/compare/0.0.3...0.0.5) (2026-04-05)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **design-system:** add design system documentation and AI agent instructions ([cb76a23](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cb76a23d02a9c0f27c9403e4d06cebfcc65d886b))
|
||||
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
* Gestion de l'arborescence des documents d'école en fonction des requêtes CRUD [N3WTS-17] ([abb4b52](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/abb4b525b296ba01fce037c6c63fb7766cfbc9b3))
|
||||
* Ajout bouton de refus de dossier avec zone de saisie de motif [N3WTS-2] ([3779a47](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3779a474171d7df716e3b0d06a26f7f9b69356fa))
|
||||
* Ajout d'un système d'historisation et d'export de données en CSV [N3WTS-5] ([f091fa0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f091fa0432135bb5478793ea92b97d13c3c68a2f))
|
||||
* Ajout d'un système de notation par classe et par matière et par élève [N3WTS-6] ([905fa5d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/905fa5dbfb3d50710a3aa04d9b28664c1c86b991))
|
||||
* Ajout de la commande npm permettant de creer un etablissement ([09b1541](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/09b1541dc83b28dcba0aead633755d58e9a534fa))
|
||||
* Ajout des composants manquant dans le FormTemplateBuilder [N3WTS-17] ([5e62ee5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5e62ee510074e30cd33802aae20d3d1c297619a3))
|
||||
* Ajout FormTemplateBuilder [N3WTS-17] ([e89d2fc](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e89d2fc4c34a99c6b67827bb895ad31a5eff10e6))
|
||||
* **backend,frontend:** régénération et visualisation inline de la fiche élève PDF ([e37aee2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e37aee2abcf7ed69145d217a30e2dce037d933d0))
|
||||
* Changement du rendu de la page des documents + gestion des ([12f5fc7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/12f5fc7aa9de10fc5591495b9ede478593c1c21a))
|
||||
* creation d'un FormRenderer.js pour creer un formulaire dynamique [NEWTS-17] ([9481a01](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9481a0132d2e1f18da5fc632d924adde04d316d4))
|
||||
* Début de suppression de docuseal côté Front [#N3WTS-17] ([1e5bc6c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1e5bc6ccba9053246e4767aa6c2da46ff776203e)), closes [#N3WTS-17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/N3WTS-17)
|
||||
* Envoi mail d'inscription au second responsable [N3WTS-1] ([d66db1b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d66db1b019f2480a8a418acc982a6f9132ff3342))
|
||||
* Envoi mail d'inscription aux enseignants [N3WTS-1] ([bd7dc2b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/bd7dc2b0c228b26e31c930342679e30aafa101e1))
|
||||
* Finalisation de la validation / refus des documents signés par les parents [N3WTS-2] ([0501c1d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0501c1dd7320d734ba6ae0b93ad4a270b903a5df))
|
||||
* Finalisation formulaire dynamique ([90b0d14](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/90b0d144184a1c402aedfe3f70647c4bb43f8c37))
|
||||
* **frontend:** fusion liste des frais et message compte existant [#NEWTS-9] ([e30a41a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e30a41a58b199b68eca687a159736b2025e9f22d)), closes [#NEWTS-9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/NEWTS-9)
|
||||
* **frontend:** refonte mobile planning et ameliorations suivi pedagogique [#NEWTS-4] ([4248a58](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4248a589c5f300fe0702b1c20cae5abaeba32688)), closes [#NEWTS-4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/NEWTS-4) [#NEWTS-4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/NEWTS-4)
|
||||
* Gestion de l'affichage des documents validés et non validés sur la page parent [N3WTS-2] ([4f7d7d0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4f7d7d002442bb0cb04063734d97f993a40474a7))
|
||||
* Gestion de la sidebar [N3WTS-8] ([ddcaba3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ddcaba382e6573f4a628a691bc0f20226a954fd7))
|
||||
* Gestion du refus définitif d'un dossier [N3WTS-2] ([2fef6d6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2fef6d61a4d38f82c28587a9642aef65b5fd387a))
|
||||
* lister uniquement les élèves inscrits dans une classe [N3WTS-6] ([6fb3c5c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6fb3c5cdb40f05b9c34b96334e785bc184751e4f))
|
||||
* Mise à jour de la page parent ([2d678b7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2d678b732f1be19b4653fbe68486455326da2157))
|
||||
* Page Inscriptions : suppression de la possibilité de créer un nouveau DI [N3WTS-8] ([195579e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/195579e21710427d303e10c97b4a4438ec381da4))
|
||||
* Page Structure : suppression de la possibilité de faire des actions d'admin [N3WTS-8] ([05c68eb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/05c68ebfaa0ff30a70ca9c42bcb306261c0fbb85))
|
||||
* Précablage du formulaire dynamique [N3WTS-17] ([dd00cba](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/dd00cba385a131ed7db66656738340234bcf5e73))
|
||||
* push test [#N3WTS-17] ([0fb668b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0fb668b21284a682a8dd5addd14e47c580723f13)), closes [#N3WTS-17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/N3WTS-17)
|
||||
* Réorganisation items dans la page [N3WTS-17] ([8549699](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8549699dec253a9763a4074e05130c4d7d40ac6e))
|
||||
* Sauvegarde des formulaires d'école dans les bons dossiers / ([b4f70e6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b4f70e6bad2fa31b6c1fd68d8538962b9d5e2650))
|
||||
* Securisation du Backend ([fa84309](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fa843097ba9490c5083b652ec2dc9a9b28c096a0))
|
||||
* Securisation du téléchargement de fichier ([a329126](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a3291262d80d2bddfc11412f55bc271ea6bf8219))
|
||||
* Traitement de clonages des templates de documents dans le back ([7486f6c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7486f6c5ce6c5aac2b051469903ef7b2936f5634)), closes [#N3WTS-17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/N3WTS-17)
|
||||
* Validation document par document [N3WTS-2] ([8fd1b62](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8fd1b62ec09a663f40262b406c2f17f64a8d5a2f))
|
||||
* WIP finalisation partie signature des parents [N3WTS-17] ([9dff32b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9dff32b388fd27a899a44e3424bb7d24b2b26afd))
|
||||
|
||||
|
||||
### Corrections de bugs
|
||||
|
||||
* Boutons de navigation + mise en page de l'aperçu du formulaire dynamique ([762dede](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/762dede0af37c41b2c1616216bd9f986d1e0c57f))
|
||||
* Changement des niveaux de logs [N3WTS-1] ([26d4b56](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/26d4b5633f7ebf5630105c3e9db802cdaeee4e37))
|
||||
* Chat getSession + passage en asyn ces getWebSocketUrl et connectToChat ([409cf05](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/409cf05f1a4554263d5f08185bce97d9425287fa))
|
||||
* coorection démarrage ([2579af9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2579af9b8b397d24e66ee08365e345f81bfb00cf))
|
||||
* Coquille [N3WTS-17] ([a034149](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a034149eae7f3b746af32c392499217cf73ab582))
|
||||
* correction du téléchargement du fichier ([053140c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/053140c8be1e10ac9b127cfb47b5691e70dd26e0))
|
||||
* Edition d'un teacher, champ email désactivé [N3WTS-1] ([176edc5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/176edc5c452fa7b03da266f5a227c730ff4ad525))
|
||||
* Emploi du temps pour les classes de l'année scolaire en cours ([5bbbcb9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5bbbcb9dc1f688cd68f47f8f7629aa03678ba582))
|
||||
* Lint ([2dc0dfa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2dc0dfa26889b319f8c6cfb08b1701f8540d06ed))
|
||||
* messagerie ([a81b76e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a81b76eceac0919fc1873cdf8362e5fe55b97adb))
|
||||
* Mise à jour des plannings ([12939fc](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/12939fca85b139a2b0178b11a4a29ee5e97800ff))
|
||||
* Mise en page sur absence de frais ou de tarifs lors de la création d'un DI ([ccdbae1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ccdbae1c08e1d1edb3bc1e3e14e8244f196cac60))
|
||||
* Mise en place de l'auto reload pour Daphne [[#65](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/65)] ([7f002e2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7f002e2e6a93d651a8d9c2a03baa0f1035844f96))
|
||||
* Ne pas envoyer de mail lors de la création d'un DI ([3c72666](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3c7266608d45cd1650775c9a2060dd1007280096))
|
||||
* On n'historise plus les matières ni les enseignants ([f9c0585](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f9c0585b3061a6ae4a8978c9f0329c8710f9bd60))
|
||||
* Réintégration du bouton de Bilan de compétence + harmonisation des paths d'upload de fichier ([2a223fe](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2a223fe3dd9dfae71117f0f47c07aee8a8893de5))
|
||||
* Revue des modales de création de groupes / formulaire ([1f2a1b8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1f2a1b88acab312dc90dcf4ba4d55048a3f302d9))
|
||||
* sélection enseignants dans les plannings ([4431c42](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4431c428d3709ef3b2e8f66edd13beed73e42709))
|
||||
* signature électronique ([92c3183](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/92c318315335d126ccd6d8637c0381a3367b7a4f))
|
||||
* Suppression d'un PROFILE si aucun PROFILE_ROLE n'y est associé [N3WTS-1] ([92c6a31](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/92c6a31740b930fb5363b9dbf91b664f78c7f1a6))
|
||||
* Suppression envoi mail / création page feedback ([4c56cb6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4c56cb647470f86151b1ff78c318e0b6468f009d))
|
||||
* Upload document ([b0e04e3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b0e04e3adc480005b0d0d74bbc0ac28f1f9d6e4b))
|
||||
|
||||
### [0.0.4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/compare/0.0.3...0.0.4) (2026-04-05)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **design-system:** add design system documentation and AI agent instructions ([cb76a23](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cb76a23d02a9c0f27c9403e4d06cebfcc65d886b))
|
||||
|
||||
|
||||
### Nouvelles fonctionnalités
|
||||
|
||||
* Gestion de l'arborescence des documents d'école en fonction des requêtes CRUD [N3WTS-17] ([abb4b52](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/abb4b525b296ba01fce037c6c63fb7766cfbc9b3))
|
||||
* Ajout bouton de refus de dossier avec zone de saisie de motif [N3WTS-2] ([3779a47](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3779a474171d7df716e3b0d06a26f7f9b69356fa))
|
||||
* Ajout d'un système d'historisation et d'export de données en CSV [N3WTS-5] ([f091fa0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f091fa0432135bb5478793ea92b97d13c3c68a2f))
|
||||
* Ajout d'un système de notation par classe et par matière et par élève [N3WTS-6] ([905fa5d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/905fa5dbfb3d50710a3aa04d9b28664c1c86b991))
|
||||
* Ajout de la commande npm permettant de creer un etablissement ([09b1541](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/09b1541dc83b28dcba0aead633755d58e9a534fa))
|
||||
* Ajout des composants manquant dans le FormTemplateBuilder [N3WTS-17] ([5e62ee5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5e62ee510074e30cd33802aae20d3d1c297619a3))
|
||||
* Ajout FormTemplateBuilder [N3WTS-17] ([e89d2fc](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e89d2fc4c34a99c6b67827bb895ad31a5eff10e6))
|
||||
* **backend,frontend:** régénération et visualisation inline de la fiche élève PDF ([e37aee2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e37aee2abcf7ed69145d217a30e2dce037d933d0))
|
||||
* Changement du rendu de la page des documents + gestion des ([12f5fc7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/12f5fc7aa9de10fc5591495b9ede478593c1c21a))
|
||||
* creation d'un FormRenderer.js pour creer un formulaire dynamique [NEWTS-17] ([9481a01](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9481a0132d2e1f18da5fc632d924adde04d316d4))
|
||||
* Début de suppression de docuseal côté Front [#N3WTS-17] ([1e5bc6c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1e5bc6ccba9053246e4767aa6c2da46ff776203e)), closes [#N3WTS-17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/N3WTS-17)
|
||||
* Envoi mail d'inscription au second responsable [N3WTS-1] ([d66db1b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d66db1b019f2480a8a418acc982a6f9132ff3342))
|
||||
* Envoi mail d'inscription aux enseignants [N3WTS-1] ([bd7dc2b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/bd7dc2b0c228b26e31c930342679e30aafa101e1))
|
||||
* Finalisation de la validation / refus des documents signés par les parents [N3WTS-2] ([0501c1d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0501c1dd7320d734ba6ae0b93ad4a270b903a5df))
|
||||
* Finalisation formulaire dynamique ([90b0d14](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/90b0d144184a1c402aedfe3f70647c4bb43f8c37))
|
||||
* **frontend:** fusion liste des frais et message compte existant [#NEWTS-9] ([e30a41a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e30a41a58b199b68eca687a159736b2025e9f22d)), closes [#NEWTS-9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/NEWTS-9)
|
||||
* **frontend:** refonte mobile planning et ameliorations suivi pedagogique [#NEWTS-4] ([4248a58](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4248a589c5f300fe0702b1c20cae5abaeba32688)), closes [#NEWTS-4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/NEWTS-4) [#NEWTS-4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/NEWTS-4)
|
||||
* Gestion de l'affichage des documents validés et non validés sur la page parent [N3WTS-2] ([4f7d7d0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4f7d7d002442bb0cb04063734d97f993a40474a7))
|
||||
* Gestion de la sidebar [N3WTS-8] ([ddcaba3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ddcaba382e6573f4a628a691bc0f20226a954fd7))
|
||||
* Gestion du refus définitif d'un dossier [N3WTS-2] ([2fef6d6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2fef6d61a4d38f82c28587a9642aef65b5fd387a))
|
||||
* lister uniquement les élèves inscrits dans une classe [N3WTS-6] ([6fb3c5c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6fb3c5cdb40f05b9c34b96334e785bc184751e4f))
|
||||
* Mise à jour de la page parent ([2d678b7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2d678b732f1be19b4653fbe68486455326da2157))
|
||||
* Page Inscriptions : suppression de la possibilité de créer un nouveau DI [N3WTS-8] ([195579e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/195579e21710427d303e10c97b4a4438ec381da4))
|
||||
* Page Structure : suppression de la possibilité de faire des actions d'admin [N3WTS-8] ([05c68eb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/05c68ebfaa0ff30a70ca9c42bcb306261c0fbb85))
|
||||
* Précablage du formulaire dynamique [N3WTS-17] ([dd00cba](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/dd00cba385a131ed7db66656738340234bcf5e73))
|
||||
* push test [#N3WTS-17] ([0fb668b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0fb668b21284a682a8dd5addd14e47c580723f13)), closes [#N3WTS-17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/N3WTS-17)
|
||||
* Réorganisation items dans la page [N3WTS-17] ([8549699](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8549699dec253a9763a4074e05130c4d7d40ac6e))
|
||||
* Sauvegarde des formulaires d'école dans les bons dossiers / ([b4f70e6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b4f70e6bad2fa31b6c1fd68d8538962b9d5e2650))
|
||||
* Securisation du Backend ([fa84309](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fa843097ba9490c5083b652ec2dc9a9b28c096a0))
|
||||
* Securisation du téléchargement de fichier ([a329126](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a3291262d80d2bddfc11412f55bc271ea6bf8219))
|
||||
* Traitement de clonages des templates de documents dans le back ([7486f6c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7486f6c5ce6c5aac2b051469903ef7b2936f5634)), closes [#N3WTS-17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/N3WTS-17)
|
||||
* Validation document par document [N3WTS-2] ([8fd1b62](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8fd1b62ec09a663f40262b406c2f17f64a8d5a2f))
|
||||
* WIP finalisation partie signature des parents [N3WTS-17] ([9dff32b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9dff32b388fd27a899a44e3424bb7d24b2b26afd))
|
||||
|
||||
|
||||
### Corrections de bugs
|
||||
|
||||
* Boutons de navigation + mise en page de l'aperçu du formulaire dynamique ([762dede](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/762dede0af37c41b2c1616216bd9f986d1e0c57f))
|
||||
* Changement des niveaux de logs [N3WTS-1] ([26d4b56](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/26d4b5633f7ebf5630105c3e9db802cdaeee4e37))
|
||||
* Chat getSession + passage en asyn ces getWebSocketUrl et connectToChat ([409cf05](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/409cf05f1a4554263d5f08185bce97d9425287fa))
|
||||
* coorection démarrage ([2579af9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2579af9b8b397d24e66ee08365e345f81bfb00cf))
|
||||
* Coquille [N3WTS-17] ([a034149](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a034149eae7f3b746af32c392499217cf73ab582))
|
||||
* correction du téléchargement du fichier ([053140c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/053140c8be1e10ac9b127cfb47b5691e70dd26e0))
|
||||
* Edition d'un teacher, champ email désactivé [N3WTS-1] ([176edc5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/176edc5c452fa7b03da266f5a227c730ff4ad525))
|
||||
* Emploi du temps pour les classes de l'année scolaire en cours ([5bbbcb9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5bbbcb9dc1f688cd68f47f8f7629aa03678ba582))
|
||||
* Lint ([2dc0dfa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2dc0dfa26889b319f8c6cfb08b1701f8540d06ed))
|
||||
* messagerie ([a81b76e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a81b76eceac0919fc1873cdf8362e5fe55b97adb))
|
||||
* Mise à jour des plannings ([12939fc](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/12939fca85b139a2b0178b11a4a29ee5e97800ff))
|
||||
* Mise en page sur absence de frais ou de tarifs lors de la création d'un DI ([ccdbae1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ccdbae1c08e1d1edb3bc1e3e14e8244f196cac60))
|
||||
* Mise en place de l'auto reload pour Daphne [[#65](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/65)] ([7f002e2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7f002e2e6a93d651a8d9c2a03baa0f1035844f96))
|
||||
* Ne pas envoyer de mail lors de la création d'un DI ([3c72666](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3c7266608d45cd1650775c9a2060dd1007280096))
|
||||
* On n'historise plus les matières ni les enseignants ([f9c0585](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f9c0585b3061a6ae4a8978c9f0329c8710f9bd60))
|
||||
* Réintégration du bouton de Bilan de compétence + harmonisation des paths d'upload de fichier ([2a223fe](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2a223fe3dd9dfae71117f0f47c07aee8a8893de5))
|
||||
* Revue des modales de création de groupes / formulaire ([1f2a1b8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1f2a1b88acab312dc90dcf4ba4d55048a3f302d9))
|
||||
* sélection enseignants dans les plannings ([4431c42](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4431c428d3709ef3b2e8f66edd13beed73e42709))
|
||||
* signature électronique ([92c3183](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/92c318315335d126ccd6d8637c0381a3367b7a4f))
|
||||
* Suppression d'un PROFILE si aucun PROFILE_ROLE n'y est associé [N3WTS-1] ([92c6a31](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/92c6a31740b930fb5363b9dbf91b664f78c7f1a6))
|
||||
* Suppression envoi mail / création page feedback ([4c56cb6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4c56cb647470f86151b1ff78c318e0b6468f009d))
|
||||
* Upload document ([b0e04e3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b0e04e3adc480005b0d0d74bbc0ac28f1f9d6e4b))
|
||||
|
||||
### [0.0.3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/compare/0.0.2...0.0.3) (2025-06-01)
|
||||
|
||||
|
||||
|
||||
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@ -0,0 +1,91 @@
|
||||
# CLAUDE.md — N3WT-SCHOOL
|
||||
|
||||
Instructions permanentes pour Claude Code sur ce projet.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend** : Python / Django — dossier `Back-End/`
|
||||
- **Frontend** : Next.js 14 (App Router) — dossier `Front-End/`
|
||||
- **Tests frontend** : `Front-End/src/test/`
|
||||
- **CSS** : Tailwind CSS 3 + `@tailwindcss/forms`
|
||||
|
||||
## Design System
|
||||
|
||||
Le design system complet est dans [`docs/design-system.md`](docs/design-system.md). À lire et appliquer systématiquement.
|
||||
|
||||
### Tokens de couleur (Tailwind)
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|-------------|-----------|------------------------------------|
|
||||
| `primary` | `#059669` | Boutons, CTA, éléments actifs |
|
||||
| `secondary` | `#064E3B` | Hover, accents sombres |
|
||||
| `tertiary` | `#10B981` | Badges, icônes d'accent |
|
||||
| `neutral` | `#F8FAFC` | Fonds de page, surfaces |
|
||||
|
||||
> **Règle absolue** : ne jamais utiliser `emerald-*`, `green-*` pour les éléments interactifs. Utiliser les tokens ci-dessus.
|
||||
|
||||
### Typographie
|
||||
|
||||
- `font-headline` → titres `h1`/`h2`/`h3` (Manrope)
|
||||
- `font-body` → texte courant, défaut sur `<body>` (Inter)
|
||||
- `font-label` → boutons, labels de form (Inter)
|
||||
|
||||
### Arrondi & Espacement
|
||||
|
||||
- Arrondi par défaut : `rounded` (4px)
|
||||
- Espacement : grille 4px/8px — pas de valeurs arbitraires
|
||||
- Mode : **light uniquement**, pas de dark mode
|
||||
|
||||
## Conventions de code
|
||||
|
||||
### Frontend (Next.js)
|
||||
|
||||
- **Composants** : React fonctionnels, pas de classes
|
||||
- **Styles** : Tailwind CSS uniquement — pas de CSS inline sauf animations
|
||||
- **Icônes** : `lucide-react`
|
||||
- **i18n** : `next-intl` — toutes les chaînes UI via `useTranslations()`
|
||||
- **Formulaires** : `react-hook-form`
|
||||
- **Imports** : alias `@/` pointe vers `Front-End/src/`
|
||||
|
||||
### Qualité
|
||||
|
||||
- Linting : ESLint (`npm run lint` dans `Front-End/`)
|
||||
- Format : Prettier
|
||||
- Tests : Jest + React Testing Library (`Front-End/src/test/`)
|
||||
|
||||
## Gestion des branches
|
||||
|
||||
- Base : `develop`
|
||||
- Nomenclature : `<type>-<nom_ticket>-<numero>` (ex: `feat-ma-feature-1234`)
|
||||
- Types : `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
|
||||
## Icônes
|
||||
|
||||
Utiliser **uniquement `lucide-react`** — jamais d'autre bibliothèque d'icônes.
|
||||
|
||||
```jsx
|
||||
import { Home, Plus } from 'lucide-react';
|
||||
<Home size={20} className="text-primary" />
|
||||
```
|
||||
|
||||
## Responsive & PWA
|
||||
|
||||
- **Mobile-first** : styles de base = mobile, breakpoints `sm:`/`md:`/`lg:` pour agrandir.
|
||||
- Touch targets ≥ 44px (`min-h-[44px]`) sur tous les éléments interactifs.
|
||||
- Pas d'interactions uniquement au `:hover` — prévoir l'équivalent tactile.
|
||||
- Les tableaux utilisent la classe `responsive-table` sur mobile.
|
||||
|
||||
## Réutilisation des composants
|
||||
|
||||
Avant de créer un composant, **vérifier `Front-End/src/components/`**.
|
||||
Composants disponibles : `AlertMessage`, `Modal`, `Pagination`, `SectionHeader`, `ProgressStep`, `EventCard`, `Calendar/*`, `Chat/*`, `Evaluation/*`, `Grades/*`, `Form/*`, `Admin/*`, `Charts/*`.
|
||||
|
||||
## À éviter
|
||||
|
||||
- Ne pas ajouter de dépendances inutiles
|
||||
- Ne pas modifier `package-lock.json` / `yarn.lock` manuellement
|
||||
- Ne pas committer sans avoir vérifié ESLint et les tests
|
||||
- Ne pas utiliser de CSS arbitraire (`p-[13px]`) sauf cas justifié
|
||||
- Ne pas ajouter de support dark mode
|
||||
- Ne pas utiliser d'autres bibliothèques d'icônes que `lucide-react`
|
||||
- Ne pas créer un composant qui existe déjà dans `components/`
|
||||
21
Front-End/messages/en/feedback.json
Normal file
21
Front-End/messages/en/feedback.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Feedback",
|
||||
"description": "Share your feedback, report a bug, or suggest an improvement. We read every message!",
|
||||
"category_label": "Category",
|
||||
"category_placeholder": "Select a category",
|
||||
"category_bug": "Report a bug",
|
||||
"category_feature": "Suggest a feature",
|
||||
"category_question": "Ask a question",
|
||||
"category_other": "Other",
|
||||
"subject_label": "Subject",
|
||||
"subject_placeholder": "Summarize your request",
|
||||
"message_label": "Message",
|
||||
"message_placeholder": "Describe your feedback in detail...",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"success": "Success",
|
||||
"success_message": "Your feedback has been sent. Thank you!",
|
||||
"error": "Error",
|
||||
"error_required_fields": "Please fill in all required fields.",
|
||||
"error_sending": "An error occurred while sending your feedback."
|
||||
}
|
||||
@ -7,5 +7,6 @@
|
||||
"educational_monitoring": "Educational Monitoring",
|
||||
"settings": "Settings",
|
||||
"schoolAdmin": "School Administration",
|
||||
"messagerie": "Messenger"
|
||||
"messagerie": "Messenger",
|
||||
"feedback": "Feedback"
|
||||
}
|
||||
|
||||
21
Front-End/messages/fr/feedback.json
Normal file
21
Front-End/messages/fr/feedback.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Feedback",
|
||||
"description": "Partagez vos retours, signalez un bug ou proposez une amélioration. Nous lisons chaque message !",
|
||||
"category_label": "Catégorie",
|
||||
"category_placeholder": "Sélectionnez une catégorie",
|
||||
"category_bug": "Signaler un bug",
|
||||
"category_feature": "Proposer une fonctionnalité",
|
||||
"category_question": "Poser une question",
|
||||
"category_other": "Autre",
|
||||
"subject_label": "Sujet",
|
||||
"subject_placeholder": "Résumez votre demande",
|
||||
"message_label": "Message",
|
||||
"message_placeholder": "Décrivez en détail votre retour...",
|
||||
"send": "Envoyer",
|
||||
"sending": "Envoi en cours...",
|
||||
"success": "Succès",
|
||||
"success_message": "Votre feedback a bien été envoyé. Merci !",
|
||||
"error": "Erreur",
|
||||
"error_required_fields": "Veuillez remplir tous les champs obligatoires.",
|
||||
"error_sending": "Une erreur est survenue lors de l'envoi du feedback."
|
||||
}
|
||||
@ -7,5 +7,6 @@
|
||||
"educational_monitoring": "Suivi pédagogique",
|
||||
"settings": "Paramètres",
|
||||
"schoolAdmin": "Administration Scolaire",
|
||||
"messagerie": "Messagerie"
|
||||
"messagerie": "Messagerie",
|
||||
"feedback": "Feedback"
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n3wt-school-front-end",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
4
Front-End/public/icons/icon.svg
Normal file
4
Front-End/public/icons/icon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="80" fill="#10b981"/>
|
||||
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold" font-size="220" fill="white">N3</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
48
Front-End/public/sw.js
Normal file
48
Front-End/public/sw.js
Normal file
@ -0,0 +1,48 @@
|
||||
const CACHE_NAME = 'n3wt-school-v1';
|
||||
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/favicon.svg',
|
||||
'/favicon.ico',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Ne pas intercepter les requêtes API ou d'authentification
|
||||
const url = new URL(event.request.url);
|
||||
if (
|
||||
url.pathname.startsWith('/api/') ||
|
||||
url.pathname.startsWith('/_next/') ||
|
||||
event.request.method !== 'GET'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Mettre en cache les réponses réussies des ressources statiques
|
||||
if (response.ok && url.origin === self.location.origin) {
|
||||
const cloned = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, cloned));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
@ -3,16 +3,19 @@ import Logo from '../components/Logo';
|
||||
|
||||
export default function Custom500() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-emerald-500">
|
||||
<div className="text-center p-6 ">
|
||||
<div className="flex items-center justify-center min-h-screen bg-primary">
|
||||
<div className="text-center p-6 bg-white rounded-md shadow-sm border border-gray-200">
|
||||
<Logo className="w-32 h-32 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-emerald-900 mb-4">
|
||||
<h2 className="font-headline text-2xl font-bold text-secondary mb-4">
|
||||
500 | Erreur interne
|
||||
</h2>
|
||||
<p className="text-emerald-900 mb-4">
|
||||
<p className="font-body text-gray-600 mb-4">
|
||||
Une erreur interne est survenue.
|
||||
</p>
|
||||
<Link className="text-gray-900 hover:underline" href="/">
|
||||
<Link
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-4 py-2 rounded font-label font-medium bg-primary hover:bg-secondary text-white transition-colors"
|
||||
href="/"
|
||||
>
|
||||
Retour Accueil
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -2,8 +2,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { PARENT_FILTER, SCHOOL_FILTER } from '@/utils/constants';
|
||||
import { Trash2, ToggleLeft, ToggleRight, Info, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Info,
|
||||
XCircle,
|
||||
Users,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import Table from '@/components/Table';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import Popup from '@/components/Popup';
|
||||
import StatusLabel from '@/components/StatusLabel';
|
||||
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
|
||||
@ -17,7 +26,6 @@ import { dissociateGuardian } from '@/app/actions/subscriptionAction';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||
import logger from '@/utils/logger';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
|
||||
const roleTypeToLabel = (roleType) => {
|
||||
switch (roleType) {
|
||||
@ -39,7 +47,7 @@ const roleTypeToBadgeClass = (roleType) => {
|
||||
case 1:
|
||||
return 'bg-red-100 text-red-600';
|
||||
case 2:
|
||||
return 'bg-green-100 text-green-600';
|
||||
return 'bg-tertiary/10 text-tertiary';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
@ -378,7 +386,7 @@ export default function Page() {
|
||||
type="button"
|
||||
className={
|
||||
row.is_active
|
||||
? 'text-emerald-500 hover:text-emerald-700'
|
||||
? 'text-primary hover:text-secondary'
|
||||
: 'text-orange-500 hover:text-orange-700'
|
||||
}
|
||||
onClick={() => handleConfirmActivateProfile(row)}
|
||||
@ -474,7 +482,7 @@ export default function Page() {
|
||||
type="button"
|
||||
className={
|
||||
row.is_active
|
||||
? 'text-emerald-500 hover:text-emerald-700'
|
||||
? 'text-primary hover:text-secondary'
|
||||
: 'text-orange-500 hover:text-orange-700'
|
||||
}
|
||||
onClick={() => handleConfirmActivateProfile(row)}
|
||||
@ -516,10 +524,10 @@ export default function Page() {
|
||||
totalPages={totalProfilesParentPages}
|
||||
onPageChange={handlePageChange}
|
||||
emptyMessage={
|
||||
<AlertMessage
|
||||
type="info"
|
||||
title="Aucun profil PARENT enregistré"
|
||||
message="Un profil Parent est ajouté lors de la création d'un nouveau dossier d'inscription."
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="Aucun profil parent enregistré"
|
||||
description="Les profils parents sont créés automatiquement lors de la création d'un dossier d'inscription."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@ -540,10 +548,10 @@ export default function Page() {
|
||||
totalPages={totalProfilesSchoolPages}
|
||||
onPageChange={handlePageChange}
|
||||
emptyMessage={
|
||||
<AlertMessage
|
||||
type="info"
|
||||
title="Aucun profil ECOLE enregistré"
|
||||
message="Un profil ECOLE est ajouté lors de la création d'un nouvel enseignant."
|
||||
<EmptyState
|
||||
icon={UserPlus}
|
||||
title="Aucun profil école enregistré"
|
||||
description="Les profils école sont créés automatiquement lors de l'ajout d'un enseignant."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
136
Front-End/src/app/[locale]/admin/feedback/page.js
Normal file
136
Front-End/src/app/[locale]/admin/feedback/page.js
Normal file
@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { sendFeedback } from '@/app/actions/emailAction';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import WisiwigTextArea from '@/components/Form/WisiwigTextArea';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import Button from '@/components/Form/Button';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const t = useTranslations('feedback');
|
||||
const { showNotification } = useNotification();
|
||||
const { selectedEstablishmentId, establishments, user } = useEstablishment();
|
||||
|
||||
// Récupérer les infos complètes de l'établissement sélectionné
|
||||
const selectedEstablishment = establishments?.find(
|
||||
(e) => e.id === selectedEstablishmentId
|
||||
);
|
||||
|
||||
const [category, setCategory] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const categoryChoices = [
|
||||
{ value: 'bug', label: t('category_bug') },
|
||||
{ value: 'feature', label: t('category_feature') },
|
||||
{ value: 'question', label: t('category_question') },
|
||||
{ value: 'other', label: t('category_other') },
|
||||
];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!category || !subject || !message) {
|
||||
showNotification(t('error_required_fields'), 'error', t('error'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Construire le nom de l'utilisateur (fallback vers l'email si nom indisponible)
|
||||
const userName = user
|
||||
? user.first_name && user.last_name
|
||||
? `${user.first_name} ${user.last_name}`
|
||||
: user.username || user.email?.split('@')[0] || ''
|
||||
: '';
|
||||
|
||||
const feedbackData = {
|
||||
category,
|
||||
subject,
|
||||
message,
|
||||
establishment: selectedEstablishment
|
||||
? {
|
||||
id: selectedEstablishment.id,
|
||||
name: selectedEstablishment.name,
|
||||
total_capacity: selectedEstablishment.total_capacity,
|
||||
evaluation_frequency: selectedEstablishment.evaluation_frequency,
|
||||
}
|
||||
: { id: selectedEstablishmentId },
|
||||
user_email: user?.email || '',
|
||||
user_name: userName,
|
||||
};
|
||||
|
||||
try {
|
||||
await sendFeedback(feedbackData);
|
||||
showNotification(t('success_message'), 'success', t('success'));
|
||||
// Réinitialiser les champs après succès
|
||||
setCategory('');
|
||||
setSubject('');
|
||||
setMessage('');
|
||||
} catch (error) {
|
||||
logger.error("Erreur lors de l'envoi du feedback:", { error });
|
||||
showNotification(t('error_sending'), 'error', t('error'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<h1 className="font-headline text-2xl font-bold text-gray-800 mb-2">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">{t('description')}</p>
|
||||
|
||||
<div className="bg-white rounded-md shadow-sm border border-gray-200 p-6">
|
||||
{/* Catégorie */}
|
||||
<SelectChoice
|
||||
name="category"
|
||||
label={t('category_label')}
|
||||
selected={category}
|
||||
callback={(e) => setCategory(e.target.value)}
|
||||
choices={categoryChoices}
|
||||
placeHolder={t('category_placeholder')}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Sujet */}
|
||||
<InputText
|
||||
name="subject"
|
||||
label={t('subject_label')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t('subject_placeholder')}
|
||||
required
|
||||
className="mb-4 mt-4"
|
||||
/>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-6">
|
||||
<WisiwigTextArea
|
||||
label={t('message_label')}
|
||||
value={message}
|
||||
onChange={setMessage}
|
||||
placeholder={t('message_placeholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bouton d'envoi */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
text={isSubmitting ? t('sending') : t('send')}
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
371
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
371
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import Attendance from '@/components/Grades/Attendance';
|
||||
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||
import { EvaluationStudentView } from '@/components/Evaluation';
|
||||
import Button from '@/components/Form/Button';
|
||||
import logger from '@/utils/logger';
|
||||
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL } from '@/utils/Url';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import {
|
||||
fetchStudents,
|
||||
fetchStudentCompetencies,
|
||||
fetchAbsences,
|
||||
editAbsences,
|
||||
deleteAbsences,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import {
|
||||
fetchEvaluations,
|
||||
fetchStudentEvaluations,
|
||||
updateStudentEvaluation,
|
||||
} from '@/app/actions/schoolAction';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
import { Award, ArrowLeft, BookOpen } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
|
||||
function getPeriodString(selectedPeriod, frequency) {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||
const nextYear = (year + 1).toString();
|
||||
const schoolYear = `${year}-${nextYear}`;
|
||||
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 3) return `A_${schoolYear}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function StudentGradesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const studentId = Number(params.studentId);
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||
useEstablishment();
|
||||
const { getNiveauLabel } = useClasses();
|
||||
|
||||
const [student, setStudent] = useState(null);
|
||||
const [studentCompetencies, setStudentCompetencies] = useState(null);
|
||||
const [grades, setGrades] = useState({});
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||
const [allAbsences, setAllAbsences] = useState([]);
|
||||
|
||||
// Evaluation states
|
||||
const [evaluations, setEvaluations] = useState([]);
|
||||
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
|
||||
|
||||
const getPeriods = () => {
|
||||
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||
return [
|
||||
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
|
||||
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
|
||||
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 2) {
|
||||
return [
|
||||
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
|
||||
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 3) {
|
||||
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Load student info
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
fetchStudents(selectedEstablishmentId, null, 5)
|
||||
.then((students) => {
|
||||
const found = students.find((s) => s.id === studentId);
|
||||
setStudent(found || null);
|
||||
})
|
||||
.catch((error) => logger.error('Error fetching students:', error));
|
||||
}
|
||||
}, [selectedEstablishmentId, studentId]);
|
||||
|
||||
// Auto-select current period
|
||||
useEffect(() => {
|
||||
const periods = getPeriods();
|
||||
const today = dayjs();
|
||||
const current = periods.find((p) => {
|
||||
const start = dayjs(`${today.year()}-${p.start}`);
|
||||
const end = dayjs(`${today.year()}-${p.end}`);
|
||||
return (
|
||||
today.isAfter(start.subtract(1, 'day')) &&
|
||||
today.isBefore(end.add(1, 'day'))
|
||||
);
|
||||
});
|
||||
setSelectedPeriod(current ? current.value : null);
|
||||
}, [selectedEstablishmentEvaluationFrequency]);
|
||||
|
||||
// Load competencies
|
||||
useEffect(() => {
|
||||
if (studentId && selectedPeriod) {
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
fetchStudentCompetencies(studentId, periodString)
|
||||
.then((data) => {
|
||||
setStudentCompetencies(data);
|
||||
if (data && data.data) {
|
||||
const initialGrades = {};
|
||||
data.data.forEach((domaine) => {
|
||||
domaine.categories.forEach((cat) => {
|
||||
cat.competences.forEach((comp) => {
|
||||
initialGrades[comp.competence_id] = comp.score ?? 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
setGrades(initialGrades);
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
logger.error('Error fetching studentCompetencies:', error)
|
||||
);
|
||||
} else {
|
||||
setGrades({});
|
||||
setStudentCompetencies(null);
|
||||
}
|
||||
}, [studentId, selectedPeriod]);
|
||||
|
||||
// Load absences
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
fetchAbsences(selectedEstablishmentId)
|
||||
.then((data) => setAllAbsences(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du fetch des absences:', error)
|
||||
);
|
||||
}
|
||||
}, [selectedEstablishmentId]);
|
||||
|
||||
// Load evaluations for the student
|
||||
useEffect(() => {
|
||||
if (
|
||||
student?.associated_class_id &&
|
||||
selectedPeriod &&
|
||||
selectedEstablishmentId
|
||||
) {
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
|
||||
// Load evaluations for the class
|
||||
fetchEvaluations(
|
||||
selectedEstablishmentId,
|
||||
student.associated_class_id,
|
||||
periodString
|
||||
)
|
||||
.then((data) => setEvaluations(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du fetch des évaluations:', error)
|
||||
);
|
||||
|
||||
// Load student's evaluation scores
|
||||
fetchStudentEvaluations(studentId, null, periodString, null)
|
||||
.then((data) => setStudentEvaluationsData(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du fetch des notes:', error)
|
||||
);
|
||||
}
|
||||
}, [student, selectedPeriod, selectedEstablishmentId]);
|
||||
|
||||
const absences = React.useMemo(() => {
|
||||
return allAbsences
|
||||
.filter((a) => a.student === studentId)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
date: a.day,
|
||||
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
|
||||
reason: a.reason,
|
||||
justified: [1, 3].includes(a.reason),
|
||||
moment: a.moment,
|
||||
commentaire: a.commentaire,
|
||||
}));
|
||||
}, [allAbsences, studentId]);
|
||||
|
||||
const handleToggleJustify = (absence) => {
|
||||
const newReason =
|
||||
absence.type === 'Absence'
|
||||
? absence.justified
|
||||
? 2
|
||||
: 1
|
||||
: absence.justified
|
||||
? 4
|
||||
: 3;
|
||||
|
||||
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
||||
.then(() => {
|
||||
setAllAbsences((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === absence.id ? { ...a, reason: newReason } : a
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error('Erreur lors du changement de justification', e)
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteAbsence = (absence) => {
|
||||
return deleteAbsences(absence.id, csrfToken)
|
||||
.then(() => {
|
||||
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Erreur lors de la suppression de l'absence", e)
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateGrade = async (studentEvalId, data) => {
|
||||
try {
|
||||
await updateStudentEvaluation(studentEvalId, data, csrfToken);
|
||||
// Reload student evaluations
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
const updatedData = await fetchStudentEvaluations(
|
||||
studentId,
|
||||
null,
|
||||
periodString,
|
||||
null
|
||||
);
|
||||
setStudentEvaluationsData(updatedData);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la modification de la note:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/admin/grades')}
|
||||
className="p-2 rounded hover:bg-gray-100 border border-gray-200 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors"
|
||||
aria-label="Retour à la liste"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-headline text-xl font-bold text-gray-800">Suivi pédagogique</h1>
|
||||
</div>
|
||||
|
||||
{/* Student profile */}
|
||||
{student && (
|
||||
<div className="bg-neutral rounded-md shadow-sm border border-gray-100 p-4 md:p-6 flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
{student.photo ? (
|
||||
<img
|
||||
src={getSecureFileUrl(student.photo)}
|
||||
alt={`${student.first_name} ${student.last_name}`}
|
||||
className="w-24 h-24 object-cover rounded-full border-4 border-primary/20 shadow"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl border-4 border-primary/10">
|
||||
{student.first_name?.[0]}
|
||||
{student.last_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<div className="text-xl font-bold text-secondary">
|
||||
{student.last_name} {student.first_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
Niveau :{' '}
|
||||
<span className="font-medium">
|
||||
{getNiveauLabel(student.level)}
|
||||
</span>
|
||||
{' | '}
|
||||
Classe :{' '}
|
||||
<span className="font-medium">
|
||||
{student.associated_class_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period selector + Evaluate button */}
|
||||
<div className="flex flex-col sm:flex-row items-end gap-3 w-full sm:w-auto">
|
||||
<div className="w-full sm:w-44">
|
||||
<SelectChoice
|
||||
name="period"
|
||||
label="Période"
|
||||
placeHolder="Choisir la période"
|
||||
choices={getPeriods().map((period) => {
|
||||
const today = dayjs();
|
||||
const end = dayjs(`${today.year()}-${period.end}`);
|
||||
return {
|
||||
value: period.value,
|
||||
label: period.label,
|
||||
disabled: today.isAfter(end),
|
||||
};
|
||||
})}
|
||||
selected={selectedPeriod || ''}
|
||||
callback={(e) => setSelectedPeriod(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
primary
|
||||
onClick={() => {
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
router.push(
|
||||
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodString}`
|
||||
);
|
||||
}}
|
||||
className="px-4 py-2 rounded shadow bg-primary text-white font-label font-medium hover:bg-secondary w-full sm:w-auto min-h-[44px] transition-colors"
|
||||
icon={<Award className="w-5 h-5" />}
|
||||
text="Évaluer"
|
||||
title="Évaluer l'élève"
|
||||
disabled={!selectedPeriod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats + Absences */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Attendance
|
||||
absences={absences}
|
||||
onToggleJustify={handleToggleJustify}
|
||||
onDelete={handleDeleteAbsence}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<GradesStatsCircle grades={grades} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
||||
</div>
|
||||
|
||||
{/* Évaluations par matière */}
|
||||
<div className="bg-neutral rounded-md shadow-sm border border-gray-200 p-4 md:p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BookOpen className="w-6 h-6 text-primary" />
|
||||
<h2 className="font-headline text-xl font-semibold text-gray-800">
|
||||
Évaluations par matière
|
||||
</h2>
|
||||
</div>
|
||||
<EvaluationStudentView
|
||||
evaluations={evaluations}
|
||||
studentEvaluations={studentEvaluationsData}
|
||||
editable={true}
|
||||
onUpdateGrade={handleUpdateGrade}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -8,8 +8,8 @@ import {
|
||||
fetchStudentCompetencies,
|
||||
editStudentCompetencies,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import { Award } from 'lucide-react';
|
||||
import { Award, ArrowLeft } from 'lucide-react';
|
||||
import logger from '@/utils/logger';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
|
||||
@ -70,7 +70,7 @@ export default function StudentCompetenciesPage() {
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
router.back();
|
||||
router.push('/admin/grades');
|
||||
})
|
||||
.catch((error) => {
|
||||
showNotification(
|
||||
@ -83,11 +83,16 @@ export default function StudentCompetenciesPage() {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<SectionHeader
|
||||
icon={Award}
|
||||
title="Bilan de compétence"
|
||||
description="Evaluez les compétence de l'élève"
|
||||
/>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<button
|
||||
onClick={() => router.push('/admin/grades')}
|
||||
className="p-2 rounded hover:bg-gray-100 border border-gray-200 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors"
|
||||
aria-label="Retour à la fiche élève"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-headline text-xl font-bold text-gray-800">Bilan de compétence</h1>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<form
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
@ -105,15 +110,6 @@ export default function StudentCompetenciesPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
text="Retour"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}}
|
||||
className="mr-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
/>
|
||||
<Button text="Enregistrer" primary type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
Calendar,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
MessageCircleHeart,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Popup from '@/components/Popup';
|
||||
@ -24,19 +25,22 @@ import {
|
||||
FE_ADMIN_PLANNING_URL,
|
||||
FE_ADMIN_SETTINGS_URL,
|
||||
FE_ADMIN_MESSAGERIE_URL,
|
||||
FE_ADMIN_FEEDBACK_URL,
|
||||
} from '@/utils/Url';
|
||||
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import Footer from '@/components/Footer';
|
||||
import MobileTopbar from '@/components/MobileTopbar';
|
||||
import { RIGHTS } from '@/utils/rights';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useChatConnection } from '@/context/ChatConnectionContext';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const t = useTranslations('sidebar');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const { profileRole, establishments, clearContext } =
|
||||
useEstablishment();
|
||||
const { profileRole, establishments, clearContext } = useEstablishment();
|
||||
const { totalUnreadCount, resetUnreadCount } = useChatConnection();
|
||||
|
||||
const sidebarItems = {
|
||||
admin: {
|
||||
@ -80,6 +84,13 @@ export default function Layout({ children }) {
|
||||
name: t('messagerie'),
|
||||
url: FE_ADMIN_MESSAGERIE_URL,
|
||||
icon: MessageSquare,
|
||||
badge: totalUnreadCount,
|
||||
},
|
||||
feedback: {
|
||||
id: 'feedback',
|
||||
name: t('feedback'),
|
||||
url: FE_ADMIN_FEEDBACK_URL,
|
||||
icon: MessageCircleHeart,
|
||||
},
|
||||
settings: {
|
||||
id: 'settings',
|
||||
@ -110,7 +121,11 @@ export default function Layout({ children }) {
|
||||
useEffect(() => {
|
||||
// Fermer la sidebar quand on change de page sur mobile
|
||||
setIsSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
// Réinitialiser le compteur non lu quand on ouvre la messagerie
|
||||
if (pathname?.includes('/messagerie')) {
|
||||
resetUnreadCount();
|
||||
}
|
||||
}, [pathname, resetUnreadCount]);
|
||||
|
||||
// Filtrage dynamique des items de la sidebar selon le rôle
|
||||
let sidebarItemsToDisplay = Object.values(sidebarItems);
|
||||
@ -123,9 +138,12 @@ export default function Layout({ children }) {
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
|
||||
{/* Topbar mobile (hamburger + logo) */}
|
||||
<MobileTopbar onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||
}`}
|
||||
>
|
||||
@ -146,7 +164,7 @@ export default function Layout({ children }) {
|
||||
)}
|
||||
|
||||
{/* Main container */}
|
||||
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-64 right-0">
|
||||
<div className="absolute overflow-auto bg-gradient-to-br from-primary/5 via-sky-50 to-primary/10 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,17 +1,10 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import SidebarTabs from '@/components/SidebarTabs';
|
||||
import EmailSender from '@/components/Admin/EmailSender';
|
||||
import InstantMessaging from '@/components/Admin/InstantMessaging';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export default function MessageriePage({ csrfToken }) {
|
||||
const tabs = [
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Envoyer un Mail',
|
||||
content: <EmailSender csrfToken={csrfToken} />,
|
||||
},
|
||||
{
|
||||
id: 'instant',
|
||||
label: 'Messagerie Instantanée',
|
||||
|
||||
@ -163,7 +163,7 @@ export default function DashboardPage() {
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
return (
|
||||
<div key={selectedEstablishmentId} className="p-6">
|
||||
<div key={selectedEstablishmentId} className="p-4 md:p-6">
|
||||
{/* Statistiques principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard
|
||||
@ -174,14 +174,14 @@ export default function DashboardPage() {
|
||||
<StatCard
|
||||
title={t('pendingRegistrations')}
|
||||
value={pendingRegistrationCount}
|
||||
icon={<Clock className="text-green-500" size={24} />}
|
||||
color="green"
|
||||
icon={<Clock className="text-tertiary" size={24} />}
|
||||
color="tertiary"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('structureCapacity')}
|
||||
value={selectedEstablishmentTotalCapacity}
|
||||
icon={<School className="text-green-500" size={24} />}
|
||||
color="emerald"
|
||||
icon={<School className="text-primary" size={24} />}
|
||||
color="primary"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('capacityRate')}
|
||||
@ -200,12 +200,12 @@ export default function DashboardPage() {
|
||||
{/* Colonne de gauche : Graphique des inscriptions + Présence */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Graphique des inscriptions */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<h2 className="text-lg font-semibold mb-6">
|
||||
<div className="bg-neutral p-4 md:p-6 rounded-md shadow-sm border border-gray-100 flex-1">
|
||||
<h2 className="font-headline text-lg font-semibold mb-4 md:mb-6">
|
||||
{t('inscriptionTrends')}
|
||||
</h2>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-6 mt-4">
|
||||
<div className="flex-1">
|
||||
<LineChart data={monthlyRegistrations} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
@ -214,14 +214,14 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Présence et assiduité */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<div className="bg-neutral p-4 md:p-6 rounded-md shadow-sm border border-gray-100 flex-1">
|
||||
<Attendance absences={absencesToday} readOnly={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colonne de droite : Événements à venir */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
|
||||
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
|
||||
<div className="bg-neutral p-4 md:p-6 rounded-md shadow-sm border border-gray-100 flex-1 h-full">
|
||||
<h2 className="font-headline text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
|
||||
{upcomingEvents.map((event, index) => (
|
||||
<EventCard key={index} {...event} />
|
||||
))}
|
||||
|
||||
@ -9,9 +9,11 @@ import EventModal from '@/components/Calendar/EventModal';
|
||||
import ScheduleNavigation from '@/components/Calendar/ScheduleNavigation';
|
||||
import { useState } from 'react';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { usePlanning } from '@/context/PlanningContext';
|
||||
|
||||
export default function Page() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [eventData, setEventData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
@ -28,17 +30,24 @@ export default function Page() {
|
||||
});
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
|
||||
const PlanningContent = ({ isDrawerOpen, setIsDrawerOpen, isModalOpen, setIsModalOpen, eventData, setEventData }) => {
|
||||
const { selectedSchedule, schedules } = usePlanning();
|
||||
|
||||
const initializeNewEvent = (date = new Date()) => {
|
||||
// S'assurer que date est un objet Date valide
|
||||
const eventDate = date instanceof Date ? date : new Date();
|
||||
|
||||
const selected =
|
||||
schedules.find((schedule) => Number(schedule.id) === Number(selectedSchedule)) ||
|
||||
schedules[0];
|
||||
|
||||
setEventData({
|
||||
title: '',
|
||||
description: '',
|
||||
start: eventDate.toISOString(),
|
||||
end: new Date(eventDate.getTime() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
location: '',
|
||||
planning: '', // Ne pas définir de valeur par défaut ici non plus
|
||||
planning: selected?.id || '',
|
||||
color: selected?.color || '',
|
||||
recursionType: RecurrenceType.NONE,
|
||||
selectedDays: [],
|
||||
recursionEnd: new Date(
|
||||
@ -51,18 +60,18 @@ export default function Page() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PlanningProvider
|
||||
establishmentId={selectedEstablishmentId}
|
||||
modeSet={PlanningModes.PLANNING}
|
||||
>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<ScheduleNavigation />
|
||||
<ScheduleNavigation
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
<Calendar
|
||||
onDateClick={initializeNewEvent}
|
||||
onEventClick={(event) => {
|
||||
setEventData(event);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
onOpenDrawer={() => setIsDrawerOpen(true)}
|
||||
/>
|
||||
<EventModal
|
||||
isOpen={isModalOpen}
|
||||
@ -71,6 +80,22 @@ export default function Page() {
|
||||
setEventData={setEventData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PlanningProvider
|
||||
establishmentId={selectedEstablishmentId}
|
||||
modeSet={PlanningModes.PLANNING}
|
||||
>
|
||||
<PlanningContent
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
setIsDrawerOpen={setIsDrawerOpen}
|
||||
isModalOpen={isModalOpen}
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
eventData={eventData}
|
||||
setEventData={setEventData}
|
||||
/>
|
||||
</PlanningProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Tab from '@/components/Tab';
|
||||
import TabContent from '@/components/TabContent';
|
||||
import Button from '@/components/Form/Button';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import CheckBox from '@/components/Form/CheckBox'; // Import du composant CheckBox
|
||||
@ -13,13 +11,8 @@ import {
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useCsrfToken } from '@/context/CsrfContext'; // Import du hook pour récupérer le csrfToken
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { useSearchParams } from 'next/navigation'; // Ajoute cet import
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState('smtp');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [smtpServer, setSmtpServer] = useState('');
|
||||
const [smtpPort, setSmtpPort] = useState('');
|
||||
const [smtpUser, setSmtpUser] = useState('');
|
||||
@ -29,23 +22,10 @@ export default function SettingsPage() {
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const csrfToken = useCsrfToken(); // Récupération du csrfToken
|
||||
const { showNotification } = useNotification();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
// Ajout : sélection automatique de l'onglet via l'ancre ou le paramètre de recherche
|
||||
useEffect(() => {
|
||||
const tabParam = searchParams.get('tab');
|
||||
if (tabParam === 'smtp') {
|
||||
setActiveTab('smtp');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Charger les paramètres SMTP existants
|
||||
useEffect(() => {
|
||||
if (activeTab === 'smtp') {
|
||||
if (csrfToken && selectedEstablishmentId) {
|
||||
fetchSmtpSettings(csrfToken, selectedEstablishmentId) // Passer le csrfToken ici
|
||||
.then((data) => {
|
||||
setSmtpServer(data.smtp_server || '');
|
||||
@ -75,7 +55,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [activeTab, csrfToken]); // Ajouter csrfToken comme dépendance
|
||||
}, [csrfToken, selectedEstablishmentId]);
|
||||
|
||||
const handleSmtpServerChange = (e) => {
|
||||
setSmtpServer(e.target.value);
|
||||
@ -128,16 +108,14 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex space-x-4 mb-4">
|
||||
<Tab
|
||||
text="Paramètres SMTP"
|
||||
active={activeTab === 'smtp'}
|
||||
onClick={() => handleTabClick('smtp')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TabContent isActive={activeTab === 'smtp'}>
|
||||
<div className="p-6">
|
||||
<h1 className="font-headline text-2xl font-bold text-gray-900 mb-6">
|
||||
Paramètres
|
||||
</h1>
|
||||
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-6">
|
||||
<h2 className="font-headline text-lg font-semibold text-gray-800 mb-4">
|
||||
Paramètres SMTP
|
||||
</h2>
|
||||
<form onSubmit={handleSmtpSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputText
|
||||
@ -187,7 +165,6 @@ export default function SettingsPage() {
|
||||
className="mt-6"
|
||||
></Button>
|
||||
</form>
|
||||
</TabContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
225
Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js
Normal file
225
Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js
Normal file
@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import {
|
||||
fetchRegistrationFileGroups,
|
||||
fetchRegistrationSchoolFileMasterById,
|
||||
createRegistrationSchoolFileMaster,
|
||||
editRegistrationSchoolFileMaster,
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import logger from '@/utils/logger';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { FE_ADMIN_STRUCTURE_URL } from '@/utils/Url';
|
||||
|
||||
export default function FormBuilderPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const csrfToken = useCsrfToken();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const formId = searchParams.get('id');
|
||||
const preGroupId = searchParams.get('groupId');
|
||||
const isEditing = !!formId;
|
||||
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [initialData, setInitialData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [existingFileUrl, setExistingFileUrl] = useState(null);
|
||||
|
||||
const normalizeBackendFile = (rawFile, rawFileUrl) => {
|
||||
if (typeof rawFileUrl === 'string' && rawFileUrl.trim()) {
|
||||
return rawFileUrl;
|
||||
}
|
||||
|
||||
if (typeof rawFile === 'string' && rawFile.trim()) {
|
||||
return rawFile;
|
||||
}
|
||||
|
||||
if (rawFile && typeof rawFile === 'object') {
|
||||
if (typeof rawFile.url === 'string' && rawFile.url.trim()) {
|
||||
return rawFile.url;
|
||||
}
|
||||
if (typeof rawFile.path === 'string' && rawFile.path.trim()) {
|
||||
return rawFile.path;
|
||||
}
|
||||
if (typeof rawFile.name === 'string' && rawFile.name.trim()) {
|
||||
return rawFile.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const previewFileUrl = useMemo(() => {
|
||||
if (uploadedFile instanceof File) {
|
||||
return URL.createObjectURL(uploadedFile);
|
||||
}
|
||||
return existingFileUrl || null;
|
||||
}, [uploadedFile, existingFileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewFileUrl && previewFileUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewFileUrl);
|
||||
}
|
||||
};
|
||||
}, [previewFileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEstablishmentId) return;
|
||||
|
||||
Promise.all([
|
||||
fetchRegistrationFileGroups(selectedEstablishmentId),
|
||||
formId ? fetchRegistrationSchoolFileMasterById(formId) : Promise.resolve(null),
|
||||
])
|
||||
.then(([groupsData, formData]) => {
|
||||
setGroups(groupsData || []);
|
||||
if (formData) {
|
||||
setInitialData(formData);
|
||||
const resolvedFile = normalizeBackendFile(
|
||||
formData.file,
|
||||
formData.file_url
|
||||
);
|
||||
if (resolvedFile) {
|
||||
setExistingFileUrl(resolvedFile);
|
||||
}
|
||||
} else if (preGroupId) {
|
||||
setInitialData({ groups: [{ id: Number(preGroupId) }] });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Error loading FormBuilder data:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [selectedEstablishmentId, formId, preGroupId]);
|
||||
|
||||
const buildFormData = async (name, group_ids, formMasterData) => {
|
||||
const dataToSend = new FormData();
|
||||
dataToSend.append(
|
||||
'data',
|
||||
JSON.stringify({
|
||||
name,
|
||||
groups: group_ids,
|
||||
formMasterData,
|
||||
establishment: selectedEstablishmentId,
|
||||
})
|
||||
);
|
||||
|
||||
if (uploadedFile instanceof File) {
|
||||
const ext =
|
||||
uploadedFile.name.lastIndexOf('.') !== -1
|
||||
? uploadedFile.name.substring(uploadedFile.name.lastIndexOf('.'))
|
||||
: '';
|
||||
const cleanName = (name || 'document')
|
||||
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
dataToSend.append('file', uploadedFile, `${cleanName}${ext}`);
|
||||
} else if (existingFileUrl && isEditing) {
|
||||
const lastDot = existingFileUrl.lastIndexOf('.');
|
||||
const ext = lastDot !== -1 ? existingFileUrl.substring(lastDot) : '';
|
||||
const cleanName = (name || 'document')
|
||||
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
try {
|
||||
const resp = await fetch(getSecureFileUrl(existingFileUrl));
|
||||
if (resp.ok) {
|
||||
const blob = await resp.blob();
|
||||
dataToSend.append('file', blob, `${cleanName}${ext}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Could not re-fetch existing file:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return dataToSend;
|
||||
};
|
||||
|
||||
const handleSave = async ({ name, group_ids, formMasterData, id }) => {
|
||||
const hasFileField = (formMasterData?.fields || []).some(
|
||||
(field) => field.type === 'file'
|
||||
);
|
||||
const hasUploadedDocument =
|
||||
uploadedFile instanceof File || Boolean(existingFileUrl);
|
||||
|
||||
if (hasFileField && !hasUploadedDocument) {
|
||||
showNotification(
|
||||
'Un document PDF doit être uploadé si le formulaire contient un champ fichier.',
|
||||
'error',
|
||||
'Erreur'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataToSend = await buildFormData(name, group_ids, formMasterData);
|
||||
if (isEditing) {
|
||||
await editRegistrationSchoolFileMaster(id || formId, dataToSend, csrfToken);
|
||||
showNotification(
|
||||
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
} else {
|
||||
await createRegistrationSchoolFileMaster(dataToSend, csrfToken);
|
||||
showNotification(
|
||||
`Le formulaire "${name}" a été créé avec succès.`,
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
}
|
||||
router.push(FE_ADMIN_STRUCTURE_URL);
|
||||
} catch (err) {
|
||||
logger.error('Error saving form:', err);
|
||||
showNotification('Erreur lors de la sauvegarde du formulaire', 'error', 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-gray-500">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-neutral">
|
||||
{/* Header sticky */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push(FE_ADMIN_STRUCTURE_URL)}
|
||||
className="flex items-center gap-2 text-primary hover:text-secondary font-label font-medium transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Retour
|
||||
</button>
|
||||
<h1 className="font-headline text-lg font-headline font-semibold text-gray-800">
|
||||
{isEditing ? 'Modifier le formulaire' : 'Créer un formulaire personnalisé'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4">
|
||||
{/* FormTemplateBuilder */}
|
||||
<FormTemplateBuilder
|
||||
onSave={handleSave}
|
||||
initialData={initialData}
|
||||
groups={groups}
|
||||
isEditing={isEditing}
|
||||
masterFile={previewFileUrl}
|
||||
onMasterFileUpload={(file) => setUploadedFile(file)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, Layers, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Users,
|
||||
Layers,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
ClipboardList,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import Table from '@/components/Table';
|
||||
import Popup from '@/components/Popup';
|
||||
import { fetchClasse } from '@/app/actions/schoolAction';
|
||||
import {
|
||||
fetchClasse,
|
||||
fetchSpecialities,
|
||||
fetchEvaluations,
|
||||
createEvaluation,
|
||||
updateEvaluation,
|
||||
deleteEvaluation,
|
||||
fetchStudentEvaluations,
|
||||
saveStudentEvaluations,
|
||||
deleteStudentEvaluation,
|
||||
} from '@/app/actions/schoolAction';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import logger from '@/utils/logger';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
@ -17,10 +35,16 @@ import {
|
||||
editAbsences,
|
||||
deleteAbsences,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import {
|
||||
EvaluationForm,
|
||||
EvaluationList,
|
||||
EvaluationGradeTable,
|
||||
} from '@/components/Evaluation';
|
||||
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function Page() {
|
||||
const searchParams = useSearchParams();
|
||||
@ -38,8 +62,54 @@ export default function Page() {
|
||||
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
|
||||
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement
|
||||
|
||||
// Tab system
|
||||
const [activeTab, setActiveTab] = useState('attendance'); // 'attendance' ou 'evaluations'
|
||||
|
||||
// Evaluation states
|
||||
const [specialities, setSpecialities] = useState([]);
|
||||
const [evaluations, setEvaluations] = useState([]);
|
||||
const [studentEvaluations, setStudentEvaluations] = useState([]);
|
||||
const [showEvaluationForm, setShowEvaluationForm] = useState(false);
|
||||
const [selectedEvaluation, setSelectedEvaluation] = useState(null);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||
const [editingEvaluation, setEditingEvaluation] = useState(null);
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||
useEstablishment();
|
||||
|
||||
// Périodes selon la fréquence d'évaluation
|
||||
const getPeriods = () => {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||
const nextYear = (year + 1).toString();
|
||||
const schoolYear = `${year}-${nextYear}`;
|
||||
|
||||
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||
return [
|
||||
{ label: 'Trimestre 1', value: `T1_${schoolYear}` },
|
||||
{ label: 'Trimestre 2', value: `T2_${schoolYear}` },
|
||||
{ label: 'Trimestre 3', value: `T3_${schoolYear}` },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 2) {
|
||||
return [
|
||||
{ label: 'Semestre 1', value: `S1_${schoolYear}` },
|
||||
{ label: 'Semestre 2', value: `S2_${schoolYear}` },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 3) {
|
||||
return [{ label: 'Année', value: `A_${schoolYear}` }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Auto-select current period
|
||||
useEffect(() => {
|
||||
const periods = getPeriods();
|
||||
if (periods.length > 0 && !selectedPeriod) {
|
||||
setSelectedPeriod(periods[0].value);
|
||||
}
|
||||
}, [selectedEstablishmentEvaluationFrequency]);
|
||||
|
||||
// AbsenceMoment constants
|
||||
const AbsenceMoment = {
|
||||
@ -158,6 +228,119 @@ export default function Page() {
|
||||
}
|
||||
}, [filteredStudents, fetchedAbsences]);
|
||||
|
||||
// Load specialities for evaluations (filtered by current school year)
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||
const currentSchoolYear = `${year}-${year + 1}`;
|
||||
fetchSpecialities(selectedEstablishmentId, currentSchoolYear)
|
||||
.then((data) => setSpecialities(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du chargement des matières:', error)
|
||||
);
|
||||
}
|
||||
}, [selectedEstablishmentId]);
|
||||
|
||||
// Load evaluations when tab is active and period is selected
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab === 'evaluations' &&
|
||||
selectedEstablishmentId &&
|
||||
schoolClassId &&
|
||||
selectedPeriod
|
||||
) {
|
||||
fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod)
|
||||
.then((data) => setEvaluations(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du chargement des évaluations:', error)
|
||||
);
|
||||
}
|
||||
}, [activeTab, selectedEstablishmentId, schoolClassId, selectedPeriod]);
|
||||
|
||||
// Load student evaluations when grading
|
||||
useEffect(() => {
|
||||
if (selectedEvaluation && schoolClassId) {
|
||||
fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId)
|
||||
.then((data) => setStudentEvaluations(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du chargement des notes:', error)
|
||||
);
|
||||
}
|
||||
}, [selectedEvaluation, schoolClassId]);
|
||||
|
||||
// Handlers for evaluations
|
||||
const handleCreateEvaluation = async (data) => {
|
||||
try {
|
||||
await createEvaluation(data, csrfToken);
|
||||
showNotification('Évaluation créée avec succès', 'success', 'Succès');
|
||||
setShowEvaluationForm(false);
|
||||
// Reload evaluations
|
||||
const updatedEvaluations = await fetchEvaluations(
|
||||
selectedEstablishmentId,
|
||||
schoolClassId,
|
||||
selectedPeriod
|
||||
);
|
||||
setEvaluations(updatedEvaluations);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la création:', error);
|
||||
showNotification('Erreur lors de la création', 'error', 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEvaluation = (evaluation) => {
|
||||
setEditingEvaluation(evaluation);
|
||||
setShowEvaluationForm(true);
|
||||
};
|
||||
|
||||
const handleUpdateEvaluation = async (data) => {
|
||||
try {
|
||||
await updateEvaluation(editingEvaluation.id, data, csrfToken);
|
||||
showNotification('Évaluation modifiée avec succès', 'success', 'Succès');
|
||||
setShowEvaluationForm(false);
|
||||
setEditingEvaluation(null);
|
||||
// Reload evaluations
|
||||
const updatedEvaluations = await fetchEvaluations(
|
||||
selectedEstablishmentId,
|
||||
schoolClassId,
|
||||
selectedPeriod
|
||||
);
|
||||
setEvaluations(updatedEvaluations);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la modification:', error);
|
||||
showNotification('Erreur lors de la modification', 'error', 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEvaluation = async (evaluationId) => {
|
||||
await deleteEvaluation(evaluationId, csrfToken);
|
||||
// Reload evaluations
|
||||
const updatedEvaluations = await fetchEvaluations(
|
||||
selectedEstablishmentId,
|
||||
schoolClassId,
|
||||
selectedPeriod
|
||||
);
|
||||
setEvaluations(updatedEvaluations);
|
||||
};
|
||||
|
||||
const handleSaveGrades = async (gradesData) => {
|
||||
await saveStudentEvaluations(gradesData, csrfToken);
|
||||
// Reload student evaluations
|
||||
const updatedStudentEvaluations = await fetchStudentEvaluations(
|
||||
null,
|
||||
selectedEvaluation.id,
|
||||
null,
|
||||
schoolClassId
|
||||
);
|
||||
setStudentEvaluations(updatedStudentEvaluations);
|
||||
};
|
||||
|
||||
const handleDeleteGrade = async (studentEvalId) => {
|
||||
await deleteStudentEvaluation(studentEvalId, csrfToken);
|
||||
setStudentEvaluations((prev) =>
|
||||
prev.filter((se) => se.id !== studentEvalId)
|
||||
);
|
||||
};
|
||||
|
||||
const handleLevelClick = (label) => {
|
||||
setSelectedLevels(
|
||||
(prev) =>
|
||||
@ -413,14 +596,16 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">{classe?.atmosphere_name}</h1>
|
||||
<h1 className="font-headline text-2xl font-bold">
|
||||
{classe?.atmosphere_name}
|
||||
</h1>
|
||||
|
||||
{/* Section Niveaux et Enseignants */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Section Niveaux */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center">
|
||||
<Layers className="w-6 h-6 mr-2" />
|
||||
<div className="bg-white p-4 rounded-md shadow-sm">
|
||||
<h2 className="font-headline text-xl font-semibold mb-4 flex items-center">
|
||||
<Layers className="w-6 h-6 mr-2 text-primary" />
|
||||
Niveaux
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
@ -429,24 +614,24 @@ export default function Page() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{classe?.levels?.length > 0 ? (
|
||||
getNiveauxLabels(classe.levels).map((label, index) => (
|
||||
<span
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleLevelClick(label)} // Gérer le clic sur un niveau
|
||||
className={`px-4 py-2 rounded-full cursor-pointer border transition-all duration-200 ${
|
||||
onClick={() => handleLevelClick(label)}
|
||||
className={`px-4 py-2 rounded font-label font-medium cursor-pointer border transition-colors min-h-[44px] ${
|
||||
selectedLevels.includes(label)
|
||||
? 'bg-emerald-200 text-emerald-800 border-emerald-300 shadow-md'
|
||||
? 'bg-primary/20 text-secondary border-primary/30 shadow-sm'
|
||||
: 'bg-gray-200 text-gray-800 border-gray-300 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selectedLevels.includes(label) ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||
<CheckCircle className="w-4 h-4 text-primary" />
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-500">Aucun niveau associé</span>
|
||||
@ -455,9 +640,9 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
{/* Section Enseignants */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center">
|
||||
<Users className="w-6 h-6 mr-2" />
|
||||
<div className="bg-white p-4 rounded-md shadow-sm">
|
||||
<h2 className="font-headline text-xl font-semibold mb-4 flex items-center">
|
||||
<Users className="w-6 h-6 mr-2 text-primary" />
|
||||
Enseignants
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">Liste des enseignants</p>
|
||||
@ -465,7 +650,7 @@ export default function Page() {
|
||||
{classe?.teachers_details?.map((teacher) => (
|
||||
<span
|
||||
key={teacher.id}
|
||||
className="px-3 py-1 bg-emerald-200 rounded-full text-emerald-800"
|
||||
className="px-3 py-1 bg-primary/20 rounded text-secondary font-label text-sm"
|
||||
>
|
||||
{teacher.last_name} {teacher.first_name}
|
||||
</span>
|
||||
@ -474,15 +659,50 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white rounded-md shadow-sm overflow-hidden">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('attendance')}
|
||||
className={`flex-1 py-3 px-4 text-center font-label font-medium transition-colors min-h-[44px] ${
|
||||
activeTab === 'attendance'
|
||||
? 'text-primary border-b-2 border-primary bg-primary/5'
|
||||
: 'text-gray-500 hover:text-secondary hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Appel du jour
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('evaluations')}
|
||||
className={`flex-1 py-3 px-4 text-center font-label font-medium transition-colors min-h-[44px] ${
|
||||
activeTab === 'evaluations'
|
||||
? 'text-primary border-b-2 border-primary bg-primary/5'
|
||||
: 'text-gray-500 hover:text-secondary hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<ClipboardList className="w-5 h-5" />
|
||||
Évaluations
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content: Attendance */}
|
||||
{activeTab === 'attendance' && (
|
||||
<>
|
||||
{/* Affichage de la date du jour */}
|
||||
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-md shadow-sm">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-primary/10 text-primary rounded">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
<h2 className="font-headline text-lg font-semibold text-gray-800">
|
||||
Appel du jour :{' '}
|
||||
<span className="ml-2 text-emerald-600">{today}</span>
|
||||
<span className="ml-2 text-primary">{today}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
@ -491,14 +711,14 @@ export default function Page() {
|
||||
text="Faire l'appel"
|
||||
onClick={handleToggleAttendanceMode}
|
||||
primary
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
||||
className="px-4 py-2 bg-primary text-white font-label font-medium rounded shadow-sm hover:bg-secondary transition-colors min-h-[44px]"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
text="Valider l'appel"
|
||||
onClick={handleValidateAttendance}
|
||||
primary
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
||||
className="px-4 py-2 bg-primary text-white font-label font-medium rounded shadow-sm hover:bg-secondary transition-colors min-h-[44px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -537,7 +757,9 @@ export default function Page() {
|
||||
<CheckBox
|
||||
item={{ id: row.id }}
|
||||
formData={{
|
||||
attendance: attendance[row.id] ? [row.id] : [],
|
||||
attendance: attendance[row.id]
|
||||
? [row.id]
|
||||
: [],
|
||||
}}
|
||||
handleChange={() =>
|
||||
handleAttendanceChange(row.id)
|
||||
@ -568,10 +790,10 @@ export default function Page() {
|
||||
|
||||
{/* Détails absence/retard */}
|
||||
{!attendance[row.id] && (
|
||||
<div className="w-full bg-emerald-50 border border-emerald-100 rounded-lg p-3 mt-2 shadow-sm">
|
||||
<div className="w-full bg-primary/5 border border-primary/10 rounded-lg p-3 mt-2 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-emerald-500" />
|
||||
<span className="font-semibold text-emerald-700 text-sm">
|
||||
<Clock className="w-4 h-4 text-primary" />
|
||||
<span className="font-semibold text-secondary text-sm">
|
||||
Motif d'absence
|
||||
</span>
|
||||
</div>
|
||||
@ -625,7 +847,9 @@ export default function Page() {
|
||||
type="text"
|
||||
className="border rounded px-2 py-1 text-sm w-full"
|
||||
placeholder="Commentaire"
|
||||
value={formAbsences[row.id]?.commentaire || ''}
|
||||
value={
|
||||
formAbsences[row.id]?.commentaire || ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
setFormAbsences((prev) => ({
|
||||
...prev,
|
||||
@ -642,7 +866,8 @@ export default function Page() {
|
||||
<CheckBox
|
||||
item={{ id: `justified-${row.id}` }}
|
||||
formData={{
|
||||
justified: !!formAbsences[row.id]?.justified,
|
||||
justified:
|
||||
!!formAbsences[row.id]?.justified,
|
||||
}}
|
||||
handleChange={() =>
|
||||
setFormAbsences((prev) => ({
|
||||
@ -673,7 +898,8 @@ export default function Page() {
|
||||
formAbsences[row.id] ||
|
||||
Object.values(fetchedAbsences).find(
|
||||
(absence) =>
|
||||
absence.student === row.id && absence.day === today
|
||||
absence.student === row.id &&
|
||||
absence.day === today
|
||||
);
|
||||
|
||||
if (!absence) {
|
||||
@ -728,6 +954,90 @@ export default function Page() {
|
||||
]}
|
||||
data={filteredStudents} // Utiliser les élèves filtrés
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tab Content: Evaluations */}
|
||||
{activeTab === 'evaluations' && (
|
||||
<div className="space-y-4">
|
||||
{/* Header avec sélecteur de période et bouton d'ajout */}
|
||||
<div className="bg-white p-4 rounded-md shadow-sm border border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<ClipboardList className="w-6 h-6 text-primary" />
|
||||
<h2 className="font-headline text-lg font-semibold text-gray-800">
|
||||
Évaluations de la classe
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="w-48">
|
||||
<SelectChoice
|
||||
name="period"
|
||||
placeHolder="Période"
|
||||
choices={getPeriods()}
|
||||
selected={selectedPeriod || ''}
|
||||
callback={(e) => setSelectedPeriod(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
primary
|
||||
text="Nouvelle évaluation"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={() => setShowEvaluationForm(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulaire de création/édition d'évaluation */}
|
||||
{showEvaluationForm && (
|
||||
<EvaluationForm
|
||||
specialities={specialities}
|
||||
period={selectedPeriod}
|
||||
schoolClassId={parseInt(schoolClassId)}
|
||||
establishmentId={selectedEstablishmentId}
|
||||
initialValues={editingEvaluation}
|
||||
onSubmit={
|
||||
editingEvaluation
|
||||
? handleUpdateEvaluation
|
||||
: handleCreateEvaluation
|
||||
}
|
||||
onCancel={() => {
|
||||
setShowEvaluationForm(false);
|
||||
setEditingEvaluation(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Liste des évaluations */}
|
||||
<div className="bg-white p-4 rounded-md shadow-sm border border-gray-200">
|
||||
<EvaluationList
|
||||
evaluations={evaluations}
|
||||
onDelete={handleDeleteEvaluation}
|
||||
onEdit={handleEditEvaluation}
|
||||
onGradeStudents={(evaluation) =>
|
||||
setSelectedEvaluation(evaluation)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal de notation */}
|
||||
{selectedEvaluation && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="w-full max-w-4xl max-h-[90vh] overflow-auto">
|
||||
<EvaluationGradeTable
|
||||
evaluation={selectedEvaluation}
|
||||
students={filteredStudents}
|
||||
studentEvaluations={studentEvaluations}
|
||||
onSave={handleSaveGrades}
|
||||
onClose={() => setSelectedEvaluation(null)}
|
||||
onDeleteGrade={handleDeleteGrade}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Popup */}
|
||||
<Popup
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getCurrentSchoolYear } from '@/utils/Date';
|
||||
|
||||
import StructureManagement from '@/components/Structure/Configuration/StructureManagement';
|
||||
import ScheduleManagement from '@/components/Structure/Planning/ScheduleManagement';
|
||||
@ -54,6 +55,13 @@ export default function Page() {
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const currentSchoolYear = getCurrentSchoolYear();
|
||||
|
||||
const scheduleClasses = classes.filter(
|
||||
(classe) => classe?.school_year === currentSchoolYear
|
||||
);
|
||||
const scheduleSpecialities = specialities;
|
||||
const scheduleTeachers = teachers;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
@ -299,9 +307,9 @@ export default function Page() {
|
||||
<ClassesProvider>
|
||||
<ScheduleManagement
|
||||
handleUpdatePlanning={handleUpdatePlanning}
|
||||
classes={classes}
|
||||
specialities={specialities}
|
||||
teachers={teachers}
|
||||
classes={scheduleClasses}
|
||||
specialities={scheduleSpecialities}
|
||||
teachers={scheduleTeachers}
|
||||
/>
|
||||
</ClassesProvider>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { User, Mail } from 'lucide-react';
|
||||
import { User, Mail, Info } from 'lucide-react';
|
||||
import InputTextIcon from '@/components/Form/InputTextIcon';
|
||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||
import Button from '@/components/Form/Button';
|
||||
@ -34,14 +34,32 @@ import {
|
||||
import {
|
||||
fetchRegistrationFileGroups,
|
||||
fetchRegistrationSchoolFileMasters,
|
||||
fetchRegistrationParentFileMasters
|
||||
fetchRegistrationParentFileMasters,
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import { fetchProfiles } from '@/app/actions/authAction';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { FE_ADMIN_SUBSCRIPTIONS_URL, BASE_URL } from '@/utils/Url';
|
||||
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
|
||||
function NoInfoAlert({ message }) {
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded border border-orange-300 bg-orange-50 px-4 py-3 text-orange-800"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="text-sm leading-6">
|
||||
<span className="font-semibold">Information :</span>{' '}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreateSubscriptionPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
studentLastName: '',
|
||||
@ -71,6 +89,8 @@ export default function CreateSubscriptionPage() {
|
||||
const registerFormMoment = searchParams.get('school_year');
|
||||
|
||||
const [students, setStudents] = useState([]);
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const [studentsPage, setStudentsPage] = useState(1);
|
||||
const [registrationDiscounts, setRegistrationDiscounts] = useState([]);
|
||||
const [tuitionDiscounts, setTuitionDiscounts] = useState([]);
|
||||
const [registrationFees, setRegistrationFees] = useState([]);
|
||||
@ -179,6 +199,10 @@ export default function CreateSubscriptionPage() {
|
||||
formDataRef.current = formData;
|
||||
}, [formData]);
|
||||
|
||||
useEffect(() => {
|
||||
setStudentsPage(1);
|
||||
}, [students]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData.guardianEmail) {
|
||||
// Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool
|
||||
@ -709,6 +733,12 @@ export default function CreateSubscriptionPage() {
|
||||
return finalAmount.toFixed(2);
|
||||
};
|
||||
|
||||
const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE);
|
||||
const pagedStudents = students.slice(
|
||||
(studentsPage - 1) * ITEMS_PER_PAGE,
|
||||
studentsPage * ITEMS_PER_PAGE
|
||||
);
|
||||
|
||||
if (isLoading === true) {
|
||||
return <Loader />; // Affichez le composant Loader
|
||||
}
|
||||
@ -716,11 +746,11 @@ export default function CreateSubscriptionPage() {
|
||||
return (
|
||||
<div className="mx-auto p-12 space-y-12">
|
||||
{registerFormID ? (
|
||||
<h1 className="text-2xl font-bold">
|
||||
<h1 className="font-headline text-2xl font-bold">
|
||||
Modifier un dossier d'inscription
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="text-2xl font-bold">
|
||||
<h1 className="font-headline text-2xl font-bold">
|
||||
Créer un dossier d'inscription
|
||||
</h1>
|
||||
)}
|
||||
@ -869,7 +899,7 @@ export default function CreateSubscriptionPage() {
|
||||
{!isNewResponsable && (
|
||||
<div className="mt-4">
|
||||
<Table
|
||||
data={students}
|
||||
data={pagedStudents}
|
||||
columns={[
|
||||
{
|
||||
name: 'photo',
|
||||
@ -877,12 +907,12 @@ export default function CreateSubscriptionPage() {
|
||||
<div className="flex justify-center items-center">
|
||||
{row.photo ? (
|
||||
<a
|
||||
href={`${BASE_URL}${row.photo}`} // Lien vers la photo
|
||||
href={getSecureFileUrl(row.photo)} // Lien vers la photo
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={`${BASE_URL}${row.photo}`}
|
||||
src={getSecureFileUrl(row.photo)}
|
||||
alt={`${row.first_name} ${row.last_name}`}
|
||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||
/>
|
||||
@ -923,15 +953,19 @@ export default function CreateSubscriptionPage() {
|
||||
}}
|
||||
rowClassName={(row) =>
|
||||
selectedStudent && selectedStudent.id === row.id
|
||||
? 'bg-emerald-600 text-white'
|
||||
? 'bg-primary text-white'
|
||||
: ''
|
||||
}
|
||||
selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
currentPage={studentsPage}
|
||||
totalPages={studentsTotalPages}
|
||||
onPageChange={setStudentsPage}
|
||||
/>
|
||||
|
||||
{selectedStudent && (
|
||||
<div className="mt-4">
|
||||
<h3 className="font-bold">
|
||||
<h3 className="font-headline font-bold">
|
||||
Responsables associés à {selectedStudent.last_name}{' '}
|
||||
{selectedStudent.first_name} :
|
||||
</h3>
|
||||
@ -986,22 +1020,13 @@ export default function CreateSubscriptionPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="bg-orange-100 border border-orange-400 text-orange-700 px-4 py-3 rounded relative"
|
||||
role="alert"
|
||||
>
|
||||
<strong className="font-bold">Information</strong>
|
||||
<span className="block sm:inline">
|
||||
Aucune réduction n'a été créée sur les frais
|
||||
d'inscription.
|
||||
</span>
|
||||
</p>
|
||||
<NoInfoAlert message="Aucune réduction n'a été créée sur les frais d'inscription." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Montant total */}
|
||||
<div className="flex items-center justify-between bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-300 mt-4">
|
||||
<div className="flex items-center justify-between bg-gray-50 p-4 rounded-md shadow-sm border border-gray-300 mt-4">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Montant total des frais d'inscription :
|
||||
</span>
|
||||
@ -1011,15 +1036,7 @@ export default function CreateSubscriptionPage() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
|
||||
role="alert"
|
||||
>
|
||||
<strong className="font-bold">Attention!</strong>
|
||||
<span className="block sm:inline">
|
||||
Aucun frais d'inscription n'a été créé.
|
||||
</span>
|
||||
</p>
|
||||
<NoInfoAlert message="Aucun frais d'inscription n'a été créé." />
|
||||
)}
|
||||
</SectionTitle>
|
||||
|
||||
@ -1050,22 +1067,13 @@ export default function CreateSubscriptionPage() {
|
||||
handleDiscountSelection={handleTuitionDiscountSelection}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="bg-orange-100 border border-orange-400 text-orange-700 px-4 py-3 rounded relative"
|
||||
role="alert"
|
||||
>
|
||||
<strong className="font-bold">Information</strong>
|
||||
<span className="block sm:inline">
|
||||
Aucune réduction n'a été créée sur les frais de
|
||||
scolarité.
|
||||
</span>
|
||||
</p>
|
||||
<NoInfoAlert message="Aucune réduction n'a été créée sur les frais de scolarité." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Montant total */}
|
||||
<div className="flex items-center justify-between bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-300 mt-4">
|
||||
<div className="flex items-center justify-between bg-gray-50 p-4 rounded-md shadow-sm border border-gray-300 mt-4">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Montant total des frais de scolarité :
|
||||
</span>
|
||||
@ -1075,15 +1083,7 @@ export default function CreateSubscriptionPage() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
|
||||
role="alert"
|
||||
>
|
||||
<strong className="font-bold">Attention!</strong>
|
||||
<span className="block sm:inline">
|
||||
Aucun frais de scolarité n'a été créé.
|
||||
</span>
|
||||
</p>
|
||||
<NoInfoAlert message="Aucun frais de scolarité n'a été créé." />
|
||||
)}
|
||||
</SectionTitle>
|
||||
|
||||
@ -1136,7 +1136,7 @@ export default function CreateSubscriptionPage() {
|
||||
className={`px-6 py-2 rounded-md shadow ${
|
||||
isSubmitDisabled()
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
: 'bg-primary text-white hover:bg-primary'
|
||||
}`}
|
||||
primary
|
||||
disabled={isSubmitDisabled()}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Table from '@/components/Table';
|
||||
import Tab from '@/components/Tab';
|
||||
import SidebarTabs from '@/components/SidebarTabs';
|
||||
import Textarea from '@/components/Textarea';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import StatusLabel from '@/components/StatusLabel';
|
||||
@ -19,6 +19,7 @@ import {
|
||||
Upload,
|
||||
Eye,
|
||||
XCircle,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import Modal from '@/components/Modal';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
@ -36,8 +37,8 @@ import {
|
||||
FE_ADMIN_SUBSCRIPTIONS_EDIT_URL,
|
||||
FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL,
|
||||
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
|
||||
BASE_URL,
|
||||
} from '@/utils/Url';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
|
||||
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
@ -54,7 +55,9 @@ import {
|
||||
HISTORICAL_FILTER,
|
||||
} from '@/utils/constants';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { exportToCSV } from '@/utils/exportCSV';
|
||||
|
||||
export default function Page({ params: { locale } }) {
|
||||
const t = useTranslations('subscriptions');
|
||||
@ -112,15 +115,29 @@ export default function Page({ params: { locale } }) {
|
||||
// Valide le refus
|
||||
const handleRefuse = () => {
|
||||
if (!refuseReason.trim()) {
|
||||
showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur');
|
||||
showNotification(
|
||||
'Merci de préciser la raison du refus.',
|
||||
'error',
|
||||
'Erreur'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('data', JSON.stringify({ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason }));
|
||||
formData.append(
|
||||
'data',
|
||||
JSON.stringify({
|
||||
status: RegistrationFormStatus.STATUS_ARCHIVED,
|
||||
notes: refuseReason,
|
||||
})
|
||||
);
|
||||
|
||||
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
|
||||
.then(() => {
|
||||
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
|
||||
showNotification(
|
||||
'Le dossier a été refusé et archivé.',
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
setReloadFetch(true);
|
||||
setIsRefusePopupOpen(false);
|
||||
})
|
||||
@ -149,6 +166,92 @@ export default function Page({ params: { locale } }) {
|
||||
setIsFilesModalOpen(true);
|
||||
};
|
||||
|
||||
// Export CSV
|
||||
const handleExportCSV = () => {
|
||||
const dataToExport =
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? registrationFormsDataCurrentYear
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? registrationFormsDataNextYear
|
||||
: registrationFormsDataHistorical;
|
||||
|
||||
const exportColumns = [
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Nom',
|
||||
transform: (value) => value?.last_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Prénom',
|
||||
transform: (value) => value?.first_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Date de naissance',
|
||||
transform: (value) => value?.birth_date || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Email contact',
|
||||
transform: (value) =>
|
||||
value?.guardians?.[0]?.associated_profile_email || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Téléphone contact',
|
||||
transform: (value) => value?.guardians?.[0]?.phone || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Nom responsable 1',
|
||||
transform: (value) => value?.guardians?.[0]?.last_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Prénom responsable 1',
|
||||
transform: (value) => value?.guardians?.[0]?.first_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Nom responsable 2',
|
||||
transform: (value) => value?.guardians?.[1]?.last_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Prénom responsable 2',
|
||||
transform: (value) => value?.guardians?.[1]?.first_name || '',
|
||||
},
|
||||
{ key: 'school_year', label: 'Année scolaire' },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Statut',
|
||||
transform: (value) => {
|
||||
const statusMap = {
|
||||
0: 'En attente',
|
||||
1: 'En cours',
|
||||
2: 'Envoyé',
|
||||
3: 'À relancer',
|
||||
4: 'À valider',
|
||||
5: 'Validé',
|
||||
6: 'Archivé',
|
||||
};
|
||||
return statusMap[value] || value;
|
||||
},
|
||||
},
|
||||
{ key: 'formatted_last_update', label: 'Dernière mise à jour' },
|
||||
];
|
||||
|
||||
const yearLabel =
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? currentSchoolYear
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? nextSchoolYear
|
||||
: 'historique';
|
||||
const filename = `inscriptions_${yearLabel}_${new Date().toISOString().split('T')[0]}`;
|
||||
exportToCSV(dataToExport, exportColumns, filename);
|
||||
};
|
||||
|
||||
const requestErrorHandler = (err) => {
|
||||
logger.error('Error fetching data:', err);
|
||||
};
|
||||
@ -447,7 +550,7 @@ export default function Page({ params: { locale } }) {
|
||||
{
|
||||
icon: (
|
||||
<span title="Envoyer le dossier">
|
||||
<Send className="w-5 h-5 text-green-500 hover:text-green-700" />
|
||||
<Send className="w-5 h-5 text-primary hover:text-secondary" />
|
||||
</span>
|
||||
),
|
||||
onClick: () =>
|
||||
@ -477,7 +580,7 @@ export default function Page({ params: { locale } }) {
|
||||
{
|
||||
icon: (
|
||||
<span title="Renvoyer le dossier">
|
||||
<Send className="w-5 h-5 text-green-500 hover:text-green-700" />
|
||||
<Send className="w-5 h-5 text-primary hover:text-secondary" />
|
||||
</span>
|
||||
),
|
||||
onClick: () =>
|
||||
@ -516,7 +619,7 @@ export default function Page({ params: { locale } }) {
|
||||
{
|
||||
icon: (
|
||||
<span title="Valider le dossier">
|
||||
<CheckCircle className="w-5 h-5 text-green-500 hover:text-green-700" />
|
||||
<CheckCircle className="w-5 h-5 text-primary hover:text-secondary" />
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
@ -631,7 +734,7 @@ export default function Page({ params: { locale } }) {
|
||||
{
|
||||
icon: (
|
||||
<span title="Uploader un mandat SEPA">
|
||||
<Upload className="w-5 h-5 text-emerald-500 hover:text-emerald-700" />
|
||||
<Upload className="w-5 h-5 text-primary hover:text-secondary" />
|
||||
</span>
|
||||
),
|
||||
onClick: () => openSepaUploadModal(row),
|
||||
@ -668,12 +771,12 @@ export default function Page({ params: { locale } }) {
|
||||
<div className="flex justify-center items-center">
|
||||
{row.student.photo ? (
|
||||
<a
|
||||
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
|
||||
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={`${BASE_URL}${row.student.photo}`}
|
||||
src={getSecureFileUrl(row.student.photo)}
|
||||
alt={`${row.student.first_name} ${row.student.last_name}`}
|
||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||
/>
|
||||
@ -741,90 +844,51 @@ export default function Page({ params: { locale } }) {
|
||||
},
|
||||
];
|
||||
|
||||
let emptyMessage;
|
||||
if (activeTab === CURRENT_YEAR_FILTER && searchTerm === '') {
|
||||
emptyMessage = (
|
||||
<AlertMessage
|
||||
type="warning"
|
||||
title="Aucun dossier d'inscription pour l'année en cours"
|
||||
message="Veuillez procéder à la création d'un nouveau dossier d'inscription pour l'année scolaire en cours."
|
||||
/>
|
||||
);
|
||||
} else if (activeTab === NEXT_YEAR_FILTER && searchTerm === '') {
|
||||
emptyMessage = (
|
||||
<AlertMessage
|
||||
type="info"
|
||||
title="Aucun dossier d'inscription pour l'année prochaine"
|
||||
message="Aucun dossier n'a encore été créé pour la prochaine année scolaire."
|
||||
/>
|
||||
);
|
||||
} else if (activeTab === HISTORICAL_FILTER && searchTerm === '') {
|
||||
emptyMessage = (
|
||||
<AlertMessage
|
||||
type="info"
|
||||
title="Aucun dossier d'inscription historique"
|
||||
message="Aucun dossier archivé n'est disponible pour les années précédentes."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const getEmptyMessageForTab = (tabFilter) => {
|
||||
if (searchTerm !== '') {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex items-center gap-8">
|
||||
{/* Tab pour l'année scolaire en cours */}
|
||||
<Tab
|
||||
text={
|
||||
<>
|
||||
{currentSchoolYear}
|
||||
<span className="ml-2 text-sm text-gray-400">
|
||||
({totalCurrentYear})
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
active={activeTab === CURRENT_YEAR_FILTER}
|
||||
onClick={() => setActiveTab(CURRENT_YEAR_FILTER)}
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="Aucun dossier trouvé"
|
||||
description="Modifiez votre recherche pour trouver un dossier d'inscription."
|
||||
/>
|
||||
|
||||
{/* Tab pour l'année scolaire prochaine */}
|
||||
<Tab
|
||||
text={
|
||||
<>
|
||||
{nextSchoolYear}
|
||||
<span className="ml-2 text-sm text-gray-400">
|
||||
({totalNextYear})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
active={activeTab === NEXT_YEAR_FILTER}
|
||||
onClick={() => setActiveTab(NEXT_YEAR_FILTER)}
|
||||
if (tabFilter === CURRENT_YEAR_FILTER) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="Aucun dossier d'inscription pour l'année en cours"
|
||||
description="Commencez par créer un dossier d'inscription pour l'année scolaire en cours."
|
||||
actionLabel="Créer un dossier"
|
||||
actionIcon={Plus}
|
||||
onAction={() => router.push(FE_ADMIN_SUBSCRIPTIONS_CREATE_URL)}
|
||||
/>
|
||||
|
||||
{/* Tab pour l'historique */}
|
||||
<Tab
|
||||
text={
|
||||
<>
|
||||
{t('historical')}
|
||||
<span className="ml-2 text-sm text-gray-400">
|
||||
({totalHistorical})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
active={activeTab === HISTORICAL_FILTER}
|
||||
onClick={() => setActiveTab(HISTORICAL_FILTER)}
|
||||
if (tabFilter === NEXT_YEAR_FILTER) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="Aucun dossier pour l'année prochaine"
|
||||
description="Aucun dossier n'a encore été créé pour la prochaine année scolaire."
|
||||
actionLabel="Créer un dossier"
|
||||
actionIcon={Plus}
|
||||
onAction={() => router.push(FE_ADMIN_SUBSCRIPTIONS_CREATE_URL)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Archive}
|
||||
title="Aucun dossier archivé"
|
||||
description="Aucun dossier archivé n'est disponible pour les années précédentes."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="border-b border-gray-200 mb-6 w-full">
|
||||
{activeTab === CURRENT_YEAR_FILTER ||
|
||||
activeTab === NEXT_YEAR_FILTER ||
|
||||
activeTab === HISTORICAL_FILTER ? (
|
||||
<React.Fragment>
|
||||
const renderTabContent = (data, currentPage, totalPages, tabFilter) => (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4 w-full">
|
||||
<div className="relative flex-grow">
|
||||
<Search
|
||||
@ -839,53 +903,80 @@ export default function Page({ params: { locale } }) {
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-secondary bg-primary/10 rounded hover:bg-primary/20 transition-colors"
|
||||
title="Exporter en CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Exporter
|
||||
</button>
|
||||
{profileRole !== 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||
router.push(url);
|
||||
}}
|
||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
||||
onClick={() => router.push(FE_ADMIN_SUBSCRIPTIONS_CREATE_URL)}
|
||||
className="flex items-center bg-primary text-white p-2 rounded-full shadow hover:bg-secondary transition duration-200"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
</div>
|
||||
<DjangoCSRFToken csrfToken={csrfToken} />
|
||||
<Table
|
||||
key={`${currentSchoolYearPage}-${searchTerm}`}
|
||||
data={
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? registrationFormsDataCurrentYear
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? registrationFormsDataNextYear
|
||||
: registrationFormsDataHistorical
|
||||
}
|
||||
key={`${tabFilter}-${currentPage}-${searchTerm}`}
|
||||
data={data}
|
||||
columns={columns}
|
||||
itemsPerPage={itemsPerPage}
|
||||
currentPage={
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? currentSchoolYearPage
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? currentSchoolNextYearPage
|
||||
: currentSchoolHistoricalYearPage
|
||||
}
|
||||
totalPages={
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? totalCurrentSchoolYearPages
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? totalNextSchoolYearPages
|
||||
: totalHistoricalPages
|
||||
}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
emptyMessage={emptyMessage}
|
||||
emptyMessage={getEmptyMessageForTab(tabFilter)}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<SidebarTabs
|
||||
tabs={[
|
||||
{
|
||||
id: CURRENT_YEAR_FILTER,
|
||||
label: `${currentSchoolYear}${totalCurrentYear > 0 ? ` (${totalCurrentYear})` : ''}`,
|
||||
content: renderTabContent(
|
||||
registrationFormsDataCurrentYear,
|
||||
currentSchoolYearPage,
|
||||
totalCurrentSchoolYearPages,
|
||||
CURRENT_YEAR_FILTER
|
||||
),
|
||||
},
|
||||
{
|
||||
id: NEXT_YEAR_FILTER,
|
||||
label: `${nextSchoolYear}${totalNextYear > 0 ? ` (${totalNextYear})` : ''}`,
|
||||
content: renderTabContent(
|
||||
registrationFormsDataNextYear,
|
||||
currentSchoolNextYearPage,
|
||||
totalNextSchoolYearPages,
|
||||
NEXT_YEAR_FILTER
|
||||
),
|
||||
},
|
||||
{
|
||||
id: HISTORICAL_FILTER,
|
||||
label: `${t('historical')}${totalHistorical > 0 ? ` (${totalHistorical})` : ''}`,
|
||||
content: renderTabContent(
|
||||
registrationFormsDataHistorical,
|
||||
currentSchoolHistoricalYearPage,
|
||||
totalHistoricalPages,
|
||||
HISTORICAL_FILTER
|
||||
),
|
||||
},
|
||||
]}
|
||||
onTabChange={(newTab) => setActiveTab(newTab)}
|
||||
/>
|
||||
<Popup
|
||||
isOpen={confirmPopupVisible}
|
||||
message={confirmPopupMessage}
|
||||
@ -898,7 +989,9 @@ export default function Page({ params: { locale } }) {
|
||||
isOpen={isRefusePopupOpen}
|
||||
message={
|
||||
<div>
|
||||
<div className="mb-2 font-semibold">Veuillez indiquer la raison du refus :</div>
|
||||
<div className="mb-2 font-semibold">
|
||||
Veuillez indiquer la raison du refus :
|
||||
</div>
|
||||
<Textarea
|
||||
value={refuseReason}
|
||||
onChange={(e) => setRefuseReason(e.target.value)}
|
||||
|
||||
@ -10,10 +10,10 @@ export default function Home() {
|
||||
const t = useTranslations('homePage');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen py-2">
|
||||
<Logo className="mb-4" /> {/* Ajout du logo */}
|
||||
<h1 className="text-4xl font-bold mb-4">{t('welcomeParents')}</h1>
|
||||
<p className="text-lg mb-8">{t('pleaseLogin')}</p>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen py-2 bg-neutral">
|
||||
<Logo className="mb-4" />
|
||||
<h1 className="font-headline text-4xl font-bold mb-4">{t('welcomeParents')}</h1>
|
||||
<p className="font-body text-lg mb-8">{t('pleaseLogin')}</p>
|
||||
<Button text={t('loginButton')} primary href="/users/login" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,17 +3,16 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { MessageSquare, Settings, Home, Menu } from 'lucide-react';
|
||||
import {
|
||||
FE_PARENTS_HOME_URL,
|
||||
FE_PARENTS_MESSAGERIE_URL
|
||||
} from '@/utils/Url';
|
||||
import { MessageSquare, Settings, Home } from 'lucide-react';
|
||||
import { FE_PARENTS_HOME_URL, FE_PARENTS_MESSAGERIE_URL } from '@/utils/Url';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import Popup from '@/components/Popup';
|
||||
import MobileTopbar from '@/components/MobileTopbar';
|
||||
import { RIGHTS } from '@/utils/rights';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import Footer from '@/components/Footer';
|
||||
import { useChatConnection } from '@/context/ChatConnectionContext';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const router = useRouter();
|
||||
@ -21,6 +20,7 @@ export default function Layout({ children }) {
|
||||
const [isPopupVisible, setIsPopupVisible] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const { clearContext } = useEstablishment();
|
||||
const { totalUnreadCount, resetUnreadCount } = useChatConnection();
|
||||
const softwareName = 'N3WT School';
|
||||
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
|
||||
|
||||
@ -40,7 +40,8 @@ export default function Layout({ children }) {
|
||||
name: 'Messagerie',
|
||||
url: FE_PARENTS_MESSAGERIE_URL,
|
||||
icon: MessageSquare,
|
||||
}
|
||||
badge: totalUnreadCount,
|
||||
},
|
||||
];
|
||||
|
||||
// Déterminer la page actuelle pour la sidebar
|
||||
@ -69,21 +70,20 @@ export default function Layout({ children }) {
|
||||
useEffect(() => {
|
||||
// Fermer la sidebar quand on change de page sur mobile
|
||||
setIsSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
// Réinitialiser le compteur non lu quand on ouvre la messagerie
|
||||
if (pathname?.includes('/messagerie')) {
|
||||
resetUnreadCount();
|
||||
}
|
||||
}, [pathname, resetUnreadCount]);
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
|
||||
{/* Bouton hamburger pour mobile */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
{/* Topbar mobile (hamburger + logo) */}
|
||||
<MobileTopbar onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||
}`}
|
||||
>
|
||||
@ -104,7 +104,7 @@ export default function Layout({ children }) {
|
||||
|
||||
{/* Main container */}
|
||||
<div
|
||||
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
|
||||
className={`absolute overflow-auto bg-gradient-to-br from-primary/5 via-sky-50 to-primary/10 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Table from '@/components/Table';
|
||||
import {
|
||||
Edit3,
|
||||
Users,
|
||||
@ -9,6 +8,12 @@ import {
|
||||
Eye,
|
||||
Upload,
|
||||
CalendarDays,
|
||||
Award,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
BookOpen,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import StatusLabel from '@/components/StatusLabel';
|
||||
import FileUpload from '@/components/Form/FileUpload';
|
||||
@ -16,10 +21,16 @@ import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
|
||||
import {
|
||||
fetchChildren,
|
||||
editRegisterForm,
|
||||
fetchStudentCompetencies,
|
||||
fetchAbsences,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import {
|
||||
fetchEvaluations,
|
||||
fetchStudentEvaluations,
|
||||
} from '@/app/actions/schoolAction';
|
||||
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
|
||||
import logger from '@/utils/logger';
|
||||
import { BASE_URL } from '@/utils/Url';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
@ -27,13 +38,47 @@ import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import ParentPlanningSection from '@/components/ParentPlanningSection';
|
||||
import EventCard from '@/components/EventCard';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// Fonction utilitaire pour générer la chaîne de période
|
||||
function getPeriodString(selectedPeriod, frequency) {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||
const nextYear = (year + 1).toString();
|
||||
const schoolYear = `${year}-${nextYear}`;
|
||||
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 3) return `A_${schoolYear}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
// Fonction pour obtenir les périodes selon la fréquence d'évaluation
|
||||
function getPeriods(frequency) {
|
||||
if (frequency === 1) {
|
||||
return [
|
||||
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
|
||||
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
|
||||
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (frequency === 2) {
|
||||
return [
|
||||
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
|
||||
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (frequency === 3) {
|
||||
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function ParentHomePage() {
|
||||
const [children, setChildren] = useState([]);
|
||||
const { user, selectedEstablishmentId } = useEstablishment();
|
||||
const [uploadingStudentId, setUploadingStudentId] = useState(null); // ID de l'étudiant pour l'upload
|
||||
const [uploadedFile, setUploadedFile] = useState(null); // Fichier uploadé
|
||||
const [uploadState, setUploadState] = useState('off'); // État "on" ou "off" pour l'affichage du composant
|
||||
const { user, selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment();
|
||||
const [uploadingStudentId, setUploadingStudentId] = useState(null);
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [uploadState, setUploadState] = useState('off');
|
||||
const [showPlanning, setShowPlanning] = useState(false);
|
||||
const [planningClassName, setPlanningClassName] = useState(null);
|
||||
const [upcomingEvents, setUpcomingEvents] = useState([]);
|
||||
@ -42,16 +87,114 @@ export default function ParentHomePage() {
|
||||
const [reloadFetch, setReloadFetch] = useState(false);
|
||||
const { getNiveauLabel } = useClasses();
|
||||
|
||||
// États pour la vue détaillée de l'élève inscrit
|
||||
const [expandedStudentId, setExpandedStudentId] = useState(null);
|
||||
const [studentCompetencies, setStudentCompetencies] = useState(null);
|
||||
const [grades, setGrades] = useState({});
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||
const [evaluations, setEvaluations] = useState([]);
|
||||
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
|
||||
const [allAbsences, setAllAbsences] = useState([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// Périodes disponibles selon la fréquence d'évaluation
|
||||
const periods = useMemo(
|
||||
() => getPeriods(selectedEstablishmentEvaluationFrequency),
|
||||
[selectedEstablishmentEvaluationFrequency]
|
||||
);
|
||||
|
||||
// Auto-sélection de la période courante
|
||||
useEffect(() => {
|
||||
if (periods.length > 0 && !selectedPeriod) {
|
||||
const today = dayjs();
|
||||
const current = periods.find((p) => {
|
||||
const start = dayjs(`${today.year()}-${p.start}`);
|
||||
const end = dayjs(`${today.year()}-${p.end}`);
|
||||
return today.isAfter(start.subtract(1, 'day')) && today.isBefore(end.add(1, 'day'));
|
||||
});
|
||||
setSelectedPeriod(current ? current.value : periods[0]?.value);
|
||||
}
|
||||
}, [periods, selectedPeriod]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user !== null) {
|
||||
const userIdFromSession = user.user_id;
|
||||
fetchChildren(userIdFromSession, selectedEstablishmentId).then((data) => {
|
||||
setChildren(data);
|
||||
// Auto-expand si un seul enfant inscrit
|
||||
const enrolledChildren = (data || []).filter((c) => c.status === 5);
|
||||
if (enrolledChildren.length === 1) {
|
||||
setExpandedStudentId(enrolledChildren[0].student.id);
|
||||
}
|
||||
});
|
||||
setReloadFetch(false);
|
||||
}
|
||||
}, [selectedEstablishmentId, reloadFetch, user]);
|
||||
|
||||
// Charger les absences
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
fetchAbsences(selectedEstablishmentId)
|
||||
.then((data) => setAllAbsences(data || []))
|
||||
.catch((error) => logger.error('Erreur fetch absences:', error));
|
||||
}
|
||||
}, [selectedEstablishmentId]);
|
||||
|
||||
// Charger les données détaillées quand un élève est étendu
|
||||
useEffect(() => {
|
||||
if (!expandedStudentId || !selectedPeriod || !selectedEstablishmentEvaluationFrequency) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedChild = children.find((c) => c.student.id === expandedStudentId);
|
||||
if (!expandedChild || expandedChild.status !== 5) return;
|
||||
|
||||
const loadDetails = async () => {
|
||||
setDetailLoading(true);
|
||||
const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency);
|
||||
|
||||
try {
|
||||
// Charger les compétences
|
||||
const competenciesData = await fetchStudentCompetencies(expandedStudentId, periodString);
|
||||
setStudentCompetencies(competenciesData);
|
||||
if (competenciesData?.data) {
|
||||
const initialGrades = {};
|
||||
competenciesData.data.forEach((domaine) => {
|
||||
domaine.categories.forEach((cat) => {
|
||||
cat.competences.forEach((comp) => {
|
||||
initialGrades[comp.competence_id] = comp.score ?? 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
setGrades(initialGrades);
|
||||
}
|
||||
|
||||
// Charger les évaluations si l'élève a une classe
|
||||
if (expandedChild.student.associated_class_id) {
|
||||
const [evalData, studentEvalData] = await Promise.all([
|
||||
fetchEvaluations(
|
||||
selectedEstablishmentId,
|
||||
expandedChild.student.associated_class_id,
|
||||
periodString
|
||||
),
|
||||
fetchStudentEvaluations(expandedStudentId, null, periodString, null)
|
||||
]);
|
||||
setEvaluations(evalData || []);
|
||||
setStudentEvaluationsData(studentEvalData || []);
|
||||
} else {
|
||||
setEvaluations([]);
|
||||
setStudentEvaluationsData([]);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors du chargement des détails:', error);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDetails();
|
||||
}, [expandedStudentId, selectedPeriod, selectedEstablishmentEvaluationFrequency, children, selectedEstablishmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
// Fetch des événements à venir
|
||||
@ -132,153 +275,6 @@ export default function ParentHomePage() {
|
||||
setShowPlanning(true);
|
||||
};
|
||||
|
||||
const childrenColumns = [
|
||||
{
|
||||
name: 'photo',
|
||||
transform: (row) => (
|
||||
<div className="flex justify-center items-center">
|
||||
{row.student.photo ? (
|
||||
<a
|
||||
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={`${BASE_URL}${row.student.photo}`}
|
||||
alt={`${row.student.first_name} ${row.student.last_name}`}
|
||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
|
||||
<span className="text-gray-500 text-sm font-semibold">
|
||||
{row.student.first_name[0]}
|
||||
{row.student.last_name[0]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ name: 'Nom', transform: (row) => `${row.student.last_name}` },
|
||||
{ name: 'Prénom', transform: (row) => `${row.student.first_name}` },
|
||||
{
|
||||
name: 'Classe',
|
||||
transform: (row) => (
|
||||
<div className="text-center">{row.student.associated_class_name}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Niveau',
|
||||
transform: (row) => (
|
||||
<div className="text-center">{getNiveauLabel(row.student.level)}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Statut',
|
||||
transform: (row) => (
|
||||
<div className="flex justify-center items-center">
|
||||
<StatusLabel status={row.status} showDropdown={false} parent />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Actions',
|
||||
transform: (row) => (
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
{row.status === 2 && (
|
||||
<button
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(row.student.id);
|
||||
}}
|
||||
aria-label="Remplir le dossier"
|
||||
>
|
||||
<Edit3 className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(row.status === 3 || row.status === 8) && (
|
||||
<button
|
||||
className="text-purple-500 hover:text-purple-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(row.student.id);
|
||||
}}
|
||||
aria-label="Visualiser le dossier"
|
||||
>
|
||||
<Eye className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{row.status === 7 && (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full text-purple-500 hover:text-purple-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(row.student.id);
|
||||
}}
|
||||
aria-label="Visualiser le dossier"
|
||||
>
|
||||
<Eye className="h-5 w-5" />
|
||||
</button>
|
||||
<a
|
||||
href={`${BASE_URL}${row.sepa_file}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700"
|
||||
aria-label="Télécharger le mandat SEPA"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</a>
|
||||
<button
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
uploadingStudentId === row.student.id && uploadState === 'on'
|
||||
? 'bg-blue-100 text-blue-600 ring-3 ring-blue-500'
|
||||
: 'text-blue-500 hover:text-blue-700'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleUpload(row.student.id);
|
||||
}}
|
||||
aria-label="Uploader un fichier"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{row.status === 5 && (
|
||||
<>
|
||||
<button
|
||||
className="text-purple-500 hover:text-purple-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(row.student.id);
|
||||
}}
|
||||
aria-label="Visualiser le dossier"
|
||||
>
|
||||
<Eye className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
className="text-emerald-500 hover:text-emerald-700 ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showClassPlanning(row.student);
|
||||
}}
|
||||
aria-label="Voir le planning de la classe"
|
||||
>
|
||||
<CalendarDays className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
{showPlanning && planningClassName ? (
|
||||
@ -286,10 +282,10 @@ export default function ParentHomePage() {
|
||||
<>
|
||||
<div className="p-4 flex items-center border-b">
|
||||
<button
|
||||
className="text-emerald-600 hover:text-emerald-800 font-semibold flex items-center"
|
||||
className="text-primary hover:text-secondary font-label font-medium min-h-[44px] flex items-center transition-colors"
|
||||
onClick={() => setShowPlanning(false)}
|
||||
>
|
||||
← Retour
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Retour
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
@ -313,7 +309,7 @@ export default function ParentHomePage() {
|
||||
title="Événements à venir"
|
||||
description="Prochains événements de l'établissement"
|
||||
/>
|
||||
<div className="bg-stone-50 p-4 rounded-lg shadow-sm border border-gray-100">
|
||||
<div className="bg-neutral p-4 rounded-md shadow-sm border border-gray-100">
|
||||
{upcomingEvents.slice(0, 3).map((event, index) => (
|
||||
<EventCard key={index} {...event} />
|
||||
))}
|
||||
@ -326,20 +322,171 @@ export default function ParentHomePage() {
|
||||
title="Vos enfants"
|
||||
description="Suivez le parcours de vos enfants"
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<Table data={children} columns={childrenColumns} />
|
||||
|
||||
{/* Cartes des enfants */}
|
||||
<div className="space-y-4">
|
||||
{children.map((child) => {
|
||||
const student = child.student;
|
||||
const isEnrolled = child.status === 5;
|
||||
const isExpanded = expandedStudentId === student.id;
|
||||
|
||||
// Absences pour cet élève (détaillées par type)
|
||||
const studentAbsencesList = allAbsences.filter((a) => a.student === student.id);
|
||||
const absenceStats = {
|
||||
justifiedAbsence: studentAbsencesList.filter((a) => a.reason === 1).length,
|
||||
unjustifiedAbsence: studentAbsencesList.filter((a) => a.reason === 2).length,
|
||||
justifiedLate: studentAbsencesList.filter((a) => a.reason === 3).length,
|
||||
unjustifiedLate: studentAbsencesList.filter((a) => a.reason === 4).length,
|
||||
};
|
||||
const totalAbsences = absenceStats.justifiedAbsence + absenceStats.unjustifiedAbsence;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={student.id}
|
||||
className="bg-white rounded-md shadow-sm border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{/* En-tête de la carte (toujours visible) */}
|
||||
<div
|
||||
className={`p-4 flex flex-col sm:flex-row items-start sm:items-center gap-4 ${
|
||||
isEnrolled ? 'cursor-pointer hover:bg-gray-50' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isEnrolled) {
|
||||
setExpandedStudentId(isExpanded ? null : student.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Photo */}
|
||||
<div className="flex-shrink-0">
|
||||
{student.photo ? (
|
||||
<img
|
||||
src={getSecureFileUrl(student.photo)}
|
||||
alt={`${student.first_name} ${student.last_name}`}
|
||||
className="w-16 h-16 object-cover rounded-full border-2 border-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-xl">
|
||||
{student.first_name?.[0]}{student.last_name?.[0]}
|
||||
</div>
|
||||
{/* Composant FileUpload et bouton Valider en dessous du tableau */}
|
||||
{uploadState === 'on' && uploadingStudentId && (
|
||||
<div className="mt-4">
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Infos principales */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-headline text-lg font-semibold text-gray-800">
|
||||
{student.last_name} {student.first_name}
|
||||
</h3>
|
||||
<div className="mt-1">
|
||||
<StatusLabel status={child.status} showDropdown={false} parent />
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-2">
|
||||
{student.associated_class_name && (
|
||||
<span>Classe : <span className="font-medium">{student.associated_class_name}</span></span>
|
||||
)}
|
||||
{student.level !== undefined && (
|
||||
<span className="ml-3">Niveau : <span className="font-medium">{getNiveauLabel(student.level)}</span></span>
|
||||
)}
|
||||
</div>
|
||||
{isEnrolled && (
|
||||
<div className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
||||
<Award className="w-3 h-3" />
|
||||
<span>Cliquez pour voir le suivi pédagogique</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{child.status === 2 && (
|
||||
<button
|
||||
className="p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-blue-500 hover:text-blue-700 hover:bg-blue-50 rounded-full transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(student.id);
|
||||
}}
|
||||
title="Remplir le dossier"
|
||||
>
|
||||
<Edit3 className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(child.status === 3 || child.status === 8 || child.status === 5 || child.status === 7) && (
|
||||
<button
|
||||
className="p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-purple-500 hover:text-purple-700 hover:bg-purple-50 rounded-full transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(student.id);
|
||||
}}
|
||||
title="Visualiser le dossier"
|
||||
>
|
||||
<Eye className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{child.status === 7 && (
|
||||
<>
|
||||
<a
|
||||
href={getSecureFileUrl(child.sepa_file)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-primary hover:text-secondary hover:bg-primary/5 rounded-full transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Télécharger le mandat SEPA"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</a>
|
||||
<button
|
||||
className={`p-2 min-h-[44px] min-w-[44px] flex items-center justify-center rounded-full transition-colors ${
|
||||
uploadingStudentId === student.id && uploadState === 'on'
|
||||
? 'bg-blue-100 text-blue-600'
|
||||
: 'text-blue-500 hover:text-blue-700 hover:bg-blue-50'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleUpload(student.id);
|
||||
}}
|
||||
title="Uploader un fichier"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEnrolled && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-primary hover:text-secondary hover:bg-tertiary/10 rounded-full transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showClassPlanning(student);
|
||||
}}
|
||||
title="Voir le planning de la classe"
|
||||
>
|
||||
<CalendarDays className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="ml-2">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload SEPA si activé */}
|
||||
{uploadState === 'on' && uploadingStudentId === student.id && (
|
||||
<div className="p-4 border-t bg-gray-50">
|
||||
<FileUpload
|
||||
selectionMessage="Sélectionnez un fichier à uploader"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
<button
|
||||
className={`mt-4 px-6 py-2 rounded-md ${
|
||||
className={`mt-4 px-4 py-2 rounded font-label font-medium min-h-[44px] transition-colors ${
|
||||
uploadedFile
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
? 'bg-primary text-white hover:bg-secondary'
|
||||
: 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||
}`}
|
||||
onClick={handleSubmit}
|
||||
@ -349,6 +496,201 @@ export default function ParentHomePage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section détaillée pour les élèves inscrits (expanded) */}
|
||||
{isEnrolled && isExpanded && (
|
||||
<div className="border-t bg-neutral p-4 space-y-6">
|
||||
|
||||
{/* Bloc période : compétences + notes */}
|
||||
<div className="bg-white rounded-md border border-primary/20 p-4 space-y-4">
|
||||
{/* Sélecteur de période */}
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-gray-100">
|
||||
<div className="w-full sm:w-48">
|
||||
<SelectChoice
|
||||
name="period"
|
||||
label="Période"
|
||||
placeHolder="Choisir la période"
|
||||
choices={periods.map((period) => ({
|
||||
value: period.value,
|
||||
label: period.label,
|
||||
}))}
|
||||
selected={selectedPeriod || ''}
|
||||
callback={(e) => setSelectedPeriod(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Chargement des données...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Résumé des compétences (pourcentages) */}
|
||||
{(() => {
|
||||
const total = Object.keys(grades).length;
|
||||
const acquired = Object.values(grades).filter((g) => g === 3).length;
|
||||
const inProgress = Object.values(grades).filter((g) => g === 2).length;
|
||||
const notAcquired = Object.values(grades).filter((g) => g === 1).length;
|
||||
const notEvaluated = Object.values(grades).filter((g) => g === 0).length;
|
||||
|
||||
const pctAcquired = total ? Math.round((acquired / total) * 100) : 0;
|
||||
const pctInProgress = total ? Math.round((inProgress / total) * 100) : 0;
|
||||
const pctNotAcquired = total ? Math.round((notAcquired / total) * 100) : 0;
|
||||
const pctNotEvaluated = total ? Math.round((notEvaluated / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="border border-gray-100 rounded-md p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Award className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-headline text-lg font-semibold text-gray-800">
|
||||
Compétences
|
||||
</h3>
|
||||
{total > 0 && (
|
||||
<span className="text-sm text-gray-500">({total} compétences)</span>
|
||||
)}
|
||||
</div>
|
||||
{total > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="flex flex-col items-center p-3 bg-primary/5 rounded-md">
|
||||
<span className="text-2xl font-bold text-primary">{pctAcquired}%</span>
|
||||
<span className="text-sm text-secondary">Acquises</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-3 bg-yellow-50 rounded-md">
|
||||
<span className="text-2xl font-bold text-yellow-600">{pctInProgress}%</span>
|
||||
<span className="text-sm text-yellow-700">En cours</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-3 bg-red-50 rounded-md">
|
||||
<span className="text-2xl font-bold text-red-500">{pctNotAcquired}%</span>
|
||||
<span className="text-sm text-red-600">Non acquises</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-3 bg-gray-100 rounded-md">
|
||||
<span className="text-2xl font-bold text-gray-500">{pctNotEvaluated}%</span>
|
||||
<span className="text-sm text-gray-600">Non évaluées</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Aucune compétence évaluée pour cette période.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Notes par matière - Vue simplifiée */}
|
||||
<div className="border border-gray-100 rounded-md p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Award className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-headline text-lg font-semibold text-gray-800">
|
||||
Notes par matière
|
||||
</h3>
|
||||
</div>
|
||||
{evaluations.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{(() => {
|
||||
// Grouper par matière
|
||||
const bySpeciality = evaluations.reduce((acc, ev) => {
|
||||
const key = ev.speciality_name || 'Sans matière';
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
name: key,
|
||||
color: ev.speciality_color || '#6B7280',
|
||||
evaluations: [],
|
||||
totalWeighted: 0,
|
||||
totalCoef: 0,
|
||||
};
|
||||
}
|
||||
const studentEval = studentEvaluationsData.find((se) => se.evaluation === ev.id);
|
||||
acc[key].evaluations.push({ ...ev, studentScore: studentEval?.score, isAbsent: studentEval?.is_absent });
|
||||
if (studentEval?.score != null && !studentEval?.is_absent) {
|
||||
const normalized = (studentEval.score / ev.max_score) * 20;
|
||||
acc[key].totalWeighted += normalized * ev.coefficient;
|
||||
acc[key].totalCoef += parseFloat(ev.coefficient);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.values(bySpeciality).map((group) => {
|
||||
const avg = group.totalCoef > 0 ? (group.totalWeighted / group.totalCoef) : null;
|
||||
const evalCount = group.evaluations.length;
|
||||
const gradedCount = group.evaluations.filter((e) => e.studentScore != null).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.name}
|
||||
className="rounded-md p-4 border"
|
||||
style={{
|
||||
backgroundColor: `${group.color}10`,
|
||||
borderColor: `${group.color}40`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<span className="font-medium text-gray-800 truncate">{group.name}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: group.color }}
|
||||
>
|
||||
{avg !== null ? avg.toFixed(1) : '-'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">/20</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{gradedCount}/{evalCount} évaluation{evalCount > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm text-center py-4">Aucune évaluation pour cette période.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Fin bloc période */}
|
||||
|
||||
{/* Section Absences — toute l'année scolaire */}
|
||||
<div className="bg-white rounded-md border border-gray-200 p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-headline text-lg font-semibold text-gray-800">
|
||||
Absences & Retards
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-4">Toute l'année scolaire</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="flex flex-col items-center p-3 bg-primary/5 rounded-md">
|
||||
<span className="text-2xl font-bold text-primary">{absenceStats.justifiedAbsence}</span>
|
||||
<span className="text-sm text-secondary text-center">Absences justifiées</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-3 bg-red-50 rounded-md">
|
||||
<span className="text-2xl font-bold text-red-500">{absenceStats.unjustifiedAbsence}</span>
|
||||
<span className="text-sm text-red-600 text-center">Absences non justifiées</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-3 bg-blue-50 rounded-md">
|
||||
<span className="text-2xl font-bold text-blue-600">{absenceStats.justifiedLate}</span>
|
||||
<span className="text-sm text-blue-700 text-center">Retards justifiés</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-3 bg-orange-50 rounded-md">
|
||||
<span className="text-2xl font-bold text-orange-500">{absenceStats.unjustifiedLate}</span>
|
||||
<span className="text-sm text-orange-600 text-center">Retards non justifiés</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -39,9 +39,12 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl mb-4">Paramètres du compte</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
<h1 className="font-headline text-2xl font-bold text-gray-900 mb-6">
|
||||
Paramètres du compte
|
||||
</h1>
|
||||
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-6 max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<InputText
|
||||
type="email"
|
||||
id="email"
|
||||
@ -66,10 +69,11 @@ export default function SettingsPage() {
|
||||
onChange={handleConfirmPasswordChange}
|
||||
required
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="submit" primary text={' Mettre à jour'} />
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="submit" primary text={'Mettre à jour'} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -80,16 +80,15 @@ export default function Page() {
|
||||
return <Loader />; // Affichez le composant Loader
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="container max mx-auto p-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-neutral">
|
||||
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-8 w-full max-w-md">
|
||||
<div className="flex justify-center mb-6">
|
||||
<Logo className="h-150 w-150" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-center mb-4">
|
||||
<h1 className="font-headline text-2xl font-bold text-center text-gray-900 mb-6">
|
||||
Authentification
|
||||
</h1>
|
||||
<form
|
||||
className="max-w-md mx-auto"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleFormLogin(new FormData(e.target));
|
||||
@ -112,16 +111,14 @@ export default function Page() {
|
||||
placeholder="Mot de passe"
|
||||
className="w-full mb-5"
|
||||
/>
|
||||
<div className="input-group mb-4"></div>
|
||||
<label>
|
||||
<div className="flex justify-end mb-4">
|
||||
<a
|
||||
className="float-right mb-4"
|
||||
className="text-sm text-primary hover:text-secondary font-label transition-colors"
|
||||
href={`${FE_USERS_NEW_PASSWORD_URL}`}
|
||||
>
|
||||
Mot de passe oublié ?
|
||||
</a>
|
||||
</label>
|
||||
<div className="form-group-submit mt-4">
|
||||
</div>
|
||||
<Button
|
||||
text="Se Connecter"
|
||||
className="w-full"
|
||||
@ -129,10 +126,9 @@ export default function Page() {
|
||||
type="submit"
|
||||
name="connect"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,16 +48,15 @@ export default function Page() {
|
||||
return <Loader />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="container max mx-auto p-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="min-h-screen bg-neutral flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-8 w-full max-w-md">
|
||||
<div className="flex justify-center mb-6">
|
||||
<Logo className="h-150 w-150" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-center mb-4">
|
||||
<h1 className="font-headline text-2xl font-bold text-center text-gray-900 mb-6">
|
||||
Nouveau Mot de passe
|
||||
</h1>
|
||||
<form
|
||||
className="max-w-md mx-auto"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
validate(new FormData(e.target));
|
||||
@ -70,28 +69,23 @@ export default function Page() {
|
||||
IconItem={User}
|
||||
label="Identifiant"
|
||||
placeholder="Identifiant"
|
||||
className="w-full"
|
||||
className="w-full mb-6"
|
||||
/>
|
||||
<div className="form-group-submit mt-4">
|
||||
<Button
|
||||
text="Réinitialiser"
|
||||
className="w-full"
|
||||
className="w-full mb-3"
|
||||
primary
|
||||
type="submit"
|
||||
name="validate"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<br />
|
||||
<div className="flex justify-center mt-2 max-w-md mx-auto">
|
||||
<Button
|
||||
text="Annuler"
|
||||
className="w-full"
|
||||
href={`${FE_USERS_LOGIN_URL}`}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,16 +61,15 @@ export default function Page() {
|
||||
return <Loader />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="container max mx-auto p-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="min-h-screen bg-neutral flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-8 w-full max-w-md">
|
||||
<div className="flex justify-center mb-6">
|
||||
<Logo className="h-150 w-150" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-center mb-4">
|
||||
<h1 className="font-headline text-2xl font-bold text-center text-gray-900 mb-6">
|
||||
Réinitialisation du mot de passe
|
||||
</h1>
|
||||
<form
|
||||
className="max-w-md mx-auto"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
validate(new FormData(e.target));
|
||||
@ -91,28 +90,23 @@ export default function Page() {
|
||||
IconItem={KeySquare}
|
||||
label="Confirmation mot de passe"
|
||||
placeholder="Confirmation mot de passe"
|
||||
className="w-full"
|
||||
className="w-full mb-6"
|
||||
/>
|
||||
<div className="form-group-submit mt-4">
|
||||
<Button
|
||||
text="Enregistrer"
|
||||
className="w-full"
|
||||
className="w-full mb-3"
|
||||
primary
|
||||
type="submit"
|
||||
name="validate"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<br />
|
||||
<div className="flex justify-center mt-2 max-w-md mx-auto">
|
||||
<Button
|
||||
text="Annuler"
|
||||
className="w-full"
|
||||
href={`${FE_USERS_LOGIN_URL}`}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,16 +65,15 @@ export default function Page() {
|
||||
return <Loader />;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="container max mx-auto p-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="min-h-screen bg-neutral flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-8 w-full max-w-md">
|
||||
<div className="flex justify-center mb-6">
|
||||
<Logo className="h-150 w-150" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-center mb-4">
|
||||
<h1 className="font-headline text-2xl font-bold text-center text-gray-900 mb-6">
|
||||
Nouveau profil
|
||||
</h1>
|
||||
<form
|
||||
className="max-w-md mx-auto"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
subscribeFormSubmit(new FormData(e.target));
|
||||
@ -103,30 +102,23 @@ export default function Page() {
|
||||
IconItem={KeySquare}
|
||||
label="Confirmation mot de passe"
|
||||
placeholder="Confirmation mot de passe"
|
||||
className="w-full"
|
||||
className="w-full mb-6"
|
||||
/>
|
||||
<div className="form-group-submit mt-4">
|
||||
<Button
|
||||
text="Enregistrer"
|
||||
className="w-full"
|
||||
className="w-full mb-3"
|
||||
primary
|
||||
type="submit"
|
||||
name="validate"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<br />
|
||||
<div className="flex justify-center mt-2 max-w-md mx-auto">
|
||||
<Button
|
||||
text="Annuler"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
router.push(`${FE_USERS_LOGIN_URL}`);
|
||||
}}
|
||||
onClick={() => router.push(`${FE_USERS_LOGIN_URL}`)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
|
||||
BE_GESTIONEMAIL_SEND_EMAIL_URL,
|
||||
BE_GESTIONEMAIL_SEND_FEEDBACK_URL,
|
||||
} from '@/utils/Url';
|
||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||
import { getCsrfToken } from '@/utils/getCsrfToken';
|
||||
@ -19,3 +20,13 @@ export const sendEmail = async (messageData) => {
|
||||
body: JSON.stringify(messageData),
|
||||
});
|
||||
};
|
||||
|
||||
// Envoyer un feedback au support
|
||||
export const sendFeedback = async (feedbackData) => {
|
||||
const csrfToken = getCsrfToken();
|
||||
return fetchWithAuth(BE_GESTIONEMAIL_SEND_FEEDBACK_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(feedbackData),
|
||||
});
|
||||
};
|
||||
|
||||
@ -26,6 +26,10 @@ export const fetchRegistrationSchoolFileMasters = (establishment) => {
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchRegistrationSchoolFileMasterById = (id) => {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${id}`);
|
||||
};
|
||||
|
||||
export const fetchRegistrationParentFileMasters = (establishment) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||
return fetchWithAuth(url);
|
||||
|
||||
@ -9,6 +9,9 @@ import {
|
||||
BE_SCHOOL_PAYMENT_MODES_URL,
|
||||
BE_SCHOOL_ESTABLISHMENT_URL,
|
||||
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
|
||||
BE_SCHOOL_EVALUATIONS_URL,
|
||||
BE_SCHOOL_STUDENT_EVALUATIONS_URL,
|
||||
BE_SCHOOL_SCHOOL_YEARS_URL,
|
||||
} from '@/utils/Url';
|
||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||
|
||||
@ -34,20 +37,25 @@ export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchSpecialities = (establishment) => {
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
|
||||
);
|
||||
export const fetchSpecialities = (establishment, schoolYear = null) => {
|
||||
let url = `${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`;
|
||||
if (schoolYear) url += `&school_year=${schoolYear}`;
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchTeachers = (establishment) => {
|
||||
return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`);
|
||||
};
|
||||
|
||||
export const fetchClasses = (establishment) => {
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
|
||||
);
|
||||
export const fetchClasses = (establishment, options = {}) => {
|
||||
let url = `${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`;
|
||||
if (options.schoolYear) url += `&school_year=${options.schoolYear}`;
|
||||
if (options.yearFilter) url += `&year_filter=${options.yearFilter}`;
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchSchoolYears = () => {
|
||||
return fetchWithAuth(BE_SCHOOL_SCHOOL_YEARS_URL);
|
||||
};
|
||||
|
||||
export const fetchClasse = (id) => {
|
||||
@ -132,3 +140,71 @@ export const removeDatas = (url, id, csrfToken) => {
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
};
|
||||
|
||||
// ===================== EVALUATIONS =====================
|
||||
|
||||
export const fetchEvaluations = (establishmentId, schoolClassId = null, period = null) => {
|
||||
let url = `${BE_SCHOOL_EVALUATIONS_URL}?establishment_id=${establishmentId}`;
|
||||
if (schoolClassId) url += `&school_class=${schoolClassId}`;
|
||||
if (period) url += `&period=${period}`;
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const createEvaluation = (data, csrfToken) => {
|
||||
return fetchWithAuth(BE_SCHOOL_EVALUATIONS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateEvaluation = (id, data, csrfToken) => {
|
||||
return fetchWithAuth(`${BE_SCHOOL_EVALUATIONS_URL}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteEvaluation = (id, csrfToken) => {
|
||||
return fetchWithAuth(`${BE_SCHOOL_EVALUATIONS_URL}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
};
|
||||
|
||||
// ===================== STUDENT EVALUATIONS =====================
|
||||
|
||||
export const fetchStudentEvaluations = (studentId = null, evaluationId = null, period = null, schoolClassId = null) => {
|
||||
let url = `${BE_SCHOOL_STUDENT_EVALUATIONS_URL}?`;
|
||||
const params = [];
|
||||
if (studentId) params.push(`student_id=${studentId}`);
|
||||
if (evaluationId) params.push(`evaluation_id=${evaluationId}`);
|
||||
if (period) params.push(`period=${period}`);
|
||||
if (schoolClassId) params.push(`school_class_id=${schoolClassId}`);
|
||||
url += params.join('&');
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const saveStudentEvaluations = (data, csrfToken) => {
|
||||
return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/bulk`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateStudentEvaluation = (id, data, csrfToken) => {
|
||||
return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteStudentEvaluation = (id, csrfToken) => {
|
||||
return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
};
|
||||
|
||||
@ -124,7 +124,7 @@ export const searchStudents = (establishmentId, query) => {
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchStudents = (establishment, id = null, status = null) => {
|
||||
export const fetchStudents = (establishment, id = null, status = null, schoolYear = null) => {
|
||||
let url;
|
||||
if (id) {
|
||||
url = `${BE_SUBSCRIPTION_STUDENTS_URL}/${id}`;
|
||||
@ -133,6 +133,9 @@ export const fetchStudents = (establishment, id = null, status = null) => {
|
||||
if (status) {
|
||||
url += `&status=${status}`;
|
||||
}
|
||||
if (schoolYear) {
|
||||
url += `&school_year=${encodeURIComponent(schoolYear)}`;
|
||||
}
|
||||
}
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
@ -1,12 +1,32 @@
|
||||
import React from 'react';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import localFont from 'next/font/local';
|
||||
import Providers from '@/components/Providers';
|
||||
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister';
|
||||
import '@/css/tailwind.css';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
const inter = localFont({
|
||||
src: '../fonts/Inter-Variable.woff2',
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const manrope = localFont({
|
||||
src: '../fonts/Manrope-Variable.woff2',
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: 'N3WT-SCHOOL',
|
||||
description: "Gestion de l'école",
|
||||
manifest: '/manifest.webmanifest',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: 'N3WT School',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
@ -14,10 +34,11 @@ export const metadata = {
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
{
|
||||
url: '/favicon.ico', // Fallback pour les anciens navigateurs
|
||||
url: '/favicon.ico',
|
||||
sizes: 'any',
|
||||
},
|
||||
],
|
||||
apple: '/icons/icon.svg',
|
||||
},
|
||||
};
|
||||
|
||||
@ -28,10 +49,11 @@ export default async function RootLayout({ children, params }) {
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body className="p-0 m-0">
|
||||
<body className={`p-0 m-0 font-body ${inter.variable} ${manrope.variable}`}>
|
||||
<Providers messages={messages} locale={locale} session={params.session}>
|
||||
{children}
|
||||
</Providers>
|
||||
<ServiceWorkerRegister />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
26
Front-End/src/app/manifest.js
Normal file
26
Front-End/src/app/manifest.js
Normal file
@ -0,0 +1,26 @@
|
||||
export default function manifest() {
|
||||
return {
|
||||
name: 'N3WT School',
|
||||
short_name: 'N3WT School',
|
||||
description: "Gestion de l'école",
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#f0fdf4',
|
||||
theme_color: '#10b981',
|
||||
orientation: 'portrait',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@ -3,16 +3,19 @@ import Logo from '../components/Logo';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-emerald-500">
|
||||
<div className="text-center p-6 ">
|
||||
<div className="flex items-center justify-center min-h-screen bg-primary">
|
||||
<div className="text-center p-6 bg-white rounded-md shadow-sm border border-gray-200">
|
||||
<Logo className="w-32 h-32 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-emerald-900 mb-4">
|
||||
<h2 className="font-headline text-2xl font-bold text-secondary mb-4">
|
||||
404 | Page non trouvée
|
||||
</h2>
|
||||
<p className="text-emerald-900 mb-4">
|
||||
<p className="font-body text-gray-600 mb-4">
|
||||
La ressource que vous souhaitez consulter n'existe pas ou plus.
|
||||
</p>
|
||||
<Link className="text-gray-900 hover:underline" href="/">
|
||||
<Link
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-4 py-2 rounded font-label font-medium bg-primary hover:bg-secondary text-white transition-colors"
|
||||
href="/"
|
||||
>
|
||||
Retour Accueil
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -14,7 +14,7 @@ export default function AnnouncementScheduler({ csrfToken }) {
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded shadow">
|
||||
<h2 className="text-xl font-bold mb-4">Planifier une Annonce</h2>
|
||||
<h2 className="font-headline text-xl font-bold mb-4">Planifier une Annonce</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block font-medium">Titre</label>
|
||||
<input
|
||||
|
||||
@ -39,7 +39,7 @@ const AffectationClasseForm = ({ eleve = {}, onSubmit, classes }) => {
|
||||
value={classe.id}
|
||||
checked={formData.classeAssocie_id === classe.id}
|
||||
onChange={handleChange}
|
||||
className="form-radio h-3 w-3 text-emerald-600 focus:ring-emerald-500 hover:ring-emerald-400 checked:bg-emerald-600 checked:h-3 checked:w-3"
|
||||
className="form-radio h-3 w-3 text-primary focus:ring-primary hover:ring-tertiary checked:bg-primary checked:h-3 checked:w-3"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`classe-${classe.id}`}
|
||||
@ -57,7 +57,7 @@ const AffectationClasseForm = ({ eleve = {}, onSubmit, classes }) => {
|
||||
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
|
||||
!formData.classeAssocie_id
|
||||
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
: 'bg-primary text-white hover:bg-primary'
|
||||
}`}
|
||||
disabled={!formData.classeAssocie_id}
|
||||
>
|
||||
|
||||
@ -7,7 +7,6 @@ const AlertMessage = ({
|
||||
actionLabel,
|
||||
onAction,
|
||||
}) => {
|
||||
// Définir les styles en fonction du type d'alerte
|
||||
const typeStyles = {
|
||||
info: 'bg-blue-100 border-blue-500 text-blue-700',
|
||||
warning: 'bg-yellow-100 border-yellow-500 text-yellow-700',
|
||||
@ -18,13 +17,13 @@ const AlertMessage = ({
|
||||
const alertStyle = typeStyles[type] || typeStyles.info;
|
||||
|
||||
return (
|
||||
<div className={`alert centered border-l-4 p-4 ${alertStyle}`} role="alert">
|
||||
<h3 className="font-bold">{title}</h3>
|
||||
<div className={`alert centered border-l-4 p-4 rounded ${alertStyle}`} role="alert">
|
||||
<h3 className="font-headline font-bold">{title}</h3>
|
||||
<p className="mt-2">{message}</p>
|
||||
{actionLabel && onAction && (
|
||||
<div className="alert-actions mt-4">
|
||||
<button
|
||||
className="btn primary bg-emerald-500 text-white rounded-md px-4 py-2 hover:bg-emerald-600"
|
||||
className="bg-primary text-white font-label font-medium rounded px-4 py-2 hover:bg-secondary transition-colors min-h-[44px]"
|
||||
onClick={onAction}
|
||||
>
|
||||
{actionLabel}
|
||||
|
||||
@ -14,11 +14,11 @@ const AlertWithModal = ({ title, message, buttonText }) => {
|
||||
className="alert centered bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4"
|
||||
role="alert"
|
||||
>
|
||||
<h3 className="font-bold">{title}</h3>
|
||||
<h3 className="font-headline font-bold">{title}</h3>
|
||||
<p className="mt-2">{message}</p>
|
||||
<div className="alert-actions mt-4">
|
||||
<button
|
||||
className="btn primary bg-emerald-500 text-white rounded-md px-4 py-2 hover:bg-emerald-600 flex items-center"
|
||||
className="btn primary bg-primary text-white rounded-md px-4 py-2 hover:bg-primary flex items-center"
|
||||
onClick={openModal}
|
||||
>
|
||||
{buttonText} <UserPlus size={20} className="ml-2" />
|
||||
|
||||
@ -4,7 +4,7 @@ const AlphabetPaginationNumber = ({ letter, active, onClick }) => (
|
||||
<button
|
||||
className={`w-8 h-8 flex items-center justify-center rounded ${
|
||||
active
|
||||
? 'bg-emerald-500 text-white'
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 bg-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
|
||||
@ -4,6 +4,7 @@ import WeekView from '@/components/Calendar/WeekView';
|
||||
import MonthView from '@/components/Calendar/MonthView';
|
||||
import YearView from '@/components/Calendar/YearView';
|
||||
import PlanningView from '@/components/Calendar/PlanningView';
|
||||
import DayView from '@/components/Calendar/DayView';
|
||||
import ToggleView from '@/components/ToggleView';
|
||||
import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
@ -11,9 +12,11 @@ import {
|
||||
addWeeks,
|
||||
addMonths,
|
||||
addYears,
|
||||
addDays,
|
||||
subWeeks,
|
||||
subMonths,
|
||||
subYears,
|
||||
subDays,
|
||||
getWeek,
|
||||
setMonth,
|
||||
setYear,
|
||||
@ -22,7 +25,7 @@ import { fr } from 'date-fns/locale';
|
||||
import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' }) => {
|
||||
const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName = '', onOpenDrawer = () => {} }) => {
|
||||
const {
|
||||
currentDate,
|
||||
setCurrentDate,
|
||||
@ -35,6 +38,14 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
} = usePlanning();
|
||||
const [visibleEvents, setVisibleEvents] = useState([]);
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768);
|
||||
check();
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
// Ajouter ces fonctions pour la gestion des mois et années
|
||||
const months = Array.from({ length: 12 }, (_, i) => ({
|
||||
@ -68,7 +79,12 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
|
||||
const navigateDate = (direction) => {
|
||||
const getNewDate = () => {
|
||||
switch (viewType) {
|
||||
const effectiveView = isMobile ? 'day' : viewType;
|
||||
switch (effectiveView) {
|
||||
case 'day':
|
||||
return direction === 'next'
|
||||
? addDays(currentDate, 1)
|
||||
: subDays(currentDate, 1);
|
||||
case 'week':
|
||||
return direction === 'next'
|
||||
? addWeeks(currentDate, 1)
|
||||
@ -91,8 +107,9 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
|
||||
{/* Navigation à gauche */}
|
||||
{/* Header uniquement sur desktop */}
|
||||
<div className="hidden md:flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
|
||||
<>
|
||||
{planningMode === PlanningModes.PLANNING && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
@ -101,10 +118,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
>
|
||||
Aujourd'hui
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateDate('prev')}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<button onClick={() => navigateDate('prev')} className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
@ -112,12 +126,8 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{format(
|
||||
currentDate,
|
||||
viewType === 'year' ? 'yyyy' : 'MMMM yyyy',
|
||||
{ locale: fr }
|
||||
)}
|
||||
<h2 className="font-headline text-xl font-semibold">
|
||||
{format(currentDate, viewType === 'year' ? 'yyyy' : 'MMMM yyyy', { locale: fr })}
|
||||
</h2>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
@ -127,11 +137,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
<div className="p-2 border-b">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{months.map((month) => (
|
||||
<button
|
||||
key={month.value}
|
||||
onClick={() => handleMonthSelect(month.value)}
|
||||
className="p-2 text-sm hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
<button key={month.value} onClick={() => handleMonthSelect(month.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
|
||||
{month.label}
|
||||
</button>
|
||||
))}
|
||||
@ -141,11 +147,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
<div className="p-2">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map((year) => (
|
||||
<button
|
||||
key={year.value}
|
||||
onClick={() => handleYearSelect(year.value)}
|
||||
className="p-2 text-sm hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
<button key={year.value} onClick={() => handleYearSelect(year.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
|
||||
{year.label}
|
||||
</button>
|
||||
))}
|
||||
@ -154,16 +156,12 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigateDate('next')}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<button onClick={() => navigateDate('next')} className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Centre : numéro de semaine ou classe/niveau */}
|
||||
<div className="flex-1 flex justify-center">
|
||||
{((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && (
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-600">
|
||||
@ -175,13 +173,11 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
)}
|
||||
{parentView && (
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold">
|
||||
{/* À adapter selon les props disponibles */}
|
||||
{planningClassName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contrôles à droite */}
|
||||
<div className="flex items-center gap-4">
|
||||
{planningMode === PlanningModes.PLANNING && (
|
||||
<ToggleView viewType={viewType} setViewType={setViewType} />
|
||||
@ -189,18 +185,36 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
{(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && (
|
||||
<button
|
||||
onClick={onDateClick}
|
||||
className="w-10 h-10 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
|
||||
className="w-10 h-10 flex items-center justify-center bg-primary text-white rounded-full hover:bg-secondary shadow-md transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* Contenu scrollable */}
|
||||
<div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{viewType === 'week' && (
|
||||
{isMobile && (
|
||||
<motion.div
|
||||
key="day"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<DayView
|
||||
onDateClick={onDateClick}
|
||||
onEventClick={onEventClick}
|
||||
events={visibleEvents}
|
||||
onOpenDrawer={onOpenDrawer}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{!isMobile && viewType === 'week' && (
|
||||
<motion.div
|
||||
key="week"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -216,7 +230,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{viewType === 'month' && (
|
||||
{!isMobile && viewType === 'month' && (
|
||||
<motion.div
|
||||
key="month"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -231,7 +245,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{viewType === 'year' && (
|
||||
{!isMobile && viewType === 'year' && (
|
||||
<motion.div
|
||||
key="year"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -242,7 +256,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
<YearView onDateClick={onDateClick} events={visibleEvents} />
|
||||
</motion.div>
|
||||
)}
|
||||
{viewType === 'planning' && (
|
||||
{!isMobile && viewType === 'planning' && (
|
||||
<motion.div
|
||||
key="planning"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
271
Front-End/src/components/Calendar/DayView.js
Normal file
271
Front-End/src/components/Calendar/DayView.js
Normal file
@ -0,0 +1,271 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { usePlanning } from '@/context/PlanningContext';
|
||||
import {
|
||||
format,
|
||||
startOfWeek,
|
||||
addDays,
|
||||
subDays,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { getWeekEvents } from '@/utils/events';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
||||
|
||||
const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
|
||||
const { currentDate, setCurrentDate, parentView, schedules } = usePlanning();
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
const timeSlots = Array.from({ length: 24 }, (_, i) => i);
|
||||
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||
const isCurrentDay = isSameDay(currentDate, new Date());
|
||||
const dayEvents = getWeekEvents(currentDate, events) || [];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setCurrentTime(new Date()), 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && isCurrentDay) {
|
||||
const currentHour = new Date().getHours();
|
||||
setTimeout(() => {
|
||||
scrollRef.current.scrollTop = currentHour * 80 - 200;
|
||||
}, 0);
|
||||
}
|
||||
}, [currentDate, isCurrentDay]);
|
||||
|
||||
const getCurrentTimePosition = () => {
|
||||
const hours = currentTime.getHours();
|
||||
const minutes = currentTime.getMinutes();
|
||||
return `${(hours + minutes / 60) * 5}rem`;
|
||||
};
|
||||
|
||||
const getScheduleColor = (event) => {
|
||||
const schedule = schedules?.find(
|
||||
(item) => Number(item.id) === Number(event.planning)
|
||||
);
|
||||
return schedule?.color || event.color || '#6B7280';
|
||||
};
|
||||
|
||||
const getScheduleClassLevelLabel = (event) => {
|
||||
const schedule = schedules?.find(
|
||||
(item) => Number(item.id) === Number(event.planning)
|
||||
);
|
||||
const scheduleName = schedule?.name || '';
|
||||
if (!scheduleName) return '';
|
||||
return scheduleName;
|
||||
};
|
||||
|
||||
const calculateEventStyle = (event, allDayEvents) => {
|
||||
const start = new Date(event.start);
|
||||
const end = new Date(event.end);
|
||||
const startMinutes = (start.getMinutes() / 60) * 5;
|
||||
const duration = ((end - start) / (1000 * 60 * 60)) * 5;
|
||||
const scheduleColor = getScheduleColor(event);
|
||||
|
||||
const overlapping = allDayEvents.filter((other) => {
|
||||
if (other.id === event.id) return false;
|
||||
const oStart = new Date(other.start);
|
||||
const oEnd = new Date(other.end);
|
||||
return !(oEnd <= start || oStart >= end);
|
||||
});
|
||||
|
||||
const eventIndex = overlapping.findIndex((e) => e.id > event.id) + 1;
|
||||
const total = overlapping.length + 1;
|
||||
|
||||
return {
|
||||
height: `${Math.max(duration, 1.5)}rem`,
|
||||
position: 'absolute',
|
||||
width: `calc((100% / ${total}) - 4px)`,
|
||||
left: `calc((100% / ${total}) * ${eventIndex})`,
|
||||
backgroundColor: `${event.color}15`,
|
||||
borderLeft: `3px solid ${event.color}`,
|
||||
borderRadius: '0.25rem',
|
||||
zIndex: 1,
|
||||
transform: `translateY(${startMinutes}rem)`,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Barre de navigation (remplace le header Calendar sur mobile) */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-white border-b shrink-0">
|
||||
<button
|
||||
onClick={onOpenDrawer}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
aria-label="Ouvrir les plannings"
|
||||
>
|
||||
<CalendarDays className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentDate(subDays(currentDate, 1))}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<label className="relative cursor-pointer">
|
||||
<span className="px-2 py-1 text-sm font-semibold text-gray-800 hover:bg-gray-100 rounded-md capitalize">
|
||||
{format(currentDate, 'EEE d MMM', { locale: fr })}
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
|
||||
value={format(currentDate, 'yyyy-MM-dd')}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setCurrentDate(new Date(e.target.value + 'T12:00:00'));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setCurrentDate(addDays(currentDate, 1))}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onDateClick?.(currentDate)}
|
||||
className="w-9 h-9 flex items-center justify-center bg-primary text-white rounded-full hover:bg-secondary shadow-md transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bandeau jours de la semaine */}
|
||||
<div className="flex gap-1 px-2 py-2 bg-white border-b overflow-x-auto shrink-0">
|
||||
{weekDays.map((day) => (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => setCurrentDate(day)}
|
||||
className={`flex flex-col items-center min-w-[2.75rem] px-1 py-1.5 rounded-xl transition-colors ${
|
||||
isSameDay(day, currentDate)
|
||||
? 'bg-primary text-white'
|
||||
: isToday(day)
|
||||
? 'border border-tertiary text-primary'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase">
|
||||
{format(day, 'EEE', { locale: fr })}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{format(day, 'd')}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grille horaire */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto relative">
|
||||
{isCurrentDay && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-10 border-primary border pointer-events-none"
|
||||
style={{ top: getCurrentTimePosition() }}
|
||||
>
|
||||
<div className="absolute -left-2 -top-2 w-2 h-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid w-full bg-gray-100 gap-[1px]"
|
||||
style={{ gridTemplateColumns: '2.5rem 1fr' }}
|
||||
>
|
||||
{timeSlots.map((hour) => (
|
||||
<React.Fragment key={hour}>
|
||||
<div className="h-20 p-1 text-right text-sm text-gray-500 bg-gray-100 font-medium">
|
||||
{`${hour.toString().padStart(2, '0')}:00`}
|
||||
</div>
|
||||
<div
|
||||
className={`h-20 relative ${
|
||||
isCurrentDay ? 'bg-primary/5/30' : 'bg-white'
|
||||
}`}
|
||||
onClick={
|
||||
parentView
|
||||
? undefined
|
||||
: () => {
|
||||
const date = new Date(currentDate);
|
||||
date.setHours(hour);
|
||||
date.setMinutes(0);
|
||||
onDateClick(date);
|
||||
}
|
||||
}
|
||||
>
|
||||
{dayEvents
|
||||
.filter((e) => new Date(e.start).getHours() === hour)
|
||||
.map((event) => {
|
||||
const scheduleColor = getScheduleColor(event);
|
||||
const classLevelLabel = getScheduleClassLevelLabel(event);
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="rounded-sm overflow-hidden cursor-pointer hover:shadow-lg"
|
||||
style={calculateEventStyle(event, dayEvents)}
|
||||
onClick={
|
||||
parentView
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
}
|
||||
}
|
||||
>
|
||||
{classLevelLabel && (
|
||||
<div
|
||||
className="px-1 py-0.5 border-t-2"
|
||||
style={{
|
||||
borderTopColor: scheduleColor,
|
||||
backgroundColor: `${scheduleColor}22`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wide truncate block text-center"
|
||||
style={{ color: scheduleColor }}
|
||||
>
|
||||
{classLevelLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="font-semibold text-xs truncate flex items-center gap-1"
|
||||
style={{ color: event.color }}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: event.color }}
|
||||
/>
|
||||
<span className="truncate flex-1">{event.title}</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: event.color, opacity: 0.75 }}
|
||||
>
|
||||
{format(new Date(event.start), 'HH:mm')} –{' '}
|
||||
{format(new Date(event.end), 'HH:mm')}
|
||||
</div>
|
||||
{event.location && (
|
||||
<div
|
||||
className="text-xs truncate"
|
||||
style={{ color: event.color, opacity: 0.75 }}
|
||||
>
|
||||
{event.location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DayView;
|
||||
@ -10,20 +10,31 @@ export default function EventModal({
|
||||
eventData,
|
||||
setEventData,
|
||||
}) {
|
||||
const { addEvent, handleUpdateEvent, handleDeleteEvent, schedules } =
|
||||
const {
|
||||
addEvent,
|
||||
handleUpdateEvent,
|
||||
handleDeleteEvent,
|
||||
schedules,
|
||||
selectedSchedule,
|
||||
} =
|
||||
usePlanning();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
// S'assurer que planning est défini lors du premier rendu
|
||||
React.useEffect(() => {
|
||||
if (!eventData?.planning && schedules.length > 0) {
|
||||
const defaultSchedule =
|
||||
schedules.find(
|
||||
(schedule) => Number(schedule.id) === Number(selectedSchedule)
|
||||
) || schedules[0];
|
||||
|
||||
setEventData((prev) => ({
|
||||
...prev,
|
||||
planning: schedules[0].id,
|
||||
color: schedules[0].color,
|
||||
planning: defaultSchedule.id,
|
||||
color: defaultSchedule.color,
|
||||
}));
|
||||
}
|
||||
}, [schedules, eventData?.planning]);
|
||||
}, [schedules, selectedSchedule, eventData?.planning]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@ -105,7 +116,7 @@ export default function EventModal({
|
||||
onChange={(e) =>
|
||||
setEventData({ ...eventData, title: e.target.value })
|
||||
}
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -120,7 +131,7 @@ export default function EventModal({
|
||||
onChange={(e) =>
|
||||
setEventData({ ...eventData, description: e.target.value })
|
||||
}
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
@ -142,7 +153,7 @@ export default function EventModal({
|
||||
color: selectedSchedule?.color || '#10b981',
|
||||
});
|
||||
}}
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
>
|
||||
{schedules.map((schedule) => (
|
||||
@ -185,7 +196,7 @@ export default function EventModal({
|
||||
recursionType: e.target.value,
|
||||
});
|
||||
}}
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{recurrenceOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@ -215,7 +226,7 @@ export default function EventModal({
|
||||
}}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
(eventData.selectedDays || []).includes(day.value)
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
? 'bg-primary/10 text-secondary'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
@ -247,13 +258,13 @@ export default function EventModal({
|
||||
: null,
|
||||
})
|
||||
}
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Début
|
||||
@ -267,7 +278,7 @@ export default function EventModal({
|
||||
start: new Date(e.target.value).toISOString(),
|
||||
})
|
||||
}
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -284,7 +295,7 @@ export default function EventModal({
|
||||
end: new Date(e.target.value).toISOString(),
|
||||
})
|
||||
}
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -301,7 +312,7 @@ export default function EventModal({
|
||||
onChange={(e) =>
|
||||
setEventData({ ...eventData, location: e.target.value })
|
||||
}
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -328,7 +339,7 @@ export default function EventModal({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-emerald-600 text-white rounded hover:bg-emerald-700"
|
||||
className="px-4 py-2 bg-primary text-white rounded hover:bg-secondary"
|
||||
>
|
||||
{eventData.id ? 'Modifier' : 'Créer'}
|
||||
</button>
|
||||
|
||||
@ -14,7 +14,24 @@ import { fr } from 'date-fns/locale';
|
||||
import { getEventsForDate } from '@/utils/events';
|
||||
|
||||
const MonthView = ({ onDateClick, onEventClick }) => {
|
||||
const { currentDate, setViewType, setCurrentDate, events } = usePlanning();
|
||||
const { currentDate, setViewType, setCurrentDate, events, schedules } =
|
||||
usePlanning();
|
||||
|
||||
const getScheduleColor = (event) => {
|
||||
const schedule = schedules?.find(
|
||||
(item) => Number(item.id) === Number(event.planning)
|
||||
);
|
||||
return schedule?.color || event.color || '#6B7280';
|
||||
};
|
||||
|
||||
const getScheduleClassLevelLabel = (event) => {
|
||||
const schedule = schedules?.find(
|
||||
(item) => Number(item.id) === Number(event.planning)
|
||||
);
|
||||
const scheduleName = schedule?.name || '';
|
||||
if (!scheduleName) return '';
|
||||
return scheduleName;
|
||||
};
|
||||
|
||||
// Obtenir tous les jours du mois actuel
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
@ -39,21 +56,24 @@ const MonthView = ({ onDateClick, onEventClick }) => {
|
||||
key={day.toString()}
|
||||
className={`p-2 overflow-y-auto relative flex flex-col
|
||||
${!isCurrentMonth ? 'bg-gray-100 text-gray-400' : ''}
|
||||
${isCurrentDay ? 'bg-emerald-50' : ''}
|
||||
${isCurrentDay ? 'bg-primary/5' : ''}
|
||||
hover:bg-gray-100 cursor-pointer border-b border-r`}
|
||||
onClick={() => handleDayClick(day)}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span
|
||||
className={`text-sm font-medium rounded-full w-7 h-7 flex items-center justify-center
|
||||
${isCurrentDay ? 'bg-emerald-500 text-white' : ''}
|
||||
${isCurrentDay ? 'bg-primary text-white' : ''}
|
||||
${!isCurrentMonth ? 'text-gray-400' : ''}`}
|
||||
>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 flex-1">
|
||||
{dayEvents.map((event, index) => (
|
||||
{dayEvents.map((event) => {
|
||||
const scheduleColor = getScheduleColor(event);
|
||||
const classLevelLabel = getScheduleClassLevelLabel(event);
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="text-xs p-1 rounded truncate cursor-pointer"
|
||||
@ -67,32 +87,68 @@ const MonthView = ({ onDateClick, onEventClick }) => {
|
||||
onEventClick(event);
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
{classLevelLabel && (
|
||||
<div
|
||||
className="-mx-1 -mt-1 mb-1 px-1 py-0.5 border-t-2"
|
||||
style={{
|
||||
borderTopColor: scheduleColor,
|
||||
backgroundColor: `${scheduleColor}22`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wide truncate block text-center"
|
||||
style={{ color: scheduleColor }}
|
||||
>
|
||||
{classLevelLabel}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 max-w-full">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: event.color }}
|
||||
/>
|
||||
<span className="truncate flex-1">{event.title}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const dayLabels = [
|
||||
{ short: 'L', long: 'Lun' },
|
||||
{ short: 'M', long: 'Mar' },
|
||||
{ short: 'M', long: 'Mer' },
|
||||
{ short: 'J', long: 'Jeu' },
|
||||
{ short: 'V', long: 'Ven' },
|
||||
{ short: 'S', long: 'Sam' },
|
||||
{ short: 'D', long: 'Dim' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white">
|
||||
<div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white overflow-x-auto">
|
||||
<div className="min-w-[280px]">
|
||||
{/* En-tête des jours de la semaine */}
|
||||
<div className="grid grid-cols-7 border-b">
|
||||
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day) => (
|
||||
{dayLabels.map((day, i) => (
|
||||
<div
|
||||
key={day}
|
||||
className="p-2 text-center text-sm font-medium text-gray-500"
|
||||
key={i}
|
||||
className="p-1 sm:p-2 text-center text-xs sm:text-sm font-medium text-gray-500"
|
||||
>
|
||||
{day}
|
||||
<span className="sm:hidden">{day.short}</span>
|
||||
<span className="hidden sm:inline">{day.long}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Grille des jours */}
|
||||
<div className="flex-1 grid grid-cols-7 grid-rows-[repeat(6,1fr)]">
|
||||
<div className="grid grid-cols-7 grid-rows-[repeat(6,1fr)]">
|
||||
{days.map((day) => renderDay(day))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ const PlanningView = ({ events, onEventClick }) => {
|
||||
|
||||
return (
|
||||
<div className="bg-white h-full overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<table className="min-w-full border-collapse">
|
||||
<thead className="bg-gray-50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="py-3 px-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">
|
||||
|
||||
@ -3,7 +3,7 @@ import { usePlanning, PlanningModes } from '@/context/PlanningContext';
|
||||
import { Plus, Edit2, Eye, EyeOff, Check, X } from 'lucide-react';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
|
||||
export default function ScheduleNavigation({ classes, modeSet = 'event', isOpen = false, onClose = () => {} }) {
|
||||
const {
|
||||
schedules,
|
||||
selectedSchedule,
|
||||
@ -62,22 +62,10 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="w-64 border-r p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold">
|
||||
{planningMode === PlanningModes.CLASS_SCHEDULE
|
||||
? 'Emplois du temps'
|
||||
: 'Plannings'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
const title = planningMode === PlanningModes.CLASS_SCHEDULE ? 'Emplois du temps' : 'Plannings';
|
||||
|
||||
const listContent = (
|
||||
<>
|
||||
{isAddingNew && (
|
||||
<div className="mb-4 p-2 border rounded">
|
||||
<input
|
||||
@ -251,6 +239,50 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop : sidebar fixe */}
|
||||
<nav className="hidden md:flex flex-col w-64 border-r p-4 h-full overflow-y-auto shrink-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-headline font-semibold">{title}</h2>
|
||||
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{listContent}
|
||||
</nav>
|
||||
|
||||
{/* Mobile : drawer en overlay */}
|
||||
<div
|
||||
className={`md:hidden fixed inset-0 z-50 transition-opacity duration-200 ${
|
||||
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-72 bg-white shadow-xl flex flex-col transition-transform duration-200 ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b shrink-0">
|
||||
<h2 className="font-headline font-semibold">{title}</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{listContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import { isToday } from 'date-fns';
|
||||
|
||||
|
||||
const WeekView = ({ onDateClick, onEventClick, events }) => {
|
||||
const { currentDate, planningMode, parentView } = usePlanning();
|
||||
const { currentDate, planningMode, parentView, schedules } = usePlanning();
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const scrollContainerRef = useRef(null); // Ajouter cette référence
|
||||
|
||||
@ -54,6 +54,8 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const todayIndex = weekDays.findIndex((day) => isToday(day));
|
||||
|
||||
const isWeekend = (date) => {
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6;
|
||||
@ -71,11 +73,28 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getScheduleColor = (event) => {
|
||||
const schedule = schedules?.find(
|
||||
(item) => Number(item.id) === Number(event.planning)
|
||||
);
|
||||
return schedule?.color || event.color || '#6B7280';
|
||||
};
|
||||
|
||||
const getScheduleClassLevelLabel = (event) => {
|
||||
const schedule = schedules?.find(
|
||||
(item) => Number(item.id) === Number(event.planning)
|
||||
);
|
||||
const scheduleName = schedule?.name || '';
|
||||
if (!scheduleName) return '';
|
||||
return scheduleName;
|
||||
};
|
||||
|
||||
const calculateEventStyle = (event, dayEvents) => {
|
||||
const start = new Date(event.start);
|
||||
const end = new Date(event.end);
|
||||
const startMinutes = (start.getMinutes() / 60) * 5;
|
||||
const duration = ((end - start) / (1000 * 60 * 60)) * 5;
|
||||
const scheduleColor = getScheduleColor(event);
|
||||
|
||||
// Trouver les événements qui se chevauchent
|
||||
const overlappingEvents = findOverlappingEvents(event, dayEvents);
|
||||
@ -101,6 +120,8 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
|
||||
|
||||
const renderEventInCell = (event, dayEvents) => {
|
||||
const eventStyle = calculateEventStyle(event, dayEvents);
|
||||
const scheduleColor = getScheduleColor(event);
|
||||
const classLevelLabel = getScheduleClassLevelLabel(event);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -116,12 +137,32 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
|
||||
}
|
||||
}
|
||||
>
|
||||
{classLevelLabel && (
|
||||
<div
|
||||
className="px-1 py-0.5 border-t-2"
|
||||
style={{
|
||||
borderTopColor: scheduleColor,
|
||||
backgroundColor: `${scheduleColor}22`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wide truncate block text-center"
|
||||
style={{ color: scheduleColor }}
|
||||
>
|
||||
{classLevelLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="font-semibold text-xs truncate"
|
||||
className="font-semibold text-xs truncate flex items-center gap-1"
|
||||
style={{ color: event.color }}
|
||||
>
|
||||
{event.title}
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: event.color }}
|
||||
/>
|
||||
<span className="truncate flex-1">{event.title}</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
@ -156,14 +197,14 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
|
||||
key={day}
|
||||
className={`h-14 p-2 text-center border-b
|
||||
${isWeekend(day) ? 'bg-gray-50' : 'bg-white'}
|
||||
${isToday(day) ? 'bg-emerald-100 border-x border-emerald-600' : ''}`}
|
||||
${isToday(day) ? 'bg-primary/10 border-x border-primary' : ''}`}
|
||||
>
|
||||
<div className="text-xs font-medium text-gray-500">
|
||||
{format(day, 'EEEE', { locale: fr })}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-semibold inline-block rounded-full w-7 h-7 leading-7
|
||||
${isToday(day) ? 'bg-emerald-500 text-white' : ''}`}
|
||||
${isToday(day) ? 'bg-primary text-white' : ''}`}
|
||||
>
|
||||
{format(day, 'd', { locale: fr })}
|
||||
</div>
|
||||
@ -173,15 +214,25 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
|
||||
|
||||
{/* Grille horaire */}
|
||||
<div ref={scrollContainerRef} className="flex-1 relative">
|
||||
{isCurrentWeek && todayIndex >= 0 && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-[5] border-x border-primary pointer-events-none"
|
||||
style={{
|
||||
left: `calc(2.5rem + ((100% - 2.5rem) / 7) * ${todayIndex})`,
|
||||
width: 'calc((100% - 2.5rem) / 7)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ligne de temps actuelle */}
|
||||
{isCurrentWeek && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-10 border-emerald-500 border pointer-events-none"
|
||||
className="absolute left-0 right-0 z-10 border-primary border pointer-events-none"
|
||||
style={{
|
||||
top: getCurrentTimePosition(),
|
||||
}}
|
||||
>
|
||||
<div className="absolute -left-2 -top-2 w-2 h-2 rounded-full bg-emerald-500" />
|
||||
<div className="absolute -left-2 -top-2 w-2 h-2 rounded-full bg-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -202,7 +253,7 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
|
||||
key={`${hour}-${day}`}
|
||||
className={`h-20 relative border-b border-gray-100
|
||||
${isWeekend(day) ? 'bg-gray-50' : 'bg-white'}
|
||||
${isToday(day) ? 'bg-emerald-100/50 border-x border-emerald-600' : ''}`}
|
||||
${isToday(day) ? 'bg-primary/10/50' : ''}`}
|
||||
onClick={
|
||||
parentView
|
||||
? undefined
|
||||
|
||||
@ -8,14 +8,14 @@ import { isSameMonth } from 'date-fns';
|
||||
const MonthCard = ({ month, eventCount, onClick }) => (
|
||||
<div
|
||||
className={`bg-white p-4 rounded shadow hover:shadow-lg cursor-pointer
|
||||
${isSameMonth(month, new Date()) ? 'ring-2 ring-emerald-500' : ''}`}
|
||||
${isSameMonth(month, new Date()) ? 'ring-2 ring-primary' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<h3 className="font-medium text-center mb-2">
|
||||
<h3 className="font-headline font-medium text-center mb-2">
|
||||
{format(month, 'MMMM', { locale: fr })}
|
||||
</h3>
|
||||
<div className="text-center text-sm">
|
||||
<span className="inline-flex items-center justify-center bg-emerald-100 text-emerald-800 px-2 py-1 rounded-full">
|
||||
<span className="inline-flex items-center justify-center bg-primary/10 text-secondary px-2 py-1 rounded-full">
|
||||
{eventCount} événements
|
||||
</span>
|
||||
</div>
|
||||
@ -36,7 +36,7 @@ const YearView = ({ onDateClick }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
|
||||
{months.map((month) => (
|
||||
<MonthCard
|
||||
key={month.getTime()}
|
||||
|
||||
@ -15,27 +15,28 @@ export default function LineChart({ data }) {
|
||||
.filter((idx) => idx !== -1);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-end space-x-4"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<div className="w-full flex space-x-4">
|
||||
{data.map((point, idx) => {
|
||||
const barHeight = Math.max((point.value / maxValue) * chartHeight, 8); // min 8px
|
||||
const barHeight = Math.max((point.value / maxValue) * chartHeight, 8);
|
||||
const isMax = maxIndices.includes(idx);
|
||||
return (
|
||||
<div key={idx} className="flex flex-col items-center flex-1">
|
||||
{/* Valeur au-dessus de la barre */}
|
||||
{/* Valeur au-dessus de la barre — hors de la zone hauteur fixe */}
|
||||
<span className="text-xs mb-1 text-gray-700 font-semibold">
|
||||
{point.value}
|
||||
</span>
|
||||
{/* Zone barres à hauteur fixe, alignées en bas */}
|
||||
<div
|
||||
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`}
|
||||
style={{
|
||||
height: `${barHeight}px`,
|
||||
transition: 'height 0.3s',
|
||||
}}
|
||||
className="w-full flex items-end justify-center"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<div
|
||||
className={`${isMax ? 'bg-tertiary' : 'bg-blue-400'} rounded-t w-4`}
|
||||
style={{ height: `${barHeight}px`, transition: 'height 0.3s' }}
|
||||
title={`${point.month}: ${point.value}`}
|
||||
/>
|
||||
</div>
|
||||
{/* Label mois en dessous */}
|
||||
<span className="text-xs mt-1 text-gray-600">{point.month}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -51,7 +51,7 @@ const ConversationItem = ({
|
||||
const getLastMessageText = () => {
|
||||
if (isTyping) {
|
||||
return (
|
||||
<span className="text-emerald-500 italic">Tape un message...</span>
|
||||
<span className="text-primary italic">Tape un message...</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -96,7 +96,7 @@ const ConversationItem = ({
|
||||
const getPresenceColor = (status) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'bg-emerald-400';
|
||||
return 'bg-tertiary';
|
||||
case 'away':
|
||||
return 'bg-yellow-400';
|
||||
case 'busy':
|
||||
@ -127,7 +127,7 @@ const ConversationItem = ({
|
||||
<div
|
||||
className={`group flex items-center p-3 cursor-pointer rounded-lg transition-all duration-200 hover:bg-gray-50 ${
|
||||
isSelected
|
||||
? 'bg-emerald-50 border-l-4 border-emerald-500'
|
||||
? 'bg-primary/5 border-l-4 border-primary'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
@ -154,8 +154,8 @@ const ConversationItem = ({
|
||||
<div className="flex-1 ml-3 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3
|
||||
className={`font-semibold truncate ${
|
||||
isSelected ? 'text-emerald-700' : 'text-gray-900'
|
||||
className={`font-headline font-semibold truncate ${
|
||||
isSelected ? 'text-secondary' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{getInterlocutorName()}
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
Archive,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
|
||||
const FileAttachment = ({
|
||||
fileName,
|
||||
@ -16,6 +17,7 @@ const FileAttachment = ({
|
||||
fileUrl,
|
||||
onDownload = null,
|
||||
}) => {
|
||||
const secureUrl = getSecureFileUrl(fileUrl);
|
||||
// Obtenir l'icône en fonction du type de fichier
|
||||
const getFileIcon = (type) => {
|
||||
if (type.startsWith('image/')) {
|
||||
@ -49,9 +51,9 @@ const FileAttachment = ({
|
||||
const handleDownload = () => {
|
||||
if (onDownload) {
|
||||
onDownload();
|
||||
} else if (fileUrl) {
|
||||
} else if (secureUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.href = secureUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
@ -64,14 +66,14 @@ const FileAttachment = ({
|
||||
|
||||
return (
|
||||
<div className="max-w-sm">
|
||||
{isImage && fileUrl ? (
|
||||
{isImage && secureUrl ? (
|
||||
// Affichage pour les images
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={fileUrl}
|
||||
src={secureUrl}
|
||||
alt={fileName}
|
||||
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => window.open(fileUrl, '_blank')}
|
||||
onClick={() => window.open(secureUrl, '_blank')}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
|
||||
<button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user