Merge remote-tracking branch 'origin/develop'

This commit is contained in:
N3WT DE COMPET
2025-06-01 13:37:08 +02:00
424 changed files with 60158 additions and 11132 deletions

4
Front-End/.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": ["next/babel"],
"plugins": []
}

View File

@ -1,2 +0,0 @@
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_USE_FAKE_DATA='false'

3
Front-End/.eslintignore Normal file
View File

@ -0,0 +1,3 @@
node_modules/
build/
public/

View File

@ -1,3 +1,10 @@
{
"extends": "next/core-web-vitals"
"extends": ["next", "next/core-web-vitals"],
"rules": {
// Ajoutez vos règles personnalisées ici
"react/react-in-jsx-scope": "off", // Désactive l'obligation d'importer React
"no-console": "error", // Avertissement pour les console.log
"semi": ["error", "always"], // Exige un point-virgule à la fin des lignes
"quotes": ["error", "single", { "avoidEscape": true }] // Exige des guillemets simples, sauf si l'on utilise des guillemets doubles à l'intérieur
}
}

View File

@ -0,0 +1,3 @@
node_modules/
build/
dist/

6
Front-End/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@ -1,6 +0,0 @@
{
"i18n-ally.localesPaths": [
"messages"
],
"i18n-ally.keystyle": "nested"
}

45
Front-End/Dockerfile Normal file
View File

@ -0,0 +1,45 @@
# Build argument pour choisir le mode
ARG BUILD_MODE=production
# Build stage pour production uniquement
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
COPY prod.env .env
RUN npm run build
# Development stage
FROM node:18-alpine AS development
ENV NODE_ENV=development
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Ajout de la surveillance des fichiers pour le hot reload
ENV WATCHPACK_POLLING=true
ENV CHOKIDAR_USEPOLLING=true
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/messages ./messages
COPY docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN chown 1001:1001 -R /app
USER nextjs
ENV HOSTNAME="0.0.0.0"
EXPOSE 3000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["node", "server.js"]
# Final stage selection
FROM ${BUILD_MODE}

View File

@ -0,0 +1,28 @@
#!/bin/sh
# Fonction pour échapper les caractères spéciaux
escape_value() {
echo "$1" | sed 's/[\/&]/\\&/g'
}
replace_value() {
key=$1
value=$2
file=$3
escaped_value=$(escape_value "$value")
find . -type f -exec sed -i "s|_${key}_|${value}|g" {} \;
}
# Lire les clés et valeurs depuis un fichier .env
if [ -f .env ]; then
while IFS='=' read -r key value; do
# Ignorer les lignes vides et les commentaires
[ -z "$key" ] && continue
[ "${key#\#}" != "$key" ] && continue
replace_value $key $value /app/
done < .env
fi
exec "$@"

View File

@ -0,0 +1,340 @@
# API Messagerie Instantanée - Guide Développeur
## Vue d'ensemble
Cette documentation technique présente l'implémentation du système de messagerie instantanée, incluant les APIs WebSocket et REST, l'architecture des composants React et les fonctions utilitaires.
## API WebSocket
### Connexion
**URL de connexion :**
```javascript
// Développement
ws://localhost:8000/ws/chat/{userId}/
// Production
wss://[domaine]/ws/chat/{userId}/
```
### Messages WebSocket
#### Messages entrants (serveur → client)
```javascript
// Liste des conversations
{
"type": "conversations_list",
"conversations": [...]
}
// Nouveau message reçu
{
"type": "new_message",
"message": {
"id": 123,
"conversation_id": 456,
"sender_id": 789,
"content": "Contenu du message",
"timestamp": "2024-01-01T12:00:00Z"
}
}
// Utilisateur en train d'écrire
{
"type": "typing_start",
"conversation_id": 456,
"user_id": 789
}
// Utilisateur a arrêté d'écrire
{
"type": "typing_stop",
"conversation_id": 456,
"user_id": 789
}
```
#### Messages sortants (client → serveur)
```javascript
// Envoyer un message
{
"type": "chat_message",
"conversation_id": 456,
"message": "Contenu du message"
}
// Signaler début de frappe
{
"type": "typing_start",
"conversation_id": 456
}
// Signaler fin de frappe
{
"type": "typing_stop",
"conversation_id": 456
}
// Marquer comme lu
{
"type": "mark_as_read",
"conversation_id": 456
}
// Rejoindre une conversation
{
"type": "join_conversation",
"conversation_id": 456
}
```
## API REST
### Endpoints disponibles
```javascript
// Récupérer les conversations
GET /api/messagerie/conversations/{userId}/
Response: Array<Conversation>
// Récupérer les messages d'une conversation
GET /api/messagerie/messages/{conversationId}/
Response: Array<Message>
// Rechercher des destinataires
GET /api/messagerie/search/{establishmentId}/?q={query}
Response: Array<User>
// Créer une conversation
POST /api/messagerie/conversations/create/
Body: { "participants": [userId1, userId2] }
Response: Conversation
// Envoyer un email (séparé de la messagerie instantanée)
POST /api/email/send/
Body: { "recipients": [...], "subject": "...", "content": "..." }
```
## Composants React
### InstantChat
**Props :**
```javascript
{
userProfileId: number, // ID de l'utilisateur connecté
establishmentId: number // ID de l'établissement
}
```
**États principaux :**
- `conversations` : Liste des conversations
- `selectedConversation` : Conversation active
- `messages` : Messages de la conversation active
- `searchQuery` : Terme de recherche
- `searchResults` : Résultats de recherche de contacts
### useWebSocket Hook
**Paramètres :**
```javascript
useWebSocket(
userProfileId, // ID utilisateur
onMessage, // Callback pour messages reçus
onConnectionChange // Callback changement de connexion
);
```
**Valeurs retournées :**
```javascript
{
isConnected: boolean,
connectionStatus: string,
sendChatMessage: (conversationId, content) => boolean,
sendTypingStart: (conversationId) => void,
sendTypingStop: (conversationId) => void,
markAsRead: (conversationId) => void,
joinConversation: (conversationId) => void,
reconnect: () => void
}
```
## Actions Redux/State
### messagerieAction.js
```javascript
// Récupérer les conversations
fetchConversations(userId): Promise<Array<Conversation>>
// Récupérer les messages
fetchMessages(conversationId): Promise<Array<Message>>
// Rechercher des destinataires
searchMessagerieRecipients(establishmentId, query): Promise<Array<User>>
// Créer une conversation
createConversation(participants): Promise<Conversation>
```
### emailAction.js
```javascript
// Envoyer un email
sendEmail(recipients, subject, content, csrfToken): Promise<Response>
// Rechercher des destinataires email
searchEmailRecipients(establishmentId, query): Promise<Array<User>>
```
## Modèles de Données
### Conversation
```javascript
{
conversation_id: number,
participants: Array<User>,
last_message: Message,
created_at: string,
updated_at: string
}
```
### Message
```javascript
{
id: number,
conversation_id: number,
sender_id: number,
content: string,
timestamp: string,
is_read: boolean
}
```
### User
```javascript
{
id: number,
first_name: string,
last_name: string,
email: string,
role: string
}
```
## Gestion des Erreurs
### WebSocket
```javascript
// Reconnexion automatique
const reconnectWebSocket = () => {
setConnectionStatus('reconnecting');
// Logique de reconnexion avec backoff exponentiel
};
// Gestion des erreurs de connexion
wsRef.current.onerror = (error) => {
logger.error('Erreur WebSocket:', error);
setIsConnected(false);
};
```
### API REST
```javascript
// Wrapper avec gestion d'erreur
const apiCall = async (url, options) => {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Erreur ${response.status}`);
}
return await response.json();
} catch (error) {
logger.error('Erreur API:', error);
throw error;
}
};
```
## Configuration des Tests
### Jest Setup
```javascript
// jest.setup.js
global.WebSocket = class MockWebSocket {
// Mock complet du WebSocket pour les tests
};
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
);
```
### Tests des Composants
```javascript
// Exemple de test
test('renders InstantChat component', async () => {
await act(async () => {
render(<InstantChat userProfileId={1} establishmentId={123} />);
});
expect(screen.getByText('Messages')).toBeInTheDocument();
});
```
## Intégration Backend
### Consumer Django
```python
# consumers.py
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
# Logique de connexion
async def chat_message(self, event):
# Traitement des messages
```
### URLs Configuration
```python
# routing.py
websocket_urlpatterns = [
re_path(r'ws/chat/(?P<user_id>\w+)/$', ChatConsumer.as_asgi()),
]
```
## Optimisations
### Performance
- Pagination des messages anciens (load on scroll)
- Debounce pour la recherche de contacts (300ms)
- Memoization des composants avec React.memo
- Lazy loading des conversations
### UX
- Reconnexion automatique avec feedback visuel
- Sauvegarde locale des messages en cours de frappe
- Indicateurs de livraison des messages
- Scrolling automatique vers les nouveaux messages

View File

@ -0,0 +1,126 @@
# Système de Messagerie Instantanée
## Présentation
Le système de messagerie instantanée de N3WT-SCHOOL permet aux utilisateurs de l'établissement (administrateurs, professeurs, parents, étudiants) de communiquer en temps réel via une interface chat moderne et intuitive.
## Fonctionnalités
### Chat en Temps Réel
- Envoi et réception de messages instantanés
- Notification de statut de frappe (utilisateur en train d'écrire)
- Indicateur de statut de connexion WebSocket
- Reconnexion automatique en cas de perte de connexion
### Gestion des Conversations
- Liste des conversations existantes
- Création de nouvelles conversations
- Recherche de destinataires par nom ou email
- Compteur de messages non lus
### Interface Utilisateur
- Interface moderne en deux panneaux (conversations + chat)
- Bulles de messages différenciées (expéditeur/destinataire)
- Indicateurs visuels de statut de connexion
- Recherche temps réel de contacts
## Utilisation
### Accès au Chat
Le système de messagerie est accessible via les pages suivantes :
- **Parents** : `/[locale]/parents/messagerie`
- **Administrateurs** : Intégré dans le panneau d'administration
### Créer une Conversation
1. Cliquer sur le bouton "+" en haut à droite de la liste des conversations
2. Rechercher un contact en tapant son nom ou email
3. Sélectionner le destinataire dans les résultats
4. La conversation se crée automatiquement
### Envoyer un Message
1. Sélectionner une conversation dans la liste de gauche
2. Taper le message dans le champ de saisie en bas
3. Appuyer sur Entrée ou cliquer sur le bouton d'envoi
## Architecture Technique
### Frontend (React/Next.js)
**Composants principaux :**
- `InstantChat` : Composant principal du chat
- `ConnectionStatus` : Affichage du statut de connexion
- `ConversationItem` : Élément de liste de conversation
- `MessageBubble` : Bulle de message individuelle
- `MessageInput` : Zone de saisie de message
- `TypingIndicator` : Indicateur de frappe
**Hook personnalisé :**
- `useWebSocket` : Gestion de la connexion WebSocket et des événements
### Backend (Django)
**Module GestionMessagerie :**
- `consumers.py` : Consumer WebSocket pour la messagerie temps réel
- `routing.py` : Configuration des routes WebSocket
- `urls.py` : URLs API REST pour les conversations et messages
**Module GestionEmail :**
- `views.py` : Vues pour l'envoi d'emails classiques
- `urls.py` : URLs pour les fonctions email
### Communication
- **WebSocket** : Communication bidirectionnelle temps réel
- **REST API** : Chargement initial des données et recherche
- **Channels** : Gestion des groupes de conversation Django
## Configuration
### URLs WebSocket
Les URLs sont configurées automatiquement selon l'environnement :
- **Développement** : `ws://localhost:8000/ws/chat/`
- **Production** : `wss://[domaine]/ws/chat/`
### Variables d'Environnement
Le système utilise les configurations standard de l'application pour :
- Base de données (conversations, messages, utilisateurs)
- Authentification (sessions Django)
- Établissements (filtrage par établissement)
## Sécurité
- Authentification requise pour accéder au chat
- Filtrage des conversations par établissement
- Validation côté serveur de tous les messages
- Gestion des permissions selon le rôle utilisateur
## Tests
Le système dispose de tests unitaires Jest couvrant :
- Rendu des composants
- Gestion des connexions WebSocket
- Recherche de contacts
- Envoi de messages
- Indicateurs de frappe
Exécution des tests :
```bash
npm test
```

33
Front-End/jest.config.js Normal file
View File

@ -0,0 +1,33 @@
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/**/*.stories.{js,jsx}',
'!src/pages/_app.js',
'!src/pages/_document.js',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

95
Front-End/jest.setup.js Normal file
View File

@ -0,0 +1,95 @@
import '@testing-library/jest-dom';
// Supprimer les avertissements React act() en environnement de test
global.IS_REACT_ACT_ENVIRONMENT = true;
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
observe() {
return null;
}
disconnect() {
return null;
}
unobserve() {
return null;
}
};
// Mock WebSocket
global.WebSocket = class WebSocket {
constructor(url) {
this.url = url;
this.readyState = WebSocket.CONNECTING;
setTimeout(() => {
this.readyState = WebSocket.OPEN;
if (this.onopen) this.onopen();
}, 10);
}
send(data) {
// Mock send
}
close() {
this.readyState = WebSocket.CLOSED;
if (this.onclose) {
this.onclose({
code: 1000,
reason: 'Normal closure',
wasClean: true,
});
}
}
static get CONNECTING() {
return 0;
}
static get OPEN() {
return 1;
}
static get CLOSING() {
return 2;
}
static get CLOSED() {
return 3;
}
};
// Mock global pour fetch
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
);
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
observe() {
return null;
}
disconnect() {
return null;
}
unobserve() {
return null;
}
};

View File

@ -1,4 +1,3 @@
{
"responsable": "Guardian",
"delete": "Delete",
@ -10,4 +9,4 @@
"profession": "Profession",
"address": "Address",
"add_responsible": "Add guardian"
}
}

View File

@ -1,9 +1,10 @@
{
"dashboard": "Dashboard",
"totalStudents": "Total Students",
"averageInscriptionTime": "Average Registration Time",
"reInscriptionRate": "Re-enrollment Rate",
"structureCapacity": "Structure Capacity",
"inscriptionTrends": "Enrollment Trends",
"upcomingEvents": "Upcoming Events"
}
"dashboard": "Dashboard",
"totalStudents": "Total Students",
"pendingRegistrations": "Pending Registration",
"reInscriptionRate": "Re-enrollment Rate",
"structureCapacity": "Structure Capacity",
"capacityRate": "Capacity Rate",
"inscriptionTrends": "Enrollment Trends",
"upcomingEvents": "Upcoming Events"
}

View File

@ -1,5 +1,5 @@
{
"welcomeParents": "Welcome Parents",
"pleaseLogin": "Please login to access your account",
"loginButton": "Go to login page"
}
"welcomeParents": "Welcome Parents",
"pleaseLogin": "Please login to access your account",
"loginButton": "Go to login page"
}

View File

@ -1,6 +1,6 @@
{
"page": "Page",
"of": "of",
"previous": "Previous",
"next": "Next"
}
"page": "Page",
"of": "of",
"previous": "Previous",
"next": "Next"
}

View File

@ -1,9 +1,11 @@
{
"dashboard": "Dashboard",
"subscriptions": "Subscriptions",
"structure": "Structure",
"planning": "Schedule",
"grades": "Grades",
"settings": "Settings",
"schoolAdmin": "School Administration"
}
"dashboard": "Dashboard",
"subscriptions": "Subscriptions",
"structure": "Structure",
"directory": "Directory",
"events": "Events",
"educational_monitoring": "Educational Monitoring",
"settings": "Settings",
"schoolAdmin": "School Administration",
"messagerie": "Messenger"
}

View File

@ -1,33 +1,35 @@
{
"headerBarTitle": "Administration",
"addStudent": "New",
"allStudents": "All Students",
"pending": "Pending Registrations",
"subscribed": "Subscribed",
"archived": "Archived",
"name": "Name",
"class": "Class",
"status": "Status",
"attendance": "Attendance",
"lastEvaluation": "Last Evaluation",
"active": "Active",
"pendingStatus": "Pending",
"goodAttendance": "Good",
"averageAttendance": "Average",
"lowAttendance": "Poor",
"searchStudent": "Search for a student...",
"title": "Registration",
"information": "Information",
"no_records": "There are currently no registration records.",
"add_button": "Add",
"create_first_record": "Please click the ADD button to create your first registration record.",
"studentName":"Student name",
"studentFistName":"Student first name",
"mainContactMail":"Main contact email",
"phone":"Phone",
"lastUpdateDate":"Last update",
"classe":"Class",
"registrationFileStatus":"Registration file status",
"files":"Files",
"subscribeFiles":"Subscribe files"
}
"headerBarTitle": "Administration",
"addStudent": "New",
"allStudents": "All Students",
"pending": "Pending Registrations",
"subscribed": "Subscribed",
"archived": "Archived",
"photo": "Photo",
"name": "Name",
"class": "Class",
"status": "Status",
"attendance": "Attendance",
"lastEvaluation": "Last Evaluation",
"active": "Active",
"pendingStatus": "Pending",
"goodAttendance": "Good",
"averageAttendance": "Average",
"lowAttendance": "Poor",
"searchStudent": "Search for a student...",
"title": "Registration",
"information": "Information",
"no_records": "There are currently no registration records.",
"add_button": "Add",
"create_first_record": "Please click the ADD button to create your first registration record.",
"studentName": "Student name",
"studentFistName": "Student first name",
"mainContactMail": "Main contact email",
"phone": "Phone",
"lastUpdateDate": "Last update",
"classe": "Class",
"registrationFileStatus": "Registration file status",
"files": "Files",
"subscribeFiles": "Subscribe files",
"historical": "Historical"
}

View File

@ -1,4 +1,3 @@
{
"responsable": "Responsable",
"delete": "Supprimer",
@ -8,6 +7,5 @@
"phone": "Téléphone",
"birthdate": "Date de naissance",
"profession": "Profession",
"address": "Adresse",
"add_responsible": "Ajouter un responsable"
"address": "Adresse"
}

View File

@ -1,9 +1,10 @@
{
"dashboard": "Tableau de bord",
"totalStudents": "Total des étudiants",
"averageInscriptionTime": "Temps moyen d'inscription",
"reInscriptionRate": "Taux de réinscription",
"structureCapacity": "Remplissage de la structure",
"inscriptionTrends": "Tendances d'inscription",
"upcomingEvents": "Événements à venir"
}
"dashboard": "Tableau de bord",
"totalStudents": "Total d'étudiants inscrits",
"pendingRegistrations": "Inscriptions en attente",
"reInscriptionRate": "Taux de réinscription",
"structureCapacity": "Capacité de la structure",
"capacityRate": "Remplissage de la structure",
"inscriptionTrends": "Tendances d'inscription",
"upcomingEvents": "Événements à venir"
}

View File

@ -1,5 +1,5 @@
{
"welcomeParents": "Bienvenue aux parents",
"pleaseLogin": "Veuillez vous connecter pour accéder à votre compte",
"loginButton": "Accéder à la page de login"
}
"welcomeParents": "Bienvenue aux parents",
"pleaseLogin": "Veuillez vous connecter pour accéder à votre compte",
"loginButton": "Accéder à la page de login"
}

View File

@ -1,6 +1,6 @@
{
"page": "Page",
"of": "sur",
"previous": "Précédent",
"next": "Suivant"
}
"page": "Page",
"of": "sur",
"previous": "Précédent",
"next": "Suivant"
}

View File

@ -1,9 +1,11 @@
{
"dashboard": "Tableau de bord",
"subscriptions": "Inscriptions",
"structure": "Structure",
"planning": "Emploi du temps",
"grades": "Notes",
"settings": "Paramètres",
"schoolAdmin": "Administration Scolaire"
}
{
"dashboard": "Tableau de bord",
"subscriptions": "Inscriptions",
"structure": "Structure",
"directory": "Annuaire",
"events": "Evenements",
"educational_monitoring": "Suivi pédagogique",
"settings": "Paramètres",
"schoolAdmin": "Administration Scolaire",
"messagerie": "Messagerie"
}

View File

@ -1,33 +1,35 @@
{
"headerBarTitle":"Administration",
"addStudent": "Nouveau",
"allStudents": "Tous les élèves",
"pending": "Inscriptions en attente",
"subscribed": "Inscrits",
"archived": "Archivés",
"name": "Nom",
"class": "Classe",
"status": "Statut",
"attendance": "Assiduité",
"lastEvaluation": "Dernière évaluation",
"active": "Actif",
"pendingStatus": "En attente",
"goodAttendance": "Bonne",
"averageAttendance": "Moyenne",
"lowAttendance": "Faible",
"searchStudent": "Rechercher un élève...",
"title": "Inscription",
"information": "Information",
"no_records": "Il n'y a actuellement aucun dossier d'inscription.",
"add_button": "Ajouter",
"create_first_record": "Veuillez cliquer sur le bouton AJOUTER pour créer votre premier dossier d'inscription.",
"studentName":"Nom de l'élève",
"studentFistName":"Prénom de l'élève",
"mainContactMail":"Email de contact principal",
"phone":"Téléphone",
"lastUpdateDate":"Dernière mise à jour",
"classe":"Classe",
"registrationFileStatus":"État du dossier d'inscription",
"files":"Fichiers",
"subscribeFiles":"Fichiers d'inscription"
}
"headerBarTitle": "Administration",
"addStudent": "Nouveau",
"allStudents": "Tous les élèves",
"pending": "Inscriptions en attente",
"subscribed": "Inscrits",
"archived": "Archivés",
"photo": "Photo",
"name": "Nom",
"class": "Classe",
"status": "Statut",
"attendance": "Assiduité",
"lastEvaluation": "Dernière évaluation",
"active": "Actif",
"pendingStatus": "En attente",
"goodAttendance": "Bonne",
"averageAttendance": "Moyenne",
"lowAttendance": "Faible",
"searchStudent": "Rechercher un élève...",
"title": "Inscription",
"information": "Information",
"no_records": "Il n'y a actuellement aucun dossier d'inscription.",
"add_button": "Ajouter",
"create_first_record": "Veuillez cliquer sur le bouton AJOUTER pour créer votre premier dossier d'inscription.",
"studentName": "Nom de l'élève",
"studentFistName": "Prénom de l'élève",
"mainContactMail": "Email de contact principal",
"phone": "Téléphone",
"lastUpdateDate": "Dernière mise à jour",
"classe": "Classe",
"registrationFileStatus": "État du dossier d'inscription",
"files": "Fichiers",
"subscribeFiles": "Fichiers d'inscription",
"historical": "Historique"
}

View File

@ -1,8 +1,61 @@
import createNextIntlPlugin from 'next-intl/plugin';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json');
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
experimental: {
instrumentationHook: true,
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'www.gravatar.com',
},
{
protocol: 'https',
hostname: 'api.demo.n3wtschool.com',
},
{
protocol: 'https',
hostname: 'api.prod.n3wtschool.com',
},
{
protocol: 'http',
hostname: 'localhost',
port: '8080',
},
],
},
env: {
NEXT_PUBLIC_APP_VERSION: pkg.version,
NEXT_PUBLIC_API_URL:
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
NEXT_PUBLIC_WSAPI_URL:
process.env.NEXT_PUBLIC_WSAPI_URL || 'ws://localhost:8080',
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',
DOCUSEAL_API_KEY: process.env.DOCUSEAL_API_KEY,
},
async rewrites() {
return [
{
source: '/api/documents/:path*',
destination: 'https://api.docuseal.com/v1/documents/:path*',
},
{
source: '/api/auth/:path*',
destination: '/api/auth/:path*', // Exclure les routes NextAuth des réécritures de proxy
},
];
},
};
export default withNextIntl(nextConfig);
export default withNextIntl(nextConfig);

15299
Front-End/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,54 @@
{
"name": "n3wt-school-front-end",
"version": "0.0.1",
"version": "0.0.2",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"check-strings": "node scripts/check-hardcoded-strings.js"
"lint-light": "next lint --quiet",
"check-strings": "node scripts/check-hardcoded-strings.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@docuseal/react": "^1.0.56",
"@radix-ui/react-dialog": "^1.1.2",
"@tailwindcss/forms": "^0.5.9",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"framer-motion": "^11.11.11",
"ics": "^3.8.1",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.453.0",
"next": "14.2.11",
"next-auth": "^4.24.11",
"next-intl": "^3.24.0",
"next-logger": "^5.0.1",
"pino": "^9.6.0",
"react": "^18",
"react-circular-progressbar": "^2.2.0",
"react-cookie": "^7.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18",
"react-phone-number-input": "^3.4.8",
"react-international-phone": "^4.5.0",
"react-quill": "^2.0.0",
"react-tooltip": "^5.28.0"
},
"devDependencies": {
"@babel/parser": "^7.26.2",
"@babel/traverse": "^7.25.9",
"@testing-library/react": "^13.4.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/user-event": "^14.4.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"autoprefixer": "^10.4.20",
"eslint": "^8",
"eslint-config-next": "14.2.11",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14"
}
}
}

View File

@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
}
};

5
Front-End/prod.env Normal file
View File

@ -0,0 +1,5 @@
NEXT_PUBLIC_API_URL=_NEXT_PUBLIC_API_URL_
NEXT_PUBLIC_WSAPI_URL=_NEXT_PUBLIC_WSAPI_URL_
NEXT_PUBLIC_USE_FAKE_DATA=_NEXT_PUBLIC_USE_FAKE_DATA_
AUTH_SECRET=_AUTH_SECRET_
NEXTAUTH_URL=_NEXTAUTH_URL_

View File

@ -1 +0,0 @@
cache

View File

@ -1 +0,0 @@
2ff5cbbb4bc1c6d178400871dfa342ac4f0b18e9b86cb64a1110be1ec54238c1

View File

@ -1,12 +0,0 @@
{
// official schema ensures that your project file is valid
"$schema": "https://inlang.com/schema/project-settings",
// the "source" language tag that is used in your project
"sourceLanguageTag": "fr",
// all the language tags you want to support in your project
"languageTags": ["fr", "en"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-json@4/dist/index.js"
], // or use another storage module: https://inlang.com/c/plugins (i18next, json, inlang message format)
"settings": {}
}

View File

@ -29,7 +29,7 @@ const TAILWIND_PATTERNS = [
// États
/^(hover|focus|active|disabled|group|dark):/,
// Couleurs
/-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]+$/
/-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]+$/,
];
// Nouveaux patterns pour ignorer les logs et imports
@ -58,7 +58,7 @@ function isHardcodedString(str) {
// Vérifier si la chaîne fait partie d'un console.log ou d'un import
const context = str.trim();
if (CODE_PATTERNS.some(pattern => pattern.test(context))) {
if (CODE_PATTERNS.some((pattern) => pattern.test(context))) {
return false;
}
@ -68,19 +68,20 @@ function isHardcodedString(str) {
}
// Vérifier si c'est une chaîne dans un import
if (context.includes('from \'') || context.includes('from "')) {
if (context.includes("from '") || context.includes('from "')) {
return false;
}
// Vérifier si c'est une classe Tailwind
const classes = str.split(' ');
if (classes.some(cls =>
TAILWIND_PATTERNS.some(pattern => pattern.test(cls))
)) {
if (
classes.some((cls) =>
TAILWIND_PATTERNS.some((pattern) => pattern.test(cls))
)
) {
return false;
}
// Autres patterns à ignorer
const IGNORE_PATTERNS = [
/^[A-Z][A-Za-z]+$/, // Noms de composants
@ -95,7 +96,7 @@ function isHardcodedString(str) {
/^className=/, // className attributes
];
return !IGNORE_PATTERNS.some(pattern => pattern.test(str));
return !IGNORE_PATTERNS.some((pattern) => pattern.test(str));
}
async function scanFile(filePath) {
@ -106,7 +107,7 @@ async function scanFile(filePath) {
const ast = babel.parse(content, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
locations: true // Active le tracking des positions
locations: true, // Active le tracking des positions
});
traverse(ast, {
@ -140,7 +141,11 @@ async function scanDirectory(dir) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory() && !file.startsWith('.') && file !== 'node_modules') {
if (
stat.isDirectory() &&
!file.startsWith('.') &&
file !== 'node_modules'
) {
Object.assign(results, await scanDirectory(filePath));
} else if (
stat.isFile() &&
@ -189,4 +194,4 @@ async function main() {
await logStringsToFile(results);
}
main().catch(console.error);
main().catch(console.error);

21
Front-End/src/app/500.js Normal file
View File

@ -0,0 +1,21 @@
import Link from 'next/link';
import Logo from '../components/Logo';
export default function Custom500() {
return (
<div className="flex items-center justify-center min-h-screen bg-emerald-500">
<div className="text-center p-6 ">
<Logo className="w-32 h-32 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-emerald-900 mb-4">
500 | Erreur interne
</h2>
<p className="text-emerald-900 mb-4">
Une erreur interne est survenue.
</p>
<Link className="text-gray-900 hover:underline" href="/">
Retour Accueil
</Link>
</div>
</div>
);
}

View File

@ -1,49 +0,0 @@
'use client'
import React, { useState, useEffect } from 'react';
import Table from '@/components/Table';
import Button from '@/components/Button';
const columns = [
{ name: 'Nom', transform: (row) => row.Nom },
{ name: 'Niveau', transform: (row) => row.Niveau },
{ name: 'Effectif', transform: (row) => row.Effectif },
];
export default function Page() {
const [classes, setClasses] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
fetchClasses();
}, [currentPage]);
const fetchClasses = async () => {
const fakeData = {
classes: [
{ Nom: 'Classe A', Niveau: '1ère année', Effectif: 30 },
{ Nom: 'Classe B', Niveau: '2ème année', Effectif: 25 },
{ Nom: 'Classe C', Niveau: '3ème année', Effectif: 28 },
],
totalPages: 3
};
setClasses(fakeData.classes);
setTotalPages(fakeData.totalPages);
};
const handlePageChange = (page) => {
setCurrentPage(page);
};
const handleCreateClass = () => {
console.log('Créer une nouvelle classe');
};
return (
<div className='p-8'>
<h1 className='heading-section'>Gestion des Classes</h1>
<Button text="Créer une nouvelle classe" onClick={handleCreateClass} primary />
<Table data={classes} columns={columns} itemsPerPage={5} />
</div>
);
}

View File

@ -0,0 +1,573 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useEstablishment } from '@/context/EstablishmentContext';
import { PARENT_FILTER, SCHOOL_FILTER } from '@/utils/constants';
import { Trash2, ToggleLeft, ToggleRight, Info, XCircle } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import StatusLabel from '@/components/StatusLabel';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
import SidebarTabs from '@/components/SidebarTabs';
import {
fetchProfileRoles,
updateProfileRoles,
deleteProfileRoles,
} from '@/app/actions/authAction';
import { dissociateGuardian } from '@/app/actions/subscriptionAction';
import { useCsrfToken } from '@/context/CsrfContext';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import logger from '@/utils/logger';
import AlertMessage from '@/components/AlertMessage';
const roleTypeToLabel = (roleType) => {
switch (roleType) {
case 0:
return 'ECOLE';
case 1:
return 'ADMIN';
case 2:
return 'PARENT';
default:
return 'UNKNOWN';
}
};
const roleTypeToBadgeClass = (roleType) => {
switch (roleType) {
case 0:
return 'bg-blue-100 text-blue-600';
case 1:
return 'bg-red-100 text-red-600';
case 2:
return 'bg-green-100 text-green-600';
default:
return 'bg-gray-100 text-gray-600';
}
};
export default function Page() {
const [profileRolesDatasParent, setProfileRolesDatasParent] = useState([]);
const [profileRolesDatasSchool, setProfileRolesDatasSchool] = useState([]);
const [reloadFetch, setReloadFetch] = useState(false);
const [popupVisible, setPopupVisible] = useState(false);
const [popupMessage, setPopupMessage] = useState('');
const [confirmPopupVisible, setConfirmPopupVisible] = useState(false);
const [confirmPopupMessage, setConfirmPopupMessage] = useState('');
const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {});
const [visibleTooltipId, setVisibleTooltipId] = useState(null);
const [activeTab, setActiveTab] = useState('parent'); // Onglet actif
const [totalProfilesParentPages, setTotalProfilesParentPages] = useState(1);
const [totalProfilesSchoolPages, setTotalProfilesSchoolPages] = useState(1);
const [currentProfilesParentPage, setCurrentProfilesParentPage] = useState(1);
const [totalProfilesParent, setTotalProfilesParent] = useState(0);
const [totalProfilesSchool, setTotalProfilesSchool] = useState(0);
const [currentProfilesSchoolPage, setCurrentProfilesSchoolPage] = useState(1);
const [profileRolesParent, setProfileRolesParent] = useState([]);
const [profileRolesSchool, setProfileRolesSchool] = useState([]);
const itemsPerPage = 15; // Nombre d'éléments par page
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
const requestErrorHandler = (err) => {
logger.error('Error fetching data:', err);
};
useEffect(() => {
if (selectedEstablishmentId) {
// Fetch data for profileRolesParent
handleProfiles();
}
}, [
selectedEstablishmentId,
reloadFetch,
currentProfilesParentPage,
currentProfilesSchoolPage,
]);
const handleProfiles = () => {
fetchProfileRoles(
selectedEstablishmentId,
PARENT_FILTER,
currentProfilesParentPage,
itemsPerPage
)
.then((data) => {
setProfileRolesDatasParent(data);
})
.catch(requestErrorHandler);
fetchProfileRoles(
selectedEstablishmentId,
SCHOOL_FILTER,
currentProfilesSchoolPage,
itemsPerPage
)
.then((data) => {
setProfileRolesDatasSchool(data);
})
.catch(requestErrorHandler);
setReloadFetch(false);
};
const handleEdit = (profileRole) => {
const updatedData = { ...profileRole, is_active: !profileRole.is_active };
return updateProfileRoles(profileRole.id, updatedData, csrfToken)
.then((data) => {
setProfileRolesParent((prevState) =>
prevState.map((item) => (item.id === profileRole.id ? data : item))
);
return data;
})
.catch((error) => {
logger.error('Error editing data:', error);
throw error;
});
};
const handleDelete = (id) => {
return deleteProfileRoles(id, csrfToken)
.then(() => {
setProfileRolesParent((prevState) =>
prevState.filter((item) => item.id !== id)
);
logger.debug('Profile deleted successfully:', id);
})
.catch((error) => {
logger.error('Error deleting profile:', error);
throw error;
});
};
const handleDissociate = (studentId, guardianId) => {
return dissociateGuardian(studentId, guardianId)
.then((response) => {
logger.debug('Guardian dissociated successfully:', guardianId);
// Vérifier si le Guardian a été supprimé
const isGuardianDeleted = response?.isGuardianDeleted;
// Mettre à jour le modèle profileRolesParent
setProfileRolesParent(
(prevState) =>
prevState
.map((profileRole) => {
if (profileRole.associated_person?.id === guardianId) {
if (isGuardianDeleted) {
// Si le Guardian est supprimé, retirer le profileRole
return null;
} else {
// Si le Guardian n'est pas supprimé, mettre à jour les élèves associés
const updatedStudents =
profileRole.associated_person.students.filter(
(student) => student.id !== studentId
);
return {
...profileRole,
associated_person: {
...profileRole.associated_person,
students: updatedStudents, // Mettre à jour les élèves associés
},
};
}
}
return profileRole; // Conserver les autres profileRolesParent
})
.filter(Boolean) // Supprimer les entrées nulles
);
})
.catch((error) => {
logger.error('Error dissociating guardian:', error);
throw error;
});
};
const profilesRoleParentDataHandler = (data) => {
if (data) {
const { profilesRoles, count, page_size } = data;
if (profilesRoles) {
setProfileRolesParent(profilesRoles);
}
const calculatedTotalPages =
count === 0 ? count : Math.ceil(count / page_size);
setTotalProfilesParent(count);
setTotalProfilesParentPages(calculatedTotalPages);
}
};
const profilesRoleSchoolDataHandler = (data) => {
if (data) {
const { profilesRoles, count, page_size } = data;
if (profilesRoles) {
setProfileRolesSchool(profilesRoles);
}
const calculatedTotalPages =
count === 0 ? count : Math.ceil(count / page_size);
setTotalProfilesSchool(count);
setTotalProfilesSchoolPages(calculatedTotalPages);
}
};
useEffect(() => {
profilesRoleParentDataHandler(profileRolesDatasParent);
profilesRoleSchoolDataHandler(profileRolesDatasSchool);
if (activeTab === 'parent') {
setTotalProfilesParentPages(
Math.ceil(totalProfilesParent / itemsPerPage)
);
} else if (activeTab === 'school') {
setTotalProfilesSchoolPages(
Math.ceil(totalProfilesSchool / itemsPerPage)
);
}
}, [profileRolesDatasParent, profileRolesDatasSchool, activeTab]);
const handlePageChange = (newPage) => {
if (activeTab === 'parent') {
setCurrentProfilesParentPage(newPage);
} else if (activeTab === 'school') {
setCurrentProfilesSchoolPage(newPage);
}
};
const handleTooltipVisibility = (id) => {
setVisibleTooltipId(id); // Définir l'ID de la ligne pour laquelle la tooltip est visible
};
const handleTooltipHide = () => {
setVisibleTooltipId(null); // Cacher toutes les tooltips
};
const handleConfirmActivateProfile = (profileRole) => {
setConfirmPopupMessage(
`Êtes-vous sûr de vouloir ${profileRole.is_active ? 'désactiver' : 'activer'} ce profil ?`
);
setConfirmPopupOnConfirm(() => () => {
handleEdit(profileRole)
.then(() => {
setPopupMessage(
`Le profil a été ${profileRole.is_active ? 'désactivé' : 'activé'} avec succès.`
);
setPopupVisible(true);
})
.catch((error) => {
setPopupMessage(
`Erreur lors de la ${profileRole.is_active ? 'désactivation' : 'activation'} du profil.`
);
setPopupVisible(true);
});
setConfirmPopupVisible(false);
});
setConfirmPopupVisible(true);
};
const handleConfirmDeleteProfile = (id) => {
setConfirmPopupMessage('Êtes-vous sûr de vouloir supprimer ce profil ?');
setConfirmPopupOnConfirm(() => () => {
handleDelete(id)
.then(() => {
setPopupMessage('Le profil a été supprimé avec succès.');
setPopupVisible(true);
})
.catch((error) => {
setPopupMessage(error.message);
setPopupVisible(true);
});
setConfirmPopupVisible(false);
});
setConfirmPopupVisible(true);
};
const handleConfirmDissociateGuardian = (profileRole, student) => {
setVisibleTooltipId(null);
setConfirmPopupMessage(
`Vous êtes sur le point de dissocier le responsable ${profileRole.associated_person?.guardian_name} de l'élève ${student.student_name}. Êtes-vous sûr de vouloir poursuivre cette opération ?`
);
setConfirmPopupOnConfirm(() => () => {
handleDissociate(student.id, profileRole.associated_person?.id)
.then(() => {
setPopupMessage('Le responsable a été dissocié avec succès.');
setPopupVisible(true);
})
.catch((error) => {
setPopupMessage(error.message);
setPopupVisible(true);
});
setConfirmPopupVisible(false);
});
setConfirmPopupVisible(true);
};
const parentColumns = [
{ name: 'Identifiant', transform: (row) => row.associated_profile_email },
{ name: 'Mise à jour', transform: (row) => row.updated_date_formatted },
{
name: 'Rôle',
transform: (row) => (
<span
className={`px-2 py-1 rounded-full font-bold ${roleTypeToBadgeClass(row.role_type)}`}
>
{roleTypeToLabel(row.role_type)}
</span>
),
},
{
name: 'Utilisateur',
transform: (row) => (
<div className="flex items-center justify-center space-x-2 relative">
<span>{row.associated_person?.guardian_name}</span>
{row.associated_person && (
<div
className="relative group"
onMouseEnter={() => handleTooltipVisibility(row.id)} // Afficher la tooltip pour cette ligne
onMouseLeave={handleTooltipHide} // Cacher la tooltip
>
<button className="relative text-blue-500 hover:text-blue-700 flex items-center justify-center">
<div className="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center font-bold">
{row.associated_person?.students?.length || 0}
</div>
</button>
{visibleTooltipId === row.id && ( // Afficher uniquement si l'ID correspond
<div className="fixed z-50 w-96 p-4 bg-white border border-gray-200 rounded shadow-lg -translate-x-1/2">
<div className="mb-2">
<strong>Elève(s) associé(s):</strong>
<div className="flex flex-col justify-center space-y-2 mt-4">
{row.associated_person?.students?.map((student) => (
<div
key={student.student_name}
className="flex justify-between items-center"
>
<span className="px-2 py-1 rounded-full text-gray-800 whitespace-nowrap inline-block min-w-0 max-w-fit">
{student.student_name}
</span>
<div className="flex items-center space-x-2">
<StatusLabel
status={student.registration_status}
showDropdown={false}
/>
<button
className="text-red-500 hover:text-red-700 flex items-center space-x-1"
onClick={() =>
handleConfirmDissociateGuardian(row, student)
}
>
<XCircle className="w-5 h-5" />
<span className="text-sm">Dissocier</span>
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
),
},
{
name: 'Actions',
transform: (row) => (
<div className="flex justify-center space-x-2">
<button
type="button"
className={
row.is_active
? 'text-emerald-500 hover:text-emerald-700'
: 'text-orange-500 hover:text-orange-700'
}
onClick={() => handleConfirmActivateProfile(row)}
>
{row.is_active ? (
<ToggleRight className="w-5 h-5 " />
) : (
<ToggleLeft className="w-5 h-5" />
)}
</button>
<button
type="button"
className="text-red-500 hover:text-red-700"
onClick={() => handleConfirmDeleteProfile(row.id)}
>
<Trash2 className="w-5 h-5" />
</button>
</div>
),
},
];
const schoolAdminColumns = [
{ name: 'Identifiant', transform: (row) => row.associated_profile_email },
{ name: 'Mise à jour', transform: (row) => row.updated_date_formatted },
{
name: 'Rôle',
transform: (row) => (
<span
className={`px-2 py-1 rounded-full font-bold ${roleTypeToBadgeClass(row.role_type)}`}
>
{roleTypeToLabel(row.role_type)}
</span>
),
},
{
name: 'Utilisateur',
transform: (row) => (
<div className="flex items-center justify-center space-x-2 relative">
<span>{row.associated_person?.teacher_name}</span>
{row.associated_person && (
<div
className="relative group"
onMouseEnter={() => handleTooltipVisibility(row.id)} // Afficher la tooltip pour cette ligne
onMouseLeave={handleTooltipHide} // Cacher la tooltip
>
<button className="relative text-blue-500 hover:text-blue-700 flex items-center justify-center">
<div className="w-6 h-6 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center font-bold">
<Info className="w-4 h-4" /> {/* Icône Info */}
</div>
</button>
{visibleTooltipId === row.id && ( // Afficher uniquement si l'ID correspond
<div className="fixed z-50 w-96 p-4 bg-white border border-gray-200 rounded shadow-lg -translate-x-1/2">
<div className="mb-2">
<strong>Classes associées:</strong>
<div className="flex flex-wrap justify-center space-x-2 mt-4">
{row.associated_person?.classes?.map((classe) => (
<span
key={classe.id}
className="px-2 py-1 rounded-full bg-gray-200 text-gray-800"
>
{classe.name}
</span>
))}
</div>
</div>
<div>
<strong>Spécialités:</strong>
<div className="flex flex-wrap justify-center space-x-2 mt-4">
{row.associated_person?.specialities?.map(
(speciality) => (
<SpecialityItem
key={speciality.name}
speciality={speciality}
isDraggable={false}
/>
)
)}
</div>
</div>
</div>
)}
</div>
)}
</div>
),
},
{
name: 'Actions',
transform: (row) => (
<div className="flex justify-center space-x-2">
<button
type="button"
className={
row.is_active
? 'text-emerald-500 hover:text-emerald-700'
: 'text-orange-500 hover:text-orange-700'
}
onClick={() => handleConfirmActivateProfile(row)}
>
{row.is_active ? (
<ToggleRight className="w-5 h-5 " />
) : (
<ToggleLeft className="w-5 h-5" />
)}
</button>
<button
type="button"
className="text-red-500 hover:text-red-700"
onClick={() => handleConfirmDeleteProfile(row.id)}
>
<Trash2 className="w-5 h-5" />
</button>
</div>
),
},
];
return (
<>
<DjangoCSRFToken csrfToken={csrfToken} />
<SidebarTabs
tabs={[
{
id: 'parent',
label: 'Parents',
content: (
<div className="h-full overflow-y-auto p-4">
<Table
key={`parent-${currentProfilesParentPage}`}
data={profileRolesParent}
columns={parentColumns}
itemsPerPage={itemsPerPage}
currentPage={currentProfilesParentPage}
totalPages={totalProfilesParentPages}
onPageChange={handlePageChange}
emptyMessage={
<AlertMessage
type="info"
title="Aucun profil PARENT enregistré"
message="Un profil Parent est ajouté lors de la création d'un nouveau dossier d'inscription."
/>
}
/>
</div>
),
},
{
id: 'school',
label: 'École',
content: (
<div className="h-full overflow-y-auto p-4">
<Table
key={`school-${currentProfilesSchoolPage}`}
data={profileRolesSchool}
columns={schoolAdminColumns}
itemsPerPage={itemsPerPage}
currentPage={currentProfilesSchoolPage}
totalPages={totalProfilesSchoolPages}
onPageChange={handlePageChange}
emptyMessage={
<AlertMessage
type="info"
title="Aucun profil ECOLE enregistré"
message="Un profil ECOLE est ajouté lors de la création d'un nouvel enseignant."
/>
}
/>
</div>
),
},
]}
onTabChange={(newActiveTab) => {
setActiveTab(newActiveTab);
}}
/>
{/* Popups */}
<Popup
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
<Popup
isOpen={confirmPopupVisible}
message={confirmPopupMessage}
onConfirm={confirmPopupOnConfirm}
onCancel={() => setConfirmPopupVisible(false)}
/>
</>
);
}

View File

@ -1,10 +1,479 @@
'use client'
'use client';
import React, { useState, useEffect } from 'react';
import SelectChoice from '@/components/SelectChoice';
import AcademicResults from '@/components/Grades/AcademicResults';
import Attendance from '@/components/Grades/Attendance';
import Remarks from '@/components/Grades/Remarks';
import WorkPlan from '@/components/Grades/WorkPlan';
import Homeworks from '@/components/Grades/Homeworks';
import SpecificEvaluations from '@/components/Grades/SpecificEvaluations';
import Orientation from '@/components/Grades/Orientation';
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
import Button from '@/components/Button';
import logger from '@/utils/logger';
import {
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
BASE_URL,
} from '@/utils/Url';
import { useRouter } from 'next/navigation';
import {
fetchStudents,
fetchStudentCompetencies,
searchStudents,
fetchAbsences,
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
import { Award, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import InputText from '@/components/InputText';
import dayjs from 'dayjs';
import { useCsrfToken } from '@/context/CsrfContext';
export default function Page() {
return (
<div className='p-8'>
<h1 className='heading-section'>Statistiques</h1>
</div>
);
}
const router = useRouter();
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment();
const { getNiveauLabel } = useClasses();
const [formData, setFormData] = useState({
selectedStudent: null,
});
const [students, setStudents] = useState([]);
const [studentCompetencies, setStudentCompetencies] = useState(null);
const [grades, setGrades] = useState({});
const [searchTerm, setSearchTerm] = useState('');
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [allAbsences, setAllAbsences] = useState([]);
// Définir les périodes selon la fréquence
const getPeriods = () => {
if (selectedEstablishmentEvaluationFrequency === 1) {
return [
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
];
}
if (selectedEstablishmentEvaluationFrequency === 2) {
return [
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
];
}
if (selectedEstablishmentEvaluationFrequency === 3) {
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
}
return [];
};
// Sélection automatique de la période courante
useEffect(() => {
if (!formData.selectedStudent) {
setSelectedPeriod(null);
return;
}
const periods = getPeriods();
const today = dayjs();
const current = periods.find((p) => {
const start = dayjs(`${today.year()}-${p.start}`);
const end = dayjs(`${today.year()}-${p.end}`);
return (
today.isAfter(start.subtract(1, 'day')) &&
today.isBefore(end.add(1, 'day'))
);
});
setSelectedPeriod(current ? current.value : null);
}, [formData.selectedStudent, selectedEstablishmentEvaluationFrequency]);
const academicResults = [
{
subject: 'Mathématiques',
grade: 16,
average: 14,
appreciation: 'Très bon travail',
},
{
subject: 'Français',
grade: 15,
average: 13,
appreciation: 'Bonne participation',
},
];
const remarks = [
{
date: '2023-09-10',
teacher: 'Mme Dupont',
comment: 'Participation active en classe.',
},
{
date: '2023-09-20',
teacher: 'M. Martin',
comment: 'Doit améliorer la concentration.',
},
];
const workPlan = [
{
objective: 'Renforcer la lecture',
support: 'Exercices hebdomadaires',
followUp: 'En cours',
},
{
objective: 'Maîtriser les tables de multiplication',
support: 'Jeux éducatifs',
followUp: 'À démarrer',
},
];
const homeworks = [
{ title: 'Rédaction', dueDate: '2023-10-10', status: 'Rendu' },
{ title: 'Exercices de maths', dueDate: '2023-10-12', status: 'À faire' },
];
const specificEvaluations = [
{
test: 'Bilan de compétences',
date: '2023-09-25',
result: 'Bon niveau général',
},
];
const orientation = [
{
date: '2023-10-01',
counselor: 'Mme Leroy',
advice: 'Poursuivre en filière générale',
},
];
const handleChange = (field, value) =>
setFormData((prev) => ({ ...prev, [field]: value }));
useEffect(() => {
if (selectedEstablishmentId) {
fetchStudents(selectedEstablishmentId, null, 5)
.then((studentsData) => {
setStudents(studentsData);
})
.catch((error) => logger.error('Error fetching students:', error));
}
}, [selectedEstablishmentId]);
// Charger les compétences et générer les grades à chaque changement d'élève sélectionné
useEffect(() => {
if (formData.selectedStudent && selectedPeriod) {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
fetchStudentCompetencies(formData.selectedStudent, periodString)
.then((data) => {
setStudentCompetencies(data);
// Générer les grades à partir du retour API
if (data && data.data) {
const initialGrades = {};
data.data.forEach((domaine) => {
domaine.categories.forEach((cat) => {
cat.competences.forEach((comp) => {
initialGrades[comp.competence_id] = comp.score ?? 0;
});
});
});
setGrades(initialGrades);
}
})
.catch((error) =>
logger.error('Error fetching studentCompetencies:', error)
);
} else {
setGrades({});
setStudentCompetencies(null);
}
}, [formData.selectedStudent, selectedPeriod]);
useEffect(() => {
if (selectedEstablishmentId) {
fetchAbsences(selectedEstablishmentId)
.then((data) => setAllAbsences(data))
.catch((error) =>
logger.error('Erreur lors du fetch des absences:', error)
);
}
}, [selectedEstablishmentId]);
// Transforme les absences backend pour l'élève sélectionné
const absences = React.useMemo(() => {
if (!formData.selectedStudent) return [];
return allAbsences
.filter((a) => a.student === formData.selectedStudent)
.map((a) => ({
id: a.id,
date: a.day,
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
reason: a.reason, // tu peux mapper le code vers un label si besoin
justified: [1, 3].includes(a.reason), // 1 et 3 = justifié
moment: a.moment,
commentaire: a.commentaire,
}));
}, [allAbsences, formData.selectedStudent]);
// Fonction utilitaire pour convertir la période sélectionnée en string backend
function getPeriodString(selectedPeriod, frequency) {
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; // année scolaire commence en septembre
const nextYear = (year + 1).toString();
const schoolYear = `${year}-${nextYear}`;
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
if (frequency === 3) return `A_${schoolYear}`;
return '';
}
// Callback pour justifier/non justifier une absence
const handleToggleJustify = (absence) => {
// Inverser l'état justifié (1/3 = justifié, 2/4 = non justifié)
const newReason =
absence.type === 'Absence'
? absence.justified
? 2 // Absence non justifiée
: 1 // Absence justifiée
: absence.justified
? 4 // Retard non justifié
: 3; // Retard justifié
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
.then(() => {
setAllAbsences((prev) =>
prev.map((a) =>
a.id === absence.id ? { ...a, reason: newReason } : a
)
);
})
.catch((e) => {
logger.error('Erreur lors du changement de justification', e);
});
};
// Callback pour supprimer une absence
const handleDeleteAbsence = (absence) => {
return deleteAbsences(absence.id, csrfToken)
.then(() => {
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
})
.catch((e) => {
logger.error("Erreur lors de la suppression de l'absence", e);
});
};
return (
<div className="p-8 space-y-8">
<SectionHeader
icon={Award}
title="Suivi pédagogique"
description="Suivez le parcours d'un élève"
/>
{/* Section haute : filtre + bouton + photo élève */}
<div className="flex flex-row gap-8 items-start">
{/* Colonne gauche : InputText + bouton */}
<div className="w-4/5 flex items-end gap-4">
<div className="flex-[3_3_0%]">
<InputText
name="studentSearch"
type="text"
label="Recherche élève"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Rechercher un élève"
required={false}
enable={true}
/>
</div>
<div className="flex-[1_1_0%]">
<SelectChoice
name="period"
label="Période"
placeHolder="Choisir la période"
choices={getPeriods().map((period) => {
const today = dayjs();
const start = dayjs(`${today.year()}-${period.start}`);
const end = dayjs(`${today.year()}-${period.end}`);
const isPast = today.isAfter(end);
return {
value: period.value,
label: period.label,
disabled: isPast,
};
})}
selected={selectedPeriod || ''}
callback={(e) => setSelectedPeriod(Number(e.target.value))}
disabled={!formData.selectedStudent}
/>
</div>
<div className="flex-[1_1_0%] flex items-end">
<Button
primary
onClick={() => {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}&period=${periodString}`;
router.push(url);
}}
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
icon={<Award className="w-6 h-6" />}
text="Evaluer"
title="Evaluez l'élève"
disabled={!formData.selectedStudent || !selectedPeriod}
/>
</div>
</div>
{/* Colonne droite : Photo élève */}
<div className="w-1/5 flex flex-col items-center justify-center">
{formData.selectedStudent &&
(() => {
const student = students.find(
(s) => s.id === formData.selectedStudent
);
if (!student) return null;
return (
<>
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-32 h-32 object-cover rounded-full border-4 border-emerald-200 mb-4 shadow"
/>
) : (
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl mb-4 border-4 border-emerald-100">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
</>
);
})()}
</div>
</div>
{/* Section basse : liste élèves + infos */}
<div className="flex flex-row gap-8 items-start mt-8">
{/* Colonne 1 : Liste des élèves */}
<div className="w-full max-w-xs">
<h3 className="text-lg font-semibold text-emerald-700 mb-4">
Liste des élèves
</h3>
<ul className="rounded-lg bg-stone-50 shadow border border-gray-100">
{students
.filter(
(student) =>
!searchTerm ||
`${student.last_name} ${student.first_name}`
.toLowerCase()
.includes(searchTerm.toLowerCase())
)
.map((student) => (
<li
key={student.id}
className={`flex items-center gap-4 px-4 py-3 hover:bg-emerald-100 cursor-pointer transition ${
formData.selectedStudent === student.id
? 'bg-emerald-100 border-l-4 border-emerald-400'
: 'border-l-2 border-gray-200'
}`}
onClick={() => handleChange('selectedStudent', student.id)}
>
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-10 h-10 object-cover rounded-full border-2 border-emerald-200"
/>
) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-lg border-2 border-emerald-100">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
<div className="flex-1">
<div className="font-semibold text-emerald-800">
{student.last_name} {student.first_name}
</div>
<div className="text-xs text-gray-600">
Niveau :{' '}
<span className="font-medium">
{getNiveauLabel(student.level)}
</span>
{' | '}
Classe :{' '}
<span className="font-medium">
{student.associated_class_name}
</span>
</div>
</div>
{/* Icône PDF si bilan dispo pour la période sélectionnée */}
{selectedPeriod &&
student.bilans &&
Array.isArray(student.bilans) &&
(() => {
// Génère la string de période attendue
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
const bilan = student.bilans.find(
(b) => b.period === periodString && b.file
);
if (bilan) {
return (
<a
href={`${BASE_URL}${bilan.file}`}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-emerald-600 hover:text-emerald-800"
title="Télécharger le bilan de compétences"
onClick={(e) => e.stopPropagation()} // Pour ne pas sélectionner à nouveau l'élève
>
<FileText className="w-5 h-5" />
</a>
);
}
return null;
})()}
</li>
))}
</ul>
</div>
{/* Colonne 2 : Reste des infos */}
<div className="flex-1">
{formData.selectedStudent && (
<div className="flex flex-col gap-8 w-full justify-center items-stretch">
<div className="w-full flex flex-row items-stretch gap-4">
<div className="flex-1 flex items-stretch justify-center h-full">
<Attendance
absences={absences}
onToggleJustify={handleToggleJustify}
onDelete={handleDeleteAbsence}
/>
</div>
<div className="flex-1 flex items-stretch justify-center h-full">
<GradesStatsCircle grades={grades} />
</div>
</div>
<div className="flex items-center justify-center">
<GradesDomainBarChart
studentCompetencies={studentCompetencies}
/>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,123 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import Button from '@/components/Button';
import GradeView from '@/components/Grades/GradeView';
import {
fetchStudentCompetencies,
editStudentCompetencies,
} from '@/app/actions/subscriptionAction';
import SectionHeader from '@/components/SectionHeader';
import { Award } from 'lucide-react';
import { useCsrfToken } from '@/context/CsrfContext';
import { useNotification } from '@/context/NotificationContext';
export default function StudentCompetenciesPage() {
const searchParams = useSearchParams();
const router = useRouter();
const csrfToken = useCsrfToken();
const { showNotification } = useNotification();
const [studentCompetencies, setStudentCompetencies] = useState([]);
const [grades, setGrades] = useState({});
const studentId = searchParams.get('studentId');
const period = searchParams.get('period');
useEffect(() => {
fetchStudentCompetencies(studentId, period)
.then((data) => {
setStudentCompetencies(data);
})
.catch((error) =>
logger.error('Error fetching studentCompetencies:', error)
);
}, []);
useEffect(() => {
if (studentCompetencies.data) {
const initialGrades = {};
studentCompetencies.data.forEach((domaine) => {
domaine.categories.forEach((cat) => {
cat.competences.forEach((comp) => {
initialGrades[comp.competence_id] = comp.score ?? 0;
});
});
});
setGrades(initialGrades);
}
}, [studentCompetencies.data]);
const handleGradeChange = (competenceId, level) => {
setGrades((prev) => ({
...prev,
[competenceId]: level,
}));
};
const handleSubmit = () => {
const data = Object.entries(grades)
.filter(([_, score]) => [1, 2, 3].includes(score))
.map(([competenceId, score]) => ({
studentId,
competenceId,
grade: score,
period: period,
}));
editStudentCompetencies(data, csrfToken)
.then(() => {
showNotification(
'Bilan de compétence sauvegardé avec succès',
'success',
'Succès'
);
router.back();
})
.catch((error) => {
showNotification(
"Erreur lors de l'enregistrement du bilan de compétence",
'error',
'Erreur'
);
});
};
return (
<div className="h-full flex flex-col p-4">
<SectionHeader
icon={Award}
title="Bilan de compétence"
description="Evaluez les compétence de l'élève"
/>
<div className="flex-1 min-h-0 flex flex-col">
<form
className="flex-1 min-h-0 flex flex-col"
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
{/* Zone scrollable pour les compétences */}
<div className="flex-1 min-h-0 overflow-y-auto">
<GradeView
data={studentCompetencies.data}
grades={grades}
onGradeChange={handleGradeChange}
/>
</div>
<div className="mt-6 flex justify-end">
<Button
text="Retour"
type="button"
onClick={(e) => {
e.preventDefault();
router.back();
}}
className="mr-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
/>
<Button text="Enregistrer" primary type="submit" />
</div>
</form>
</div>
</div>
);
}

View File

@ -1,96 +1,188 @@
'use client'
// src/components/Layout.js
import React from 'react';
'use client';
import React, { useState, useEffect } from 'react';
import Sidebar from '@/components/Sidebar';
import { usePathname } from 'next/navigation';
import {useTranslations} from 'next-intl';
import { useTranslations } from 'next-intl';
import {
LayoutDashboard,
FileText,
School,
Users,
Building,
Home,
Award,
Calendar,
Settings,
FileText,
LogOut
LogOut,
MessageSquare,
} from 'lucide-react';
import DropdownMenu from '@/components/DropdownMenu';
import Logo from '@/components/Logo';
import Popup from '@/components/Popup';
import {
FR_ADMIN_HOME_URL,
FR_ADMIN_SUBSCRIPTIONS_URL,
FR_ADMIN_STRUCTURE_URL,
FR_ADMIN_GRADES_URL,
FR_ADMIN_PLANNING_URL,
FR_ADMIN_SETTINGS_URL
FE_ADMIN_HOME_URL,
FE_ADMIN_SUBSCRIPTIONS_URL,
FE_ADMIN_STRUCTURE_URL,
FE_ADMIN_DIRECTORY_URL,
FE_ADMIN_GRADES_URL,
FE_ADMIN_PLANNING_URL,
FE_ADMIN_SETTINGS_URL,
FE_ADMIN_MESSAGERIE_URL,
} from '@/utils/Url';
import { disconnect } from '@/app/lib/actions';
import { disconnect } from '@/app/actions/authAction';
import ProtectedRoute from '@/components/ProtectedRoute';
import { getGravatarUrl } from '@/utils/gravatar';
import Footer from '@/components/Footer';
import { getRightStr, RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext';
import ProfileSelector from '@/components/ProfileSelector';
export default function Layout({
children,
}) {
const t = useTranslations('sidebar');
export default function Layout({ children }) {
const t = useTranslations('sidebar');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { profileRole, establishments, user, clearContext } =
useEstablishment();
const sidebarItems = {
"admin": { "id": "admin", "name": t('dashboard'), "url": FR_ADMIN_HOME_URL, "icon": Home },
"subscriptions": { "id": "subscriptions", "name": t('subscriptions'), "url": FR_ADMIN_SUBSCRIPTIONS_URL, "icon": Users },
"structure": { "id": "structure", "name": t('structure'), "url": FR_ADMIN_STRUCTURE_URL, "icon": Building },
"grades": { "id": "grades", "name": t('grades'), "url": FR_ADMIN_GRADES_URL, "icon": FileText },
"planning": { "id": "planning", "name": t('planning'), "url": FR_ADMIN_PLANNING_URL, "icon": Calendar },
"settings": { "id": "settings", "name": t('settings'), "url": FR_ADMIN_SETTINGS_URL, "icon": Settings }
admin: {
id: 'admin',
name: t('dashboard'),
url: FE_ADMIN_HOME_URL,
icon: LayoutDashboard,
},
subscriptions: {
id: 'subscriptions',
name: t('subscriptions'),
url: FE_ADMIN_SUBSCRIPTIONS_URL,
icon: FileText,
},
structure: {
id: 'structure',
name: t('structure'),
url: FE_ADMIN_STRUCTURE_URL,
icon: School,
},
directory: {
id: 'directory',
name: t('directory'),
url: FE_ADMIN_DIRECTORY_URL,
icon: Users,
},
grades: {
id: 'grades',
name: t('educational_monitoring'),
url: FE_ADMIN_GRADES_URL,
icon: Award,
},
planning: {
id: 'planning',
name: t('events'),
url: FE_ADMIN_PLANNING_URL,
icon: Calendar,
},
messagerie: {
id: 'messagerie',
name: t('messagerie'),
url: FE_ADMIN_MESSAGERIE_URL,
icon: MessageSquare,
},
settings: {
id: 'settings',
name: t('settings'),
url: FE_ADMIN_SETTINGS_URL,
icon: Settings,
},
};
const [isPopupVisible, setIsPopupVisible] = useState(false);
const pathname = usePathname();
const currentPage = pathname.split('/').pop();
const headerTitle = sidebarItems[currentPage]?.name || t('dashboard');
const softwareName = "N3WT School";
const softwareVersion = "v1.0.0";
const softwareName = 'N3WT School';
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
const handleDisconnect = () => {
setIsPopupVisible(true);
};
const confirmDisconnect = () => {
setIsPopupVisible(false);
disconnect();
clearContext();
};
const dropdownItems = [
{
type: 'info',
content: (
<div className="px-4 py-2">
<div className="font-medium">{user?.email || 'Utilisateur'}</div>
<div className="text-xs text-gray-400">
{getRightStr(profileRole) || ''}
</div>
</div>
),
},
{
type: 'separator',
content: <hr className="my-2 border-gray-200" />,
},
{
type: 'item',
label: 'Déconnexion',
onClick: disconnect,
onClick: handleDisconnect,
icon: LogOut,
},
];
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
useEffect(() => {
// Fermer la sidebar quand on change de page sur mobile
setIsSidebarOpen(false);
}, [pathname]);
return (
<>
<div className="flex min-h-screen bg-gray-50">
<Sidebar currentPage={currentPage} items={Object.values(sidebarItems)} className="h-full" />
<div className="flex flex-col flex-1">
{/* Header - h-16 = 64px */}
<header className="h-16 bg-white border-b border-gray-200 px-8 py-4 flex items-center justify-between z-9">
<div className="text-xl font-semibold">{headerTitle}</div>
<DropdownMenu
buttonContent={<img src="https://i.pravatar.cc/32" alt="Profile" className="w-8 h-8 rounded-full cursor-pointer" />}
items={dropdownItems}
buttonClassName=""
menuClassName="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded shadow-lg"
/>
</header>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Content avec scroll si nécessaire */}
<div className="flex-1 overflow-auto">
{children}
</div>
{/* Footer - h-16 = 64px */}
<footer className="h-16 bg-white border-t border-gray-200 px-8 py-4 flex items-center justify-between">
<div>
<span>&copy; {new Date().getFullYear()} N3WT-INNOV Tous droits réservés.</span>
<div>{softwareName} - {softwareVersion}</div>
</div>
<Logo className="w-8 h-8" />
</footer>
</div>
</div>
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
{/* Sidebar */}
<div
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
isSidebarOpen ? 'block' : 'hidden md:block'
}`}
>
<Sidebar
establishments={establishments}
currentPage={currentPage}
items={Object.values(sidebarItems)}
onCloseMobile={toggleSidebar}
/>
</div>
</>
{/* Overlay for mobile */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-20 md:hidden"
onClick={toggleSidebar}
/>
)}
{/* Main container */}
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-64 right-0">
{children}
</div>
{/* Footer */}
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
<Popup
isOpen={isPopupVisible}
message="Êtes-vous sûr(e) de vouloir vous déconnecter ?"
onConfirm={confirmDisconnect}
onCancel={() => setIsPopupVisible(false)}
/>
</ProtectedRoute>
);
}

View File

@ -0,0 +1,27 @@
'use client';
import React from 'react';
import SidebarTabs from '@/components/SidebarTabs';
import EmailSender from '@/components/Admin/EmailSender';
import InstantMessaging from '@/components/Admin/InstantMessaging';
import logger from '@/utils/logger';
export default function MessageriePage({ csrfToken }) {
const tabs = [
{
id: 'email',
label: 'Envoyer un Mail',
content: <EmailSender csrfToken={csrfToken} />,
},
{
id: 'instant',
label: 'Messagerie Instantanée',
content: <InstantMessaging csrfToken={csrfToken} />,
},
];
return (
<div className="h-full flex flex-col p-0 m-0">
<SidebarTabs tabs={tabs} />
</div>
);
}

View File

@ -1,169 +1,252 @@
'use client'
'use client';
import React, { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { Users, Clock, CalendarCheck, School, TrendingUp, UserCheck } from 'lucide-react';
import {
Users,
Clock,
CalendarCheck,
School,
AlertTriangle,
CheckCircle2,
} from 'lucide-react';
import Loader from '@/components/Loader';
import { BK_GESTIONENSEIGNANTS_CLASSES_URL } from '@/utils/Url';
import ClasseDetails from '@/components/ClasseDetails';
import StatCard from '@/components/StatCard';
import EventCard from '@/components/EventCard';
import logger from '@/utils/logger';
import {
fetchRegisterForms,
fetchAbsences,
} from '@/app/actions/subscriptionAction';
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import Attendance from '@/components/Grades/Attendance';
import LineChart from '@/components/Charts/LineChart';
import PieChart from '@/components/Charts/PieChart';
// Composant StatCard pour afficher une statistique
const StatCard = ({ title, value, icon, change, color = "blue" }) => (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
<div className="flex justify-between items-start">
<div>
<h3 className="text-gray-500 text-sm font-medium">{title}</h3>
<p className="text-2xl font-semibold mt-1">{value}</p>
{change && (
<p className={`text-sm ${change > 0 ? 'text-green-500' : 'text-red-500'}`}>
{change > 0 ? '+' : ''}{change}% depuis le mois dernier
</p>
)}
</div>
<div className={`p-3 rounded-full bg-${color}-100`}>
{icon}
</div>
</div>
</div>
);
// Composant EventCard pour afficher les événements
const EventCard = ({ title, date, description, type }) => (
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-100 mb-4">
<div className="flex items-center gap-3">
<CalendarCheck className="text-blue-500" size={20} />
<div>
<h4 className="font-medium">{title}</h4>
<p className="text-sm text-gray-500">{date}</p>
<p className="text-sm mt-1">{description}</p>
</div>
</div>
</div>
);
const mockCompletionRate = 72; // en pourcentage
export default function DashboardPage() {
const t = useTranslations('dashboard');
const [isLoading, setIsLoading] = useState(true);
const [stats, setStats] = useState({
totalStudents: 0,
averageInscriptionTime: 0,
reInscriptionRate: 0,
structureCapacity: 0,
upcomingEvents: [],
monthlyStats: {
inscriptions: [],
completionRate: 0
}
});
const [isLoading, setIsLoading] = useState(false);
const [currentYearRegistrationCount, setCurrentYearRegistrationCount] =
useState(0);
const [upcomingEvents, setUpcomingEvents] = useState([]);
const [classes, setClasses] = useState([]);
const [absencesToday, setAbsencesToday] = useState([]);
const {
selectedEstablishmentId,
selectedEstablishmentTotalCapacity,
apiDocuseal,
} = useEstablishment();
const fetchClasses = () => {
fetch(`${BK_GESTIONENSEIGNANTS_CLASSES_URL}`)
.then(response => response.json())
.then(data => {
setClasses(data);
})
.catch(error => {
console.error('Error fetching classes:', error);
});
};
const [statusDistribution, setStatusDistribution] = useState([
{ label: 'Non envoyé', value: 0 },
{ label: 'En attente', value: 0 },
{ label: 'En validation', value: 0 },
{ label: 'Validé', value: 0 },
]);
const [monthlyRegistrations, setMonthlyRegistrations] = useState([]);
useEffect(() => {
// Fetch data for classes
fetchClasses();
if (!selectedEstablishmentId) return;
// Simulation de chargement des données
setTimeout(() => {
setStats({
totalStudents: 245,
averageInscriptionTime: 3.5,
reInscriptionRate: 85,
structureCapacity: 300,
upcomingEvents: [
setIsLoading(true); // Début du chargement
// Fetch des formulaires d'inscription
fetchRegisterForms(selectedEstablishmentId)
.then((data) => {
logger.info('Pending registrations fetched:', data);
setCurrentYearRegistrationCount(data.count || 0);
const forms = data.registerForms || [];
// Filtrage des statuts
const distribution = [
{
title: "Réunion de rentrée",
date: "2024-09-01",
description: "Présentation de l'année scolaire",
type: "meeting"
label: 'Non envoyé',
value: forms.filter((f) => f.status === 1).length,
},
{
title: "Date limite inscriptions",
date: "2024-08-15",
description: "Clôture des inscriptions",
type: "deadline"
label: 'En attente',
value: forms.filter((f) => f.status === 2 || f.status === 7).length,
},
{
label: 'En validation',
value: forms.filter((f) => f.status === 3 || f.status === 8).length,
},
{
label: 'Validé',
value: forms.filter((f) => f.status === 5).length,
},
];
setStatusDistribution(distribution);
// Calcul des inscriptions validées par mois
const validForms = forms.filter(
(f) => f.status === 5 && f.formatted_last_update
);
// Format attendu : "29-05-2025 09:23"
const monthLabels = [
'Janv',
'Fév',
'Mars',
'Avr',
'Mai',
'Juin',
'Juil',
'Août',
'Sept',
'Oct',
'Nov',
'Déc',
];
const monthlyCount = Array(12).fill(0);
validForms.forEach((f) => {
const [day, month, yearAndTime] = f.formatted_last_update.split('-');
const monthIdx = parseInt(month, 10) - 1;
if (monthIdx >= 0 && monthIdx < 12) {
monthlyCount[monthIdx]++;
}
],
monthlyStats: {
inscriptions: [150, 180, 210, 245],
completionRate: 78
}
});
const monthlyData = monthLabels.map((label, idx) => ({
month: label,
value: monthlyCount[idx],
}));
setMonthlyRegistrations(monthlyData);
})
.catch((error) => {
logger.error('Erreur lors du fetch des inscriptions :', error);
});
setIsLoading(false);
}, 1000);
}, []);
// Fetch des événements à venir
fetchUpcomingEvents(selectedEstablishmentId)
.then((data) => {
setUpcomingEvents(data);
})
.catch((error) => {
logger.error('Error fetching upcoming events:', error);
});
// Fetch absences/retards du jour
fetchAbsences(selectedEstablishmentId)
.then((data) => {
const today = new Date().toISOString().split('T')[0];
const absToday = (data || []).filter((a) => a.day === today);
// Mapping pour Attendance
setAbsencesToday(
absToday.map((a) => ({
id: a.id,
date: a.day,
type: [1, 2].includes(a.reason) ? 'Absence' : 'Retard',
justified: [1, 3].includes(a.reason),
commentaire: a.commentaire,
student_name: a.student_name,
}))
);
})
.catch((error) => {
logger.error('Erreur lors du fetch des absences du jour:', error);
})
.finally(() => {
setIsLoading(false); // Fin du chargement
});
}, [selectedEstablishmentId]);
// Calculs à partir de statusDistribution
const totalStudents =
statusDistribution.find((s) => s.label === 'Validé')?.value || 0;
const pendingRegistrationCount = statusDistribution
.filter((s) => s.label !== 'Validé')
.reduce((acc, s) => acc + s.value, 0);
if (isLoading) return <Loader />;
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">{t('dashboard')}</h1>
<div key={selectedEstablishmentId} className="p-6">
<div className="flex items-center gap-3 mb-6">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
apiDocuseal
? 'bg-green-100 text-green-700 border border-green-300'
: 'bg-red-100 text-red-700 border border-red-300'
}`}
>
{apiDocuseal ? (
<CheckCircle2 className="w-4 h-4 mr-2 text-green-500" />
) : (
<AlertTriangle className="w-4 h-4 mr-2 text-red-500" />
)}
{apiDocuseal
? 'Clé API Docuseal renseignée'
: 'Clé API Docuseal manquante'}
</span>
</div>
{/* Statistiques principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard
title={t('totalStudents')}
value={stats.totalStudents}
value={totalStudents}
icon={<Users className="text-blue-500" size={24} />}
change={12}
/>
<StatCard
title={t('averageInscriptionTime')}
value={`${stats.averageInscriptionTime} jours`}
title={t('pendingRegistrations')}
value={pendingRegistrationCount}
icon={<Clock className="text-green-500" size={24} />}
color="green"
/>
<StatCard
title={t('reInscriptionRate')}
value={`${stats.reInscriptionRate}%`}
icon={<UserCheck className="text-purple-500" size={24} />}
color="purple"
title={t('structureCapacity')}
value={selectedEstablishmentTotalCapacity}
icon={<School className="text-green-500" size={24} />}
color="emerald"
/>
<StatCard
title={t('structureCapacity')}
value={`${(stats.totalStudents/stats.structureCapacity * 100).toFixed(1)}%`}
title={t('capacityRate')}
value={
selectedEstablishmentTotalCapacity > 0
? `${((totalStudents / selectedEstablishmentTotalCapacity) * 100).toFixed(2)}%`
: 0
}
icon={<School className="text-orange-500" size={24} />}
color="orange"
/>
</div>
{/* Événements et KPIs */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Graphique des inscriptions */}
<div className="lg:col-span-2 bg-white p-6 rounded-lg shadow-sm border border-gray-100">
<h2 className="text-lg font-semibold mb-4">{t('inscriptionTrends')}</h2>
{/* Insérer ici un composant de graphique */}
<div className="h-64 bg-gray-50 rounded flex items-center justify-center">
<TrendingUp size={48} className="text-gray-300" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Colonne de gauche : Graphique des inscriptions + Présence */}
<div className="flex flex-col gap-6">
{/* Graphique des inscriptions */}
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
<h2 className="text-lg font-semibold mb-6">
{t('inscriptionTrends')}
</h2>
<div className="flex flex-row gap-4">
<div className="flex-1 p-6">
<LineChart data={monthlyRegistrations} />
</div>
<div className="flex-1 flex items-center justify-center">
<PieChart data={statusDistribution} />
</div>
</div>
</div>
{/* Présence et assiduité */}
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
<Attendance absences={absencesToday} readOnly={true} />
</div>
</div>
{/* Événements à venir */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
{/* Colonne de droite : Événements à venir */}
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
{stats.upcomingEvents.map((event, index) => (
{upcomingEvents.map((event, index) => (
<EventCard key={index} {...event} />
))}
</div>
</div>
<div className="flex flex-wrap">
{classes.map((classe) => (
<div className="lg:col-span-2 bg-white p-6 rounded-lg shadow-sm border border-gray-100 mr-4">
<ClasseDetails key={classe.id} classe={classe} />
</div>
))}
</div>
</div>
);
}
}

View File

@ -1,9 +1,14 @@
'use client'
import { PlanningProvider } from '@/context/PlanningContext';
import Calendar from '@/components/Calendar';
import EventModal from '@/components/EventModal';
import ScheduleNavigation from '@/components/ScheduleNavigation';
'use client';
import {
PlanningModes,
PlanningProvider,
RecurrenceType,
} from '@/context/PlanningContext';
import Calendar from '@/components/Calendar/Calendar';
import EventModal from '@/components/Calendar/EventModal';
import ScheduleNavigation from '@/components/Calendar/ScheduleNavigation';
import { useState } from 'react';
import { useEstablishment } from '@/context/EstablishmentContext';
export default function Page() {
const [isModalOpen, setIsModalOpen] = useState(false);
@ -13,14 +18,15 @@ export default function Page() {
start: '',
end: '',
location: '',
scheduleId: '', // Enlever la valeur par défaut ici
recurrence: 'none',
planning: '', // Enlever la valeur par défaut ici
recursionType: RecurrenceType.NONE,
selectedDays: [],
recurrenceEnd: '',
recursionEnd: '',
customInterval: 1,
customUnit: 'days',
viewType: 'week' // Ajouter la vue semaine par défaut
viewType: 'week', // Ajouter la vue semaine par défaut
});
const { selectedEstablishmentId } = useEstablishment();
const initializeNewEvent = (date = new Date()) => {
// S'assurer que date est un objet Date valide
@ -32,18 +38,23 @@ export default function Page() {
start: eventDate.toISOString(),
end: new Date(eventDate.getTime() + 2 * 60 * 60 * 1000).toISOString(),
location: '',
scheduleId: '', // Ne pas définir de valeur par défaut ici non plus
recurrence: 'none',
planning: '', // Ne pas définir de valeur par défaut ici non plus
recursionType: RecurrenceType.NONE,
selectedDays: [],
recurrenceEnd: '',
recursionEnd: new Date(
eventDate.getTime() + 2 * 60 * 60 * 1000
).toISOString(),
customInterval: 1,
customUnit: 'days'
customUnit: 'days',
});
setIsModalOpen(true);
};
return (
<PlanningProvider>
<PlanningProvider
establishmentId={selectedEstablishmentId}
modeSet={PlanningModes.PLANNING}
>
<div className="flex h-full overflow-hidden">
<ScheduleNavigation />
<Calendar
@ -62,4 +73,4 @@ export default function Page() {
</div>
</PlanningProvider>
);
}
}

View File

@ -1,12 +1,22 @@
'use client'
import React, { useState } from 'react';
'use client';
import React, { useState, useEffect } from 'react';
import Tab from '@/components/Tab';
import TabContent from '@/components/TabContent';
import Button from '@/components/Button';
import InputText from '@/components/InputText';
import CheckBox from '@/components/CheckBox'; // Import du composant CheckBox
import logger from '@/utils/logger';
import {
fetchSmtpSettings,
editSmtpSettings,
} from '@/app/actions/settingsAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useCsrfToken } from '@/context/CsrfContext'; // Import du hook pour récupérer le csrfToken
import { useNotification } from '@/context/NotificationContext';
import { useSearchParams } from 'next/navigation'; // Ajoute cet import
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState('structure');
const [activeTab, setActiveTab] = useState('smtp');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
@ -14,22 +24,58 @@ export default function SettingsPage() {
const [smtpPort, setSmtpPort] = useState('');
const [smtpUser, setSmtpUser] = useState('');
const [smtpPassword, setSmtpPassword] = useState('');
const [useTls, setUseTls] = useState(true);
const [useSsl, setUseSsl] = useState(false);
const { selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken(); // Récupération du csrfToken
const { showNotification } = useNotification();
const searchParams = useSearchParams();
const handleTabClick = (tab) => {
setActiveTab(tab);
};
const handleEmailChange = (e) => {
setEmail(e.target.value);
};
// Ajout : sélection automatique de l'onglet via l'ancre ou le paramètre de recherche
useEffect(() => {
const tabParam = searchParams.get('tab');
if (tabParam === 'smtp') {
setActiveTab('smtp');
}
}, [searchParams]);
const handlePasswordChange = (e) => {
setPassword(e.target.value);
};
const handleConfirmPasswordChange = (e) => {
setConfirmPassword(e.target.value);
};
// Charger les paramètres SMTP existants
useEffect(() => {
if (activeTab === 'smtp') {
fetchSmtpSettings(csrfToken, selectedEstablishmentId) // Passer le csrfToken ici
.then((data) => {
setSmtpServer(data.smtp_server || '');
setSmtpPort(data.smtp_port || '');
setSmtpUser(data.smtp_user || '');
setSmtpPassword(data.smtp_password || '');
setUseTls(data.use_tls || false);
setUseSsl(data.use_ssl || false);
})
.catch((error) => {
if (error.response && error.response.status === 404) {
showNotification(
"Les données SMTP n'ont pas été trouvées.",
'warning',
'Attention'
);
} else {
logger.error(
'Erreur lors du chargement des paramètres SMTP:',
error
);
showNotification(
'Erreur lors du chargement des paramètres SMTP.',
'error',
'Erreur'
);
}
});
}
}, [activeTab, csrfToken]); // Ajouter csrfToken comme dépendance
const handleSmtpServerChange = (e) => {
setSmtpServer(e.target.value);
@ -47,34 +93,43 @@ export default function SettingsPage() {
setSmtpPassword(e.target.value);
};
const handleSubmit = (e) => {
e.preventDefault();
if (password !== confirmPassword) {
alert('Les mots de passe ne correspondent pas');
return;
}
// Logique pour mettre à jour l'email et le mot de passe
console.log('Email:', email);
console.log('Password:', password);
};
const handleSmtpSubmit = (e) => {
e.preventDefault();
// Logique pour mettre à jour les paramètres SMTP
console.log('SMTP Server:', smtpServer);
console.log('SMTP Port:', smtpPort);
console.log('SMTP User:', smtpUser);
console.log('SMTP Password:', smtpPassword);
const smtpData = {
establishment: selectedEstablishmentId,
smtp_server: smtpServer,
smtp_port: smtpPort,
smtp_user: smtpUser,
smtp_password: smtpPassword,
use_tls: useTls,
use_ssl: useSsl,
};
editSmtpSettings(smtpData, csrfToken) // Passer le csrfToken ici
.then(() => {
showNotification(
'Paramètres SMTP mis à jour avec succès.',
'success',
'Succès'
);
logger.debug('SMTP Settings Updated:', smtpData);
})
.catch((error) => {
logger.error(
'Erreur lors de la mise à jour des paramètres SMTP:',
error
);
showNotification(
'Erreur lors de la mise à jour des paramètres SMTP.',
'error',
'Erreur'
);
});
};
return (
<div className="p-8">
<div className="flex space-x-4 mb-4">
<Tab
text="Informations de la structure"
active={activeTab === 'structure'}
onClick={() => handleTabClick('structure')}
/>
<Tab
text="Paramètres SMTP"
active={activeTab === 'smtp'}
@ -82,21 +137,55 @@ export default function SettingsPage() {
/>
</div>
<div className="mt-4">
<TabContent isActive={activeTab === 'structure'}>
<form onSubmit={handleSubmit}>
<InputText label="Email" value={email} onChange={handleEmailChange} />
<InputText label="Mot de passe" type="password" value={password} onChange={handlePasswordChange} />
<InputText label="Confirmer le mot de passe" type="password" value={confirmPassword} onChange={handleConfirmPasswordChange} />
<Button type="submit" primary text="Mettre à jour"></Button>
</form>
</TabContent>
<TabContent isActive={activeTab === 'smtp'}>
<form onSubmit={handleSmtpSubmit}>
<InputText label="Serveur SMTP" value={smtpServer} onChange={handleSmtpServerChange} />
<InputText label="Port SMTP" value={smtpPort} onChange={handleSmtpPortChange} />
<InputText label="Utilisateur SMTP" value={smtpUser} onChange={handleSmtpUserChange} />
<InputText label="Mot de passe SMTP" type="password" value={smtpPassword} onChange={handleSmtpPasswordChange} />
<Button type="submit" primary text="Mettre à jour"></Button>
<div className="grid grid-cols-2 gap-4">
<InputText
label="Serveur SMTP"
value={smtpServer}
onChange={handleSmtpServerChange}
/>
<InputText
label="Port SMTP"
value={smtpPort}
onChange={handleSmtpPortChange}
/>
<InputText
label="Utilisateur SMTP"
value={smtpUser}
onChange={handleSmtpUserChange}
/>
<InputText
label="Mot de passe SMTP"
type="password"
value={smtpPassword}
onChange={handleSmtpPasswordChange}
/>
</div>
<div className="mt-6 border-t pt-4">
<div className="flex items-center space-x-4">
<CheckBox
item={{ id: 'useTls' }}
formData={{ useTls }}
handleChange={() => setUseTls((prev) => !prev)} // Inverser la valeur booléenne
fieldName="useTls"
itemLabelFunc={() => 'Utiliser TLS'}
/>
<CheckBox
item={{ id: 'useSsl' }}
formData={{ useSsl }}
handleChange={() => setUseSsl((prev) => !prev)} // Inverser la valeur booléenne
fieldName="useSsl"
itemLabelFunc={() => 'Utiliser SSL'}
/>
</div>
</div>
<Button
type="submit"
primary
text="Mettre à jour"
className="mt-6"
></Button>
</form>
</TabContent>
</div>

View File

@ -0,0 +1,742 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Users, Layers, CheckCircle, Clock, XCircle } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import { fetchClasse } from '@/app/actions/schoolAction';
import { useSearchParams } from 'next/navigation';
import logger from '@/utils/logger';
import { useClasses } from '@/context/ClassesContext';
import Button from '@/components/Button';
import SelectChoice from '@/components/SelectChoice';
import CheckBox from '@/components/CheckBox';
import {
fetchAbsences,
createAbsences,
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction';
import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useNotification } from '@/context/NotificationContext';
export default function Page() {
const searchParams = useSearchParams();
const { showNotification } = useNotification();
const schoolClassId = searchParams.get('schoolClassId');
const [classe, setClasse] = useState([]);
const { getNiveauxLabels, getNiveauLabel } = useClasses();
const [popupVisible, setPopupVisible] = useState(false);
const [popupMessage, setPopupMessage] = useState('');
const [selectedLevels, setSelectedLevels] = useState([]); // Par défaut, tous les niveaux sont sélectionnés
const [filteredStudents, setFilteredStudents] = useState([]);
const [isEditingAttendance, setIsEditingAttendance] = useState(false); // État pour le mode édition
const [attendance, setAttendance] = useState({}); // État pour les cases cochées
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
// AbsenceMoment constants
const AbsenceMoment = {
MORNING: { value: 1, label: 'Matinée' },
AFTERNOON: { value: 2, label: 'Après-midi' },
TOTAL: { value: 3, label: 'Journée' },
};
// AbsenceReason constants
const AbsenceReason = {
JUSTIFIED_ABSENCE: { value: 1, label: 'Absence justifiée' },
UNJUSTIFIED_ABSENCE: { value: 2, label: 'Absence non justifiée' },
JUSTIFIED_LATE: { value: 3, label: 'Retard justifié' },
UNJUSTIFIED_LATE: { value: 4, label: 'Retard non justifié' },
};
useEffect(() => {
// Récupérer les données de la classe et initialiser les élèves filtrés
if (schoolClassId) {
fetchClasse(schoolClassId)
.then((classeData) => {
logger.debug('Classes récupérées :', classeData);
setClasse(classeData);
setFilteredStudents(classeData.students); // Initialiser les élèves filtrés
setSelectedLevels(getNiveauxLabels(classeData.levels)); // Initialiser les niveaux sélectionnés
})
.catch(requestErrorHandler);
}
}, [schoolClassId]);
useEffect(() => {
// Récupérer les absences pour l'établissement sélectionné
if (selectedEstablishmentId) {
fetchAbsences(selectedEstablishmentId)
.then((data) => {
const absencesById = data.reduce((acc, absence) => {
acc[absence.student] = absence;
return acc;
}, {});
setFetchedAbsences(absencesById);
})
.catch((error) =>
logger.error('Erreur lors de la récupération des absences :', error)
);
}
}, [selectedEstablishmentId]);
useEffect(() => {
// Filtrer les élèves en fonction des niveaux sélectionnés
if (classe && selectedLevels.length > 0) {
const filtered = classe.students.filter((student) =>
selectedLevels.includes(getNiveauLabel(student.level))
);
setFilteredStudents(filtered);
} else {
setFilteredStudents([]); // Aucun élève si aucun niveau n'est sélectionné
}
}, [selectedLevels, classe]);
useEffect(() => {
// Initialiser `attendance` et `formAbsences` en fonction des élèves filtrés et des absences
if (filteredStudents.length > 0 && fetchedAbsences) {
const today = new Date().toISOString().split('T')[0];
const initialAttendance = {};
const initialFormAbsences = {};
filteredStudents.forEach((student) => {
const existingAbsence =
fetchedAbsences[student.id] &&
fetchedAbsences[student.id].day === today
? fetchedAbsences[student.id]
: null;
if (existingAbsence) {
// Si une absence existe pour aujourd'hui, décocher la case et pré-remplir les champs
// Conversion reason -> type/justified
let type = '';
let justified = false;
switch (existingAbsence.reason) {
case AbsenceReason.JUSTIFIED_ABSENCE.value:
type = 'absence';
justified = true;
break;
case AbsenceReason.UNJUSTIFIED_ABSENCE.value:
type = 'absence';
justified = false;
break;
case AbsenceReason.JUSTIFIED_LATE.value:
type = 'retard';
justified = true;
break;
case AbsenceReason.UNJUSTIFIED_LATE.value:
type = 'retard';
justified = false;
break;
default:
type = '';
justified = false;
}
initialAttendance[student.id] = false;
initialFormAbsences[student.id] = {
...existingAbsence,
type,
justified,
};
} else {
// Sinon, cocher la case par défaut
initialAttendance[student.id] = true;
}
});
setAttendance(initialAttendance);
setFormAbsences(initialFormAbsences);
}
}, [filteredStudents, fetchedAbsences]);
const handleLevelClick = (label) => {
setSelectedLevels(
(prev) =>
prev.includes(label)
? prev.filter((level) => level !== label) // Retirer le niveau si déjà sélectionné
: [...prev, label] // Ajouter le niveau si non sélectionné
);
};
const handleToggleAttendanceMode = () => {
setIsEditingAttendance((prev) => !prev); // Basculer entre mode édition et visualisation
};
const handleValidateAttendance = () => {
let hasError = false;
// Pour chaque élève filtré (présents et absents)
filteredStudents.forEach((student) => {
const studentId = student.id;
const isPresent = attendance[studentId];
const existingAbsence = fetchedAbsences[studentId];
if (isPresent) {
// Si l'élève est présent et qu'une absence existe, la supprimer
if (existingAbsence) {
deleteAbsences(existingAbsence.id, csrfToken)
.then(() => {
logger.debug(
`Absence pour l'élève ${studentId} supprimée (présent).`
);
setFetchedAbsences((prev) => {
const updatedAbsences = { ...prev };
delete updatedAbsences[studentId];
return updatedAbsences;
});
setFormAbsences((prev) => {
const updatedAbsences = { ...prev };
delete updatedAbsences[studentId];
return updatedAbsences;
});
})
.catch((error) => {
logger.error(
`Erreur lors de la suppression de l'absence pour l'élève ${studentId}:`,
error
);
showNotification(
`Erreur lors de la suppression de l'absence pour l'élève ${studentId}`,
'error',
'Erreur'
);
});
}
// Si tu veux garder une trace de la présence, tu peux ici appeler une API ou enregistrer un "Présent"
} else {
// Si l'élève est absent, créer ou modifier l'absence
const absenceData = formAbsences[studentId];
if (!absenceData || !absenceData.type || !absenceData.moment) {
logger.error(
`Tous les champs requis doivent être fournis pour l'élève ${studentId}.`
);
showNotification(
`Tous les champs requis doivent être fournis pour l'élève ${studentId}.`,
'error',
'Erreur'
);
hasError = true;
// On ne fait pas de return ici, on continue la boucle pour les autres élèves
} else {
saveAbsence(studentId, absenceData);
}
}
});
// On ne quitte le mode édition que s'il n'y a pas d'erreur
if (!hasError) {
setIsEditingAttendance(false);
}
};
const handleAttendanceChange = (studentId) => {
const today = new Date().toISOString().split('T')[0]; // Obtenir la date actuelle au format YYYY-MM-DD
setAttendance((prev) => {
const updatedAttendance = {
...prev,
[studentId]: !prev[studentId], // Inverser l'état de présence
};
// Si l'élève est décoché (absent)
if (!updatedAttendance[studentId]) {
// Vérifier s'il existe une absence pour le jour actuel
const existingAbsence = Object.values(fetchedAbsences).find(
(absence) => absence.student === studentId && absence.day === today
);
if (existingAbsence) {
// Afficher l'absence existante pour le jour actuel
setFormAbsences((prev) => ({
...prev,
[studentId]: {
...existingAbsence,
},
}));
} else {
// Initialiser des champs vides pour créer une nouvelle absence
setFormAbsences((prev) => ({
...prev,
[studentId]: {
day: today,
reason: null,
moment: null,
},
}));
}
} else {
// Si l'élève est recoché (présent), supprimer l'absence existante
const existingAbsence = Object.values(fetchedAbsences).find(
(absence) => absence.student === studentId && absence.day === today
);
if (existingAbsence) {
// Appeler la fonction pour supprimer l'absence
deleteAbsences(existingAbsence.id, csrfToken)
.then(() => {
logger.debug(
`Absence pour l'élève ${studentId} supprimée avec succès.`
);
// Mettre à jour les absences récupérées
setFetchedAbsences((prev) => {
const updatedAbsences = { ...prev };
delete updatedAbsences[studentId];
return updatedAbsences;
});
})
.catch((error) => {
logger.error(
`Erreur lors de la suppression de l'absence pour l'élève ${studentId}:`,
error
);
});
}
// Supprimer les données d'absence dans `formAbsences`
setFormAbsences((prev) => {
const updatedAbsences = { ...prev };
delete updatedAbsences[studentId];
return updatedAbsences;
});
}
return updatedAttendance;
});
};
const getAbsenceReason = (type, justified) => {
if (type === 'absence') {
return justified
? AbsenceReason.JUSTIFIED_ABSENCE.value
: AbsenceReason.UNJUSTIFIED_ABSENCE.value;
} else if (type === 'retard') {
return justified
? AbsenceReason.JUSTIFIED_LATE.value
: AbsenceReason.UNJUSTIFIED_LATE.value;
}
return null;
};
const saveAbsence = (studentId, absenceData) => {
if (!absenceData.type || !studentId || !absenceData.moment) {
logger.error('Tous les champs requis doivent être fournis.');
showNotification(
'Tous les champs requis doivent être fournis.',
'error',
'Erreur'
);
return;
}
const reason = getAbsenceReason(absenceData.type, absenceData.justified);
const payload = {
student: studentId,
day: absenceData.day,
reason: reason,
moment: absenceData.moment,
establishment: selectedEstablishmentId,
commentaire: absenceData.commentaire,
};
if (absenceData.id) {
// Modifier une absence existante
editAbsences(absenceData.id, payload, csrfToken)
.then(() => {
logger.debug(
`Absence pour l'élève ${studentId} modifiée avec succès.`
);
showNotification(
'Opération effectuée avec succès.',
'success',
'Succès'
);
// Mettre à jour fetchedAbsences et formAbsences localement
setFetchedAbsences((prev) => ({
...prev,
[studentId]: { ...prev[studentId], ...payload },
}));
setFormAbsences((prev) => ({
...prev,
[studentId]: { ...prev[studentId], ...payload },
}));
})
.catch((error) => {
logger.error(
`Erreur lors de la modification de l'absence pour l'élève ${studentId}:`,
error
);
});
} else {
// Créer une nouvelle absence
createAbsences(payload, csrfToken)
.then((response) => {
logger.debug(`Absence pour l'élève ${studentId} créée avec succès.`);
showNotification(
'Opération effectuée avec succès.',
'success',
'Succès'
);
// Mettre à jour fetchedAbsences et formAbsences localement
setFetchedAbsences((prev) => ({
...prev,
[studentId]: { id: response.id, ...payload },
}));
setFormAbsences((prev) => ({
...prev,
[studentId]: { id: response.id, ...payload },
}));
})
.catch((error) => {
logger.error(
`Erreur lors de la création de l'absence pour l'élève ${studentId}:`,
error
);
});
}
};
const requestErrorHandler = (err) => {
logger.error('Error fetching data:', err);
};
const today = new Date().toISOString().split('T')[0]; // Obtenez la date actuelle au format YYYY-MM-DD
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">{classe?.atmosphere_name}</h1>
{/* Section Niveaux et Enseignants */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Section Niveaux */}
<div className="bg-white p-4 rounded-lg shadow-md">
<h2 className="text-xl font-semibold mb-4 flex items-center">
<Layers className="w-6 h-6 mr-2" />
Niveaux
</h2>
<p className="text-sm text-gray-500 mb-4">
Filtrer les élèves par niveau
</p>
<div className="flex flex-wrap gap-2">
{classe?.levels?.length > 0 ? (
getNiveauxLabels(classe.levels).map((label, index) => (
<span
key={index}
onClick={() => handleLevelClick(label)} // Gérer le clic sur un niveau
className={`px-4 py-2 rounded-full cursor-pointer border transition-all duration-200 ${
selectedLevels.includes(label)
? 'bg-emerald-200 text-emerald-800 border-emerald-300 shadow-md'
: 'bg-gray-200 text-gray-800 border-gray-300 hover:bg-gray-300'
}`}
>
{selectedLevels.includes(label) ? (
<span className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-600" />
{label}
</span>
) : (
label
)}
</span>
))
) : (
<span className="text-gray-500">Aucun niveau associé</span>
)}
</div>
</div>
{/* Section Enseignants */}
<div className="bg-white p-4 rounded-lg shadow-md">
<h2 className="text-xl font-semibold mb-4 flex items-center">
<Users className="w-6 h-6 mr-2" />
Enseignants
</h2>
<p className="text-sm text-gray-500 mb-4">Liste des enseignants</p>
<div className="flex flex-wrap gap-2">
{classe?.teachers_details?.map((teacher) => (
<span
key={teacher.id}
className="px-3 py-1 bg-emerald-200 rounded-full text-emerald-800"
>
{teacher.last_name} {teacher.first_name}
</span>
))}
</div>
</div>
</div>
{/* Affichage de la date du jour */}
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
<Clock className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold text-gray-800">
Appel du jour :{' '}
<span className="ml-2 text-emerald-600">{today}</span>
</h2>
</div>
<div className="flex items-center">
{!isEditingAttendance ? (
<Button
text="Faire l'appel"
onClick={handleToggleAttendanceMode}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
) : (
<Button
text="Valider l'appel"
onClick={handleValidateAttendance}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
)}
</div>
</div>
<Table
columns={[
{
name: 'Nom',
transform: (row) => (
<div className="text-center">{row.last_name}</div>
),
},
{
name: 'Prénom',
transform: (row) => (
<div className="text-center">{row.first_name}</div>
),
},
{
name: 'Niveau',
transform: (row) => (
<div className="text-center">{getNiveauLabel(row.level)}</div>
),
},
...(isEditingAttendance
? [
{
name: "Gestion de l'appel",
transform: (row) => (
<div className="flex flex-col gap-2 items-center">
{/* Présence */}
<div className="flex items-center gap-2">
{attendance[row.id] ? (
<>
<CheckBox
item={{ id: row.id }}
formData={{
attendance: attendance[row.id] ? [row.id] : [],
}}
handleChange={() =>
handleAttendanceChange(row.id)
}
fieldName="attendance"
/>
<span className="text-sm font-medium text-gray-700">
Présent
</span>
</>
) : (
<>
{/* Icône croix pour remettre l'élève en présent */}
<button
type="button"
onClick={() => handleAttendanceChange(row.id)}
className="text-red-500 hover:text-red-700 transition"
title="Annuler l'absence"
>
<XCircle className="w-6 h-6" />
</button>
<span className="text-sm font-medium text-red-600">
Effacer l&apos;absence
</span>
</>
)}
</div>
{/* Détails absence/retard */}
{!attendance[row.id] && (
<div className="w-full bg-emerald-50 border border-emerald-100 rounded-lg p-3 mt-2 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-emerald-500" />
<span className="font-semibold text-emerald-700 text-sm">
Motif d&apos;absence
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 items-center">
{/* Select Absence/Retard */}
<SelectChoice
name={`type-${row.id}`}
label=""
placeHolder="Type"
selected={formAbsences[row.id]?.type || ''}
callback={(e) =>
setFormAbsences((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
type: e.target.value,
},
}))
}
choices={[
{ value: 'absence', label: 'Absence' },
{ value: 'retard', label: 'Retard' },
]}
/>
{/* Select Moment */}
<SelectChoice
name={`moment-${row.id}`}
label=""
placeHolder="Durée"
selected={formAbsences[row.id]?.moment || ''}
callback={(e) =>
setFormAbsences((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
moment: parseInt(e.target.value, 10),
},
}))
}
choices={Object.values(AbsenceMoment).map(
(moment) => ({
value: moment.value,
label: moment.label,
})
)}
/>
{/* Nouveau champ commentaire */}
<input
type="text"
className="border rounded px-2 py-1 text-sm w-full"
placeholder="Commentaire"
value={formAbsences[row.id]?.commentaire || ''}
onChange={(e) =>
setFormAbsences((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
commentaire: e.target.value,
},
}))
}
/>
{/* Checkbox Justifié */}
<div className="flex items-center gap-2 justify-center">
<CheckBox
item={{ id: `justified-${row.id}` }}
formData={{
justified: !!formAbsences[row.id]?.justified,
}}
handleChange={() =>
setFormAbsences((prev) => ({
...prev,
[row.id]: {
...prev[row.id],
justified: !prev[row.id]?.justified,
},
}))
}
fieldName="justified"
itemLabelFunc={() => 'Justifié'}
/>
</div>
</div>
</div>
)}
</div>
),
},
]
: [
{
name: 'Statut',
transform: (row) => {
const today = new Date().toISOString().split('T')[0];
const absence =
formAbsences[row.id] ||
Object.values(fetchedAbsences).find(
(absence) =>
absence.student === row.id && absence.day === today
);
if (!absence) {
return (
<div className="text-center text-green-500 flex justify-center items-center gap-2">
<CheckCircle className="w-5 h-5" />
Présent
</div>
);
}
switch (absence.reason) {
case AbsenceReason.JUSTIFIED_LATE.value:
return (
<div className="text-center text-yellow-500 flex justify-center items-center gap-2">
<Clock className="w-5 h-5" />
Retard justifié
</div>
);
case AbsenceReason.UNJUSTIFIED_LATE.value:
return (
<div className="text-center text-red-500 flex justify-center items-center gap-2">
<Clock className="w-5 h-5" />
Retard non justifié
</div>
);
case AbsenceReason.JUSTIFIED_ABSENCE.value:
return (
<div className="text-center text-blue-500 flex justify-center items-center gap-2">
<CheckCircle className="w-5 h-5" />
Absence justifiée
</div>
);
case AbsenceReason.UNJUSTIFIED_ABSENCE.value:
return (
<div className="text-center text-red-500 flex justify-center items-center gap-2">
<CheckCircle className="w-5 h-5" />
Absence non justifiée
</div>
);
default:
return (
<div className="text-center text-gray-500 flex justify-center items-center gap-2">
<CheckCircle className="w-5 h-5" />
Statut inconnu
</div>
);
}
},
},
]),
]}
data={filteredStudents} // Utiliser les élèves filtrés
/>
{/* Popup */}
<Popup
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
onCancel={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
</div>
);
}

View File

@ -1,125 +1,262 @@
'use client'
'use client';
import React, { useState, useEffect } from 'react';
import { School, Calendar } from 'lucide-react';
import TabsStructure from '@/components/Structure/Configuration/TabsStructure';
import ScheduleManagement from '@/components/Structure/Planning/ScheduleManagement'
import StructureManagement from '@/components/Structure/Configuration/StructureManagement'
import { BK_GESTIONENSEIGNANTS_SPECIALITES_URL,
BK_GESTIONENSEIGNANTS_CLASSES_URL,
BK_GESTIONENSEIGNANTS_TEACHERS_URL,
BK_GESTIONENSEIGNANTS_PLANNINGS_URL } from '@/utils/Url';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'
import useCsrfToken from '@/hooks/useCsrfToken';
import StructureManagement from '@/components/Structure/Configuration/StructureManagement';
import ScheduleManagement from '@/components/Structure/Planning/ScheduleManagement';
import FeesManagement from '@/components/Structure/Tarification/FeesManagement';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import { useCsrfToken } from '@/context/CsrfContext';
import { ClassesProvider } from '@/context/ClassesContext';
import {
createDatas,
updateDatas,
removeDatas,
fetchSpecialities,
fetchTeachers,
fetchClasses,
fetchRegistrationDiscounts,
fetchTuitionDiscounts,
fetchRegistrationFees,
fetchTuitionFees,
fetchRegistrationPaymentPlans,
fetchTuitionPaymentPlans,
fetchRegistrationPaymentModes,
fetchTuitionPaymentModes,
fetchEstablishmentCompetencies,
} from '@/app/actions/schoolAction';
import { fetchProfiles } from '@/app/actions/authAction';
import SidebarTabs from '@/components/SidebarTabs';
import FilesGroupsManagement from '@/components/Structure/Files/FilesGroupsManagement';
import { fetchRegistrationSchoolFileMasters } from '@/app/actions/registerFileGroupAction';
import logger from '@/utils/logger';
import { useEstablishment } from '@/context/EstablishmentContext';
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList';
export default function Page() {
const [specialities, setSpecialities] = useState([]);
const [classes, setClasses] = useState([]);
const [teachers, setTeachers] = useState([]);
const [schedules, setSchedules] = useState([]);
const [activeTab, setActiveTab] = useState('Configuration');
const tabs = [
{ id: 'Configuration', title: "Configuration de l'école", icon: School },
{ id: 'Schedule', title: "Gestion de l'emploi du temps", icon: Calendar },
];
const [registrationDiscounts, setRegistrationDiscounts] = useState([]);
const [tuitionDiscounts, setTuitionDiscounts] = useState([]);
const [registrationFees, setRegistrationFees] = useState([]);
const [tuitionFees, setTuitionFees] = useState([]);
const [fichiers, setFichiers] = useState([]);
const [registrationPaymentPlans, setRegistrationPaymentPlans] = useState([]);
const [tuitionPaymentPlans, setTuitionPaymentPlans] = useState([]);
const [registrationPaymentModes, setRegistrationPaymentModes] = useState([]);
const [tuitionPaymentModes, setTuitionPaymentModes] = useState([]);
const [profiles, setProfiles] = useState([]);
const [establishmentCompetencies, setEstablishmentCompetencies] = useState(
[]
);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
useEffect(() => {
// Fetch data for specialities
fetchSpecialities();
if (selectedEstablishmentId) {
// Fetch data for specialities
handleSpecialities();
// Fetch data for teachers
fetchTeachers();
// Fetch data for teachers
handleTeachers();
// Fetch data for classes
fetchClasses();
// Fetch data for schedules
fetchSchedules();
}, []);
// Fetch data for classes
handleClasses();
const fetchSpecialities = () => {
fetch(`${BK_GESTIONENSEIGNANTS_SPECIALITES_URL}`)
.then(response => response.json())
.then(data => {
// Fetch data for registration discounts
handleRegistrationDiscounts();
// Fetch data for tuition discounts
handleTuitionDiscounts();
// Fetch data for registration fees
handleRegistrationFees();
// Fetch data for tuition fees
handleTuitionFees();
// Fetch data for registration file schoolFileTemplates
fetchRegistrationSchoolFileMasters(selectedEstablishmentId)
.then((data) => {
setFichiers(data);
})
.catch((error) => logger.error('Error fetching files:', error));
// Fetch data for registration payment plans
handleRegistrationPaymentPlans();
// Fetch data for tuition payment plans
handleTuitionPaymentPlans();
// Fetch data for registration payment modes
handleRegistrationPaymentModes();
// Fetch data for tuition payment modes
handleTuitionPaymentModes();
fetchProfiles()
.then((data) => {
setProfiles(data);
})
.catch((error) => {
logger.error('Error fetching profileRoles:', error);
});
// Fetch data for establishment competencies
handleEstablishmentCompetencies();
}
}, [selectedEstablishmentId]);
const handleEstablishmentCompetencies = (cycle = 1) => {
fetchEstablishmentCompetencies(selectedEstablishmentId, cycle)
.then((data) => {
setEstablishmentCompetencies(data);
})
.catch((error) =>
logger.error('Error fetching setEstablishmentCompetencies:', error)
);
};
const handleSpecialities = () => {
fetchSpecialities(selectedEstablishmentId)
.then((data) => {
setSpecialities(data);
})
.catch(error => {
console.error('Error fetching specialities:', error);
});
.catch((error) => logger.error('Error fetching specialities:', error));
};
const fetchTeachers = () => {
fetch(`${BK_GESTIONENSEIGNANTS_TEACHERS_URL}`)
.then(response => response.json())
.then(data => {
const handleTeachers = () => {
fetchTeachers(selectedEstablishmentId)
.then((data) => {
setTeachers(data);
})
.catch(error => {
console.error('Error fetching teachers:', error);
});
.catch((error) => logger.error('Error fetching teachers:', error));
};
const fetchClasses = () => {
fetch(`${BK_GESTIONENSEIGNANTS_CLASSES_URL}`)
.then(response => response.json())
.then(data => {
const handleClasses = () => {
fetchClasses(selectedEstablishmentId)
.then((data) => {
setClasses(data);
})
.catch(error => {
console.error('Error fetching classes:', error);
});
.catch((error) => logger.error('Error fetching classes:', error));
};
const fetchSchedules = () => {
fetch(`${BK_GESTIONENSEIGNANTS_PLANNINGS_URL}`)
.then(response => response.json())
.then(data => {
setSchedules(data);
const handleRegistrationDiscounts = () => {
fetchRegistrationDiscounts(selectedEstablishmentId)
.then((data) => {
setRegistrationDiscounts(data);
})
.catch(error => {
console.error('Error fetching classes:', error);
});
.catch((error) =>
logger.error('Error fetching registration discounts:', error)
);
};
const handleTuitionDiscounts = () => {
fetchTuitionDiscounts(selectedEstablishmentId)
.then((data) => {
setTuitionDiscounts(data);
})
.catch((error) =>
logger.error('Error fetching tuition discounts:', error)
);
};
const handleRegistrationFees = () => {
fetchRegistrationFees(selectedEstablishmentId)
.then((data) => {
setRegistrationFees(data);
})
.catch((error) =>
logger.error('Error fetching registration fees:', error)
);
};
const handleTuitionFees = () => {
fetchTuitionFees(selectedEstablishmentId)
.then((data) => {
setTuitionFees(data);
})
.catch((error) => logger.error('Error fetching tuition fees', error));
};
const handleRegistrationPaymentPlans = () => {
fetchRegistrationPaymentPlans(selectedEstablishmentId)
.then((data) => {
setRegistrationPaymentPlans(data);
})
.catch((error) =>
logger.error('Error fetching registration payment plans:', error)
);
};
const handleTuitionPaymentPlans = () => {
fetchTuitionPaymentPlans(selectedEstablishmentId)
.then((data) => {
setTuitionPaymentPlans(data);
})
.catch((error) =>
logger.error('Error fetching tuition payment plans:', error)
);
};
const handleRegistrationPaymentModes = () => {
fetchRegistrationPaymentModes(selectedEstablishmentId)
.then((data) => {
setRegistrationPaymentModes(data);
})
.catch((error) =>
logger.error('Error fetching registration payment modes:', error)
);
};
const handleTuitionPaymentModes = () => {
fetchTuitionPaymentModes(selectedEstablishmentId)
.then((data) => {
setTuitionPaymentModes(data);
})
.catch((error) =>
logger.error('Error fetching tuition payment modes:', error)
);
};
const handleCreate = (url, newData, setDatas) => {
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(newData),
credentials: 'include'
})
.then(response => response.json())
.then(data => {
console.log('Succes :', data);
setDatas(prevState => [...prevState, data]);
})
.catch(error => {
console.error('Erreur :', error);
});
return createDatas(url, newData, csrfToken)
.then((data) => {
setDatas((prevState) => [...prevState, data]);
return data;
})
.catch((error) => {
logger.error('Error creating data:', error);
throw error;
});
};
const handleEdit = (url, id, updatedData, setDatas) => {
fetch(`${url}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(updatedData),
credentials: 'include'
})
.then(response => response.json())
.then(data => {
setDatas(prevState => prevState.map(item => item.id === id ? data : item));
})
.catch(error => {
console.error('Erreur :', error);
});
return updateDatas(url, id, updatedData, csrfToken)
.then((data) => {
setDatas((prevState) =>
prevState.map((item) => (item.id === id ? data : item))
);
return data;
})
.catch((error) => {
logger.error('Error editing data:', error);
throw error;
});
};
const handleDelete = (url, id, setDatas) => {
return removeDatas(url, id, csrfToken)
.then((data) => {
setDatas((prevState) => prevState.filter((item) => item.id !== id));
return data;
})
.catch((error) => {
logger.error('Error deleting data:', error);
throw error;
});
};
const handleUpdatePlanning = (url, planningId, updatedData) => {
@ -127,72 +264,123 @@ export default function Page() {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(updatedData),
credentials: 'include'
credentials: 'include',
})
.then(response => response.json())
.then(data => {
console.log('Planning mis à jour avec succès :', data);
//setDatas(data);
})
.catch(error => {
console.error('Erreur :', error);
});
.then((response) => response.json())
.then((data) => {
logger.debug('Planning mis à jour avec succès :', data);
//setDatas(data);
})
.catch((error) => {
logger.error('Erreur :', error);
});
};
const handleDelete = (url, id, setDatas) => {
fetch(`${url}/${id}`, {
method:'DELETE',
headers: {
'Content-Type':'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include'
})
.then(response => response.json())
.then(data => {
console.log('Success:', data);
setDatas(prevState => prevState.filter(item => item.id !== id));
})
.catch(error => {
console.error('Error fetching data:', error);
error = error.errorMessage;
console.log(error);
});
};
const tabs = [
{
id: 'Configuration',
label: 'Classes',
content: (
<div className="h-full overflow-y-auto p-4">
<StructureManagement
specialities={specialities}
setSpecialities={setSpecialities}
teachers={teachers}
setTeachers={setTeachers}
classes={classes}
setClasses={setClasses}
profiles={profiles}
handleCreate={handleCreate}
handleEdit={handleEdit}
handleDelete={handleDelete}
/>
</div>
),
},
{
id: 'Schedule',
label: 'Emploi du temps',
content: (
<div className="h-full overflow-y-auto p-4">
<ClassesProvider>
<ScheduleManagement
handleUpdatePlanning={handleUpdatePlanning}
classes={classes}
specialities={specialities}
teachers={teachers}
/>
</ClassesProvider>
</div>
),
},
{
id: 'Fees',
label: 'Tarifs',
content: (
<div className="h-full overflow-y-auto p-4">
<FeesManagement
registrationDiscounts={registrationDiscounts}
setRegistrationDiscounts={setRegistrationDiscounts}
tuitionDiscounts={tuitionDiscounts}
setTuitionDiscounts={setTuitionDiscounts}
registrationFees={registrationFees}
setRegistrationFees={setRegistrationFees}
tuitionFees={tuitionFees}
setTuitionFees={setTuitionFees}
registrationPaymentPlans={registrationPaymentPlans}
setRegistrationPaymentPlans={setRegistrationPaymentPlans}
tuitionPaymentPlans={tuitionPaymentPlans}
setTuitionPaymentPlans={setTuitionPaymentPlans}
registrationPaymentModes={registrationPaymentModes}
setRegistrationPaymentModes={setRegistrationPaymentModes}
tuitionPaymentModes={tuitionPaymentModes}
setTuitionPaymentModes={setTuitionPaymentModes}
handleCreate={handleCreate}
handleEdit={handleEdit}
handleDelete={handleDelete}
/>
</div>
),
},
{
id: 'Files',
label: 'Documents',
content: (
<div className="h-full overflow-y-auto p-4">
<FilesGroupsManagement
csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal={apiDocuseal}
/>
</div>
),
},
{
id: 'Competencies',
label: 'Compétences',
content: (
<div className="h-full overflow-y-auto p-4">
<CompetenciesList
establishmentCompetencies={establishmentCompetencies}
onChangeCycle={handleEstablishmentCompetencies}
/>
</div>
),
},
];
return (
<div className='p-8'>
<DjangoCSRFToken csrfToken={csrfToken} />
<TabsStructure activeTab={activeTab} setActiveTab={setActiveTab} tabs={tabs} />
{activeTab === 'Configuration' && (
<>
<StructureManagement
specialities={specialities}
setSpecialities={setSpecialities}
teachers={teachers}
setTeachers={setTeachers}
classes={classes}
setClasses={setClasses}
handleCreate={handleCreate}
handleEdit={handleEdit}
handleDelete={handleDelete} />
</>
)}
{activeTab === 'Schedule' && (
<ClassesProvider>
<ScheduleManagement
handleUpdatePlanning={handleUpdatePlanning}
classes={classes}
/>
</ClassesProvider>
)}
</div>
<>
<PlanningProvider
establishmentId={selectedEstablishmentId}
modeSet={PlanningModes.CLASS_SCHEDULE}
>
<DjangoCSRFToken csrfToken={csrfToken} />
<SidebarTabs tabs={tabs} />
</PlanningProvider>
</>
);
};
}

View File

@ -1,77 +0,0 @@
import React, { useState } from 'react';
import { Upload } from 'lucide-react';
export default function FileUpload({ onFileUpload }) {
const [dragActive, setDragActive] = useState(false);
const [fileName, setFileName] = useState('');
const [file, setFile] = useState(null);
const handleDragOver = (event) => {
event.preventDefault();
setDragActive(true);
};
const handleDragLeave = () => {
setDragActive(false);
};
const handleDrop = (event) => {
event.preventDefault();
setDragActive(false);
const droppedFile = event.dataTransfer.files[0];
setFile(droppedFile);
setFileName(droppedFile.name.replace(/\.[^/.]+$/, ""));
};
const handleFileChange = (event) => {
const selectedFile = event.target.files[0];
setFile(selectedFile);
setFileName(selectedFile.name.replace(/\.[^/.]+$/, ""));
};
const handleFileNameChange = (event) => {
setFileName(event.target.value);
};
const handleUpload = () => {
if (file) {
onFileUpload(file, fileName);
setFile(null);
setFileName('');
}
};
return (
<div>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed p-8 rounded-md ${dragActive ? 'border-blue-500' : 'border-gray-300'} flex flex-col items-center justify-center`}
style={{ height: '200px' }}
>
<input type="file" onChange={handleFileChange} className="hidden" id="fileInput" />
<label htmlFor="fileInput" className="cursor-pointer flex flex-col items-center">
<Upload size={48} className="text-gray-400 mb-2" />
<p className="text-center">{fileName || 'Glissez et déposez un fichier ici ou cliquez ici pour sélectionner un fichier'}</p>
</label>
</div>
<div className="flex mt-2">
<input
type="text"
placeholder="Nom du fichier"
value={fileName}
onChange={handleFileNameChange}
className="flex-grow p-2 border border-gray-200 rounded-md"
/>
<button
onClick={handleUpload}
className={`p-2 rounded-md shadow transition duration-200 ml-2 ${file ? 'bg-emerald-600 text-white hover:bg-emerald-900' : 'bg-gray-300 text-gray-500 cursor-not-allowed'}`}
disabled={!file}
>
Ajouter
</button>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,91 +0,0 @@
'use client'
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import InscriptionFormShared from '@/components/Inscription/InscriptionFormShared';
import { FR_ADMIN_SUBSCRIPTIONS_URL,
BK_GESTIONINSCRIPTION_ELEVE_URL,
BK_GESTIONINSCRIPTION_FICHEINSCRIPTION_URL } from '@/utils/Url';
import useCsrfToken from '@/hooks/useCsrfToken';
import { mockStudent } from '@/data/mockStudent';
const useFakeData = process.env.NEXT_PUBLIC_USE_FAKE_DATA === 'true';
export default function Page() {
const searchParams = useSearchParams();
const idProfil = searchParams.get('id');
const idEleve = searchParams.get('idEleve'); // Changé de codeDI à idEleve
const [initialData, setInitialData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const csrfToken = useCsrfToken();
useEffect(() => {
if (useFakeData) {
setInitialData(mockStudent);
setIsLoading(false);
} else {
fetch(`${BK_GESTIONINSCRIPTION_ELEVE_URL}/${idEleve}`) // Utilisation de idEleve au lieu de codeDI
.then(response => response.json())
.then(data => {
console.log('Fetched data:', data); // Pour le débogage
const formattedData = {
id: data.id,
nom: data.nom,
prenom: data.prenom,
adresse: data.adresse,
dateNaissance: data.dateNaissance,
lieuNaissance: data.lieuNaissance,
codePostalNaissance: data.codePostalNaissance,
nationalite: data.nationalite,
medecinTraitant: data.medecinTraitant,
niveau: data.niveau,
responsables: data.responsables || []
};
setInitialData(formattedData);
setIsLoading(false);
})
.catch(error => {
console.error('Error fetching student data:', error);
setIsLoading(false);
});
}
}, [idEleve]); // Dépendance changée à idEleve
const handleSubmit = async (data) => {
if (useFakeData) {
console.log('Fake submit:', data);
return;
}
try {
const response = await fetch(`${BK_GESTIONINSCRIPTION_FICHEINSCRIPTION_URL}/${idEleve}`, { // Utilisation de idEleve
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include',
body: JSON.stringify(data),
});
const result = await response.json();
console.log('Success:', result);
// Redirection après succès
window.location.href = FR_ADMIN_SUBSCRIPTIONS_URL;
} catch (error) {
console.error('Error:', error);
alert('Une erreur est survenue lors de la mise à jour des données');
}
};
return (
<InscriptionFormShared
initialData={initialData}
csrfToken={csrfToken}
onSubmit={handleSubmit}
cancelUrl={FR_ADMIN_SUBSCRIPTIONS_URL}
isLoading={isLoading}
/>
);
}

View File

@ -0,0 +1,69 @@
'use client';
import React, { useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import InscriptionFormShared from '@/components/Inscription/InscriptionFormShared';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext';
import { editRegisterForm } from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger';
import Loader from '@/components/Loader';
import { useNotification } from '@/context/NotificationContext';
export default function Page() {
const router = useRouter();
const { showNotification } = useNotification();
const searchParams = useSearchParams();
const studentId = searchParams.get('studentId');
const enable = searchParams.get('enabled') === 'true';
const [formErrors, setFormErrors] = useState({});
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (data) => {
setIsLoading(true);
editRegisterForm(studentId, data, csrfToken)
.then((result) => {
setIsLoading(false);
logger.debug('Success:', result);
showNotification(
"Dossier d'inscription soumis avec succès",
'success',
'Succès'
);
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
})
.catch((error) => {
setIsLoading(false);
logger.error('Error:', error.message);
showNotification(
"Erreur lors de la soumission du dossier d'inscription",
'error',
'Erreur'
);
if (error.details) {
logger.error('Form errors:', error.details);
setFormErrors(error.details);
}
});
};
if (isLoading) {
return <Loader />;
}
return (
<InscriptionFormShared
studentId={studentId}
csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal = {apiDocuseal}
onSubmit={handleSubmit}
cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL}
errors={formErrors}
enable={enable}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,102 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import ValidateSubscription from '@/components/Inscription/ValidateSubscription';
import { editRegisterForm } from '@/app/actions/subscriptionAction';
import { fetchClasses } from '@/app/actions/schoolAction';
import { useCsrfToken } from '@/context/CsrfContext';
import logger from '@/utils/logger';
import Loader from '@/components/Loader';
import { useEstablishment } from '@/context/EstablishmentContext';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { useNotification } from '@/context/NotificationContext';
export default function Page() {
const searchParams = useSearchParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
// Récupérer les paramètres de la requête
const studentId = searchParams.get('studentId');
const firstName = searchParams.get('firstName');
const lastName = searchParams.get('lastName');
const level = searchParams.get('level');
const sepa_file =
searchParams.get('sepa_file') === 'null'
? null
: searchParams.get('sepa_file');
const student_file = searchParams.get('student_file');
const [classes, setClasses] = useState([]);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
const { showNotification } = useNotification();
const requestErrorHandler = (err) => {
logger.error('Error fetching data:', err);
};
useEffect(() => {
if (selectedEstablishmentId) {
setIsLoading(true);
fetchClasses(selectedEstablishmentId)
.then((classesData) => {
logger.debug('Classes récupérées :', classesData);
// Filtrer les classes par niveau
const filteredClasses = classesData.filter(
(classe) => classe.levels.includes(parseInt(level, 10)) // Vérifier si le niveau de l'étudiant est dans les niveaux de la classe
);
setClasses(filteredClasses); // Mettre à jour les classes filtrées
setIsLoading(false);
})
.catch(requestErrorHandler);
}
}, [selectedEstablishmentId]);
const handleAcceptRF = (data) => {
const formData = new FormData();
formData.append('data', JSON.stringify(data));
setIsLoading(true);
// Appeler l'API pour mettre à jour le RF
editRegisterForm(studentId, formData, csrfToken)
.then((response) => {
logger.debug('RF mis à jour avec succès:', response);
showNotification(
'Le dossier d\'inscription a été validé avec succès',
'success',
'Succès'
);
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
setIsLoading(false);
})
.catch((error) => {
showNotification(
"Erreur lors de la validation du dossier d'inscription",
'error',
'Erreur'
);
setIsLoading(false);
logger.error('Erreur lors de la mise à jour du RF:', error);
});
};
if (isLoading) {
return <Loader />;
}
return (
<ValidateSubscription
studentId={studentId}
firstName={firstName}
lastName={lastName}
sepa_file={sepa_file}
student_file={student_file}
onAccept={handleAcceptRF}
classes={classes}
/>
);
}

View File

@ -1,23 +0,0 @@
'use client'
import React, { useState, useEffect } from 'react';
import Button from '@/components/Button';
import { MoreVertical, Send, Edit, Trash2, FileText, ChevronUp, UserPlus } from 'lucide-react';
import Modal from '@/components/Modal';
export default function Page() {
const [isOpen, setIsOpen] = useState(false);
const openModal = () => {
setIsOpen(true);
}
return (
<div className='p-8'>
<Button text={"addTeacher"} primary onClick={openModal} icon={<UserPlus size={20} />} />
<Modal isOpen={isOpen} setIsOpen={setIsOpen} />
</div>
);
}

View File

@ -1,5 +1,5 @@
'use client'
import {useTranslations} from 'next-intl';
'use client';
import { useTranslations } from 'next-intl';
import React from 'react';
import Button from '@/components/Button';
import Logo from '@/components/Logo'; // Import du composant Logo

View File

@ -1,113 +0,0 @@
'use client'
import React, { useState, useEffect } from 'react';
import InscriptionFormShared from '@/components/Inscription/InscriptionFormShared';
import { useSearchParams, redirect, useRouter } from 'next/navigation';
import useCsrfToken from '@/hooks/useCsrfToken';
import { FR_PARENTS_HOME_URL,
BK_GESTIONINSCRIPTION_ELEVE_URL,
BK_GESTIONINSCRIPTION_FICHEINSCRIPTION_URL,
BK_GESTIONINSCRIPTION_RECUPEREDERNIER_RESPONSABLE_URL } from '@/utils/Url';
import { mockStudent } from '@/data/mockStudent';
const useFakeData = process.env.NEXT_PUBLIC_USE_FAKE_DATA === 'true';
export default function Page() {
const searchParams = useSearchParams();
const idProfil = searchParams.get('id');
const idEleve = searchParams.get('idEleve');
const router = useRouter();
const [initialData, setInitialData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const csrfToken = useCsrfToken();
const [currentProfil, setCurrentProfil] = useState("");
const [lastIdResponsable, setLastIdResponsable] = useState(1);
useEffect(() => {
if (!idEleve || !idProfil) {
console.error('Missing idEleve or idProfil');
return;
}
if (useFakeData) {
setInitialData(mockStudent);
setLastIdResponsable(999);
setIsLoading(false);
} else {
Promise.all([
// Fetch eleve data
fetch(`${BK_GESTIONINSCRIPTION_ELEVE_URL}/${idEleve}`),
// Fetch last responsable ID
fetch(BK_GESTIONINSCRIPTION_RECUPEREDERNIER_RESPONSABLE_URL)
])
.then(async ([eleveResponse, responsableResponse]) => {
const eleveData = await eleveResponse.json();
const responsableData = await responsableResponse.json();
const formattedData = {
id: eleveData.id,
nom: eleveData.nom,
prenom: eleveData.prenom,
adresse: eleveData.adresse,
dateNaissance: eleveData.dateNaissance,
lieuNaissance: eleveData.lieuNaissance,
codePostalNaissance: eleveData.codePostalNaissance,
nationalite: eleveData.nationalite,
medecinTraitant: eleveData.medecinTraitant,
niveau: eleveData.niveau,
responsables: eleveData.responsables || []
};
setInitialData(formattedData);
setLastIdResponsable(responsableData.lastid);
let profils = eleveData.profils;
const currentProf = profils.find(profil => profil.id === idProfil);
if (currentProf) {
setCurrentProfil(currentProf);
}
})
.catch(error => {
console.error('Error fetching data:', error);
})
.finally(() => {
setIsLoading(false);
});
}
}, [idEleve, idProfil]);
const handleSubmit = async (data) => {
if (useFakeData) {
console.log('Fake submit:', data);
return;
}
try {
const response = await fetch(`${BK_GESTIONINSCRIPTION_FICHEINSCRIPTION_URL}/${idEleve}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include',
body: JSON.stringify(data),
});
const result = await response.json();
console.log('Success:', result);
router.push(FR_PARENTS_HOME_URL);
} catch (error) {
console.error('Error:', error);
}
};
return (
<InscriptionFormShared
initialData={initialData}
csrfToken={csrfToken}
onSubmit={handleSubmit}
cancelUrl={FR_PARENTS_HOME_URL}
isLoading={isLoading}
/>
);
}

View File

@ -0,0 +1,62 @@
'use client';
import React, { useState } from 'react';
import InscriptionFormShared from '@/components/Inscription/InscriptionFormShared';
import { useSearchParams, useRouter } from 'next/navigation';
import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext';
import { FE_PARENTS_HOME_URL } from '@/utils/Url';
import { editRegisterForm } from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger';
import Loader from '@/components/Loader';
import { useNotification } from '@/context/NotificationContext';
export default function Page() {
const searchParams = useSearchParams();
const { showNotification } = useNotification();
const studentId = searchParams.get('studentId');
const enable = searchParams.get('enabled') === 'true';
const router = useRouter();
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (data) => {
setIsLoading(true);
editRegisterForm(studentId, data, csrfToken)
.then((result) => {
setIsLoading(false);
logger.debug('Success:', result);
showNotification(
"Dossier d'inscription soumis avec succès",
'success',
'Succès'
);
router.push(FE_PARENTS_HOME_URL);
})
.catch((error) => {
setIsLoading(false);
showNotification(
"Erreur lors de la soumission du dossier d'inscription",
'error',
'Erreur'
);
logger.error('Error:', error);
});
};
if (isLoading === true) {
return <Loader />; // Affichez le composant Loader
}
return (
<InscriptionFormShared
studentId={studentId}
csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal = {apiDocuseal}
onSubmit={handleSubmit}
cancelUrl={FE_PARENTS_HOME_URL}
enable={enable}
/>
);
}

View File

@ -1,92 +1,123 @@
'use client'
'use client';
// src/components/Layout.js
import React, { useState, useEffect } from 'react';
import DropdownMenu from '@/components/DropdownMenu';
import { useRouter } from 'next/navigation'; // Ajout de l'importation
import { Bell, User, MessageSquare, LogOut, Settings, Home } from 'lucide-react'; // Ajout de l'importation de l'icône Home
import Logo from '@/components/Logo'; // Ajout de l'importation du composant Logo
import { FR_PARENTS_HOME_URL,FR_PARENTS_MESSAGERIE_URL,FR_PARENTS_SETTINGS_URL, BK_GESTIONINSCRIPTION_MESSAGES_URL } from '@/utils/Url'; // Ajout de l'importation de l'URL de la page d'accueil parent
import useLocalStorage from '@/hooks/useLocalStorage';
import Sidebar from '@/components/Sidebar';
import { useRouter, usePathname } from 'next/navigation';
import { MessageSquare, Settings, Home, Menu } from 'lucide-react';
import {
FE_PARENTS_HOME_URL,
FE_PARENTS_MESSAGERIE_URL
} from '@/utils/Url';
import ProtectedRoute from '@/components/ProtectedRoute';
import { disconnect } from '@/app/actions/authAction';
import Popup from '@/components/Popup';
import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext';
import Footer from '@/components/Footer';
export default function Layout({
children,
}) {
export default function Layout({ children }) {
const router = useRouter();
const pathname = usePathname();
const [isPopupVisible, setIsPopupVisible] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { clearContext } = useEstablishment();
const softwareName = 'N3WT School';
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
const router = useRouter(); // Définition de router
const [messages, setMessages] = useState([]);
const [userId, setUserId] = useLocalStorage("userId", '') ;
// Vérifier si on est sur la page messagerie
const isMessagingPage = pathname?.includes('/messagerie');
// Configuration des éléments de la sidebar pour les parents
const sidebarItems = [
{
id: 'home',
name: 'Accueil',
url: FE_PARENTS_HOME_URL,
icon: Home,
},
{
id: 'messagerie',
name: 'Messagerie',
url: FE_PARENTS_MESSAGERIE_URL,
icon: MessageSquare,
}
];
// Déterminer la page actuelle pour la sidebar
const getCurrentPage = () => {
if (pathname?.includes('/messagerie')) return 'messagerie';
if (pathname?.includes('/settings')) return 'settings';
return 'home';
};
const currentPage = getCurrentPage();
const handleDisconnect = () => {
setIsPopupVisible(true);
};
const confirmDisconnect = () => {
setIsPopupVisible(false);
disconnect();
clearContext();
};
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
useEffect(() => {
setUserId(userId);
fetch(`${BK_GESTIONINSCRIPTION_MESSAGES_URL}/${userId}`, {
headers: {
'Content-Type': 'application/json',
},
}).then(response => response.json())
.then(data => {
if (data) {
setMessages(data);
}
console.log('Success :', data);
})
.catch(error => {
console.error('Error fetching data:', error);
});
}, []);
// Fermer la sidebar quand on change de page sur mobile
setIsSidebarOpen(false);
}, [pathname]);
return (
<>
<div className="flex flex-col min-h-screen bg-gray-50">
{/* Entête */}
<header className="bg-white border-b border-gray-200 px-8 py-4 flex items-center justify-between fixed top-0 left-0 right-0 z-10">
<div className="flex items-center space-x-2">
<Logo className="h-8 w-8" /> {/* Utilisation du composant Logo */}
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
{/* Bouton hamburger pour mobile */}
<button
onClick={toggleSidebar}
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden"
>
<Menu size={20} />
</button>
<div className="text-xl font-semibold">Accueil</div>
</div>
<div className="flex items-center space-x-4">
<button
className="p-2 rounded-full hover:bg-gray-200"
onClick={() => { router.push(FR_PARENTS_HOME_URL); }} // Utilisation de router pour revenir à l'accueil parent
>
<Home />
</button>
<div className="relative">
<button
className="p-2 rounded-full hover:bg-gray-200"
onClick={() => { router.push(FR_PARENTS_MESSAGERIE_URL); }} // Utilisation de router
>
<MessageSquare />
</button>
{messages.length > 0 && (
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-emerald-600"></span>
)}
</div>
<DropdownMenu
buttonContent={<User />}
items={[
{ label: 'Se déconnecter', icon: LogOut, onClick: () => {} },
{ label: 'Settings', icon: Settings , onClick: () => { router.push(FR_PARENTS_SETTINGS_URL); } }
]}
buttonClassName="p-2 rounded-full hover:bg-gray-200"
menuClassName="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg"
/>
</div>
</header>
{/* Content */}
<div className="pt-20 p-8 flex-1"> {/* Ajout de flex-1 pour utiliser toute la hauteur disponible */}
{children}
</div>
{/* Sidebar */}
<div
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
isSidebarOpen ? 'block' : 'hidden md:block'
}`}
>
<Sidebar
currentPage={currentPage}
items={sidebarItems}
onCloseMobile={toggleSidebar}
/>
</div>
</>
{/* Overlay for mobile */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-20 md:hidden"
onClick={toggleSidebar}
/>
)}
{/* Main container */}
<div
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
>
{children}
</div>
{/* Footer */}
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
<Popup
isOpen={isPopupVisible}
message="Êtes-vous sûr(e) de vouloir vous déconnecter ?"
onConfirm={confirmDisconnect}
onCancel={() => setIsPopupVisible(false)}
/>
</ProtectedRoute>
);
}

View File

@ -1,106 +1,28 @@
'use client'
import React, { useState, useRef, useEffect } from 'react';
import { SendHorizontal } from 'lucide-react';
const contacts = [
{ id: 1, name: 'Facturation', profilePic: 'https://i.pravatar.cc/32' },
{ id: 2, name: 'Enseignant 1', profilePic: 'https://i.pravatar.cc/32' },
{ id: 3, name: 'Contact', profilePic: 'https://i.pravatar.cc/32' },
];
'use client';
import React from 'react';
import InstantChat from '@/components/Chat/InstantChat';
import { useEstablishment } from '@/context/EstablishmentContext';
export default function MessageriePage() {
const [selectedContact, setSelectedContact] = useState(null);
const [messages, setMessages] = useState({});
const [newMessage, setNewMessage] = useState('');
const messagesEndRef = useRef(null);
const { user, selectedEstablishmentId } = useEstablishment();
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSendMessage = () => {
if (newMessage.trim() && selectedContact) {
const contactMessages = messages[selectedContact.id] || [];
setMessages({
...messages,
[selectedContact.id]: [...contactMessages, { id: contactMessages.length + 1, text: newMessage, date: new Date() }],
});
setNewMessage('');
simulateContactResponse(selectedContact.id);
}
};
const handleKeyPress = (event) => {
if (event.key === 'Enter') {
handleSendMessage();
}
};
const simulateContactResponse = (contactId) => {
setTimeout(() => {
setMessages((prevMessages) => {
const contactMessages = prevMessages[contactId] || [];
return {
...prevMessages,
[contactId]: [...contactMessages, { id: contactMessages.length + 2, text: 'Réponse automatique', isResponse: true, date: new Date() }],
};
});
}, 2000);
};
if (!user?.user_id || !selectedEstablishmentId) {
return (
<div className="flex items-center justify-center h-full w-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement de la messagerie...</p>
</div>
</div>
);
}
return (
<div className="flex" style={{ height: 'calc(100vh - 128px )' }}> {/* Utilisation de calc pour soustraire la hauteur de l'entête */}
<div className="w-1/4 border-r border-gray-200 p-4 overflow-y-auto h-full ">
{contacts.map((contact) => (
<div
key={contact.id}
className={`p-2 cursor-pointer ${selectedContact?.id === contact.id ? 'bg-gray-200' : ''}`}
onClick={() => setSelectedContact(contact)}
>
<img src={contact.profilePic} alt={`${contact.name}'s profile`} className="w-8 h-8 rounded-full inline-block mr-2" />
{contact.name}
</div>
))}
</div>
<div className="flex-1 flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-4 h-full">
{selectedContact && (messages[selectedContact.id] || []).map((message) => (
<div
key={message.id}
className={`mb-2 p-2 rounded max-w-xs ${message.isResponse ? 'bg-gray-200 justify-self-end' : 'bg-emerald-200 justify-self-start'}`}
style={{ borderRadius: message.isResponse ? '20px 20px 0 20px' : '20px 20px 20px 0', minWidth: '25%' }}
>
<div className="flex items-center mb-1">
<img src={selectedContact.profilePic} alt={`${selectedContact.name}'s profile`} className="w-8 h-8 rounded-full inline-block mr-2" />
<span className="text-xs text-gray-600">{selectedContact.name}</span>
<span className="text-xs text-gray-400 ml-2">{new Date(message.date).toLocaleTimeString()}</span>
</div>
{message.text}
</div>
))}
<div ref={messagesEndRef} />
</div>
<div className="p-4 border-t border-gray-200 flex">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
className="w-full p-2 border border-gray-300 rounded"
placeholder="Écrire un message..."
onKeyDown={handleKeyPress}
/>
<button
onClick={handleSendMessage}
className="p-2 bg-emerald-500 text-white rounded mr-2"
>
<SendHorizontal />
</button>
</div>
</div>
<div className="h-full flex flex-col">
<InstantChat
userProfileId={user.user_id}
establishmentId={selectedEstablishmentId}
/>
</div>
);
}

View File

@ -1,90 +1,356 @@
'use client'
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Table from '@/components/Table';
import { Edit } from 'lucide-react';
import StatusLabel from '@/components/StatusLabel';
import useLocalStorage from '@/hooks/useLocalStorage';
import { BK_GESTIONINSCRIPTION_ENFANTS_URL , FR_PARENTS_EDIT_INSCRIPTION_URL } from '@/utils/Url';
import {
Edit3,
Users,
Download,
Eye,
Upload,
CalendarDays,
} from 'lucide-react';
import StatusLabel from '@/components/StatusLabel';
import FileUpload from '@/components/FileUpload';
import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
import {
fetchChildren,
editRegisterForm,
} from '@/app/actions/subscriptionAction';
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useCsrfToken } from '@/context/CsrfContext';
import { useClasses } from '@/context/ClassesContext';
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
import SectionHeader from '@/components/SectionHeader';
import ParentPlanningSection from '@/components/ParentPlanningSection';
import EventCard from '@/components/EventCard';
export default function ParentHomePage() {
const [actions, setActions] = useState([]);
const [children, setChildren] = useState([]);
const [userId, setUserId] = useLocalStorage("userId", '') ;
const { user, selectedEstablishmentId } = useEstablishment();
const [uploadingStudentId, setUploadingStudentId] = useState(null); // ID de l'étudiant pour l'upload
const [uploadedFile, setUploadedFile] = useState(null); // Fichier uploadé
const [uploadState, setUploadState] = useState('off'); // État "on" ou "off" pour l'affichage du composant
const [showPlanning, setShowPlanning] = useState(false);
const [planningClassName, setPlanningClassName] = useState(null);
const [upcomingEvents, setUpcomingEvents] = useState([]);
const router = useRouter();
const csrfToken = useCsrfToken();
const [reloadFetch, setReloadFetch] = useState(false);
const { getNiveauLabel } = useClasses();
useEffect(() => {
if (!userId) return;
if (user !== null) {
const userIdFromSession = user.user_id;
fetchChildren(userIdFromSession, selectedEstablishmentId).then((data) => {
setChildren(data);
});
setReloadFetch(false);
}
}, [selectedEstablishmentId, reloadFetch, user]);
const fetchActions = async () => {
const response = await fetch('/api/actions');
const data = await response.json();
setActions(data);
};
useEffect(() => {
if (selectedEstablishmentId) {
// Fetch des événements à venir
fetchUpcomingEvents(selectedEstablishmentId)
.then((data) => {
setUpcomingEvents(data);
})
.catch((error) => {
logger.error('Error fetching upcoming events:', error);
});
}
}, [selectedEstablishmentId]);
const fetchEleves = async () => {
const response = await fetch(`${BK_GESTIONINSCRIPTION_ENFANTS_URL}/${userId}`);
const data = await response.json();
console.log(data);
setChildren(data);
};
fetchEleves();
}, [userId]);
function handleView(eleveId) {
logger.debug(`View dossier for student id: ${eleveId}`);
router.push(
`${FE_PARENTS_EDIT_SUBSCRIPTION_URL}?studentId=${eleveId}&enabled=false`
);
}
function handleEdit(eleveId) {
// Logique pour éditer le dossier de l'élève
console.log(`Edit dossier for eleve id: ${eleveId}`);
router.push(`${FR_PARENTS_EDIT_INSCRIPTION_URL}?id=${userId}&idEleve=${eleveId}`);
logger.debug(`Edit dossier for student id: ${eleveId}`);
router.push(
`${FE_PARENTS_EDIT_SUBSCRIPTION_URL}?studentId=${eleveId}&enabled=true`
);
}
const actionColumns = [
{ name: 'Action', transform: (row) => row.action },
];
const getShadowColor = (etat) => {
switch (etat) {
case 1:
return 'shadow-blue-500'; // Couleur d'ombre plus visible
case 2:
return 'shadow-orange-500'; // Couleur d'ombre plus visible
case 3:
return 'shadow-purple-500'; // Couleur d'ombre plus visible
case 4:
return 'shadow-red-500'; // Couleur d'ombre plus visible
case 5:
return 'shadow-green-500'; // Couleur d'ombre plus visible
case 6:
return 'shadow-red-500'; // Couleur d'ombre plus visible
default:
return 'shadow-green-500'; // Couleur d'ombre plus visible
const handleFileUpload = (file) => {
if (!file) {
logger.error("Aucun fichier sélectionné pour l'upload.");
return;
}
setUploadedFile(file); // Conserve le fichier en mémoire
logger.debug('Fichier sélectionné :', file.name);
};
const handleSubmit = () => {
if (!uploadedFile || !uploadingStudentId) {
logger.error('Aucun fichier ou étudiant sélectionné.');
return;
}
const jsonData = {
status: 3,
};
const formData = new FormData();
formData.append('data', JSON.stringify(jsonData));
formData.append('sepa_file', uploadedFile); // Ajoute le fichier SEPA
editRegisterForm(uploadingStudentId, formData, csrfToken)
.then((response) => {
logger.debug('RF mis à jour avec succès:', response);
setReloadFetch(true);
setUploadState('off');
})
.catch((error) => {
logger.error('Erreur lors de la mise à jour du RF:', error);
});
};
const toggleUpload = (studentId) => {
if (uploadingStudentId === studentId && uploadState === 'on') {
// Si le composant est déjà affiché pour cet étudiant, on le masque
setUploadState('off');
setUploadingStudentId(null);
setUploadedFile(null); // Réinitialise le fichier
} else {
// Sinon, on l'affiche pour cet étudiant
setUploadState('on');
setUploadingStudentId(studentId);
}
};
return (
<div>
<div>
<h2 className="text-xl font-semibold mb-4">Dernières actions à effectuer</h2>
<Table data={actions} columns={actionColumns} itemsPerPage={5} />
</div>
const showClassPlanning = (student) => {
setPlanningClassName(
`${student.associated_class_name} - ${getNiveauLabel(student.level)}`
);
setShowPlanning(true);
};
<div>
<h2 className="text-xl font-semibold mb-4">Enfants</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{children.map((child) => (
<div key={child.eleve.id} className={`border p-4 rounded shadow ${getShadowColor(child.etat)}`}>
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">{child.eleve.nom} {child.eleve.prenom}</h3>
<Edit className="cursor-pointer" onClick={() => handleEdit(child.eleve.id)} />
</div>
<StatusLabel etat={child.etat } showDropdown={false}/>
const childrenColumns = [
{
name: 'photo',
transform: (row) => (
<div className="flex justify-center items-center">
{row.student.photo ? (
<a
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.student.photo}`}
alt={`${row.student.first_name} ${row.student.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>
</a>
) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
<span className="text-gray-500 text-sm font-semibold">
{row.student.first_name[0]}
{row.student.last_name[0]}
</span>
</div>
))}
)}
</div>
</div>
),
},
{ name: 'Nom', transform: (row) => `${row.student.last_name}` },
{ name: 'Prénom', transform: (row) => `${row.student.first_name}` },
{
name: 'Classe',
transform: (row) => (
<div className="text-center">{row.student.associated_class_name}</div>
),
},
{
name: 'Niveau',
transform: (row) => (
<div className="text-center">{getNiveauLabel(row.student.level)}</div>
),
},
{
name: 'Statut',
transform: (row) => (
<div className="flex justify-center items-center">
<StatusLabel status={row.status} showDropdown={false} parent />
</div>
),
},
{
name: 'Actions',
transform: (row) => (
<div className="flex justify-center items-center gap-2">
{row.status === 2 && (
<button
className="text-blue-500 hover:text-blue-700"
onClick={(e) => {
e.stopPropagation();
handleEdit(row.student.id);
}}
aria-label="Remplir le dossier"
>
<Edit3 className="h-5 w-5" />
</button>
)}
{(row.status === 3 || row.status === 8) && (
<button
className="text-purple-500 hover:text-purple-700"
onClick={(e) => {
e.stopPropagation();
handleView(row.student.id);
}}
aria-label="Visualiser le dossier"
>
<Eye className="h-5 w-5" />
</button>
)}
{row.status === 7 && (
<>
<button
className="flex items-center justify-center w-8 h-8 rounded-full text-purple-500 hover:text-purple-700"
onClick={(e) => {
e.stopPropagation();
handleView(row.student.id);
}}
aria-label="Visualiser le dossier"
>
<Eye className="h-5 w-5" />
</button>
<a
href={`${BASE_URL}${row.sepa_file}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700"
aria-label="Télécharger le mandat SEPA"
>
<Download className="h-5 w-5" />
</a>
<button
className={`flex items-center justify-center w-8 h-8 rounded-full ${
uploadingStudentId === row.student.id && uploadState === 'on'
? 'bg-blue-100 text-blue-600 ring-3 ring-blue-500'
: 'text-blue-500 hover:text-blue-700'
}`}
onClick={(e) => {
e.stopPropagation();
toggleUpload(row.student.id);
}}
aria-label="Uploader un fichier"
>
<Upload className="h-5 w-5" />
</button>
</>
)}
{row.status === 5 && (
<>
<button
className="text-purple-500 hover:text-purple-700"
onClick={(e) => {
e.stopPropagation();
handleView(row.student.id);
}}
aria-label="Visualiser le dossier"
>
<Eye className="h-5 w-5" />
</button>
<button
className="text-emerald-500 hover:text-emerald-700 ml-1"
onClick={(e) => {
e.stopPropagation();
showClassPlanning(row.student);
}}
aria-label="Voir le planning de la classe"
>
<CalendarDays className="h-5 w-5" />
</button>
</>
)}
</div>
),
},
];
return (
<div className="w-full h-full">
{showPlanning && planningClassName ? (
// Affichage grand format mais respectant la sidebar
<>
<div className="p-4 flex items-center border-b">
<button
className="text-emerald-600 hover:text-emerald-800 font-semibold flex items-center"
onClick={() => setShowPlanning(false)}
>
Retour
</button>
</div>
<div className="flex-1 flex overflow-hidden">
<PlanningProvider
establishmentId={selectedEstablishmentId}
modeSet={PlanningModes.CLASS_SCHEDULE}
readOnly={true}
>
<ParentPlanningSection planningClassName={planningClassName} />
</PlanningProvider>
</div>
</>
) : (
// Affichage classique avec le tableau des enfants
<div>
{/* Section des événements à venir */}
{upcomingEvents.length > 0 && (
<div className="mb-6">
<SectionHeader
icon={CalendarDays}
title="Événements à venir"
description="Prochains événements de l'établissement"
/>
<div className="bg-stone-50 p-4 rounded-lg shadow-sm border border-gray-100">
{upcomingEvents.slice(0, 3).map((event, index) => (
<EventCard key={index} {...event} />
))}
</div>
</div>
)}
<SectionHeader
icon={Users}
title="Vos enfants"
description="Suivez le parcours de vos enfants"
/>
<div className="overflow-x-auto">
<Table data={children} columns={childrenColumns} />
</div>
{/* Composant FileUpload et bouton Valider en dessous du tableau */}
{uploadState === 'on' && uploadingStudentId && (
<div className="mt-4">
<FileUpload
selectionMessage="Sélectionnez un fichier à uploader"
onFileSelect={handleFileUpload}
/>
<button
className={`mt-4 px-6 py-2 rounded-md ${
uploadedFile
? 'bg-emerald-500 text-white hover:bg-emerald-600'
: 'bg-gray-300 text-gray-700 cursor-not-allowed'
}`}
onClick={handleSubmit}
disabled={!uploadedFile}
>
Valider
</button>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -1,12 +1,15 @@
'use client'
'use client';
import React, { useState } from 'react';
import Button from '@/components/Button';
import InputText from '@/components/InputText';
import logger from '@/utils/logger';
import { useNotification } from '@/context/NotificationContext';
export default function SettingsPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const { showNotification } = useNotification();
const handleEmailChange = (e) => {
setEmail(e.target.value);
@ -23,12 +26,16 @@ export default function SettingsPage() {
const handleSubmit = (e) => {
e.preventDefault();
if (password !== confirmPassword) {
alert('Les mots de passe ne correspondent pas');
showNotification(
'Les mots de passe ne correspondent pas',
'error',
'Erreur'
);
return;
}
// Logique pour mettre à jour l'email et le mot de passe
console.log('Email:', email);
console.log('Password:', password);
logger.debug('Email:', email);
logger.debug('Password:', password);
};
return (
@ -60,13 +67,7 @@ export default function SettingsPage() {
required
/>
<div className="flex items-center justify-between">
<Button
type="submit"
primary
text={" Mettre à jour"}
/>
<Button type="submit" primary text={' Mettre à jour'} />
</div>
</form>
</div>

View File

@ -0,0 +1,5 @@
'use client';
function ErrorBoundary({ error }) {
return <>{error.message}</>;
}

View File

@ -1,135 +1,146 @@
'use client'
import React, { useState, useEffect } from 'react'
import DjangoCSRFToken from '@/components/DjangoCSRFToken'
'use client';
import React, { useState } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo';
import { useSearchParams, useRouter } from 'next/navigation'
import { useRouter } from 'next/navigation';
import InputTextIcon from '@/components/InputTextIcon';
import Loader from '@/components/Loader'; // Importez le composant Loader
import Button from '@/components/Button'; // Importez le composant Button
import { User, KeySquare } from 'lucide-react'; // Importez directement les icônes nécessaires
import { BK_LOGIN_URL, FR_ADMIN_SUBSCRIPTIONS_EDIT_URL, FR_ADMIN_SUBSCRIPTIONS_URL, FR_PARENTS_HOME_URL, FR_USERS_NEW_PASSWORD_URL, FR_USERS_SUBSCRIBE_URL } from '@/utils/Url';
import useLocalStorage from '@/hooks/useLocalStorage';
import useCsrfToken from '@/hooks/useCsrfToken';
const useFakeData = process.env.NEXT_PUBLIC_USE_FAKE_DATA === 'true';
import { FE_USERS_NEW_PASSWORD_URL, getRedirectUrlFromRole } from '@/utils/Url';
import { login } from '@/app/actions/authAction';
import { getSession } from 'next-auth/react';
import { useCsrfToken } from '@/context/CsrfContext'; // Importez le hook useCsrfToken
import logger from '@/utils/logger';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useNotification } from '@/context/NotificationContext';
export default function Page() {
const searchParams = useSearchParams();
const [errorMessage, setErrorMessage] = useState("");
const [userFieldError,setUserFieldError] = useState("")
const [passwordFieldError,setPasswordFieldError] = useState("")
const { initializeContextWithSession } = useEstablishment();
const [isLoading, setIsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const csrfToken = useCsrfToken(); // Utilisez le hook useCsrfToken
const { showNotification } = useNotification();
const [userId, setUserId] = useLocalStorage("userId", '') ;
function handleFormLogin(formData) {
setIsLoading(true);
const router = useRouter();
const csrfToken = useCsrfToken();
login({
email: formData.get('login'),
password: formData.get('password'),
})
.then((result) => {
logger.debug('Sign In Result', result);
function isOK(data) {
return data.errorMessage === ""
}
function handleFormLogin(formData) {
if (useFakeData) {
// Simuler une réponse réussie
const data = {
errorFields: {},
errorMessage: "",
profil: "fakeProfileId"
};
setUserFieldError("")
setPasswordFieldError("")
setErrorMessage("")
if(isOK(data)){
localStorage.setItem('userId', data.profil); // Stocker l'identifiant de l'utilisateur
router.push(`${FR_ADMIN_SUBSCRIPTIONS_EDIT_URL}?id=${data.profil}`);
} else {
if(data.errorFields){
setUserFieldError(data.errorFields.email)
setPasswordFieldError(data.errorFields.password);
}
if(data.errorMessage){
setErrorMessage(data.errorMessage)
}
}
if (result.error) {
showNotification(
result.error,
'error',
'Erreur'
);
setIsLoading(false);
} else {
const request = new Request(
`${BK_LOGIN_URL}`,
{
method:'POST',
headers: {
'Content-Type':'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify( {
email: formData.get('login'),
password: formData.get('password'),
}),
credentials: 'include',
}
);
fetch(request).then(response => response.json())
.then(data => {
console.log('Success:', data);
setUserFieldError("")
setPasswordFieldError("")
setErrorMessage("")
if(isOK(data)){
localStorage.setItem('userId', data.profil); // Stocker l'identifiant de l'utilisateur
if (data.droit == 0) {
// Vue ECOLE
} else if (data.droit == 1) {
// Vue ADMIN
router.push(`${FR_ADMIN_SUBSCRIPTIONS_URL}`);
} else if (data.droit == 2) {
// Vue PARENT
router.push(`${FR_PARENTS_HOME_URL}`);
} else {
// Cas anormal
}
// On initialise le contexte establishement avec la session
getSession()
.then((session) => {
initializeContextWithSession(session, (role) => {
const url = getRedirectUrlFromRole(role);
if (url) {
router.push(url);
} else {
if(data.errorFields){
setUserFieldError(data.errorFields.email)
setPasswordFieldError(data.errorFields.password);
}
if(data.errorMessage){
setErrorMessage(data.errorMessage)
}
showNotification(
'Type de rôle non géré',
'error',
'Erreur'
);
}
});
setIsLoading(false);
})
.catch(error => {
console.error('Error fetching data:', error);
error = error.message;
console.log(error);
.catch((error) => {
logger.error(
'Erreur lors de la récupération de la session:',
error
);
setIsLoading(false);
showNotification(
'Une erreur est survenue lors de la récupération de la session.',
'error',
'Erreur'
);
});
}
}
})
.catch((error) => {
logger.error('Erreur lors de la connexion:', error);
setIsLoading(false);
showNotification(
'Une erreur est survenue lors de la connexion.',
'error',
'Erreur'
);
});
}
if (isLoading === true) {
return <Loader /> // Affichez le composant Loader
} else {
return <>
<div className="container max mx-auto p-4">
<div className="flex justify-center mb-4">
<Logo className="h-150 w-150" />
</div>
<h1 className="text-2xl text-emerald-900 font-bold text-center mb-4">Authentification</h1>
<form className="max-w-md mx-auto" onSubmit={(e) => { e.preventDefault(); handleFormLogin(new FormData(e.target)); }}>
<DjangoCSRFToken csrfToken={csrfToken} />
<InputTextIcon name="login" type="text" IconItem={User} label="Identifiant" placeholder="Identifiant" errorMsg={userFieldError} className="w-full" />
<InputTextIcon name="password" type="password" IconItem={KeySquare} label="Mot de passe" placeholder="Mot de passe" errorMsg={passwordFieldError} className="w-full" />
<div className="input-group mb-4">
</div>
<label className="text-red-500">{errorMessage}</label>
<label><a className="float-right text-emerald-900" href={`${FR_USERS_NEW_PASSWORD_URL}`}>Mot de passe oublié ?</a></label>
<div className="form-group-submit mt-4">
<Button text="Se Connecter" className="w-full" primary type="submit" name="connect" />
</div>
</form>
if (isLoading === true) {
return <Loader />; // Affichez le composant Loader
} else {
return (
<>
<div className="container max mx-auto p-4">
<div className="flex justify-center mb-4">
<Logo className="h-150 w-150" />
</div>
<h1 className="text-2xl font-bold text-center mb-4">
Authentification
</h1>
<form
className="max-w-md mx-auto"
onSubmit={(e) => {
e.preventDefault();
handleFormLogin(new FormData(e.target));
}}
>
<DjangoCSRFToken csrfToken={csrfToken} />
<InputTextIcon
name="login"
type="text"
IconItem={User}
label="Identifiant"
placeholder="Identifiant"
className="w-full mb-5"
/>
<InputTextIcon
name="password"
type="password"
IconItem={KeySquare}
label="Mot de passe"
placeholder="Mot de passe"
className="w-full mb-5"
/>
<div className="input-group mb-4"></div>
<label>
<a
className="float-right mb-4"
href={`${FE_USERS_NEW_PASSWORD_URL}`}
>
Mot de passe oublié ?
</a>
</label>
<div className="form-group-submit mt-4">
<Button
text="Se Connecter"
className="w-full"
primary
type="submit"
name="connect"
/>
</div>
</>
}
};
</form>
</div>
</>
);
}
}

View File

@ -1,107 +1,109 @@
'use client'
'use client';
import React, { useState, useEffect } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'
import React, { useState } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo';
import { useSearchParams, useRouter } from 'next/navigation';
import InputTextIcon from '@/components/InputTextIcon';
import Loader from '@/components/Loader'; // Importez le composant Loader
import Button from '@/components/Button'; // Importez le composant Button
import Popup from '@/components/Popup'; // Importez le composant Popup
import { User } from 'lucide-react'; // Importez directement les icônes nécessaires
import { BK_NEW_PASSWORD_URL,FR_USERS_LOGIN_URL } from '@/utils/Url';
import useCsrfToken from '@/hooks/useCsrfToken';
const useFakeData = process.env.NEXT_PUBLIC_USE_FAKE_DATA === 'true';
import Loader from '@/components/Loader';
import Button from '@/components/Button';
import { User } from 'lucide-react';
import { FE_USERS_LOGIN_URL } from '@/utils/Url';
import { useCsrfToken } from '@/context/CsrfContext';
import { sendNewPassword } from '@/app/actions/authAction';
import logger from '@/utils/logger';
import { useNotification } from '@/context/NotificationContext';
export default function Page() {
const searchParams = useSearchParams();
const [errorMessage, setErrorMessage] = useState("");
const [userFieldError, setUserFieldError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [popupVisible, setPopupVisible] = useState(false);
const [popupMessage, setPopupMessage] = useState("");
const [popupConfirmAction, setPopupConfirmAction] = useState(null);
const csrfToken = useCsrfToken();
const { showNotification } = useNotification();
const [isLoading, setIsLoading] = useState(false);
const csrfToken = useCsrfToken();
function validate(formData) {
if (useFakeData) {
setTimeout(() => {
setUserFieldError("");
setErrorMessage("");
setPopupMessage("Mot de passe réinitialisé avec succès !");
setPopupConfirmAction(() => () => setPopupVisible(false));
setPopupVisible(true);
}, 1000); // Simule un délai de traitement
function validate(formData) {
const data = { email: formData.get('email') };
setIsLoading(true);
sendNewPassword(data, csrfToken)
.then((data) => {
logger.debug('Success:', data);
if (data.message !== '') {
showNotification(
data.message,
'success',
'Succès'
);
router.push(`${FE_USERS_LOGIN_URL}`);
} else {
const request = new Request(
`${BK_NEW_PASSWORD_URL}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include',
body: JSON.stringify({
email: formData.get('email')
}),
}
if (data.errorMessage) {
showNotification(
data.errorMessage,
'error',
'Erreur'
);
fetch(request).then(response => response.json())
.then(data => {
console.log('Success:', data);
setUserFieldError("");
setErrorMessage("");
if (data.errorMessage === "") {
setPopupMessage(data.message);
setPopupConfirmAction(() => () => setPopupVisible(false));
setPopupVisible(true);
} else {
if (data.errorFields) {
setUserFieldError(data.errorFields.email);
}
if (data.errorMessage) {
setErrorMessage(data.errorMessage);
}
}
})
.catch(error => {
console.error('Error fetching data:', error);
error = error.errorMessage;
console.log(error);
});
} else if (data.errorFields) {
showNotification(
data.errorFields.email,
'error',
'Erreur'
);
}
}
}
setIsLoading(false);
})
.catch((error) => {
logger.error('Error fetching data:', error);
setIsLoading(false);
error = error.errorMessage;
logger.debug(error);
});
}
if (isLoading === true) {
return <Loader /> // Affichez le composant Loader
} else {
return <>
<div className="container max mx-auto p-4">
<div className="flex justify-center mb-4">
<Logo className="h-150 w-150" />
</div>
<h1 className="text-2xl font-bold text-center mb-4">Nouveau Mot de passe</h1>
<form className="max-w-md mx-auto" onSubmit={(e) => { e.preventDefault(); validate(new FormData(e.target)); }}>
<DjangoCSRFToken csrfToken={csrfToken} />
<InputTextIcon name="email" type="text" IconItem={User} label="Identifiant" placeholder="Identifiant" errorMsg={userFieldError} className="w-full" />
<p className="text-red-500">{errorMessage}</p>
<div className="form-group-submit mt-4">
<Button text="Réinitialiser" className="w-full" primary type="submit" name="validate" />
</div>
</form>
<br />
<div className='flex justify-center mt-2 max-w-md mx-auto'>
<Button text="Annuler" className="w-full" href={ `${FR_USERS_LOGIN_URL}`} />
</div>
</div>
<Popup
visible={popupVisible}
message={popupMessage}
onConfirm={popupConfirmAction}
onCancel={() => setPopupVisible(false)}
if (isLoading === true) {
return <Loader />;
} else {
return (
<>
<div className="container max mx-auto p-4">
<div className="flex justify-center mb-4">
<Logo className="h-150 w-150" />
</div>
<h1 className="text-2xl font-bold text-center mb-4">
Nouveau Mot de passe
</h1>
<form
className="max-w-md mx-auto"
onSubmit={(e) => {
e.preventDefault();
validate(new FormData(e.target));
}}
>
<DjangoCSRFToken csrfToken={csrfToken} />
<InputTextIcon
name="email"
type="text"
IconItem={User}
label="Identifiant"
placeholder="Identifiant"
className="w-full"
/>
</>
}
}
<div className="form-group-submit mt-4">
<Button
text="Réinitialiser"
className="w-full"
primary
type="submit"
name="validate"
/>
</div>
</form>
<br />
<div className="flex justify-center mt-2 max-w-md mx-auto">
<Button
text="Annuler"
className="w-full"
href={`${FE_USERS_LOGIN_URL}`}
/>
</div>
</div>
</>
);
}
}

View File

@ -1,144 +1,127 @@
'use client'
'use client';
// src/app/pages/subscribe.js
import React, { useState, useEffect } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo';
import { useSearchParams, useRouter } from 'next/navigation'
import { useSearchParams, useRouter } from 'next/navigation';
import InputTextIcon from '@/components/InputTextIcon';
import Loader from '@/components/Loader'; // Importez le composant Loader
import Button from '@/components/Button'; // Importez le composant Button
import Popup from '@/components/Popup';
import { BK_RESET_PASSWORD_URL, FR_USERS_LOGIN_URL } from '@/utils/Url';
import { KeySquare } from 'lucide-react'; // Importez directement les icônes nécessaires
import useCsrfToken from '@/hooks/useCsrfToken';
const useFakeData = process.env.NEXT_PUBLIC_USE_FAKE_DATA === 'true';
import Loader from '@/components/Loader';
import Button from '@/components/Button';
import { FE_USERS_LOGIN_URL } from '@/utils/Url';
import { KeySquare } from 'lucide-react';
import { useCsrfToken } from '@/context/CsrfContext';
import { resetPassword } from '@/app/actions/authAction';
import logger from '@/utils/logger';
import { useNotification } from '@/context/NotificationContext';
export default function Page() {
const searchParams = useSearchParams();
const uuid = searchParams.get('uuid');
const [errorMessage, setErrorMessage] = useState("");
const [password1FieldError,setPassword1FieldError] = useState("")
const [password2FieldError,setPassword2FieldError] = useState("")
const [isLoading, setIsLoading] = useState(true);
const [popupVisible, setPopupVisible] = useState(false);
const [popupMessage, setPopupMessage] = useState("");
const searchParams = useSearchParams();
const { showNotification } = useNotification();
const uuid = searchParams.get('uuid');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const csrfToken = useCsrfToken();
const router = useRouter();
const csrfToken = useCsrfToken();
useEffect(() => {
if (useFakeData) {
setTimeout(() => {
setIsLoading(false);
}, 1000);
} else {
const url= `${BK_RESET_PASSWORD_URL}/${uuid}`;
fetch(url, {
headers: {
'Content-Type': 'application/json',
},
}).then(response => response.json())
.then(data => {
console.log('Success:', data);
setIsLoading(true);
if(data.errorFields){
setPassword1FieldError(data.errorFields.password1)
setPassword2FieldError(data.errorFields.password2)
}
if(data.errorMessage){
setErrorMessage(data.errorMessage)
}
setIsLoading(false);
})
.catch(error => {
console.error('Error fetching data:', error);
});
}
}, []);
function validate(formData) {
const data = {
password1: formData.get('password1'),
password2: formData.get('password2'),
};
setIsLoading(true);
resetPassword(uuid, data, csrfToken)
.then((data) => {
if (data.message !== '') {
function validate(formData) {
if (useFakeData) {
setTimeout(() => {
setPopupMessage("Mot de passe réinitialisé avec succès");
setPopupVisible(true);
}, 1000);
} else {
const request = new Request(
`${BK_RESET_PASSWORD_URL}/${uuid}`,
{
method:'POST',
headers: {
'Content-Type':'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include',
body: JSON.stringify( {
password1: formData.get('password1'),
password2: formData.get('password2'),
}),
}
logger.debug('Success:', data);
showNotification(
data.message,
'success',
'Succès'
);
fetch(request).then(response => response.json())
.then(data => {
console.log('Success:', data);
setPassword1FieldError("")
setPassword2FieldError("")
setErrorMessage("")
if(data.errorMessage === ""){
setPopupMessage(data.message);
setPopupVisible(true);
} else {
if(data.errorMessage){
setErrorMessage(data.errorMessage);
}
if(data.errorFields){
setPassword1FieldError(data.errorFields.password1)
setPassword2FieldError(data.errorFields.password2)
}
}
})
.catch(error => {
console.error('Error fetching data:', error);
error = error.errorMessage;
console.log(error);
});
router.push(`${FE_USERS_LOGIN_URL}`);
} else {
if (data.errorMessage) {
showNotification(
data.errorMessage,
'error',
'Erreur'
);
} else if (data.errorFields) {
showNotification(
data.errorFields.password1 || data.errorFields.password2,
'error',
'Erreur'
);
}
}
}
setIsLoading(false);
})
.catch((error) => {
logger.error('Error fetching data:', error);
error = error.errorMessage;
logger.debug(error);
setIsLoading(false);
});
}
if (isLoading === true) {
return <Loader /> // Affichez le composant Loader
} else {
return <>
<Popup
visible={popupVisible}
message={popupMessage}
onConfirm={() => {
setPopupVisible(false);
router.push(`${FR_USERS_LOGIN_URL}`);
}}
onCancel={() => setPopupVisible(false)}
if (isLoading === true) {
return <Loader />;
} else {
return (
<>
<div className="container max mx-auto p-4">
<div className="flex justify-center mb-4">
<Logo className="h-150 w-150" />
</div>
<h1 className="text-2xl font-bold text-center mb-4">
Réinitialisation du mot de passe
</h1>
<form
className="max-w-md mx-auto"
onSubmit={(e) => {
e.preventDefault();
validate(new FormData(e.target));
}}
>
<DjangoCSRFToken csrfToken={csrfToken} />
<InputTextIcon
name="password1"
type="password"
IconItem={KeySquare}
label="Mot de passe"
placeholder="Mot de passe"
className="w-full mb-5"
/>
<div className="container max mx-auto p-4">
<div className="flex justify-center mb-4">
<Logo className="h-150 w-150" />
</div>
<h1 className="text-2xl font-bold text-center mb-4">Réinitialisation du mot de passe</h1>
<form className="max-w-md mx-auto" onSubmit={(e) => { e.preventDefault(); validate(new FormData(e.target)); }}>
<DjangoCSRFToken csrfToken={csrfToken} />
<InputTextIcon name="password1" type="password" IconItem={KeySquare} label="Mot de passe" placeholder="Mot de passe" errorMsg={password1FieldError} className="w-full" />
<InputTextIcon name="password2" type="password" IconItem={KeySquare} label="Confirmation mot de passe" placeholder="Confirmation mot de passe" errorMsg={password2FieldError} className="w-full" />
<label className="text-red-500">{errorMessage}</label>
<div className="form-group-submit mt-4">
<Button text="Enregistrer" className="w-full" primary type="submit" name="validate" />
</div>
</form>
<br/>
<div className="flex justify-center mt-2 max-w-md mx-auto">
<Button text="Annuler" className="w-full" href={`${FR_USERS_LOGIN_URL}`} />
</div>
<InputTextIcon
name="password2"
type="password"
IconItem={KeySquare}
label="Confirmation mot de passe"
placeholder="Confirmation mot de passe"
className="w-full"
/>
<div className="form-group-submit mt-4">
<Button
text="Enregistrer"
className="w-full"
primary
type="submit"
name="validate"
/>
</div>
</>
}
}
</form>
<br />
<div className="flex justify-center mt-2 max-w-md mx-auto">
<Button
text="Annuler"
className="w-full"
href={`${FE_USERS_LOGIN_URL}`}
/>
</div>
</div>
</>
);
}
}

View File

@ -1,180 +1,138 @@
'use client'
// src/app/pages/subscribe.js
'use client';
import React, { useState, useEffect } from 'react';
import DjangoCSRFToken from '@/components/DjangoCSRFToken'
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Logo from '@/components/Logo';
import { useSearchParams, useRouter } from 'next/navigation'
import { useSearchParams, useRouter } from 'next/navigation';
import InputTextIcon from '@/components/InputTextIcon';
import Loader from '@/components/Loader'; // Importez le composant Loader
import Button from '@/components/Button'; // Importez le composant Button
import Popup from '@/components/Popup'; // Importez le composant Popup
import { User, KeySquare } from 'lucide-react'; // Importez directement les icônes nécessaires
import { BK_REGISTER_URL, FR_USERS_LOGIN_URL } from '@/utils/Url';
import useCsrfToken from '@/hooks/useCsrfToken';
const useFakeData = process.env.NEXT_PUBLIC_USE_FAKE_DATA === 'true';
import Loader from '@/components/Loader';
import Button from '@/components/Button';
import { User, KeySquare } from 'lucide-react';
import { FE_USERS_LOGIN_URL } from '@/utils/Url';
import { useCsrfToken } from '@/context/CsrfContext';
import { subscribe } from '@/app/actions/authAction';
import logger from '@/utils/logger';
import { useNotification } from '@/context/NotificationContext';
export default function Page() {
const searchParams = useSearchParams();
const searchParams = useSearchParams();
const { showNotification } = useNotification();
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [userFieldError,setUserFieldError] = useState("")
const [password1FieldError,setPassword1FieldError] = useState("")
const [password2FieldError,setPassword2FieldError] = useState("")
const [isLoading, setIsLoading] = useState(true);
const [popupVisible, setPopupVisible] = useState(false);
const [popupMessage, setPopupMessage] = useState("");
const router = useRouter();
const csrfToken = useCsrfToken();
const router = useRouter();
const csrfToken = useCsrfToken();
const establishment_id = searchParams.get('establishment_id');
useEffect(() => {
if (useFakeData) {
// Simuler une réponse réussie
const data = {
errorFields: {},
errorMessage: ""
};
setUserFieldError("")
setPassword1FieldError("")
setPassword2FieldError("")
setErrorMessage("")
setIsLoading(false);
function subscribeFormSubmit(formData) {
const data = {
email: formData.get('login'),
password1: formData.get('password1'),
password2: formData.get('password2'),
establishment_id: establishment_id,
};
setIsLoading(true);
subscribe(data, csrfToken)
.then((data) => {
logger.debug('Success:', data);
if (data.message !== '') {
showNotification(
data.message,
'success',
'Succès'
);
router.push(`${FE_USERS_LOGIN_URL}`);
} else {
const url= `${BK_REGISTER_URL}`;
fetch(url, {
headers: {
'Content-Type': 'application/json',
},
}).then(response => response.json())
.then(data => {
console.log('Success:', data);
setUserFieldError("")
setPassword1FieldError("")
setPassword2FieldError("")
setErrorMessage("")
setIsLoading(true);
if(data.errorFields){
setUserFieldError(data.errorFields.email)
setPassword1FieldError(data.errorFields.password1)
setPassword2FieldError(data.errorFields.password2)
}
if(data.errorMessage){
setErrorMessage(data.errorMessage)
}
setIsLoading(false);
})
.catch(error => {
console.error('Error fetching data:', error);
});
}
}, []);
function isOK(data) {
return data.errorMessage === ""
}
function suscribe(formData) {
if (useFakeData) {
// Simuler une réponse réussie
const data = {
errorFields: {},
errorMessage: ""
};
setUserFieldError("")
setPassword1FieldError("")
setPassword2FieldError("")
setErrorMessage("")
if(isOK(data)){
setPopupMessage("Votre compte a été créé avec succès");
setPopupVisible(true);
} else {
if(data.errorMessage){
setErrorMessage(data.errorMessage);
}
if(data.errorFields){
setUserFieldError(data.errorFields.email)
setPassword1FieldError(data.errorFields.password1)
setPassword2FieldError(data.errorFields.password2)
}
}
} else {
const request = new Request(
`${BK_REGISTER_URL}`,
{
method:'POST',
headers: {
'Content-Type':'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include',
body: JSON.stringify( {
email: formData.get('login'),
password1: formData.get('password1'),
password2: formData.get('password2'),
}),
}
if (data.errorMessage) {
showNotification(
data.errorMessage,
'error',
'Erreur'
);
fetch(request).then(response => response.json())
.then(data => {
console.log('Success:', data);
setUserFieldError("")
setPassword1FieldError("")
setPassword2FieldError("")
setErrorMessage("")
if(isOK(data)){
setPopupMessage(data.message);
setPopupVisible(true);
} else {
if(data.errorMessage){
setErrorMessage(data.errorMessage);
}
if(data.errorFields){
setUserFieldError(data.errorFields.email)
setPassword1FieldError(data.errorFields.password1)
setPassword2FieldError(data.errorFields.password2)
}
}
})
.catch(error => {
console.error('Error fetching data:', error);
error = error.errorMessage;
console.log(error);
});
} else if (data.errorFields) {
showNotification(
data.errorFields.email || data.errorFields.password1 || data.errorFields.password2,
'error',
'Erreur'
);
}
}
}
setIsLoading(false);
})
.catch((error) => {
setIsLoading(false);
logger.error('Error fetching data:', error);
error = error.errorMessage;
logger.debug(error);
});
}
if (isLoading === true) {
return <Loader /> // Affichez le composant Loader
} else {
return <>
<div className="container max mx-auto p-4">
<div className="flex justify-center mb-4">
<Logo className="h-150 w-150" />
</div>
<h1 className="text-2xl font-bold text-center mb-4">Nouveau profil</h1>
<form className="max-w-md mx-auto" onSubmit={(e) => { e.preventDefault(); suscribe(new FormData(e.target)); }}>
<DjangoCSRFToken csrfToken={csrfToken} />
<InputTextIcon name="login" type="text" IconItem={User} label="Identifiant" placeholder="Identifiant" errorMsg={userFieldError} className="w-full" />
<InputTextIcon name="password1" type="password" IconItem={KeySquare} label="Mot de passe" placeholder="Mot de passe" errorMsg={password1FieldError} className="w-full" />
<InputTextIcon name="password2" type="password" IconItem={KeySquare} label="Confirmation mot de passe" placeholder="Confirmation mot de passe" errorMsg={password2FieldError} className="w-full" />
<p className="text-red-500">{errorMessage}</p>
<div className="form-group-submit mt-4">
<Button text="Enregistrer" className="w-full" primary type="submit" name="validate" />
</div>
</form>
<br/>
<div className='flex justify-center mt-2 max-w-md mx-auto'><Button text="Annuler" className="w-full" onClick={()=>{router.push(`${FR_USERS_LOGIN_URL}`)}} /></div>
</div>
<Popup
visible={popupVisible}
message={popupMessage}
onConfirm={() => {
setPopupVisible(false);
router.push(`${FR_USERS_LOGIN_URL}`);
}}
onCancel={() => setPopupVisible(false)}
if (isLoading === true) {
return <Loader />;
} else {
return (
<>
<div className="container max mx-auto p-4">
<div className="flex justify-center mb-4">
<Logo className="h-150 w-150" />
</div>
<h1 className="text-2xl font-bold text-center mb-4">
Nouveau profil
</h1>
<form
className="max-w-md mx-auto"
onSubmit={(e) => {
e.preventDefault();
subscribeFormSubmit(new FormData(e.target));
}}
>
<DjangoCSRFToken csrfToken={csrfToken} />
<InputTextIcon
name="login"
type="text"
IconItem={User}
label="Identifiant"
placeholder="Identifiant"
className="w-full mb-5"
/>
</>
}
}
<InputTextIcon
name="password1"
type="password"
IconItem={KeySquare}
label="Mot de passe"
placeholder="Mot de passe"
className="w-full mb-5"
/>
<InputTextIcon
name="password2"
type="password"
IconItem={KeySquare}
label="Confirmation mot de passe"
placeholder="Confirmation mot de passe"
className="w-full"
/>
<div className="form-group-submit mt-4">
<Button
text="Enregistrer"
className="w-full"
primary
type="submit"
name="validate"
/>
</div>
</form>
<br />
<div className="flex justify-center mt-2 max-w-md mx-auto">
<Button
text="Annuler"
className="w-full"
onClick={() => {
router.push(`${FE_USERS_LOGIN_URL}`);
}}
/>
</div>
</div>
</>
);
}
}

View File

@ -0,0 +1,31 @@
import logger from '@/utils/logger';
/**
*
* @param {*} response
* @returns
*/
export const requestResponseHandler = async (response) => {
try {
const body = await response?.json();
if (response.ok) {
return body;
}
// Throw an error with the JSON body containing the form errors
const error = new Error(body?.errorMessage || 'Une erreur est survenue');
error.details = body;
error.response = response;
throw error;
} catch (error) {
logger.error('Une erreur est survenue lors du traitement de la réponse', {
error,
response,
});
throw error;
}
};
export const errorHandler = (error) => {
logger.error('Error:', { error });
// Handle the error here, e.g., show a notification
throw error;
};

View File

@ -0,0 +1,201 @@
import { signOut, signIn } from 'next-auth/react';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
import {
BE_AUTH_LOGIN_URL,
BE_AUTH_REFRESH_JWT_URL,
BE_AUTH_REGISTER_URL,
BE_AUTH_PROFILES_URL,
BE_AUTH_PROFILES_ROLES_URL,
BE_AUTH_RESET_PASSWORD_URL,
BE_AUTH_NEW_PASSWORD_URL,
FE_USERS_LOGIN_URL,
} from '@/utils/Url';
import { PARENT_FILTER } from '@/utils/constants';
/**
* Login action
*/
export const login = (data) => {
return signIn('credentials', {
redirect: false,
email: data.email,
password: data.password,
role_type: data.role_type,
});
};
/**
* Login user with API
*/
export const getJWT = (data) => {
const request = new Request(`${BE_AUTH_LOGIN_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
credentials: 'include',
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const refreshJWT = (data) => {
const request = new Request(`${BE_AUTH_REFRESH_JWT_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
credentials: 'include',
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
/**
* Disconnects the user after confirming the action.
* If `NEXT_PUBLIC_USE_FAKE_DATA` environment variable is set to 'true', it will log a fake disconnect and redirect to the login URL.
* Otherwise, it will call `signOut` from NextAuth.js to handle the logout.
*
* @function
* @name disconnect
* @returns {void}
*/
export const disconnect = () => {
signOut({ callbackUrl: FE_USERS_LOGIN_URL });
};
export const fetchProfileRoles = (
establishment,
filter = PARENT_FILTER,
page = '',
pageSize = ''
) => {
let url = `${BE_AUTH_PROFILES_ROLES_URL}?filter=${filter}&establishment_id=${establishment}`;
if (page !== '' && pageSize !== '') {
url = `${BE_AUTH_PROFILES_ROLES_URL}?filter=${filter}&establishment_id=${establishment}&page=${page}`;
}
return fetch(url, {
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const updateProfileRoles = (id, data, csrfToken) => {
const request = new Request(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(data),
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const deleteProfileRoles = async (id, csrfToken) => {
const response = await fetch(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
});
if (!response.ok) {
// Extraire le message d'erreur du backend
const errorData = await response.json();
const errorMessage =
errorData?.error ||
'Une erreur est survenue lors de la suppression du profil.';
// Jeter une erreur avec le message spécifique
throw new Error(errorMessage);
}
return response.json();
};
export const fetchProfiles = () => {
return fetch(`${BE_AUTH_PROFILES_URL}`)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const createProfile = (data, csrfToken) => {
const request = new Request(`${BE_AUTH_PROFILES_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(data),
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const deleteProfile = (id, csrfToken) => {
const request = new Request(`${BE_AUTH_PROFILES_URL}/${id}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const updateProfile = (id, data, csrfToken) => {
const request = new Request(`${BE_AUTH_PROFILES_URL}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(data),
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const sendNewPassword = (data, csrfToken) => {
const request = new Request(`${BE_AUTH_NEW_PASSWORD_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(data),
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const subscribe = (data, csrfToken) => {
const request = new Request(`${BE_AUTH_REGISTER_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(data),
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const resetPassword = (uuid, data, csrfToken) => {
const request = new Request(`${BE_AUTH_RESET_PASSWORD_URL}/${uuid}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(data),
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};

View File

@ -0,0 +1,34 @@
import {
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
BE_GESTIONEMAIL_SEND_EMAIL_URL,
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
import { getCsrfToken } from '@/utils/getCsrfToken';
// Recherche de destinataires pour email
export const searchRecipients = (establishmentId, query) => {
const url = `${BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
// Envoyer un email
export const sendEmail = async (messageData) => {
const csrfToken = getCsrfToken();
return fetch(BE_GESTIONEMAIL_SEND_EMAIL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(messageData),
})
.then(requestResponseHandler)
.catch(errorHandler);
};

View File

@ -0,0 +1,251 @@
import {
BE_GESTIONMESSAGERIE_CONVERSATIONS_URL,
BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL,
BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL,
BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL,
BE_GESTIONMESSAGERIE_MARK_AS_READ_URL,
BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL,
BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL,
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
import logger from '@/utils/logger';
// Helper pour construire les en-têtes avec CSRF
const buildHeaders = (csrfToken) => {
const headers = {
'Content-Type': 'application/json',
};
// Ajouter le token CSRF
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
return headers;
};
/**
* Récupère les conversations d'un utilisateur
*/
export const fetchConversations = async (userId, csrfToken) => {
try {
// Utiliser la nouvelle route avec user_id en paramètre d'URL
const url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/user/${userId}/`;
const response = await fetch(url, {
method: 'GET',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la récupération des conversations:', error);
return errorHandler(error);
}
};
/**
* Récupère les messages d'une conversation
*/
export const fetchMessages = async (
conversationId,
page = 1,
limit = 50,
csrfToken,
userId = null
) => {
try {
// Utiliser la nouvelle URL avec conversation_id en paramètre d'URL
let url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/${conversationId}/messages/?page=${page}&limit=${limit}`;
// Ajouter user_id si fourni pour calculer correctement is_read
if (userId) {
url += `&user_id=${userId}`;
}
const response = await fetch(url, {
method: 'GET',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la récupération des messages:', error);
return errorHandler(error);
}
};
/**
* Envoie un message dans une conversation
*/
export const sendMessage = async (messageData, csrfToken) => {
try {
const response = await fetch(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, {
method: 'POST',
headers: buildHeaders(csrfToken),
credentials: 'include',
body: JSON.stringify(messageData),
});
return await requestResponseHandler(response);
} catch (error) {
logger.error("Erreur lors de l'envoi du message:", error);
return errorHandler(error);
}
};
/**
* Crée une nouvelle conversation
*/
export const createConversation = async (participantIds, csrfToken) => {
try {
const requestBody = {
participant_ids: participantIds, // Le backend attend "participant_ids"
conversation_type: 'private', // Spécifier le type de conversation
name: '', // Le nom sera généré côté backend
};
const response = await fetch(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, {
method: 'POST',
headers: buildHeaders(csrfToken),
credentials: 'include',
body: JSON.stringify(requestBody),
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la création de la conversation:', error);
return errorHandler(error);
}
};
/**
* Recherche des destinataires pour la messagerie
*/
export const searchMessagerieRecipients = async (
establishmentId,
query,
csrfToken
) => {
try {
const baseUrl = BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL.endsWith('/')
? BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL
: BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL + '/';
const url = `${baseUrl}?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
const response = await fetch(url, {
method: 'GET',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la recherche des destinataires:', error);
return errorHandler(error);
}
};
/**
* Marque des messages comme lus
*/
export const markAsRead = async (conversationId, userId, csrfToken) => {
try {
const response = await fetch(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, {
method: 'POST',
headers: buildHeaders(csrfToken),
credentials: 'include',
body: JSON.stringify({
conversation_id: conversationId,
user_id: userId,
}),
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors du marquage des messages comme lus:', error);
return errorHandler(error);
}
};
/**
* Upload un fichier pour la messagerie
*/
export const uploadFile = async (
file,
conversationId,
senderId,
csrfToken,
onProgress = null
) => {
const formData = new FormData();
formData.append('file', file);
formData.append('conversation_id', conversationId);
formData.append('sender_id', senderId);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (onProgress) {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
onProgress(percentComplete);
}
});
}
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response);
} catch (error) {
reject(new Error('Réponse invalide du serveur'));
}
} else {
try {
const errorResponse = JSON.parse(xhr.responseText);
reject(new Error(errorResponse.message || "Erreur lors de l'upload"));
} catch {
reject(new Error(`Erreur HTTP: ${xhr.status}`));
}
}
});
xhr.addEventListener('error', () => {
reject(new Error("Erreur réseau lors de l'upload"));
});
xhr.addEventListener('timeout', () => {
reject(new Error("Timeout lors de l'upload"));
});
xhr.open('POST', BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL);
xhr.withCredentials = true;
xhr.timeout = 30000;
// Ajouter le header CSRF pour XMLHttpRequest
if (csrfToken) {
xhr.setRequestHeader('X-CSRFToken', csrfToken);
}
xhr.send(formData);
});
};
/**
* Supprime une conversation
*/
export const deleteConversation = async (conversationId, csrfToken) => {
try {
const url = `${BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL}/${conversationId}/`;
const response = await fetch(url, {
method: 'DELETE',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la suppression de la conversation:', error);
return errorHandler(error);
}
};

View File

@ -0,0 +1,113 @@
import { BE_PLANNING_PLANNINGS_URL, BE_PLANNING_EVENTS_URL } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
const getData = (url) => {
return fetch(`${url}`).then(requestResponseHandler).catch(errorHandler);
};
const createDatas = (url, newData, csrfToken) => {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(newData),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
const updateDatas = (url, updatedData, csrfToken) => {
return fetch(`${url}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(updatedData),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
const removeDatas = (url, csrfToken) => {
return fetch(`${url}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchPlannings = (
establishment_id = null,
planningMode = null
) => {
let url = `${BE_PLANNING_PLANNINGS_URL}`;
if (establishment_id) {
url += `?establishment_id=${establishment_id}`;
}
if (planningMode) {
url += `&planning_mode=${planningMode}`;
}
return getData(url);
};
export const getPlanning = (id) => {
return getData(`${BE_PLANNING_PLANNINGS_URL}/${id}`);
};
export const createPlanning = (newData, csrfToken) => {
return createDatas(`${BE_PLANNING_PLANNINGS_URL}`, newData, csrfToken);
};
export const updatePlanning = (id, newData, csrfToken) => {
return updateDatas(`${BE_PLANNING_PLANNINGS_URL}/${id}`, newData, csrfToken);
};
export const deletePlanning = (id, csrfToken) => {
return removeDatas(`${BE_PLANNING_PLANNINGS_URL}/${id}`, csrfToken);
};
export const fetchEvents = (establishment_id = null, planningMode = null) => {
let url = `${BE_PLANNING_EVENTS_URL}`;
if (establishment_id) {
url += `?establishment_id=${establishment_id}`;
}
if (planningMode) {
url += `&planning_mode=${planningMode}`;
}
return getData(url);
};
export const getEvent = (id) => {
return getData(`${BE_PLANNING_EVENTS_URL}/${id}`);
};
export const createEvent = (newData, csrfToken) => {
return createDatas(`${BE_PLANNING_EVENTS_URL}`, newData, csrfToken);
};
export const updateEvent = (id, newData, csrfToken) => {
return updateDatas(`${BE_PLANNING_EVENTS_URL}/${id}`, newData, csrfToken);
};
export const deleteEvent = (id, csrfToken) => {
return removeDatas(`${BE_PLANNING_EVENTS_URL}/${id}`, csrfToken);
};
export const fetchUpcomingEvents = (establishment_id = null) => {
let url = `${BE_PLANNING_EVENTS_URL}/upcoming`;
if (establishment_id) {
url += `?establishment_id=${establishment_id}`;
}
return getData(`${url}`);
};

View File

@ -0,0 +1,388 @@
import {
BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL,
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL,
BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL,
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL,
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL,
FE_API_DOCUSEAL_CLONE_URL,
FE_API_DOCUSEAL_DOWNLOAD_URL,
FE_API_DOCUSEAL_GENERATE_TOKEN,
FE_API_DOCUSEAL_DELETE_URL
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
// FETCH requests
export async function fetchRegistrationFileGroups(establishment) {
const response = await fetch(
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}?establishment_id=${establishment}`,
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch file groups');
}
return response.json();
}
export const fetchRegistrationFileFromGroup = async (groupId) => {
const response = await fetch(
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}/school_file_templates`,
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
);
if (!response.ok) {
throw new Error(
'Erreur lors de la récupération des fichiers associés au groupe'
);
}
return response.json();
};
export const fetchRegistrationSchoolFileMasters = (establishment) => {
let url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishment}`;
const request = new Request(`${url}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const fetchRegistrationParentFileMasters = (establishment) => {
let url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
const request = new Request(`${url}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const fetchRegistrationSchoolFileTemplates = (establishment) => {
let url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}?establishment_id=${establishment}`;
const request = new Request(`${url}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
// CREATE requests
export async function createRegistrationFileGroup(groupData, csrfToken) {
const response = await fetch(
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(groupData),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error('Failed to create file group');
}
return response.json();
}
export const createRegistrationSchoolFileMaster = (data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const createRegistrationParentFileMaster = (data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const createRegistrationSchoolFileTemplate = (data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const createRegistrationParentFileTemplate = (data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
// EDIT requests
export const editRegistrationFileGroup = async (
groupId,
groupData,
csrfToken
) => {
const response = await fetch(
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(groupData),
}
);
if (!response.ok) {
throw new Error('Erreur lors de la modification du groupe');
}
return response.json();
};
export const editRegistrationSchoolFileMaster = (fileId, data, csrfToken) => {
return fetch(
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
{
method: 'PUT',
body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
}
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const editRegistrationParentFileMaster = (id, data, csrfToken) => {
return fetch(
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`,
{
method: 'PUT',
body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
}
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const editRegistrationSchoolFileTemplates = (
fileId,
data,
csrfToken
) => {
return fetch(
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`,
{
method: 'PUT',
body: data,
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const editRegistrationParentFileTemplates = (
fileId,
data,
csrfToken
) => {
return fetch(
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${fileId}`,
{
method: 'PUT',
body: data,
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}
)
.then(requestResponseHandler)
.catch(errorHandler);
};
// DELETE requests
export async function deleteRegistrationFileGroup(groupId, csrfToken) {
const response = await fetch(
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
{
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}
);
return response;
}
export const deleteRegistrationSchoolFileMaster = (fileId, csrfToken) => {
return fetch(
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
{
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}
);
};
export const deleteRegistrationParentFileMaster = (id, csrfToken) => {
return fetch(
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`,
{
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}
);
};
export const deleteRegistrationSchoolFileTemplates = (fileId, csrfToken) => {
return fetch(
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`,
{
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}
);
};
export const deleteRegistrationParentFileTemplate = (id, csrfToken) => {
return fetch(
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${id}`,
{
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}
);
};
// API requests
export const removeTemplate = (templateId, selectedEstablishmentId, apiDocuseal) => {
return fetch(`${FE_API_DOCUSEAL_DELETE_URL}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
templateId,
establishment_id :selectedEstablishmentId,
apiDocuseal
}),
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const cloneTemplate = (templateId, email, is_required, selectedEstablishmentId, apiDocuseal) => {
return fetch(`${FE_API_DOCUSEAL_CLONE_URL}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
templateId,
email,
is_required,
establishment_id :selectedEstablishmentId,
apiDocuseal
}),
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const downloadTemplate = (slug, selectedEstablishmentId, apiDocuseal) => {
const url = `${FE_API_DOCUSEAL_DOWNLOAD_URL}/${slug}?establishment_id=${selectedEstablishmentId}&apiDocuseal=${apiDocuseal}`;
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const generateToken = (email, id = null, selectedEstablishmentId, apiDocuseal) => {
return fetch(`${FE_API_DOCUSEAL_GENERATE_TOKEN}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_email: email, id, establishment_id :selectedEstablishmentId, apiDocuseal }),
})
.then(requestResponseHandler)
.catch(errorHandler);
};

View File

@ -0,0 +1,194 @@
import {
BE_SCHOOL_SPECIALITIES_URL,
BE_SCHOOL_TEACHERS_URL,
BE_SCHOOL_SCHOOLCLASSES_URL,
BE_SCHOOL_PLANNINGS_URL,
BE_SCHOOL_FEES_URL,
BE_SCHOOL_DISCOUNTS_URL,
BE_SCHOOL_PAYMENT_PLANS_URL,
BE_SCHOOL_PAYMENT_MODES_URL,
BE_SCHOOL_ESTABLISHMENT_URL,
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
export const deleteEstablishmentCompetencies = (ids, csrfToken) => {
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({ ids }),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const createEstablishmentCompetencies = (newData, csrfToken) => {
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(newData),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => {
return fetch(
`${BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL}?establishment_id=${establishment}&cycle=${cycle}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchSpecialities = (establishment) => {
return fetch(
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchTeachers = (establishment) => {
return fetch(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchClasses = (establishment) => {
return fetch(
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchClasse = (id) => {
return fetch(`${BE_SCHOOL_SCHOOLCLASSES_URL}/${id}`).then(
requestResponseHandler
);
};
export const fetchSchedules = () => {
return fetch(`${BE_SCHOOL_PLANNINGS_URL}`)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchRegistrationDiscounts = (establishment) => {
return fetch(
`${BE_SCHOOL_DISCOUNTS_URL}?filter=registration&establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchTuitionDiscounts = (establishment) => {
return fetch(
`${BE_SCHOOL_DISCOUNTS_URL}?filter=tuition&establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchRegistrationFees = (establishment) => {
return fetch(
`${BE_SCHOOL_FEES_URL}?filter=registration&establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchTuitionFees = (establishment) => {
return fetch(
`${BE_SCHOOL_FEES_URL}?filter=tuition&establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchRegistrationPaymentPlans = (establishment) => {
return fetch(
`${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=registration&establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchTuitionPaymentPlans = (establishment) => {
return fetch(
`${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=tuition&establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchRegistrationPaymentModes = (establishment) => {
return fetch(
`${BE_SCHOOL_PAYMENT_MODES_URL}?filter=registration&establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchTuitionPaymentModes = (establishment) => {
return fetch(
`${BE_SCHOOL_PAYMENT_MODES_URL}?filter=tuition&establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchEstablishment = (establishment) => {
return fetch(`${BE_SCHOOL_ESTABLISHMENT_URL}/${establishment}`)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const createDatas = (url, newData, csrfToken) => {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(newData),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const updateDatas = (url, id, updatedData, csrfToken) => {
return fetch(`${url}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(updatedData),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const removeDatas = (url, id, csrfToken) => {
return fetch(`${url}/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};

View File

@ -0,0 +1,35 @@
import { BE_SETTINGS_SMTP_URL } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
export const PENDING = 'pending';
export const SUBSCRIBED = 'subscribed';
export const ARCHIVED = 'archived';
export const fetchSmtpSettings = (csrfToken, establishment_id = null) => {
let url = `${BE_SETTINGS_SMTP_URL}/`;
if (establishment_id) {
url += `?establishment_id=${establishment_id}`;
}
return fetch(`${url}`, {
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const editSmtpSettings = (data, csrfToken) => {
return fetch(`${BE_SETTINGS_SMTP_URL}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(data),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};

View File

@ -0,0 +1,304 @@
import {
BE_SUBSCRIPTION_STUDENTS_URL,
BE_SUBSCRIPTION_CHILDRENS_URL,
BE_SUBSCRIPTION_REGISTERFORMS_URL,
BE_SUBSCRIPTION_LAST_GUARDIAN_ID_URL,
BE_SUBSCRIPTION_ABSENCES_URL,
BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL,
BE_SUBSCRIPTION_SEARCH_STUDENTS_URL,
} from '@/utils/Url';
import { CURRENT_YEAR_FILTER } from '@/utils/constants';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
export const editStudentCompetencies = (data, csrfToken) => {
const request = new Request(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
method: 'PUT',
body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const fetchStudentCompetencies = (id, period) => {
// Si period est vide, ne pas l'ajouter à l'URL
const url = period
? `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}&period=${period}`
: `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}`;
const request = new Request(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const fetchRegisterForms = (
establishment,
filter = CURRENT_YEAR_FILTER,
page = '',
pageSize = '',
search = ''
) => {
let url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}?filter=${filter}&establishment_id=${establishment}`;
if (page !== '' && pageSize !== '') {
url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}?filter=${filter}&establishment_id=${establishment}&page=${page}&search=${search}`;
}
return fetch(url, {
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchRegisterForm = (id) => {
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`) // Utilisation de studentId au lieu de codeDI
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchLastGuardian = () => {
return fetch(`${BE_SUBSCRIPTION_LAST_GUARDIAN_ID_URL}`)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const editRegisterForm = (id, data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
method: 'PUT',
headers: {
'X-CSRFToken': csrfToken,
},
body: data,
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const createRegisterForm = (data, csrfToken) => {
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}`;
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(data),
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const sendRegisterForm = (id) => {
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/send`;
return fetch(url, {
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const resendRegisterForm = (id) => {
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/resend`;
return fetch(url, {
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const archiveRegisterForm = (id) => {
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/archive`;
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const searchStudents = (establishmentId, query) => {
const url = `${BE_SUBSCRIPTION_SEARCH_STUDENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchStudents = (establishment, id = null, status = null) => {
let url;
if (id) {
url = `${BE_SUBSCRIPTION_STUDENTS_URL}/${id}`;
} else {
url = `${BE_SUBSCRIPTION_STUDENTS_URL}?establishment_id=${establishment}`;
if (status) {
url += `&status=${status}`;
}
}
const request = new Request(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const fetchChildren = (id, establishment) => {
const request = new Request(
`${BE_SUBSCRIPTION_CHILDRENS_URL}/${id}?establishment_id=${establishment}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export async function getRegisterFormFileTemplate(fileId) {
const response = await fetch(
`${BE_SUBSCRIPTION_REGISTERFORM_FILE_TEMPLATE_URL}/${fileId}`,
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch file template');
}
return response.json();
}
export const fetchSchoolFileTemplatesFromRegistrationFiles = async (id) => {
const response = await fetch(
`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/school_file_templates`,
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
);
if (!response.ok) {
throw new Error(
'Erreur lors de la récupération des fichiers associés au groupe'
);
}
return response.json();
};
export const fetchParentFileTemplatesFromRegistrationFiles = async (id) => {
const response = await fetch(
`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/parent_file_templates`,
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
);
if (!response.ok) {
throw new Error(
'Erreur lors de la récupération des fichiers associés au groupe'
);
}
return response.json();
};
export const dissociateGuardian = async (studentId, guardianId) => {
const response = await fetch(
`${BE_SUBSCRIPTION_STUDENTS_URL}/${studentId}/guardians/${guardianId}/dissociate`,
{
credentials: 'include',
method: 'PUT',
headers: {
Accept: 'application/json',
},
}
);
if (!response.ok) {
// Extraire le message d'erreur du backend
const errorData = await response.json();
const errorMessage =
errorData?.error || 'Une erreur est survenue lors de la dissociation.';
// Jeter une erreur avec le message spécifique
throw new Error(errorMessage);
}
return response.json();
};
export const fetchAbsences = (establishment) => {
return fetch(
`${BE_SUBSCRIPTION_ABSENCES_URL}?establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const createAbsences = (data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const editAbsences = (absenceId, payload, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}/${absenceId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(payload), // Sérialisez les données en JSON
credentials: 'include',
}).then((response) => {
if (!response.ok) {
return response.json().then((error) => {
throw new Error(error);
});
}
return response.json();
});
};
export const deleteAbsences = (id, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}/${id}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
});
};

View File

@ -1,11 +1,11 @@
import React from 'react';
import { NextIntlClientProvider } from 'next-intl';
import {getMessages} from 'next-intl/server';
import "@/css/tailwind.css";
import { getMessages } from 'next-intl/server';
import Providers from '@/components/Providers';
import '@/css/tailwind.css';
import { headers } from 'next/headers';
export const metadata = {
title: "N3WT-SCHOOL",
title: 'N3WT-SCHOOL',
description: "Gestion de l'école",
icons: {
icon: [
@ -14,22 +14,24 @@ export const metadata = {
type: 'image/svg+xml',
},
{
url: '/favicon.ico', // Fallback pour les anciens navigateurs
url: '/favicon.ico', // Fallback pour les anciens navigateurs
sizes: 'any',
},
],
},
};
export default async function RootLayout({ children, params: { locale } }) {
const messages = await getMessages();
export default async function RootLayout({ children, params }) {
const headersList = headers();
const locale = headersList.get('x-locale') || 'fr';
const messages = await getMessages(locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
<body className="p-0 m-0">
<Providers messages={messages} locale={locale} session={params.session}>
{children}
</NextIntlClientProvider>
</Providers>
</body>
</html>
);

View File

@ -1,39 +0,0 @@
import {
BK_LOGIN_URL,
FR_USERS_LOGIN_URL ,
FR_ADMIN_HOME_URL,
FR_ADMIN_SUBSCRIPTIONS_URL,
FR_ADMIN_CLASSES_URL,
FR_ADMIN_GRADES_URL,
FR_ADMIN_PLANNING_URL,
FR_ADMIN_TEACHERS_URL,
FR_ADMIN_SETTINGS_URL
} from '@/utils/Url';
import {mockUser} from "@/data/mockUsersData";
const useFakeData = process.env.NEXT_PUBLIC_USE_FAKE_DATA === 'true';
/**
* Disconnects the user after confirming the action.
* If `NEXT_PUBLIC_USE_FAKE_DATA` environment variable is set to 'true', it will log a fake disconnect and redirect to the login URL.
* Otherwise, it will send a PUT request to the backend to update the user profile and then redirect to the login URL.
*
* @function
* @name disconnect
* @returns {void}
*/
export function disconnect () {
if (confirm("\nÊtes-vous sûr(e) de vouloir vous déconnecter ?")) {
if (useFakeData) {
console.log('Fake disconnect:', mockUser);
router.push(`${FR_USERS_LOGIN_URL}`);
} else {
console.log('Fake disconnect:', mockUser);
router.push(`${FR_USERS_LOGIN_URL}`);
}
}
};

View File

@ -1,15 +1,21 @@
import Link from 'next/link'
import Logo from '../components/Logo'
import Link from 'next/link';
import Logo from '../components/Logo';
export default function NotFound() {
return (
<div className='flex items-center justify-center min-h-screen bg-emerald-500'>
<div className='text-center p-6 '>
<div className="flex items-center justify-center min-h-screen bg-emerald-500">
<div className="text-center p-6 ">
<Logo className="w-32 h-32 mx-auto mb-4" />
<h2 className='text-2xl font-bold text-emerald-900 mb-4'>404 | Page non trouvée</h2>
<p className='text-emerald-900 mb-4'>La ressource que vous souhaitez consulter n'existe pas ou plus.</p>
<Link className="text-gray-900 hover:underline" href="/">Retour Accueil</Link>
<h2 className="text-2xl font-bold text-emerald-900 mb-4">
404 | Page non trouvée
</h2>
<p className="text-emerald-900 mb-4">
La ressource que vous souhaitez consulter n&apos;existe pas ou plus.
</p>
<Link className="text-gray-900 hover:underline" href="/">
Retour Accueil
</Link>
</div>
</div>
)
}
);
}

View File

@ -0,0 +1,53 @@
'use client';
import logger from '@/utils/logger';
import React, { useState } from 'react';
export default function AnnouncementScheduler({ csrfToken }) {
const [title, setTitle] = useState('');
const [date, setDate] = useState('');
const [message, setMessage] = useState('');
const handleSchedule = () => {
// Logique pour planifier une annonce
logger.debug('Annonce planifiée:', { title, date, message });
};
return (
<div className="p-4 bg-white rounded shadow">
<h2 className="text-xl font-bold mb-4">Planifier une Annonce</h2>
<div className="mb-4">
<label className="block font-medium">Titre</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-2 border rounded"
/>
</div>
<div className="mb-4">
<label className="block font-medium">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full p-2 border rounded"
/>
</div>
<div className="mb-4">
<label className="block font-medium">Message</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full p-2 border rounded"
rows="5"
/>
</div>
<button
onClick={handleSchedule}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Planifier
</button>
</div>
);
}

View File

@ -0,0 +1,158 @@
'use client';
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { sendEmail, searchRecipients } from '@/app/actions/emailAction';
import { fetchSmtpSettings } from '@/app/actions/settingsAction';
import { useNotification } from '@/context/NotificationContext';
import { useEstablishment } from '@/context/EstablishmentContext';
import AlertMessage from '@/components/AlertMessage';
import RecipientInput from '@/components/RecipientInput';
import { useRouter } from 'next/navigation'; // Ajoute cette ligne
import WisiwigTextArea from '@/components/WisiwigTextArea';
import logger from '@/utils/logger';
import InputText from '@/components/InputText';
import Button from '@/components/Button';
export default function EmailSender({ csrfToken }) {
const [recipients, setRecipients] = useState([]);
const [fromEmail, setFromEmail] = useState('');
const [cc, setCc] = useState([]);
const [bcc, setBcc] = useState([]);
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const [smtpConfigured, setSmtpConfigured] = useState(false); // État pour vérifier si SMTP est configuré
const { showNotification } = useNotification();
const { selectedEstablishmentId } = useEstablishment(); // Récupérer l'establishment_id depuis le contexte
const router = useRouter(); // Ajoute cette ligne
useEffect(() => {
// Vérifier si les paramètres SMTP sont configurés
fetchSmtpSettings(csrfToken, selectedEstablishmentId)
.then((data) => {
if (data.smtp_server && data.smtp_port && data.smtp_user) {
setFromEmail(data.smtp_user);
setSmtpConfigured(true);
} else {
setSmtpConfigured(false);
}
})
.catch((error) => {
logger.error('Erreur lors de la vérification des paramètres SMTP:', {
error,
});
setSmtpConfigured(false);
});
}, [csrfToken, selectedEstablishmentId]);
const handleSendEmail = async () => {
const data = {
recipients,
cc,
bcc,
subject,
message,
establishment_id: selectedEstablishmentId, // Ajouter l'establishment_id à la payload
};
try {
await sendEmail(data);
showNotification('Email envoyé avec succès.', 'success', 'Succès');
// Réinitialiser les champs après succès
setRecipients([]);
setCc([]);
setBcc([]);
setSubject('');
setMessage('');
} catch (error) {
logger.error("Erreur lors de l'envoi de l'email:", { error });
showNotification(
"Une erreur est survenue lors de l'envoi de l'email.",
'error',
'Erreur'
);
}
};
if (!smtpConfigured) {
return (
<AlertMessage
type="warning"
title="Configuration SMTP requise"
message="Les paramètres SMTP de cet établissement ne sont pas configurés. Veuillez les configurer dans la page des paramètres."
actionLabel="Aller aux paramètres"
onAction={() => router.push('/admin/settings?tab=smtp')} // Utilise next/navigation ici
/>
);
}
return (
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow-md">
{/* Form */}
<div className="p-4 flex flex-col min-h-[600px]">
{' '}
{/* Ajout flex-col et min-h */}
{/* Destinataires */}
<RecipientInput
label="Destinataires"
recipients={recipients}
setRecipients={setRecipients}
searchRecipients={searchRecipients}
establishmentId={selectedEstablishmentId}
required
/>
{/* Cc */}
<div className="mt-2">
<RecipientInput
label="Cc"
placeholder="Ajouter Cc"
recipients={cc}
searchRecipients={searchRecipients}
establishmentId={selectedEstablishmentId}
setRecipients={setCc}
/>
</div>
{/* Bcc */}
<div className="mt-2">
<RecipientInput
label="Cci"
placeholder="Ajouter Bcc"
recipients={bcc}
searchRecipients={searchRecipients}
establishmentId={selectedEstablishmentId}
setRecipients={setBcc}
/>
</div>
{/* Subject */}
<InputText
name="subject"
label="Sujet"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Saisir le sujet"
className="mb-4 mt-2"
required
/>
{/* Email Body */}
<div className="mb-4 flex flex-col">
<WisiwigTextArea
label="Mail"
value={message}
onChange={setMessage}
placeholder="Ecrivez votre mail ici..."
required
/>
</div>
{/* Footer */}
<div className="flex justify-between items-center mt-10">
<Button
text="Envoyer"
onClick={handleSendEmail}
primary
className="px-4 py-2"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
import React, { useEffect, useState } from 'react';
import InstantChat from '@/components/Chat/InstantChat';
import { useEstablishment } from '@/context/EstablishmentContext';
import logger from '@/utils/logger';
export default function InstantMessaging({ csrfToken }) {
const { user, selectedEstablishmentId } = useEstablishment();
return (
<div className="h-full flex flex-col">
<InstantChat
userProfileId={user.user_id}
establishmentId={selectedEstablishmentId}
/>
</div>
);
}

View File

@ -1,9 +1,8 @@
import React, { useState } from 'react';
const AffectationClasseForm = ({ eleve, onSubmit, classes }) => {
const AffectationClasseForm = ({ eleve = {}, onSubmit, classes }) => {
const [formData, setFormData] = useState({
classeAssocie_id: eleve.classeAssocie_id || null,
classeAssocie_id: eleve?.classeAssocie_id || null,
});
const handleChange = (e) => {
@ -15,12 +14,13 @@ const AffectationClasseForm = ({ eleve, onSubmit, classes }) => {
};
const handleSubmit = () => {
onSubmit({
logger.debug(formData);
/*onSubmit({
eleve: {
...formData
...formData,
},
etat:5
});
etat: 5,
});*/
};
return (
@ -30,19 +30,22 @@ const AffectationClasseForm = ({ eleve, onSubmit, classes }) => {
Classes
</label>
<div className="mt-2 grid grid-cols-1 gap-4">
{classes.map(classe => (
{classes.map((classe) => (
<div key={classe.id} className="flex items-center">
<input
type="radio"
id={`classe-${classe.id}`}
name="classeAssocie_id"
value={classe.id}
checked={formData.classeAssocie_id === classe.id}
onChange={handleChange}
className="form-radio h-3 w-3 text-emerald-600 focus:ring-emerald-500 hover:ring-emerald-400 checked:bg-emerald-600 checked:h-3 checked:w-3"
type="radio"
id={`classe-${classe.id}`}
name="classeAssocie_id"
value={classe.id}
checked={formData.classeAssocie_id === classe.id}
onChange={handleChange}
className="form-radio h-3 w-3 text-emerald-600 focus:ring-emerald-500 hover:ring-emerald-400 checked:bg-emerald-600 checked:h-3 checked:w-3"
/>
<label htmlFor={`classe-${classe.id}`} className="ml-2 block text-sm text-gray-900 flex items-center">
{classe.nom_ambiance}
<label
htmlFor={`classe-${classe.id}`}
className="ml-2 block text-sm text-gray-900 flex items-center"
>
{classe.atmosphere_name}
</label>
</div>
))}
@ -50,15 +53,15 @@ const AffectationClasseForm = ({ eleve, onSubmit, classes }) => {
</div>
<div className="flex justify-end mt-4 space-x-4">
<button
onClick={handleSubmit}
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
(!formData.classeAssocie_id )
? "bg-gray-300 text-gray-700 cursor-not-allowed"
: "bg-emerald-500 text-white hover:bg-emerald-600"
}`}
disabled={(!formData.classeAssocie_id)}
onClick={handleSubmit}
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
!formData.classeAssocie_id
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
disabled={!formData.classeAssocie_id}
>
Associer
Associer
</button>
</div>
</form>

View File

@ -1,15 +1,36 @@
import React from 'react';
const AlertMessage = ({ title, message, buttonText, buttonLink }) => {
const AlertMessage = ({
type = 'info',
title,
message,
actionLabel,
onAction,
}) => {
// Définir les styles en fonction du type d'alerte
const typeStyles = {
info: 'bg-blue-100 border-blue-500 text-blue-700',
warning: 'bg-yellow-100 border-yellow-500 text-yellow-700',
error: 'bg-red-100 border-red-500 text-red-700',
success: 'bg-green-100 border-green-500 text-green-700',
};
const alertStyle = typeStyles[type] || typeStyles.info;
return (
<div className="alert centered bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4" role="alert">
<div className={`alert centered border-l-4 p-4 ${alertStyle}`} role="alert">
<h3 className="font-bold">{title}</h3>
<p className="mt-2">{message}</p>
<div className="alert-actions mt-4">
<a className="btn primary bg-emerald-500 text-white rounded-md px-4 py-2 hover:bg-emerald-600" href={buttonLink}>
{buttonText} <i className="icon profile-add"></i>
</a>
</div>
{actionLabel && onAction && (
<div className="alert-actions mt-4">
<button
className="btn primary bg-emerald-500 text-white rounded-md px-4 py-2 hover:bg-emerald-600"
onClick={onAction}
>
{actionLabel}
</button>
</div>
)}
</div>
);
};

View File

@ -2,28 +2,31 @@ import React, { useState } from 'react';
import Modal from '@/components/Modal';
import { UserPlus } from 'lucide-react';
const AlertWithModal = ({ title, message, buttonText}) => {
const [isOpen, setIsOpen] = useState(false);
const AlertWithModal = ({ title, message, buttonText }) => {
const [isOpen, setIsOpen] = useState(false);
const openModal = () => {
setIsOpen(true);
};
const openModal = () => {
setIsOpen(true);
};
return (
<div className="alert centered bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4" role="alert">
<h3 className="font-bold">{title}</h3>
<p className="mt-2">{message}</p>
<div className="alert-actions mt-4">
<button
className="btn primary bg-emerald-500 text-white rounded-md px-4 py-2 hover:bg-emerald-600 flex items-center"
onClick={openModal}
>
{buttonText} <UserPlus size={20} className="ml-2" />
</button>
</div>
<Modal isOpen={isOpen} setIsOpen={setIsOpen} />
</div>
);
return (
<div
className="alert centered bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4"
role="alert"
>
<h3 className="font-bold">{title}</h3>
<p className="mt-2">{message}</p>
<div className="alert-actions mt-4">
<button
className="btn primary bg-emerald-500 text-white rounded-md px-4 py-2 hover:bg-emerald-600 flex items-center"
onClick={openModal}
>
{buttonText} <UserPlus size={20} className="ml-2" />
</button>
</div>
<Modal isOpen={isOpen} setIsOpen={setIsOpen} />
</div>
);
};
export default AlertWithModal;
export default AlertWithModal;

View File

@ -1,37 +1,39 @@
import React, { useState } from 'react';
const AlphabetPaginationNumber = ({ letter, active , onClick}) => (
<button className={`w-8 h-8 flex items-center justify-center rounded ${
active ? 'bg-emerald-500 text-white' : 'text-gray-600 bg-gray-200 hover:bg-gray-50'
}`} onClick={onClick}>
const AlphabetPaginationNumber = ({ letter, active, onClick }) => (
<button
className={`w-8 h-8 flex items-center justify-center rounded ${
active
? 'bg-emerald-500 text-white'
: 'text-gray-600 bg-gray-200 hover:bg-gray-50'
}`}
onClick={onClick}
>
{letter}
</button>
);
const AlphabetLinks = ({filter, onLetterClick }) => {
const AlphabetLinks = ({ filter, onLetterClick }) => {
const [currentLetter, setCurrentLetter] = useState(filter);
const alphabet = "*ABCDEFGHIJKLMNOPQRSTUVWXYZ".split('');
const alphabet = '*ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
return (
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
{alphabet.map((letter) => (
<AlphabetPaginationNumber
key={letter}
letter={letter}
active={currentLetter === letter }
onClick={() => {setCurrentLetter(letter);onLetterClick(letter)}}
/>
))}
{alphabet.map((letter) => (
<AlphabetPaginationNumber
key={letter}
letter={letter}
active={currentLetter === letter}
onClick={() => {
setCurrentLetter(letter);
onLetterClick(letter);
}}
/>
))}
</div>
</div>
);
};
export default AlphabetLinks;
export default AlphabetLinks;

View File

@ -1,9 +1,18 @@
import React from 'react';
import { useRouter } from 'next/navigation';
const Button = ({ text, onClick, href, className, primary, icon, disabled}) => {
const Button = ({
text,
onClick,
href,
className,
primary,
icon,
disabled,
}) => {
const router = useRouter();
const baseClass = 'px-4 py-2 rounded-md text-white h-8 flex items-center justify-center';
const baseClass =
'px-4 py-2 rounded-md text-white h-8 flex items-center justify-center';
const primaryClass = 'bg-emerald-500 hover:bg-emerald-600';
const secondaryClass = 'bg-gray-300 hover:bg-gray-400 text-black';
const buttonClass = `${baseClass} ${primary && !disabled ? primaryClass : secondaryClass} ${className}`;
@ -18,7 +27,8 @@ const Button = ({ text, onClick, href, className, primary, icon, disabled}) => {
return (
<button className={buttonClass} onClick={handleClick} disabled={disabled}>
{icon && <span className="mr-2">{icon}</span>}
{icon && text && <span className="mr-2">{icon}</span>}
{icon && !text && icon}
{text}
</button>
);

View File

@ -1,212 +0,0 @@
import React, { useEffect, useState } from 'react';
import { usePlanning } from '@/context/PlanningContext';
import WeekView from '@/components/Calendar/WeekView';
import MonthView from '@/components/Calendar/MonthView';
import YearView from '@/components/Calendar/YearView';
import PlanningView from '@/components/Calendar/PlanningView';
import ToggleView from '@/components/ToggleView';
import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react';
import { format, addWeeks, addMonths, addYears, subWeeks, subMonths, subYears, getWeek, setMonth, setYear } from 'date-fns';
import { fr } from 'date-fns/locale';
import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import
const Calendar = ({ onDateClick, onEventClick }) => {
const { currentDate, setCurrentDate, viewType, setViewType, events, hiddenSchedules } = usePlanning();
const [visibleEvents, setVisibleEvents] = useState([]);
const [showDatePicker, setShowDatePicker] = useState(false);
// Ajouter ces fonctions pour la gestion des mois et années
const months = Array.from({ length: 12 }, (_, i) => ({
value: i,
label: format(new Date(2024, i, 1), 'MMMM', { locale: fr })
}));
const years = Array.from({ length: 10 }, (_, i) => ({
value: new Date().getFullYear() - 5 + i,
label: new Date().getFullYear() - 5 + i
}));
const handleMonthSelect = (monthIndex) => {
setCurrentDate(setMonth(currentDate, monthIndex));
setShowDatePicker(false);
};
const handleYearSelect = (year) => {
setCurrentDate(setYear(currentDate, year));
setShowDatePicker(false);
};
useEffect(() => {
// S'assurer que le filtrage est fait au niveau parent
const filtered = events.filter(event => !hiddenSchedules.includes(event.scheduleId));
setVisibleEvents(filtered);
console.log('Events filtrés:', filtered); // Debug
}, [events, hiddenSchedules]);
const navigateDate = (direction) => {
const getNewDate = () => {
switch (viewType) {
case 'week':
return direction === 'next'
? addWeeks(currentDate, 1)
: subWeeks(currentDate, 1);
case 'month':
return direction === 'next'
? addMonths(currentDate, 1)
: subMonths(currentDate, 1);
case 'year':
return direction === 'next'
? addYears(currentDate, 1)
: subYears(currentDate, 1);
default:
return currentDate;
}
};
setCurrentDate(getNewDate());
};
return (
<div className="flex-1 flex h-full flex-col">
<div className="flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
{/* Navigation à gauche */}
<div className="flex items-center gap-4">
<button
onClick={() => setCurrentDate(new Date())}
className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Aujourd'hui
</button>
<button onClick={() => navigateDate('prev')} className="p-2 hover:bg-gray-100 rounded-full">
<ChevronLeft className="w-5 h-5" />
</button>
{/* Menu déroulant pour le mois/année */}
<div className="relative">
<button
onClick={() => setShowDatePicker(!showDatePicker)}
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
>
<h2 className="text-xl font-semibold">
{format(currentDate, viewType === 'year' ? 'yyyy' : 'MMMM yyyy', { locale: fr })}
</h2>
<ChevronDown className="w-4 h-4" />
</button>
{/* Menu de sélection du mois/année */}
{showDatePicker && (
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 w-64">
{viewType !== 'year' && (
<div className="p-2 border-b">
<div className="grid grid-cols-3 gap-1">
{months.map((month) => (
<button
key={month.value}
onClick={() => handleMonthSelect(month.value)}
className="p-2 text-sm hover:bg-gray-100 rounded-md"
>
{month.label}
</button>
))}
</div>
</div>
)}
<div className="p-2">
<div className="grid grid-cols-3 gap-1">
{years.map((year) => (
<button
key={year.value}
onClick={() => handleYearSelect(year.value)}
className="p-2 text-sm hover:bg-gray-100 rounded-md"
>
{year.label}
</button>
))}
</div>
</div>
</div>
)}
</div>
<button onClick={() => navigateDate('next')} className="p-2 hover:bg-gray-100 rounded-full">
<ChevronRight className="w-5 h-5" />
</button>
</div>
{/* Numéro de semaine au centre */}
{viewType === 'week' && (
<div className="flex items-center gap-1 text-sm font-medium text-gray-600">
<span>Semaine</span>
<span className="px-2 py-1 bg-gray-100 rounded-md">
{getWeek(currentDate, { weekStartsOn: 1 })}
</span>
</div>
)}
{/* Contrôles à droite */}
<div className="flex items-center gap-4">
<ToggleView viewType={viewType} setViewType={setViewType} />
<button
onClick={onDateClick}
className="w-10 h-10 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
{/* Contenu scrollable */}
<div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden">
<AnimatePresence mode="wait">
{viewType === 'week' && (
<motion.div
key="week"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="h-full flex flex-col"
>
<WeekView onDateClick={onDateClick} onEventClick={onEventClick} events={visibleEvents} />
</motion.div>
)}
{viewType === 'month' && (
<motion.div
key="month"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<MonthView onDateClick={onDateClick} onEventClick={onEventClick} events={visibleEvents} />
</motion.div>
)}
{viewType === 'year' && (
<motion.div
key="year"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<YearView onDateClick={onDateClick} events={visibleEvents} />
</motion.div>
)}
{viewType === 'planning' && (
<motion.div
key="planning"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<PlanningView onEventClick={onEventClick} events={visibleEvents} />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
};
export default Calendar;

View File

@ -0,0 +1,265 @@
import React, { useEffect, useState } from 'react';
import { usePlanning, PlanningModes } from '@/context/PlanningContext';
import WeekView from '@/components/Calendar/WeekView';
import MonthView from '@/components/Calendar/MonthView';
import YearView from '@/components/Calendar/YearView';
import PlanningView from '@/components/Calendar/PlanningView';
import ToggleView from '@/components/ToggleView';
import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react';
import {
format,
addWeeks,
addMonths,
addYears,
subWeeks,
subMonths,
subYears,
getWeek,
setMonth,
setYear,
} from 'date-fns';
import { fr } from 'date-fns/locale';
import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import
import logger from '@/utils/logger';
const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' }) => {
const {
currentDate,
setCurrentDate,
viewType,
setViewType,
events,
hiddenSchedules,
planningMode,
parentView
} = usePlanning();
const [visibleEvents, setVisibleEvents] = useState([]);
const [showDatePicker, setShowDatePicker] = useState(false);
// Ajouter ces fonctions pour la gestion des mois et années
const months = Array.from({ length: 12 }, (_, i) => ({
value: i,
label: format(new Date(2024, i, 1), 'MMMM', { locale: fr }),
}));
const years = Array.from({ length: 10 }, (_, i) => ({
value: new Date().getFullYear() - 5 + i,
label: new Date().getFullYear() - 5 + i,
}));
const handleMonthSelect = (monthIndex) => {
setCurrentDate(setMonth(currentDate, monthIndex));
setShowDatePicker(false);
};
const handleYearSelect = (year) => {
setCurrentDate(setYear(currentDate, year));
setShowDatePicker(false);
};
useEffect(() => {
// S'assurer que le filtrage est fait au niveau parent
const filtered = events?.filter(
(event) => !hiddenSchedules.includes(event.planning)
);
setVisibleEvents(filtered);
logger.debug('Events filtrés:', filtered); // Debug
}, [events, hiddenSchedules]);
const navigateDate = (direction) => {
const getNewDate = () => {
switch (viewType) {
case 'week':
return direction === 'next'
? addWeeks(currentDate, 1)
: subWeeks(currentDate, 1);
case 'month':
return direction === 'next'
? addMonths(currentDate, 1)
: subMonths(currentDate, 1);
case 'year':
return direction === 'next'
? addYears(currentDate, 1)
: subYears(currentDate, 1);
default:
return currentDate;
}
};
setCurrentDate(getNewDate());
};
return (
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
{/* Navigation à gauche */}
{planningMode === PlanningModes.PLANNING && (
<div className="flex items-center gap-4">
<button
onClick={() => setCurrentDate(new Date())}
className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
Aujourd&apos;hui
</button>
<button
onClick={() => navigateDate('prev')}
className="p-2 hover:bg-gray-100 rounded-full"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="relative">
<button
onClick={() => setShowDatePicker(!showDatePicker)}
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
>
<h2 className="text-xl font-semibold">
{format(
currentDate,
viewType === 'year' ? 'yyyy' : 'MMMM yyyy',
{ locale: fr }
)}
</h2>
<ChevronDown className="w-4 h-4" />
</button>
{showDatePicker && (
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 w-64">
{viewType !== 'year' && (
<div className="p-2 border-b">
<div className="grid grid-cols-3 gap-1">
{months.map((month) => (
<button
key={month.value}
onClick={() => handleMonthSelect(month.value)}
className="p-2 text-sm hover:bg-gray-100 rounded-md"
>
{month.label}
</button>
))}
</div>
</div>
)}
<div className="p-2">
<div className="grid grid-cols-3 gap-1">
{years.map((year) => (
<button
key={year.value}
onClick={() => handleYearSelect(year.value)}
className="p-2 text-sm hover:bg-gray-100 rounded-md"
>
{year.label}
</button>
))}
</div>
</div>
</div>
)}
</div>
<button
onClick={() => navigateDate('next')}
className="p-2 hover:bg-gray-100 rounded-full"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
)}
{/* Centre : numéro de semaine ou classe/niveau */}
<div className="flex-1 flex justify-center">
{((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && (
<div className="flex items-center gap-1 text-sm font-medium text-gray-600">
<span>Semaine</span>
<span className="px-2 py-1 bg-gray-100 rounded-md">
{getWeek(currentDate, { weekStartsOn: 1 })}
</span>
</div>
)}
{parentView && (
<span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold">
{/* À adapter selon les props disponibles */}
{planningClassName}
</span>
)}
</div>
{/* Contrôles à droite */}
<div className="flex items-center gap-4">
{planningMode === PlanningModes.PLANNING && (
<ToggleView viewType={viewType} setViewType={setViewType} />
)}
{(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && (
<button
onClick={onDateClick}
className="w-10 h-10 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
>
<Plus className="w-5 h-5" />
</button>
)}
</div>
</div>
{/* Contenu scrollable */}
<div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden">
<AnimatePresence mode="wait">
{viewType === 'week' && (
<motion.div
key="week"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="h-full flex flex-col"
>
<WeekView
onDateClick={onDateClick}
onEventClick={onEventClick}
events={visibleEvents}
/>
</motion.div>
)}
{viewType === 'month' && (
<motion.div
key="month"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<MonthView
onDateClick={onDateClick}
onEventClick={onEventClick}
events={visibleEvents}
/>
</motion.div>
)}
{viewType === 'year' && (
<motion.div
key="year"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<YearView onDateClick={onDateClick} events={visibleEvents} />
</motion.div>
)}
{viewType === 'planning' && (
<motion.div
key="planning"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<PlanningView
onEventClick={onEventClick}
events={visibleEvents}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
};
export default Calendar;

View File

@ -0,0 +1,340 @@
import { usePlanning, RecurrenceType } from '@/context/PlanningContext';
import { format } from 'date-fns';
import React from 'react';
import { useNotification } from '@/context/NotificationContext';
import Modal from '@/components/Modal';
export default function EventModal({
isOpen,
onClose,
eventData,
setEventData,
}) {
const { addEvent, handleUpdateEvent, handleDeleteEvent, schedules } =
usePlanning();
const { showNotification } = useNotification();
// S'assurer que planning est défini lors du premier rendu
React.useEffect(() => {
if (!eventData?.planning && schedules.length > 0) {
setEventData((prev) => ({
...prev,
planning: schedules[0].id,
color: schedules[0].color,
}));
}
}, [schedules, eventData?.planning]);
if (!isOpen) return null;
const recurrenceOptions = [
{ value: RecurrenceType.NONE, label: 'Aucune' },
{ value: RecurrenceType.DAILY, label: 'Quotidienne' },
{ value: RecurrenceType.WEEKLY, label: 'Hebdomadaire' },
{ value: RecurrenceType.MONTHLY, label: 'Mensuelle' },
/* { value: RecurrenceType.CUSTOM, label: 'Personnalisée' }, */
];
const daysOfWeek = [
{ value: 1, label: 'Lun' },
{ value: 2, label: 'Mar' },
{ value: 3, label: 'Mer' },
{ value: 4, label: 'Jeu' },
{ value: 5, label: 'Ven' },
{ value: 6, label: 'Sam' },
{ value: 0, label: 'Dim' },
];
const handleSubmit = (e) => {
e.preventDefault();
if (!eventData.planning) {
showNotification(
'Veuillez sélectionner un planning',
'warning',
'Attention'
);
return;
}
const selectedSchedule = schedules.find((s) => s.id === eventData.planning);
if (eventData.id) {
handleUpdateEvent(eventData.id, {
...eventData,
planning: eventData.planning, // S'assurer que planning est bien défini
color: eventData.color || selectedSchedule?.color,
});
} else {
addEvent({
...eventData,
id: `event-${Date.now()}`,
planning: eventData.planning, // S'assurer que planning est bien défini
color: eventData.color || selectedSchedule?.color,
});
}
onClose();
};
const handleDelete = () => {
if (
eventData.id &&
confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')
) {
handleDeleteEvent(eventData.id);
onClose();
}
};
return (
<Modal
isOpen={isOpen}
setIsOpen={onClose}
title={eventData.id ? "Modifier l'événement" : 'Nouvel événement'}
modalClassName="w-full max-w-md"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Titre */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Titre
</label>
<input
type="text"
value={eventData.title || ''}
onChange={(e) =>
setEventData({ ...eventData, title: e.target.value })
}
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={eventData.description || ''}
onChange={(e) =>
setEventData({ ...eventData, description: e.target.value })
}
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
rows="3"
/>
</div>
{/* Planning */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Planning
</label>
<select
value={eventData.planning || schedules[0]?.id}
onChange={(e) => {
const selectedSchedule = schedules.find(
(s) => s.id === e.target.value
);
setEventData({
...eventData,
planning: e.target.value,
color: selectedSchedule?.color || '#10b981',
});
}}
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
required
>
{schedules.map((schedule) => (
<option key={schedule.id} value={schedule.id}>
{schedule.name}
</option>
))}
</select>
</div>
{/* Couleur */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Couleur
</label>
<input
type="color"
value={
eventData.color ||
schedules.find((s) => s.id === eventData.planning)?.color ||
'#10b981'
}
onChange={(e) =>
setEventData({ ...eventData, color: e.target.value })
}
className="w-full h-10 p-1 rounded border"
/>
</div>
{/* Récurrence */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Récurrence
</label>
<select
value={eventData.recursionType || RecurrenceType.NONE}
onChange={(e) => {
return setEventData({
...eventData,
recursionType: e.target.value,
});
}}
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
{recurrenceOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Paramètres de récurrence personnalisée */}
{eventData.recursionType == RecurrenceType.CUSTOM && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Jours de répétition
</label>
<div className="flex gap-2 flex-wrap">
{daysOfWeek.map((day) => (
<button
key={day.value}
type="button"
onClick={() => {
const days = eventData.selectedDays || [];
const newDays = days.includes(day.value)
? days.filter((d) => d !== day.value)
: [...days, day.value];
setEventData({ ...eventData, selectedDays: newDays });
}}
className={`px-3 py-1 rounded-full text-sm ${
(eventData.selectedDays || []).includes(day.value)
? 'bg-emerald-100 text-emerald-800'
: 'bg-gray-100 text-gray-600'
}`}
>
{day.label}
</button>
))}
</div>
</div>
)}
{/* Date de fin de récurrence */}
{eventData.recursionType != RecurrenceType.NONE && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fin de récurrence
</label>
<input
type="date"
value={
eventData.recursionEnd
? format(new Date(eventData.recursionEnd), 'yyyy-MM-dd')
: ''
}
onChange={(e) =>
setEventData({
...eventData,
recursionEnd: e.target.value
? new Date(e.target.value).toISOString()
: null,
})
}
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</div>
)}
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Début
</label>
<input
type="datetime-local"
value={format(new Date(eventData.start), "yyyy-MM-dd'T'HH:mm")}
onChange={(e) =>
setEventData({
...eventData,
start: new Date(e.target.value).toISOString(),
})
}
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fin
</label>
<input
type="datetime-local"
value={format(new Date(eventData.end), "yyyy-MM-dd'T'HH:mm")}
onChange={(e) =>
setEventData({
...eventData,
end: new Date(e.target.value).toISOString(),
})
}
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
required
/>
</div>
</div>
{/* Lieu */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Lieu
</label>
<input
type="text"
value={eventData.location || ''}
onChange={(e) =>
setEventData({ ...eventData, location: e.target.value })
}
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</div>
{/* Boutons */}
<div className="flex justify-between gap-2 mt-6">
<div>
{eventData.id && (
<button
type="button"
onClick={handleDelete}
className="px-4 py-2 text-red-600 hover:bg-red-50 rounded"
>
Supprimer
</button>
)}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
>
Annuler
</button>
<button
type="submit"
className="px-4 py-2 bg-emerald-600 text-white rounded hover:bg-emerald-700"
>
{eventData.id ? 'Modifier' : 'Créer'}
</button>
</div>
</div>
</form>
</Modal>
);
}

View File

@ -1,6 +1,15 @@
import React from 'react';
import { usePlanning } from '@/context/PlanningContext';
import { format, startOfWeek, endOfWeek, eachDayOfInterval, startOfMonth, endOfMonth, isSameMonth, isToday } from 'date-fns';
import {
format,
startOfWeek,
endOfWeek,
eachDayOfInterval,
startOfMonth,
endOfMonth,
isSameMonth,
isToday,
} from 'date-fns';
import { fr } from 'date-fns/locale';
import { getEventsForDate } from '@/utils/events';
@ -35,7 +44,8 @@ const MonthView = ({ onDateClick, onEventClick }) => {
onClick={() => handleDayClick(day)}
>
<div className="flex justify-between items-center mb-1">
<span className={`text-sm font-medium rounded-full w-7 h-7 flex items-center justify-center
<span
className={`text-sm font-medium rounded-full w-7 h-7 flex items-center justify-center
${isCurrentDay ? 'bg-emerald-500 text-white' : ''}
${!isCurrentMonth ? 'text-gray-400' : ''}`}
>
@ -50,7 +60,7 @@ const MonthView = ({ onDateClick, onEventClick }) => {
style={{
backgroundColor: `${event.color}15`,
color: event.color,
borderLeft: `2px solid ${event.color}`
borderLeft: `2px solid ${event.color}`,
}}
onClick={(e) => {
e.stopPropagation();
@ -69,8 +79,11 @@ const MonthView = ({ onDateClick, onEventClick }) => {
<div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white">
{/* En-tête des jours de la semaine */}
<div className="grid grid-cols-7 border-b">
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => (
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day) => (
<div
key={day}
className="p-2 text-center text-sm font-medium text-gray-500"
>
{day}
</div>
))}
@ -83,4 +96,4 @@ const MonthView = ({ onDateClick, onEventClick }) => {
);
};
export default MonthView;
export default MonthView;

View File

@ -15,17 +15,20 @@ const PlanningView = ({ events, onEventClick }) => {
// Sinon, créer une entrée pour chaque jour
const days = eachDayOfInterval({ start, end });
return days.map(day => ({
return days.map((day) => ({
...event,
displayDate: day,
isMultiDay: true
isMultiDay: true,
}));
};
// Aplatir tous les événements en incluant les événements sur plusieurs jours
const flattenedEvents = events
.flatMap(splitEventByDays)
.sort((a, b) => new Date(a.displayDate || a.start) - new Date(b.displayDate || b.start));
.sort(
(a, b) =>
new Date(a.displayDate || a.start) - new Date(b.displayDate || b.start)
);
return (
<div className="bg-white h-full overflow-auto">
@ -58,26 +61,29 @@ const PlanningView = ({ events, onEventClick }) => {
<td className="py-3 px-4 text-sm text-gray-900 whitespace-nowrap">
<div className="flex items-center gap-1">
<span className="font-extrabold">{format(start, 'd')}</span>
<span className="font-semibold">{format(start, 'MMM', { locale: fr }).toLowerCase()}</span>
<span className="font-semibold">{format(start, 'EEE', { locale: fr })}</span>
<span className="font-semibold">
{format(start, 'MMM', { locale: fr }).toLowerCase()}
</span>
<span className="font-semibold">
{format(start, 'EEE', { locale: fr })}
</span>
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-900 whitespace-nowrap">
<div className="flex items-center">
<div
<div className="flex items-center">
<div
className="w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: event.color }}
/>
{isMultiDay
? (isSameDay(start, new Date(event.start))
? "À partir de "
: isSameDay(start, end)
? "Jusqu'à "
: "Toute la journée")
: ""
}
{format(new Date(event.start), 'HH:mm')}
{!isMultiDay && ` - ${format(end, 'HH:mm')}`}
{isMultiDay
? isSameDay(start, new Date(event.start))
? 'À partir de '
: isSameDay(start, end)
? "Jusqu'à "
: 'Toute la journée'
: ''}
{format(new Date(event.start), 'HH:mm')}
{!isMultiDay && ` - ${format(end, 'HH:mm')}`}
</div>
</td>
<td className="py-3 px-4">
@ -108,4 +114,4 @@ const PlanningView = ({ events, onEventClick }) => {
);
};
export default PlanningView;
export default PlanningView;

View File

@ -1,27 +1,44 @@
import { useState } from 'react';
import { usePlanning } from '@/context/PlanningContext';
import { usePlanning, PlanningModes } from '@/context/PlanningContext';
import { Plus, Edit2, Eye, EyeOff, Check, X } from 'lucide-react';
import logger from '@/utils/logger';
export default function ScheduleNavigation() {
const { schedules, selectedSchedule, setSelectedSchedule, hiddenSchedules, toggleScheduleVisibility, addSchedule, updateSchedule } = usePlanning();
export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
const {
schedules,
selectedSchedule,
setSelectedSchedule,
hiddenSchedules,
toggleScheduleVisibility,
addSchedule,
updateSchedule,
planningMode,
} = usePlanning();
const [editingId, setEditingId] = useState(null);
const [editedName, setEditedName] = useState('');
const [editedColor, setEditedColor] = useState('');
const [editedSchoolClass, setEditedSchoolClass] = useState(null);
const [isAddingNew, setIsAddingNew] = useState(false);
const [newSchedule, setNewSchedule] = useState({ name: '', color: '#10b981' });
const [newSchedule, setNewSchedule] = useState({
name: '',
color: '#10b981',
school_class: '', // Ajout du champ pour la classe
});
const handleEdit = (schedule) => {
setEditingId(schedule.id);
setEditedName(schedule.name);
setEditedColor(schedule.color);
setEditedSchoolClass(schedule.school_class);
};
const handleSave = () => {
if (editingId) {
updateSchedule(editingId, {
...schedules.find(s => s.id === editingId),
...schedules.find((s) => s.id === editingId),
name: editedName,
color: editedColor
color: editedColor,
school_class: editedSchoolClass, // Ajout de l'ID de la classe
});
setEditingId(null);
}
@ -29,19 +46,30 @@ export default function ScheduleNavigation() {
const handleAddNew = () => {
if (newSchedule.name) {
let payload = {
name: newSchedule.name,
color: newSchedule.color,
};
if (planningMode === PlanningModes.CLASS_SCHEDULE) {
payload.school_class = newSchedule.school_class; // Ajout de l'ID de la classe
}
addSchedule({
id: `schedule-${Date.now()}`,
...newSchedule
...payload,
});
setIsAddingNew(false);
setNewSchedule({ name: '', color: '#10b981' });
setNewSchedule({ name: '', color: '#10b981', school_class: '' });
}
};
return (
<nav className="w-64 border-r p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">Plannings</h2>
<h2 className="font-semibold">
{planningMode === PlanningModes.CLASS_SCHEDULE
? 'Emplois du temps'
: 'Plannings'}
</h2>
<button
onClick={() => setIsAddingNew(true)}
className="p-1 hover:bg-gray-100 rounded"
@ -55,19 +83,52 @@ export default function ScheduleNavigation() {
<input
type="text"
value={newSchedule.name}
onChange={(e) => setNewSchedule(prev => ({ ...prev, name: e.target.value }))}
onChange={(e) =>
setNewSchedule((prev) => ({ ...prev, name: e.target.value }))
}
className="w-full p-1 mb-2 border rounded"
placeholder="Nom du planning"
placeholder={
planningMode === PlanningModes.CLASS_SCHEDULE
? "Nom de l'emplois du temps"
: 'Nom du planning'
}
/>
<div className="flex gap-2 items-center mb-2">
<label className="text-sm">Couleur:</label>
<input
type="color"
value={newSchedule.color}
onChange={(e) => setNewSchedule(prev => ({ ...prev, color: e.target.value }))}
onChange={(e) =>
setNewSchedule((prev) => ({ ...prev, color: e.target.value }))
}
className="w-8 h-8"
/>
</div>
{planningMode === PlanningModes.CLASS_SCHEDULE && (
<div className="mb-2">
<label className="text-sm">Classe (optionnel):</label>
<select
value={newSchedule.school_class}
onChange={(e) =>
setNewSchedule((prev) => ({
...prev,
school_class: e.target.value,
}))
}
className="w-full p-1 border rounded"
>
<option value="">Aucune</option>
{classes.map((classe) => {
logger.debug({ classe });
return (
<option key={classe.id} value={classe.id}>
{classe.atmosphere_name}
</option>
);
})}
</select>
</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={() => setIsAddingNew(false)}
@ -86,11 +147,13 @@ export default function ScheduleNavigation() {
)}
<ul className="space-y-2">
{schedules.map(schedule => (
{schedules.map((schedule) => (
<li
key={schedule.id}
className={`p-2 rounded ${
selectedSchedule === schedule.id ? 'bg-gray-100' : 'hover:bg-gray-50'
selectedSchedule === schedule.id
? 'bg-gray-100'
: 'hover:bg-gray-50'
}`}
>
{editingId === schedule.id ? (
@ -110,6 +173,23 @@ export default function ScheduleNavigation() {
className="w-8 h-8"
/>
</div>
{planningMode === PlanningModes.CLASS_SCHEDULE && (
<div className="mb-2">
<label className="text-sm">Classe:</label>
<select
value={editedSchoolClass}
onChange={(e) => setEditedSchoolClass(e.target.value)}
className="w-full p-1 border rounded"
>
<option value="">Aucune</option>
{classes.map((classe) => (
<option key={classe.id} value={classe.id}>
{classe.atmosphere_name}
</option>
))}
</select>
</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={() => setEditingId(null)}
@ -135,7 +215,13 @@ export default function ScheduleNavigation() {
className="w-3 h-3 rounded-full"
style={{ backgroundColor: schedule.color }}
/>
<span className={hiddenSchedules.includes(schedule.id) ? 'text-gray-400' : ''}>
<span
className={
hiddenSchedules.includes(schedule.id)
? 'text-gray-400'
: ''
}
>
{schedule.name}
</span>
</div>
@ -167,4 +253,4 @@ export default function ScheduleNavigation() {
</ul>
</nav>
);
}
}

View File

@ -1,12 +1,13 @@
import React, { useEffect, useState, useRef } from 'react';
import { usePlanning } from '@/context/PlanningContext';
import { format, startOfWeek, addDays, differenceInMinutes, isSameDay } from 'date-fns';
import { usePlanning, PlanningModes } from '@/context/PlanningContext';
import { format, startOfWeek, addDays, isSameDay } from 'date-fns';
import { fr } from 'date-fns/locale';
import { getWeekEvents } from '@/utils/events';
import { isToday } from 'date-fns';
const WeekView = ({ onDateClick, onEventClick, events }) => {
const { currentDate } = usePlanning();
const { currentDate, planningMode, parentView } = usePlanning();
const [currentTime, setCurrentTime] = useState(new Date());
const scrollContainerRef = useRef(null); // Ajouter cette référence
@ -16,7 +17,7 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
// Maintenant on peut utiliser weekDays
const isCurrentWeek = weekDays.some(day => isSameDay(day, new Date()));
const isCurrentWeek = weekDays.some((day) => isSameDay(day, new Date()));
// Mettre à jour la position de la ligne toutes les minutes
useEffect(() => {
@ -43,7 +44,8 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
const getCurrentTimePosition = () => {
const hours = currentTime.getHours();
const minutes = currentTime.getMinutes();
return `${(hours + minutes / 60) * 5}rem`;
const rowHeight = 5; // Hauteur des lignes en rem (h-20 = 5rem)
return `${(hours + minutes / 60) * rowHeight}rem`;
};
// Utiliser les événements déjà filtrés passés en props
@ -61,7 +63,7 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
const eventStart = new Date(event.start);
const eventEnd = new Date(event.end);
return dayEvents.filter(otherEvent => {
return dayEvents.filter((otherEvent) => {
if (otherEvent.id === event.id) return false;
const otherStart = new Date(otherEvent.start);
const otherEnd = new Date(otherEvent.end);
@ -77,7 +79,7 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
// Trouver les événements qui se chevauchent
const overlappingEvents = findOverlappingEvents(event, dayEvents);
const eventIndex = overlappingEvents.findIndex(e => e.id > event.id) + 1;
const eventIndex = overlappingEvents.findIndex((e) => e.id > event.id) + 1;
const totalOverlapping = overlappingEvents.length + 1;
// Calculer la largeur et la position horizontale
@ -93,7 +95,7 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
borderLeft: `3px solid ${event.color}`,
borderRadius: '0.25rem',
zIndex: 1,
transform: `translateY(${startMinutes}rem)`
transform: `translateY(${startMinutes}rem)`,
};
};
@ -105,20 +107,34 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
key={event.id}
className="rounded-sm overflow-hidden cursor-pointer hover:shadow-lg"
style={eventStyle}
onClick={(e) => {
e.stopPropagation();
onEventClick(event);
}}
onClick={
parentView
? undefined
: (e) => {
e.stopPropagation();
onEventClick(event);
}
}
>
<div className="p-1">
<div className="font-semibold text-xs truncate" style={{ color: event.color }}>
<div
className="font-semibold text-xs truncate"
style={{ color: event.color }}
>
{event.title}
</div>
<div className="text-xs" style={{ color: event.color, opacity: 0.75 }}>
{format(new Date(event.start), 'HH:mm')} - {format(new Date(event.end), 'HH:mm')}
<div
className="text-xs"
style={{ color: event.color, opacity: 0.75 }}
>
{format(new Date(event.start), 'HH:mm')} -{' '}
{format(new Date(event.end), 'HH:mm')}
</div>
{event.location && (
<div className="text-xs truncate" style={{ color: event.color, opacity: 0.75 }}>
<div
className="text-xs truncate"
style={{ color: event.color, opacity: 0.75 }}
>
{event.location}
</div>
)}
@ -128,22 +144,27 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
};
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex flex-col h-full overflow-y-auto">
{/* En-tête des jours */}
<div className="grid gap-[1px] bg-gray-100 pr-[17px]" style={{ gridTemplateColumns: "2.5rem repeat(7, 1fr)" }}>
<div
className="grid gap-[1px] w-full bg-gray-100"
style={{ gridTemplateColumns: '2.5rem repeat(7, 1fr)' }}
>
<div className="bg-white h-14"></div>
{weekDays.map((day) => (
<div
key={day}
className={`p-2 text-center border-b
className={`h-14 p-2 text-center border-b
${isWeekend(day) ? 'bg-gray-50' : 'bg-white'}
${isToday(day) ? 'bg-emerald-100 border-x border-emerald-600' : ''}`}
>
<div className="text-xs font-medium text-gray-500">
{format(day, 'EEEE', { locale: fr })}
</div>
<div className={`text-sm font-semibold inline-block rounded-full w-7 h-7 leading-7
${isToday(day) ? 'bg-emerald-500 text-white' : ''}`}>
<div
className={`text-sm font-semibold inline-block rounded-full w-7 h-7 leading-7
${isToday(day) ? 'bg-emerald-500 text-white' : ''}`}
>
{format(day, 'd', { locale: fr })}
</div>
</div>
@ -151,24 +172,24 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
</div>
{/* Grille horaire */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto relative">
<div ref={scrollContainerRef} className="flex-1 relative">
{/* Ligne de temps actuelle */}
{isCurrentWeek && (
<div
className="absolute left-0 right-0 z-10 border-emerald-500 border pointer-events-none"
style={{
top: getCurrentTimePosition(),
}}
>
<div
className="absolute -left-2 -top-1 w-2 h-2 rounded-full bg-emerald-500"
/>
<div className="absolute -left-2 -top-2 w-2 h-2 rounded-full bg-emerald-500" />
</div>
)}
<div className="grid gap-[1px] bg-gray-100" style={{ gridTemplateColumns: "2.5rem repeat(7, 1fr)" }}>
{timeSlots.map(hour => (
<div
className="grid gap-[1px] w-full bg-gray-100"
style={{ gridTemplateColumns: '2.5rem repeat(7, 1fr)' }}
>
{timeSlots.map((hour) => (
<React.Fragment key={hour}>
<div className="h-20 p-1 text-right text-sm text-gray-500 bg-gray-100 font-medium">
{`${hour.toString().padStart(2, '0')}:00`}
@ -182,17 +203,23 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
className={`h-20 relative border-b border-gray-100
${isWeekend(day) ? 'bg-gray-50' : 'bg-white'}
${isToday(day) ? 'bg-emerald-100/50 border-x border-emerald-600' : ''}`}
onClick={() => {
const date = new Date(day);
date.setHours(hour);
onDateClick(date);
}}
onClick={
parentView
? undefined
: () => {
const date = new Date(day);
date.setHours(hour);
onDateClick(date);
}
}
>
<div className="flex gap-1"> {/* Ajout de gap-1 */}
{dayEvents.filter(event => {
const eventStart = new Date(event.start);
return eventStart.getHours() === hour;
}).map(event => renderEventInCell(event, dayEvents))}
<div className="grid gap-1">
{dayEvents
.filter((event) => {
const eventStart = new Date(event.start);
return eventStart.getHours() === hour;
})
.map((event) => renderEventInCell(event, dayEvents))}
</div>
</div>
);
@ -205,4 +232,4 @@ const WeekView = ({ onDateClick, onEventClick, events }) => {
);
};
export default WeekView;
export default WeekView;

View File

@ -37,7 +37,7 @@ const YearView = ({ onDateClick }) => {
return (
<div className="grid grid-cols-4 gap-4 p-4">
{months.map(month => (
{months.map((month) => (
<MonthCard
key={month.getTime()}
month={month}
@ -49,4 +49,4 @@ const YearView = ({ onDateClick }) => {
);
};
export default YearView;
export default YearView;

View File

@ -0,0 +1,45 @@
import React from 'react';
export default function LineChart({ data }) {
if (!data || data.length === 0) {
return <div className="text-gray-400 text-center">Aucune donnée</div>;
}
// Hauteur max du graphique en pixels
const chartHeight = 120;
const maxValue = Math.max(...data.map((d) => d.value), 1);
// Trouver les indices des barres ayant la valeur max (pour gérer les égalités)
const maxIndices = data
.map((d, idx) => (d.value === maxValue ? idx : -1))
.filter((idx) => idx !== -1);
return (
<div
className="w-full flex items-end space-x-4"
style={{ height: chartHeight }}
>
{data.map((point, idx) => {
const barHeight = Math.max((point.value / maxValue) * chartHeight, 8); // min 8px
const isMax = maxIndices.includes(idx);
return (
<div key={idx} className="flex flex-col items-center flex-1">
{/* Valeur au-dessus de la barre */}
<span className="text-xs mb-1 text-gray-700 font-semibold">
{point.value}
</span>
<div
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`}
style={{
height: `${barHeight}px`,
transition: 'height 0.3s',
}}
title={`${point.month}: ${point.value}`}
/>
<span className="text-xs mt-1 text-gray-600">{point.month}</span>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,85 @@
import React from 'react';
// Utilisation de couleurs hexadécimales pour le SVG
const COLORS = [
'#60a5fa', // bleu-400
'#fb923c', // orange-400
'#a78bfa', // violet-400
'#34d399', // émeraude-400
];
export default function PieChart({ data }) {
if (!data || data.length === 0) {
return <div className="text-gray-400 text-center">Aucune donnée</div>;
}
const total = data.reduce((acc, d) => acc + d.value, 0);
if (total === 0) {
return <div className="text-gray-400 text-center">Aucune donnée</div>;
}
let cumulative = 0;
return (
<div className="flex items-center justify-center w-full">
<svg width={100} height={100} viewBox="0 0 32 32">
{data.map((slice, idx) => {
if (slice.value === 0) return null;
const value = (slice.value / total) * 100;
if (value === 100) {
// Cas 100% : on dessine un cercle plein
return (
<circle
key={idx}
cx="16"
cy="16"
r="16"
fill={COLORS[idx % COLORS.length]}
/>
);
}
const startAngle = (cumulative / 100) * 360;
const endAngle = ((cumulative + value) / 100) * 360;
const largeArc = value > 50 ? 1 : 0;
const x1 = 16 + 16 * Math.cos((Math.PI * (startAngle - 90)) / 180);
const y1 = 16 + 16 * Math.sin((Math.PI * (startAngle - 90)) / 180);
const x2 = 16 + 16 * Math.cos((Math.PI * (endAngle - 90)) / 180);
const y2 = 16 + 16 * Math.sin((Math.PI * (endAngle - 90)) / 180);
const pathData = `
M16,16
L${x1},${y1}
A16,16 0 ${largeArc} 1 ${x2},${y2}
Z
`;
cumulative += value;
return (
<path
key={idx}
d={pathData}
fill={COLORS[idx % COLORS.length]}
stroke="#fff"
strokeWidth="0.5"
/>
);
})}
</svg>
<div className="ml-4 flex flex-col space-y-1">
{data.map((slice, idx) =>
slice.value > 0 && (
<div
key={idx}
className="flex items-center text-xs font-semibold"
style={{ color: COLORS[idx % COLORS.length] }}
>
<span
className="inline-block w-3 h-3 mr-2 rounded"
style={{ backgroundColor: COLORS[idx % COLORS.length] }}
/>
{slice.label} : {slice.value}
</div>
)
)}
</div>
</div>
);
}

View File

@ -0,0 +1,79 @@
import React from 'react';
import { Wifi, WifiOff, RotateCcw } from 'lucide-react';
const ConnectionStatus = ({ status, onReconnect }) => {
const getStatusInfo = () => {
switch (status) {
case 'connected':
return {
icon: <Wifi className="w-4 h-4" />,
text: 'Connecté',
className: 'text-green-600 bg-green-50 border-green-200',
};
case 'disconnected':
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Déconnecté',
className: 'text-red-600 bg-red-50 border-red-200',
};
case 'reconnecting':
return {
icon: <RotateCcw className="w-4 h-4 animate-spin" />,
text: 'Reconnexion...',
className: 'text-yellow-600 bg-yellow-50 border-yellow-200',
};
case 'error':
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Erreur de connexion',
className: 'text-red-600 bg-red-50 border-red-200',
};
case 'failed':
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Connexion échouée',
className: 'text-red-600 bg-red-50 border-red-200',
};
default:
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Inconnu',
className: 'text-gray-600 bg-gray-50 border-gray-200',
};
}
};
if (status === 'connected') {
return (
<div className="flex items-center justify-between px-3 py-2 border rounded-lg text-green-600 bg-green-50 border-green-200">
<div className="flex items-center space-x-2">
<Wifi className="w-4 h-4" />
<span className="text-sm font-medium">Connecté</span>
</div>
</div>
);
}
const { icon, text, className } = getStatusInfo();
return (
<div
className={`flex items-center justify-between px-3 py-2 border rounded-lg ${className}`}
>
<div className="flex items-center space-x-2">
{icon}
<span className="text-sm font-medium">{text}</span>
</div>
{(status === 'failed' || status === 'error') && onReconnect && (
<button
onClick={onReconnect}
className="text-sm underline hover:no-underline"
>
Réessayer
</button>
)}
</div>
);
};
export default ConnectionStatus;

View File

@ -0,0 +1,196 @@
import React from 'react';
import { User, Trash2 } from 'lucide-react';
import { getGravatarUrl } from '@/utils/gravatar';
const ConversationItem = ({
conversation,
isSelected,
onClick,
onDelete, // Nouvelle prop pour la suppression
unreadCount = 0,
lastMessage,
isTyping = false,
userPresences = {}, // Nouveau prop pour les statuts de présence
}) => {
const formatTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now - date) / (1000 * 60 * 60);
if (diffInHours < 24) {
return date.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
});
} else {
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
});
}
};
const getInterlocutorName = () => {
if (conversation.interlocuteur) {
// Si nous avons le nom et prénom, les utiliser
if (
conversation.interlocuteur.first_name &&
conversation.interlocuteur.last_name
) {
return `${conversation.interlocuteur.first_name} ${conversation.interlocuteur.last_name}`;
}
// Sinon, utiliser l'email comme fallback
if (conversation.interlocuteur.email) {
return conversation.interlocuteur.email;
}
}
return conversation.name || 'Utilisateur inconnu';
};
const getLastMessageText = () => {
if (isTyping) {
return (
<span className="text-emerald-500 italic">Tape un message...</span>
);
}
if (lastMessage) {
return lastMessage.content || lastMessage.corpus || 'Message...';
}
if (conversation.last_message) {
return (
conversation.last_message.content ||
conversation.last_message.corpus ||
'Message...'
);
}
return 'Aucun message';
};
const getLastMessageTime = () => {
if (lastMessage) {
return formatTime(lastMessage.created_at || lastMessage.date_envoi);
}
if (conversation.last_message) {
return formatTime(
conversation.last_message.created_at ||
conversation.last_message.date_envoi
);
}
return '';
};
const getUserPresenceStatus = () => {
if (conversation.interlocuteur?.id) {
const presence = userPresences[conversation.interlocuteur.id];
return presence?.status || 'offline';
}
return 'offline';
};
const getPresenceColor = (status) => {
switch (status) {
case 'online':
return 'bg-emerald-400';
case 'away':
return 'bg-yellow-400';
case 'busy':
return 'bg-red-400';
case 'offline':
default:
return 'bg-gray-400';
}
};
const getPresenceLabel = (status) => {
switch (status) {
case 'online':
return 'En ligne';
case 'away':
return 'Absent';
case 'busy':
return 'Occupé';
case 'offline':
default:
return 'Hors ligne';
}
};
const presenceStatus = getUserPresenceStatus();
return (
<div
className={`group flex items-center p-3 cursor-pointer rounded-lg transition-all duration-200 hover:bg-gray-50 ${
isSelected
? 'bg-emerald-50 border-l-4 border-emerald-500'
: 'hover:bg-gray-50'
}`}
onClick={onClick}
>
{/* Avatar */}
<div className="relative">
<img
src={getGravatarUrl(
conversation.interlocuteur?.email || 'default',
48
)}
alt={`Avatar de ${getInterlocutorName()}`}
className="w-12 h-12 rounded-full object-cover shadow-md"
/>
{/* Indicateur de statut en ligne */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-4 h-4 ${getPresenceColor(presenceStatus)} border-2 border-white rounded-full`}
title={getPresenceLabel(presenceStatus)}
></div>
</div>
{/* Contenu de la conversation */}
<div className="flex-1 ml-3 overflow-hidden">
<div className="flex items-center justify-between">
<h3
className={`font-semibold truncate ${
isSelected ? 'text-emerald-700' : 'text-gray-900'
}`}
>
{getInterlocutorName()}
</h3>
<div className="flex items-center space-x-2">
{unreadCount > 0 && (
<span className="bg-red-500 text-white text-xs rounded-full w-4 h-4 text-center"></span>
)}
<span className="text-xs text-gray-500">
{getLastMessageTime()}
</span>
{/* Bouton de suppression */}
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation(); // Empêcher la sélection de la conversation
onDelete();
}}
className="opacity-0 group-hover:opacity-100 hover:bg-red-100 p-1 rounded transition-all duration-200"
title="Supprimer la conversation"
>
<Trash2 className="w-4 h-4 text-red-500 hover:text-red-700" />
</button>
)}
</div>
</div>
<p
className={`text-sm truncate mt-1 ${isTyping ? '' : 'text-gray-600'}`}
>
{getLastMessageText()}
</p>
</div>
</div>
);
};
export default ConversationItem;

View File

@ -0,0 +1,115 @@
import React from 'react';
import {
Download,
FileText,
Image,
Film,
Music,
Archive,
AlertCircle,
} from 'lucide-react';
const FileAttachment = ({
fileName,
fileSize,
fileType,
fileUrl,
onDownload = null,
}) => {
// Obtenir l'icône en fonction du type de fichier
const getFileIcon = (type) => {
if (type.startsWith('image/')) {
return <Image className="w-6 h-6 text-blue-500" />;
}
if (type.startsWith('video/')) {
return <Film className="w-6 h-6 text-purple-500" />;
}
if (type.startsWith('audio/')) {
return <Music className="w-6 h-6 text-green-500" />;
}
if (type.includes('pdf')) {
return <FileText className="w-6 h-6 text-red-500" />;
}
if (type.includes('zip') || type.includes('rar')) {
return <Archive className="w-6 h-6 text-yellow-500" />;
}
return <FileText className="w-6 h-6 text-gray-500" />;
};
// Formater la taille du fichier
const formatFileSize = (bytes) => {
if (!bytes) return '';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Gérer le téléchargement
const handleDownload = () => {
if (onDownload) {
onDownload();
} else if (fileUrl) {
const link = document.createElement('a');
link.href = fileUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
// Vérifier si c'est une image pour afficher un aperçu
const isImage = fileType && fileType.startsWith('image/');
return (
<div className="max-w-sm">
{isImage && fileUrl ? (
// Affichage pour les images
<div className="relative group">
<img
src={fileUrl}
alt={fileName}
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(fileUrl, '_blank')}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
<button
onClick={handleDownload}
className="opacity-0 group-hover:opacity-100 bg-white bg-opacity-90 hover:bg-opacity-100 rounded-full p-2 transition-all"
>
<Download className="w-4 h-4 text-gray-700" />
</button>
</div>
{fileName && (
<p className="mt-1 text-xs text-gray-500 truncate">{fileName}</p>
)}
</div>
) : (
// Affichage pour les autres fichiers
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg border hover:bg-gray-100 transition-colors">
<div className="flex-shrink-0">{getFileIcon(fileType)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{fileName || 'Fichier sans nom'}
</p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</p>
)}
</div>
<button
onClick={handleDownload}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 transition-colors"
title="Télécharger"
>
<Download className="w-4 h-4" />
</button>
</div>
)}
</div>
);
};
export default FileAttachment;

View File

@ -0,0 +1,179 @@
import React, { useState } from 'react';
import { X, Upload, FileText, Image, AlertCircle } from 'lucide-react';
const FileUpload = ({
file,
onUpload,
onCancel,
conversationId,
senderId,
maxSize = 10 * 1024 * 1024, // 10MB par défaut
}) => {
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState(null);
// Vérifier le type de fichier et obtenir l'icône appropriée
const getFileIcon = (fileType) => {
if (fileType.startsWith('image/')) {
return <Image className="w-8 h-8 text-blue-500" alt="Icône image" />;
}
return <FileText className="w-8 h-8 text-gray-500" alt="Icône fichier" />;
};
// Formater la taille du fichier
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Vérifier si le fichier est valide
const isValidFile = () => {
const allowedTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
];
if (!allowedTypes.includes(file.type)) {
return false;
}
if (file.size > maxSize) {
return false;
}
return true;
};
// Gérer l'upload
const handleUpload = async () => {
if (!isValidFile()) {
setError('Type de fichier non autorisé ou fichier trop volumineux');
return;
}
setIsUploading(true);
setError(null);
try {
const result = await onUpload(
file,
conversationId,
senderId,
setUploadProgress
);
// L'upload s'est bien passé, le parent gère la suite
} catch (error) {
setError(error.message || "Erreur lors de l'upload");
setIsUploading(false);
}
};
if (!file) return null;
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-lg">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-3">
{getFileIcon(file.type)}
<div>
<p className="font-medium text-gray-900 truncate max-w-xs">
{file.name}
</p>
<p className="text-sm text-gray-500">{formatFileSize(file.size)}</p>
</div>
</div>
<button
onClick={onCancel}
disabled={isUploading}
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Prévisualisation pour les images */}
{file.type.startsWith('image/') && (
<div className="mb-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={URL.createObjectURL(file)}
alt="Aperçu du fichier sélectionné"
className="max-w-full h-32 object-cover rounded-lg"
/>
</div>
)}
{/* Validation du fichier */}
{!isValidFile() && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-700">
{file.size > maxSize
? `Fichier trop volumineux (max ${formatFileSize(maxSize)})`
: 'Type de fichier non autorisé'}
</span>
</div>
)}
{/* Erreur d'upload */}
{error && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-700">{error}</span>
</div>
)}
{/* Barre de progression */}
{isUploading && (
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-gray-600">Upload en cours...</span>
<span className="text-sm text-gray-600">
{Math.round(uploadProgress)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* Boutons d'action */}
<div className="flex justify-end space-x-2">
<button
onClick={onCancel}
disabled={isUploading}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleUpload}
disabled={isUploading || !isValidFile()}
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Upload className="w-4 h-4" />
<span>{isUploading ? 'Upload...' : 'Envoyer'}</span>
</button>
</div>
</div>
);
};
export default FileUpload;

File diff suppressed because it is too large Load Diff

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