Merge remote-tracking branch 'origin/develop' into N3WTS-5-Historique-ACA

This commit is contained in:
N3WT DE COMPET
2026-04-04 13:54:37 +02:00
31 changed files with 2180 additions and 576 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

@ -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,55 @@ 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 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

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

@ -8,7 +8,8 @@ import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import { EvaluationStudentView } from '@/components/Evaluation'; 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,
@ -147,21 +148,33 @@ export default function StudentGradesPage() {
// Load evaluations for the student // Load evaluations for the student
useEffect(() => { useEffect(() => {
if (student?.associated_class_id && selectedPeriod && selectedEstablishmentId) { if (
student?.associated_class_id &&
selectedPeriod &&
selectedEstablishmentId
) {
const periodString = getPeriodString( const periodString = getPeriodString(
selectedPeriod, selectedPeriod,
selectedEstablishmentEvaluationFrequency selectedEstablishmentEvaluationFrequency
); );
// Load evaluations for the class // Load evaluations for the class
fetchEvaluations(selectedEstablishmentId, student.associated_class_id, periodString) fetchEvaluations(
selectedEstablishmentId,
student.associated_class_id,
periodString
)
.then((data) => setEvaluations(data)) .then((data) => setEvaluations(data))
.catch((error) => logger.error('Erreur lors du fetch des évaluations:', error)); .catch((error) =>
logger.error('Erreur lors du fetch des évaluations:', error)
);
// Load student's evaluation scores // Load student's evaluation scores
fetchStudentEvaluations(studentId, null, periodString, null) fetchStudentEvaluations(studentId, null, periodString, null)
.then((data) => setStudentEvaluationsData(data)) .then((data) => setStudentEvaluationsData(data))
.catch((error) => logger.error('Erreur lors du fetch des notes:', error)); .catch((error) =>
logger.error('Erreur lors du fetch des notes:', error)
);
} }
}, [student, selectedPeriod, selectedEstablishmentId]); }, [student, selectedPeriod, selectedEstablishmentId]);
@ -182,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(() => {
@ -193,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) => {
@ -210,8 +229,16 @@ export default function StudentGradesPage() {
try { try {
await updateStudentEvaluation(studentEvalId, data, csrfToken); await updateStudentEvaluation(studentEvalId, data, csrfToken);
// Reload student evaluations // Reload student evaluations
const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency); const periodString = getPeriodString(
const updatedData = await fetchStudentEvaluations(studentId, null, periodString, null); selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
const updatedData = await fetchStudentEvaluations(
studentId,
null,
periodString,
null
);
setStudentEvaluationsData(updatedData); setStudentEvaluationsData(updatedData);
} catch (error) { } catch (error) {
logger.error('Erreur lors de la modification de la note:', error); logger.error('Erreur lors de la modification de la note:', error);
@ -237,7 +264,7 @@ export default function StudentGradesPage() {
<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-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">
{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-emerald-200 shadow"
/> />

View File

@ -1,21 +1,35 @@
'use client'; 'use client';
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Award, Eye, Search, BarChart2, X, Pencil, Trash2, Save, Download } from 'lucide-react'; import {
Award,
Eye,
Search,
BarChart2,
X,
Pencil,
Trash2,
Save,
Download
} from 'lucide-react';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import Table from '@/components/Table'; import Table from '@/components/Table';
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,
} 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 {
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 { useCsrfToken } from '@/context/CsrfContext';
@ -48,9 +62,17 @@ function calcCompetencyStats(data) {
const total = scores.length; const total = scores.length;
return { return {
acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100), acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100),
inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100), inProgress: Math.round(
notAcquired: Math.round((scores.filter((s) => s === 1).length / total) * 100), (scores.filter((s) => s === 2).length / total) * 100
notEvaluated: Math.round((scores.filter((s) => s === null || s === undefined || s === 0).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
),
}; };
} }
@ -71,10 +93,26 @@ function getPeriodColumns(frequency) {
} }
const COMPETENCY_COLUMNS = [ const COMPETENCY_COLUMNS = [
{ key: 'acquired', label: 'Acquises', color: 'bg-emerald-100 text-emerald-700' }, {
{ key: 'inProgress', label: 'En cours', color: 'bg-yellow-100 text-yellow-700' }, key: 'acquired',
{ key: 'notAcquired', label: 'Non acquises', color: 'bg-red-100 text-red-600' }, label: 'Acquises',
{ key: 'notEvaluated', label: 'Non évaluées', color: 'bg-gray-100 text-gray-600' }, color: 'bg-emerald-100 text-emerald-700',
},
{
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) {
@ -103,13 +141,13 @@ function getCurrentPeriodValue(frequency) {
function PercentBadge({ value, loading, color }) { 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 badgeColor = color || ( const badgeColor =
value >= 75 color ||
(value >= 75
? 'bg-emerald-100 text-emerald-700' ? 'bg-emerald-100 text-emerald-700'
: 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 ${badgeColor}`} className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${badgeColor}`}
@ -206,7 +244,9 @@ export default function Page() {
if (data?.data) { if (data?.data) {
data.data.forEach((d) => data.data.forEach((d) =>
d.categories.forEach((c) => d.categories.forEach((c) =>
c.competences.forEach((comp) => studentScores[studentId].push(comp.score)) c.competences.forEach((comp) =>
studentScores[studentId].push(comp.score)
)
) )
); );
} }
@ -219,10 +259,21 @@ export default function Page() {
} else { } else {
const total = scores.length; const total = scores.length;
map[studentId] = { map[studentId] = {
acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100), acquired: Math.round(
inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100), (scores.filter((s) => s === 3).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), 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
),
}; };
} }
}); });
@ -317,15 +368,31 @@ export default function Page() {
const handleSaveEval = async (evalItem) => { const handleSaveEval = async (evalItem) => {
try { try {
await updateStudentEvaluation(evalItem.id, { await updateStudentEvaluation(
score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)), evalItem.id,
is_absent: editAbsent, {
}, csrfToken); score: editAbsent
? null
: editScore === ''
? null
: parseFloat(editScore),
is_absent: editAbsent,
},
csrfToken
);
// Update local state // Update local state
setStudentEvaluations((prev) => setStudentEvaluations((prev) =>
prev.map((e) => prev.map((e) =>
e.id === evalItem.id e.id === evalItem.id
? { ...e, score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)), is_absent: editAbsent } ? {
...e,
score: editAbsent
? null
: editScore === ''
? null
: parseFloat(editScore),
is_absent: editAbsent,
}
: e : e
) )
); );
@ -373,7 +440,10 @@ export default function Page() {
{ 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 },
...COMPETENCY_COLUMNS.map(({ label }) => ({ name: label, transform: () => null })), ...COMPETENCY_COLUMNS.map(({ label }) => ({
name: label,
transform: () => null,
})),
{ name: 'Absences', transform: () => null }, { name: 'Absences', transform: () => null },
{ name: 'Actions', transform: () => null }, { name: 'Actions', transform: () => null },
]; ];
@ -386,13 +456,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"
/> />
@ -400,7 +470,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>
)} )}
@ -419,7 +490,9 @@ 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-emerald-700 hover:underline font-medium"
> >
@ -440,7 +513,10 @@ export default function Page() {
return ( return (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<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"
> >
@ -540,10 +616,12 @@ export default function Page() {
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50"> <div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
<div> <div>
<h2 className="text-lg font-semibold text-gray-800"> <h2 className="text-lg font-semibold text-gray-800">
Notes de {gradesModalStudent.first_name} {gradesModalStudent.last_name} Notes de {gradesModalStudent.first_name}{' '}
{gradesModalStudent.last_name}
</h2> </h2>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{gradesModalStudent.associated_class_name || 'Classe non assignée'} {gradesModalStudent.associated_class_name ||
'Classe non assignée'}
</p> </p>
</div> </div>
<button <button
@ -568,25 +646,38 @@ export default function Page() {
<div className="space-y-6"> <div className="space-y-6">
{/* Résumé des moyennes */} {/* Résumé des moyennes */}
{(() => { {(() => {
const subjectAverages = Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => { const subjectAverages = Object.entries(groupedBySubject)
const scores = evaluations .map(([subject, { color, evaluations }]) => {
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent) const scores = evaluations
.map(e => parseFloat(e.score)) .filter(
.filter(s => !isNaN(s)); (e) =>
const avg = scores.length e.score !== null &&
? scores.reduce((sum, s) => sum + s, 0) / scores.length e.score !== undefined &&
: null; !e.is_absent
return { subject, color, avg }; )
}).filter(s => s.avg !== null && !isNaN(s.avg)); .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 const overallAvg = subjectAverages.length
? (subjectAverages.reduce((sum, s) => sum + s.avg, 0) / subjectAverages.length).toFixed(1) ? (
subjectAverages.reduce((sum, s) => sum + s.avg, 0) /
subjectAverages.length
).toFixed(1)
: null; : null;
return ( return (
<div className="bg-gradient-to-r from-emerald-50 to-blue-50 rounded-lg p-4 border border-emerald-100"> <div className="bg-gradient-to-r from-emerald-50 to-blue-50 rounded-lg p-4 border border-emerald-100">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-600">Résumé</span> <span className="text-sm font-medium text-gray-600">
Résumé
</span>
{overallAvg !== null && ( {overallAvg !== null && (
<span className="text-lg font-bold text-emerald-700"> <span className="text-lg font-bold text-emerald-700">
Moyenne générale : {overallAvg}/20 Moyenne générale : {overallAvg}/20
@ -603,7 +694,9 @@ export default function Page() {
className="w-2.5 h-2.5 rounded-full" className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
></span> ></span>
<span className="text-sm text-gray-700">{subject}</span> <span className="text-sm text-gray-700">
{subject}
</span>
<span className="text-sm font-semibold text-gray-800"> <span className="text-sm font-semibold text-gray-800">
{avg.toFixed(1)} {avg.toFixed(1)}
</span> </span>
@ -614,134 +707,175 @@ export default function Page() {
); );
})()} })()}
{Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => { {Object.entries(groupedBySubject).map(
const scores = evaluations ([subject, { color, evaluations }]) => {
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent) const scores = evaluations
.map(e => parseFloat(e.score)) .filter(
.filter(s => !isNaN(s)); (e) =>
const avg = scores.length e.score !== null &&
? (scores.reduce((sum, s) => sum + s, 0) / scores.length).toFixed(1) e.score !== undefined &&
: null; !e.is_absent
return ( )
<div key={subject} className="border rounded-lg overflow-hidden"> .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 <div
className="flex items-center justify-between px-4 py-3" key={subject}
style={{ backgroundColor: `${color}20` }} className="border rounded-lg overflow-hidden"
> >
<div className="flex items-center gap-2"> <div
<span className="flex items-center justify-between px-4 py-3"
className="w-3 h-3 rounded-full" style={{ backgroundColor: `${color}20` }}
style={{ backgroundColor: color }} >
></span> <div className="flex items-center gap-2">
<span className="font-semibold text-gray-800">{subject}</span> <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> </div>
{avg !== null && ( <table className="w-full text-sm">
<span className="text-sm font-bold text-gray-700"> <thead className="bg-gray-50">
Moyenne : {avg} <tr>
</span> <th className="text-left px-4 py-2 font-medium text-gray-600">
)} Évaluation
</div> </th>
<table className="w-full text-sm"> <th className="text-left px-4 py-2 font-medium text-gray-600">
<thead className="bg-gray-50"> Période
<tr> </th>
<th className="text-left px-4 py-2 font-medium text-gray-600">Évaluation</th> <th className="text-right px-4 py-2 font-medium text-gray-600">
<th className="text-left px-4 py-2 font-medium text-gray-600">Période</th> Note
<th className="text-right px-4 py-2 font-medium text-gray-600">Note</th> </th>
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">Actions</th> <th className="text-center px-4 py-2 font-medium text-gray-600 w-24">
</tr> Actions
</thead> </th>
<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-emerald-600 hover:bg-emerald-50 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> </tr>
);})} </thead>
</tbody> <tbody>
</table> {evaluations.map((evalItem) => {
</div> 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-emerald-600 hover:bg-emerald-50 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>
)} )}
</div> </div>

View File

@ -34,12 +34,13 @@ 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';
export default function CreateSubscriptionPage() { export default function CreateSubscriptionPage() {
@ -181,7 +182,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) {
@ -530,7 +533,7 @@ export default function CreateSubscriptionPage() {
'Succès' 'Succès'
); );
router.push(FE_ADMIN_SUBSCRIPTIONS_URL); router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
}) })
.catch((error) => { .catch((error) => {
setIsLoading(false); setIsLoading(false);
logger.error('Erreur lors de la mise à jour du dossier:', error); logger.error('Erreur lors de la mise à jour du dossier:', error);
@ -714,7 +717,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
@ -884,12 +890,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"
/> />

View File

@ -37,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';
@ -114,15 +114,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);
}) })
@ -713,12 +727,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"
/> />
@ -953,7 +967,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

@ -19,7 +19,7 @@ import {
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
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';
@ -139,12 +139,12 @@ export default function ParentHomePage() {
<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"
/> />
@ -225,7 +225,7 @@ export default function ParentHomePage() {
<Eye className="h-5 w-5" /> <Eye className="h-5 w-5" />
</button> </button>
<a <a
href={`${BASE_URL}${row.sepa_file}`} href={getSecureFileUrl(row.sepa_file)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700" className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700"

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 { Inter, Manrope } from 'next/font/google';
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 = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
const manrope = Manrope({
subsets: ['latin'],
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

@ -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

@ -2,9 +2,16 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import FormRenderer from '@/components/Form/FormRenderer'; import FormRenderer from '@/components/Form/FormRenderer';
import FileUpload from '@/components/Form/FileUpload'; import FileUpload from '@/components/Form/FileUpload';
import { CheckCircle, Hourglass, FileText, Download, Upload, XCircle } from 'lucide-react'; import {
CheckCircle,
Hourglass,
FileText,
Download,
Upload,
XCircle,
} from 'lucide-react';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url'; import { getSecureFileUrl } from '@/utils/fileUrl';
/** /**
* Composant pour afficher et gérer les formulaires dynamiques d'inscription * Composant pour afficher et gérer les formulaires dynamiques d'inscription
@ -36,8 +43,12 @@ export default function DynamicFormsList({
const dataState = { ...prevData }; const dataState = { ...prevData };
schoolFileTemplates.forEach((tpl) => { schoolFileTemplates.forEach((tpl) => {
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé // Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0; const hasLocalData =
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0; prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
const hasServerData =
existingResponses &&
existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0;
if (!hasLocalData && hasServerData) { if (!hasLocalData && hasServerData) {
// Pas de données locales mais données serveur : utiliser les données serveur // Pas de données locales mais données serveur : utiliser les données serveur
@ -56,7 +67,10 @@ export default function DynamicFormsList({
const validationState = { ...prevValidation }; const validationState = { ...prevValidation };
schoolFileTemplates.forEach((tpl) => { schoolFileTemplates.forEach((tpl) => {
const hasLocalValidation = prevValidation[tpl.id] === true; const hasLocalValidation = prevValidation[tpl.id] === true;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0; const hasServerData =
existingResponses &&
existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0;
if (!hasLocalValidation && hasServerData) { if (!hasLocalValidation && hasServerData) {
// Pas validé localement mais données serveur : marquer comme validé // Pas validé localement mais données serveur : marquer comme validé
@ -76,13 +90,21 @@ export default function DynamicFormsList({
useEffect(() => { useEffect(() => {
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant // Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
const allFormsValid = schoolFileTemplates.every( const allFormsValid = schoolFileTemplates.every(
tpl => tpl.isValidated === true || (tpl) =>
tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) || (formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0) (existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
); );
onValidationChange(allFormsValid); onValidationChange(allFormsValid);
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]); }, [
formsData,
formsValidation,
existingResponses,
schoolFileTemplates,
onValidationChange,
]);
/** /**
* Gère la soumission d'un formulaire individuel * Gère la soumission d'un formulaire individuel
@ -177,9 +199,9 @@ export default function DynamicFormsList({
}); });
} }
} catch (error) { } catch (error) {
logger.error('Erreur lors de l\'upload du fichier :', error); logger.error("Erreur lors de l'upload du fichier :", error);
} }
}; };
const isDynamicForm = (template) => const isDynamicForm = (template) =>
template.formTemplateData && template.formTemplateData &&
@ -205,11 +227,15 @@ export default function DynamicFormsList({
<div className="text-sm text-gray-600 mb-4"> <div className="text-sm text-gray-600 mb-4">
{/* Compteur x/y : inclut les documents validés */} {/* Compteur x/y : inclut les documents validés */}
{ {
schoolFileTemplates.filter(tpl => { schoolFileTemplates.filter((tpl) => {
// Validé ou complété localement // Validé ou complété localement
return tpl.isValidated === true || return (
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) || tpl.isValidated === true ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0); (formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
}).length }).length
} }
{' / '} {' / '}
@ -219,11 +245,13 @@ export default function DynamicFormsList({
{/* Tri des templates par état */} {/* Tri des templates par état */}
{(() => { {(() => {
// Helper pour état // Helper pour état
const getState = tpl => { const getState = (tpl) => {
if (tpl.isValidated === true) return 0; // validé if (tpl.isValidated === true) return 0; // validé
const isCompletedLocally = !!( const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) || (formsData[tpl.id] &&
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0) Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
); );
if (isCompletedLocally) return 1; // complété/en attente if (isCompletedLocally) return 1; // complété/en attente
return 2; // à compléter/refusé return 2; // à compléter/refusé
@ -234,11 +262,17 @@ export default function DynamicFormsList({
return ( return (
<ul className="space-y-2"> <ul className="space-y-2">
{sortedTemplates.map((tpl, index) => { {sortedTemplates.map((tpl, index) => {
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id; const isActive =
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined; schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
const isValidated =
typeof tpl.isValidated === 'boolean'
? tpl.isValidated
: undefined;
const isCompletedLocally = !!( const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) || (formsData[tpl.id] &&
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0) Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
); );
// Statut d'affichage // Statut d'affichage
@ -258,8 +292,12 @@ export default function DynamicFormsList({
borderClass = 'border border-emerald-200'; borderClass = 'border border-emerald-200';
textClass = 'text-emerald-700'; textClass = 'text-emerald-700';
bgClass = isActive ? 'bg-emerald-200' : bgClass; bgClass = isActive ? 'bg-emerald-200' : bgClass;
borderClass = isActive ? 'border border-emerald-300' : borderClass; borderClass = isActive
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass; ? 'border border-emerald-300'
: borderClass;
textClass = isActive
? 'text-emerald-900 font-semibold'
: textClass;
canEdit = false; canEdit = false;
} else if (isValidated === false) { } else if (isValidated === false) {
if (isCompletedLocally) { if (isCompletedLocally) {
@ -267,16 +305,24 @@ export default function DynamicFormsList({
statusColor = 'orange'; statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />; icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50'; bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200'; borderClass = isActive
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700'; ? 'border border-orange-300'
: 'border border-orange-200';
textClass = isActive
? 'text-orange-900 font-semibold'
: 'text-orange-700';
canEdit = true; canEdit = true;
} else { } else {
statusLabel = 'Refusé'; statusLabel = 'Refusé';
statusColor = 'red'; statusColor = 'red';
icon = <XCircle className="w-5 h-5 text-red-500" />; icon = <XCircle className="w-5 h-5 text-red-500" />;
bgClass = isActive ? 'bg-red-200' : 'bg-red-50'; bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
borderClass = isActive ? 'border border-red-300' : 'border border-red-200'; borderClass = isActive
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700'; ? 'border border-red-300'
: 'border border-red-200';
textClass = isActive
? 'text-red-900 font-semibold'
: 'text-red-700';
canEdit = true; canEdit = true;
} }
} else { } else {
@ -285,8 +331,12 @@ export default function DynamicFormsList({
statusColor = 'orange'; statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />; icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50'; bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200'; borderClass = isActive
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700'; ? 'border border-orange-300'
: 'border border-orange-200';
textClass = isActive
? 'text-orange-900 font-semibold'
: 'text-orange-700';
canEdit = true; canEdit = true;
} else { } else {
statusLabel = 'À compléter'; statusLabel = 'À compléter';
@ -294,7 +344,9 @@ export default function DynamicFormsList({
icon = <Hourglass className="w-5 h-5 text-gray-400" />; icon = <Hourglass className="w-5 h-5 text-gray-400" />;
bgClass = isActive ? 'bg-gray-200' : ''; bgClass = isActive ? 'bg-gray-200' : '';
borderClass = isActive ? 'border border-gray-300' : ''; borderClass = isActive ? 'border border-gray-300' : '';
textClass = isActive ? 'text-gray-900 font-semibold' : 'text-gray-600'; textClass = isActive
? 'text-gray-900 font-semibold'
: 'text-gray-600';
canEdit = true; canEdit = true;
} }
} }
@ -307,13 +359,22 @@ export default function DynamicFormsList({
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold` ? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
: `${bgClass} ${borderClass} ${textClass}` : `${bgClass} ${borderClass} ${textClass}`
}`} }`}
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))} onClick={() =>
setCurrentTemplateIndex(
schoolFileTemplates.findIndex((t) => t.id === tpl.id)
)
}
> >
<span className="mr-3">{icon}</span> <span className="mr-3">{icon}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm truncate flex items-center gap-2"> <div className="text-sm truncate flex items-center gap-2">
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'} {tpl.formMasterData?.title ||
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}> tpl.title ||
tpl.name ||
'Formulaire sans nom'}
<span
className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}
>
{statusLabel} {statusLabel}
</span> </span>
</div> </div>
@ -337,38 +398,56 @@ export default function DynamicFormsList({
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-gray-800"> <h3 className="text-xl font-semibold text-gray-800">
{currentTemplate.name} {currentTemplate.name}
</h3> </h3>
{/* Label d'état */} {/* Label d'état */}
{currentTemplate.isValidated === true ? ( {currentTemplate.isValidated === true ? (
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span> <span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) || Validé
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? ( </span>
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</span> ) : (formsData[currentTemplate.id] &&
Object.keys(formsData[currentTemplate.id]).length > 0) ||
(existingResponses[currentTemplate.id] &&
Object.keys(existingResponses[currentTemplate.id]).length >
0) ? (
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">
Complété
</span>
) : ( ) : (
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">Refusé</span> <span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
Refusé
</span>
)} )}
</div> </div>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
{currentTemplate.formTemplateData?.description || {currentTemplate.formTemplateData?.description ||
currentTemplate.description || ''} currentTemplate.description ||
''}
</p> </p>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500 mt-1">
Formulaire {(() => { Formulaire{' '}
{(() => {
// Trouver l'index du template courant dans la liste triée // Trouver l'index du template courant dans la liste triée
const getState = tpl => { const getState = (tpl) => {
if (tpl.isValidated === true) return 0; if (tpl.isValidated === true) return 0;
const isCompletedLocally = !!( const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) || (formsData[tpl.id] &&
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0) Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
); );
if (isCompletedLocally) return 1; if (isCompletedLocally) return 1;
return 2; return 2;
}; };
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b)); const sortedTemplates = [...schoolFileTemplates].sort(
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id); (a, b) => getState(a) - getState(b)
);
const idx = sortedTemplates.findIndex(
(tpl) => tpl.id === currentTemplate.id
);
return idx + 1; return idx + 1;
})()} sur {schoolFileTemplates.length} })()}{' '}
sur {schoolFileTemplates.length}
</div> </div>
</div> </div>
@ -405,14 +484,15 @@ export default function DynamicFormsList({
// Formulaire existant (PDF, image, etc.) // Formulaire existant (PDF, image, etc.)
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
{/* Cas validé : affichage en iframe */} {/* Cas validé : affichage en iframe */}
{currentTemplate.isValidated === true && currentTemplate.file && ( {currentTemplate.isValidated === true &&
<iframe currentTemplate.file && (
src={`${BASE_URL}${currentTemplate.file}`} <iframe
title={currentTemplate.name} src={getSecureFileUrl(currentTemplate.file)}
className="w-full" title={currentTemplate.name}
style={{ height: '600px', border: 'none' }} className="w-full"
/> style={{ height: '600px', border: 'none' }}
)} />
)}
{/* Cas non validé : bouton télécharger + upload */} {/* Cas non validé : bouton télécharger + upload */}
{currentTemplate.isValidated !== true && ( {currentTemplate.isValidated !== true && (
@ -420,9 +500,7 @@ export default function DynamicFormsList({
{/* Bouton télécharger le document source */} {/* Bouton télécharger le document source */}
{currentTemplate.file && ( {currentTemplate.file && (
<a <a
href={`${BASE_URL}${currentTemplate.file}`} href={getSecureFileUrl(currentTemplate.file)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition" className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
download download
> >
@ -436,7 +514,9 @@ export default function DynamicFormsList({
<FileUpload <FileUpload
key={currentTemplate.id} key={currentTemplate.id}
selectionMessage={'Sélectionnez le fichier du document'} selectionMessage={'Sélectionnez le fichier du document'}
onFileSelect={(file) => handleUpload(file, currentTemplate)} onFileSelect={(file) =>
handleUpload(file, currentTemplate)
}
required required
enable={true} enable={true}
/> />

View File

@ -5,7 +5,7 @@ import {
fetchSchoolFileTemplatesFromRegistrationFiles, fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles, fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import { BASE_URL } from '@/utils/Url'; import { getSecureFileUrl } from '@/utils/fileUrl';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
const FilesModal = ({ const FilesModal = ({
@ -56,27 +56,27 @@ const FilesModal = ({
registrationFile: selectedRegisterForm.registration_file registrationFile: selectedRegisterForm.registration_file
? { ? {
name: 'Fiche élève', name: 'Fiche élève',
url: `${BASE_URL}${selectedRegisterForm.registration_file}`, url: getSecureFileUrl(selectedRegisterForm.registration_file),
} }
: null, : null,
fusionFile: selectedRegisterForm.fusion_file fusionFile: selectedRegisterForm.fusion_file
? { ? {
name: 'Documents fusionnés', name: 'Documents fusionnés',
url: `${BASE_URL}${selectedRegisterForm.fusion_file}`, url: getSecureFileUrl(selectedRegisterForm.fusion_file),
} }
: null, : null,
schoolFiles: fetchedSchoolFiles.map((file) => ({ schoolFiles: fetchedSchoolFiles.map((file) => ({
name: file.name || 'Document scolaire', name: file.name || 'Document scolaire',
url: file.file ? `${BASE_URL}${file.file}` : null, url: file.file ? getSecureFileUrl(file.file) : null,
})), })),
parentFiles: parentFiles.map((file) => ({ parentFiles: parentFiles.map((file) => ({
name: file.master_name || 'Document parent', name: file.master_name || 'Document parent',
url: file.file ? `${BASE_URL}${file.file}` : null, url: file.file ? getSecureFileUrl(file.file) : null,
})), })),
sepaFile: selectedRegisterForm.sepa_file sepaFile: selectedRegisterForm.sepa_file
? { ? {
name: 'Mandat SEPA', name: 'Mandat SEPA',
url: `${BASE_URL}${selectedRegisterForm.sepa_file}`, url: getSecureFileUrl(selectedRegisterForm.sepa_file),
} }
: null, : null,
}; };

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import FileUpload from '@/components/Form/FileUpload'; import FileUpload from '@/components/Form/FileUpload';
import { Upload, Eye, Trash2, FileText } from 'lucide-react'; import { Upload, Eye, Trash2, FileText } from 'lucide-react';
import { BASE_URL } from '@/utils/Url'; import { getSecureFileUrl } from '@/utils/fileUrl';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
@ -230,7 +230,7 @@ export default function FilesToUpload({
<div className="mt-4"> <div className="mt-4">
{actionType === 'view' && selectedFile.fileName ? ( {actionType === 'view' && selectedFile.fileName ? (
<iframe <iframe
src={`${BASE_URL}${selectedFile.fileName}`} src={getSecureFileUrl(selectedFile.fileName)}
title="Document Viewer" title="Document Viewer"
className="w-full" className="w-full"
style={{ style={{

View File

@ -7,7 +7,7 @@ import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
import FileUpload from '@/components/Form/FileUpload'; import FileUpload from '@/components/Form/FileUpload';
import { BASE_URL } from '@/utils/Url'; import { getSecureFileUrl } from '@/utils/fileUrl';
import { levels, genders } from '@/utils/constants'; import { levels, genders } from '@/utils/constants';
export default function StudentInfoForm({ export default function StudentInfoForm({
@ -57,7 +57,7 @@ export default function StudentInfoForm({
// Convertir la photo en fichier binaire si elle est un chemin ou une URL // Convertir la photo en fichier binaire si elle est un chemin ou une URL
if (photoPath && typeof photoPath === 'string') { if (photoPath && typeof photoPath === 'string') {
fetch(`${BASE_URL}${photoPath}`) fetch(getSecureFileUrl(photoPath))
.then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {
throw new Error('Erreur lors de la récupération de la photo.'); throw new Error('Erreur lors de la récupération de la photo.');

View File

@ -3,11 +3,11 @@ import React, { useState, useEffect } from 'react';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import SelectChoice from '@/components/Form/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import { BASE_URL } from '@/utils/Url';
import { import {
fetchSchoolFileTemplatesFromRegistrationFiles, fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles, fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import { getSecureFileUrl } from '@/utils/fileUrl';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { School, FileText } from 'lucide-react'; import { School, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
@ -49,15 +49,18 @@ export default function ValidateSubscription({
// Parent templates // Parent templates
parentFileTemplates.forEach((tpl, i) => { parentFileTemplates.forEach((tpl, i) => {
if (typeof tpl.isValidated === 'boolean') { if (typeof tpl.isValidated === 'boolean') {
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated ? 'accepted' : 'refused'; newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated
? 'accepted'
: 'refused';
} }
}); });
setDocStatuses(s => ({ ...s, ...newStatuses })); setDocStatuses((s) => ({ ...s, ...newStatuses }));
}, [schoolFileTemplates, parentFileTemplates]); }, [schoolFileTemplates, parentFileTemplates]);
const [showRefusedPopup, setShowRefusedPopup] = useState(false); const [showRefusedPopup, setShowRefusedPopup] = useState(false);
// Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée) // Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée)
const [showFinalValidationPopup, setShowFinalValidationPopup] = useState(false); const [showFinalValidationPopup, setShowFinalValidationPopup] =
useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
associated_class: null, associated_class: null,
@ -131,7 +134,7 @@ export default function ValidateSubscription({
const handleRefuseDossier = () => { const handleRefuseDossier = () => {
// Message clair avec la liste des documents refusés // Message clair avec la liste des documents refusés
let notes = 'Dossier non validé pour les raisons suivantes :\n'; let notes = 'Dossier non validé pour les raisons suivantes :\n';
notes += refusedDocs.map(doc => `- ${doc.name}`).join('\n'); notes += refusedDocs.map((doc) => `- ${doc.name}`).join('\n');
const data = { const data = {
status: 2, status: 2,
notes, notes,
@ -177,10 +180,18 @@ export default function ValidateSubscription({
.filter((doc, idx) => docStatuses[idx] === 'refused'); .filter((doc, idx) => docStatuses[idx] === 'refused');
// Récupère la liste des documents à cocher (hors fiche élève) // Récupère la liste des documents à cocher (hors fiche élève)
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0); const docIndexes = allTemplates
const allChecked = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused'); .map((_, idx) => idx)
const allValidated = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted'); .filter((idx) => idx !== 0);
const hasRefused = docIndexes.some(idx => docStatuses[idx] === 'refused'); const allChecked =
docIndexes.length > 0 &&
docIndexes.every(
(idx) => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused'
);
const allValidated =
docIndexes.length > 0 &&
docIndexes.every((idx) => docStatuses[idx] === 'accepted');
const hasRefused = docIndexes.some((idx) => docStatuses[idx] === 'refused');
logger.debug(allTemplates); logger.debug(allTemplates);
return ( return (
@ -202,7 +213,7 @@ export default function ValidateSubscription({
{allTemplates[currentTemplateIndex].name || 'Document sans nom'} {allTemplates[currentTemplateIndex].name || 'Document sans nom'}
</h3> </h3>
<iframe <iframe
src={`${BASE_URL}${allTemplates[currentTemplateIndex].file}`} src={getSecureFileUrl(allTemplates[currentTemplateIndex].file)}
title={ title={
allTemplates[currentTemplateIndex].type === 'main' allTemplates[currentTemplateIndex].type === 'main'
? 'Document Principal' ? 'Document Principal'
@ -252,18 +263,32 @@ export default function ValidateSubscription({
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1 className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`} ${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
aria-pressed={docStatuses[index] === 'accepted'} aria-pressed={docStatuses[index] === 'accepted'}
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'accepted' })); setDocStatuses((s) => ({
...s,
[index]: 'accepted',
}));
// Appel API pour valider le document // Appel API pour valider le document
if (handleValidateOrRefuseDoc) { if (handleValidateOrRefuseDoc) {
let template = null; let template = null;
let type = null; let type = null;
if (index > 0 && index <= schoolFileTemplates.length) { if (
index > 0 &&
index <= schoolFileTemplates.length
) {
template = schoolFileTemplates[index - 1]; template = schoolFileTemplates[index - 1];
type = 'school'; type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) { } else if (
template = parentFileTemplates[index - 1 - schoolFileTemplates.length]; index > schoolFileTemplates.length &&
index <=
schoolFileTemplates.length +
parentFileTemplates.length
) {
template =
parentFileTemplates[
index - 1 - schoolFileTemplates.length
];
type = 'parent'; type = 'parent';
} }
if (template && template.id) { if (template && template.id) {
@ -284,18 +309,29 @@ export default function ValidateSubscription({
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1 className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`} ${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
aria-pressed={docStatuses[index] === 'refused'} aria-pressed={docStatuses[index] === 'refused'}
onClick={e => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'refused' })); setDocStatuses((s) => ({ ...s, [index]: 'refused' }));
// Appel API pour refuser le document // Appel API pour refuser le document
if (handleValidateOrRefuseDoc) { if (handleValidateOrRefuseDoc) {
let template = null; let template = null;
let type = null; let type = null;
if (index > 0 && index <= schoolFileTemplates.length) { if (
index > 0 &&
index <= schoolFileTemplates.length
) {
template = schoolFileTemplates[index - 1]; template = schoolFileTemplates[index - 1];
type = 'school'; type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) { } else if (
template = parentFileTemplates[index - 1 - schoolFileTemplates.length]; index > schoolFileTemplates.length &&
index <=
schoolFileTemplates.length +
parentFileTemplates.length
) {
template =
parentFileTemplates[
index - 1 - schoolFileTemplates.length
];
type = 'parent'; type = 'parent';
} }
if (template && template.id) { if (template && template.id) {
@ -351,7 +387,7 @@ export default function ValidateSubscription({
<div className="mt-auto py-4"> <div className="mt-auto py-4">
<Button <Button
text="Soumettre" text="Soumettre"
onClick={e => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé) // 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
// 2. Si tous cochés et au moins un refusé : popup refus // 2. Si tous cochés et au moins un refusé : popup refus
@ -367,12 +403,14 @@ export default function ValidateSubscription({
}} }}
primary primary
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${ className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
!allChecked || (allChecked && allValidated && !formData.associated_class) !allChecked ||
(allChecked && allValidated && !formData.associated_class)
? '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-emerald-500 text-white hover:bg-emerald-600'
}`} }`}
disabled={ disabled={
!allChecked || (allChecked && allValidated && !formData.associated_class) !allChecked ||
(allChecked && allValidated && !formData.associated_class)
} }
/> />
</div> </div>
@ -391,7 +429,7 @@ export default function ValidateSubscription({
<span className="font-semibold text-blue-700">{email}</span> <span className="font-semibold text-blue-700">{email}</span>
{' avec la liste des documents non validés :'} {' avec la liste des documents non validés :'}
<ul className="list-disc ml-6 mt-2"> <ul className="list-disc ml-6 mt-2">
{refusedDocs.map(doc => ( {refusedDocs.map((doc) => (
<li key={doc.idx}>{doc.name}</li> <li key={doc.idx}>{doc.name}</li>
))} ))}
</ul> </ul>

View File

@ -9,9 +9,7 @@ import { usePopup } from '@/context/PopupContext';
import { getRightStr } from '@/utils/rights'; import { getRightStr } from '@/utils/rights';
import { ChevronDown } from 'lucide-react'; // Import de l'icône import { ChevronDown } from 'lucide-react'; // Import de l'icône
import Image from 'next/image'; // Import du composant Image import Image from 'next/image'; // Import du composant Image
import { import { getSecureFileUrl } from '@/utils/fileUrl';
BASE_URL,
} from '@/utils/Url';
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => { const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
const { const {
@ -24,7 +22,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
setSelectedEstablishmentEvaluationFrequency, setSelectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentTotalCapacity, setSelectedEstablishmentTotalCapacity,
selectedEstablishmentLogo, selectedEstablishmentLogo,
setSelectedEstablishmentLogo setSelectedEstablishmentLogo,
} = useEstablishment(); } = useEstablishment();
const { isConnected, connectionStatus } = useChatConnection(); const { isConnected, connectionStatus } = useChatConnection();
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
@ -38,8 +36,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
user.roles[roleId].establishment__evaluation_frequency; user.roles[roleId].establishment__evaluation_frequency;
const establishmentTotalCapacity = const establishmentTotalCapacity =
user.roles[roleId].establishment__total_capacity; user.roles[roleId].establishment__total_capacity;
const establishmentLogo = const establishmentLogo = user.roles[roleId].establishment__logo;
user.roles[roleId].establishment__logo;
setProfileRole(role); setProfileRole(role);
setSelectedEstablishmentId(establishmentId); setSelectedEstablishmentId(establishmentId);
setSelectedEstablishmentEvaluationFrequency( setSelectedEstablishmentEvaluationFrequency(
@ -108,7 +105,11 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
<div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100"> <div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
<div className="relative"> <div className="relative">
<Image <Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)} src={
selectedEstablishmentLogo
? getSecureFileUrl(selectedEstablishmentLogo)
: getGravatarUrl(user?.email)
}
alt="Profile" alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md" className="w-8 h-8 rounded-full object-cover shadow-md"
width={32} width={32}
@ -128,7 +129,11 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
<div className="flex items-center gap-2 cursor-pointer px-4 bg-white h-24"> <div className="flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
<div className="relative"> <div className="relative">
<Image <Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)} src={
selectedEstablishmentLogo
? getSecureFileUrl(selectedEstablishmentLogo)
: getGravatarUrl(user?.email)
}
alt="Profile" alt="Profile"
className="w-16 h-16 rounded-full object-cover shadow-md" className="w-16 h-16 rounded-full object-cover shadow-md"
width={64} width={64}
@ -185,15 +190,23 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
label: ( label: (
<div className="flex items-center text-left"> <div className="flex items-center text-left">
<Image <Image
src={establishment.logo ? `${BASE_URL}${establishment.logo}` : getGravatarUrl(user?.email)} src={
establishment.logo
? getSecureFileUrl(establishment.logo)
: getGravatarUrl(user?.email)
}
alt="Profile" alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md mr-3" className="w-8 h-8 rounded-full object-cover shadow-md mr-3"
width={32} width={32}
height={32} height={32}
/> />
<div> <div>
<div className="font-bold ext-sm text-gray-500">{establishment.name}</div> <div className="font-bold ext-sm text-gray-500">
<div className="italic text-sm text-gray-500">{getRightStr(establishment.role_type)}</div> {establishment.name}
</div>
<div className="italic text-sm text-gray-500">
{getRightStr(establishment.role_type)}
</div>
</div> </div>
</div> </div>
), ),
@ -212,9 +225,10 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
] ]
} }
buttonClassName="w-full" buttonClassName="w-full"
menuClassName={compact menuClassName={
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50' compact
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10' ? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
} }
dropdownOpen={dropdownOpen} dropdownOpen={dropdownOpen}
setDropdownOpen={setDropdownOpen} setDropdownOpen={setDropdownOpen}

View File

@ -0,0 +1,54 @@
import { getToken } from 'next-auth/jwt';
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const token = await getToken({
req,
secret: process.env.AUTH_SECRET,
cookieName: 'n3wtschool_session_token',
});
if (!token?.token) {
return res.status(401).json({ error: 'Non authentifié' });
}
const { path } = req.query;
if (!path) {
return res.status(400).json({ error: 'Le paramètre "path" est requis' });
}
try {
const backendUrl = `${BACKEND_URL}/Common/serve-file/?path=${encodeURIComponent(path)}`;
const backendRes = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token.token}`,
Connection: 'close',
},
});
if (!backendRes.ok) {
return res.status(backendRes.status).json({
error: `Erreur backend: ${backendRes.status}`,
});
}
const contentType =
backendRes.headers.get('content-type') || 'application/octet-stream';
const contentDisposition = backendRes.headers.get('content-disposition');
res.setHeader('Content-Type', contentType);
if (contentDisposition) {
res.setHeader('Content-Disposition', contentDisposition);
}
const buffer = Buffer.from(await backendRes.arrayBuffer());
return res.send(buffer);
} catch {
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
}
}

View File

@ -0,0 +1,25 @@
/**
* Construit l'URL sécurisée pour accéder à un fichier media via le proxy Next.js.
* Le proxy `/api/download` injecte le JWT côté serveur avant de transmettre au backend Django.
*
* Gère les chemins relatifs ("/data/some/file.pdf") et les URLs absolues du backend
* ("http://backend:8000/data/some/file.pdf").
*
* @param {string} filePath - Chemin ou URL complète du fichier
* @returns {string|null} URL vers /api/download?path=... ou null si pas de chemin
*/
export const getSecureFileUrl = (filePath) => {
if (!filePath) return null;
// Si c'est une URL absolue, extraire le chemin /data/...
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
try {
const url = new URL(filePath);
filePath = url.pathname;
} catch {
return null;
}
}
return `/api/download?path=${encodeURIComponent(filePath)}`;
};

View File

@ -4,7 +4,25 @@ module.exports = {
'./src/**/*.{js,jsx,ts,tsx}', // Ajustez ce chemin selon la structure de votre projet './src/**/*.{js,jsx,ts,tsx}', // Ajustez ce chemin selon la structure de votre projet
], ],
theme: { theme: {
extend: {}, extend: {
colors: {
primary: '#059669',
secondary: '#064E3B',
tertiary: '#10B981',
neutral: '#F8FAFC',
},
fontFamily: {
headline: ['var(--font-manrope)', 'Manrope', 'sans-serif'],
body: ['var(--font-inter)', 'Inter', 'sans-serif'],
label: ['var(--font-inter)', 'Inter', 'sans-serif'],
},
borderRadius: {
DEFAULT: '4px',
sm: '2px',
md: '6px',
lg: '8px',
},
},
}, },
plugins: [require('@tailwindcss/forms')], plugins: [require('@tailwindcss/forms')],
}; };

293
docs/design-system.md Normal file
View File

@ -0,0 +1,293 @@
# Design System — N3WT-SCHOOL
## Vue d'ensemble
Le design system N3WT-SCHOOL définit les tokens visuels et les conventions d'usage pour garantir une interface cohérente sur l'ensemble du produit.
---
## Couleurs
Les couleurs sont définies comme tokens Tailwind dans `Front-End/tailwind.config.js`.
| Token Tailwind | Valeur hex | Usage |
| -------------- | ---------- | -------------------------------------------------- |
| `primary` | `#059669` | Boutons principaux, CTA, éléments interactifs clés |
| `secondary` | `#064E3B` | Hover des éléments primaires, accents sombres |
| `tertiary` | `#10B981` | Badges, icônes d'accent, highlights |
| `neutral` | `#F8FAFC` | Fonds de page, surfaces, arrière-plans neutres |
### Règles d'usage
- **Ne jamais** utiliser les classes Tailwind brutes `emerald-*`, `green-*` pour les éléments interactifs. Utiliser les tokens (`primary`, `secondary`, `tertiary`).
- Les fonds décoratifs (gradients, illustrations) peuvent conserver les classes Tailwind standards.
- Les états de statut (success, error, warning, info) peuvent conserver leurs couleurs sémantiques (`green`, `red`, `yellow`, `blue`).
### Exemples
```jsx
// Bouton principal
<button className="bg-primary hover:bg-secondary text-white">Valider</button>
// Texte accent
<span className="text-primary font-medium">Voir plus</span>
// Badge
<span className="bg-tertiary/10 text-tertiary">Actif</span>
// Fond de page
<div className="bg-neutral min-h-screen">...</div>
```
---
## Typographie
Les polices sont chargées via `next/font/google` dans `Front-End/src/app/layout.js` et exposées comme variables CSS.
| Rôle | Police | Token Tailwind | Usage |
| --------- | --------- | --------------- | ----------------------------------- |
| Headlines | `Manrope` | `font-headline` | `h1`, `h2`, `h3`, titres de section |
| Body | `Inter` | `font-body` | Paragraphes, contenu (défaut) |
| Labels | `Inter` | `font-label` | Boutons, labels de formulaires |
> `font-body` est appliqué par défaut sur `<body>`. Il n'est donc pas nécessaire de l'ajouter manuellement sur chaque élément de texte courant.
### Exemples
```jsx
// Titre de page
<h1 className="font-headline text-2xl font-bold text-gray-900">Tableau de bord</h1>
// Sous-titre
<h2 className="font-headline text-xl font-semibold text-secondary">Élèves</h2>
// Label de formulaire
<label className="font-label text-sm font-medium text-gray-700">Prénom</label>
```
---
## Rayon de bordure (Border Radius)
Arrondi subtil, niveau 1.
| Token Tailwind | Valeur | Usage |
| -------------- | ------ | ---------------------------------- |
| `rounded-sm` | `2px` | Éléments très petits (badges fins) |
| `rounded` | `4px` | Par défaut — boutons, inputs |
| `rounded-md` | `6px` | Cards, modales |
| `rounded-lg` | `8px` | Grandes surfaces |
> Éviter `rounded-xl` (12px) et `rounded-full` sauf pour les avatars ou indicateurs circulaires.
---
## Espacement
Espacement normal, base 8px (niveau 2). Utiliser les multiples de 4px/8px du système Tailwind standard.
| Classe | Valeur |
| ------ | ------ |
| `p-1` | 4px |
| `p-2` | 8px |
| `p-3` | 12px |
| `p-4` | 16px |
| `p-6` | 24px |
| `p-8` | 32px |
---
## Composants récurrents
### Bouton principal
```jsx
<button className="bg-primary hover:bg-secondary text-white font-label font-medium px-4 py-2 rounded transition-colors">
Action
</button>
```
### Bouton secondaire
```jsx
<button className="border border-primary text-primary hover:bg-primary hover:text-white font-label font-medium px-4 py-2 rounded transition-colors">
Action secondaire
</button>
```
### Card
```jsx
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-4">
<h2 className="font-headline text-lg font-semibold text-gray-900">Titre</h2>
<p className="text-gray-600 mt-2">Contenu</p>
</div>
```
### Badge de statut
```jsx
<span className="inline-flex items-center bg-tertiary/10 text-tertiary text-xs font-label font-medium px-2 py-0.5 rounded">
Actif
</span>
```
### Lien d'action
```jsx
<a className="text-primary hover:text-secondary underline-offset-2 hover:underline transition-colors">
Voir le détail
</a>
```
---
## Configuration Tailwind
Fichier : `Front-End/tailwind.config.js`
```js
theme: {
extend: {
colors: {
primary: '#059669',
secondary: '#064E3B',
tertiary: '#10B981',
neutral: '#F8FAFC',
},
fontFamily: {
headline: ['var(--font-manrope)', 'Manrope', 'sans-serif'],
body: ['var(--font-inter)', 'Inter', 'sans-serif'],
label: ['var(--font-inter)', 'Inter', 'sans-serif'],
},
borderRadius: {
DEFAULT: '4px',
sm: '2px',
md: '6px',
lg: '8px',
},
},
},
```
---
## Icônes
Toutes les icônes utilisent la bibliothèque **`lucide-react`** exclusivement.
```jsx
import { Home, User, ChevronRight } from 'lucide-react';
// Taille standard
<Home size={20} className="text-primary" />
// Avec label accessible
<button className="flex items-center gap-2">
<Plus size={16} />
Ajouter
</button>
```
### Règles icônes
- **Toujours** importer depuis `lucide-react` — jamais d'autres bibliothèques d'icônes.
- Taille par défaut : `size={20}` (inline), `size={24}` (boutons standalone).
- Couleur : via `className="text-*"` — ne jamais utiliser le prop `color`.
- Icônes seules sans texte : ajouter `aria-label` ou wrapper `title` pour l'accessibilité.
---
## Responsive & PWA
L'interface est conçue **mobile-first** pour un usage PWA (Progressive Web App).
### Breakpoints Tailwind
| Préfixe | Largeur min | Contexte |
| -------- | ----------- | ------------------------ |
| _(base)_ | 0px | Mobile (priorité) |
| `sm:` | 640px | Grands mobiles/tablettes |
| `md:` | 768px | Tablettes |
| `lg:` | 1024px | Desktop |
### Principes responsive
- **Toujours** penser desktop en premier — les styles de base ciblent le desktop.
- Les sidebars passent en overlay sur mobile (`md:hidden` / `hidden md:block`).
- Les tableaux utilisent le mode `responsive-table` (classe utilitaire définie dans `tailwind.css`) sur mobile.
- Les grilles : commencer par `grid-cols-1`, étendre avec `sm:grid-cols-2 lg:grid-cols-3`.
- Les textes : tailles mobiles d'abord (`text-sm sm:text-base`), ne jamais forcer une grande taille sur mobile.
- Touch targets : minimum `44px × 44px` pour tous les éléments interactifs (`min-h-[44px]`).
### PWA
- Tous les écrans doivent fonctionner hors-ligne ou en mode dégradé (données en cache).
- Les interactions clés (navigation, actions principales) doivent être accessibles sans rechargement de page.
- Pas de contenu critique uniquement visible au survol (`hover:`) — prévoir une alternative tactile.
### Exemples responsive
```jsx
// Layout page
<div className="px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
// Grille de cards
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
// Titre responsive
<h1 className="font-headline text-xl sm:text-2xl lg:text-3xl font-bold">
// Bouton full-width sur mobile
<button className="w-full sm:w-auto bg-primary text-white px-4 py-2 rounded">
// Navigation mobile/desktop
<nav className="hidden md:flex gap-4">
<nav className="flex md:hidden"> {/* version mobile */}
```
---
## Bibliothèque de composants existants
**Avant de créer un nouveau composant, toujours vérifier s'il en existe un dans `Front-End/src/components/`.**
### Composants disponibles (inventaire)
| Composant | Chemin | Usage |
| --------------- | ----------------------------- | ----------------------------------------- |
| `AlertMessage` | `components/AlertMessage.js` | Messages success / error / warning / info |
| `Modal` | `components/Modal.js` | Fenêtre modale générique |
| `Pagination` | `components/Pagination.js` | Pagination de listes |
| `SectionHeader` | `components/SectionHeader.js` | En-tête de section avec icône |
| `ProgressStep` | `components/ProgressStep.js` | Étapes d'un formulaire multi-step |
| `EventCard` | `components/EventCard.js` | Card d'événement de planning |
| `Calendar/*` | `components/Calendar/` | Vues calendrier (semaine, mois…) |
| `Chat/*` | `components/Chat/` | Interface de messagerie |
| `Evaluation/*` | `components/Evaluation/` | Formulaires d'évaluation |
| `Grades/*` | `components/Grades/` | Affichage et saisie des notes |
| `Form/*` | `components/Form/` | Inputs, selects, composants de formulaire |
| `Admin/*` | `components/Admin/` | Composants spécifiques à l'admin |
| `Charts/*` | `components/Charts/` | Graphiques et visualisations |
### Règles de réutilisation
1. **Chercher avant de créer** : effectuer une recherche dans `components/` avant d'implémenter un nouveau composant.
2. **Étendre, ne pas dupliquer** : si un composant existant est proche mais manque d'une variante, l'étendre via des props — ne pas créer une copie.
3. **Props plutôt que fork** : passer des variantes via `variant`, `size`, `className` plutôt que de dupliquer le JSX.
4. **Appliquer le design system dans les composants** : tout composant existant ou nouveau doit utiliser les tokens (`primary`, `secondary`, `tertiary`, `neutral`, `font-headline`, etc.) — jamais de couleurs codées en dur.
---
## Règles pour les agents IA (Copilot / Claude)
1. **Couleurs interactives** : toujours utiliser `primary`, `secondary`, `tertiary`, `neutral` — jamais `emerald-*` pour les boutons, liens, icônes actives.
2. **Typographie** : utiliser `font-headline` sur les `h1`/`h2`/`h3`. Le `font-body` est le défaut.
3. **Arrondi** : préférer `rounded` (4px) pour les éléments courants. Éviter `rounded-xl` sauf exception justifiée.
4. **Espacement** : respecter la grille 4px/8px. Pas de valeurs arbitraires `p-[13px]`.
5. **Mode** : interface en mode **light** uniquement — ne pas ajouter de support dark mode.
6. **Icônes** : utiliser uniquement `lucide-react` — jamais d'autres bibliothèques d'icônes.
7. **Responsive** : mobile-first — les styles de base ciblent le mobile, les breakpoints `sm:`/`md:`/`lg:` étendent vers le haut.
8. **PWA** : pas d'interactions uniquement au survol, touch targets ≥ 44px, navigation sans rechargement.
9. **Réutilisation** : chercher un composant existant dans `components/` avant d'en créer un nouveau.

View File

@ -5,7 +5,8 @@
"prepare": "husky", "prepare": "husky",
"release": "standard-version", "release": "standard-version",
"update-version": "node scripts/update-version.js", "update-version": "node scripts/update-version.js",
"format": "prettier --write Front-End" "format": "prettier --write Front-End",
"create-establishment": "node scripts/create-establishment.js"
}, },
"standard-version": { "standard-version": {
"scripts": { "scripts": {

View File

@ -0,0 +1,437 @@
#!/usr/bin/env node
/**
* CLI pour créer un ou plusieurs établissements N3WT.
*
* Usage interactif :
* node scripts/create-establishment.js
*
* Usage batch (fichier JSON) :
* node scripts/create-establishment.js --file batch.json
*
* Format du fichier batch :
* {
* "backendUrl": "http://localhost:8080", // optionnel (fallback : URL_DJANGO dans conf/backend.env)
* "apiKey": "TOk3n1234!!", // optionnel (fallback : WEBHOOK_API_KEY dans conf/backend.env)
* "establishments": [
* {
* "name": "École Dupont",
* "address": "1 rue de la Paix, Paris",
* "total_capacity": 300,
* "establishment_type": [1, 2], // 1=Maternelle, 2=Primaire, 3=Secondaire
* "evaluation_frequency": 1, // 1=Trimestre, 2=Semestre, 3=Année
* "licence_code": "LIC001", // optionnel
* "directeur": {
* "email": "directeur@dupont.fr",
* "password": "motdepasse123",
* "last_name": "Dupont",
* "first_name": "Jean"
* }
* }
* ]
* }
*/
const readline = require("readline");
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
// ── Lecture de conf/backend.env ───────────────────────────────────────────────
function loadBackendEnv() {
const envPath = path.join(__dirname, "..", "conf", "backend.env");
if (!fs.existsSync(envPath)) return;
const lines = fs.readFileSync(envPath, "utf8").split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
// Ne pas écraser les variables déjà définies dans l'environnement
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
// ── Helpers readline ──────────────────────────────────────────────────────────
let rl;
function getRL() {
if (!rl) {
rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
}
return rl;
}
function ask(question, defaultValue) {
const suffix =
defaultValue != null && defaultValue !== "" ? ` (${defaultValue})` : "";
return new Promise((resolve) => {
getRL().question(`${question}${suffix}: `, (answer) => {
resolve(
answer.trim() || (defaultValue != null ? String(defaultValue) : ""),
);
});
});
}
function askRequired(question) {
return new Promise((resolve) => {
const prompt = () => {
getRL().question(`${question}: `, (answer) => {
if (!answer.trim()) {
console.log(" ⚠ Ce champ est obligatoire.");
prompt();
} else {
resolve(answer.trim());
}
});
};
prompt();
});
}
function askChoices(question, choices) {
return new Promise((resolve) => {
console.log(`\n${question}`);
choices.forEach((c, i) => console.log(` ${i + 1}. ${c.label}`));
const prompt = () => {
getRL().question(
"Choix (numéros séparés par des virgules): ",
(answer) => {
const nums = answer
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => n >= 1 && n <= choices.length);
if (nums.length === 0) {
console.log(" ⚠ Sélectionnez au moins une option.");
prompt();
} else {
resolve(nums.map((n) => choices[n - 1].value));
}
},
);
};
prompt();
});
}
function askChoice(question, choices, defaultIndex) {
return new Promise((resolve) => {
console.log(`\n${question}`);
choices.forEach((c, i) =>
console.log(
` ${i + 1}. ${c.label}${i === defaultIndex ? " (défaut)" : ""}`,
),
);
const prompt = () => {
getRL().question(`Choix (1-${choices.length}): `, (answer) => {
if (!answer.trim() && defaultIndex != null) {
resolve(choices[defaultIndex].value);
return;
}
const n = parseInt(answer.trim(), 10);
if (n >= 1 && n <= choices.length) {
resolve(choices[n - 1].value);
} else {
console.log(" ⚠ Choix invalide.");
prompt();
}
});
};
prompt();
});
}
// ── HTTP ──────────────────────────────────────────────────────────────────────
function postJSON(url, data, apiKey) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const mod = parsed.protocol === "https:" ? https : http;
const body = JSON.stringify(data);
const req = mod.request(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"X-API-Key": apiKey,
},
},
(res) => {
let raw = "";
res.on("data", (chunk) => (raw += chunk));
res.on("end", () => {
try {
resolve({ status: res.statusCode, data: JSON.parse(raw) });
} catch {
resolve({ status: res.statusCode, data: raw });
}
});
},
);
req.on("error", reject);
req.write(body);
req.end();
});
}
// ── Création d'un établissement ───────────────────────────────────────────────
async function createEstablishment(backendUrl, apiKey, payload) {
const url = `${backendUrl.replace(/\/$/, "")}/Establishment/establishments`;
const res = await postJSON(url, payload, apiKey);
return res;
}
// ── Validation basique d'un enregistrement batch ──────────────────────────────
function validateRecord(record, index) {
const errors = [];
const label = record.name ? `"${record.name}"` : `#${index + 1}`;
if (!record.name) errors.push("name manquant");
if (!record.address) errors.push("address manquant");
if (!record.total_capacity) errors.push("total_capacity manquant");
if (
!Array.isArray(record.establishment_type) ||
record.establishment_type.length === 0
)
errors.push("establishment_type manquant ou vide");
if (!record.directeur) errors.push("directeur manquant");
else {
if (!record.directeur.email) errors.push("directeur.email manquant");
if (!record.directeur.password) errors.push("directeur.password manquant");
if (!record.directeur.last_name)
errors.push("directeur.last_name manquant");
if (!record.directeur.first_name)
errors.push("directeur.first_name manquant");
}
if (errors.length > 0) {
throw new Error(`Établissement ${label} invalide : ${errors.join(", ")}`);
}
}
// ── Mode batch ────────────────────────────────────────────────────────────────
async function runBatch(filePath) {
const absPath = path.resolve(filePath);
if (!fs.existsSync(absPath)) {
console.error(`❌ Fichier introuvable : ${absPath}`);
process.exit(1);
}
let batch;
try {
batch = JSON.parse(fs.readFileSync(absPath, "utf8"));
} catch (err) {
console.error(`❌ Fichier JSON invalide : ${err.message}`);
process.exit(1);
}
const backendUrl =
batch.backendUrl || process.env.URL_DJANGO || "http://localhost:8080";
const apiKey = batch.apiKey || process.env.WEBHOOK_API_KEY;
if (!apiKey) {
console.error(
"❌ apiKey manquant dans le fichier batch ou la variable WEBHOOK_API_KEY.",
);
process.exit(1);
}
const establishments = batch.establishments;
if (!Array.isArray(establishments) || establishments.length === 0) {
console.error("❌ Le fichier batch ne contient aucun établissement.");
process.exit(1);
}
// Validation préalable de tous les enregistrements
establishments.forEach((record, i) => validateRecord(record, i));
console.log(
`\n📋 Batch : ${establishments.length} établissement(s) à créer sur ${backendUrl}\n`,
);
let success = 0;
let failure = 0;
for (let i = 0; i < establishments.length; i++) {
const record = establishments[i];
const label = `[${i + 1}/${establishments.length}] ${record.name}`;
process.stdout.write(` ${label} ... `);
try {
const res = await createEstablishment(backendUrl, apiKey, record);
if (res.status === 201) {
console.log(`✅ (ID: ${res.data.id})`);
success++;
} else {
console.log(`❌ HTTP ${res.status}`);
console.error(` ${JSON.stringify(res.data)}`);
failure++;
}
} catch (err) {
console.log(`❌ Erreur réseau : ${err.message}`);
failure++;
}
}
console.log(`\nRésultat : ${success} créé(s), ${failure} échec(s).`);
if (failure > 0) process.exit(1);
}
// ── Mode interactif ───────────────────────────────────────────────────────────
async function runInteractive() {
console.log("╔══════════════════════════════════════════╗");
console.log("║ Création d'un établissement N3WT ║");
console.log("╚══════════════════════════════════════════╝\n");
const backendUrl = await ask(
"URL du backend Django",
process.env.URL_DJANGO || "http://localhost:8080",
);
const apiKey = await ask(
"Clé API webhook (WEBHOOK_API_KEY)",
process.env.WEBHOOK_API_KEY || "",
);
if (!apiKey) {
console.error("❌ La clé API est obligatoire.");
getRL().close();
process.exit(1);
}
// --- Établissement ---
console.log("\n── Établissement ──");
const name = await askRequired("Nom de l'établissement");
const address = await askRequired("Adresse");
const totalCapacity = parseInt(await askRequired("Capacité totale"), 10);
const establishmentType = await askChoices("Type(s) de structure:", [
{ label: "Maternelle", value: 1 },
{ label: "Primaire", value: 2 },
{ label: "Secondaire", value: 3 },
]);
const evaluationFrequency = await askChoice(
"Fréquence d'évaluation:",
[
{ label: "Trimestre", value: 1 },
{ label: "Semestre", value: 2 },
{ label: "Année", value: 3 },
],
0,
);
const licenceCode = await ask("Code licence (optionnel)", "");
// --- Directeur (admin) ---
console.log("\n── Compte directeur (admin) ──");
const directorEmail = await askRequired("Email du directeur");
const directorPassword = await askRequired("Mot de passe");
const directorLastName = await askRequired("Nom de famille");
const directorFirstName = await askRequired("Prénom");
// --- Récapitulatif ---
console.log("\n── Récapitulatif ──");
console.log(` Établissement : ${name}`);
console.log(` Adresse : ${address}`);
console.log(` Capacité : ${totalCapacity}`);
console.log(` Type(s) : ${establishmentType.join(", ")}`);
console.log(` Évaluation : ${evaluationFrequency}`);
console.log(
` Directeur : ${directorFirstName} ${directorLastName} <${directorEmail}>`,
);
const confirm = await ask("\nConfirmer la création ? (o/n)", "o");
if (confirm.toLowerCase() !== "o") {
console.log("Annulé.");
getRL().close();
process.exit(0);
}
const payload = {
name,
address,
total_capacity: totalCapacity,
establishment_type: establishmentType,
evaluation_frequency: evaluationFrequency,
...(licenceCode && { licence_code: licenceCode }),
directeur: {
email: directorEmail,
password: directorPassword,
last_name: directorLastName,
first_name: directorFirstName,
},
};
console.log("\nCréation en cours...");
try {
const res = await createEstablishment(backendUrl, apiKey, payload);
if (res.status === 201) {
console.log("\n✅ Établissement créé avec succès !");
console.log(` ID : ${res.data.id}`);
console.log(` Nom : ${res.data.name}`);
} else {
console.error(`\n❌ Erreur (HTTP ${res.status}):`);
console.error(JSON.stringify(res.data, null, 2));
process.exit(1);
}
} catch (err) {
console.error("\n❌ Erreur réseau:", err.message);
process.exit(1);
}
getRL().close();
}
// ── Point d'entrée ────────────────────────────────────────────────────────────
async function main() {
loadBackendEnv();
const args = process.argv.slice(2);
const fileIndex = args.indexOf("--file");
if (fileIndex !== -1) {
const filePath = args[fileIndex + 1];
if (!filePath) {
console.error(
"❌ Argument --file manquant. Usage : --file <chemin/vers/batch.json>",
);
process.exit(1);
}
await runBatch(filePath);
} else {
await runInteractive();
}
}
main().catch((err) => {
console.error("❌ Erreur inattendue:", err.message);
process.exit(1);
});