45 Commits

Author SHA1 Message Date
4aacdc1c28 Merge remote-tracking branch 'origin/ci-jenkins' into develop 2026-04-05 16:31:50 +02:00
9e8201a1ec chore: version 1.0.0 2026-04-05 16:14:32 +02:00
40b2c8d1e5 Merge branch 'develop' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into develop 2026-04-05 16:13:29 +02:00
9d97023cae chore(release): 0.0.5 2026-04-05 16:08:30 +02:00
cb782fa109 chore(release): 0.0.4 2026-04-05 16:08:27 +02:00
92c3183153 fix: signature électronique 2026-04-05 16:07:57 +02:00
db587ec747 fix: signature électronique 2026-04-05 16:06:04 +02:00
a81b76ecea fix: messagerie 2026-04-05 15:04:26 +02:00
4431c428d3 fix: sélection enseignants dans les plannings 2026-04-05 12:08:04 +02:00
2ef71f99c3 chore: Application du design system 2026-04-05 12:00:34 +02:00
f9c0585b30 fix: On n'historise plus les matières ni les enseignants 2026-04-05 11:41:52 +02:00
12939fca85 fix: Mise à jour des plannings 2026-04-05 11:09:32 +02:00
1f2a1b88ac fix: Revue des modales de création de groupes / formulaire 2026-04-05 10:41:17 +02:00
762dede0af fix: Boutons de navigation + mise en page de l'aperçu du formulaire dynamique 2026-04-05 10:36:15 +02:00
ccdbae1c08 fix: Mise en page sur absence de frais ou de tarifs lors de la création d'un DI 2026-04-05 09:32:17 +02:00
2a223fe3dd fix: Réintégration du bouton de Bilan de compétence + harmonisation des paths d'upload de fichier 2026-04-05 09:24:30 +02:00
409cf05f1a fix: Chat getSession + passage en asyn ces getWebSocketUrl et connectToChat 2026-04-04 23:18:16 +02:00
b0e04e3adc fix: Upload document 2026-04-04 23:18:15 +02:00
3c7266608d fix: Ne pas envoyer de mail lors de la création d'un DI 2026-04-04 22:47:01 +02:00
5bbbcb9dc1 fix: Emploi du temps pour les classes de l'année scolaire en cours 2026-04-04 22:25:25 +02:00
053140c8be fix: correction du téléchargement du fichier 2026-04-04 22:23:47 +02:00
90b0d14418 feat: Finalisation formulaire dynamique 2026-04-04 20:08:25 +02:00
ae06b6fef7 chore: renommage des variables d'environnement pour le start.py 2026-04-04 17:42:21 +02:00
e37aee2abc feat(backend,frontend): régénération et visualisation inline de la fiche élève PDF 2026-04-04 17:40:46 +02:00
2d678b732f feat: Mise à jour de la page parent 2026-04-04 15:36:39 +02:00
4c56cb6474 fix: Suppression envoi mail / création page feedback 2026-04-04 14:26:23 +02:00
79e14a23fe Merge pull request 'feat: Ajout d'un système d'historisation et d'export de données en CSV [N3WTS-5]' (!76) from N3WTS-5-Historique-ACA into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/76
2026-04-04 11:55:03 +00:00
269866fb1c Merge remote-tracking branch 'origin/develop' into N3WTS-5-Historique-ACA 2026-04-04 13:54:37 +02:00
f091fa0432 feat: Ajout d'un système d'historisation et d'export de données en CSV [N3WTS-5] 2026-04-04 13:51:43 +02:00
a3291262d8 feat: Securisation du téléchargement de fichier 2026-04-04 13:44:57 +02:00
5f6c015d02 Merge branch 'worktree-design-system' into develop 2026-04-04 12:02:32 +02:00
09b1541dc8 feat: Ajout de la commande npm permettant de creer un etablissement 2026-04-04 11:57:59 +02:00
cb76a23d02 docs(design-system): add design system documentation and AI agent instructions
- Add docs/design-system.md with color tokens, typography, spacing, icons, responsive/PWA rules and component reuse guidelines
- Add CLAUDE.md with permanent instructions for Claude Code
- Add .github/instructions/design-system.instruction.md for GitHub Copilot
- Update .github/copilot-instructions.md to reference the design system
- Update Front-End/tailwind.config.js with color tokens (primary, secondary, tertiary, neutral) and font families (Manrope/Inter)
- Update Front-End/src/app/layout.js to load Manrope and Inter via next/font/google
2026-04-04 11:56:19 +02:00
2579af9b8b fix: coorection démarrage 2026-04-04 10:49:35 +02:00
3a132ae0bd Merge pull request 'N3WTS-6-Amelioration_Suivi_Eleve-ACA' (!75) from N3WTS-6-Amelioration_Suivi_Eleve-ACA into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/75
2026-04-04 06:36:09 +00:00
905fa5dbfb feat: Ajout d'un système de notation par classe et par matière et par élève [N3WTS-6] 2026-04-03 22:10:32 +02:00
edb9ace6ae Merge remote-tracking branch 'origin/develop' into N3WTS-6-Amelioration_Suivi_Eleve-ACA 2026-04-03 17:35:29 +02:00
6fb3c5cdb4 feat: lister uniquement les élèves inscrits dans une classe [N3WTS-6] 2026-03-14 13:11:30 +01:00
e9a30b7bde Merge branch 'ci-jenkins' of ssh://git.v0id.ovh:5022/n3wt-innov/n3wt-school into ci-jenkins 2026-02-16 16:11:18 +01:00
ff1d113698 docs: Mise en place du MO de MEP 2026-02-16 16:10:22 +01:00
12a6ad1d61 fix: clean ws 2026-02-16 11:37:17 +01:00
856443d4ed fix: clean ws 2026-02-09 14:15:36 +01:00
ace4dcbf07 fix: remplace les conditions de build 2026-02-09 13:54:13 +01:00
61f63f9dc9 fix: remplace cleanWs par deleteDir (méthode native Jenkins) 2026-02-09 13:50:22 +01:00
d9e998d2ff chore: modification de la ci 2026-02-09 12:26:03 +01:00
202 changed files with 10357 additions and 3123 deletions

View File

@ -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) - Documentation en français pour les nouvelles fonctionnalités (si applicable)
- Référence : [documentation guidelines](./instructions/documentation.instruction.md) - 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 ## Références
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md) - **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md) - **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
- **Tests** : [run tests](./instructions/run-tests.instruction.md) - **Tests** : [run tests](./instructions/run-tests.instruction.md)
- **Design System** : [design system instructions](./instructions/design-system.instruction.md)

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

View File

@ -1,5 +1,5 @@
La documentation doit être en français et claire pour les utilisateurs francophones. 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. 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. 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. Tout ce qui concerne la gestion de projet, roadmap ne doit pas apparaître dans la documentation.

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ node_modules/
hardcoded-strings-report.md hardcoded-strings-report.md
backend.env backend.env
*.log *.log
.claude/worktrees/*

1
.husky/commit-msg Normal file → Executable file
View File

@ -1 +1,2 @@
#!/bin/sh
npx --no -- commitlint --edit $1 npx --no -- commitlint --edit $1

1
.husky/pre-commit Normal file → Executable file
View File

@ -1 +1,2 @@
#!/bin/sh
cd $(dirname "$0")/../Front-End/ && npm run lint-light cd $(dirname "$0")/../Front-End/ && npm run lint-light

1
.husky/prepare-commit-msg Normal file → Executable file
View File

@ -1 +1,2 @@
#!/bin/sh
#node scripts/prepare-commit-msg.js "$1" "$2" #node scripts/prepare-commit-msg.js "$1" "$2"

View File

@ -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.models
import django.contrib.auth.validators import django.contrib.auth.validators

View File

@ -14,7 +14,7 @@ class ProfileSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Profile 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}} extra_kwargs = {'password': {'write_only': True}}
def get_roles(self, obj): def get_roles(self, obj):

View File

@ -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 import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@ -3,6 +3,7 @@ from django.urls import path, re_path
from .views import ( from .views import (
DomainListCreateView, DomainDetailView, DomainListCreateView, DomainDetailView,
CategoryListCreateView, CategoryDetailView, CategoryListCreateView, CategoryDetailView,
ServeFileView,
) )
urlpatterns = [ urlpatterns = [
@ -11,4 +12,6 @@ urlpatterns = [
re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"), 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"), re_path(r'^categories/(?P<id>[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"),
path('serve-file/', ServeFileView.as_view(), name="serve_file"),
] ]

View 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.http.response import JsonResponse
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -117,3 +122,61 @@ class CategoryDetailView(APIView):
return JsonResponse({'message': 'Deleted'}, safe=False) return JsonResponse({'message': 'Deleted'}, safe=False)
except Category.DoesNotExist: except Category.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) 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

View File

@ -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 Establishment.models
import django.contrib.postgres.fields import django.contrib.postgres.fields

View File

@ -1,9 +1,10 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (
SendEmailView, search_recipients SendEmailView, search_recipients, SendFeedbackView
) )
urlpatterns = [ urlpatterns = [
path('send-email/', SendEmailView.as_view(), name='send_email'), path('send-email/', SendEmailView.as_view(), name='send_email'),
path('search-recipients/', search_recipients, name='search_recipients'), path('search-recipients/', search_recipients, name='search_recipients'),
path('send-feedback/', SendFeedbackView.as_view(), name='send_feedback'),
] ]

View File

@ -5,6 +5,7 @@ from rest_framework import status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from django.db.models import Q from django.db.models import Q
from django.conf import settings
from Auth.models import Profile, ProfileRole from Auth.models import Profile, ProfileRole
import N3wtSchool.mailManager as mailer import N3wtSchool.mailManager as mailer
@ -119,3 +120,84 @@ def search_recipients(request):
}) })
return JsonResponse(results, safe=False) 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
)

View File

@ -72,11 +72,11 @@ class ChatConsumer(AsyncWebsocketConsumer):
if presence: if presence:
await self.broadcast_presence_update(self.user_id, 'online') await self.broadcast_presence_update(self.user_id, 'online')
await self.accept()
# Envoyer les statuts de présence existants des autres utilisateurs connectés # Envoyer les statuts de présence existants des autres utilisateurs connectés
await self.send_existing_user_presences() await self.send_existing_user_presences()
await self.accept()
logger.info(f"User {self.user_id} connected to chat") logger.info(f"User {self.user_id} connected to chat")
async def send_existing_user_presences(self): async def send_existing_user_presences(self):

View File

@ -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.db.models.deletion
import django.utils.timezone import django.utils.timezone

View File

@ -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.db.models.deletion
from django.conf import settings from django.conf import settings

View File

@ -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.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@ -9,6 +9,7 @@ from .models import Planning, Events, RecursionType
from .serializers import PlanningSerializer, EventsSerializer from .serializers import PlanningSerializer, EventsSerializer
from N3wtSchool import bdd from N3wtSchool import bdd
from Subscriptions.util import getCurrentSchoolYear
class PlanningView(APIView): class PlanningView(APIView):
@ -17,6 +18,7 @@ class PlanningView(APIView):
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
planning_mode = request.GET.get('planning_mode', None) planning_mode = request.GET.get('planning_mode', None)
current_school_year = getCurrentSchoolYear()
plannings = bdd.getAllObjects(Planning) plannings = bdd.getAllObjects(Planning)
@ -25,7 +27,10 @@ class PlanningView(APIView):
# Filtrer en fonction du planning_mode # Filtrer en fonction du planning_mode
if planning_mode == "classSchedule": 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": elif planning_mode == "planning":
plannings = plannings.filter(school_class__isnull=True) plannings = plannings.filter(school_class__isnull=True)
@ -79,6 +84,7 @@ class EventsView(APIView):
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
planning_mode = request.GET.get('planning_mode', None) planning_mode = request.GET.get('planning_mode', None)
current_school_year = getCurrentSchoolYear()
filterParams = {} filterParams = {}
plannings=[] plannings=[]
events = Events.objects.all() events = Events.objects.all()
@ -86,6 +92,8 @@ class EventsView(APIView):
filterParams['establishment'] = establishment_id filterParams['establishment'] = establishment_id
if planning_mode is not None: if planning_mode is not None:
filterParams['school_class__isnull'] = (planning_mode!="classSchedule") filterParams['school_class__isnull'] = (planning_mode!="classSchedule")
if planning_mode == "classSchedule":
filterParams['school_class__school_year'] = current_school_year
if filterParams: if filterParams:
plannings = Planning.objects.filter(**filterParams) plannings = Planning.objects.filter(**filterParams)
events = Events.objects.filter(planning__in=plannings) events = Events.objects.filter(planning__in=plannings)

View File

@ -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.contrib.postgres.fields
import django.db.models.deletion import django.db.models.deletion
@ -99,6 +99,7 @@ class Migration(migrations.Migration):
('number_of_students', models.PositiveIntegerField(blank=True, null=True)), ('number_of_students', models.PositiveIntegerField(blank=True, null=True)),
('teaching_language', models.CharField(blank=True, max_length=255)), ('teaching_language', models.CharField(blank=True, max_length=255)),
('school_year', models.CharField(blank=True, max_length=9)), ('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)), ('updated_date', models.DateTimeField(auto_now=True)),
('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)), ('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)),
('time_range', models.JSONField(default=list)), ('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')), ('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( migrations.CreateModel(
name='Teacher', name='Teacher',
fields=[ fields=[

View File

@ -48,6 +48,7 @@ class SchoolClass(models.Model):
number_of_students = models.PositiveIntegerField(null=True, blank=True) number_of_students = models.PositiveIntegerField(null=True, blank=True)
teaching_language = models.CharField(max_length=255, blank=True) teaching_language = models.CharField(max_length=255, blank=True)
school_year = models.CharField(max_length=9, 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) updated_date = models.DateTimeField(auto_now=True)
teachers = models.ManyToManyField(Teacher, blank=True) teachers = models.ManyToManyField(Teacher, blank=True)
levels = models.ManyToManyField('Common.Level', blank=True, related_name='school_classes') levels = models.ManyToManyField('Common.Level', blank=True, related_name='school_classes')
@ -156,3 +157,26 @@ class EstablishmentCompetency(models.Model):
if self.competency: if self.competency:
return f"{self.establishment.name} - {self.competency.name}" return f"{self.establishment.name} - {self.competency.name}"
return f"{self.establishment.name} - {self.custom_name} (custom)" 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})"

View File

@ -10,10 +10,11 @@ from .models import (
PaymentPlan, PaymentPlan,
PaymentMode, PaymentMode,
EstablishmentCompetency, EstablishmentCompetency,
Competency Competency,
Evaluation
) )
from Auth.models import Profile, ProfileRole from Auth.models import Profile, ProfileRole
from Subscriptions.models import Student from Subscriptions.models import Student, StudentEvaluation
from Establishment.models import Establishment from Establishment.models import Establishment
from Auth.serializers import ProfileRoleSerializer from Auth.serializers import ProfileRoleSerializer
from N3wtSchool import settings from N3wtSchool import settings
@ -182,12 +183,17 @@ class SchoolClassSerializer(serializers.ModelSerializer):
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False) teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False) establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
teachers_details = serializers.SerializerMethodField() teachers_details = serializers.SerializerMethodField()
students = StudentDetailSerializer(many=True, read_only=True) students = serializers.SerializerMethodField()
class Meta: class Meta:
model = SchoolClass model = SchoolClass
fields = '__all__' 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): def create(self, validated_data):
teachers_data = validated_data.pop('teachers', []) teachers_data = validated_data.pop('teachers', [])
levels_data = validated_data.pop('levels', []) levels_data = validated_data.pop('levels', [])
@ -300,3 +306,31 @@ class PaymentModeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = PaymentMode model = PaymentMode
fields = '__all__' 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}"

View File

@ -11,6 +11,9 @@ from .views import (
PaymentModeListCreateView, PaymentModeDetailView, PaymentModeListCreateView, PaymentModeDetailView,
CompetencyListCreateView, CompetencyDetailView, CompetencyListCreateView, CompetencyDetailView,
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView, EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
EvaluationListCreateView, EvaluationDetailView,
StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView,
SchoolYearsListView,
) )
urlpatterns = [ urlpatterns = [
@ -43,4 +46,16 @@ urlpatterns = [
re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"), 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"), 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"),
] ]

View File

@ -16,7 +16,8 @@ from .models import (
PaymentPlan, PaymentPlan,
PaymentMode, PaymentMode,
EstablishmentCompetency, EstablishmentCompetency,
Competency Competency,
Evaluation
) )
from .serializers import ( from .serializers import (
TeacherSerializer, TeacherSerializer,
@ -28,19 +29,43 @@ from .serializers import (
PaymentPlanSerializer, PaymentPlanSerializer,
PaymentModeSerializer, PaymentModeSerializer,
EstablishmentCompetencySerializer, EstablishmentCompetencySerializer,
CompetencySerializer CompetencySerializer,
EvaluationSerializer,
StudentEvaluationSerializer
) )
from Common.models import Domain, Category from Common.models import Domain, Category
from N3wtSchool.bdd import delete_object, getAllObjects, getObject from N3wtSchool.bdd import delete_object, getAllObjects, getObject
from django.db.models import Q from django.db.models import Q
from collections import defaultdict from collections import defaultdict
from Subscriptions.models import Student, StudentCompetency from Subscriptions.models import Student, StudentCompetency, StudentEvaluation
from Subscriptions.util import getCurrentSchoolYear from Subscriptions.util import getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears
import logging import logging
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
logger = logging.getLogger(__name__) 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(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialityListCreateView(APIView): class SpecialityListCreateView(APIView):
@ -183,12 +208,33 @@ class SchoolClassListCreateView(APIView):
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) 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: if establishment_id is None:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
school_classes_list = getAllObjects(SchoolClass) school_classes_list = getAllObjects(SchoolClass)
if school_classes_list: 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) classes_serializer = SchoolClassSerializer(school_classes_list, many=True)
return JsonResponse(classes_serializer.data, safe=False) return JsonResponse(classes_serializer.data, safe=False)
@ -785,3 +831,179 @@ class EstablishmentCompetencyDetailView(APIView):
return JsonResponse({'message': 'Deleted'}, safe=False) return JsonResponse({'message': 'Deleted'}, safe=False)
except EstablishmentCompetency.DoesNotExist: except EstablishmentCompetency.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) 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)

View File

@ -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 import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@ -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 Subscriptions.models
import django.db.models.deletion import django.db.models.deletion
@ -40,6 +40,8 @@ class Migration(migrations.Migration):
('birth_place', models.CharField(blank=True, default='', max_length=200)), ('birth_place', models.CharField(blank=True, default='', max_length=200)),
('birth_postal_code', models.IntegerField(blank=True, default=0)), ('birth_postal_code', models.IntegerField(blank=True, default=0)),
('attending_physician', models.CharField(blank=True, default='', max_length=200)), ('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')), ('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)), ('name', models.CharField(default='', max_length=255)),
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)), ('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)), ('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( migrations.CreateModel(
@ -90,6 +94,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='Subscriptions.student')), ('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)), ('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)), ('last_update', models.DateTimeField(auto_now=True)),
('school_year', models.CharField(blank=True, default='', max_length=9)), ('school_year', models.CharField(blank=True, default='', max_length=9)),
('notes', models.CharField(blank=True, max_length=200)), ('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')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=255)), ('name', models.CharField(default='', max_length=255)),
('is_required', models.BooleanField(default=False)), ('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)), ('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)), ('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')), ('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=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('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')), ('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')), ('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)), ('score', models.IntegerField(blank=True, null=True)),
('comment', models.TextField(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)), ('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')), ('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')), ('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')}, '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')},
},
),
] ]

View File

@ -54,17 +54,38 @@ class Sibling(models.Model):
return "SIBLING" return "SIBLING"
def registration_photo_upload_to(instance, filename): 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): 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) register_form = getattr(instance.student, 'registrationform', None)
if register_form: if register_form and register_form.establishment:
pk = register_form.pk 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: else:
# fallback sur l'id de l'élève si pas de registrationform est_name = "unknown_establishment"
pk = instance.student.pk
return f"registration_files/dossier_rf_{pk}/bilan/{filename}" 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): class BilanCompetence(models.Model):
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='bilans') student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='bilans')
@ -130,6 +151,10 @@ class Student(models.Model):
# One-to-Many Relationship # One-to-Many Relationship
associated_class = models.ForeignKey('School.SchoolClass', on_delete=models.SET_NULL, null=True, blank=True, related_name='students') 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): def __str__(self):
return self.last_name + "_" + self.first_name return self.last_name + "_" + self.first_name
@ -252,6 +277,7 @@ class RegistrationForm(models.Model):
# One-to-One Relationship # One-to-One Relationship
student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True) student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True)
status = models.IntegerField(choices=RegistrationFormStatus, default=RegistrationFormStatus.RF_IDLE) 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) last_update = models.DateTimeField(auto_now=True)
school_year = models.CharField(max_length=9, default="", blank=True) school_year = models.CharField(max_length=9, default="", blank=True)
notes = models.CharField(max_length=200, 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) groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
name = models.CharField(max_length=255, default="") name = models.CharField(max_length=255, default="")
is_required = models.BooleanField(default=False) 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) formMasterData = models.JSONField(default=list, blank=True, null=True)
file = models.FileField( file = models.FileField(
upload_to=registration_school_file_master_upload_to, upload_to=registration_school_file_master_upload_to,
@ -398,7 +425,9 @@ class RegistrationSchoolFileMaster(models.Model):
and isinstance(self.formMasterData, dict) and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields") 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: else:
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant # Pour les forms existants, le nom attendu est self.name + extension du fichier existant
extension = os.path.splitext(old_filename)[1] extension = os.path.splitext(old_filename)[1]
@ -433,16 +462,9 @@ class RegistrationSchoolFileMaster(models.Model):
except RegistrationSchoolFileMaster.DoesNotExist: except RegistrationSchoolFileMaster.DoesNotExist:
pass pass
# --- Traitement PDF dynamique AVANT le super().save() --- # IMPORTANT: pour les formulaires dynamiques, le fichier du master doit
if ( # rester le document source uploadé (PDF/image). La génération du PDF final
self.formMasterData # est faite au niveau des templates (par élève), pas sur le master.
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)
super().save(*args, **kwargs) 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) 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) file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
formTemplateData = models.JSONField(default=list, blank=True, null=True) 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): def __str__(self):
return self.name return self.name
@ -578,6 +604,8 @@ class StudentCompetency(models.Model):
default="", default="",
blank=True blank=True
) )
created_at = models.DateTimeField(auto_now_add=True, null=True)
updated_at = models.DateTimeField(auto_now=True, null=True)
class Meta: class Meta:
unique_together = ('student', 'establishment_competency', 'period') unique_together = ('student', 'establishment_competency', 'period')
@ -589,12 +617,34 @@ class StudentCompetency(models.Model):
def __str__(self): def __str__(self):
return f"{self.student} - {self.establishment_competency} - Score: {self.score} - Period: {self.period}" 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) ####### ####### Parent files templates (par dossier d'inscription) #######
class RegistrationParentFileTemplate(models.Model): class RegistrationParentFileTemplate(models.Model):
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True) 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) 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) 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): def __str__(self):
return self.master.name if self.master else f"ParentFile_{self.pk}" return self.master.name if self.master else f"ParentFile_{self.pk}"

View File

@ -21,7 +21,6 @@ from N3wtSchool import settings
from django.utils import timezone from django.utils import timezone
import pytz import pytz
import Subscriptions.util as util import Subscriptions.util as util
from N3wtSchool.mailManager import sendRegisterForm
class AbsenceManagementSerializer(serializers.ModelSerializer): class AbsenceManagementSerializer(serializers.ModelSerializer):
student_name = serializers.SerializerMethodField() student_name = serializers.SerializerMethodField()
@ -39,10 +38,15 @@ class AbsenceManagementSerializer(serializers.ModelSerializer):
class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer): class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False) id = serializers.IntegerField(required=False)
file_url = serializers.SerializerMethodField()
class Meta: class Meta:
model = RegistrationSchoolFileMaster model = RegistrationSchoolFileMaster
fields = '__all__' fields = '__all__'
def get_file_url(self, obj):
return obj.file.url if obj.file else None
class RegistrationParentFileMasterSerializer(serializers.ModelSerializer): class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False) id = serializers.IntegerField(required=False)
class Meta: class Meta:
@ -52,6 +56,9 @@ class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer): class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False) id = serializers.IntegerField(required=False)
file_url = serializers.SerializerMethodField() file_url = serializers.SerializerMethodField()
master_file_url = serializers.SerializerMethodField()
requires_electronic_signature = serializers.SerializerMethodField()
is_electronically_signed = serializers.SerializerMethodField()
class Meta: class Meta:
model = RegistrationSchoolFileTemplate model = RegistrationSchoolFileTemplate
@ -61,6 +68,33 @@ class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
# Retourne l'URL complète du fichier si disponible # Retourne l'URL complète du fichier si disponible
return obj.file.url if obj.file else None 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): class RegistrationParentFileTemplateSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False) id = serializers.IntegerField(required=False)
file_url = serializers.SerializerMethodField() file_url = serializers.SerializerMethodField()
@ -216,14 +250,6 @@ class StudentSerializer(serializers.ModelSerializer):
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data) profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
profile_role_serializer.is_valid(raise_exception=True) profile_role_serializer.is_valid(raise_exception=True)
profile_role = profile_role_serializer.save() 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: elif profile_role:
# Récupérer un ProfileRole existant par son ID # Récupérer un ProfileRole existant par son ID
profile_role = ProfileRole.objects.get(id=profile_role.id) profile_role = ProfileRole.objects.get(id=profile_role.id)
@ -399,10 +425,11 @@ class RegistrationFormSerializer(serializers.ModelSerializer):
class StudentByParentSerializer(serializers.ModelSerializer): class StudentByParentSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False) id = serializers.IntegerField(required=False)
associated_class_name = serializers.SerializerMethodField() associated_class_name = serializers.SerializerMethodField()
associated_class_id = serializers.SerializerMethodField()
class Meta: class Meta:
model = Student 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): def __init__(self, *args, **kwargs):
super(StudentByParentSerializer, self).__init__(*args, **kwargs) super(StudentByParentSerializer, self).__init__(*args, **kwargs)
@ -412,6 +439,9 @@ class StudentByParentSerializer(serializers.ModelSerializer):
def get_associated_class_name(self, obj): def get_associated_class_name(self, obj):
return obj.associated_class.atmosphere_name if obj.associated_class else None 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): class RegistrationFormByParentSerializer(serializers.ModelSerializer):
student = StudentByParentSerializer(many=False, required=True) student = StudentByParentSerializer(many=False, required=True)

View File

@ -4,9 +4,22 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Bilan de compétences</title> <title>Bilan de compétences</title>
<style> <style>
body { font-family: Arial, sans-serif; margin: 2em; } body { font-family: Arial, sans-serif; margin: 1.2em; color: #111827; }
h1, h2 { color: #059669; } 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-table { width: 100%; border-collapse: collapse; margin-bottom: 2em; }
.domain-header th { .domain-header th {
background: #d1fae5; background: #d1fae5;
@ -25,16 +38,77 @@
th, td { border: 1px solid #e5e7eb; padding: 0.5em; } th, td { border: 1px solid #e5e7eb; padding: 0.5em; }
th.competence-header { background: #d1fae5; } th.competence-header { background: #d1fae5; }
td.competence-nom { word-break: break-word; max-width: 320px; } 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> </style>
</head> </head>
<body> <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> <h1>Bilan de compétences</h1>
</div>
<div class="student-info"> <div class="student-info">
<strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}<br> <table>
<strong>Niveau :</strong> {{ student.level }}<br> <tr>
<strong>Classe :</strong> {{ student.class_name }}<br> <td><strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}</td>
<strong>Période :</strong> {{ period }}<br> <td style="text-align: right;"><strong>Date :</strong> {{ date }}</td>
<strong>Date :</strong> {{ date }} </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> </div>
{% for domaine in domaines %} {% for domaine in domaines %}
@ -72,41 +146,33 @@
</table> </table>
{% endfor %} {% endfor %}
<div style="margin-top: 60px; padding: 0; max-width: 700px;"> <div class="footer-note">
<div style=" <div style="font-weight: 700; color: #059669; font-size: 1.1em;">
min-height: 180px; Appréciation générale / Commentaire
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;">&nbsp;</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;">&nbsp;</span>
</div>
</div> </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> </div>
</body> </body>
</html> </html>

View 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">&nbsp;</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 %}
&nbsp;
{% 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">&nbsp;</span>
{% endif %}
</div>
{% if field.field_type %}
<div class="field-meta">Type: {{ field.field_type }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</body>
</html>

View File

@ -1,228 +1,319 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Fiche élève de {{ student.last_name }} {{ student.first_name }}</title> <title>
Fiche élève — {{ student.last_name }} {{ student.first_name }}
</title>
<style> <style>
@page { @page {
size: A4; size: A4;
margin: 2cm; margin: 1.5cm 2cm;
} }
body { body {
font-family: 'Arial', sans-serif; font-family: "Helvetica", "Arial", sans-serif;
font-size: 12pt; font-size: 10pt;
color: #222; color: #1e293b;
background: #fff; background: #fff;
margin: 0; margin: 0;
padding: 0; padding: 0;
line-height: 1.4;
} }
.container {
/* ── Header ── */
.header-table {
width: 100%; width: 100%;
padding: 0; border: none;
background: #fff; margin-bottom: 16px;
} }
.header { .header-table td {
text-align: center; border: none;
margin-bottom: 24px; padding: 0;
border-bottom: 2px solid #4CAF50; vertical-align: middle;
padding-bottom: 12px; }
position: relative; .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 { .title {
font-size: 22pt; font-size: 20pt;
font-weight: bold; font-weight: bold;
color: #4CAF50; color: #064e3b;
margin: 0 0 2px 0;
}
.subtitle {
font-size: 11pt;
color: #059669;
margin: 0; margin: 0;
font-weight: normal;
}
.header-line {
border: none;
border-top: 3px solid #059669;
margin: 12px 0 20px 0;
} }
.photo { .photo {
position: absolute; width: 80px;
top: 0; height: 80px;
right: 0;
width: 90px;
height: 90px;
object-fit: cover; object-fit: cover;
border: 1px solid #4CAF50; border: 2px solid #059669;
border-radius: 8px; border-radius: 4px;
} }
/* ── Sections ── */
.section { .section {
margin-bottom: 32px; /* Espacement augmenté entre les sections */ margin-bottom: 20px;
} }
.section-title { .section-header {
font-size: 15pt; background-color: #059669;
color: #ffffff;
font-size: 11pt;
font-weight: bold; font-weight: bold;
color: #4CAF50; padding: 6px 12px;
margin-bottom: 18px; /* Espacement sous le titre de section */ margin-bottom: 0;
border-bottom: 1px solid #4CAF50; letter-spacing: 0.5px;
padding-bottom: 2px; border-radius: 2px 2px 0 0;
}
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;
} }
.subsection-title { .subsection-title {
font-size: 12pt; font-size: 10pt;
color: #333; color: #064e3b;
margin: 8px 0 4px 0;
font-weight: bold; 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> </style>
</head> </head>
<body> <body>
{% load myTemplateTag %} {% load myTemplateTag %}
<div class="container">
<!-- Header Section --> <!-- ═══════ HEADER ═══════ -->
<div class="header"> <table class="header-table">
<h1 class="title">Fiche élève de {{ student.last_name }} {{ student.first_name }}</h1> <tr>
{% if student.photo %} <td class="header-left">
<img src="{{ student.get_photo_url }}" alt="Photo de l'élève" class="photo" /> {% if establishment %}
{% else %} <p class="school-name">{{ establishment.name }}</p>
<img src="/static/img/default-photo.jpg" alt="Photo par défaut" class="photo" />
{% endif %} {% endif %}
</div> <h1 class="title">Fiche &Eacute;l&egrave;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">
<div class="section-title">ÉLÈVE</div> <div class="section-header">INFORMATIONS DE L'ÉLÈVE</div>
<table> <table class="data">
<tr> <tr>
<td class="label-cell">Nom</td> <td class="label">Nom</td>
<td class="value-cell">{{ student.last_name }}</td> <td class="value">{{ student.last_name }}</td>
<td class="label-cell">Prénom</td> <td class="label">Prénom</td>
<td class="value-cell">{{ student.first_name }}</td> <td class="value">{{ student.first_name }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Adresse</td> <td class="label">Genre</td>
<td class="value-cell" colspan="3">{{ student.address }}</td> <td class="value">{{ student|getStudentGender }}</td>
<td class="label">Niveau</td>
<td class="value">{{ student|getStudentLevel }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Genre</td> <td class="label">Date de naissance</td>
<td class="value-cell">{{ student|getStudentGender }}</td> <td class="value">{{ student.formatted_birth_date }}</td>
<td class="label-cell">Né(e) le</td> <td class="label">Lieu de naissance</td>
<td class="value-cell">{{ student.birth_date }}</td> <!-- prettier-ignore -->
<td class="value">{{ student.birth_place }}{% if student.birth_postal_code %} ({{ student.birth_postal_code }}){% endif %}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">À</td> <td class="label">Nationalité</td>
<td class="value-cell">{{ student.birth_place }} ({{ student.birth_postal_code }})</td> <td class="value">{{ student.nationality }}</td>
<td class="label-cell">Nationalité</td> <td class="label">Médecin traitant</td>
<td class="value-cell">{{ student.nationality }}</td> <td class="value">{{ student.attending_physician }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Niveau</td> <td class="label">Adresse</td>
<td class="value-cell">{{ student|getStudentLevel }}</td> <td class="value" colspan="3">{{ student.address }}</td>
<td class="label-cell"></td>
<td class="value-cell"></td>
</tr> </tr>
</table> </table>
</div> </div>
<!-- Responsables --> <!-- ═══════ RESPONSABLES ═══════ -->
<div class="section"> <div class="section">
<div class="section-title">RESPONSABLES</div> <div class="section-header">RESPONSABLES LÉGAUX</div>
{% for guardian in student.getGuardians %} {% for guardian in student.getGuardians %}
<div>
<div class="subsection-title">Responsable {{ forloop.counter }}</div> <div class="subsection-title">Responsable {{ forloop.counter }}</div>
<table> <table class="data">
<tr> <tr>
<td class="label-cell">Nom</td> <td class="label">Nom</td>
<td class="value-cell">{{ guardian.last_name }}</td> <td class="value">{{ guardian.last_name }}</td>
<td class="label-cell">Prénom</td> <td class="label">Prénom</td>
<td class="value-cell">{{ guardian.first_name }}</td> <td class="value">{{ guardian.first_name }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Adresse</td> <td class="label">Date de naissance</td>
<td class="value-cell" colspan="3">{{ guardian.address }}</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>
<tr> <tr>
<td class="label-cell">Email</td> <td class="label">Email</td>
<td class="value-cell" colspan="3">{{ guardian.email }}</td> <td class="value" colspan="3">{{ guardian.email }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Né(e) le</td> <td class="label">Adresse</td>
<td class="value-cell">{{ guardian.birth_date }}</td> <td class="value" colspan="3">{{ guardian.address }}</td>
<td class="label-cell">Téléphone</td>
<td class="value-cell">{{ guardian.phone|phone_format }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Profession</td> <td class="label">Profession</td>
<td class="value-cell" colspan="3">{{ guardian.profession }}</td> <td class="value" colspan="3">{{ guardian.profession }}</td>
</tr> </tr>
</table> </table>
</div> {% empty %}
<p style="color: #94a3b8; font-style: italic; padding: 8px">
Aucun responsable renseigné.
</p>
{% endfor %} {% endfor %}
</div> </div>
<!-- Fratrie --> <!-- ═══════ FRATRIE ═══════ -->
{% if student.getSiblings %}
<div class="section"> <div class="section">
<div class="section-title">FRATRIE</div> <div class="section-header">FRATRIE</div>
{% for sibling in student.getSiblings %} {% for sibling in student.getSiblings %}
<div> <div class="subsection-title">Frère / Sœur {{ forloop.counter }}</div>
<div class="subsection-title">Frère/Soeur {{ forloop.counter }}</div> <table class="data">
<table>
<tr> <tr>
<td class="label-cell">Nom</td> <td class="label">Nom</td>
<td class="value-cell">{{ sibling.last_name }}</td> <td class="value">{{ sibling.last_name }}</td>
<td class="label-cell">Prénom</td> <td class="label">Prénom</td>
<td class="value-cell">{{ sibling.first_name }}</td> <td class="value">{{ sibling.first_name }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Né(e) le</td> <td class="label">Date de naissance</td>
<td class="value-cell" colspan="3">{{ sibling.birth_date }}</td> <td class="value" colspan="3">{{ sibling.birth_date }}</td>
</tr> </tr>
</table> </table>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
<!-- Paiement --> <!-- ═══════ PAIEMENT ═══════ -->
<div class="section"> <div class="section">
<div class="section-title">MODALITÉS DE PAIEMENT</div> <div class="section-header">MODALITÉS DE PAIEMENT</div>
<table> <table class="payment">
<tr> <tr>
<td class="label-cell">Frais d'inscription</td> <td class="label">Frais d'inscription</td>
<td class="value-cell">{{ student|getRegistrationPaymentMethod }} en {{ student|getRegistrationPaymentPlan }}</td> <!-- prettier-ignore -->
<td class="value">{{ student|getRegistrationPaymentMethod }} — {{ student|getRegistrationPaymentPlan }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Frais de scolarité</td> <td class="label">Frais de scolarité</td>
<td class="value-cell">{{ student|getTuitionPaymentMethod }} en {{ student|getTuitionPaymentPlan }}</td> <!-- prettier-ignore -->
<td class="value">{{ student|getTuitionPaymentMethod }} — {{ student|getTuitionPaymentPlan }}</td>
</tr> </tr>
</table> </table>
</div> </div>
<!-- Signature --> <!-- ═══════ SIGNATURE ═══════ -->
<div class="signature"> <div class="signature-block">
Fait le <span class="signature-text">{{ signatureDate }}</span> à <span class="signature-text">{{ signatureTime }}</span> <p>
Document généré le
<span class="signature-date">{{ signatureDate }}</span> à
<span class="signature-date">{{ signatureTime }}</span>
</p>
</div> </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> </html>

View File

@ -3,7 +3,7 @@ from django.urls import path, re_path
from . import views from . import views
# RF # RF
from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, generate_registration_pdf
# SubClasses # SubClasses
from .views import StudentView, GuardianView, ChildrenListView, StudentListView, DissociateGuardianView from .views import StudentView, GuardianView, ChildrenListView, StudentListView, DissociateGuardianView
# Files # Files
@ -30,6 +30,7 @@ from .views import (
) )
urlpatterns = [ 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]+)/archive$', archive, name="archive"),
re_path(r'^registerForms/(?P<id>[0-9]+)/resend$', resend, name="resend"), re_path(r'^registerForms/(?P<id>[0-9]+)/resend$', resend, name="resend"),
re_path(r'^registerForms/(?P<id>[0-9]+)/send$', send, name="send"), re_path(r'^registerForms/(?P<id>[0-9]+)/send$', send, name="send"),

View File

@ -8,18 +8,22 @@ from N3wtSchool import renderers
from N3wtSchool import bdd from N3wtSchool import bdd
from io import BytesIO from io import BytesIO
import base64
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4 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.base import ContentFile
from django.core.files import File from django.core.files import File
from pathlib import Path from pathlib import Path
import os import os
from enum import Enum from enum import Enum
from urllib.parse import unquote_to_bytes
import random import random
import string import string
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from PyPDF2 import PdfMerger, PdfReader from PyPDF2 import PdfMerger, PdfReader, PdfWriter
from PyPDF2.errors import PdfReadError from PyPDF2.errors import PdfReadError
import shutil import shutil
@ -29,9 +33,79 @@ import json
from django.http import QueryDict from django.http import QueryDict
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from svglib.svglib import svg2rlg
logger = logging.getLogger(__name__) 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): 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. 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 # Sauvegarder le nouveau fichier
file_field.save(filename, content, save=save) 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): def build_payload_from_request(request):
""" """
Normalise la request en payload prêt à être donné au serializer. 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] base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
slug = f"{base_slug}_{register_form.pk}_{m.pk}" 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 file_name = None
if m.file and hasattr(m.file, 'name') and m.file.name: if m.file and hasattr(m.file, 'name') and m.file.name:
file_name = os.path.basename(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}") logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
file_name = None 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 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: 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 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 master_file_changed = template_file_name != file_name
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé --- # --- 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) 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 continue
# Sinon, création du template comme avant # Sinon, création du template — sauvegarder d'abord pour obtenir un pk
tmpl = RegistrationSchoolFileTemplate( tmpl = RegistrationSchoolFileTemplate(
master=m, master=m,
registration_form=register_form, registration_form=register_form,
@ -262,8 +459,10 @@ def create_templates_for_registration_form(register_form):
formTemplateData=m.formMasterData or [], formTemplateData=m.formMasterData or [],
slug=slug, slug=slug,
) )
tmpl.save() # pk attribué ici
if file_name: 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): if master_file_path and not os.path.exists(abs_path):
try: try:
import shutil import shutil
@ -453,6 +652,8 @@ def rfToPDF(registerForm, filename):
'signatureDate': convertToStr(_now(), '%d-%m-%Y'), 'signatureDate': convertToStr(_now(), '%d-%m-%Y'),
'signatureTime': convertToStr(_now(), '%H:%M'), 'signatureTime': convertToStr(_now(), '%H:%M'),
'student': registerForm.student, 'student': registerForm.student,
'establishment': registerForm.establishment,
'school_year': registerForm.school_year,
} }
# Générer le PDF # Générer le PDF
@ -474,6 +675,24 @@ def rfToPDF(registerForm, filename):
return registerForm.registration_file 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): def delete_registration_files(registerForm):
""" """
Supprime le fichier et le dossier associés à un RegistrationForm. Supprime le fichier et le dossier associés à un RegistrationForm.
@ -527,55 +746,196 @@ def getHistoricalYears(count=5):
return historical_years 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) Génère un PDF composite du formulaire dynamique:
et l'associe au RegistrationSchoolFileTemplate. - le document source uploadé (PDF/image) si présent,
Le PDF contient le titre, les labels et types de champs. - puis un rendu du formulaire (similaire à l'aperçu),
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier. - 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(" ", "_") form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
filename = f"{form_name}.pdf" filename = f"{form_name}.pdf"
fields = form_json.get("fields", []) if isinstance(form_json, dict) else []
# Générer le PDF # Compatibilité ascendante : charger depuis un chemin si nécessaire
buffer = BytesIO() if base_pdf_content is None and base_pdf_path and os.path.exists(base_pdf_path):
c = canvas.Canvas(buffer, pagesize=A4) 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 y = 800
# Titre c.setFont("Helvetica-Bold", 18)
c.setFont("Helvetica-Bold", 20) c.drawString(60, y, form_json.get("title", "Formulaire"))
c.drawString(100, y, form_json.get("title", "Formulaire")) y -= 35
y -= 40
# Champs c.setFont("Helvetica", 11)
c.setFont("Helvetica", 12) for field in fields_to_render:
fields = form_json.get("fields", [])
for field in fields:
label = field.get("label", field.get("id", ""))
ftype = field.get("type", "") 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", "") value = field.get("value", "")
# Afficher la valeur si elle existe
if value not in (None, ""): if ftype == "file":
c.drawString(100, y, f"{label} [{ftype}] : {value}") c.drawString(60, y, f"{label}")
else: y -= 18
c.drawString(100, y, f"{label} [{ftype}]")
y -= 25 if source_is_image and source_image_reader and source_image_size:
if y < 100: 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() c.showPage()
y = 800 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() c.save()
buffer.seek(0) layout_buffer.seek(0)
pdf_content = buffer.read() 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) # 4) Fallback minimal si aucune page n'a été créée
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name: if len(writer.pages) == 0:
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/')) fallback = BytesIO()
if os.path.exists(existing_file_path): c_fb = canvas.Canvas(fallback, pagesize=A4)
os.remove(existing_file_path) c_fb.setFont("Helvetica-Bold", 16)
register_form.registration_file.delete(save=False) 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 out = BytesIO()
return ContentFile(pdf_content, name=os.path.basename(filename)) writer.write(out)
out.seek(0)
return ContentFile(out.read(), name=os.path.basename(filename))

View File

@ -5,7 +5,8 @@ from .register_form_views import (
resend, resend,
archive, archive,
get_school_file_templates_by_rf, 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 ( from .registration_school_file_masters_views import (
RegistrationSchoolFileMasterView, RegistrationSchoolFileMasterView,
@ -48,6 +49,7 @@ __all__ = [
'get_registration_files_by_group', 'get_registration_files_by_group',
'get_school_file_templates_by_rf', 'get_school_file_templates_by_rf',
'get_parent_file_templates_by_rf', 'get_parent_file_templates_by_rf',
'generate_registration_pdf',
'StudentView', 'StudentView',
'StudentListView', 'StudentListView',
'ChildrenListView', 'ChildrenListView',

View File

@ -1,4 +1,5 @@
from django.http.response import JsonResponse from django.http.response import JsonResponse
from django.http import HttpResponse
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from rest_framework.views import APIView from rest_framework.views import APIView
@ -411,6 +412,17 @@ class RegisterFormWithIdView(APIView):
# Initialisation de la liste des fichiers à fusionner # Initialisation de la liste des fichiers à fusionner
fileNames = [] 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 # Ajout du fichier registration_file en première position
if registerForm.registration_file: if registerForm.registration_file:
fileNames.append(registerForm.registration_file.path) 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) return JsonResponse(serializer.data, safe=False)
except RegistrationParentFileTemplate.DoesNotExist: except RegistrationParentFileTemplate.DoesNotExist:
return JsonResponse({'error': 'Aucune pièce à fournir trouvée pour ce dossier d\'inscription'}, status=status.HTTP_404_NOT_FOUND) 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

View File

@ -58,6 +58,8 @@ class RegistrationParentFileTemplateView(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileTemplateSimpleView(APIView): class RegistrationParentFileTemplateSimpleView(APIView):
parser_classes = [MultiPartParser, FormParser, JSONParser]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Récupère un template d'inscription spécifique", operation_description="Récupère un template d'inscription spécifique",
responses={ responses={
@ -82,11 +84,15 @@ class RegistrationParentFileTemplateSimpleView(APIView):
} }
) )
def put(self, request, id): 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) template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None: if template is None:
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) 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(): if serializer.is_valid():
serializer.save() serializer.save()
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK) return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)

View File

@ -125,6 +125,25 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
if resp: if resp:
return 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}") logger.info(f"payload for update serializer: {payload}")
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True) serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)

View File

@ -16,6 +16,20 @@ import Subscriptions.util as util
logger = logging.getLogger(__name__) 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): class RegistrationSchoolFileTemplateView(APIView):
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Récupère tous les templates d'inscription pour un établissement donné", operation_description="Récupère tous les templates d'inscription pour un établissement donné",
@ -95,15 +109,26 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
responses = None responses = None
if "responses" in formTemplateData: if "responses" in formTemplateData:
resp = formTemplateData["responses"] resp = formTemplateData["responses"]
if isinstance(resp, dict) and "responses" in resp: responses = _extract_nested_responses(resp)
responses = resp["responses"]
elif isinstance(resp, dict): # Nettoyer les meta-cles qui ne sont pas des reponses de champs
responses = resp 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: if responses and "fields" in formTemplateData:
for field in formTemplateData["fields"]: for field in formTemplateData["fields"]:
field_id = field.get("id") field_id = field.get("id")
if field_id and field_id in responses: if field_id and field_id in responses:
field["value"] = responses[field_id] 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 payload['formTemplateData'] = formTemplateData
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id) 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) return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
# Cas 2 : Formulaire dynamique (JSON) # Cas 2 : Formulaire dynamique (JSON)
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload) serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload, partial=True)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
# Régénérer le PDF si besoin # Régénérer le PDF si besoin
@ -148,19 +173,50 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
and formTemplateData.get("fields") and formTemplateData.get("fields")
and hasattr(template, "file") and hasattr(template, "file")
): ):
old_pdf_name = None # Lire le contenu du fichier source en mémoire AVANT suppression.
if template.file and template.file.name: # Priorité au fichier master (document source admin) pour éviter
old_pdf_name = os.path.basename(template.file.name) # 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: 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) template.file.delete(save=False)
if os.path.exists(template.file.path): if os.path.exists(old_path):
os.remove(template.file.path) os.remove(old_path)
except Exception as e: except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier existant: {e}") logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
from Subscriptions.util import generate_form_json_pdf from Subscriptions.util import generate_form_json_pdf
pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData) pdf_file = generate_form_json_pdf(
pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf" template.registration_form,
template.file.save(pdf_filename, pdf_file, save=True) 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({'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) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@ -176,12 +176,42 @@ class StudentCompetencyListCreateView(APIView):
if domaine_dict["categories"]: if domaine_dict["categories"]:
result.append(domaine_dict) 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 = { context = {
"student": { "student": {
"first_name": student.first_name, "first_name": student.first_name,
"last_name": student.last_name, "last_name": student.last_name,
"level": student.level, "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, "period": period,
"date": date.today().strftime("%d/%m/%Y"), "date": date.today().strftime("%d/%m/%Y"),

View File

@ -54,6 +54,12 @@ class StudentListView(APIView):
description="ID de l'établissement", description="ID de l'établissement",
type=openapi.TYPE_INTEGER, type=openapi.TYPE_INTEGER,
required=True 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): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
status_filter = request.GET.get('status', None) # Nouveau filtre optionnel 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: if establishment_id is None:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) 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: if status_filter:
students_qs = students_qs.filter(registrationform__status=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_qs = students_qs.distinct()
students_serializer = StudentByRFCreationSerializer(students_qs, many=True) students_serializer = StudentByRFCreationSerializer(students_qs, many=True)
return JsonResponse(students_serializer.data, safe=False) return JsonResponse(students_serializer.data, safe=False)

View File

@ -1 +1 @@
__version__ = "0.0.3" __version__ = "1.0.0"

View File

@ -11,9 +11,9 @@ def run_command(command):
print(f"stderr: {stderr.decode()}") print(f"stderr: {stderr.decode()}")
return process.returncode return process.returncode
test_mode = os.getenv('test_mode', 'false').lower() == 'true' test_mode = os.getenv('TEST_MODE', 'false').lower() == 'true'
flush_data = os.getenv('flush_data', 'false').lower() == 'true' flush_data = os.getenv('FLUSH_DATA', 'false').lower() == 'true'
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true' migrate_data = os.getenv('MIGRATE_DATA', 'false').lower() == 'true'
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true' watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
collect_static_cmd = [ collect_static_cmd = [
@ -61,12 +61,6 @@ if __name__ == "__main__":
if run_command(command) != 0: if run_command(command) != 0:
exit(1) exit(1)
if flush_data:
for command in flush_data_cmd:
if run_command(command) != 0:
exit(1)
for command in migrate_commands: for command in migrate_commands:
if run_command(command) != 0: if run_command(command) != 0:
exit(1) exit(1)
@ -75,6 +69,11 @@ if __name__ == "__main__":
if run_command(command) != 0: if run_command(command) != 0:
exit(1) exit(1)
if flush_data:
for command in flush_data_cmd:
if run_command(command) != 0:
exit(1)
if test_mode: if test_mode:
for test_command in test_commands: for test_command in test_commands:
if run_command(test_command) != 0: if run_command(test_command) != 0:

View 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

View File

@ -2,6 +2,76 @@
Toutes les modifications notables apportées à ce projet seront documentées dans ce fichier. Toutes les modifications notables apportées à ce projet seront documentées dans ce fichier.
### [1.0.0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/compare/0.0.3...1.0.0) (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) ### [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
View 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/`

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

View File

@ -7,5 +7,6 @@
"educational_monitoring": "Educational Monitoring", "educational_monitoring": "Educational Monitoring",
"settings": "Settings", "settings": "Settings",
"schoolAdmin": "School Administration", "schoolAdmin": "School Administration",
"messagerie": "Messenger" "messagerie": "Messenger",
"feedback": "Feedback"
} }

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

View File

@ -7,5 +7,6 @@
"educational_monitoring": "Suivi pédagogique", "educational_monitoring": "Suivi pédagogique",
"settings": "Paramètres", "settings": "Paramètres",
"schoolAdmin": "Administration Scolaire", "schoolAdmin": "Administration Scolaire",
"messagerie": "Messagerie" "messagerie": "Messagerie",
"feedback": "Feedback"
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "n3wt-school-front-end", "name": "n3wt-school-front-end",
"version": "0.0.3", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@ -3,16 +3,19 @@ import Logo from '../components/Logo';
export default function Custom500() { export default function Custom500() {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-emerald-500"> <div className="flex items-center justify-center min-h-screen bg-primary">
<div className="text-center p-6 "> <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" /> <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 500 | Erreur interne
</h2> </h2>
<p className="text-emerald-900 mb-4"> <p className="font-body text-gray-600 mb-4">
Une erreur interne est survenue. Une erreur interne est survenue.
</p> </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 Retour Accueil
</Link> </Link>
</div> </div>

View File

@ -2,8 +2,17 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { PARENT_FILTER, SCHOOL_FILTER } from '@/utils/constants'; 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 Table from '@/components/Table';
import EmptyState from '@/components/EmptyState';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import StatusLabel from '@/components/StatusLabel'; import StatusLabel from '@/components/StatusLabel';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'; import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
@ -17,7 +26,6 @@ import { dissociateGuardian } from '@/app/actions/subscriptionAction';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'; import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import AlertMessage from '@/components/AlertMessage';
const roleTypeToLabel = (roleType) => { const roleTypeToLabel = (roleType) => {
switch (roleType) { switch (roleType) {
@ -39,7 +47,7 @@ const roleTypeToBadgeClass = (roleType) => {
case 1: case 1:
return 'bg-red-100 text-red-600'; return 'bg-red-100 text-red-600';
case 2: case 2:
return 'bg-green-100 text-green-600'; return 'bg-tertiary/10 text-tertiary';
default: default:
return 'bg-gray-100 text-gray-600'; return 'bg-gray-100 text-gray-600';
} }
@ -378,7 +386,7 @@ export default function Page() {
type="button" type="button"
className={ className={
row.is_active row.is_active
? 'text-emerald-500 hover:text-emerald-700' ? 'text-primary hover:text-secondary'
: 'text-orange-500 hover:text-orange-700' : 'text-orange-500 hover:text-orange-700'
} }
onClick={() => handleConfirmActivateProfile(row)} onClick={() => handleConfirmActivateProfile(row)}
@ -474,7 +482,7 @@ export default function Page() {
type="button" type="button"
className={ className={
row.is_active row.is_active
? 'text-emerald-500 hover:text-emerald-700' ? 'text-primary hover:text-secondary'
: 'text-orange-500 hover:text-orange-700' : 'text-orange-500 hover:text-orange-700'
} }
onClick={() => handleConfirmActivateProfile(row)} onClick={() => handleConfirmActivateProfile(row)}
@ -516,10 +524,10 @@ export default function Page() {
totalPages={totalProfilesParentPages} totalPages={totalProfilesParentPages}
onPageChange={handlePageChange} onPageChange={handlePageChange}
emptyMessage={ emptyMessage={
<AlertMessage <EmptyState
type="info" icon={Users}
title="Aucun profil PARENT enregistré" title="Aucun profil parent enregistré"
message="Un profil Parent est ajouté lors de la création d'un nouveau dossier d'inscription." 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} totalPages={totalProfilesSchoolPages}
onPageChange={handlePageChange} onPageChange={handlePageChange}
emptyMessage={ emptyMessage={
<AlertMessage <EmptyState
type="info" icon={UserPlus}
title="Aucun profil ECOLE enregistré" title="Aucun profil école enregistré"
message="Un profil ECOLE est ajouté lors de la création d'un nouvel enseignant." description="Les profils école sont créés automatiquement lors de l'ajout d'un enseignant."
/> />
} }
/> />

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

View File

@ -5,9 +5,11 @@ import SelectChoice from '@/components/Form/SelectChoice';
import Attendance from '@/components/Grades/Attendance'; import Attendance from '@/components/Grades/Attendance';
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle'; import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart'; import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import { EvaluationStudentView } from '@/components/Evaluation';
import Button from '@/components/Form/Button'; import Button from '@/components/Form/Button';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url'; import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { import {
fetchStudents, fetchStudents,
fetchStudentCompetencies, fetchStudentCompetencies,
@ -15,9 +17,14 @@ import {
editAbsences, editAbsences,
deleteAbsences, deleteAbsences,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import {
fetchEvaluations,
fetchStudentEvaluations,
updateStudentEvaluation,
} from '@/app/actions/schoolAction';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import { Award, ArrowLeft } from 'lucide-react'; import { Award, ArrowLeft, BookOpen } from 'lucide-react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
@ -46,6 +53,10 @@ export default function StudentGradesPage() {
const [selectedPeriod, setSelectedPeriod] = useState(null); const [selectedPeriod, setSelectedPeriod] = useState(null);
const [allAbsences, setAllAbsences] = useState([]); const [allAbsences, setAllAbsences] = useState([]);
// Evaluation states
const [evaluations, setEvaluations] = useState([]);
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
const getPeriods = () => { const getPeriods = () => {
if (selectedEstablishmentEvaluationFrequency === 1) { if (selectedEstablishmentEvaluationFrequency === 1) {
return [ return [
@ -135,6 +146,38 @@ export default function StudentGradesPage() {
} }
}, [selectedEstablishmentId]); }, [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(() => { const absences = React.useMemo(() => {
return allAbsences return allAbsences
.filter((a) => a.student === studentId) .filter((a) => a.student === studentId)
@ -152,8 +195,12 @@ export default function StudentGradesPage() {
const handleToggleJustify = (absence) => { const handleToggleJustify = (absence) => {
const newReason = const newReason =
absence.type === 'Absence' absence.type === 'Absence'
? absence.justified ? 2 : 1 ? absence.justified
: absence.justified ? 4 : 3; ? 2
: 1
: absence.justified
? 4
: 3;
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken) editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
.then(() => { .then(() => {
@ -163,7 +210,9 @@ export default function StudentGradesPage() {
) )
); );
}) })
.catch((e) => logger.error('Erreur lors du changement de justification', e)); .catch((e) =>
logger.error('Erreur lors du changement de justification', e)
);
}; };
const handleDeleteAbsence = (absence) => { const handleDeleteAbsence = (absence) => {
@ -176,37 +225,57 @@ export default function StudentGradesPage() {
); );
}; };
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 ( return (
<div className="p-4 md:p-8 space-y-6"> <div className="p-4 md:p-8 space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={() => router.push('/admin/grades')} onClick={() => router.push('/admin/grades')}
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200" 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" aria-label="Retour à la liste"
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
</button> </button>
<h1 className="text-xl font-bold text-gray-800">Suivi pédagogique</h1> <h1 className="font-headline text-xl font-bold text-gray-800">Suivi pédagogique</h1>
</div> </div>
{/* Student profile */} {/* Student profile */}
{student && ( {student && (
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-100 p-4 md:p-6 flex flex-col sm:flex-row items-center sm:items-start gap-4"> <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 ? ( {student.photo ? (
<img <img
src={`${BASE_URL}${student.photo}`} src={getSecureFileUrl(student.photo)}
alt={`${student.first_name} ${student.last_name}`} alt={`${student.first_name} ${student.last_name}`}
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow" 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-emerald-100"> <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.first_name?.[0]}
{student.last_name?.[0]} {student.last_name?.[0]}
</div> </div>
)} )}
<div className="flex-1 text-center sm:text-left"> <div className="flex-1 text-center sm:text-left">
<div className="text-xl font-bold text-emerald-800"> <div className="text-xl font-bold text-secondary">
{student.last_name} {student.first_name} {student.last_name} {student.first_name}
</div> </div>
<div className="text-sm text-gray-600 mt-1"> <div className="text-sm text-gray-600 mt-1">
@ -253,7 +322,7 @@ export default function StudentGradesPage() {
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodString}` `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodString}`
); );
}} }}
className="px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full sm:w-auto" 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" />} icon={<Award className="w-5 h-5" />}
text="Évaluer" text="Évaluer"
title="Évaluer l'élève" title="Évaluer l'élève"
@ -280,6 +349,22 @@ export default function StudentGradesPage() {
<div> <div>
<GradesDomainBarChart studentCompetencies={studentCompetencies} /> <GradesDomainBarChart studentCompetencies={studentCompetencies} />
</div> </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>
</div> </div>
); );

View File

@ -1,45 +1,94 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Award, Eye, Search } from 'lucide-react'; import {
Award,
Eye,
Search,
BarChart2,
X,
Pencil,
Trash2,
Save,
Download,
FileText,
UserPlus,
Users,
} from 'lucide-react';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import Table from '@/components/Table'; import Table from '@/components/Table';
import EmptyState from '@/components/EmptyState';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { import {
BASE_URL,
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL, FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { import {
fetchStudents, fetchStudents,
fetchStudentCompetencies, fetchStudentCompetencies,
fetchAbsences, fetchAbsences,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import {
fetchStudentEvaluations,
updateStudentEvaluation,
deleteStudentEvaluation,
} from '@/app/actions/schoolAction';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import { useCsrfToken } from '@/context/CsrfContext';
import { exportToCSV } from '@/utils/exportCSV';
import SchoolYearFilter from '@/components/SchoolYearFilter';
import {
getCurrentSchoolYear,
getNextSchoolYear,
getHistoricalYears,
} from '@/utils/Date';
import {
CURRENT_YEAR_FILTER,
NEXT_YEAR_FILTER,
HISTORICAL_FILTER,
} from '@/utils/constants';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
function getPeriodString(periodValue, frequency) { function getPeriodString(periodValue, frequency, schoolYear = null) {
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; const year =
const schoolYear = `${year}-${year + 1}`; schoolYear ||
if (frequency === 1) return `T${periodValue}_${schoolYear}`; (() => {
if (frequency === 2) return `S${periodValue}_${schoolYear}`; const y = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
if (frequency === 3) return `A_${schoolYear}`; return `${y}-${y + 1}`;
})();
if (frequency === 1) return `T${periodValue}_${year}`;
if (frequency === 2) return `S${periodValue}_${year}`;
if (frequency === 3) return `A_${year}`;
return ''; return '';
} }
function calcPercent(data) { function calcCompetencyStats(data) {
if (!data?.data) return null; if (!data?.data) return null;
const scores = []; const scores = [];
data.data.forEach((d) => data.data.forEach((d) =>
d.categories.forEach((c) => d.categories.forEach((c) =>
c.competences.forEach((comp) => scores.push(comp.score ?? 0)) c.competences.forEach((comp) => scores.push(comp.score))
) )
); );
if (!scores.length) return null; if (!scores.length) return null;
return Math.round( const total = scores.length;
(scores.filter((s) => s === 3).length / scores.length) * 100 return {
); acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100),
inProgress: Math.round(
(scores.filter((s) => s === 2).length / total) * 100
),
notAcquired: Math.round(
(scores.filter((s) => s === 1).length / total) * 100
),
notEvaluated: Math.round(
(scores.filter((s) => s === null || s === undefined || s === 0).length /
total) *
100
),
};
} }
function getPeriodColumns(frequency) { function getPeriodColumns(frequency) {
@ -58,6 +107,29 @@ function getPeriodColumns(frequency) {
return []; return [];
} }
const COMPETENCY_COLUMNS = [
{
key: 'acquired',
label: 'Acquises',
color: 'bg-primary/10 text-secondary',
},
{
key: 'inProgress',
label: 'En cours',
color: 'bg-yellow-100 text-yellow-700',
},
{
key: 'notAcquired',
label: 'Non acquises',
color: 'bg-red-100 text-red-600',
},
{
key: 'notEvaluated',
label: 'Non évaluées',
color: 'bg-gray-100 text-gray-600',
},
];
function getCurrentPeriodValue(frequency) { function getCurrentPeriodValue(frequency) {
const periods = const periods =
{ {
@ -81,18 +153,19 @@ function getCurrentPeriodValue(frequency) {
return current?.value ?? null; return current?.value ?? null;
} }
function PercentBadge({ value, loading }) { function PercentBadge({ value, loading, color }) {
if (loading) return <span className="text-gray-300 text-xs"></span>; if (loading) return <span className="text-gray-300 text-xs"></span>;
if (value === null) return <span className="text-gray-400 text-xs"></span>; if (value === null) return <span className="text-gray-400 text-xs"></span>;
const color = const badgeColor =
value >= 75 color ||
? 'bg-emerald-100 text-emerald-700' (value >= 75
? 'bg-primary/10 text-secondary'
: value >= 50 : value >= 50
? 'bg-yellow-100 text-yellow-700' ? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-600'; : 'bg-red-100 text-red-600');
return ( return (
<span <span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${color}`} className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${badgeColor}`}
> >
{value}% {value}%
</span> </span>
@ -101,6 +174,7 @@ function PercentBadge({ value, loading }) {
export default function Page() { export default function Page() {
const router = useRouter(); const router = useRouter();
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment(); useEstablishment();
const { getNiveauLabel } = useClasses(); const { getNiveauLabel } = useClasses();
@ -111,17 +185,40 @@ export default function Page() {
const [statsMap, setStatsMap] = useState({}); const [statsMap, setStatsMap] = useState({});
const [statsLoading, setStatsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(false);
const [absencesMap, setAbsencesMap] = useState({}); const [absencesMap, setAbsencesMap] = useState({});
const [gradesModalStudent, setGradesModalStudent] = useState(null);
const [studentEvaluations, setStudentEvaluations] = useState([]);
const [gradesLoading, setGradesLoading] = useState(false);
const [editingEvalId, setEditingEvalId] = useState(null);
const [editScore, setEditScore] = useState('');
const [editAbsent, setEditAbsent] = useState(false);
const periodColumns = getPeriodColumns( // Filtrage par année scolaire
selectedEstablishmentEvaluationFrequency const [activeYearFilter, setActiveYearFilter] = useState(CURRENT_YEAR_FILTER);
const currentSchoolYear = useMemo(() => getCurrentSchoolYear(), []);
const nextSchoolYear = useMemo(() => getNextSchoolYear(), []);
const historicalYears = useMemo(() => getHistoricalYears(5), []);
// Déterminer l'année scolaire sélectionnée
const selectedSchoolYear = useMemo(() => {
if (activeYearFilter === CURRENT_YEAR_FILTER) return currentSchoolYear;
if (activeYearFilter === NEXT_YEAR_FILTER) return nextSchoolYear;
// Pour l'historique, on utilise la première année historique par défaut
// L'utilisateur pourra choisir une année spécifique si nécessaire
return historicalYears[0];
}, [activeYearFilter, currentSchoolYear, nextSchoolYear, historicalYears]);
const periodColumns = useMemo(
() => getPeriodColumns(selectedEstablishmentEvaluationFrequency),
[selectedEstablishmentEvaluationFrequency]
); );
const currentPeriodValue = getCurrentPeriodValue( const currentPeriodValue = getCurrentPeriodValue(
selectedEstablishmentEvaluationFrequency selectedEstablishmentEvaluationFrequency
); );
useEffect(() => { useEffect(() => {
if (!selectedEstablishmentId) return; if (!selectedEstablishmentId) return;
fetchStudents(selectedEstablishmentId, null, 5) fetchStudents(selectedEstablishmentId, null, 5, selectedSchoolYear)
.then((data) => setStudents(data)) .then((data) => setStudents(data))
.catch((error) => logger.error('Error fetching students:', error)); .catch((error) => logger.error('Error fetching students:', error));
@ -136,9 +233,9 @@ export default function Page() {
setAbsencesMap(map); setAbsencesMap(map);
}) })
.catch((error) => logger.error('Error fetching absences:', error)); .catch((error) => logger.error('Error fetching absences:', error));
}, [selectedEstablishmentId]); }, [selectedEstablishmentId, selectedSchoolYear]);
// Fetch stats for all students × all periods // Fetch stats for all students - aggregate all periods
useEffect(() => { useEffect(() => {
if (!students.length || !selectedEstablishmentEvaluationFrequency) return; if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
@ -147,7 +244,11 @@ export default function Page() {
const tasks = students.flatMap((student) => const tasks = students.flatMap((student) =>
periodColumns.map(({ value: periodValue }) => { periodColumns.map(({ value: periodValue }) => {
const periodStr = getPeriodString(periodValue, frequency); const periodStr = getPeriodString(
periodValue,
frequency,
selectedSchoolYear
);
return fetchStudentCompetencies(student.id, periodStr) return fetchStudentCompetencies(student.id, periodStr)
.then((data) => ({ studentId: student.id, periodValue, data })) .then((data) => ({ studentId: student.id, periodValue, data }))
.catch(() => ({ studentId: student.id, periodValue, data: null })); .catch(() => ({ studentId: student.id, periodValue, data: null }));
@ -156,20 +257,55 @@ export default function Page() {
Promise.all(tasks).then((results) => { Promise.all(tasks).then((results) => {
const map = {}; const map = {};
results.forEach(({ studentId, periodValue, data }) => { // Group by student and aggregate all competency scores across periods
if (!map[studentId]) map[studentId] = {}; const studentScores = {};
map[studentId][periodValue] = calcPercent(data); results.forEach(({ studentId, data }) => {
if (!studentScores[studentId]) studentScores[studentId] = [];
if (data?.data) {
data.data.forEach((d) =>
d.categories.forEach((c) =>
c.competences.forEach((comp) =>
studentScores[studentId].push(comp.score)
)
)
);
}
}); });
Object.keys(map).forEach((id) => { // Calculate stats for each student
const vals = Object.values(map[id]).filter((v) => v !== null); Object.keys(studentScores).forEach((studentId) => {
map[id].global = vals.length const scores = studentScores[studentId];
? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length) if (!scores.length) {
: null; map[studentId] = null;
} else {
const total = scores.length;
map[studentId] = {
acquired: Math.round(
(scores.filter((s) => s === 3).length / total) * 100
),
inProgress: Math.round(
(scores.filter((s) => s === 2).length / total) * 100
),
notAcquired: Math.round(
(scores.filter((s) => s === 1).length / total) * 100
),
notEvaluated: Math.round(
(scores.filter((s) => s === null || s === undefined || s === 0)
.length /
total) *
100
),
};
}
}); });
setStatsMap(map); setStatsMap(map);
setStatsLoading(false); setStatsLoading(false);
}); });
}, [students, selectedEstablishmentEvaluationFrequency]); }, [
students,
selectedEstablishmentEvaluationFrequency,
selectedSchoolYear,
periodColumns,
]);
const filteredStudents = students.filter( const filteredStudents = students.filter(
(student) => (student) =>
@ -189,24 +325,185 @@ export default function Page() {
currentPage * ITEMS_PER_PAGE currentPage * ITEMS_PER_PAGE
); );
const openGradesModal = (e, student) => {
e.stopPropagation();
setGradesModalStudent(student);
setGradesLoading(true);
fetchStudentEvaluations(student.id)
.then((data) => {
setStudentEvaluations(data || []);
setGradesLoading(false);
})
.catch((error) => {
logger.error('Error fetching student evaluations:', error);
setStudentEvaluations([]);
setGradesLoading(false);
});
};
const closeGradesModal = () => {
setGradesModalStudent(null);
setStudentEvaluations([]);
setEditingEvalId(null);
};
// Export CSV
const handleExportCSV = () => {
const exportColumns = [
{ key: 'id', label: 'ID' },
{ key: 'last_name', label: 'Nom' },
{ key: 'first_name', label: 'Prénom' },
{ key: 'birth_date', label: 'Date de naissance' },
{
key: 'level',
label: 'Niveau',
transform: (value) => getNiveauLabel(value),
},
{ key: 'associated_class_name', label: 'Classe' },
{
key: 'id',
label: 'Absences',
transform: (value) => absencesMap[value] || 0,
},
];
// Ajouter les colonnes de compétences si les stats sont chargées
COMPETENCY_COLUMNS.forEach(({ key, label }) => {
exportColumns.push({
key: 'id',
label: label,
transform: (value) => {
const stats = statsMap[value];
return stats?.[key] !== undefined ? `${stats[key]}%` : '';
},
});
});
const filename = `suivi_eleves_${selectedSchoolYear}_${new Date().toISOString().split('T')[0]}`;
exportToCSV(filteredStudents, exportColumns, filename);
};
const startEditingEval = (evalItem) => {
setEditingEvalId(evalItem.id);
setEditScore(evalItem.score ?? '');
setEditAbsent(evalItem.is_absent ?? false);
};
const cancelEditingEval = () => {
setEditingEvalId(null);
setEditScore('');
setEditAbsent(false);
};
const handleSaveEval = async (evalItem) => {
try {
await updateStudentEvaluation(
evalItem.id,
{
score: editAbsent
? null
: editScore === ''
? null
: parseFloat(editScore),
is_absent: editAbsent,
},
csrfToken
);
// Update local state
setStudentEvaluations((prev) =>
prev.map((e) =>
e.id === evalItem.id
? {
...e,
score: editAbsent
? null
: editScore === ''
? null
: parseFloat(editScore),
is_absent: editAbsent,
}
: e
)
);
cancelEditingEval();
} catch (error) {
logger.error('Error updating evaluation:', error);
}
};
const handleDeleteEval = async (evalItem) => {
if (!confirm('Supprimer cette note ?')) return;
try {
await deleteStudentEvaluation(evalItem.id, csrfToken);
setStudentEvaluations((prev) => prev.filter((e) => e.id !== evalItem.id));
} catch (error) {
logger.error('Error deleting evaluation:', error);
}
};
// Group evaluations by subject
const groupedBySubject = studentEvaluations.reduce((acc, evalItem) => {
const subjectName = evalItem.speciality_name || 'Sans matière';
const subjectColor = evalItem.speciality_color || '#6B7280';
if (!acc[subjectName]) {
acc[subjectName] = { color: subjectColor, evaluations: [] };
}
acc[subjectName].evaluations.push(evalItem);
return acc;
}, {});
const handleEvaluer = (e, studentId) => { const handleEvaluer = (e, studentId) => {
e.stopPropagation(); e.stopPropagation();
const periodStr = getPeriodString( const periodStr = getPeriodString(
currentPeriodValue, currentPeriodValue,
selectedEstablishmentEvaluationFrequency selectedEstablishmentEvaluationFrequency,
selectedSchoolYear
); );
router.push( router.push(
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}` `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}`
); );
}; };
const getBilanForStudent = (student) => {
const bilans = Array.isArray(student?.bilans) ? student.bilans : [];
if (!bilans.length) return null;
const currentPeriodStr = currentPeriodValue
? getPeriodString(
currentPeriodValue,
selectedEstablishmentEvaluationFrequency,
selectedSchoolYear
)
: null;
if (currentPeriodStr) {
const exact = bilans.find(
(bilan) => bilan?.period === currentPeriodStr && bilan?.file
);
if (exact) return exact;
}
const schoolYearSuffix = `_${selectedSchoolYear}`;
const sameYearBilans = bilans.filter(
(bilan) => bilan?.file && bilan?.period?.endsWith(schoolYearSuffix)
);
if (!sameYearBilans.length) return null;
return [...sameYearBilans].sort(
(a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)
)[0];
};
const columns = [ const columns = [
{ name: 'Photo', transform: () => null }, { name: 'Photo', transform: () => null },
{ name: 'Élève', transform: () => null }, { name: 'Élève', transform: () => null },
{ name: 'Niveau', transform: () => null }, { name: 'Niveau', transform: () => null },
{ name: 'Classe', transform: () => null }, { name: 'Classe', transform: () => null },
...periodColumns.map(({ label }) => ({ name: label, transform: () => null })), ...COMPETENCY_COLUMNS.map(({ label }) => ({
{ name: 'Stat globale', transform: () => null }, name: label,
transform: () => null,
})),
{ name: 'Absences', transform: () => null }, { name: 'Absences', transform: () => null },
{ name: 'Actions', transform: () => null }, { name: 'Actions', transform: () => null },
]; ];
@ -219,13 +516,13 @@ export default function Page() {
<div className="flex justify-center items-center"> <div className="flex justify-center items-center">
{student.photo ? ( {student.photo ? (
<a <a
href={`${BASE_URL}${student.photo}`} href={getSecureFileUrl(student.photo)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<img <img
src={`${BASE_URL}${student.photo}`} src={getSecureFileUrl(student.photo)}
alt={`${student.first_name} ${student.last_name}`} alt={`${student.first_name} ${student.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full" className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/> />
@ -233,7 +530,8 @@ export default function Page() {
) : ( ) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full"> <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"> <span className="text-gray-500 text-sm font-semibold">
{student.first_name?.[0]}{student.last_name?.[0]} {student.first_name?.[0]}
{student.last_name?.[0]}
</span> </span>
</div> </div>
)} )}
@ -252,22 +550,17 @@ export default function Page() {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
router.push(`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`); router.push(
`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`
);
}} }}
className="text-emerald-700 hover:underline font-medium" className="text-secondary hover:underline font-medium"
> >
{student.associated_class_name} {student.associated_class_name}
</button> </button>
) : ( ) : (
student.associated_class_name student.associated_class_name
); );
case 'Stat globale':
return (
<PercentBadge
value={stats.global ?? null}
loading={statsLoading && !('global' in stats)}
/>
);
case 'Absences': case 'Absences':
return absencesMap[student.id] ? ( return absencesMap[student.id] ? (
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600"> <span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600">
@ -277,20 +570,46 @@ export default function Page() {
<span className="text-gray-400 text-xs">0</span> <span className="text-gray-400 text-xs">0</span>
); );
case 'Actions': case 'Actions':
const bilan = getBilanForStudent(student);
return ( return (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{bilan?.file && (
<a
href={getSecureFileUrl(bilan.file)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-cyan-100 text-cyan-700 hover:bg-cyan-200 transition whitespace-nowrap"
title={`Télécharger le bilan de compétences (${bilan.period})`}
>
<FileText size={14} />
Bilan
</a>
)}
<button <button
onClick={(e) => { e.stopPropagation(); router.push(`/admin/grades/${student.id}`); }} onClick={(e) => {
e.stopPropagation();
router.push(`/admin/grades/${student.id}`);
}}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition whitespace-nowrap" className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition whitespace-nowrap"
title="Voir la fiche" title="Voir la fiche"
> >
<Eye size={14} /> <Eye size={14} />
Fiche Fiche
</button> </button>
<button
onClick={(e) => openGradesModal(e, student)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700 hover:bg-blue-200 transition whitespace-nowrap"
title="Voir les notes"
>
<BarChart2 size={14} />
Notes
</button>
<button <button
onClick={(e) => handleEvaluer(e, student.id)} onClick={(e) => handleEvaluer(e, student.id)}
disabled={!currentPeriodValue} disabled={!currentPeriodValue}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-emerald-100 text-emerald-700 hover:bg-emerald-200 transition whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed" className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-primary/10 text-secondary hover:bg-primary/20 transition whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
title="Évaluer" title="Évaluer"
> >
<Award size={14} /> <Award size={14} />
@ -299,12 +618,13 @@ export default function Page() {
</div> </div>
); );
default: { default: {
const col = periodColumns.find((c) => c.label === column); const col = COMPETENCY_COLUMNS.find((c) => c.label === column);
if (col) { if (col) {
return ( return (
<PercentBadge <PercentBadge
value={stats[col.value] ?? null} value={stats?.[col.key] ?? null}
loading={statsLoading && !(col.value in stats)} loading={statsLoading && !stats}
color={col.color}
/> />
); );
} }
@ -320,7 +640,14 @@ export default function Page() {
title="Suivi pédagogique" title="Suivi pédagogique"
description="Suivez le parcours d'un élève" description="Suivez le parcours d'un élève"
/> />
<div className="relative flex-grow max-w-md"> <SchoolYearFilter
activeFilter={activeYearFilter}
onFilterChange={setActiveYearFilter}
showNextYear={true}
showHistorical={true}
/>
<div className="flex justify-between items-center w-full">
<div className="relative flex-grow">
<Search <Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
size={20} size={20}
@ -333,6 +660,15 @@ export default function Page() {
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
<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 ml-4"
title="Exporter en CSV"
>
<Download className="w-4 h-4" />
Exporter
</button>
</div>
<Table <Table
data={pagedStudents} data={pagedStudents}
@ -343,9 +679,309 @@ export default function Page() {
totalPages={totalPages} totalPages={totalPages}
onPageChange={setCurrentPage} onPageChange={setCurrentPage}
emptyMessage={ emptyMessage={
<span className="text-gray-400 text-sm">Aucun élève trouvé</span> students.length === 0 && !searchTerm ? (
<EmptyState
icon={Users}
title="Aucun élève inscrit"
description="Commencez par inscrire des élèves pour suivre leur parcours pédagogique."
actionLabel="Inscrire un élève"
actionIcon={UserPlus}
onAction={() => router.push(FE_ADMIN_SUBSCRIPTIONS_CREATE_URL)}
/>
) : (
<EmptyState
icon={Search}
title="Aucun élève trouvé"
description="Modifiez votre recherche pour trouver un élève."
/>
)
} }
/> />
{/* Modal Notes par matière */}
{gradesModalStudent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
<div>
<h2 className="font-headline text-lg font-semibold text-gray-800">
Notes de {gradesModalStudent.first_name}{' '}
{gradesModalStudent.last_name}
</h2>
<p className="text-sm text-gray-500">
{gradesModalStudent.associated_class_name ||
'Classe non assignée'}
</p>
</div>
<button
onClick={closeGradesModal}
className="p-2 hover:bg-gray-200 rounded-full transition"
>
<X size={20} className="text-gray-500" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{gradesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : Object.keys(groupedBySubject).length === 0 ? (
<div className="text-center py-12 text-gray-400">
Aucune note enregistrée pour cet élève.
</div>
) : (
<div className="space-y-6">
{/* Résumé des moyennes */}
{(() => {
const subjectAverages = Object.entries(groupedBySubject)
.map(([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(
(e) =>
e.score !== null &&
e.score !== undefined &&
!e.is_absent
)
.map((e) => parseFloat(e.score))
.filter((s) => !isNaN(s));
const avg = scores.length
? scores.reduce((sum, s) => sum + s, 0) /
scores.length
: null;
return { subject, color, avg };
})
.filter((s) => s.avg !== null && !isNaN(s.avg));
const overallAvg = subjectAverages.length
? (
subjectAverages.reduce((sum, s) => sum + s.avg, 0) /
subjectAverages.length
).toFixed(1)
: null;
return (
<div className="bg-gradient-to-r from-primary/5 to-blue-50 rounded-lg p-4 border border-primary/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-600">
Résumé
</span>
{overallAvg !== null && (
<span className="text-lg font-bold text-secondary">
Moyenne générale : {overallAvg}/20
</span>
)}
</div>
<div className="flex flex-wrap gap-3">
{subjectAverages.map(({ subject, color, avg }) => (
<div
key={subject}
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-full border shadow-sm"
>
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="text-sm text-gray-700">
{subject}
</span>
<span className="text-sm font-semibold text-gray-800">
{avg.toFixed(1)}
</span>
</div>
))}
</div>
</div>
);
})()}
{Object.entries(groupedBySubject).map(
([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(
(e) =>
e.score !== null &&
e.score !== undefined &&
!e.is_absent
)
.map((e) => parseFloat(e.score))
.filter((s) => !isNaN(s));
const avg = scores.length
? (
scores.reduce((sum, s) => sum + s, 0) /
scores.length
).toFixed(1)
: null;
return (
<div
key={subject}
className="border rounded-lg overflow-hidden"
>
<div
className="flex items-center justify-between px-4 py-3"
style={{ backgroundColor: `${color}20` }}
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="font-semibold text-gray-800">
{subject}
</span>
</div>
{avg !== null && (
<span className="text-sm font-bold text-gray-700">
Moyenne : {avg}
</span>
)}
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 font-medium text-gray-600">
Évaluation
</th>
<th className="text-left px-4 py-2 font-medium text-gray-600">
Période
</th>
<th className="text-right px-4 py-2 font-medium text-gray-600">
Note
</th>
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">
Actions
</th>
</tr>
</thead>
<tbody>
{evaluations.map((evalItem) => {
const isEditing = editingEvalId === evalItem.id;
return (
<tr
key={evalItem.id}
className="border-t hover:bg-gray-50"
>
<td className="px-4 py-2 text-gray-700">
{evalItem.evaluation_name || 'Évaluation'}
</td>
<td className="px-4 py-2 text-gray-500">
{evalItem.period || '—'}
</td>
<td className="px-4 py-2 text-right">
{isEditing ? (
<div className="flex items-center justify-end gap-2">
<label className="flex items-center gap-1 text-xs text-gray-600">
<input
type="checkbox"
checked={editAbsent}
onChange={(e) => {
setEditAbsent(e.target.checked);
if (e.target.checked)
setEditScore('');
}}
/>
Abs
</label>
{!editAbsent && (
<input
type="number"
value={editScore}
onChange={(e) =>
setEditScore(e.target.value)
}
min="0"
max={evalItem.max_score || 20}
step="0.5"
className="w-16 text-center px-1 py-0.5 border rounded text-sm"
/>
)}
<span className="text-gray-500">
/{evalItem.max_score || 20}
</span>
</div>
) : evalItem.is_absent ? (
<span className="text-orange-500 font-medium">
Absent
</span>
) : evalItem.score !== null ? (
<span className="font-semibold text-gray-800">
{evalItem.score}/
{evalItem.max_score || 20}
</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-2 text-center">
{isEditing ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() =>
handleSaveEval(evalItem)
}
className="p-1 text-primary hover:bg-primary/5 rounded"
title="Enregistrer"
>
<Save size={14} />
</button>
<button
onClick={cancelEditingEval}
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
title="Annuler"
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center justify-center gap-1">
<button
onClick={() =>
startEditingEval(evalItem)
}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() =>
handleDeleteEval(evalItem)
}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-gray-50 flex justify-end">
<button
onClick={closeGradesModal}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -70,7 +70,7 @@ export default function StudentCompetenciesPage() {
'success', 'success',
'Succès' 'Succès'
); );
router.push(`/admin/grades/${studentId}`); router.push('/admin/grades');
}) })
.catch((error) => { .catch((error) => {
showNotification( showNotification(
@ -86,12 +86,12 @@ export default function StudentCompetenciesPage() {
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<button <button
onClick={() => router.push('/admin/grades')} onClick={() => router.push('/admin/grades')}
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200" 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" aria-label="Retour à la fiche élève"
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
</button> </button>
<h1 className="text-xl font-bold text-gray-800">Bilan de compétence</h1> <h1 className="font-headline text-xl font-bold text-gray-800">Bilan de compétence</h1>
</div> </div>
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<form <form

View File

@ -12,6 +12,7 @@ import {
Calendar, Calendar,
Settings, Settings,
MessageSquare, MessageSquare,
MessageCircleHeart,
} from 'lucide-react'; } from 'lucide-react';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -24,6 +25,7 @@ import {
FE_ADMIN_PLANNING_URL, FE_ADMIN_PLANNING_URL,
FE_ADMIN_SETTINGS_URL, FE_ADMIN_SETTINGS_URL,
FE_ADMIN_MESSAGERIE_URL, FE_ADMIN_MESSAGERIE_URL,
FE_ADMIN_FEEDBACK_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
@ -32,12 +34,13 @@ import Footer from '@/components/Footer';
import MobileTopbar from '@/components/MobileTopbar'; import MobileTopbar from '@/components/MobileTopbar';
import { RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useChatConnection } from '@/context/ChatConnectionContext';
export default function Layout({ children }) { export default function Layout({ children }) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { profileRole, establishments, clearContext } = const { profileRole, establishments, clearContext } = useEstablishment();
useEstablishment(); const { totalUnreadCount, resetUnreadCount } = useChatConnection();
const sidebarItems = { const sidebarItems = {
admin: { admin: {
@ -81,6 +84,13 @@ export default function Layout({ children }) {
name: t('messagerie'), name: t('messagerie'),
url: FE_ADMIN_MESSAGERIE_URL, url: FE_ADMIN_MESSAGERIE_URL,
icon: MessageSquare, icon: MessageSquare,
badge: totalUnreadCount,
},
feedback: {
id: 'feedback',
name: t('feedback'),
url: FE_ADMIN_FEEDBACK_URL,
icon: MessageCircleHeart,
}, },
settings: { settings: {
id: 'settings', id: 'settings',
@ -111,7 +121,11 @@ export default function Layout({ children }) {
useEffect(() => { useEffect(() => {
// Fermer la sidebar quand on change de page sur mobile // Fermer la sidebar quand on change de page sur mobile
setIsSidebarOpen(false); 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 // Filtrage dynamique des items de la sidebar selon le rôle
let sidebarItemsToDisplay = Object.values(sidebarItems); let sidebarItemsToDisplay = Object.values(sidebarItems);
@ -150,7 +164,7 @@ export default function Layout({ children }) {
)} )}
{/* Main container */} {/* Main container */}
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md:top-0 bottom-16 left-0 md: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} {children}
</div> </div>

View File

@ -1,17 +1,10 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import SidebarTabs from '@/components/SidebarTabs'; import SidebarTabs from '@/components/SidebarTabs';
import EmailSender from '@/components/Admin/EmailSender';
import InstantMessaging from '@/components/Admin/InstantMessaging'; import InstantMessaging from '@/components/Admin/InstantMessaging';
import logger from '@/utils/logger';
export default function MessageriePage({ csrfToken }) { export default function MessageriePage({ csrfToken }) {
const tabs = [ const tabs = [
{
id: 'email',
label: 'Envoyer un Mail',
content: <EmailSender csrfToken={csrfToken} />,
},
{ {
id: 'instant', id: 'instant',
label: 'Messagerie Instantanée', label: 'Messagerie Instantanée',

View File

@ -174,14 +174,14 @@ export default function DashboardPage() {
<StatCard <StatCard
title={t('pendingRegistrations')} title={t('pendingRegistrations')}
value={pendingRegistrationCount} value={pendingRegistrationCount}
icon={<Clock className="text-green-500" size={24} />} icon={<Clock className="text-tertiary" size={24} />}
color="green" color="tertiary"
/> />
<StatCard <StatCard
title={t('structureCapacity')} title={t('structureCapacity')}
value={selectedEstablishmentTotalCapacity} value={selectedEstablishmentTotalCapacity}
icon={<School className="text-green-500" size={24} />} icon={<School className="text-primary" size={24} />}
color="emerald" color="primary"
/> />
<StatCard <StatCard
title={t('capacityRate')} title={t('capacityRate')}
@ -200,8 +200,8 @@ export default function DashboardPage() {
{/* Colonne de gauche : Graphique des inscriptions + Présence */} {/* Colonne de gauche : Graphique des inscriptions + Présence */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Graphique des inscriptions */} {/* Graphique des inscriptions */}
<div className="bg-stone-50 p-4 md: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">
<h2 className="text-lg font-semibold mb-4 md:mb-6"> <h2 className="font-headline text-lg font-semibold mb-4 md:mb-6">
{t('inscriptionTrends')} {t('inscriptionTrends')}
</h2> </h2>
<div className="flex flex-col sm:flex-row gap-6 mt-4"> <div className="flex flex-col sm:flex-row gap-6 mt-4">
@ -214,14 +214,14 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Présence et assiduité */} {/* Présence et assiduité */}
<div className="bg-stone-50 p-4 md: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} /> <Attendance absences={absencesToday} readOnly={true} />
</div> </div>
</div> </div>
{/* Colonne de droite : Événements à venir */} {/* Colonne de droite : Événements à venir */}
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full"> <div className="bg-neutral p-4 md:p-6 rounded-md shadow-sm border border-gray-100 flex-1 h-full">
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2> <h2 className="font-headline text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
{upcomingEvents.map((event, index) => ( {upcomingEvents.map((event, index) => (
<EventCard key={index} {...event} /> <EventCard key={index} {...event} />
))} ))}

View File

@ -9,6 +9,7 @@ import EventModal from '@/components/Calendar/EventModal';
import ScheduleNavigation from '@/components/Calendar/ScheduleNavigation'; import ScheduleNavigation from '@/components/Calendar/ScheduleNavigation';
import { useState } from 'react'; import { useState } from 'react';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { usePlanning } from '@/context/PlanningContext';
export default function Page() { export default function Page() {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@ -29,17 +30,24 @@ export default function Page() {
}); });
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const PlanningContent = ({ isDrawerOpen, setIsDrawerOpen, isModalOpen, setIsModalOpen, eventData, setEventData }) => {
const { selectedSchedule, schedules } = usePlanning();
const initializeNewEvent = (date = new Date()) => { const initializeNewEvent = (date = new Date()) => {
// S'assurer que date est un objet Date valide
const eventDate = date instanceof Date ? date : new Date(); const eventDate = date instanceof Date ? date : new Date();
const selected =
schedules.find((schedule) => Number(schedule.id) === Number(selectedSchedule)) ||
schedules[0];
setEventData({ setEventData({
title: '', title: '',
description: '', description: '',
start: eventDate.toISOString(), start: eventDate.toISOString(),
end: new Date(eventDate.getTime() + 2 * 60 * 60 * 1000).toISOString(), end: new Date(eventDate.getTime() + 2 * 60 * 60 * 1000).toISOString(),
location: '', location: '',
planning: '', // Ne pas définir de valeur par défaut ici non plus planning: selected?.id || '',
color: selected?.color || '',
recursionType: RecurrenceType.NONE, recursionType: RecurrenceType.NONE,
selectedDays: [], selectedDays: [],
recursionEnd: new Date( recursionEnd: new Date(
@ -52,10 +60,6 @@ export default function Page() {
}; };
return ( return (
<PlanningProvider
establishmentId={selectedEstablishmentId}
modeSet={PlanningModes.PLANNING}
>
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
<ScheduleNavigation <ScheduleNavigation
isOpen={isDrawerOpen} isOpen={isDrawerOpen}
@ -76,6 +80,22 @@ export default function Page() {
setEventData={setEventData} setEventData={setEventData}
/> />
</div> </div>
);
};
return (
<PlanningProvider
establishmentId={selectedEstablishmentId}
modeSet={PlanningModes.PLANNING}
>
<PlanningContent
isDrawerOpen={isDrawerOpen}
setIsDrawerOpen={setIsDrawerOpen}
isModalOpen={isModalOpen}
setIsModalOpen={setIsModalOpen}
eventData={eventData}
setEventData={setEventData}
/>
</PlanningProvider> </PlanningProvider>
); );
} }

View File

@ -1,7 +1,5 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Tab from '@/components/Tab';
import TabContent from '@/components/TabContent';
import Button from '@/components/Form/Button'; import Button from '@/components/Form/Button';
import InputText from '@/components/Form/InputText'; import InputText from '@/components/Form/InputText';
import CheckBox from '@/components/Form/CheckBox'; // Import du composant CheckBox import CheckBox from '@/components/Form/CheckBox'; // Import du composant CheckBox
@ -13,13 +11,8 @@ import {
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useCsrfToken } from '@/context/CsrfContext'; // Import du hook pour récupérer le csrfToken import { useCsrfToken } from '@/context/CsrfContext'; // Import du hook pour récupérer le csrfToken
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import { useSearchParams } from 'next/navigation'; // Ajoute cet import
export default function SettingsPage() { 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 [smtpServer, setSmtpServer] = useState('');
const [smtpPort, setSmtpPort] = useState(''); const [smtpPort, setSmtpPort] = useState('');
const [smtpUser, setSmtpUser] = useState(''); const [smtpUser, setSmtpUser] = useState('');
@ -29,23 +22,10 @@ export default function SettingsPage() {
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken(); // Récupération du csrfToken const csrfToken = useCsrfToken(); // Récupération du csrfToken
const { showNotification } = useNotification(); 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 // Charger les paramètres SMTP existants
useEffect(() => { useEffect(() => {
if (activeTab === 'smtp') { if (csrfToken && selectedEstablishmentId) {
fetchSmtpSettings(csrfToken, selectedEstablishmentId) // Passer le csrfToken ici fetchSmtpSettings(csrfToken, selectedEstablishmentId) // Passer le csrfToken ici
.then((data) => { .then((data) => {
setSmtpServer(data.smtp_server || ''); setSmtpServer(data.smtp_server || '');
@ -75,7 +55,7 @@ export default function SettingsPage() {
} }
}); });
} }
}, [activeTab, csrfToken]); // Ajouter csrfToken comme dépendance }, [csrfToken, selectedEstablishmentId]);
const handleSmtpServerChange = (e) => { const handleSmtpServerChange = (e) => {
setSmtpServer(e.target.value); setSmtpServer(e.target.value);
@ -128,16 +108,14 @@ export default function SettingsPage() {
}; };
return ( return (
<div className="p-8"> <div className="p-6">
<div className="flex space-x-4 mb-4"> <h1 className="font-headline text-2xl font-bold text-gray-900 mb-6">
<Tab Paramètres
text="Paramètres SMTP" </h1>
active={activeTab === 'smtp'} <div className="bg-white rounded-md border border-gray-200 shadow-sm p-6">
onClick={() => handleTabClick('smtp')} <h2 className="font-headline text-lg font-semibold text-gray-800 mb-4">
/> Paramètres SMTP
</div> </h2>
<div className="mt-4">
<TabContent isActive={activeTab === 'smtp'}>
<form onSubmit={handleSmtpSubmit}> <form onSubmit={handleSmtpSubmit}>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InputText <InputText
@ -187,7 +165,6 @@ export default function SettingsPage() {
className="mt-6" className="mt-6"
></Button> ></Button>
</form> </form>
</TabContent>
</div> </div>
</div> </div>
); );

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

View File

@ -1,10 +1,28 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; 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 Table from '@/components/Table';
import Popup from '@/components/Popup'; 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 { useSearchParams } from 'next/navigation';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
@ -17,10 +35,16 @@ import {
editAbsences, editAbsences,
deleteAbsences, deleteAbsences,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import {
EvaluationForm,
EvaluationList,
EvaluationGradeTable,
} from '@/components/Evaluation';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import dayjs from 'dayjs';
export default function Page() { export default function Page() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -38,8 +62,54 @@ export default function Page() {
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement 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 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 // AbsenceMoment constants
const AbsenceMoment = { const AbsenceMoment = {
@ -158,6 +228,119 @@ export default function Page() {
} }
}, [filteredStudents, fetchedAbsences]); }, [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) => { const handleLevelClick = (label) => {
setSelectedLevels( setSelectedLevels(
(prev) => (prev) =>
@ -413,14 +596,16 @@ export default function Page() {
return ( return (
<div className="p-6 space-y-6"> <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 */} {/* Section Niveaux et Enseignants */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Section Niveaux */} {/* Section Niveaux */}
<div className="bg-white p-4 rounded-lg shadow-md"> <div className="bg-white p-4 rounded-md shadow-sm">
<h2 className="text-xl font-semibold mb-4 flex items-center"> <h2 className="font-headline text-xl font-semibold mb-4 flex items-center">
<Layers className="w-6 h-6 mr-2" /> <Layers className="w-6 h-6 mr-2 text-primary" />
Niveaux Niveaux
</h2> </h2>
<p className="text-sm text-gray-500 mb-4"> <p className="text-sm text-gray-500 mb-4">
@ -429,24 +614,24 @@ export default function Page() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{classe?.levels?.length > 0 ? ( {classe?.levels?.length > 0 ? (
getNiveauxLabels(classe.levels).map((label, index) => ( getNiveauxLabels(classe.levels).map((label, index) => (
<span <button
key={index} key={index}
onClick={() => handleLevelClick(label)} // Gérer le clic sur un niveau onClick={() => handleLevelClick(label)}
className={`px-4 py-2 rounded-full cursor-pointer border transition-all duration-200 ${ className={`px-4 py-2 rounded font-label font-medium cursor-pointer border transition-colors min-h-[44px] ${
selectedLevels.includes(label) 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' : 'bg-gray-200 text-gray-800 border-gray-300 hover:bg-gray-300'
}`} }`}
> >
{selectedLevels.includes(label) ? ( {selectedLevels.includes(label) ? (
<span className="flex items-center gap-2"> <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} {label}
</span> </span>
) : ( ) : (
label label
)} )}
</span> </button>
)) ))
) : ( ) : (
<span className="text-gray-500">Aucun niveau associé</span> <span className="text-gray-500">Aucun niveau associé</span>
@ -455,9 +640,9 @@ export default function Page() {
</div> </div>
{/* Section Enseignants */} {/* Section Enseignants */}
<div className="bg-white p-4 rounded-lg shadow-md"> <div className="bg-white p-4 rounded-md shadow-sm">
<h2 className="text-xl font-semibold mb-4 flex items-center"> <h2 className="font-headline text-xl font-semibold mb-4 flex items-center">
<Users className="w-6 h-6 mr-2" /> <Users className="w-6 h-6 mr-2 text-primary" />
Enseignants Enseignants
</h2> </h2>
<p className="text-sm text-gray-500 mb-4">Liste des enseignants</p> <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) => ( {classe?.teachers_details?.map((teacher) => (
<span <span
key={teacher.id} 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} {teacher.last_name} {teacher.first_name}
</span> </span>
@ -474,15 +659,50 @@ export default function Page() {
</div> </div>
</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 */} {/* 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 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" /> <Clock className="w-6 h-6" />
</div> </div>
<h2 className="text-lg font-semibold text-gray-800"> <h2 className="font-headline text-lg font-semibold text-gray-800">
Appel du jour :{' '} Appel du jour :{' '}
<span className="ml-2 text-emerald-600">{today}</span> <span className="ml-2 text-primary">{today}</span>
</h2> </h2>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
@ -491,14 +711,14 @@ export default function Page() {
text="Faire l'appel" text="Faire l'appel"
onClick={handleToggleAttendanceMode} onClick={handleToggleAttendanceMode}
primary 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 <Button
text="Valider l'appel" text="Valider l'appel"
onClick={handleValidateAttendance} onClick={handleValidateAttendance}
primary 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> </div>
@ -537,7 +757,9 @@ export default function Page() {
<CheckBox <CheckBox
item={{ id: row.id }} item={{ id: row.id }}
formData={{ formData={{
attendance: attendance[row.id] ? [row.id] : [], attendance: attendance[row.id]
? [row.id]
: [],
}} }}
handleChange={() => handleChange={() =>
handleAttendanceChange(row.id) handleAttendanceChange(row.id)
@ -568,10 +790,10 @@ export default function Page() {
{/* Détails absence/retard */} {/* Détails absence/retard */}
{!attendance[row.id] && ( {!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"> <div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-emerald-500" /> <Clock className="w-4 h-4 text-primary" />
<span className="font-semibold text-emerald-700 text-sm"> <span className="font-semibold text-secondary text-sm">
Motif d&apos;absence Motif d&apos;absence
</span> </span>
</div> </div>
@ -625,7 +847,9 @@ export default function Page() {
type="text" type="text"
className="border rounded px-2 py-1 text-sm w-full" className="border rounded px-2 py-1 text-sm w-full"
placeholder="Commentaire" placeholder="Commentaire"
value={formAbsences[row.id]?.commentaire || ''} value={
formAbsences[row.id]?.commentaire || ''
}
onChange={(e) => onChange={(e) =>
setFormAbsences((prev) => ({ setFormAbsences((prev) => ({
...prev, ...prev,
@ -642,7 +866,8 @@ export default function Page() {
<CheckBox <CheckBox
item={{ id: `justified-${row.id}` }} item={{ id: `justified-${row.id}` }}
formData={{ formData={{
justified: !!formAbsences[row.id]?.justified, justified:
!!formAbsences[row.id]?.justified,
}} }}
handleChange={() => handleChange={() =>
setFormAbsences((prev) => ({ setFormAbsences((prev) => ({
@ -673,7 +898,8 @@ export default function Page() {
formAbsences[row.id] || formAbsences[row.id] ||
Object.values(fetchedAbsences).find( Object.values(fetchedAbsences).find(
(absence) => (absence) =>
absence.student === row.id && absence.day === today absence.student === row.id &&
absence.day === today
); );
if (!absence) { if (!absence) {
@ -728,6 +954,90 @@ export default function Page() {
]} ]}
data={filteredStudents} // Utiliser les élèves filtrés 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 */}
<Popup <Popup

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { getCurrentSchoolYear } from '@/utils/Date';
import StructureManagement from '@/components/Structure/Configuration/StructureManagement'; import StructureManagement from '@/components/Structure/Configuration/StructureManagement';
import ScheduleManagement from '@/components/Structure/Planning/ScheduleManagement'; import ScheduleManagement from '@/components/Structure/Planning/ScheduleManagement';
@ -54,6 +55,13 @@ export default function Page() {
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId, profileRole } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
const currentSchoolYear = getCurrentSchoolYear();
const scheduleClasses = classes.filter(
(classe) => classe?.school_year === currentSchoolYear
);
const scheduleSpecialities = specialities;
const scheduleTeachers = teachers;
useEffect(() => { useEffect(() => {
if (selectedEstablishmentId) { if (selectedEstablishmentId) {
@ -299,9 +307,9 @@ export default function Page() {
<ClassesProvider> <ClassesProvider>
<ScheduleManagement <ScheduleManagement
handleUpdatePlanning={handleUpdatePlanning} handleUpdatePlanning={handleUpdatePlanning}
classes={classes} classes={scheduleClasses}
specialities={specialities} specialities={scheduleSpecialities}
teachers={teachers} teachers={scheduleTeachers}
/> />
</ClassesProvider> </ClassesProvider>
</div> </div>

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState, useRef, useEffect } from 'react'; 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 InputTextIcon from '@/components/Form/InputTextIcon';
import ToggleSwitch from '@/components/Form/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import Button from '@/components/Form/Button'; import Button from '@/components/Form/Button';
@ -34,14 +34,32 @@ import {
import { import {
fetchRegistrationFileGroups, fetchRegistrationFileGroups,
fetchRegistrationSchoolFileMasters, fetchRegistrationSchoolFileMasters,
fetchRegistrationParentFileMasters fetchRegistrationParentFileMasters,
} from '@/app/actions/registerFileGroupAction'; } from '@/app/actions/registerFileGroupAction';
import { fetchProfiles } from '@/app/actions/authAction'; import { fetchProfiles } from '@/app/actions/authAction';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import { useCsrfToken } from '@/context/CsrfContext'; 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'; 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() { export default function CreateSubscriptionPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
studentLastName: '', studentLastName: '',
@ -181,7 +199,9 @@ export default function CreateSubscriptionPage() {
formDataRef.current = formData; formDataRef.current = formData;
}, [formData]); }, [formData]);
useEffect(() => { setStudentsPage(1); }, [students]); useEffect(() => {
setStudentsPage(1);
}, [students]);
useEffect(() => { useEffect(() => {
if (!formData.guardianEmail) { if (!formData.guardianEmail) {
@ -714,7 +734,10 @@ export default function CreateSubscriptionPage() {
}; };
const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE); const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE);
const pagedStudents = students.slice((studentsPage - 1) * ITEMS_PER_PAGE, studentsPage * ITEMS_PER_PAGE); const pagedStudents = students.slice(
(studentsPage - 1) * ITEMS_PER_PAGE,
studentsPage * ITEMS_PER_PAGE
);
if (isLoading === true) { if (isLoading === true) {
return <Loader />; // Affichez le composant Loader return <Loader />; // Affichez le composant Loader
@ -723,11 +746,11 @@ export default function CreateSubscriptionPage() {
return ( return (
<div className="mx-auto p-12 space-y-12"> <div className="mx-auto p-12 space-y-12">
{registerFormID ? ( {registerFormID ? (
<h1 className="text-2xl font-bold"> <h1 className="font-headline text-2xl font-bold">
Modifier un dossier d&apos;inscription Modifier un dossier d&apos;inscription
</h1> </h1>
) : ( ) : (
<h1 className="text-2xl font-bold"> <h1 className="font-headline text-2xl font-bold">
Créer un dossier d&apos;inscription Créer un dossier d&apos;inscription
</h1> </h1>
)} )}
@ -884,12 +907,12 @@ export default function CreateSubscriptionPage() {
<div className="flex justify-center items-center"> <div className="flex justify-center items-center">
{row.photo ? ( {row.photo ? (
<a <a
href={`${BASE_URL}${row.photo}`} // Lien vers la photo href={getSecureFileUrl(row.photo)} // Lien vers la photo
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<img <img
src={`${BASE_URL}${row.photo}`} src={getSecureFileUrl(row.photo)}
alt={`${row.first_name} ${row.last_name}`} 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" className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/> />
@ -930,7 +953,7 @@ export default function CreateSubscriptionPage() {
}} }}
rowClassName={(row) => rowClassName={(row) =>
selectedStudent && selectedStudent.id === row.id 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 selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau
@ -942,7 +965,7 @@ export default function CreateSubscriptionPage() {
{selectedStudent && ( {selectedStudent && (
<div className="mt-4"> <div className="mt-4">
<h3 className="font-bold"> <h3 className="font-headline font-bold">
Responsables associés à {selectedStudent.last_name}{' '} Responsables associés à {selectedStudent.last_name}{' '}
{selectedStudent.first_name} : {selectedStudent.first_name} :
</h3> </h3>
@ -997,22 +1020,13 @@ export default function CreateSubscriptionPage() {
} }
/> />
) : ( ) : (
<p <NoInfoAlert message="Aucune réduction n&apos;a été créée sur les frais d&apos;inscription." />
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&apos;a été créée sur les frais
d&apos;inscription.
</span>
</p>
)} )}
</div> </div>
</div> </div>
{/* Montant total */} {/* 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"> <span className="text-sm font-medium text-gray-600">
Montant total des frais d&apos;inscription : Montant total des frais d&apos;inscription :
</span> </span>
@ -1022,15 +1036,7 @@ export default function CreateSubscriptionPage() {
</div> </div>
</> </>
) : ( ) : (
<p <NoInfoAlert message="Aucun frais d&apos;inscription n&apos;a été créé." />
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&apos;inscription n&apos;a été créé.
</span>
</p>
)} )}
</SectionTitle> </SectionTitle>
@ -1061,22 +1067,13 @@ export default function CreateSubscriptionPage() {
handleDiscountSelection={handleTuitionDiscountSelection} handleDiscountSelection={handleTuitionDiscountSelection}
/> />
) : ( ) : (
<p <NoInfoAlert message="Aucune réduction n&apos;a été créée sur les frais de scolarité." />
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&apos;a été créée sur les frais de
scolarité.
</span>
</p>
)} )}
</div> </div>
</div> </div>
{/* Montant total */} {/* 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"> <span className="text-sm font-medium text-gray-600">
Montant total des frais de scolarité : Montant total des frais de scolarité :
</span> </span>
@ -1086,15 +1083,7 @@ export default function CreateSubscriptionPage() {
</div> </div>
</> </>
) : ( ) : (
<p <NoInfoAlert message="Aucun frais de scolarité n&apos;a été créé." />
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&apos;a été créé.
</span>
</p>
)} )}
</SectionTitle> </SectionTitle>
@ -1147,7 +1136,7 @@ export default function CreateSubscriptionPage() {
className={`px-6 py-2 rounded-md shadow ${ className={`px-6 py-2 rounded-md shadow ${
isSubmitDisabled() isSubmitDisabled()
? 'bg-gray-300 text-gray-500 cursor-not-allowed' ? '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 primary
disabled={isSubmitDisabled()} disabled={isSubmitDisabled()}

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Tab from '@/components/Tab'; import SidebarTabs from '@/components/SidebarTabs';
import Textarea from '@/components/Textarea'; import Textarea from '@/components/Textarea';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import StatusLabel from '@/components/StatusLabel'; import StatusLabel from '@/components/StatusLabel';
@ -19,6 +19,7 @@ import {
Upload, Upload,
Eye, Eye,
XCircle, XCircle,
Download,
} from 'lucide-react'; } from 'lucide-react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
@ -36,8 +37,8 @@ import {
FE_ADMIN_SUBSCRIPTIONS_EDIT_URL, FE_ADMIN_SUBSCRIPTIONS_EDIT_URL,
FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL, FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL,
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL, FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
BASE_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'; import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
@ -54,7 +55,9 @@ import {
HISTORICAL_FILTER, HISTORICAL_FILTER,
} from '@/utils/constants'; } from '@/utils/constants';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
import EmptyState from '@/components/EmptyState';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import { exportToCSV } from '@/utils/exportCSV';
export default function Page({ params: { locale } }) { export default function Page({ params: { locale } }) {
const t = useTranslations('subscriptions'); const t = useTranslations('subscriptions');
@ -112,15 +115,29 @@ export default function Page({ params: { locale } }) {
// Valide le refus // Valide le refus
const handleRefuse = () => { const handleRefuse = () => {
if (!refuseReason.trim()) { 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; return;
} }
const formData = new FormData(); 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) editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
.then(() => { .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); setReloadFetch(true);
setIsRefusePopupOpen(false); setIsRefusePopupOpen(false);
}) })
@ -149,6 +166,92 @@ export default function Page({ params: { locale } }) {
setIsFilesModalOpen(true); 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) => { const requestErrorHandler = (err) => {
logger.error('Error fetching data:', err); logger.error('Error fetching data:', err);
}; };
@ -447,7 +550,7 @@ export default function Page({ params: { locale } }) {
{ {
icon: ( icon: (
<span title="Envoyer le dossier"> <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> </span>
), ),
onClick: () => onClick: () =>
@ -477,7 +580,7 @@ export default function Page({ params: { locale } }) {
{ {
icon: ( icon: (
<span title="Renvoyer le dossier"> <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> </span>
), ),
onClick: () => onClick: () =>
@ -516,7 +619,7 @@ export default function Page({ params: { locale } }) {
{ {
icon: ( icon: (
<span title="Valider le dossier"> <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> </span>
), ),
onClick: () => { onClick: () => {
@ -631,7 +734,7 @@ export default function Page({ params: { locale } }) {
{ {
icon: ( icon: (
<span title="Uploader un mandat SEPA"> <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> </span>
), ),
onClick: () => openSepaUploadModal(row), onClick: () => openSepaUploadModal(row),
@ -668,12 +771,12 @@ export default function Page({ params: { locale } }) {
<div className="flex justify-center items-center"> <div className="flex justify-center items-center">
{row.student.photo ? ( {row.student.photo ? (
<a <a
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<img <img
src={`${BASE_URL}${row.student.photo}`} src={getSecureFileUrl(row.student.photo)}
alt={`${row.student.first_name} ${row.student.last_name}`} 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" 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; const getEmptyMessageForTab = (tabFilter) => {
if (activeTab === CURRENT_YEAR_FILTER && searchTerm === '') { if (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 />;
}
return ( return (
<div className="p-8"> <EmptyState
<div className="border-b border-gray-200 mb-6"> icon={Search}
<div className="flex items-center gap-8"> title="Aucun dossier trouvé"
{/* Tab pour l'année scolaire en cours */} description="Modifiez votre recherche pour trouver un dossier d'inscription."
<Tab
text={
<>
{currentSchoolYear}
<span className="ml-2 text-sm text-gray-400">
({totalCurrentYear})
</span>
</>
}
active={activeTab === CURRENT_YEAR_FILTER}
onClick={() => setActiveTab(CURRENT_YEAR_FILTER)}
/> />
);
{/* 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} if (tabFilter === CURRENT_YEAR_FILTER) {
onClick={() => setActiveTab(NEXT_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} if (tabFilter === NEXT_YEAR_FILTER) {
onClick={() => setActiveTab(HISTORICAL_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"> const renderTabContent = (data, currentPage, totalPages, tabFilter) => (
{activeTab === CURRENT_YEAR_FILTER || <div className="p-4">
activeTab === NEXT_YEAR_FILTER ||
activeTab === HISTORICAL_FILTER ? (
<React.Fragment>
<div className="flex justify-between items-center mb-4 w-full"> <div className="flex justify-between items-center mb-4 w-full">
<div className="relative flex-grow"> <div className="relative flex-grow">
<Search <Search
@ -839,53 +903,80 @@ export default function Page({ params: { locale } }) {
onChange={handleSearchChange} onChange={handleSearchChange}
/> />
</div> </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 && ( {profileRole !== 0 && (
<button <button
onClick={() => { onClick={() => router.push(FE_ADMIN_SUBSCRIPTIONS_CREATE_URL)}
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`; className="flex items-center bg-primary text-white p-2 rounded-full shadow hover:bg-secondary transition duration-200"
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"
> >
<Plus className="w-5 h-5" /> <Plus className="w-5 h-5" />
</button> </button>
)} )}
</div> </div>
</div>
<div className="w-full">
<DjangoCSRFToken csrfToken={csrfToken} /> <DjangoCSRFToken csrfToken={csrfToken} />
<Table <Table
key={`${currentSchoolYearPage}-${searchTerm}`} key={`${tabFilter}-${currentPage}-${searchTerm}`}
data={ data={data}
activeTab === CURRENT_YEAR_FILTER
? registrationFormsDataCurrentYear
: activeTab === NEXT_YEAR_FILTER
? registrationFormsDataNextYear
: registrationFormsDataHistorical
}
columns={columns} columns={columns}
itemsPerPage={itemsPerPage} itemsPerPage={itemsPerPage}
currentPage={ currentPage={currentPage}
activeTab === CURRENT_YEAR_FILTER totalPages={totalPages}
? currentSchoolYearPage
: activeTab === NEXT_YEAR_FILTER
? currentSchoolNextYearPage
: currentSchoolHistoricalYearPage
}
totalPages={
activeTab === CURRENT_YEAR_FILTER
? totalCurrentSchoolYearPages
: activeTab === NEXT_YEAR_FILTER
? totalNextSchoolYearPages
: totalHistoricalPages
}
onPageChange={handlePageChange} onPageChange={handlePageChange}
emptyMessage={emptyMessage} emptyMessage={getEmptyMessageForTab(tabFilter)}
/> />
</div> </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 <Popup
isOpen={confirmPopupVisible} isOpen={confirmPopupVisible}
message={confirmPopupMessage} message={confirmPopupMessage}
@ -898,7 +989,9 @@ export default function Page({ params: { locale } }) {
isOpen={isRefusePopupOpen} isOpen={isRefusePopupOpen}
message={ message={
<div> <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 <Textarea
value={refuseReason} value={refuseReason}
onChange={(e) => setRefuseReason(e.target.value)} onChange={(e) => setRefuseReason(e.target.value)}

View File

@ -10,10 +10,10 @@ export default function Home() {
const t = useTranslations('homePage'); const t = useTranslations('homePage');
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen py-2"> <div className="flex flex-col items-center justify-center min-h-screen py-2 bg-neutral">
<Logo className="mb-4" /> {/* Ajout du logo */} <Logo className="mb-4" />
<h1 className="text-4xl font-bold mb-4">{t('welcomeParents')}</h1> <h1 className="font-headline text-4xl font-bold mb-4">{t('welcomeParents')}</h1>
<p className="text-lg mb-8">{t('pleaseLogin')}</p> <p className="font-body text-lg mb-8">{t('pleaseLogin')}</p>
<Button text={t('loginButton')} primary href="/users/login" /> <Button text={t('loginButton')} primary href="/users/login" />
</div> </div>
); );

View File

@ -4,10 +4,7 @@ import React, { useState, useEffect } from 'react';
import Sidebar from '@/components/Sidebar'; import Sidebar from '@/components/Sidebar';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { MessageSquare, Settings, Home } from 'lucide-react'; import { MessageSquare, Settings, Home } from 'lucide-react';
import { import { FE_PARENTS_HOME_URL, FE_PARENTS_MESSAGERIE_URL } from '@/utils/Url';
FE_PARENTS_HOME_URL,
FE_PARENTS_MESSAGERIE_URL
} from '@/utils/Url';
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -15,6 +12,7 @@ import MobileTopbar from '@/components/MobileTopbar';
import { RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import { useChatConnection } from '@/context/ChatConnectionContext';
export default function Layout({ children }) { export default function Layout({ children }) {
const router = useRouter(); const router = useRouter();
@ -22,6 +20,7 @@ export default function Layout({ children }) {
const [isPopupVisible, setIsPopupVisible] = useState(false); const [isPopupVisible, setIsPopupVisible] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { clearContext } = useEstablishment(); const { clearContext } = useEstablishment();
const { totalUnreadCount, resetUnreadCount } = useChatConnection();
const softwareName = 'N3WT School'; const softwareName = 'N3WT School';
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`; const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
@ -41,7 +40,8 @@ export default function Layout({ children }) {
name: 'Messagerie', name: 'Messagerie',
url: FE_PARENTS_MESSAGERIE_URL, url: FE_PARENTS_MESSAGERIE_URL,
icon: MessageSquare, icon: MessageSquare,
} badge: totalUnreadCount,
},
]; ];
// Déterminer la page actuelle pour la sidebar // Déterminer la page actuelle pour la sidebar
@ -70,7 +70,11 @@ export default function Layout({ children }) {
useEffect(() => { useEffect(() => {
// Fermer la sidebar quand on change de page sur mobile // Fermer la sidebar quand on change de page sur mobile
setIsSidebarOpen(false); setIsSidebarOpen(false);
}, [pathname]); // Réinitialiser le compteur non lu quand on ouvre la messagerie
if (pathname?.includes('/messagerie')) {
resetUnreadCount();
}
}, [pathname, resetUnreadCount]);
return ( return (
<ProtectedRoute requiredRight={RIGHTS.PARENT}> <ProtectedRoute requiredRight={RIGHTS.PARENT}>
@ -100,7 +104,7 @@ export default function Layout({ children }) {
{/* Main container */} {/* Main container */}
<div <div
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md: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} {children}
</div> </div>

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Table from '@/components/Table';
import { import {
Edit3, Edit3,
Users, Users,
@ -9,6 +8,12 @@ import {
Eye, Eye,
Upload, Upload,
CalendarDays, CalendarDays,
Award,
ChevronDown,
ChevronUp,
BookOpen,
ArrowLeft,
Clock,
} from 'lucide-react'; } from 'lucide-react';
import StatusLabel from '@/components/StatusLabel'; import StatusLabel from '@/components/StatusLabel';
import FileUpload from '@/components/Form/FileUpload'; import FileUpload from '@/components/Form/FileUpload';
@ -16,10 +21,16 @@ import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
import { import {
fetchChildren, fetchChildren,
editRegisterForm, editRegisterForm,
fetchStudentCompetencies,
fetchAbsences,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import {
fetchEvaluations,
fetchStudentEvaluations,
} from '@/app/actions/schoolAction';
import { fetchUpcomingEvents } from '@/app/actions/planningAction'; import { fetchUpcomingEvents } from '@/app/actions/planningAction';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url'; import { getSecureFileUrl } from '@/utils/fileUrl';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
@ -27,13 +38,47 @@ import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import ParentPlanningSection from '@/components/ParentPlanningSection'; import ParentPlanningSection from '@/components/ParentPlanningSection';
import EventCard from '@/components/EventCard'; 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() { export default function ParentHomePage() {
const [children, setChildren] = useState([]); const [children, setChildren] = useState([]);
const { user, selectedEstablishmentId } = useEstablishment(); const { user, selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment();
const [uploadingStudentId, setUploadingStudentId] = useState(null); // ID de l'étudiant pour l'upload const [uploadingStudentId, setUploadingStudentId] = useState(null);
const [uploadedFile, setUploadedFile] = useState(null); // Fichier uploadé const [uploadedFile, setUploadedFile] = useState(null);
const [uploadState, setUploadState] = useState('off'); // État "on" ou "off" pour l'affichage du composant const [uploadState, setUploadState] = useState('off');
const [showPlanning, setShowPlanning] = useState(false); const [showPlanning, setShowPlanning] = useState(false);
const [planningClassName, setPlanningClassName] = useState(null); const [planningClassName, setPlanningClassName] = useState(null);
const [upcomingEvents, setUpcomingEvents] = useState([]); const [upcomingEvents, setUpcomingEvents] = useState([]);
@ -42,16 +87,114 @@ export default function ParentHomePage() {
const [reloadFetch, setReloadFetch] = useState(false); const [reloadFetch, setReloadFetch] = useState(false);
const { getNiveauLabel } = useClasses(); 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(() => { useEffect(() => {
if (user !== null) { if (user !== null) {
const userIdFromSession = user.user_id; const userIdFromSession = user.user_id;
fetchChildren(userIdFromSession, selectedEstablishmentId).then((data) => { fetchChildren(userIdFromSession, selectedEstablishmentId).then((data) => {
setChildren(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); setReloadFetch(false);
} }
}, [selectedEstablishmentId, reloadFetch, user]); }, [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(() => { useEffect(() => {
if (selectedEstablishmentId) { if (selectedEstablishmentId) {
// Fetch des événements à venir // Fetch des événements à venir
@ -132,153 +275,6 @@ export default function ParentHomePage() {
setShowPlanning(true); 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 ( return (
<div className="w-full h-full"> <div className="w-full h-full">
{showPlanning && planningClassName ? ( {showPlanning && planningClassName ? (
@ -286,10 +282,10 @@ export default function ParentHomePage() {
<> <>
<div className="p-4 flex items-center border-b"> <div className="p-4 flex items-center border-b">
<button <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)} onClick={() => setShowPlanning(false)}
> >
Retour <ArrowLeft className="w-4 h-4 mr-1" /> Retour
</button> </button>
</div> </div>
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
@ -313,7 +309,7 @@ export default function ParentHomePage() {
title="Événements à venir" title="Événements à venir"
description="Prochains événements de l'établissement" 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) => ( {upcomingEvents.slice(0, 3).map((event, index) => (
<EventCard key={index} {...event} /> <EventCard key={index} {...event} />
))} ))}
@ -326,20 +322,171 @@ export default function ParentHomePage() {
title="Vos enfants" title="Vos enfants"
description="Suivez le parcours de 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> </div>
{/* Composant FileUpload et bouton Valider en dessous du tableau */} )}
{uploadState === 'on' && uploadingStudentId && ( </div>
<div className="mt-4">
{/* 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 <FileUpload
selectionMessage="Sélectionnez un fichier à uploader" selectionMessage="Sélectionnez un fichier à uploader"
onFileSelect={handleFileUpload} onFileSelect={handleFileUpload}
/> />
<button <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 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' : 'bg-gray-300 text-gray-700 cursor-not-allowed'
}`} }`}
onClick={handleSubmit} onClick={handleSubmit}
@ -349,6 +496,201 @@ export default function ParentHomePage() {
</button> </button>
</div> </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&apos;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>
)} )}
</div> </div>

View File

@ -39,9 +39,12 @@ export default function SettingsPage() {
}; };
return ( return (
<div className="p-4"> <div className="p-6">
<h2 className="text-xl mb-4">Paramètres du compte</h2> <h1 className="font-headline text-2xl font-bold text-gray-900 mb-6">
<form onSubmit={handleSubmit}> 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 <InputText
type="email" type="email"
id="email" id="email"
@ -66,10 +69,11 @@ export default function SettingsPage() {
onChange={handleConfirmPasswordChange} onChange={handleConfirmPasswordChange}
required required
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between pt-2">
<Button type="submit" primary text={' Mettre à jour'} /> <Button type="submit" primary text={'Mettre à jour'} />
</div> </div>
</form> </form>
</div> </div>
</div>
); );
} }

View File

@ -80,16 +80,15 @@ export default function Page() {
return <Loader />; // Affichez le composant Loader return <Loader />; // Affichez le composant Loader
} else { } else {
return ( return (
<> <div className="min-h-screen flex items-center justify-center p-4 bg-neutral">
<div className="container max mx-auto 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-4"> <div className="flex justify-center mb-6">
<Logo className="h-150 w-150" /> <Logo className="h-150 w-150" />
</div> </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 Authentification
</h1> </h1>
<form <form
className="max-w-md mx-auto"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleFormLogin(new FormData(e.target)); handleFormLogin(new FormData(e.target));
@ -112,16 +111,14 @@ export default function Page() {
placeholder="Mot de passe" placeholder="Mot de passe"
className="w-full mb-5" className="w-full mb-5"
/> />
<div className="input-group mb-4"></div> <div className="flex justify-end mb-4">
<label>
<a <a
className="float-right mb-4" className="text-sm text-primary hover:text-secondary font-label transition-colors"
href={`${FE_USERS_NEW_PASSWORD_URL}`} href={`${FE_USERS_NEW_PASSWORD_URL}`}
> >
Mot de passe oublié ? Mot de passe oublié ?
</a> </a>
</label> </div>
<div className="form-group-submit mt-4">
<Button <Button
text="Se Connecter" text="Se Connecter"
className="w-full" className="w-full"
@ -129,10 +126,9 @@ export default function Page() {
type="submit" type="submit"
name="connect" name="connect"
/> />
</div>
</form> </form>
</div> </div>
</> </div>
); );
} }
} }

View File

@ -48,16 +48,15 @@ export default function Page() {
return <Loader />; return <Loader />;
} else { } else {
return ( return (
<> <div className="min-h-screen bg-neutral flex items-center justify-center p-4">
<div className="container max mx-auto 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-4"> <div className="flex justify-center mb-6">
<Logo className="h-150 w-150" /> <Logo className="h-150 w-150" />
</div> </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 Nouveau Mot de passe
</h1> </h1>
<form <form
className="max-w-md mx-auto"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
validate(new FormData(e.target)); validate(new FormData(e.target));
@ -70,28 +69,23 @@ export default function Page() {
IconItem={User} IconItem={User}
label="Identifiant" label="Identifiant"
placeholder="Identifiant" placeholder="Identifiant"
className="w-full" className="w-full mb-6"
/> />
<div className="form-group-submit mt-4">
<Button <Button
text="Réinitialiser" text="Réinitialiser"
className="w-full" className="w-full mb-3"
primary primary
type="submit" type="submit"
name="validate" name="validate"
/> />
</div>
</form>
<br />
<div className="flex justify-center mt-2 max-w-md mx-auto">
<Button <Button
text="Annuler" text="Annuler"
className="w-full" className="w-full"
href={`${FE_USERS_LOGIN_URL}`} href={`${FE_USERS_LOGIN_URL}`}
/> />
</form>
</div> </div>
</div> </div>
</>
); );
} }
} }

View File

@ -61,16 +61,15 @@ export default function Page() {
return <Loader />; return <Loader />;
} else { } else {
return ( return (
<> <div className="min-h-screen bg-neutral flex items-center justify-center p-4">
<div className="container max mx-auto 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-4"> <div className="flex justify-center mb-6">
<Logo className="h-150 w-150" /> <Logo className="h-150 w-150" />
</div> </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 Réinitialisation du mot de passe
</h1> </h1>
<form <form
className="max-w-md mx-auto"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
validate(new FormData(e.target)); validate(new FormData(e.target));
@ -91,28 +90,23 @@ export default function Page() {
IconItem={KeySquare} IconItem={KeySquare}
label="Confirmation mot de passe" label="Confirmation mot de passe"
placeholder="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 <Button
text="Enregistrer" text="Enregistrer"
className="w-full" className="w-full mb-3"
primary primary
type="submit" type="submit"
name="validate" name="validate"
/> />
</div>
</form>
<br />
<div className="flex justify-center mt-2 max-w-md mx-auto">
<Button <Button
text="Annuler" text="Annuler"
className="w-full" className="w-full"
href={`${FE_USERS_LOGIN_URL}`} href={`${FE_USERS_LOGIN_URL}`}
/> />
</form>
</div> </div>
</div> </div>
</>
); );
} }
} }

View File

@ -65,16 +65,15 @@ export default function Page() {
return <Loader />; return <Loader />;
} else { } else {
return ( return (
<> <div className="min-h-screen bg-neutral flex items-center justify-center p-4">
<div className="container max mx-auto 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-4"> <div className="flex justify-center mb-6">
<Logo className="h-150 w-150" /> <Logo className="h-150 w-150" />
</div> </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 Nouveau profil
</h1> </h1>
<form <form
className="max-w-md mx-auto"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
subscribeFormSubmit(new FormData(e.target)); subscribeFormSubmit(new FormData(e.target));
@ -103,30 +102,23 @@ export default function Page() {
IconItem={KeySquare} IconItem={KeySquare}
label="Confirmation mot de passe" label="Confirmation mot de passe"
placeholder="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 <Button
text="Enregistrer" text="Enregistrer"
className="w-full" className="w-full mb-3"
primary primary
type="submit" type="submit"
name="validate" name="validate"
/> />
</div>
</form>
<br />
<div className="flex justify-center mt-2 max-w-md mx-auto">
<Button <Button
text="Annuler" text="Annuler"
className="w-full" className="w-full"
onClick={() => { onClick={() => router.push(`${FE_USERS_LOGIN_URL}`)}
router.push(`${FE_USERS_LOGIN_URL}`);
}}
/> />
</form>
</div> </div>
</div> </div>
</>
); );
} }
} }

View File

@ -1,6 +1,7 @@
import { import {
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL, BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
BE_GESTIONEMAIL_SEND_EMAIL_URL, BE_GESTIONEMAIL_SEND_EMAIL_URL,
BE_GESTIONEMAIL_SEND_FEEDBACK_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { fetchWithAuth } from '@/utils/fetchWithAuth'; import { fetchWithAuth } from '@/utils/fetchWithAuth';
import { getCsrfToken } from '@/utils/getCsrfToken'; import { getCsrfToken } from '@/utils/getCsrfToken';
@ -19,3 +20,13 @@ export const sendEmail = async (messageData) => {
body: JSON.stringify(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),
});
};

View File

@ -26,6 +26,10 @@ export const fetchRegistrationSchoolFileMasters = (establishment) => {
return fetchWithAuth(url); return fetchWithAuth(url);
}; };
export const fetchRegistrationSchoolFileMasterById = (id) => {
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${id}`);
};
export const fetchRegistrationParentFileMasters = (establishment) => { export const fetchRegistrationParentFileMasters = (establishment) => {
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`; const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
return fetchWithAuth(url); return fetchWithAuth(url);

View File

@ -9,6 +9,9 @@ import {
BE_SCHOOL_PAYMENT_MODES_URL, BE_SCHOOL_PAYMENT_MODES_URL,
BE_SCHOOL_ESTABLISHMENT_URL, BE_SCHOOL_ESTABLISHMENT_URL,
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
BE_SCHOOL_EVALUATIONS_URL,
BE_SCHOOL_STUDENT_EVALUATIONS_URL,
BE_SCHOOL_SCHOOL_YEARS_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { fetchWithAuth } from '@/utils/fetchWithAuth'; import { fetchWithAuth } from '@/utils/fetchWithAuth';
@ -34,20 +37,25 @@ export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => {
); );
}; };
export const fetchSpecialities = (establishment) => { export const fetchSpecialities = (establishment, schoolYear = null) => {
return fetchWithAuth( let url = `${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`;
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}` if (schoolYear) url += `&school_year=${schoolYear}`;
); return fetchWithAuth(url);
}; };
export const fetchTeachers = (establishment) => { export const fetchTeachers = (establishment) => {
return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`); return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`);
}; };
export const fetchClasses = (establishment) => { export const fetchClasses = (establishment, options = {}) => {
return fetchWithAuth( let url = `${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`;
`${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) => { export const fetchClasse = (id) => {
@ -132,3 +140,71 @@ export const removeDatas = (url, id, csrfToken) => {
headers: { 'X-CSRFToken': 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 },
});
};

View File

@ -124,7 +124,7 @@ export const searchStudents = (establishmentId, query) => {
return fetchWithAuth(url); return fetchWithAuth(url);
}; };
export const fetchStudents = (establishment, id = null, status = null) => { export const fetchStudents = (establishment, id = null, status = null, schoolYear = null) => {
let url; let url;
if (id) { if (id) {
url = `${BE_SUBSCRIPTION_STUDENTS_URL}/${id}`; url = `${BE_SUBSCRIPTION_STUDENTS_URL}/${id}`;
@ -133,6 +133,9 @@ export const fetchStudents = (establishment, id = null, status = null) => {
if (status) { if (status) {
url += `&status=${status}`; url += `&status=${status}`;
} }
if (schoolYear) {
url += `&school_year=${encodeURIComponent(schoolYear)}`;
}
} }
return fetchWithAuth(url); return fetchWithAuth(url);
}; };

View File

@ -1,10 +1,23 @@
import React from 'react'; import React from 'react';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import localFont from 'next/font/local';
import Providers from '@/components/Providers'; import Providers from '@/components/Providers';
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister'; import ServiceWorkerRegister from '@/components/ServiceWorkerRegister';
import '@/css/tailwind.css'; import '@/css/tailwind.css';
import { headers } from 'next/headers'; 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 = { export const metadata = {
title: 'N3WT-SCHOOL', title: 'N3WT-SCHOOL',
description: "Gestion de l'école", description: "Gestion de l'école",
@ -36,7 +49,7 @@ export default async function RootLayout({ children, params }) {
return ( return (
<html lang={locale}> <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}> <Providers messages={messages} locale={locale} session={params.session}>
{children} {children}
</Providers> </Providers>

View File

@ -3,16 +3,19 @@ import Logo from '../components/Logo';
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-emerald-500"> <div className="flex items-center justify-center min-h-screen bg-primary">
<div className="text-center p-6 "> <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" /> <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 404 | Page non trouvée
</h2> </h2>
<p className="text-emerald-900 mb-4"> <p className="font-body text-gray-600 mb-4">
La ressource que vous souhaitez consulter n&apos;existe pas ou plus. La ressource que vous souhaitez consulter n&apos;existe pas ou plus.
</p> </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 Retour Accueil
</Link> </Link>
</div> </div>

View File

@ -14,7 +14,7 @@ export default function AnnouncementScheduler({ csrfToken }) {
return ( return (
<div className="p-4 bg-white rounded shadow"> <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"> <div className="mb-4">
<label className="block font-medium">Titre</label> <label className="block font-medium">Titre</label>
<input <input

View File

@ -39,7 +39,7 @@ const AffectationClasseForm = ({ eleve = {}, onSubmit, classes }) => {
value={classe.id} value={classe.id}
checked={formData.classeAssocie_id === classe.id} checked={formData.classeAssocie_id === classe.id}
onChange={handleChange} 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 <label
htmlFor={`classe-${classe.id}`} 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 ${ className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
!formData.classeAssocie_id !formData.classeAssocie_id
? 'bg-gray-300 text-gray-700 cursor-not-allowed' ? '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} disabled={!formData.classeAssocie_id}
> >

View File

@ -7,7 +7,6 @@ const AlertMessage = ({
actionLabel, actionLabel,
onAction, onAction,
}) => { }) => {
// Définir les styles en fonction du type d'alerte
const typeStyles = { const typeStyles = {
info: 'bg-blue-100 border-blue-500 text-blue-700', info: 'bg-blue-100 border-blue-500 text-blue-700',
warning: 'bg-yellow-100 border-yellow-500 text-yellow-700', warning: 'bg-yellow-100 border-yellow-500 text-yellow-700',
@ -18,13 +17,13 @@ const AlertMessage = ({
const alertStyle = typeStyles[type] || typeStyles.info; const alertStyle = typeStyles[type] || typeStyles.info;
return ( return (
<div className={`alert centered border-l-4 p-4 ${alertStyle}`} role="alert"> <div className={`alert centered border-l-4 p-4 rounded ${alertStyle}`} role="alert">
<h3 className="font-bold">{title}</h3> <h3 className="font-headline font-bold">{title}</h3>
<p className="mt-2">{message}</p> <p className="mt-2">{message}</p>
{actionLabel && onAction && ( {actionLabel && onAction && (
<div className="alert-actions mt-4"> <div className="alert-actions mt-4">
<button <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} onClick={onAction}
> >
{actionLabel} {actionLabel}

View File

@ -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" className="alert centered bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4"
role="alert" role="alert"
> >
<h3 className="font-bold">{title}</h3> <h3 className="font-headline font-bold">{title}</h3>
<p className="mt-2">{message}</p> <p className="mt-2">{message}</p>
<div className="alert-actions mt-4"> <div className="alert-actions mt-4">
<button <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} onClick={openModal}
> >
{buttonText} <UserPlus size={20} className="ml-2" /> {buttonText} <UserPlus size={20} className="ml-2" />

View File

@ -4,7 +4,7 @@ const AlphabetPaginationNumber = ({ letter, active, onClick }) => (
<button <button
className={`w-8 h-8 flex items-center justify-center rounded ${ className={`w-8 h-8 flex items-center justify-center rounded ${
active active
? 'bg-emerald-500 text-white' ? 'bg-primary text-white'
: 'text-gray-600 bg-gray-200 hover:bg-gray-50' : 'text-gray-600 bg-gray-200 hover:bg-gray-50'
}`} }`}
onClick={onClick} onClick={onClick}

View File

@ -126,7 +126,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName = '',
onClick={() => setShowDatePicker(!showDatePicker)} onClick={() => setShowDatePicker(!showDatePicker)}
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md" className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
> >
<h2 className="text-xl font-semibold"> <h2 className="font-headline text-xl font-semibold">
{format(currentDate, viewType === 'year' ? 'yyyy' : 'MMMM yyyy', { locale: fr })} {format(currentDate, viewType === 'year' ? 'yyyy' : 'MMMM yyyy', { locale: fr })}
</h2> </h2>
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
@ -185,7 +185,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName = '',
{(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && ( {(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && (
<button <button
onClick={onDateClick} 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" /> <Plus className="w-5 h-5" />
</button> </button>

View File

@ -13,7 +13,7 @@ import { getWeekEvents } from '@/utils/events';
import { CalendarDays, ChevronLeft, ChevronRight, Plus } from 'lucide-react'; import { CalendarDays, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => { const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
const { currentDate, setCurrentDate, parentView } = usePlanning(); const { currentDate, setCurrentDate, parentView, schedules } = usePlanning();
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
const scrollRef = useRef(null); const scrollRef = useRef(null);
@ -43,11 +43,28 @@ const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
return `${(hours + minutes / 60) * 5}rem`; 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 calculateEventStyle = (event, allDayEvents) => {
const start = new Date(event.start); const start = new Date(event.start);
const end = new Date(event.end); const end = new Date(event.end);
const startMinutes = (start.getMinutes() / 60) * 5; const startMinutes = (start.getMinutes() / 60) * 5;
const duration = ((end - start) / (1000 * 60 * 60)) * 5; const duration = ((end - start) / (1000 * 60 * 60)) * 5;
const scheduleColor = getScheduleColor(event);
const overlapping = allDayEvents.filter((other) => { const overlapping = allDayEvents.filter((other) => {
if (other.id === event.id) return false; if (other.id === event.id) return false;
@ -114,7 +131,7 @@ const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
<button <button
onClick={() => onDateClick?.(currentDate)} onClick={() => onDateClick?.(currentDate)}
className="w-9 h-9 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors" 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" /> <Plus className="w-4 h-4" />
</button> </button>
@ -128,9 +145,9 @@ const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
onClick={() => setCurrentDate(day)} onClick={() => setCurrentDate(day)}
className={`flex flex-col items-center min-w-[2.75rem] px-1 py-1.5 rounded-xl transition-colors ${ className={`flex flex-col items-center min-w-[2.75rem] px-1 py-1.5 rounded-xl transition-colors ${
isSameDay(day, currentDate) isSameDay(day, currentDate)
? 'bg-emerald-600 text-white' ? 'bg-primary text-white'
: isToday(day) : isToday(day)
? 'border border-emerald-400 text-emerald-600' ? 'border border-tertiary text-primary'
: 'text-gray-600 hover:bg-gray-100' : 'text-gray-600 hover:bg-gray-100'
}`} }`}
> >
@ -146,10 +163,10 @@ const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
<div ref={scrollRef} className="flex-1 overflow-y-auto relative"> <div ref={scrollRef} className="flex-1 overflow-y-auto relative">
{isCurrentDay && ( {isCurrentDay && (
<div <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() }} 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> </div>
)} )}
@ -163,8 +180,8 @@ const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
{`${hour.toString().padStart(2, '0')}:00`} {`${hour.toString().padStart(2, '0')}:00`}
</div> </div>
<div <div
className={`h-20 relative border-b border-gray-100 ${ className={`h-20 relative ${
isCurrentDay ? 'bg-emerald-50/30' : 'bg-white' isCurrentDay ? 'bg-primary/5/30' : 'bg-white'
}`} }`}
onClick={ onClick={
parentView parentView
@ -179,7 +196,10 @@ const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
> >
{dayEvents {dayEvents
.filter((e) => new Date(e.start).getHours() === hour) .filter((e) => new Date(e.start).getHours() === hour)
.map((event) => ( .map((event) => {
const scheduleColor = getScheduleColor(event);
const classLevelLabel = getScheduleClassLevelLabel(event);
return (
<div <div
key={event.id} key={event.id}
className="rounded-sm overflow-hidden cursor-pointer hover:shadow-lg" className="rounded-sm overflow-hidden cursor-pointer hover:shadow-lg"
@ -193,12 +213,32 @@ const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
} }
} }
> >
{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="p-1">
<div <div
className="font-semibold text-xs truncate" className="font-semibold text-xs truncate flex items-center gap-1"
style={{ color: event.color }} 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>
<div <div
className="text-xs" className="text-xs"
@ -217,7 +257,8 @@ const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
)} )}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
</React.Fragment> </React.Fragment>
))} ))}

View File

@ -10,20 +10,31 @@ export default function EventModal({
eventData, eventData,
setEventData, setEventData,
}) { }) {
const { addEvent, handleUpdateEvent, handleDeleteEvent, schedules } = const {
addEvent,
handleUpdateEvent,
handleDeleteEvent,
schedules,
selectedSchedule,
} =
usePlanning(); usePlanning();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
// S'assurer que planning est défini lors du premier rendu // S'assurer que planning est défini lors du premier rendu
React.useEffect(() => { React.useEffect(() => {
if (!eventData?.planning && schedules.length > 0) { if (!eventData?.planning && schedules.length > 0) {
const defaultSchedule =
schedules.find(
(schedule) => Number(schedule.id) === Number(selectedSchedule)
) || schedules[0];
setEventData((prev) => ({ setEventData((prev) => ({
...prev, ...prev,
planning: schedules[0].id, planning: defaultSchedule.id,
color: schedules[0].color, color: defaultSchedule.color,
})); }));
} }
}, [schedules, eventData?.planning]); }, [schedules, selectedSchedule, eventData?.planning]);
if (!isOpen) return null; if (!isOpen) return null;
@ -105,7 +116,7 @@ export default function EventModal({
onChange={(e) => onChange={(e) =>
setEventData({ ...eventData, title: e.target.value }) 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 required
/> />
</div> </div>
@ -120,7 +131,7 @@ export default function EventModal({
onChange={(e) => onChange={(e) =>
setEventData({ ...eventData, description: e.target.value }) 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" rows="3"
/> />
</div> </div>
@ -142,7 +153,7 @@ export default function EventModal({
color: selectedSchedule?.color || '#10b981', 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 required
> >
{schedules.map((schedule) => ( {schedules.map((schedule) => (
@ -185,7 +196,7 @@ export default function EventModal({
recursionType: e.target.value, 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) => ( {recurrenceOptions.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
@ -215,7 +226,7 @@ export default function EventModal({
}} }}
className={`px-3 py-1 rounded-full text-sm ${ className={`px-3 py-1 rounded-full text-sm ${
(eventData.selectedDays || []).includes(day.value) (eventData.selectedDays || []).includes(day.value)
? 'bg-emerald-100 text-emerald-800' ? 'bg-primary/10 text-secondary'
: 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
}`} }`}
> >
@ -247,7 +258,7 @@ export default function EventModal({
: null, : 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> </div>
)} )}
@ -267,7 +278,7 @@ export default function EventModal({
start: new Date(e.target.value).toISOString(), 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 required
/> />
</div> </div>
@ -284,7 +295,7 @@ export default function EventModal({
end: new Date(e.target.value).toISOString(), 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 required
/> />
</div> </div>
@ -301,7 +312,7 @@ export default function EventModal({
onChange={(e) => onChange={(e) =>
setEventData({ ...eventData, location: e.target.value }) 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> </div>
@ -328,7 +339,7 @@ export default function EventModal({
</button> </button>
<button <button
type="submit" 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'} {eventData.id ? 'Modifier' : 'Créer'}
</button> </button>

View File

@ -14,7 +14,24 @@ import { fr } from 'date-fns/locale';
import { getEventsForDate } from '@/utils/events'; import { getEventsForDate } from '@/utils/events';
const MonthView = ({ onDateClick, onEventClick }) => { 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 // Obtenir tous les jours du mois actuel
const monthStart = startOfMonth(currentDate); const monthStart = startOfMonth(currentDate);
@ -39,21 +56,24 @@ const MonthView = ({ onDateClick, onEventClick }) => {
key={day.toString()} key={day.toString()}
className={`p-2 overflow-y-auto relative flex flex-col className={`p-2 overflow-y-auto relative flex flex-col
${!isCurrentMonth ? 'bg-gray-100 text-gray-400' : ''} ${!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`} hover:bg-gray-100 cursor-pointer border-b border-r`}
onClick={() => handleDayClick(day)} onClick={() => handleDayClick(day)}
> >
<div className="flex justify-between items-center mb-1"> <div className="flex justify-between items-center mb-1">
<span <span
className={`text-sm font-medium rounded-full w-7 h-7 flex items-center justify-center 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' : ''}`} ${!isCurrentMonth ? 'text-gray-400' : ''}`}
> >
{format(day, 'd')} {format(day, 'd')}
</span> </span>
</div> </div>
<div className="space-y-1 flex-1"> <div className="space-y-1 flex-1">
{dayEvents.map((event, index) => ( {dayEvents.map((event) => {
const scheduleColor = getScheduleColor(event);
const classLevelLabel = getScheduleClassLevelLabel(event);
return (
<div <div
key={event.id} key={event.id}
className="text-xs p-1 rounded truncate cursor-pointer" className="text-xs p-1 rounded truncate cursor-pointer"
@ -67,9 +87,32 @@ const MonthView = ({ onDateClick, onEventClick }) => {
onEventClick(event); 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> </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>
</div> </div>
); );

View File

@ -247,7 +247,7 @@ export default function ScheduleNavigation({ classes, modeSet = 'event', isOpen
{/* Desktop : sidebar fixe */} {/* Desktop : sidebar fixe */}
<nav className="hidden md:flex flex-col w-64 border-r p-4 h-full overflow-y-auto shrink-0"> <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"> <div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">{title}</h2> <h2 className="font-headline font-semibold">{title}</h2>
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded"> <button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</button> </button>
@ -268,7 +268,7 @@ export default function ScheduleNavigation({ classes, modeSet = 'event', isOpen
}`} }`}
> >
<div className="flex items-center justify-between p-4 border-b shrink-0"> <div className="flex items-center justify-between p-4 border-b shrink-0">
<h2 className="font-semibold">{title}</h2> <h2 className="font-headline font-semibold">{title}</h2>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded"> <button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />

View File

@ -7,7 +7,7 @@ import { isToday } from 'date-fns';
const WeekView = ({ onDateClick, onEventClick, events }) => { const WeekView = ({ onDateClick, onEventClick, events }) => {
const { currentDate, planningMode, parentView } = usePlanning(); const { currentDate, planningMode, parentView, schedules } = usePlanning();
const [currentTime, setCurrentTime] = useState(new Date()); const [currentTime, setCurrentTime] = useState(new Date());
const scrollContainerRef = useRef(null); // Ajouter cette référence const scrollContainerRef = useRef(null); // Ajouter cette référence
@ -54,6 +54,8 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
return acc; return acc;
}, {}); }, {});
const todayIndex = weekDays.findIndex((day) => isToday(day));
const isWeekend = (date) => { const isWeekend = (date) => {
const day = date.getDay(); const day = date.getDay();
return day === 0 || day === 6; 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 calculateEventStyle = (event, dayEvents) => {
const start = new Date(event.start); const start = new Date(event.start);
const end = new Date(event.end); const end = new Date(event.end);
const startMinutes = (start.getMinutes() / 60) * 5; const startMinutes = (start.getMinutes() / 60) * 5;
const duration = ((end - start) / (1000 * 60 * 60)) * 5; const duration = ((end - start) / (1000 * 60 * 60)) * 5;
const scheduleColor = getScheduleColor(event);
// Trouver les événements qui se chevauchent // Trouver les événements qui se chevauchent
const overlappingEvents = findOverlappingEvents(event, dayEvents); const overlappingEvents = findOverlappingEvents(event, dayEvents);
@ -101,6 +120,8 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
const renderEventInCell = (event, dayEvents) => { const renderEventInCell = (event, dayEvents) => {
const eventStyle = calculateEventStyle(event, dayEvents); const eventStyle = calculateEventStyle(event, dayEvents);
const scheduleColor = getScheduleColor(event);
const classLevelLabel = getScheduleClassLevelLabel(event);
return ( return (
<div <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="p-1">
<div <div
className="font-semibold text-xs truncate" className="font-semibold text-xs truncate flex items-center gap-1"
style={{ color: event.color }} 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>
<div <div
className="text-xs" className="text-xs"
@ -156,14 +197,14 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
key={day} key={day}
className={`h-14 p-2 text-center border-b className={`h-14 p-2 text-center border-b
${isWeekend(day) ? 'bg-gray-50' : 'bg-white'} ${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"> <div className="text-xs font-medium text-gray-500">
{format(day, 'EEEE', { locale: fr })} {format(day, 'EEEE', { locale: fr })}
</div> </div>
<div <div
className={`text-sm font-semibold inline-block rounded-full w-7 h-7 leading-7 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 })} {format(day, 'd', { locale: fr })}
</div> </div>
@ -173,15 +214,25 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
{/* Grille horaire */} {/* Grille horaire */}
<div ref={scrollContainerRef} className="flex-1 relative"> <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 */} {/* Ligne de temps actuelle */}
{isCurrentWeek && ( {isCurrentWeek && (
<div <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={{ style={{
top: getCurrentTimePosition(), 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> </div>
)} )}
@ -202,7 +253,7 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
key={`${hour}-${day}`} key={`${hour}-${day}`}
className={`h-20 relative border-b border-gray-100 className={`h-20 relative border-b border-gray-100
${isWeekend(day) ? 'bg-gray-50' : 'bg-white'} ${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={ onClick={
parentView parentView
? undefined ? undefined

View File

@ -8,14 +8,14 @@ import { isSameMonth } from 'date-fns';
const MonthCard = ({ month, eventCount, onClick }) => ( const MonthCard = ({ month, eventCount, onClick }) => (
<div <div
className={`bg-white p-4 rounded shadow hover:shadow-lg cursor-pointer 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} 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 })} {format(month, 'MMMM', { locale: fr })}
</h3> </h3>
<div className="text-center text-sm"> <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 {eventCount} événements
</span> </span>
</div> </div>

View File

@ -31,7 +31,7 @@ export default function LineChart({ data }) {
style={{ height: chartHeight }} style={{ height: chartHeight }}
> >
<div <div
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`} className={`${isMax ? 'bg-tertiary' : 'bg-blue-400'} rounded-t w-4`}
style={{ height: `${barHeight}px`, transition: 'height 0.3s' }} style={{ height: `${barHeight}px`, transition: 'height 0.3s' }}
title={`${point.month}: ${point.value}`} title={`${point.month}: ${point.value}`}
/> />

View File

@ -51,7 +51,7 @@ const ConversationItem = ({
const getLastMessageText = () => { const getLastMessageText = () => {
if (isTyping) { if (isTyping) {
return ( 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) => { const getPresenceColor = (status) => {
switch (status) { switch (status) {
case 'online': case 'online':
return 'bg-emerald-400'; return 'bg-tertiary';
case 'away': case 'away':
return 'bg-yellow-400'; return 'bg-yellow-400';
case 'busy': case 'busy':
@ -127,7 +127,7 @@ const ConversationItem = ({
<div <div
className={`group flex items-center p-3 cursor-pointer rounded-lg transition-all duration-200 hover:bg-gray-50 ${ className={`group flex items-center p-3 cursor-pointer rounded-lg transition-all duration-200 hover:bg-gray-50 ${
isSelected isSelected
? 'bg-emerald-50 border-l-4 border-emerald-500' ? 'bg-primary/5 border-l-4 border-primary'
: 'hover:bg-gray-50' : 'hover:bg-gray-50'
}`} }`}
onClick={onClick} onClick={onClick}
@ -154,8 +154,8 @@ const ConversationItem = ({
<div className="flex-1 ml-3 overflow-hidden"> <div className="flex-1 ml-3 overflow-hidden">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 <h3
className={`font-semibold truncate ${ className={`font-headline font-semibold truncate ${
isSelected ? 'text-emerald-700' : 'text-gray-900' isSelected ? 'text-secondary' : 'text-gray-900'
}`} }`}
> >
{getInterlocutorName()} {getInterlocutorName()}

View File

@ -8,6 +8,7 @@ import {
Archive, Archive,
AlertCircle, AlertCircle,
} from 'lucide-react'; } from 'lucide-react';
import { getSecureFileUrl } from '@/utils/fileUrl';
const FileAttachment = ({ const FileAttachment = ({
fileName, fileName,
@ -16,6 +17,7 @@ const FileAttachment = ({
fileUrl, fileUrl,
onDownload = null, onDownload = null,
}) => { }) => {
const secureUrl = getSecureFileUrl(fileUrl);
// Obtenir l'icône en fonction du type de fichier // Obtenir l'icône en fonction du type de fichier
const getFileIcon = (type) => { const getFileIcon = (type) => {
if (type.startsWith('image/')) { if (type.startsWith('image/')) {
@ -49,9 +51,9 @@ const FileAttachment = ({
const handleDownload = () => { const handleDownload = () => {
if (onDownload) { if (onDownload) {
onDownload(); onDownload();
} else if (fileUrl) { } else if (secureUrl) {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = fileUrl; link.href = secureUrl;
link.download = fileName; link.download = fileName;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@ -64,14 +66,14 @@ const FileAttachment = ({
return ( return (
<div className="max-w-sm"> <div className="max-w-sm">
{isImage && fileUrl ? ( {isImage && secureUrl ? (
// Affichage pour les images // Affichage pour les images
<div className="relative group"> <div className="relative group">
<img <img
src={fileUrl} src={secureUrl}
alt={fileName} alt={fileName}
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity" 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"> <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 <button

View File

@ -116,7 +116,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
const handleWebSocketMessage = useCallback( const handleWebSocketMessage = useCallback(
(data) => { (data) => {
// Debug : vérifier userProfileId à chaque message // Debug : vérifier userProfileId à chaque message
logger.debug('🔍 handleWebSocketMessage appelé:', { logger.debug(' handleWebSocketMessage appelé:', {
messageType: data.type, messageType: data.type,
currentUserProfileId: userProfileId, currentUserProfileId: userProfileId,
userProfileIdType: typeof userProfileId, userProfileIdType: typeof userProfileId,
@ -153,7 +153,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
case 'new_message': case 'new_message':
const newMessage = data.message; const newMessage = data.message;
logger.debug('🆕 NOUVEAU MESSAGE WebSocket reçu:', { logger.debug(' NOUVEAU MESSAGE WebSocket reçu:', {
senderId: newMessage.sender?.id, senderId: newMessage.sender?.id,
content: newMessage.content?.substring(0, 50), content: newMessage.content?.substring(0, 50),
conversationId: conversationId:
@ -171,14 +171,14 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
// Vérifier si ce message a déjà été traité // Vérifier si ce message a déjà été traité
if (processedMessages.has(messageId)) { if (processedMessages.has(messageId)) {
logger.debug('🔍 Message déjà traité, ignoré:', { logger.debug(' Message déjà traité, ignoré:', {
messageId, messageId,
processedCount: processedMessages.size, processedCount: processedMessages.size,
}); });
break; break;
} }
logger.debug('🆔 ID unique généré pour le message:', messageId); logger.debug(' ID unique généré pour le message:', messageId);
// Marquer le message comme traité // Marquer le message comme traité
setProcessedMessages((prev) => { setProcessedMessages((prev) => {
@ -193,7 +193,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
}); });
// Debug: vérifier le type de message reçu // Debug: vérifier le type de message reçu
logger.debug('🔍 Message reçu:', { logger.debug(' Message reçu:', {
content: newMessage.content?.substring(0, 50), content: newMessage.content?.substring(0, 50),
message_type: newMessage.message_type, message_type: newMessage.message_type,
sender_id: newMessage.sender_id, sender_id: newMessage.sender_id,
@ -235,7 +235,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
String(newMessage.sender.id) === String(userProfileId); String(newMessage.sender.id) === String(userProfileId);
// Debug détaillé pour comprendre le problème d'incrémentation // Debug détaillé pour comprendre le problème d'incrémentation
logger.debug('🔍 Analyse du message pour compteur:', { logger.debug(' Analyse du message pour compteur:', {
messageId: messageId, messageId: messageId,
senderId: newMessage.sender.id, senderId: newMessage.sender.id,
userProfileId: userProfileId, userProfileId: userProfileId,
@ -253,12 +253,12 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
if (shouldIncrementUnread) { if (shouldIncrementUnread) {
logger.debug( logger.debug(
'🔺 INCRÉMENTATION du compteur non lu pour conversation:', ' INCRÉMENTATION du compteur non lu pour conversation:',
convId convId
); );
} else { } else {
logger.debug( logger.debug(
"➡️ Pas d'incrémentation (message de l'utilisateur connecté)" " Pas d'incrémentation (message de l'utilisateur connecté)"
); );
} }
@ -278,7 +278,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
case 'typing_status': case 'typing_status':
const { user_id, is_typing, conversation_id, user_name } = data; const { user_id, is_typing, conversation_id, user_name } = data;
logger.debug('📝 Typing status reçu:', { logger.debug(' Typing status reçu:', {
user_id, user_id,
is_typing, is_typing,
conversation_id, conversation_id,
@ -294,13 +294,13 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
String(currentConversationId) === String(conversation_id) String(currentConversationId) === String(conversation_id)
) { ) {
logger.debug( logger.debug(
'📝 Mise à jour typing pour conversation sélectionnée' ' Mise à jour typing pour conversation sélectionnée'
); );
setTypingUsers((prev) => { setTypingUsers((prev) => {
// Utiliser le nom de l'utilisateur s'il est disponible, sinon l'ID // Utiliser le nom de l'utilisateur s'il est disponible, sinon l'ID
const displayName = user_name || `Utilisateur ${user_id}`; const displayName = user_name || `Utilisateur ${user_id}`;
logger.debug( logger.debug(
'📝 Display name:', ' Display name:',
displayName, displayName,
'is_typing:', 'is_typing:',
is_typing is_typing
@ -311,21 +311,21 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
? prev ? prev
: [...prev, displayName]; : [...prev, displayName];
logger.debug( logger.debug(
'📝 Nouveaux utilisateurs en train de taper:', ' Nouveaux utilisateurs en train de taper:',
newUsers newUsers
); );
return newUsers; return newUsers;
} else { } else {
const newUsers = prev.filter((name) => name !== displayName); const newUsers = prev.filter((name) => name !== displayName);
logger.debug( logger.debug(
'📝 Utilisateurs en train de taper après suppression:', ' Utilisateurs en train de taper après suppression:',
newUsers newUsers
); );
return newUsers; return newUsers;
} }
}); });
} else { } else {
logger.debug('📝 Typing status pour une autre conversation:', { logger.debug(' Typing status pour une autre conversation:', {
selectedConversationId: currentConversationId, selectedConversationId: currentConversationId,
messageConversationId: conversation_id, messageConversationId: conversation_id,
}); });
@ -378,7 +378,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
setConversations(data || []); setConversations(data || []);
} catch (error) { } catch (error) {
logger.error(' Erreur lors du chargement des conversations:', error); logger.error(' Erreur lors du chargement des conversations:', error);
setConversations([]); setConversations([]);
} finally { } finally {
setLoading(false); setLoading(false);
@ -539,30 +539,30 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
// Sélectionner une conversation // Sélectionner une conversation
const selectConversation = useCallback( const selectConversation = useCallback(
(conversation) => { (conversation) => {
logger.debug('🔄 Sélection de la conversation:', conversation); logger.debug(' Sélection de la conversation:', conversation);
setSelectedConversation(conversation); setSelectedConversation(conversation);
setTypingUsers([]); setTypingUsers([]);
setIsMobileSidebarOpen(false); setIsMobileSidebarOpen(false);
// Utiliser id ou conversation_id selon ce qui est disponible // Utiliser id ou conversation_id selon ce qui est disponible
const conversationId = conversation.id || conversation.conversation_id; const conversationId = conversation.id || conversation.conversation_id;
logger.debug('🔄 ID de conversation extrait:', conversationId); logger.debug(' ID de conversation extrait:', conversationId);
if (conversationId) { if (conversationId) {
logger.debug( logger.debug(
'🔄 Chargement des messages pour conversation:', ' Chargement des messages pour conversation:',
conversationId conversationId
); );
loadMessages(conversationId); loadMessages(conversationId);
logger.debug( logger.debug(
'🔄 Tentative de rejoindre la conversation:', ' Tentative de rejoindre la conversation:',
conversationId conversationId
); );
const joinResult = joinConversation(conversationId); const joinResult = joinConversation(conversationId);
logger.debug('🔄 Résultat joinConversation:', joinResult); logger.debug(' Résultat joinConversation:', joinResult);
} else { } else {
logger.error(" Impossible de trouver l'ID de conversation"); logger.error(" Impossible de trouver l'ID de conversation");
} }
}, },
[loadMessages, joinConversation] [loadMessages, joinConversation]
@ -571,20 +571,20 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
// Envoyer un message // Envoyer un message
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
(content, attachment = null) => { (content, attachment = null) => {
logger.debug('📤 handleSendMessage appelé:', { logger.debug(' handleSendMessage appelé:', {
content, content,
attachment, attachment,
selectedConversation, selectedConversation,
}); });
if (!selectedConversation) { if (!selectedConversation) {
logger.warn(' Aucune conversation sélectionnée'); logger.warn(' Aucune conversation sélectionnée');
return; return;
} }
// Vérifier qu'on a soit du contenu, soit un fichier // Vérifier qu'on a soit du contenu, soit un fichier
if (!content.trim() && !attachment) { if (!content.trim() && !attachment) {
logger.warn(' Aucun contenu ni fichier à envoyer'); logger.warn(' Aucun contenu ni fichier à envoyer');
return; return;
} }
@ -592,7 +592,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
const conversationId = const conversationId =
selectedConversation.id || selectedConversation.conversation_id; selectedConversation.id || selectedConversation.conversation_id;
if (!conversationId) { if (!conversationId) {
logger.error(" Impossible de trouver l'ID de la conversation"); logger.error(" Impossible de trouver l'ID de la conversation");
return; return;
} }
@ -608,23 +608,23 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
messageData.attachment = attachment; messageData.attachment = attachment;
} }
logger.debug('📤 Envoi du message via WebSocket:', messageData); logger.debug(' Envoi du message via WebSocket:', messageData);
logger.debug('📤 Type de message:', messageData.type); logger.debug(' Type de message:', messageData.type);
logger.debug('📤 État de la connexion:', { logger.debug(' État de la connexion:', {
isConnected, isConnected,
connectionStatus, connectionStatus,
}); });
const success = sendChatMessage(messageData); const success = sendChatMessage(messageData);
logger.debug('📤 Résultat envoi message:', success); logger.debug(' Résultat envoi message:', success);
if (!success) { if (!success) {
logger.error( logger.error(
" Impossible d'envoyer le message - WebSocket non connecté" " Impossible d'envoyer le message - WebSocket non connecté"
); );
} else { } else {
logger.debug(' Message envoyé avec succès'); logger.debug(' Message envoyé avec succès');
} }
}, },
[ [
@ -834,7 +834,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
{/* En-tête */} {/* En-tête */}
<div className="p-4 border-b border-gray-200"> <div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900">Messages</h2> <h2 className="font-headline text-lg font-semibold text-gray-900">Messages</h2>
<button <button
onClick={handleStartNewConversation} onClick={handleStartNewConversation}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors" className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
@ -857,7 +857,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
value={searchQuery} value={searchQuery}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
className={`w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 ${ className={`w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-lg focus:outline-none focus:ring-2 ${
showSearch ? 'focus:ring-emerald-500' : 'focus:ring-emerald-500' showSearch ? 'focus:ring-primary' : 'focus:ring-primary'
}`} }`}
/> />
{showSearch && searchQuery && ( {showSearch && searchQuery && (
@ -896,7 +896,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
</div> </div>
) : showSearch && searchResults.length > 0 ? ( ) : showSearch && searchResults.length > 0 ? (
<div className="p-2"> <div className="p-2">
<h3 className="text-sm font-medium text-gray-700 mb-2 px-2"> <h3 className="font-headline text-sm font-medium text-gray-700 mb-2 px-2">
Résultats de recherche Résultats de recherche
</h3> </h3>
{searchResults.map((user) => ( {searchResults.map((user) => (
@ -915,7 +915,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
<div <div
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 ${ className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 ${
userPresences[user.id]?.status === 'online' userPresences[user.id]?.status === 'online'
? 'bg-emerald-400' ? 'bg-tertiary'
: 'bg-gray-400' : 'bg-gray-400'
} border-2 border-white rounded-full`} } border-2 border-white rounded-full`}
title={ title={
@ -937,7 +937,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
<div <div
className={`text-xs ${ className={`text-xs ${
userPresences[user.id]?.status === 'online' userPresences[user.id]?.status === 'online'
? 'text-emerald-500' ? 'text-primary'
: 'text-gray-500' : 'text-gray-500'
}`} }`}
> >
@ -1019,7 +1019,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
selectedConversation.interlocuteur?.id && selectedConversation.interlocuteur?.id &&
userPresences[selectedConversation.interlocuteur.id] userPresences[selectedConversation.interlocuteur.id]
?.status === 'online' ?.status === 'online'
? 'bg-emerald-400' ? 'bg-tertiary'
: 'bg-gray-400' : 'bg-gray-400'
} border-2 border-white rounded-full`} } border-2 border-white rounded-full`}
title={ title={
@ -1032,7 +1032,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
></div> ></div>
</div> </div>
<div className="flex-1 ml-3 overflow-hidden"> <div className="flex-1 ml-3 overflow-hidden">
<h3 className="font-semibold text-gray-900"> <h3 className="font-headline font-semibold text-gray-900">
{selectedConversation.interlocuteur {selectedConversation.interlocuteur
? selectedConversation.interlocuteur.first_name && ? selectedConversation.interlocuteur.first_name &&
selectedConversation.interlocuteur.last_name selectedConversation.interlocuteur.last_name
@ -1046,7 +1046,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
selectedConversation.interlocuteur?.id && selectedConversation.interlocuteur?.id &&
userPresences[selectedConversation.interlocuteur.id] userPresences[selectedConversation.interlocuteur.id]
?.status === 'online' ?.status === 'online'
? 'text-emerald-500' ? 'text-primary'
: 'text-gray-500' : 'text-gray-500'
}`} }`}
> >
@ -1140,10 +1140,10 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
) : ( ) : (
<div className="flex-1 flex items-center justify-center bg-gray-50"> <div className="flex-1 flex items-center justify-center bg-gray-50">
<div className="text-center"> <div className="text-center">
<div className="w-16 h-16 bg-emerald-200 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4">
<MessageSquare className="w-8 h-8 text-emerald-600" /> <MessageSquare className="w-8 h-8 text-primary" />
</div> </div>
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="font-headline text-lg font-medium text-gray-900 mb-2">
Sélectionnez une conversation Sélectionnez une conversation
</h3> </h3>
<p className="text-gray-500"> <p className="text-gray-500">

View File

@ -60,19 +60,19 @@ const MessageInput = ({
const handleSend = () => { const handleSend = () => {
const trimmedMessage = message.trim(); const trimmedMessage = message.trim();
logger.debug('📝 MessageInput: handleSend appelé:', { logger.debug(' MessageInput: handleSend appelé:', {
message, message,
trimmedMessage, trimmedMessage,
disabled, disabled,
}); });
if (!trimmedMessage || disabled) { if (!trimmedMessage || disabled) {
logger.debug(' MessageInput: Message vide ou désactivé'); logger.debug(' MessageInput: Message vide ou désactivé');
return; return;
} }
logger.debug( logger.debug(
'📤 MessageInput: Appel de onSendMessage avec:', ' MessageInput: Appel de onSendMessage avec:',
trimmedMessage trimmedMessage
); );
onSendMessage(trimmedMessage); onSendMessage(trimmedMessage);

View File

@ -10,7 +10,7 @@ const ClasseDetails = ({ classe }) => {
const pourcentage = Math.round((nombreElevesInscrits / capaciteTotale) * 100); const pourcentage = Math.round((nombreElevesInscrits / capaciteTotale) * 100);
const getColor = (pourcentage) => { const getColor = (pourcentage) => {
if (pourcentage < 50) return 'bg-emerald-500'; if (pourcentage < 50) return 'bg-primary';
if (pourcentage < 75) return 'bg-orange-500'; if (pourcentage < 75) return 'bg-orange-500';
return 'bg-red-500'; return 'bg-red-500';
}; };
@ -52,7 +52,7 @@ const ClasseDetails = ({ classe }) => {
</div> </div>
</div> </div>
<h3 className="text-xl font-semibold mb-4">Liste des élèves</h3> <h3 className="font-headline text-xl font-semibold mb-4">Liste des élèves</h3>
<div className="bg-white rounded-lg border border-gray-200 shadow-md"> <div className="bg-white rounded-lg border border-gray-200 shadow-md">
<Table <Table
columns={[ columns={[

View File

@ -50,7 +50,7 @@ const DateTab = ({
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
{dates[activeTab]?.map((date, index) => ( {dates[activeTab]?.map((date, index) => (
<div key={index} className="flex items-center space-x-3"> <div key={index} className="flex items-center space-x-3">
<span className="text-emerald-700 font-semibold"> <span className="text-secondary font-semibold">
Échéance {index + 1} Échéance {index + 1}
</span> </span>
<input <input
@ -63,7 +63,7 @@ const DateTab = ({
e.target.value e.target.value
) )
} }
className="p-2 border border-emerald-300 rounded focus:outline-none focus:ring-2 focus:ring-emerald-500 cursor-pointer" className="p-2 border border-primary/30 rounded focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer"
/> />
{modifiedDates[`${activeTab}-${index}`] && ( {modifiedDates[`${activeTab}-${index}`] && (
<button <button
@ -74,7 +74,7 @@ const DateTab = ({
due_dates: dates[activeTab], due_dates: dates[activeTab],
}) })
} }
className="text-emerald-500 hover:text-emerald-800" className="text-primary hover:text-secondary"
> >
<Check className="w-5 h-5" /> <Check className="w-5 h-5" />
</button> </button>

Some files were not shown because too many files have changed in this diff Show More