mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-05 12:41:27 +00:00
Merge remote-tracking branch 'origin/develop' into N3WTS-5-Historique-ACA
This commit is contained in:
20
.github/copilot-instructions.md
vendored
20
.github/copilot-instructions.md
vendored
@ -52,8 +52,28 @@ Pour le front-end, les exigences de qualité sont les suivantes :
|
|||||||
- Documentation en français pour les nouvelles fonctionnalités (si applicable)
|
- 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)
|
||||||
|
|||||||
116
.github/instructions/design-system.instruction.md
vendored
Normal file
116
.github/instructions/design-system.instruction.md
vendored
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
applyTo: "Front-End/src/**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design System — Règles Copilot
|
||||||
|
|
||||||
|
Référence complète : [`docs/design-system.md`](../../docs/design-system.md)
|
||||||
|
|
||||||
|
## Couleurs — tokens Tailwind obligatoires
|
||||||
|
|
||||||
|
Utiliser **toujours** ces tokens pour les éléments interactifs :
|
||||||
|
|
||||||
|
| Token | Hex | Remplace |
|
||||||
|
|-------------|-----------|-----------------------------------|
|
||||||
|
| `primary` | `#059669` | `emerald-600`, `emerald-500` |
|
||||||
|
| `secondary` | `#064E3B` | `emerald-700`, `emerald-800` |
|
||||||
|
| `tertiary` | `#10B981` | `emerald-400`, `emerald-500` |
|
||||||
|
| `neutral` | `#F8FAFC` | Fonds neutres |
|
||||||
|
|
||||||
|
**Ne jamais écrire** `bg-emerald-*`, `text-emerald-*`, `border-emerald-*` pour des éléments interactifs.
|
||||||
|
|
||||||
|
### Patterns corrects
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Bouton
|
||||||
|
<button className="bg-primary hover:bg-secondary text-white px-4 py-2 rounded">
|
||||||
|
|
||||||
|
// Texte actif
|
||||||
|
<span className="text-primary">
|
||||||
|
|
||||||
|
// Badge
|
||||||
|
<span className="bg-tertiary/10 text-tertiary text-xs px-2 py-0.5 rounded">
|
||||||
|
|
||||||
|
// Fond de page
|
||||||
|
<div className="bg-neutral">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typographie
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Titre de section
|
||||||
|
<h1 className="font-headline text-2xl font-bold">
|
||||||
|
|
||||||
|
// Sous-titre
|
||||||
|
<h2 className="font-headline text-xl font-semibold">
|
||||||
|
|
||||||
|
// Label de formulaire
|
||||||
|
<label className="font-label text-sm font-medium text-gray-700">
|
||||||
|
```
|
||||||
|
|
||||||
|
> `font-body` est le défaut sur `<body>` — inutile de l'ajouter sur les `<p>`.
|
||||||
|
|
||||||
|
## Arrondi
|
||||||
|
|
||||||
|
- Par défaut : `rounded` (4px)
|
||||||
|
- Cards / modales : `rounded-md` (6px)
|
||||||
|
- Grandes surfaces : `rounded-lg` (8px)
|
||||||
|
- **Éviter** `rounded-xl` sauf avatars ou indicateurs circulaires
|
||||||
|
|
||||||
|
## Espacement
|
||||||
|
|
||||||
|
- Grille 4px/8px : `p-1`=4px, `p-2`=8px, `p-3`=12px, `p-4`=16px
|
||||||
|
- **Pas** de valeurs arbitraires `p-[13px]`
|
||||||
|
|
||||||
|
## Mode
|
||||||
|
|
||||||
|
Interface **light uniquement** — ne pas ajouter `dark:` prefixes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Icônes
|
||||||
|
|
||||||
|
Utiliser **uniquement** `lucide-react`. Jamais d'autres bibliothèques d'icônes.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Home, Plus, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
<Home size={20} className="text-primary" />
|
||||||
|
<button className="flex items-center gap-2"><Plus size={16} />Ajouter</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Taille par défaut : `size={20}` inline, `size={24}` boutons standalone
|
||||||
|
- Couleur via `className="text-*"` uniquement — jamais le prop `color`
|
||||||
|
- Icône seule : ajouter `aria-label` pour l'accessibilité
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive & PWA
|
||||||
|
|
||||||
|
**Mobile-first** : les styles de base ciblent le mobile, on étend avec `sm:` / `md:` / `lg:`.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Layout
|
||||||
|
<div className="px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
|
||||||
|
|
||||||
|
// Grille
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
|
||||||
|
// Bouton full-width mobile
|
||||||
|
<button className="w-full sm:w-auto bg-primary text-white px-4 py-2 rounded">
|
||||||
|
```
|
||||||
|
|
||||||
|
- Touch targets ≥ 44px : `min-h-[44px]` sur tous les éléments interactifs
|
||||||
|
- Pas d'interactions uniquement au `:hover` — prévoir une alternative tactile
|
||||||
|
- Tableaux sur mobile : utiliser la classe utilitaire `responsive-table` (définie dans `tailwind.css`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Réutilisation des composants
|
||||||
|
|
||||||
|
**Toujours chercher un composant existant dans `Front-End/src/components/` avant d'en créer un.**
|
||||||
|
|
||||||
|
Composants clés disponibles : `AlertMessage`, `Modal`, `Pagination`, `SectionHeader`, `ProgressStep`, `EventCard`, `Calendar/*`, `Chat/*`, `Evaluation/*`, `Grades/*`, `Form/*`, `Admin/*`, `Charts/*`.
|
||||||
|
|
||||||
|
- Étendre via des props (`variant`, `size`, `className`) plutôt que de dupliquer
|
||||||
|
- Appliquer les tokens du design system dans tout composant modifié ou créé
|
||||||
@ -1,5 +1,5 @@
|
|||||||
La documentation doit être en français et claire pour les utilisateurs francophones.
|
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
1
.gitignore
vendored
@ -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
1
.husky/commit-msg
Normal file → Executable file
@ -1 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
npx --no -- commitlint --edit $1
|
npx --no -- commitlint --edit $1
|
||||||
1
.husky/pre-commit
Normal file → Executable file
1
.husky/pre-commit
Normal file → Executable file
@ -1 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
cd $(dirname "$0")/../Front-End/ && npm run lint-light
|
cd $(dirname "$0")/../Front-End/ && npm run lint-light
|
||||||
1
.husky/prepare-commit-msg
Normal file → Executable file
1
.husky/prepare-commit-msg
Normal file → Executable file
@ -1 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
#node scripts/prepare-commit-msg.js "$1" "$2"
|
#node scripts/prepare-commit-msg.js "$1" "$2"
|
||||||
@ -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"),
|
||||||
]
|
]
|
||||||
@ -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
91
CLAUDE.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# CLAUDE.md — N3WT-SCHOOL
|
||||||
|
|
||||||
|
Instructions permanentes pour Claude Code sur ce projet.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Backend** : Python / Django — dossier `Back-End/`
|
||||||
|
- **Frontend** : Next.js 14 (App Router) — dossier `Front-End/`
|
||||||
|
- **Tests frontend** : `Front-End/src/test/`
|
||||||
|
- **CSS** : Tailwind CSS 3 + `@tailwindcss/forms`
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
Le design system complet est dans [`docs/design-system.md`](docs/design-system.md). À lire et appliquer systématiquement.
|
||||||
|
|
||||||
|
### Tokens de couleur (Tailwind)
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|-------------|-----------|------------------------------------|
|
||||||
|
| `primary` | `#059669` | Boutons, CTA, éléments actifs |
|
||||||
|
| `secondary` | `#064E3B` | Hover, accents sombres |
|
||||||
|
| `tertiary` | `#10B981` | Badges, icônes d'accent |
|
||||||
|
| `neutral` | `#F8FAFC` | Fonds de page, surfaces |
|
||||||
|
|
||||||
|
> **Règle absolue** : ne jamais utiliser `emerald-*`, `green-*` pour les éléments interactifs. Utiliser les tokens ci-dessus.
|
||||||
|
|
||||||
|
### Typographie
|
||||||
|
|
||||||
|
- `font-headline` → titres `h1`/`h2`/`h3` (Manrope)
|
||||||
|
- `font-body` → texte courant, défaut sur `<body>` (Inter)
|
||||||
|
- `font-label` → boutons, labels de form (Inter)
|
||||||
|
|
||||||
|
### Arrondi & Espacement
|
||||||
|
|
||||||
|
- Arrondi par défaut : `rounded` (4px)
|
||||||
|
- Espacement : grille 4px/8px — pas de valeurs arbitraires
|
||||||
|
- Mode : **light uniquement**, pas de dark mode
|
||||||
|
|
||||||
|
## Conventions de code
|
||||||
|
|
||||||
|
### Frontend (Next.js)
|
||||||
|
|
||||||
|
- **Composants** : React fonctionnels, pas de classes
|
||||||
|
- **Styles** : Tailwind CSS uniquement — pas de CSS inline sauf animations
|
||||||
|
- **Icônes** : `lucide-react`
|
||||||
|
- **i18n** : `next-intl` — toutes les chaînes UI via `useTranslations()`
|
||||||
|
- **Formulaires** : `react-hook-form`
|
||||||
|
- **Imports** : alias `@/` pointe vers `Front-End/src/`
|
||||||
|
|
||||||
|
### Qualité
|
||||||
|
|
||||||
|
- Linting : ESLint (`npm run lint` dans `Front-End/`)
|
||||||
|
- Format : Prettier
|
||||||
|
- Tests : Jest + React Testing Library (`Front-End/src/test/`)
|
||||||
|
|
||||||
|
## Gestion des branches
|
||||||
|
|
||||||
|
- Base : `develop`
|
||||||
|
- Nomenclature : `<type>-<nom_ticket>-<numero>` (ex: `feat-ma-feature-1234`)
|
||||||
|
- Types : `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||||
|
|
||||||
|
## Icônes
|
||||||
|
|
||||||
|
Utiliser **uniquement `lucide-react`** — jamais d'autre bibliothèque d'icônes.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Home, Plus } from 'lucide-react';
|
||||||
|
<Home size={20} className="text-primary" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive & PWA
|
||||||
|
|
||||||
|
- **Mobile-first** : styles de base = mobile, breakpoints `sm:`/`md:`/`lg:` pour agrandir.
|
||||||
|
- Touch targets ≥ 44px (`min-h-[44px]`) sur tous les éléments interactifs.
|
||||||
|
- Pas d'interactions uniquement au `:hover` — prévoir l'équivalent tactile.
|
||||||
|
- Les tableaux utilisent la classe `responsive-table` sur mobile.
|
||||||
|
|
||||||
|
## Réutilisation des composants
|
||||||
|
|
||||||
|
Avant de créer un composant, **vérifier `Front-End/src/components/`**.
|
||||||
|
Composants disponibles : `AlertMessage`, `Modal`, `Pagination`, `SectionHeader`, `ProgressStep`, `EventCard`, `Calendar/*`, `Chat/*`, `Evaluation/*`, `Grades/*`, `Form/*`, `Admin/*`, `Charts/*`.
|
||||||
|
|
||||||
|
## À éviter
|
||||||
|
|
||||||
|
- Ne pas ajouter de dépendances inutiles
|
||||||
|
- Ne pas modifier `package-lock.json` / `yarn.lock` manuellement
|
||||||
|
- Ne pas committer sans avoir vérifié ESLint et les tests
|
||||||
|
- Ne pas utiliser de CSS arbitraire (`p-[13px]`) sauf cas justifié
|
||||||
|
- Ne pas ajouter de support dark mode
|
||||||
|
- Ne pas utiliser d'autres bibliothèques d'icônes que `lucide-react`
|
||||||
|
- Ne pas créer un composant qui existe déjà dans `components/`
|
||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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,
|
||||||
|
{
|
||||||
|
score: editAbsent
|
||||||
|
? null
|
||||||
|
: editScore === ''
|
||||||
|
? null
|
||||||
|
: parseFloat(editScore),
|
||||||
is_absent: editAbsent,
|
is_absent: editAbsent,
|
||||||
}, csrfToken);
|
},
|
||||||
|
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)
|
||||||
|
.map(([subject, { color, evaluations }]) => {
|
||||||
const scores = evaluations
|
const scores = evaluations
|
||||||
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
|
.filter(
|
||||||
.map(e => parseFloat(e.score))
|
(e) =>
|
||||||
.filter(s => !isNaN(s));
|
e.score !== null &&
|
||||||
|
e.score !== undefined &&
|
||||||
|
!e.is_absent
|
||||||
|
)
|
||||||
|
.map((e) => parseFloat(e.score))
|
||||||
|
.filter((s) => !isNaN(s));
|
||||||
const avg = scores.length
|
const avg = scores.length
|
||||||
? scores.reduce((sum, s) => sum + s, 0) / scores.length
|
? scores.reduce((sum, s) => sum + s, 0) /
|
||||||
|
scores.length
|
||||||
: null;
|
: null;
|
||||||
return { subject, color, avg };
|
return { subject, color, avg };
|
||||||
}).filter(s => s.avg !== null && !isNaN(s.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,16 +707,28 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => {
|
{Object.entries(groupedBySubject).map(
|
||||||
|
([subject, { color, evaluations }]) => {
|
||||||
const scores = evaluations
|
const scores = evaluations
|
||||||
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
|
.filter(
|
||||||
.map(e => parseFloat(e.score))
|
(e) =>
|
||||||
.filter(s => !isNaN(s));
|
e.score !== null &&
|
||||||
|
e.score !== undefined &&
|
||||||
|
!e.is_absent
|
||||||
|
)
|
||||||
|
.map((e) => parseFloat(e.score))
|
||||||
|
.filter((s) => !isNaN(s));
|
||||||
const avg = scores.length
|
const avg = scores.length
|
||||||
? (scores.reduce((sum, s) => sum + s, 0) / scores.length).toFixed(1)
|
? (
|
||||||
|
scores.reduce((sum, s) => sum + s, 0) /
|
||||||
|
scores.length
|
||||||
|
).toFixed(1)
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={subject} className="border rounded-lg overflow-hidden">
|
<div
|
||||||
|
key={subject}
|
||||||
|
className="border rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between px-4 py-3"
|
className="flex items-center justify-between px-4 py-3"
|
||||||
style={{ backgroundColor: `${color}20` }}
|
style={{ backgroundColor: `${color}20` }}
|
||||||
@ -633,7 +738,9 @@ export default function Page() {
|
|||||||
className="w-3 h-3 rounded-full"
|
className="w-3 h-3 rounded-full"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
></span>
|
></span>
|
||||||
<span className="font-semibold text-gray-800">{subject}</span>
|
<span className="font-semibold text-gray-800">
|
||||||
|
{subject}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{avg !== null && (
|
{avg !== null && (
|
||||||
<span className="text-sm font-bold text-gray-700">
|
<span className="text-sm font-bold text-gray-700">
|
||||||
@ -644,17 +751,28 @@ export default function Page() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-2 font-medium text-gray-600">Évaluation</th>
|
<th className="text-left px-4 py-2 font-medium text-gray-600">
|
||||||
<th className="text-left px-4 py-2 font-medium text-gray-600">Période</th>
|
Évaluation
|
||||||
<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-left px-4 py-2 font-medium text-gray-600">
|
||||||
|
Période
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium text-gray-600">
|
||||||
|
Note
|
||||||
|
</th>
|
||||||
|
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{evaluations.map((evalItem) => {
|
{evaluations.map((evalItem) => {
|
||||||
const isEditing = editingEvalId === evalItem.id;
|
const isEditing = editingEvalId === evalItem.id;
|
||||||
return (
|
return (
|
||||||
<tr key={evalItem.id} className="border-t hover:bg-gray-50">
|
<tr
|
||||||
|
key={evalItem.id}
|
||||||
|
className="border-t hover:bg-gray-50"
|
||||||
|
>
|
||||||
<td className="px-4 py-2 text-gray-700">
|
<td className="px-4 py-2 text-gray-700">
|
||||||
{evalItem.evaluation_name || 'Évaluation'}
|
{evalItem.evaluation_name || 'Évaluation'}
|
||||||
</td>
|
</td>
|
||||||
@ -670,7 +788,8 @@ export default function Page() {
|
|||||||
checked={editAbsent}
|
checked={editAbsent}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setEditAbsent(e.target.checked);
|
setEditAbsent(e.target.checked);
|
||||||
if (e.target.checked) setEditScore('');
|
if (e.target.checked)
|
||||||
|
setEditScore('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
Abs
|
Abs
|
||||||
@ -679,20 +798,27 @@ export default function Page() {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={editScore}
|
value={editScore}
|
||||||
onChange={(e) => setEditScore(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setEditScore(e.target.value)
|
||||||
|
}
|
||||||
min="0"
|
min="0"
|
||||||
max={evalItem.max_score || 20}
|
max={evalItem.max_score || 20}
|
||||||
step="0.5"
|
step="0.5"
|
||||||
className="w-16 text-center px-1 py-0.5 border rounded text-sm"
|
className="w-16 text-center px-1 py-0.5 border rounded text-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-500">/{evalItem.max_score || 20}</span>
|
<span className="text-gray-500">
|
||||||
|
/{evalItem.max_score || 20}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : evalItem.is_absent ? (
|
) : evalItem.is_absent ? (
|
||||||
<span className="text-orange-500 font-medium">Absent</span>
|
<span className="text-orange-500 font-medium">
|
||||||
|
Absent
|
||||||
|
</span>
|
||||||
) : evalItem.score !== null ? (
|
) : evalItem.score !== null ? (
|
||||||
<span className="font-semibold text-gray-800">
|
<span className="font-semibold text-gray-800">
|
||||||
{evalItem.score}/{evalItem.max_score || 20}
|
{evalItem.score}/
|
||||||
|
{evalItem.max_score || 20}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400">—</span>
|
<span className="text-gray-400">—</span>
|
||||||
@ -702,7 +828,9 @@ export default function Page() {
|
|||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSaveEval(evalItem)}
|
onClick={() =>
|
||||||
|
handleSaveEval(evalItem)
|
||||||
|
}
|
||||||
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
|
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
|
||||||
title="Enregistrer"
|
title="Enregistrer"
|
||||||
>
|
>
|
||||||
@ -719,14 +847,18 @@ export default function Page() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => startEditingEval(evalItem)}
|
onClick={() =>
|
||||||
|
startEditingEval(evalItem)
|
||||||
|
}
|
||||||
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
|
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
|
||||||
title="Modifier"
|
title="Modifier"
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteEval(evalItem)}
|
onClick={() =>
|
||||||
|
handleDeleteEval(evalItem)
|
||||||
|
}
|
||||||
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
||||||
title="Supprimer"
|
title="Supprimer"
|
||||||
>
|
>
|
||||||
@ -736,12 +868,14 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);})}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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) {
|
||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
@ -341,34 +402,52 @@ export default function DynamicFormsList({
|
|||||||
</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,9 +484,10 @@ 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 &&
|
||||||
|
currentTemplate.file && (
|
||||||
<iframe
|
<iframe
|
||||||
src={`${BASE_URL}${currentTemplate.file}`}
|
src={getSecureFileUrl(currentTemplate.file)}
|
||||||
title={currentTemplate.name}
|
title={currentTemplate.name}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: '600px', border: 'none' }}
|
style={{ height: '600px', border: 'none' }}
|
||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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.');
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,7 +225,8 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
buttonClassName="w-full"
|
buttonClassName="w-full"
|
||||||
menuClassName={compact
|
menuClassName={
|
||||||
|
compact
|
||||||
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
|
? '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'
|
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import { Edit, Trash2, FileText, Star, ChevronDown, Plus } from 'lucide-react';
|
||||||
Edit,
|
|
||||||
Trash2,
|
|
||||||
FileText,
|
|
||||||
Star,
|
|
||||||
ChevronDown,
|
|
||||||
Plus,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||||
import {
|
import {
|
||||||
@ -38,7 +31,7 @@ import DropdownMenu from '@/components/DropdownMenu';
|
|||||||
import CheckBox from '@/components/Form/CheckBox';
|
import CheckBox from '@/components/Form/CheckBox';
|
||||||
import Button from '@/components/Form/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import InputText from '@/components/Form/InputText';
|
import InputText from '@/components/Form/InputText';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
|
||||||
function getItemBgColor(type, selected, forceTheme = false) {
|
function getItemBgColor(type, selected, forceTheme = false) {
|
||||||
// Colonne gauche : blanc si rien n'est sélectionné, emerald si sélectionné
|
// Colonne gauche : blanc si rien n'est sélectionné, emerald si sélectionné
|
||||||
@ -73,7 +66,9 @@ function SimpleList({
|
|||||||
groupDocCount = null,
|
groupDocCount = null,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded border border-gray-200 bg-white ${minHeight} flex flex-col`}>
|
<div
|
||||||
|
className={`rounded border border-gray-200 bg-white ${minHeight} flex flex-col`}
|
||||||
|
>
|
||||||
{title && (
|
{title && (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
@ -85,7 +80,9 @@ function SimpleList({
|
|||||||
${headerClassName}
|
${headerClassName}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{headerContent ? headerContent : (
|
{headerContent ? (
|
||||||
|
headerContent
|
||||||
|
) : (
|
||||||
<span className="text-base text-gray-700">{title}</span>
|
<span className="text-base text-gray-700">{title}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -106,11 +103,12 @@ function SimpleList({
|
|||||||
? 'z-0 relative'
|
? 'z-0 relative'
|
||||||
: '';
|
: '';
|
||||||
const marginFix =
|
const marginFix =
|
||||||
selectable && idx !== items.length - 1
|
selectable && idx !== items.length - 1 ? '-mb-[1px]' : '';
|
||||||
? '-mb-[1px]'
|
|
||||||
: '';
|
|
||||||
let description = '';
|
let description = '';
|
||||||
if (typeof item.description === 'string' && item.description.trim()) {
|
if (
|
||||||
|
typeof item.description === 'string' &&
|
||||||
|
item.description.trim()
|
||||||
|
) {
|
||||||
description = item.description;
|
description = item.description;
|
||||||
} else if (
|
} else if (
|
||||||
item._type === 'emerald' &&
|
item._type === 'emerald' &&
|
||||||
@ -124,17 +122,17 @@ function SimpleList({
|
|||||||
}
|
}
|
||||||
const groupsLabel =
|
const groupsLabel =
|
||||||
showGroups && Array.isArray(item.groups) && item.groups.length > 0
|
showGroups && Array.isArray(item.groups) && item.groups.length > 0
|
||||||
? item.groups.map(g => g.name).join(', ')
|
? item.groups.map((g) => g.name).join(', ')
|
||||||
: null;
|
: null;
|
||||||
const docCount = groupDocCount && typeof groupDocCount === 'function'
|
const docCount =
|
||||||
|
groupDocCount && typeof groupDocCount === 'function'
|
||||||
? groupDocCount(item)
|
? groupDocCount(item)
|
||||||
: null;
|
: null;
|
||||||
const showCustomForm =
|
const showCustomForm =
|
||||||
item._type === 'emerald' &&
|
item._type === 'emerald' &&
|
||||||
Array.isArray(item.formMasterData?.fields) &&
|
Array.isArray(item.formMasterData?.fields) &&
|
||||||
item.formMasterData.fields.length > 0;
|
item.formMasterData.fields.length > 0;
|
||||||
const showRequired =
|
const showRequired = item._type === 'orange' && item.is_required;
|
||||||
item._type === 'orange' && item.is_required;
|
|
||||||
|
|
||||||
// Correction du bug liseré : appliquer un z-index élevé au premier item sélectionné
|
// Correction du bug liseré : appliquer un z-index élevé au premier item sélectionné
|
||||||
const extraZ = selected && idx === 0 ? 'z-20 relative' : '';
|
const extraZ = selected && idx === 0 ? 'z-20 relative' : '';
|
||||||
@ -163,7 +161,9 @@ function SimpleList({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{docCount !== null && (
|
{docCount !== null && (
|
||||||
<span className="text-xs text-blue-700 font-semibold mr-2">{docCount} document{docCount > 1 ? 's' : ''}</span>
|
<span className="text-xs text-blue-700 font-semibold mr-2">
|
||||||
|
{docCount} document{docCount > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{showCustomForm && (
|
{showCustomForm && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border border-yellow-600 bg-yellow-400 text-yellow-900 mr-1">
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border border-yellow-600 bg-yellow-400 text-yellow-900 mr-1">
|
||||||
@ -176,7 +176,9 @@ function SimpleList({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showGroups && groupsLabel && (
|
{showGroups && groupsLabel && (
|
||||||
<span className="text-xs text-gray-500 mr-2">{groupsLabel}</span>
|
<span className="text-xs text-gray-500 mr-2">
|
||||||
|
{groupsLabel}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{actionButtons && actionButtons(item)}
|
{actionButtons && actionButtons(item)}
|
||||||
</div>
|
</div>
|
||||||
@ -192,7 +194,7 @@ function SimpleList({
|
|||||||
export default function FilesGroupsManagement({
|
export default function FilesGroupsManagement({
|
||||||
csrfToken,
|
csrfToken,
|
||||||
selectedEstablishmentId,
|
selectedEstablishmentId,
|
||||||
profileRole
|
profileRole,
|
||||||
}) {
|
}) {
|
||||||
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
|
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
|
||||||
const [parentFiles, setParentFileMasters] = useState([]);
|
const [parentFiles, setParentFileMasters] = useState([]);
|
||||||
@ -246,7 +248,12 @@ export default function FilesGroupsManagement({
|
|||||||
return found || group;
|
return found || group;
|
||||||
} else {
|
} else {
|
||||||
// C'est un ID
|
// C'est un ID
|
||||||
return groups.find((g) => g.id === group) || { id: group, name: 'Groupe inconnu' };
|
return (
|
||||||
|
groups.find((g) => g.id === group) || {
|
||||||
|
id: group,
|
||||||
|
name: 'Groupe inconnu',
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@ -323,7 +330,11 @@ export default function FilesGroupsManagement({
|
|||||||
|
|
||||||
const editTemplateMaster = (file) => {
|
const editTemplateMaster = (file) => {
|
||||||
// Si le formulaire n'est pas personnalisé, ouvrir la popup de téléchargement
|
// Si le formulaire n'est pas personnalisé, ouvrir la popup de téléchargement
|
||||||
if (!file.formMasterData || !Array.isArray(file.formMasterData.fields) || file.formMasterData.fields.length === 0) {
|
if (
|
||||||
|
!file.formMasterData ||
|
||||||
|
!Array.isArray(file.formMasterData.fields) ||
|
||||||
|
file.formMasterData.fields.length === 0
|
||||||
|
) {
|
||||||
setFileToEdit(file);
|
setFileToEdit(file);
|
||||||
setIsFileUploadPopupOpen(true);
|
setIsFileUploadPopupOpen(true);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@ -334,7 +345,12 @@ export default function FilesGroupsManagement({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSchoolFileMaster = ({ name, group_ids, formMasterData, file }) => {
|
const handleCreateSchoolFileMaster = ({
|
||||||
|
name,
|
||||||
|
group_ids,
|
||||||
|
formMasterData,
|
||||||
|
file,
|
||||||
|
}) => {
|
||||||
// Toujours envoyer en FormData, même sans fichier
|
// Toujours envoyer en FormData, même sans fichier
|
||||||
const dataToSend = new FormData();
|
const dataToSend = new FormData();
|
||||||
const jsonData = {
|
const jsonData = {
|
||||||
@ -390,7 +406,7 @@ export default function FilesGroupsManagement({
|
|||||||
// Correction : normaliser group_ids pour ne garder que les IDs (number/string)
|
// Correction : normaliser group_ids pour ne garder que les IDs (number/string)
|
||||||
let normalizedGroupIds = [];
|
let normalizedGroupIds = [];
|
||||||
if (Array.isArray(group_ids)) {
|
if (Array.isArray(group_ids)) {
|
||||||
normalizedGroupIds = group_ids.map(g =>
|
normalizedGroupIds = group_ids.map((g) =>
|
||||||
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
|
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -400,7 +416,7 @@ export default function FilesGroupsManagement({
|
|||||||
name: name,
|
name: name,
|
||||||
groups: normalizedGroupIds,
|
groups: normalizedGroupIds,
|
||||||
formMasterData: formMasterData,
|
formMasterData: formMasterData,
|
||||||
establishment: selectedEstablishmentId
|
establishment: selectedEstablishmentId,
|
||||||
};
|
};
|
||||||
dataToSend.append('data', JSON.stringify(jsonData));
|
dataToSend.append('data', JSON.stringify(jsonData));
|
||||||
|
|
||||||
@ -432,12 +448,12 @@ export default function FilesGroupsManagement({
|
|||||||
const finalFileName = `${cleanName}${extension}`;
|
const finalFileName = `${cleanName}${extension}`;
|
||||||
// Correction : il faut récupérer le fichier à l'URL d'origine, pas à la nouvelle URL renommée
|
// Correction : il faut récupérer le fichier à l'URL d'origine, pas à la nouvelle URL renommée
|
||||||
// On utilise le path original (file) pour le fetch, pas le chemin avec le nouveau nom
|
// On utilise le path original (file) pour le fetch, pas le chemin avec le nouveau nom
|
||||||
fetch(`${BASE_URL}${file}`)
|
fetch(getSecureFileUrl(file))
|
||||||
.then(response => {
|
.then((response) => {
|
||||||
if (!response.ok) throw new Error('Fichier distant introuvable');
|
if (!response.ok) throw new Error('Fichier distant introuvable');
|
||||||
return response.blob();
|
return response.blob();
|
||||||
})
|
})
|
||||||
.then(blob => {
|
.then((blob) => {
|
||||||
dataToSend.append('file', blob, finalFileName);
|
dataToSend.append('file', blob, finalFileName);
|
||||||
editRegistrationSchoolFileMaster(id, dataToSend, csrfToken)
|
editRegistrationSchoolFileMaster(id, dataToSend, csrfToken)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@ -461,7 +477,10 @@ export default function FilesGroupsManagement({
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('Erreur lors de la récupération du fichier existant pour renommage:', error);
|
logger.error(
|
||||||
|
'Erreur lors de la récupération du fichier existant pour renommage:',
|
||||||
|
error
|
||||||
|
);
|
||||||
showNotification(
|
showNotification(
|
||||||
'Erreur lors de la récupération du fichier existant pour renommage',
|
'Erreur lors de la récupération du fichier existant pour renommage',
|
||||||
'error',
|
'error',
|
||||||
@ -620,15 +639,28 @@ export default function FilesGroupsManagement({
|
|||||||
|
|
||||||
// Correction du bug : ne pas supprimer l'élément lors de l'édition d'un doc parent
|
// Correction du bug : ne pas supprimer l'élément lors de l'édition d'un doc parent
|
||||||
const handleEdit = (id, updatedFile) => {
|
const handleEdit = (id, updatedFile) => {
|
||||||
logger.debug('[FilesGroupsManagement] handleEdit called with:', id, updatedFile);
|
logger.debug(
|
||||||
|
'[FilesGroupsManagement] handleEdit called with:',
|
||||||
|
id,
|
||||||
|
updatedFile
|
||||||
|
);
|
||||||
if (typeof updatedFile !== 'object' || updatedFile === null) {
|
if (typeof updatedFile !== 'object' || updatedFile === null) {
|
||||||
logger.error('[FilesGroupsManagement] handleEdit: updatedFile is not an object', updatedFile);
|
logger.error(
|
||||||
|
'[FilesGroupsManagement] handleEdit: updatedFile is not an object',
|
||||||
|
updatedFile
|
||||||
|
);
|
||||||
return Promise.reject(new Error('updatedFile is not an object'));
|
return Promise.reject(new Error('updatedFile is not an object'));
|
||||||
}
|
}
|
||||||
logger.debug('[FilesGroupsManagement] handleEdit payload:', JSON.stringify(updatedFile));
|
logger.debug(
|
||||||
|
'[FilesGroupsManagement] handleEdit payload:',
|
||||||
|
JSON.stringify(updatedFile)
|
||||||
|
);
|
||||||
return editRegistrationParentFileMaster(id, updatedFile, csrfToken)
|
return editRegistrationParentFileMaster(id, updatedFile, csrfToken)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
logger.debug('[FilesGroupsManagement] editRegistrationParentFileMaster response:', response);
|
logger.debug(
|
||||||
|
'[FilesGroupsManagement] editRegistrationParentFileMaster response:',
|
||||||
|
response
|
||||||
|
);
|
||||||
const modifiedFile = response.data || response;
|
const modifiedFile = response.data || response;
|
||||||
setParentFileMasters((prevFiles) =>
|
setParentFileMasters((prevFiles) =>
|
||||||
prevFiles.map((file) => (file.id === id ? modifiedFile : file))
|
prevFiles.map((file) => (file.id === id ? modifiedFile : file))
|
||||||
@ -654,19 +686,32 @@ export default function FilesGroupsManagement({
|
|||||||
const handleDelete = (id) => {
|
const handleDelete = (id) => {
|
||||||
// Vérification avant suppression : afficher une popup de confirmation
|
// Vérification avant suppression : afficher une popup de confirmation
|
||||||
setRemovePopupMessage(
|
setRemovePopupMessage(
|
||||||
'Attention !\nVous êtes sur le point de supprimer la pièce à fournir.\nÊtes-vous sûr(e) de vouloir poursuivre l\'opération ?'
|
"Attention !\nVous êtes sur le point de supprimer la pièce à fournir.\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?"
|
||||||
);
|
);
|
||||||
setRemovePopupOnConfirm(() => () => {
|
setRemovePopupOnConfirm(() => () => {
|
||||||
deleteRegistrationParentFileMaster(id, csrfToken)
|
deleteRegistrationParentFileMaster(id, csrfToken)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setParentFileMasters((prevFiles) => prevFiles.filter((file) => file.id !== id));
|
setParentFileMasters((prevFiles) =>
|
||||||
|
prevFiles.filter((file) => file.id !== id)
|
||||||
|
);
|
||||||
logger.debug('Document parent supprimé avec succès:', id);
|
logger.debug('Document parent supprimé avec succès:', id);
|
||||||
showNotification('La pièce à fournir a été supprimée avec succès.', 'success', 'Succès');
|
showNotification(
|
||||||
|
'La pièce à fournir a été supprimée avec succès.',
|
||||||
|
'success',
|
||||||
|
'Succès'
|
||||||
|
);
|
||||||
setRemovePopupVisible(false);
|
setRemovePopupVisible(false);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('Erreur lors de la suppression du fichier parent:', error);
|
logger.error(
|
||||||
showNotification('Erreur lors de la suppression de la pièce à fournir.', 'error', 'Erreur');
|
'Erreur lors de la suppression du fichier parent:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
showNotification(
|
||||||
|
'Erreur lors de la suppression de la pièce à fournir.',
|
||||||
|
'error',
|
||||||
|
'Erreur'
|
||||||
|
);
|
||||||
setRemovePopupVisible(false);
|
setRemovePopupVisible(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -701,13 +746,28 @@ export default function FilesGroupsManagement({
|
|||||||
aria-expanded={showHelp}
|
aria-expanded={showHelp}
|
||||||
aria-controls="aide-inscription"
|
aria-controls="aide-inscription"
|
||||||
>
|
>
|
||||||
<span className="underline">{showHelp ? 'Masquer' : 'Afficher'} l’aide</span>
|
<span className="underline">
|
||||||
<svg className={`w-4 h-4 transition-transform ${showHelp ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
{showHelp ? 'Masquer' : 'Afficher'} l’aide
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform ${showHelp ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{showHelp && (
|
{showHelp && (
|
||||||
<div id="aide-inscription" className="p-4 bg-blue-50 border border-blue-200 rounded mb-4">
|
<div
|
||||||
|
id="aide-inscription"
|
||||||
|
className="p-4 bg-blue-50 border border-blue-200 rounded mb-4"
|
||||||
|
>
|
||||||
<h2 className="text-lg font-bold mb-2">
|
<h2 className="text-lg font-bold mb-2">
|
||||||
Gestion des dossiers et documents d'inscription
|
Gestion des dossiers et documents d'inscription
|
||||||
</h2>
|
</h2>
|
||||||
@ -715,33 +775,61 @@ export default function FilesGroupsManagement({
|
|||||||
<p>
|
<p>
|
||||||
<span className="font-semibold">Organisation de la page :</span>
|
<span className="font-semibold">Organisation de la page :</span>
|
||||||
<br />
|
<br />
|
||||||
<span className="text-blue-700 font-semibold">Colonne de gauche</span> : liste des dossiers d'inscription (groupes/classes).
|
<span className="text-blue-700 font-semibold">
|
||||||
|
Colonne de gauche
|
||||||
|
</span>{' '}
|
||||||
|
: liste des dossiers d'inscription (groupes/classes).
|
||||||
<br />
|
<br />
|
||||||
<span className="text-emerald-700 font-semibold">Colonne de droite</span> : liste des documents à fournir pour l'inscription.
|
<span className="text-emerald-700 font-semibold">
|
||||||
|
Colonne de droite
|
||||||
|
</span>{' '}
|
||||||
|
: liste des documents à fournir pour l'inscription.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="font-semibold">Ajout de dossiers :</span>
|
<span className="font-semibold">Ajout de dossiers :</span>
|
||||||
<br />
|
<br />
|
||||||
Cliquez sur le bouton <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">+</span> à droite de la liste pour créer un nouveau dossier d'inscription.
|
Cliquez sur le bouton{' '}
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">
|
||||||
|
+
|
||||||
|
</span>{' '}
|
||||||
|
à droite de la liste pour créer un nouveau dossier
|
||||||
|
d'inscription.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="font-semibold">Ajout de documents :</span>
|
<span className="font-semibold">Ajout de documents :</span>
|
||||||
<br />
|
<br />
|
||||||
Cliquez sur le bouton <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">+</span> à droite de la liste des documents pour ajouter :
|
Cliquez sur le bouton{' '}
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">
|
||||||
|
+
|
||||||
|
</span>{' '}
|
||||||
|
à droite de la liste des documents pour ajouter :
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside ml-6">
|
<ul className="list-disc list-inside ml-6">
|
||||||
<li>
|
<li>
|
||||||
<span className="text-yellow-700 font-semibold">Formulaire personnalisé</span> : créé dynamiquement par l'école, à remplir et/ou signer électroniquement par la famille.
|
<span className="text-yellow-700 font-semibold">
|
||||||
|
Formulaire personnalisé
|
||||||
|
</span>{' '}
|
||||||
|
: créé dynamiquement par l'école, à remplir et/ou signer
|
||||||
|
électroniquement par la famille.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="text-black font-semibold">Formulaire existant</span> : importez un PDF ou autre document à faire remplir.
|
<span className="text-black font-semibold">
|
||||||
|
Formulaire existant
|
||||||
|
</span>{' '}
|
||||||
|
: importez un PDF ou autre document à faire remplir.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<span className="text-orange-700 font-semibold">Pièce à fournir</span> : document à déposer par la famille (ex : RIB, justificatif de domicile).
|
<span className="text-orange-700 font-semibold">
|
||||||
|
Pièce à fournir
|
||||||
|
</span>{' '}
|
||||||
|
: document à déposer par la famille (ex : RIB, justificatif de
|
||||||
|
domicile).
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-2 text-sm text-gray-600">
|
<div className="mt-2 text-sm text-gray-600">
|
||||||
<span className="font-semibold">Astuce :</span> Créez d'abord vos dossiers d'inscription avant d'ajouter des documents à fournir.
|
<span className="font-semibold">Astuce :</span> Créez d'abord
|
||||||
|
vos dossiers d'inscription avant d'ajouter des documents
|
||||||
|
à fournir.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -764,14 +852,13 @@ export default function FilesGroupsManagement({
|
|||||||
filteredParentFiles = parentFiles.filter(
|
filteredParentFiles = parentFiles.filter(
|
||||||
(file) =>
|
(file) =>
|
||||||
file.groups &&
|
file.groups &&
|
||||||
file.groups.some((gid) =>
|
file.groups.some(
|
||||||
(typeof gid === 'object' ? gid.id : gid) === selectedGroupId
|
(gid) => (typeof gid === 'object' ? gid.id : gid) === selectedGroupId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedDocuments =
|
const mergedDocuments = selectedGroupId
|
||||||
selectedGroupId
|
|
||||||
? [
|
? [
|
||||||
...filteredFiles.map((doc) => ({ ...doc, _type: 'emerald' })),
|
...filteredFiles.map((doc) => ({ ...doc, _type: 'emerald' })),
|
||||||
...filteredParentFiles.map((doc) => ({ ...doc, _type: 'orange' })),
|
...filteredParentFiles.map((doc) => ({ ...doc, _type: 'orange' })),
|
||||||
@ -783,17 +870,19 @@ export default function FilesGroupsManagement({
|
|||||||
const groupId = group.id;
|
const groupId = group.id;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
// Documents école
|
// Documents école
|
||||||
count += schoolFileMasters.filter(
|
count += schoolFileMasters.filter((file) =>
|
||||||
(file) =>
|
|
||||||
Array.isArray(file.groups)
|
Array.isArray(file.groups)
|
||||||
? file.groups.some((g) => (typeof g === 'object' ? g.id : g) === groupId)
|
? file.groups.some(
|
||||||
|
(g) => (typeof g === 'object' ? g.id : g) === groupId
|
||||||
|
)
|
||||||
: false
|
: false
|
||||||
).length;
|
).length;
|
||||||
// Pièces à fournir
|
// Pièces à fournir
|
||||||
count += parentFiles.filter(
|
count += parentFiles.filter((file) =>
|
||||||
(file) =>
|
|
||||||
Array.isArray(file.groups)
|
Array.isArray(file.groups)
|
||||||
? file.groups.some((g) => (typeof g === 'object' ? g.id : g) === groupId)
|
? file.groups.some(
|
||||||
|
(g) => (typeof g === 'object' ? g.id : g) === groupId
|
||||||
|
)
|
||||||
: false
|
: false
|
||||||
).length;
|
).length;
|
||||||
return count;
|
return count;
|
||||||
@ -840,7 +929,10 @@ export default function FilesGroupsManagement({
|
|||||||
actionButtons={(row) => (
|
actionButtons={(row) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleGroupEdit(row); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGroupEdit(row);
|
||||||
|
}}
|
||||||
className="p-2 rounded-full hover:bg-gray-100 transition"
|
className="p-2 rounded-full hover:bg-gray-100 transition"
|
||||||
title="Modifier"
|
title="Modifier"
|
||||||
>
|
>
|
||||||
@ -849,7 +941,10 @@ export default function FilesGroupsManagement({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleGroupDelete(row.id); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGroupDelete(row.id);
|
||||||
|
}}
|
||||||
className="p-2 rounded-full hover:bg-gray-100 transition"
|
className="p-2 rounded-full hover:bg-gray-100 transition"
|
||||||
title="Supprimer"
|
title="Supprimer"
|
||||||
>
|
>
|
||||||
@ -894,7 +989,8 @@ export default function FilesGroupsManagement({
|
|||||||
Formulaire existant
|
Formulaire existant
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
onClick: () => handleDocDropdownSelect('formulaire_existant'),
|
onClick: () =>
|
||||||
|
handleDocDropdownSelect('formulaire_existant'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@ -1008,12 +1104,18 @@ export default function FilesGroupsManagement({
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title={isEditing ? 'Modification du formulaire' : 'Créer un formulaire personnalisé'}
|
title={
|
||||||
|
isEditing
|
||||||
|
? 'Modification du formulaire'
|
||||||
|
: 'Créer un formulaire personnalisé'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="w-11/12 h-5/6 max-w-5xl max-h-[90vh] overflow-y-auto">
|
<div className="w-11/12 h-5/6 max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||||
<FormTemplateBuilder
|
<FormTemplateBuilder
|
||||||
onSave={(data) => {
|
onSave={(data) => {
|
||||||
(isEditing ? handleEditSchoolFileMaster : handleCreateSchoolFileMaster)(data);
|
(isEditing
|
||||||
|
? handleEditSchoolFileMaster
|
||||||
|
: handleCreateSchoolFileMaster)(data);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
}}
|
}}
|
||||||
initialData={isEditing ? fileToEdit : undefined}
|
initialData={isEditing ? fileToEdit : undefined}
|
||||||
@ -1027,15 +1129,25 @@ export default function FilesGroupsManagement({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isFileUploadPopupOpen}
|
isOpen={isFileUploadPopupOpen}
|
||||||
setIsOpen={setIsFileUploadPopupOpen}
|
setIsOpen={setIsFileUploadPopupOpen}
|
||||||
title={fileToEdit && fileToEdit.id ? 'Modifier le document existant' : 'Télécharger un document existant'}
|
title={
|
||||||
|
fileToEdit && fileToEdit.id
|
||||||
|
? 'Modifier le document existant'
|
||||||
|
: 'Télécharger un document existant'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="w-full max-h-[90vh] overflow-y-auto">
|
<div className="w-full max-h-[90vh] overflow-y-auto">
|
||||||
{fileToEdit && fileToEdit.id ? (
|
{fileToEdit && fileToEdit.id ? (
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-4 w-full"
|
className="flex flex-col gap-4 w-full"
|
||||||
onSubmit={e => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!fileToEdit?.name || !fileToEdit?.groups || fileToEdit.groups.length === 0 || !fileToEdit?.file) return;
|
if (
|
||||||
|
!fileToEdit?.name ||
|
||||||
|
!fileToEdit?.groups ||
|
||||||
|
fileToEdit.groups.length === 0 ||
|
||||||
|
!fileToEdit?.file
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
handleEditSchoolFileMaster({
|
handleEditSchoolFileMaster({
|
||||||
id: fileToEdit.id,
|
id: fileToEdit.id,
|
||||||
@ -1059,30 +1171,38 @@ export default function FilesGroupsManagement({
|
|||||||
label="Nom du document"
|
label="Nom du document"
|
||||||
name="name"
|
name="name"
|
||||||
value={fileToEdit?.name || ''}
|
value={fileToEdit?.name || ''}
|
||||||
onChange={e => setFileToEdit({ ...fileToEdit, name: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setFileToEdit({ ...fileToEdit, name: e.target.value })
|
||||||
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Groupes d'inscription <span className="text-red-500">*</span>
|
Groupes d'inscription{' '}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
|
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
|
||||||
{groups && groups.length > 0 ? (
|
{groups && groups.length > 0 ? (
|
||||||
groups.map((group) => {
|
groups.map((group) => {
|
||||||
const selectedGroupIds = (fileToEdit?.groups || []).map(g =>
|
const selectedGroupIds = (fileToEdit?.groups || []).map(
|
||||||
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
|
(g) =>
|
||||||
|
typeof g === 'object' && g !== null && 'id' in g
|
||||||
|
? g.id
|
||||||
|
: g
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<CheckBox
|
<CheckBox
|
||||||
key={group.id}
|
key={group.id}
|
||||||
item={{ id: group.id }}
|
item={{ id: group.id }}
|
||||||
formData={{
|
formData={{
|
||||||
groups: selectedGroupIds
|
groups: selectedGroupIds,
|
||||||
}}
|
}}
|
||||||
handleChange={() => {
|
handleChange={() => {
|
||||||
let group_ids = selectedGroupIds;
|
let group_ids = selectedGroupIds;
|
||||||
if (group_ids.includes(group.id)) {
|
if (group_ids.includes(group.id)) {
|
||||||
group_ids = group_ids.filter((id) => id !== group.id);
|
group_ids = group_ids.filter(
|
||||||
|
(id) => id !== group.id
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
group_ids = [...group_ids, group.id];
|
group_ids = [...group_ids, group.id];
|
||||||
}
|
}
|
||||||
@ -1104,14 +1224,16 @@ export default function FilesGroupsManagement({
|
|||||||
{fileToEdit?.file && (
|
{fileToEdit?.file && (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<FileText className="w-5 h-5 text-gray-600" />
|
<FileText className="w-5 h-5 text-gray-600" />
|
||||||
<span className="text-sm truncate">{fileToEdit.file.name || fileToEdit.file.path || 'Document sélectionné'}</span>
|
<span className="text-sm truncate">
|
||||||
|
{fileToEdit.file.name ||
|
||||||
|
fileToEdit.file.path ||
|
||||||
|
'Document sélectionné'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FileUpload
|
<FileUpload
|
||||||
selectionMessage="Sélectionnez le fichier du document"
|
selectionMessage="Sélectionnez le fichier du document"
|
||||||
onFileSelect={file =>
|
onFileSelect={(file) => setFileToEdit({ ...fileToEdit, file })}
|
||||||
setFileToEdit({ ...fileToEdit, file })
|
|
||||||
}
|
|
||||||
required
|
required
|
||||||
enable
|
enable
|
||||||
/>
|
/>
|
||||||
@ -1131,9 +1253,15 @@ export default function FilesGroupsManagement({
|
|||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-4 w-full"
|
className="flex flex-col gap-4 w-full"
|
||||||
onSubmit={e => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!fileToEdit?.name || !fileToEdit?.groups || fileToEdit.groups.length === 0 || !fileToEdit?.file) return;
|
if (
|
||||||
|
!fileToEdit?.name ||
|
||||||
|
!fileToEdit?.groups ||
|
||||||
|
fileToEdit.groups.length === 0 ||
|
||||||
|
!fileToEdit?.file
|
||||||
|
)
|
||||||
|
return;
|
||||||
handleCreateSchoolFileMaster({
|
handleCreateSchoolFileMaster({
|
||||||
name: fileToEdit.name,
|
name: fileToEdit.name,
|
||||||
group_ids: fileToEdit.groups,
|
group_ids: fileToEdit.groups,
|
||||||
@ -1147,30 +1275,38 @@ export default function FilesGroupsManagement({
|
|||||||
label="Nom du document"
|
label="Nom du document"
|
||||||
name="name"
|
name="name"
|
||||||
value={fileToEdit?.name || ''}
|
value={fileToEdit?.name || ''}
|
||||||
onChange={e => setFileToEdit({ ...fileToEdit, name: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setFileToEdit({ ...fileToEdit, name: e.target.value })
|
||||||
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Groupes d'inscription <span className="text-red-500">*</span>
|
Groupes d'inscription{' '}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
|
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
|
||||||
{groups && groups.length > 0 ? (
|
{groups && groups.length > 0 ? (
|
||||||
groups.map((group) => {
|
groups.map((group) => {
|
||||||
const selectedGroupIds = (fileToEdit?.groups || []).map(g =>
|
const selectedGroupIds = (fileToEdit?.groups || []).map(
|
||||||
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
|
(g) =>
|
||||||
|
typeof g === 'object' && g !== null && 'id' in g
|
||||||
|
? g.id
|
||||||
|
: g
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<CheckBox
|
<CheckBox
|
||||||
key={group.id}
|
key={group.id}
|
||||||
item={{ id: group.id }}
|
item={{ id: group.id }}
|
||||||
formData={{
|
formData={{
|
||||||
groups: selectedGroupIds
|
groups: selectedGroupIds,
|
||||||
}}
|
}}
|
||||||
handleChange={() => {
|
handleChange={() => {
|
||||||
let group_ids = selectedGroupIds;
|
let group_ids = selectedGroupIds;
|
||||||
if (group_ids.includes(group.id)) {
|
if (group_ids.includes(group.id)) {
|
||||||
group_ids = group_ids.filter((id) => id !== group.id);
|
group_ids = group_ids.filter(
|
||||||
|
(id) => id !== group.id
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
group_ids = [...group_ids, group.id];
|
group_ids = [...group_ids, group.id];
|
||||||
}
|
}
|
||||||
@ -1190,9 +1326,7 @@ export default function FilesGroupsManagement({
|
|||||||
</div>
|
</div>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
selectionMessage="Sélectionnez le fichier du document"
|
selectionMessage="Sélectionnez le fichier du document"
|
||||||
onFileSelect={file =>
|
onFileSelect={(file) => setFileToEdit({ ...fileToEdit, file })}
|
||||||
setFileToEdit({ ...fileToEdit, file })
|
|
||||||
}
|
|
||||||
required
|
required
|
||||||
enable
|
enable
|
||||||
/>
|
/>
|
||||||
@ -1229,13 +1363,14 @@ export default function FilesGroupsManagement({
|
|||||||
<div className="w-full max-w-md max-h-[90vh] overflow-y-auto">
|
<div className="w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<form
|
<form
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
onSubmit={e => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (
|
if (
|
||||||
!editingParentFile?.name ||
|
!editingParentFile?.name ||
|
||||||
!editingParentFile?.groups ||
|
!editingParentFile?.groups ||
|
||||||
editingParentFile.groups.length === 0
|
editingParentFile.groups.length === 0
|
||||||
) return;
|
)
|
||||||
|
return;
|
||||||
const payload = {
|
const payload = {
|
||||||
name: editingParentFile.name,
|
name: editingParentFile.name,
|
||||||
description: editingParentFile.description || '',
|
description: editingParentFile.description || '',
|
||||||
@ -1255,41 +1390,61 @@ export default function FilesGroupsManagement({
|
|||||||
label="Nom de la pièce à fournir"
|
label="Nom de la pièce à fournir"
|
||||||
name="name"
|
name="name"
|
||||||
value={editingParentFile?.name || ''}
|
value={editingParentFile?.name || ''}
|
||||||
onChange={e => setEditingParentFile({ ...editingParentFile, name: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditingParentFile({
|
||||||
|
...editingParentFile,
|
||||||
|
name: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<InputText
|
<InputText
|
||||||
label="Description"
|
label="Description"
|
||||||
name="description"
|
name="description"
|
||||||
value={editingParentFile?.description || ''}
|
value={editingParentFile?.description || ''}
|
||||||
onChange={e => setEditingParentFile({ ...editingParentFile, description: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditingParentFile({
|
||||||
|
...editingParentFile,
|
||||||
|
description: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
required={false}
|
required={false}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Groupes d'inscription <span className="text-red-500">*</span>
|
Groupes d'inscription{' '}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
|
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
|
||||||
{groups && groups.length > 0 ? (
|
{groups && groups.length > 0 ? (
|
||||||
groups.map((group) => {
|
groups.map((group) => {
|
||||||
const selectedGroupIds = (editingParentFile?.groups || []).map(g =>
|
const selectedGroupIds = (
|
||||||
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
|
editingParentFile?.groups || []
|
||||||
|
).map((g) =>
|
||||||
|
typeof g === 'object' && g !== null && 'id' in g
|
||||||
|
? g.id
|
||||||
|
: g
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<CheckBox
|
<CheckBox
|
||||||
key={group.id}
|
key={group.id}
|
||||||
item={{ id: group.id }}
|
item={{ id: group.id }}
|
||||||
formData={{
|
formData={{
|
||||||
groups: selectedGroupIds
|
groups: selectedGroupIds,
|
||||||
}}
|
}}
|
||||||
handleChange={() => {
|
handleChange={() => {
|
||||||
let group_ids = selectedGroupIds;
|
let group_ids = selectedGroupIds;
|
||||||
if (group_ids.includes(group.id)) {
|
if (group_ids.includes(group.id)) {
|
||||||
group_ids = group_ids.filter((id) => id !== group.id);
|
group_ids = group_ids.filter(
|
||||||
|
(id) => id !== group.id
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
group_ids = [...group_ids, group.id];
|
group_ids = [...group_ids, group.id];
|
||||||
}
|
}
|
||||||
setEditingParentFile({ ...editingParentFile, groups: group_ids });
|
setEditingParentFile({
|
||||||
|
...editingParentFile,
|
||||||
|
groups: group_ids,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
fieldName="groups"
|
fieldName="groups"
|
||||||
itemLabelFunc={() => group.name}
|
itemLabelFunc={() => group.name}
|
||||||
|
|||||||
54
Front-End/src/pages/api/download.js
Normal file
54
Front-End/src/pages/api/download.js
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Front-End/src/utils/fileUrl.js
Normal file
25
Front-End/src/utils/fileUrl.js
Normal 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)}`;
|
||||||
|
};
|
||||||
@ -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
293
docs/design-system.md
Normal 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.
|
||||||
@ -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": {
|
||||||
|
|||||||
437
scripts/create-establishment.js
Normal file
437
scripts/create-establishment.js
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user