Merge pull request 'NEWTS-9-Fusion_Liste_frais' (!73) from NEWTS-9-Fusion_Liste_frais into develop

Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/73
This commit is contained in:
Luc SORIGNET
2026-03-15 11:11:34 +00:00
15 changed files with 1169 additions and 294 deletions

View File

@ -56,3 +56,4 @@ Pour le front-end, les exigences de qualité sont les suivantes :
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md) - **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md) - **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
- **Tests** : [run tests](./instructions/run-tests.instruction.md)

View File

@ -0,0 +1,53 @@
---
applyTo: "**"
---
# Lancer les tests N3WT-SCHOOL
## Tests backend (Django)
Les tests backend tournent dans le conteneur Docker. Toujours utiliser `--settings=N3wtSchool.test_settings`.
```powershell
# Tous les tests
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings --verbosity=2
# Un module spécifique
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests --verbosity=2
```
### Points importants
- Le fichier `Back-End/N3wtSchool/test_settings.py` configure l'environnement de test :
- Base PostgreSQL dédiée `school_test` (SQLite incompatible avec `ArrayField`)
- Cache en mémoire locale (pas de Redis)
- Channels en mémoire (`InMemoryChannelLayer`)
- Throttling désactivé
- Hashage MD5 (plus rapide)
- Email en mode `locmem`
- Si le conteneur n'est pas démarré : `docker compose up -d` depuis la racine du projet
- Les logs `WARNING` dans la sortie des tests sont normaux (endpoints qui retournent 400/401 intentionnellement)
## Tests frontend (Jest)
```powershell
# Depuis le dossier Front-End
cd Front-End
npm test -- --watchAll=false
# Avec couverture
npm test -- --watchAll=false --coverage
```
### Points importants
- Les tests sont dans `Front-End/src/test/`
- Les warnings `ReactDOMTestUtils.act is deprecated` sont non bloquants (dépendance `@testing-library/react`)
- Config Jest : `Front-End/jest.config.js`
## Résultats attendus
| Périmètre | Nb tests | Statut |
| -------------- | -------- | ------ |
| Backend Django | 121 | ✅ OK |
| Frontend Jest | 24 | ✅ OK |

View File

@ -31,5 +31,5 @@ returnMessage = {
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée', WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
PROFIL_INACTIVE: 'Le profil n\'est pas actif', PROFIL_INACTIVE: 'Le profil n\'est pas actif',
MESSAGE_ACTIVATION_PROFILE: 'Votre profil a été activé avec succès', MESSAGE_ACTIVATION_PROFILE: 'Votre profil a été activé avec succès',
PROFIL_ACTIVE: 'Le profil est déjà actif', PROFIL_ACTIVE: 'Un compte a été détecté et existe déjà pour cet établissement',
} }

View File

@ -0,0 +1,66 @@
"""
Settings de test pour l'exécution des tests unitaires Django.
Utilise la base PostgreSQL du docker-compose (ArrayField non supporté par SQLite).
Redis et Celery sont désactivés.
"""
import os
os.environ.setdefault('SECRET_KEY', 'django-insecure-test-secret-key-for-unit-tests-only')
os.environ.setdefault('WEBHOOK_API_KEY', 'test-webhook-api-key-for-unit-tests-only')
os.environ.setdefault('DJANGO_DEBUG', 'True')
from N3wtSchool.settings import * # noqa: F401, F403
# Base de données PostgreSQL dédiée aux tests (isolée de la base de prod)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'school_test',
'USER': os.environ.get('DB_USER', 'postgres'),
'PASSWORD': os.environ.get('DB_PASSWORD', 'postgres'),
'HOST': os.environ.get('DB_HOST', 'database'),
'PORT': os.environ.get('DB_PORT', '5432'),
'TEST': {
'NAME': 'school_test',
},
}
}
# Cache en mémoire locale (pas de Redis)
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
# Sessions en base de données (plus simple que le cache pour les tests)
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
# Django Channels en mémoire (pas de Redis)
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer',
}
}
# Désactiver Celery pendant les tests
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
# Email en mode console (pas d'envoi réel)
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
# Clé secrète fixe pour les tests
SECRET_KEY = 'django-insecure-test-secret-key-for-unit-tests-only'
SIMPLE_JWT['SIGNING_KEY'] = SECRET_KEY # noqa: F405
# Désactiver le throttling pendant les tests
REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [] # noqa: F405
REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = {} # noqa: F405
# Accélérer le hashage des mots de passe pour les tests
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# Désactiver les logs verbeux pendant les tests
LOGGING['root']['level'] = 'CRITICAL' # noqa: F405

View File

@ -284,3 +284,70 @@ class EstablishmentCompetencyEndpointAuthTest(TestCase):
_assert_endpoint_requires_auth( _assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"competency": 1} self, "post", self.list_url, payload={"competency": 1}
) )
# ---------------------------------------------------------------------------
# Fee - validation du paramètre filter
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class FeeFilterValidationTest(TestCase):
"""Tests de validation du paramètre 'filter' sur l'endpoint Fee list."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("School:fee_list_create")
self.user = create_user("fee_filter_test@example.com")
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
def test_get_fees_sans_filter_retourne_400(self):
"""GET sans paramètre 'filter' doit retourner 400."""
response = self.client.get(self.list_url, {"establishment_id": 1})
self.assertEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET sans filter devrait retourner 400",
)
def test_get_fees_filter_invalide_retourne_400(self):
"""GET avec un filtre inconnu doit retourner 400."""
response = self.client.get(
self.list_url, {"establishment_id": 1, "filter": "unknown"}
)
self.assertEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET avec filter='unknown' devrait retourner 400",
)
def test_get_fees_filter_registration_accepte(self):
"""GET avec filter='registration' doit être accepté (200 ou 400 si establishment manquant)."""
response = self.client.get(
self.list_url, {"establishment_id": 99999, "filter": "registration"}
)
self.assertNotEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET avec filter='registration' ne doit pas retourner 400 pour une raison de filtre invalide",
)
def test_get_fees_filter_tuition_accepte(self):
"""GET avec filter='tuition' doit être accepté (200 ou autre selon l'establishment)."""
response = self.client.get(
self.list_url, {"establishment_id": 99999, "filter": "tuition"}
)
self.assertNotEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET avec filter='tuition' ne doit pas retourner 400 pour une raison de filtre invalide",
)
def test_get_fees_sans_establishment_id_retourne_400(self):
"""GET sans establishment_id doit retourner 400."""
response = self.client.get(self.list_url, {"filter": "registration"})
self.assertEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET sans establishment_id devrait retourner 400",
)

View File

@ -12,6 +12,7 @@ from .models import (
Planning, Planning,
Discount, Discount,
Fee, Fee,
FeeType,
PaymentPlan, PaymentPlan,
PaymentMode, PaymentMode,
EstablishmentCompetency, EstablishmentCompetency,
@ -288,7 +289,13 @@ class FeeListCreateView(APIView):
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
filter = request.GET.get('filter', '').strip() filter = request.GET.get('filter', '').strip()
fee_type_value = 0 if filter == 'registration' else 1 if filter not in ('registration', 'tuition'):
return JsonResponse(
{'error': "Le paramètre 'filter' doit être 'registration' ou 'tuition'"},
safe=False,
status=status.HTTP_400_BAD_REQUEST,
)
fee_type_value = FeeType.REGISTRATION_FEE if filter == 'registration' else FeeType.TUITION_FEE
fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct() fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct()
fee_serializer = FeeSerializer(fees, many=True) fee_serializer = FeeSerializer(fees, many=True)

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { DollarSign } from 'lucide-react'; import { DollarSign } from 'lucide-react';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import logger from '@/utils/logger';
const paymentModesOptions = [ const paymentModesOptions = [
{ id: 1, name: 'Prélèvement SEPA' }, { id: 1, name: 'Prélèvement SEPA' },
@ -9,8 +10,14 @@ const paymentModesOptions = [
{ id: 4, name: 'Espèce' }, { id: 4, name: 'Espèce' },
]; ];
/**
* Affiche les modes de paiement communs aux deux types de frais.
* Quand `allPaymentModes` est fourni (mode unifié), un mode activé est créé
* pour les deux types (inscription 0 ET scolarité 1).
*/
const PaymentModeSelector = ({ const PaymentModeSelector = ({
paymentModes, paymentModes,
allPaymentModes,
setPaymentModes, setPaymentModes,
handleCreate, handleCreate,
handleDelete, handleDelete,
@ -19,23 +26,45 @@ const PaymentModeSelector = ({
const [activePaymentModes, setActivePaymentModes] = useState([]); const [activePaymentModes, setActivePaymentModes] = useState([]);
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const modes = useMemo(
() =>
Array.isArray(allPaymentModes)
? allPaymentModes
: Array.isArray(paymentModes)
? paymentModes
: [],
[allPaymentModes, paymentModes]
);
const unified = !!allPaymentModes;
useEffect(() => { useEffect(() => {
const activeModes = paymentModes.map((mode) => mode.mode); const activeModes = [...new Set(modes.map((mode) => mode.mode))];
setActivePaymentModes(activeModes); setActivePaymentModes(activeModes);
}, [paymentModes]); }, [modes]);
const handleModeToggle = (modeId) => { const handleModeToggle = (modeId) => {
const updatedMode = paymentModes.find((mode) => mode.mode === modeId); const isActive = activePaymentModes.includes(modeId);
const isActive = !!updatedMode;
if (!isActive) { if (!isActive) {
handleCreate({ if (unified) {
mode: modeId, [0, 1].forEach((t) =>
type, handleCreate({
establishment: selectedEstablishmentId, mode: modeId,
}); type: t,
establishment: selectedEstablishmentId,
}).catch((e) => logger.error(e))
);
} else {
handleCreate({
mode: modeId,
type,
establishment: selectedEstablishmentId,
}).catch((e) => logger.error(e));
}
} else { } else {
handleDelete(updatedMode.id, null); const toDelete = modes.filter((m) => m.mode === modeId);
toDelete.forEach((m) =>
handleDelete(m.id, null).catch((e) => logger.error(e))
);
} }
}; };

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Calendar } from 'lucide-react'; import { Calendar } from 'lucide-react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -13,8 +13,22 @@ const paymentPlansOptions = [
{ id: 4, name: '12 fois', frequency: 12 }, { id: 4, name: '12 fois', frequency: 12 },
]; ];
/**
* Affiche les plans de paiement communs aux deux types de frais.
* Quand `allPaymentPlans` est fourni (mode unifié), un plan coché est créé pour
* les deux types (inscription 0 ET scolarité 1) en même temps.
*
* Props (mode unifié) :
* allPaymentPlans : [{plan_type, type, ...}, ...] - liste combinée des deux types
* handleCreate : (data) => Promise - avec type et establishment déjà présent dans data
* handleDelete : (id) => Promise
*
* Props (mode legacy) :
* paymentPlans, handleCreate, handleDelete, type
*/
const PaymentPlanSelector = ({ const PaymentPlanSelector = ({
paymentPlans, paymentPlans,
allPaymentPlans,
handleCreate, handleCreate,
handleDelete, handleDelete,
type, type,
@ -24,38 +38,63 @@ const PaymentPlanSelector = ({
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const [checkedPlans, setCheckedPlans] = useState([]); const [checkedPlans, setCheckedPlans] = useState([]);
// Vérifie si un plan existe pour ce type (par id) const plans = useMemo(
() =>
Array.isArray(allPaymentPlans)
? allPaymentPlans
: Array.isArray(paymentPlans)
? paymentPlans
: [],
[allPaymentPlans, paymentPlans]
);
const unified = !!allPaymentPlans;
// Un plan est coché si au moins un enregistrement existe pour cette option
const isChecked = (planOption) => checkedPlans.includes(planOption.id); const isChecked = (planOption) => checkedPlans.includes(planOption.id);
// Création ou suppression du plan
const handlePlanToggle = (planOption) => { const handlePlanToggle = (planOption) => {
const updatedPlan = paymentPlans.find(
(plan) => plan.plan_type === planOption.id
);
if (isChecked(planOption)) { if (isChecked(planOption)) {
// Supprimer tous les enregistrements correspondant à cette option (les deux types en mode unifié)
const toDelete = plans.filter(
(p) =>
(typeof p.plan_type === 'object' ? p.plan_type.id : p.plan_type) ===
planOption.id
);
setCheckedPlans((prev) => prev.filter((id) => id !== planOption.id)); setCheckedPlans((prev) => prev.filter((id) => id !== planOption.id));
handleDelete(updatedPlan.id, null); toDelete.forEach((p) =>
handleDelete(p.id, null).catch((e) => logger.error(e))
);
} else { } else {
setCheckedPlans((prev) => [...prev, planOption.id]); setCheckedPlans((prev) => [...prev, planOption.id]);
handleCreate({ if (unified) {
plan_type: planOption.id, // Créer pour inscription (0) et scolarité (1)
type, [0, 1].forEach((t) =>
establishment: selectedEstablishmentId, handleCreate({
}); plan_type: planOption.id,
type: t,
establishment: selectedEstablishmentId,
}).catch((e) => logger.error(e))
);
} else {
handleCreate({
plan_type: planOption.id,
type,
establishment: selectedEstablishmentId,
}).catch((e) => logger.error(e));
}
} }
}; };
useEffect(() => { useEffect(() => {
if (paymentPlans && paymentPlans.length > 0) { if (plans.length > 0) {
setCheckedPlans( const ids = plans.map((plan) =>
paymentPlans.map((plan) => typeof plan.plan_type === 'object' ? plan.plan_type.id : plan.plan_type
typeof plan.plan_type === 'object'
? plan.plan_type.id
: plan.plan_type
)
); );
setCheckedPlans([...new Set(ids)]);
} else {
setCheckedPlans([]);
} }
}, [paymentPlans]); }, [plans]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">

View File

@ -9,6 +9,8 @@ import SectionHeader from '@/components/SectionHeader';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
const DISCOUNT_TYPE_LABELS = { 0: 'Inscription', 1: 'Scolarité' };
const DiscountsSection = ({ const DiscountsSection = ({
discounts, discounts,
setDiscounts, setDiscounts,
@ -16,6 +18,7 @@ const DiscountsSection = ({
handleEdit, handleEdit,
handleDelete, handleDelete,
type, type,
unified = false,
subscriptionMode = false, subscriptionMode = false,
selectedDiscounts, selectedDiscounts,
handleDiscountSelection, handleDiscountSelection,
@ -39,7 +42,7 @@ const DiscountsSection = ({
amount: '', amount: '',
description: '', description: '',
discount_type: 0, discount_type: 0,
type: type, type: unified ? 0 : type,
establishment: selectedEstablishmentId, establishment: selectedEstablishmentId,
}); });
}; };
@ -219,6 +222,21 @@ const DiscountsSection = ({
handleChange, handleChange,
'Description' 'Description'
); );
case 'TYPE':
return (
<select
className="border rounded px-2 py-1 text-sm"
value={currentData.type}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (editingDiscount) setFormData((p) => ({ ...p, type: val }));
else setNewDiscount((p) => ({ ...p, type: val }));
}}
>
<option value={0}>Inscription</option>
<option value={1}>Scolarité</option>
</select>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -259,6 +277,18 @@ const DiscountsSection = ({
return discount.description; return discount.description;
case 'MISE A JOUR': case 'MISE A JOUR':
return discount.updated_at_formatted; return discount.updated_at_formatted;
case 'TYPE':
return (
<span
className={`text-xs font-semibold px-2 py-1 rounded-full ${
discount.type === 0
? 'bg-blue-100 text-blue-700'
: 'bg-purple-100 text-purple-700'
}`}
>
{DISCOUNT_TYPE_LABELS[discount.type]}
</span>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -335,34 +365,25 @@ const DiscountsSection = ({
{ name: 'LIBELLE', label: 'Libellé' }, { name: 'LIBELLE', label: 'Libellé' },
{ name: 'DESCRIPTION', label: 'Description' }, { name: 'DESCRIPTION', label: 'Description' },
{ name: 'REMISE', label: 'Remise' }, { name: 'REMISE', label: 'Remise' },
...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: '', label: 'Sélection' }, { name: '', label: 'Sélection' },
] ]
: [ : [
{ name: 'LIBELLE', label: 'Libellé' }, { name: 'LIBELLE', label: 'Libellé' },
{ name: 'REMISE', label: 'Remise' }, { name: 'REMISE', label: 'Remise' },
{ name: 'DESCRIPTION', label: 'Description' }, { name: 'DESCRIPTION', label: 'Description' },
...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: 'MISE A JOUR', label: 'Date mise à jour' }, { name: 'MISE A JOUR', label: 'Date mise à jour' },
{ name: 'ACTIONS', label: 'Actions' }, { name: 'ACTIONS', label: 'Actions' },
]; ];
let emptyMessage; const emptyMessage = (
if (type === 0) { <AlertMessage
emptyMessage = ( type="info"
<AlertMessage title="Aucune réduction enregistrée"
type="info" message="Aucune réduction n'a encore été enregistrée"
title="Aucune réduction enregistrée" />
message="Aucune réduction sur les frais d'inscription n'a été enregistrée" );
/>
);
} else {
emptyMessage = (
<AlertMessage
type="info"
title="Aucune réduction enregistrée"
message="Aucune réduction sur les frais de scolarité n'a été enregistrée"
/>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -370,8 +391,8 @@ const DiscountsSection = ({
<SectionHeader <SectionHeader
icon={Tag} icon={Tag}
discountStyle={true} discountStyle={true}
title={`${type == 0 ? "Liste des réductions sur les frais d'inscription" : 'Liste des réductions sur les frais de scolarité'}`} title="Liste des réductions"
description={`Gérez ${type == 0 ? " vos réductions sur les frais d'inscription" : ' vos réductions sur les frais de scolarité'}`} description="Gérez vos réductions sur les frais d'inscription et de scolarité"
button={!subscriptionMode} button={!subscriptionMode}
onClick={handleAddDiscount} onClick={handleAddDiscount}
/> />

View File

@ -0,0 +1,145 @@
import React from 'react';
import FeesSection from '@/components/Structure/Tarification/FeesSection';
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
import PaymentPlanSelector from '@/components/PaymentPlanSelector';
import PaymentModeSelector from '@/components/PaymentModeSelector';
import {
BE_SCHOOL_FEES_URL,
BE_SCHOOL_DISCOUNTS_URL,
BE_SCHOOL_PAYMENT_PLANS_URL,
BE_SCHOOL_PAYMENT_MODES_URL,
} from '@/utils/Url';
/**
* Bloc complet de gestion des frais pour un type donné (inscription ou scolarité).
* Regroupe : liste des frais, réductions, plans et modes de paiement.
*
* @param {string} title - Titre affiché dans le séparateur de section
* @param {Array} fees - Liste des frais du type
* @param {Function} setFees - Setter des frais
* @param {Array} discounts - Liste des réductions du type
* @param {Function} setDiscounts - Setter des réductions
* @param {Array} paymentPlans - Plans de paiement du type
* @param {Function} setPaymentPlans - Setter des plans de paiement
* @param {Array} paymentModes - Modes de paiement du type
* @param {Function} setPaymentModes - Setter des modes de paiement
* @param {number} type - 0 = inscription, 1 = scolarité
* @param {Function} handleCreate - (url, newData, setter) => Promise
* @param {Function} handleEdit - (url, id, updatedData, setter) => Promise
* @param {Function} handleDelete - (url, id, setter) => Promise
* @param {Function} onDiscountDelete - Callback invoqué après suppression d'une réduction
*/
const FeeTypeSection = ({
title,
fees,
setFees,
discounts,
setDiscounts,
paymentPlans,
setPaymentPlans,
paymentModes,
setPaymentModes,
type,
handleCreate,
handleEdit,
handleDelete,
onDiscountDelete,
}) => {
return (
<>
<div className="w-4/5 mx-auto flex items-center mt-8">
<hr className="flex-grow border-t-2 border-gray-300" />
<span className="mx-4 text-gray-600 font-semibold">{title}</span>
<hr className="flex-grow border-t-2 border-gray-300" />
</div>
<div className="mt-8 w-4/5">
<FeesSection
fees={fees}
setFees={setFees}
discounts={discounts}
handleCreate={(newData) =>
handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setFees)
}
handleEdit={(id, updatedData) =>
handleEdit(`${BE_SCHOOL_FEES_URL}`, id, updatedData, setFees)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setFees)
}
type={type}
/>
</div>
<div className="mt-12 w-4/5">
<DiscountsSection
discounts={discounts}
setDiscounts={setDiscounts}
handleCreate={(newData) =>
handleCreate(`${BE_SCHOOL_DISCOUNTS_URL}`, newData, setDiscounts)
}
handleEdit={(id, updatedData) =>
handleEdit(
`${BE_SCHOOL_DISCOUNTS_URL}`,
id,
updatedData,
setDiscounts
)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_DISCOUNTS_URL}`, id, setDiscounts)
}
onDiscountDelete={onDiscountDelete}
type={type}
/>
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="col-span-1 mt-4">
<PaymentPlanSelector
paymentPlans={paymentPlans}
setPaymentPlans={setPaymentPlans}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
newData,
setPaymentPlans
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
id,
setPaymentPlans
)
}
type={type}
/>
</div>
<div className="col-span-1 mt-4">
<PaymentModeSelector
paymentModes={paymentModes}
setPaymentModes={setPaymentModes}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
newData,
setPaymentModes
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
id,
setPaymentModes
)
}
type={type}
/>
</div>
</div>
</>
);
};
export default FeeTypeSection;

View File

@ -1,4 +1,4 @@
import React from 'react'; import React from 'react';
import FeesSection from '@/components/Structure/Tarification/FeesSection'; import FeesSection from '@/components/Structure/Tarification/FeesSection';
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection'; import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
import PaymentPlanSelector from '@/components/PaymentPlanSelector'; import PaymentPlanSelector from '@/components/PaymentPlanSelector';
@ -31,223 +31,141 @@ const FeesManagement = ({
handleEdit, handleEdit,
handleDelete, handleDelete,
}) => { }) => {
const handleDiscountDelete = (id, type) => { // Liste unique triée par type puis par nom
if (type === 0) { const allFees = [...(registrationFees ?? []), ...(tuitionFees ?? [])].sort(
setRegistrationFees((prevFees) => (a, b) => a.type - b.type || (a.name ?? '').localeCompare(b.name ?? '')
prevFees.map((fee) => ({ );
...fee,
discounts: fee.discounts.filter((discountId) => discountId !== id), const setAllFees = (updater) => {
})) const next = typeof updater === 'function' ? updater(allFees) : updater;
); setRegistrationFees(next.filter((f) => f.type === 0));
} else { setTuitionFees(next.filter((f) => f.type === 1));
setTuitionFees((prevFees) =>
prevFees.map((fee) => ({
...fee,
discounts: fee.discounts.filter((discountId) => discountId !== id),
}))
);
}
}; };
const allDiscounts = [
...(registrationDiscounts ?? []),
...(tuitionDiscounts ?? []),
].sort(
(a, b) => a.type - b.type || (a.name ?? '').localeCompare(b.name ?? '')
);
const setAllDiscounts = (updater) => {
const next =
typeof updater === 'function' ? updater(allDiscounts) : updater;
setRegistrationDiscounts(next.filter((d) => d.type === 0));
setTuitionDiscounts(next.filter((d) => d.type === 1));
};
const allPaymentPlans = [
...(registrationPaymentPlans ?? []),
...(tuitionPaymentPlans ?? []),
];
const allPaymentModes = [
...(registrationPaymentModes ?? []),
...(tuitionPaymentModes ?? []),
];
return ( return (
<div className="w-full"> <div className="w-full space-y-12">
<div className="w-4/5 mx-auto flex items-center mt-8"> {/* Tableau unique des frais */}
<hr className="flex-grow border-t-2 border-gray-300" /> <FeesSection
<span className="mx-4 text-gray-600 font-semibold"> fees={allFees}
Frais d&apos;inscription setFees={setAllFees}
</span> unified={true}
<hr className="flex-grow border-t-2 border-gray-300" /> handleCreate={(feeData) => {
</div> const setter =
feeData.type === 0 ? setRegistrationFees : setTuitionFees;
return handleCreate(BE_SCHOOL_FEES_URL, feeData, setter);
}}
handleEdit={(id, data) => {
const fee = allFees.find((f) => f.id === id);
const feeType = data.type ?? fee?.type;
const setter = feeType === 0 ? setRegistrationFees : setTuitionFees;
return handleEdit(BE_SCHOOL_FEES_URL, id, data, setter);
}}
handleDelete={(id) => {
const fee = allFees.find((f) => f.id === id);
const setter = fee?.type === 0 ? setRegistrationFees : setTuitionFees;
return handleDelete(BE_SCHOOL_FEES_URL, id, setter);
}}
/>
<div className="mt-8 w-4/5"> {/* Tableau unique des réductions */}
<FeesSection <DiscountsSection
fees={registrationFees} discounts={allDiscounts}
setFees={setRegistrationFees} setDiscounts={setAllDiscounts}
discounts={registrationDiscounts} unified={true}
handleCreate={(newData) => handleCreate={(data) => {
handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setRegistrationFees) const setter =
} data.type === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
handleEdit={(id, updatedData) => return handleCreate(BE_SCHOOL_DISCOUNTS_URL, data, setter);
handleEdit( }}
`${BE_SCHOOL_FEES_URL}`, handleEdit={(id, data) => {
id, const discount = allDiscounts.find((d) => d.id === id);
updatedData, const discountType = data.type ?? discount?.type;
setRegistrationFees const setter =
) discountType === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
} return handleEdit(BE_SCHOOL_DISCOUNTS_URL, id, data, setter);
handleDelete={(id) => }}
handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setRegistrationFees) handleDelete={(id) => {
} const discount = allDiscounts.find((d) => d.id === id);
type={0} const setter =
/> discount?.type === 0
</div> ? setRegistrationDiscounts
<div className="mt-12 w-4/5"> : setTuitionDiscounts;
<DiscountsSection return handleDelete(BE_SCHOOL_DISCOUNTS_URL, id, setter);
discounts={registrationDiscounts} }}
setDiscounts={setRegistrationDiscounts} onDiscountDelete={(id) => {
handleCreate={(newData) => // Retire la réduction des frais concernés
handleCreate( setAllFees((prevFees) =>
`${BE_SCHOOL_DISCOUNTS_URL}`, prevFees.map((fee) => ({
newData, ...fee,
setRegistrationDiscounts discounts: fee.discounts.filter((dId) => dId !== id),
) }))
} );
handleEdit={(id, updatedData) => }}
handleEdit( />
`${BE_SCHOOL_DISCOUNTS_URL}`,
id, {/* Plans et modes de paiement communs */}
updatedData,
setRegistrationDiscounts
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_DISCOUNTS_URL}`,
id,
setRegistrationDiscounts
)
}
onDiscountDelete={(id) => handleDiscountDelete(id, 0)}
type={0}
/>
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="col-span-1 mt-4"> <div className="col-span-1 mt-4">
<PaymentPlanSelector <PaymentPlanSelector
paymentPlans={registrationPaymentPlans} allPaymentPlans={allPaymentPlans}
setPaymentPlans={setRegistrationPaymentPlans} handleCreate={(data) => {
handleCreate={(newData) => const setter =
handleCreate( data.type === 0
`${BE_SCHOOL_PAYMENT_PLANS_URL}`, ? setRegistrationPaymentPlans
newData, : setTuitionPaymentPlans;
setRegistrationPaymentPlans return handleCreate(BE_SCHOOL_PAYMENT_PLANS_URL, data, setter);
) }}
} handleDelete={(id) => {
handleDelete={(id) => const plan = allPaymentPlans.find((p) => p.id === id);
handleDelete( const setter =
`${BE_SCHOOL_PAYMENT_PLANS_URL}`, plan?.type === 0
id, ? setRegistrationPaymentPlans
setRegistrationPaymentPlans : setTuitionPaymentPlans;
) return handleDelete(BE_SCHOOL_PAYMENT_PLANS_URL, id, setter);
} }}
type={0}
/> />
</div> </div>
<div className="col-span-1 mt-4"> <div className="col-span-1 mt-4">
<PaymentModeSelector <PaymentModeSelector
paymentModes={registrationPaymentModes} allPaymentModes={allPaymentModes}
setPaymentModes={setRegistrationPaymentModes} handleCreate={(data) => {
handleCreate={(newData) => const setter =
handleCreate( data.type === 0
`${BE_SCHOOL_PAYMENT_MODES_URL}`, ? setRegistrationPaymentModes
newData, : setTuitionPaymentModes;
setRegistrationPaymentModes return handleCreate(BE_SCHOOL_PAYMENT_MODES_URL, data, setter);
) }}
} handleDelete={(id) => {
handleDelete={(id) => const mode = allPaymentModes.find((m) => m.id === id);
handleDelete( const setter =
`${BE_SCHOOL_PAYMENT_MODES_URL}`, mode?.type === 0
id, ? setRegistrationPaymentModes
setRegistrationPaymentModes : setTuitionPaymentModes;
) return handleDelete(BE_SCHOOL_PAYMENT_MODES_URL, id, setter);
} }}
type={0}
/>
</div>
</div>
<div className="w-4/5 mx-auto flex items-center mt-16">
<hr className="flex-grow border-t-2 border-gray-300" />
<span className="mx-4 text-gray-600 font-semibold">
Frais de scolarité
</span>
<hr className="flex-grow border-t-2 border-gray-300" />
</div>
<div className="mt-8 w-4/5">
<FeesSection
fees={tuitionFees}
setFees={setTuitionFees}
discounts={tuitionDiscounts}
handleCreate={(newData) =>
handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setTuitionFees)
}
handleEdit={(id, updatedData) =>
handleEdit(`${BE_SCHOOL_FEES_URL}`, id, updatedData, setTuitionFees)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setTuitionFees)
}
type={1}
/>
</div>
<div className="mt-12 w-4/5">
<DiscountsSection
discounts={tuitionDiscounts}
setDiscounts={setTuitionDiscounts}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_DISCOUNTS_URL}`,
newData,
setTuitionDiscounts
)
}
handleEdit={(id, updatedData) =>
handleEdit(
`${BE_SCHOOL_DISCOUNTS_URL}`,
id,
updatedData,
setTuitionDiscounts
)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_DISCOUNTS_URL}`, id, setTuitionDiscounts)
}
onDiscountDelete={(id) => handleDiscountDelete(id, 1)}
type={1}
/>
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="col-span-1 mt-4">
<PaymentPlanSelector
paymentPlans={tuitionPaymentPlans}
setPaymentPlans={setTuitionPaymentPlans}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
newData,
setTuitionPaymentPlans
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
id,
setTuitionPaymentPlans
)
}
type={1}
/>
</div>
<div className="col-span-1 mt-4">
<PaymentModeSelector
paymentModes={tuitionPaymentModes}
setPaymentModes={setTuitionPaymentModes}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
newData,
setTuitionPaymentModes
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
id,
setTuitionPaymentModes
)
}
type={1}
/> />
</div> </div>
</div> </div>

View File

@ -9,6 +9,13 @@ import SectionHeader from '@/components/SectionHeader';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
const FEE_TYPE_LABELS = { 0: 'Inscription', 1: 'Scolarité' };
/**
* @param {boolean} [unified=false] - true : tableau mixte inscription+scolarité avec colonne TYPE.
* Dans ce cas, `fees` contient les frais des deux types et `handleCreate`/`handleEdit`/`handleDelete`
* sont des fonctions (url, data, setter) déjà partiellement appliquées par le parent.
*/
const FeesSection = ({ const FeesSection = ({
fees, fees,
setFees, setFees,
@ -16,6 +23,7 @@ const FeesSection = ({
handleEdit, handleEdit,
handleDelete, handleDelete,
type, type,
unified = false,
subscriptionMode = false, subscriptionMode = false,
selectedFees, selectedFees,
handleFeeSelection, handleFeeSelection,
@ -29,8 +37,9 @@ const FeesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const labelTypeFrais = // En mode unifié, le type effectif est celui du frais ou celui du formulaire de création
type === 0 ? "Frais d'inscription" : 'Frais de scolarité'; const labelTypeFrais = (feeType) =>
feeType === 0 ? "Frais d'inscription" : 'Frais de scolarité';
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
// Récupération des messages d'erreur // Récupération des messages d'erreur
@ -44,10 +53,8 @@ const FeesSection = ({
name: '', name: '',
base_amount: '', base_amount: '',
description: '', description: '',
validity_start_date: '',
validity_end_date: '',
discounts: [], discounts: [],
type: type, type: unified ? 0 : type,
establishment: selectedEstablishmentId, establishment: selectedEstablishmentId,
}); });
}; };
@ -91,8 +98,8 @@ const FeesSection = ({
const handleUpdateFee = (id, updatedFee) => { const handleUpdateFee = (id, updatedFee) => {
if (updatedFee.name && updatedFee.base_amount) { if (updatedFee.name && updatedFee.base_amount) {
handleEdit(id, updatedFee) handleEdit(id, updatedFee)
.then((updatedFee) => { .then((updated) => {
setFees(fees.map((fee) => (fee.id === id ? updatedFee : fee))); setFees(fees.map((fee) => (fee.id === id ? updated : fee)));
setEditingFee(null); setEditingFee(null);
setLocalErrors({}); setLocalErrors({});
}) })
@ -193,6 +200,21 @@ const FeesSection = ({
handleChange, handleChange,
'Description' 'Description'
); );
case 'TYPE':
return (
<select
className="border rounded px-2 py-1 text-sm"
value={currentData.type}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (isEditing) setFormData((p) => ({ ...p, type: val }));
else setNewFee((p) => ({ ...p, type: val }));
}}
>
<option value={0}>Inscription</option>
<option value={1}>Scolarité</option>
</select>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -222,6 +244,7 @@ const FeesSection = ({
return null; return null;
} }
} else { } else {
const feeLabel = labelTypeFrais(fee.type);
switch (column) { switch (column) {
case 'NOM': case 'NOM':
return fee.name; return fee.name;
@ -231,6 +254,18 @@ const FeesSection = ({
return fee.updated_at_formatted; return fee.updated_at_formatted;
case 'DESCRIPTION': case 'DESCRIPTION':
return fee.description; return fee.description;
case 'TYPE':
return (
<span
className={`text-xs font-semibold px-2 py-1 rounded-full ${
fee.type === 0
? 'bg-blue-100 text-blue-700'
: 'bg-purple-100 text-purple-700'
}`}
>
{FEE_TYPE_LABELS[fee.type]}
</span>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -257,22 +292,20 @@ const FeesSection = ({
onClick={() => { onClick={() => {
setRemovePopupVisible(true); setRemovePopupVisible(true);
setRemovePopupMessage( setRemovePopupMessage(
`Attentions ! \nVous êtes sur le point de supprimer un ${labelTypeFrais} .\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?` `Attentions ! \nVous êtes sur le point de supprimer un ${feeLabel}.\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?`
); );
setRemovePopupOnConfirm(() => () => { setRemovePopupOnConfirm(() => () => {
handleRemoveFee(fee.id) handleRemoveFee(fee.id)
.then((data) => { .then((data) => {
logger.debug('Success:', data); logger.debug('Success:', data);
setPopupMessage( setPopupMessage(feeLabel + ' correctement supprimé');
labelTypeFrais + ' correctement supprimé'
);
setPopupVisible(true); setPopupVisible(true);
setRemovePopupVisible(false); setRemovePopupVisible(false);
}) })
.catch((error) => { .catch((error) => {
logger.error('Error archiving data:', error); logger.error('Error archiving data:', error);
setPopupMessage( setPopupMessage(
'Erreur lors de la suppression du ' + labelTypeFrais 'Erreur lors de la suppression du ' + feeLabel
); );
setPopupVisible(true); setPopupVisible(true);
setRemovePopupVisible(false); setRemovePopupVisible(false);
@ -307,42 +340,33 @@ const FeesSection = ({
{ name: 'NOM', label: 'Nom' }, { name: 'NOM', label: 'Nom' },
{ name: 'DESCRIPTION', label: 'Description' }, { name: 'DESCRIPTION', label: 'Description' },
{ name: 'MONTANT', label: 'Montant de base' }, { name: 'MONTANT', label: 'Montant de base' },
...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: '', label: 'Sélection' }, { name: '', label: 'Sélection' },
] ]
: [ : [
{ name: 'NOM', label: 'Nom' }, { name: 'NOM', label: 'Nom' },
{ name: 'MONTANT', label: 'Montant de base' }, { name: 'MONTANT', label: 'Montant de base' },
{ name: 'DESCRIPTION', label: 'Description' }, { name: 'DESCRIPTION', label: 'Description' },
...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: 'MISE A JOUR', label: 'Date mise à jour' }, { name: 'MISE A JOUR', label: 'Date mise à jour' },
{ name: 'ACTIONS', label: 'Actions' }, { name: 'ACTIONS', label: 'Actions' },
]; ];
let emptyMessage; const emptyMessage = (
if (type === 0) { <AlertMessage
emptyMessage = ( type="warning"
<AlertMessage title="Aucun frais enregistré"
type="warning" message="Veuillez procéder à la création de nouveaux frais"
title="Aucun frais d'inscription enregistré" />
message="Veuillez procéder à la création de nouveaux frais d'inscription" );
/>
);
} else {
emptyMessage = (
<AlertMessage
type="warning"
title="Aucun frais de scolarité enregistré"
message="Veuillez procéder à la création de nouveaux frais de scolarité"
/>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{!subscriptionMode && ( {!subscriptionMode && (
<SectionHeader <SectionHeader
icon={CreditCard} icon={CreditCard}
title={`${type == 0 ? "Liste des frais d'inscription" : 'Liste des frais de scolarité'}`} title="Liste des frais"
description={`Gérez${type == 0 ? " vos frais d'inscription" : ' vos frais de scolarité'}`} description="Gérez vos frais d'inscription et de scolarité"
button={!subscriptionMode} button={!subscriptionMode}
onClick={handleAddFee} onClick={handleAddFee}
/> />

View File

@ -0,0 +1,149 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import FeeTypeSection from '@/components/Structure/Tarification/FeeTypeSection';
// Mock du contexte établissement
jest.mock('@/context/EstablishmentContext', () => ({
useEstablishment: () => ({ selectedEstablishmentId: 1 }),
}));
// Mock des sous-composants pour isoler FeeTypeSection
jest.mock(
'@/components/Structure/Tarification/FeesSection',
() =>
function MockFeesSection({ type }) {
return (
<div data-testid={`fees-section-type-${type}`}>
FeesSection type={type}
</div>
);
}
);
jest.mock(
'@/components/Structure/Tarification/DiscountsSection',
() =>
function MockDiscountsSection({ type }) {
return (
<div data-testid={`discounts-section-type-${type}`}>
DiscountsSection type={type}
</div>
);
}
);
jest.mock(
'@/components/PaymentPlanSelector',
() =>
function MockPaymentPlanSelector({ type }) {
return (
<div data-testid={`payment-plan-type-${type}`}>
PaymentPlanSelector type={type}
</div>
);
}
);
jest.mock(
'@/components/PaymentModeSelector',
() =>
function MockPaymentModeSelector({ type }) {
return (
<div data-testid={`payment-mode-type-${type}`}>
PaymentModeSelector type={type}
</div>
);
}
);
jest.mock('@/utils/Url', () => ({
BE_SCHOOL_FEES_URL: '/api/fees',
BE_SCHOOL_DISCOUNTS_URL: '/api/discounts',
BE_SCHOOL_PAYMENT_PLANS_URL: '/api/payment-plans',
BE_SCHOOL_PAYMENT_MODES_URL: '/api/payment-modes',
}));
const defaultProps = {
title: "Frais d'inscription",
fees: [],
setFees: jest.fn(),
discounts: [],
setDiscounts: jest.fn(),
paymentPlans: [],
setPaymentPlans: jest.fn(),
paymentModes: [],
setPaymentModes: jest.fn(),
type: 0,
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
onDiscountDelete: jest.fn(),
};
describe('FeeTypeSection - type inscription (type=0)', () => {
it('affiche le titre passé en props', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByText("Frais d'inscription")).toBeInTheDocument();
});
it('rend le composant FeesSection avec le bon type', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByTestId('fees-section-type-0')).toBeInTheDocument();
});
it('rend le composant DiscountsSection avec le bon type', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByTestId('discounts-section-type-0')).toBeInTheDocument();
});
it('rend le composant PaymentPlanSelector avec le bon type', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByTestId('payment-plan-type-0')).toBeInTheDocument();
});
it('rend le composant PaymentModeSelector avec le bon type', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByTestId('payment-mode-type-0')).toBeInTheDocument();
});
});
describe('FeeTypeSection - type scolarité (type=1)', () => {
const tuitionProps = {
...defaultProps,
title: 'Frais de scolarité',
type: 1,
};
it('affiche le titre "Frais de scolarité"', () => {
render(<FeeTypeSection {...tuitionProps} />);
expect(screen.getByText('Frais de scolarité')).toBeInTheDocument();
});
it('rend tous les sous-composants avec type=1', () => {
render(<FeeTypeSection {...tuitionProps} />);
expect(screen.getByTestId('fees-section-type-1')).toBeInTheDocument();
expect(screen.getByTestId('discounts-section-type-1')).toBeInTheDocument();
expect(screen.getByTestId('payment-plan-type-1')).toBeInTheDocument();
expect(screen.getByTestId('payment-mode-type-1')).toBeInTheDocument();
});
});
describe('FeeTypeSection - transmission des handlers', () => {
it('passe les fonctions handleCreate, handleEdit, handleDelete aux sous-composants', () => {
const handleCreate = jest.fn();
const handleEdit = jest.fn();
const handleDelete = jest.fn();
// On vérifie que le composant se rend sans erreur avec les handlers
expect(() =>
render(
<FeeTypeSection
{...defaultProps}
handleCreate={handleCreate}
handleEdit={handleEdit}
handleDelete={handleDelete}
/>
)
).not.toThrow();
});
});

View File

@ -0,0 +1,145 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import FeesManagement from '@/components/Structure/Tarification/FeesManagement';
jest.mock('@/context/EstablishmentContext', () => ({
useEstablishment: () => ({ selectedEstablishmentId: 1 }),
}));
jest.mock('@/utils/Url', () => ({
BE_SCHOOL_FEES_URL: '/api/fees',
BE_SCHOOL_DISCOUNTS_URL: '/api/discounts',
BE_SCHOOL_PAYMENT_PLANS_URL: '/api/payment-plans',
BE_SCHOOL_PAYMENT_MODES_URL: '/api/payment-modes',
}));
jest.mock('@/utils/logger', () => ({ error: jest.fn() }));
jest.mock(
'@/components/Structure/Tarification/FeesSection',
() =>
function MockFeesSection({ fees, unified }) {
return (
<div
data-testid="fees-section"
data-unified={unified ? 'true' : 'false'}
>
{fees.map((f) => (
<span key={f.id}>{f.name}</span>
))}
</div>
);
}
);
jest.mock(
'@/components/Structure/Tarification/DiscountsSection',
() =>
function MockDiscountsSection({ discounts, unified }) {
return (
<div
data-testid="discounts-section"
data-unified={unified ? 'true' : 'false'}
>
{discounts.map((d) => (
<span key={d.id}>{d.name}</span>
))}
</div>
);
}
);
jest.mock(
'@/components/PaymentPlanSelector',
() =>
function MockPaymentPlanSelector({ allPaymentPlans }) {
return (
<div data-testid="payment-plan-selector">
{(allPaymentPlans ?? []).length} plans
</div>
);
}
);
jest.mock(
'@/components/PaymentModeSelector',
() =>
function MockPaymentModeSelector({ allPaymentModes }) {
return (
<div data-testid="payment-mode-selector">
{(allPaymentModes ?? []).length} modes
</div>
);
}
);
const defaultProps = {
registrationFees: [],
setRegistrationFees: jest.fn(),
tuitionFees: [],
setTuitionFees: jest.fn(),
registrationDiscounts: [],
setRegistrationDiscounts: jest.fn(),
tuitionDiscounts: [],
setTuitionDiscounts: jest.fn(),
registrationPaymentPlans: [],
setRegistrationPaymentPlans: jest.fn(),
tuitionPaymentPlans: [],
setTuitionPaymentPlans: jest.fn(),
registrationPaymentModes: [],
setRegistrationPaymentModes: jest.fn(),
tuitionPaymentModes: [],
setTuitionPaymentModes: jest.fn(),
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
};
describe('FeesManagement - vue unifiée', () => {
it('affiche la section des frais en mode unifié', () => {
render(<FeesManagement {...defaultProps} />);
const section = screen.getByTestId('fees-section');
expect(section).toBeInTheDocument();
expect(section).toHaveAttribute('data-unified', 'true');
});
it('affiche la section des réductions en mode unifié', () => {
render(<FeesManagement {...defaultProps} />);
const section = screen.getByTestId('discounts-section');
expect(section).toBeInTheDocument();
expect(section).toHaveAttribute('data-unified', 'true');
});
it('affiche le sélecteur de plans de paiement', () => {
render(<FeesManagement {...defaultProps} />);
expect(screen.getByTestId('payment-plan-selector')).toBeInTheDocument();
});
it('affiche le sélecteur de modes de paiement', () => {
render(<FeesManagement {...defaultProps} />);
expect(screen.getByTestId('payment-mode-selector')).toBeInTheDocument();
});
it('fusionne les frais inscription et scolarité en une seule liste', () => {
render(
<FeesManagement
{...defaultProps}
registrationFees={[{ id: 1, name: 'Inscription A', type: 0 }]}
tuitionFees={[{ id: 2, name: 'Scolarité B', type: 1 }]}
/>
);
expect(screen.getByText('Inscription A')).toBeInTheDocument();
expect(screen.getByText('Scolarité B')).toBeInTheDocument();
});
it('fusionne les plans de paiement inscription et scolarité', () => {
render(
<FeesManagement
{...defaultProps}
registrationPaymentPlans={[{ id: 10, plan_type: 1, type: 0 }]}
tuitionPaymentPlans={[{ id: 11, plan_type: 1, type: 1 }]}
/>
);
expect(screen.getByText('2 plans')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,211 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import FeesSection from '@/components/Structure/Tarification/FeesSection';
/* eslint-disable react/display-name */
// Mock du contexte établissement
jest.mock('@/context/EstablishmentContext', () => ({
useEstablishment: () => ({ selectedEstablishmentId: 1 }),
}));
// Mock des composants UI pour isoler les tests unitaires
jest.mock(
'@/components/Table',
() =>
({ data, columns, renderCell, emptyMessage }) => {
if (!data || data.length === 0)
return <div data-testid="empty-message">{emptyMessage}</div>;
return (
<table>
<tbody>
{data.map((row) => (
<tr key={row.id}>
{columns.map((col) => (
<td key={col.name}>{renderCell(row, col.name)}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
);
jest.mock(
'@/components/Popup',
() =>
({ isOpen, message, onConfirm, onCancel }) =>
isOpen ? (
<div data-testid="popup">
<p>{message}</p>
<button onClick={onConfirm}>Confirmer</button>
<button onClick={onCancel}>Annuler</button>
</div>
) : null
);
jest.mock('@/components/SectionHeader', () => ({ title, button, onClick }) => (
<div>
<h2>{title}</h2>
{button && <button onClick={onClick}>Ajouter</button>}
</div>
));
jest.mock('@/components/AlertMessage', () => ({ title, message }) => (
<div data-testid="alert-message">
<strong>{title}</strong>
<p>{message}</p>
</div>
));
jest.mock(
'@/components/Form/InputText',
() =>
({ name, value, onChange, placeholder }) => (
<input
name={name}
value={value}
onChange={onChange}
placeholder={placeholder}
/>
)
);
jest.mock('@/components/Form/CheckBox', () => ({ item, handleChange }) => (
<input type="checkbox" onChange={handleChange} />
));
jest.mock('@/utils/logger', () => ({
error: jest.fn(),
debug: jest.fn(),
}));
const mockFee = {
id: 1,
name: 'Frais test',
base_amount: '200.00',
description: 'Description test',
updated_at_formatted: '01-01-2026 10:00',
is_active: true,
discounts: [],
type: 0,
};
describe('FeesSection - type inscription (type=0)', () => {
const defaultProps = {
fees: [mockFee],
setFees: jest.fn(),
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
type: 0,
};
it('affiche le titre "Liste des frais"', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.getByText('Liste des frais')).toBeInTheDocument();
});
it('affiche les données du frais dans le tableau', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.getByText('Frais test')).toBeInTheDocument();
expect(screen.getByText('200.00 €')).toBeInTheDocument();
});
it('affiche le bouton Ajouter en mode gestion', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.getByText('Ajouter')).toBeInTheDocument();
});
it('affiche le message vide quand la liste est vide', () => {
render(<FeesSection {...defaultProps} fees={[]} />);
expect(screen.getByTestId('empty-message')).toBeInTheDocument();
expect(screen.getByText('Aucun frais enregistré')).toBeInTheDocument();
});
});
describe('FeesSection - type scolarité (type=1)', () => {
const defaultProps = {
fees: [{ ...mockFee, type: 1 }],
setFees: jest.fn(),
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
type: 1,
};
it('affiche le titre "Liste des frais" aussi pour type=1', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.getByText('Liste des frais')).toBeInTheDocument();
});
it('affiche le message vide générique quand la liste est vide', () => {
render(<FeesSection {...defaultProps} fees={[]} />);
expect(screen.getByText('Aucun frais enregistré')).toBeInTheDocument();
});
});
describe('FeesSection - mode sélection (subscriptionMode)', () => {
const defaultProps = {
fees: [mockFee],
setFees: jest.fn(),
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
type: 0,
subscriptionMode: true,
selectedFees: [],
handleFeeSelection: jest.fn(),
};
it('cache le header section en mode subscription', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.queryByText('Liste des frais')).not.toBeInTheDocument();
});
it("n'affiche pas le bouton Ajouter en mode subscription", () => {
render(<FeesSection {...defaultProps} />);
expect(screen.queryByText('Ajouter')).not.toBeInTheDocument();
});
});
describe("FeesSection - création d'un nouveau frais", () => {
it('initialise le nouveau frais avec le bon type', () => {
const setFees = jest.fn();
const handleCreate = jest.fn(() =>
Promise.resolve({ id: 2, name: 'Nouveau', base_amount: '100' })
);
render(
<FeesSection
fees={[]}
setFees={setFees}
handleCreate={handleCreate}
handleEdit={jest.fn()}
handleDelete={jest.fn()}
type={0}
/>
);
fireEvent.click(screen.getByText('Ajouter'));
// Le nouveau frais doit apparaître dans le tableau
expect(screen.queryByPlaceholderText('Nom des frais')).toBeInTheDocument();
});
it('initialise le nouveau frais avec type=1 pour les frais de scolarité', () => {
render(
<FeesSection
fees={[]}
setFees={jest.fn()}
handleCreate={jest.fn()}
handleEdit={jest.fn()}
handleDelete={jest.fn()}
type={1}
/>
);
fireEvent.click(screen.getByText('Ajouter'));
expect(screen.queryByPlaceholderText('Nom des frais')).toBeInTheDocument();
});
});