diff --git a/Back-End/N3wtSchool/error.py b/Back-End/N3wtSchool/error.py
index a357692..4f30b1f 100644
--- a/Back-End/N3wtSchool/error.py
+++ b/Back-End/N3wtSchool/error.py
@@ -31,5 +31,5 @@ returnMessage = {
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
PROFIL_INACTIVE: 'Le profil n\'est pas actif',
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',
}
\ No newline at end of file
diff --git a/Back-End/School/tests.py b/Back-End/School/tests.py
index 632b98d..7a802f5 100644
--- a/Back-End/School/tests.py
+++ b/Back-End/School/tests.py
@@ -284,3 +284,70 @@ class EstablishmentCompetencyEndpointAuthTest(TestCase):
_assert_endpoint_requires_auth(
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",
+ )
diff --git a/Back-End/School/views.py b/Back-End/School/views.py
index 2f7b818..5353133 100644
--- a/Back-End/School/views.py
+++ b/Back-End/School/views.py
@@ -12,6 +12,7 @@ from .models import (
Planning,
Discount,
Fee,
+ FeeType,
PaymentPlan,
PaymentMode,
EstablishmentCompetency,
@@ -288,7 +289,13 @@ class FeeListCreateView(APIView):
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
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()
fee_serializer = FeeSerializer(fees, many=True)
diff --git a/Front-End/src/components/PaymentModeSelector.js b/Front-End/src/components/PaymentModeSelector.js
index 1bfb343..7e8f1b9 100644
--- a/Front-End/src/components/PaymentModeSelector.js
+++ b/Front-End/src/components/PaymentModeSelector.js
@@ -1,6 +1,7 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { DollarSign } from 'lucide-react';
import { useEstablishment } from '@/context/EstablishmentContext';
+import logger from '@/utils/logger';
const paymentModesOptions = [
{ id: 1, name: 'Prélèvement SEPA' },
@@ -9,8 +10,14 @@ const paymentModesOptions = [
{ 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 = ({
paymentModes,
+ allPaymentModes,
setPaymentModes,
handleCreate,
handleDelete,
@@ -19,23 +26,45 @@ const PaymentModeSelector = ({
const [activePaymentModes, setActivePaymentModes] = useState([]);
const { selectedEstablishmentId } = useEstablishment();
+ const modes = useMemo(
+ () =>
+ Array.isArray(allPaymentModes)
+ ? allPaymentModes
+ : Array.isArray(paymentModes)
+ ? paymentModes
+ : [],
+ [allPaymentModes, paymentModes]
+ );
+ const unified = !!allPaymentModes;
+
useEffect(() => {
- const activeModes = paymentModes.map((mode) => mode.mode);
+ const activeModes = [...new Set(modes.map((mode) => mode.mode))];
setActivePaymentModes(activeModes);
- }, [paymentModes]);
+ }, [modes]);
const handleModeToggle = (modeId) => {
- const updatedMode = paymentModes.find((mode) => mode.mode === modeId);
- const isActive = !!updatedMode;
-
+ const isActive = activePaymentModes.includes(modeId);
if (!isActive) {
- handleCreate({
- mode: modeId,
- type,
- establishment: selectedEstablishmentId,
- });
+ if (unified) {
+ [0, 1].forEach((t) =>
+ handleCreate({
+ mode: modeId,
+ type: t,
+ establishment: selectedEstablishmentId,
+ }).catch((e) => logger.error(e))
+ );
+ } else {
+ handleCreate({
+ mode: modeId,
+ type,
+ establishment: selectedEstablishmentId,
+ }).catch((e) => logger.error(e));
+ }
} 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))
+ );
}
};
diff --git a/Front-End/src/components/PaymentPlanSelector.js b/Front-End/src/components/PaymentPlanSelector.js
index f2f12e7..eeac07a 100644
--- a/Front-End/src/components/PaymentPlanSelector.js
+++ b/Front-End/src/components/PaymentPlanSelector.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useMemo } from 'react';
import { Calendar } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
@@ -13,8 +13,22 @@ const paymentPlansOptions = [
{ 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 = ({
paymentPlans,
+ allPaymentPlans,
handleCreate,
handleDelete,
type,
@@ -24,38 +38,63 @@ const PaymentPlanSelector = ({
const { selectedEstablishmentId } = useEstablishment();
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);
- // Création ou suppression du plan
const handlePlanToggle = (planOption) => {
- const updatedPlan = paymentPlans.find(
- (plan) => plan.plan_type === planOption.id
- );
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));
- handleDelete(updatedPlan.id, null);
+ toDelete.forEach((p) =>
+ handleDelete(p.id, null).catch((e) => logger.error(e))
+ );
} else {
setCheckedPlans((prev) => [...prev, planOption.id]);
- handleCreate({
- plan_type: planOption.id,
- type,
- establishment: selectedEstablishmentId,
- });
+ if (unified) {
+ // Créer pour inscription (0) et scolarité (1)
+ [0, 1].forEach((t) =>
+ 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(() => {
- if (paymentPlans && paymentPlans.length > 0) {
- setCheckedPlans(
- paymentPlans.map((plan) =>
- typeof plan.plan_type === 'object'
- ? plan.plan_type.id
- : plan.plan_type
- )
+ if (plans.length > 0) {
+ const ids = plans.map((plan) =>
+ typeof plan.plan_type === 'object' ? plan.plan_type.id : plan.plan_type
);
+ setCheckedPlans([...new Set(ids)]);
+ } else {
+ setCheckedPlans([]);
}
- }, [paymentPlans]);
+ }, [plans]);
return (
diff --git a/Front-End/src/components/Structure/Tarification/DiscountsSection.js b/Front-End/src/components/Structure/Tarification/DiscountsSection.js
index 1053f53..da69f53 100644
--- a/Front-End/src/components/Structure/Tarification/DiscountsSection.js
+++ b/Front-End/src/components/Structure/Tarification/DiscountsSection.js
@@ -9,6 +9,8 @@ import SectionHeader from '@/components/SectionHeader';
import { useEstablishment } from '@/context/EstablishmentContext';
import AlertMessage from '@/components/AlertMessage';
+const DISCOUNT_TYPE_LABELS = { 0: 'Inscription', 1: 'Scolarité' };
+
const DiscountsSection = ({
discounts,
setDiscounts,
@@ -16,6 +18,7 @@ const DiscountsSection = ({
handleEdit,
handleDelete,
type,
+ unified = false,
subscriptionMode = false,
selectedDiscounts,
handleDiscountSelection,
@@ -39,7 +42,7 @@ const DiscountsSection = ({
amount: '',
description: '',
discount_type: 0,
- type: type,
+ type: unified ? 0 : type,
establishment: selectedEstablishmentId,
});
};
@@ -219,6 +222,21 @@ const DiscountsSection = ({
handleChange,
'Description'
);
+ case 'TYPE':
+ return (
+
+ );
case 'ACTIONS':
return (
@@ -259,6 +277,18 @@ const DiscountsSection = ({
return discount.description;
case 'MISE A JOUR':
return discount.updated_at_formatted;
+ case 'TYPE':
+ return (
+
+ {DISCOUNT_TYPE_LABELS[discount.type]}
+
+ );
case 'ACTIONS':
return (
@@ -335,34 +365,25 @@ const DiscountsSection = ({
{ name: 'LIBELLE', label: 'Libellé' },
{ name: 'DESCRIPTION', label: 'Description' },
{ name: 'REMISE', label: 'Remise' },
+ ...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: '', label: 'Sélection' },
]
: [
{ name: 'LIBELLE', label: 'Libellé' },
{ name: 'REMISE', label: 'Remise' },
{ name: 'DESCRIPTION', label: 'Description' },
+ ...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: 'MISE A JOUR', label: 'Date mise à jour' },
{ name: 'ACTIONS', label: 'Actions' },
];
- let emptyMessage;
- if (type === 0) {
- emptyMessage = (
-
- );
- } else {
- emptyMessage = (
-
- );
- }
+ const emptyMessage = (
+
+ );
return (
@@ -370,8 +391,8 @@ const DiscountsSection = ({
diff --git a/Front-End/src/components/Structure/Tarification/FeeTypeSection.js b/Front-End/src/components/Structure/Tarification/FeeTypeSection.js
new file mode 100644
index 0000000..7253b80
--- /dev/null
+++ b/Front-End/src/components/Structure/Tarification/FeeTypeSection.js
@@ -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 (
+ <>
+
+
+ {title}
+
+
+
+
+
+ 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}
+ />
+
+
+
+
+ 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}
+ />
+
+
+
+
+
+ handleCreate(
+ `${BE_SCHOOL_PAYMENT_PLANS_URL}`,
+ newData,
+ setPaymentPlans
+ )
+ }
+ handleDelete={(id) =>
+ handleDelete(
+ `${BE_SCHOOL_PAYMENT_PLANS_URL}`,
+ id,
+ setPaymentPlans
+ )
+ }
+ type={type}
+ />
+
+
+
+ handleCreate(
+ `${BE_SCHOOL_PAYMENT_MODES_URL}`,
+ newData,
+ setPaymentModes
+ )
+ }
+ handleDelete={(id) =>
+ handleDelete(
+ `${BE_SCHOOL_PAYMENT_MODES_URL}`,
+ id,
+ setPaymentModes
+ )
+ }
+ type={type}
+ />
+
+
+ >
+ );
+};
+
+export default FeeTypeSection;
diff --git a/Front-End/src/components/Structure/Tarification/FeesManagement.js b/Front-End/src/components/Structure/Tarification/FeesManagement.js
index 40225f9..5dd14dc 100644
--- a/Front-End/src/components/Structure/Tarification/FeesManagement.js
+++ b/Front-End/src/components/Structure/Tarification/FeesManagement.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React from 'react';
import FeesSection from '@/components/Structure/Tarification/FeesSection';
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
import PaymentPlanSelector from '@/components/PaymentPlanSelector';
@@ -31,223 +31,141 @@ const FeesManagement = ({
handleEdit,
handleDelete,
}) => {
- const handleDiscountDelete = (id, type) => {
- if (type === 0) {
- setRegistrationFees((prevFees) =>
- prevFees.map((fee) => ({
- ...fee,
- discounts: fee.discounts.filter((discountId) => discountId !== id),
- }))
- );
- } else {
- setTuitionFees((prevFees) =>
- prevFees.map((fee) => ({
- ...fee,
- discounts: fee.discounts.filter((discountId) => discountId !== id),
- }))
- );
- }
+ // Liste unique triée par type puis par nom
+ const allFees = [...(registrationFees ?? []), ...(tuitionFees ?? [])].sort(
+ (a, b) => a.type - b.type || (a.name ?? '').localeCompare(b.name ?? '')
+ );
+
+ const setAllFees = (updater) => {
+ const next = typeof updater === 'function' ? updater(allFees) : updater;
+ setRegistrationFees(next.filter((f) => f.type === 0));
+ setTuitionFees(next.filter((f) => f.type === 1));
};
+ 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 (
-
-
-
-
- Frais d'inscription
-
-
-
+
+ {/* Tableau unique des frais */}
+
{
+ 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);
+ }}
+ />
-
-
- handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setRegistrationFees)
- }
- handleEdit={(id, updatedData) =>
- handleEdit(
- `${BE_SCHOOL_FEES_URL}`,
- id,
- updatedData,
- setRegistrationFees
- )
- }
- handleDelete={(id) =>
- handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setRegistrationFees)
- }
- type={0}
- />
-
-
-
- handleCreate(
- `${BE_SCHOOL_DISCOUNTS_URL}`,
- newData,
- setRegistrationDiscounts
- )
- }
- handleEdit={(id, updatedData) =>
- handleEdit(
- `${BE_SCHOOL_DISCOUNTS_URL}`,
- id,
- updatedData,
- setRegistrationDiscounts
- )
- }
- handleDelete={(id) =>
- handleDelete(
- `${BE_SCHOOL_DISCOUNTS_URL}`,
- id,
- setRegistrationDiscounts
- )
- }
- onDiscountDelete={(id) => handleDiscountDelete(id, 0)}
- type={0}
- />
-
+ {/* Tableau unique des réductions */}
+ {
+ const setter =
+ data.type === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
+ return handleCreate(BE_SCHOOL_DISCOUNTS_URL, data, setter);
+ }}
+ handleEdit={(id, data) => {
+ const discount = allDiscounts.find((d) => d.id === id);
+ const discountType = data.type ?? discount?.type;
+ const setter =
+ discountType === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
+ return handleEdit(BE_SCHOOL_DISCOUNTS_URL, id, data, setter);
+ }}
+ handleDelete={(id) => {
+ const discount = allDiscounts.find((d) => d.id === id);
+ const setter =
+ discount?.type === 0
+ ? setRegistrationDiscounts
+ : setTuitionDiscounts;
+ return handleDelete(BE_SCHOOL_DISCOUNTS_URL, id, setter);
+ }}
+ onDiscountDelete={(id) => {
+ // Retire la réduction des frais concernés
+ setAllFees((prevFees) =>
+ prevFees.map((fee) => ({
+ ...fee,
+ discounts: fee.discounts.filter((dId) => dId !== id),
+ }))
+ );
+ }}
+ />
+
+ {/* Plans et modes de paiement communs */}
- handleCreate(
- `${BE_SCHOOL_PAYMENT_PLANS_URL}`,
- newData,
- setRegistrationPaymentPlans
- )
- }
- handleDelete={(id) =>
- handleDelete(
- `${BE_SCHOOL_PAYMENT_PLANS_URL}`,
- id,
- setRegistrationPaymentPlans
- )
- }
- type={0}
+ allPaymentPlans={allPaymentPlans}
+ handleCreate={(data) => {
+ const setter =
+ data.type === 0
+ ? setRegistrationPaymentPlans
+ : setTuitionPaymentPlans;
+ return handleCreate(BE_SCHOOL_PAYMENT_PLANS_URL, data, setter);
+ }}
+ handleDelete={(id) => {
+ const plan = allPaymentPlans.find((p) => p.id === id);
+ const setter =
+ plan?.type === 0
+ ? setRegistrationPaymentPlans
+ : setTuitionPaymentPlans;
+ return handleDelete(BE_SCHOOL_PAYMENT_PLANS_URL, id, setter);
+ }}
/>
- handleCreate(
- `${BE_SCHOOL_PAYMENT_MODES_URL}`,
- newData,
- setRegistrationPaymentModes
- )
- }
- handleDelete={(id) =>
- handleDelete(
- `${BE_SCHOOL_PAYMENT_MODES_URL}`,
- id,
- setRegistrationPaymentModes
- )
- }
- type={0}
- />
-
-
-
-
-
-
- Frais de scolarité
-
-
-
-
-
-
- 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}
- />
-
-
-
- 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}
- />
-
-
-
-
- handleCreate(
- `${BE_SCHOOL_PAYMENT_PLANS_URL}`,
- newData,
- setTuitionPaymentPlans
- )
- }
- handleDelete={(id) =>
- handleDelete(
- `${BE_SCHOOL_PAYMENT_PLANS_URL}`,
- id,
- setTuitionPaymentPlans
- )
- }
- type={1}
- />
-
-
-
- handleCreate(
- `${BE_SCHOOL_PAYMENT_MODES_URL}`,
- newData,
- setTuitionPaymentModes
- )
- }
- handleDelete={(id) =>
- handleDelete(
- `${BE_SCHOOL_PAYMENT_MODES_URL}`,
- id,
- setTuitionPaymentModes
- )
- }
- type={1}
+ allPaymentModes={allPaymentModes}
+ handleCreate={(data) => {
+ const setter =
+ data.type === 0
+ ? setRegistrationPaymentModes
+ : setTuitionPaymentModes;
+ return handleCreate(BE_SCHOOL_PAYMENT_MODES_URL, data, setter);
+ }}
+ handleDelete={(id) => {
+ const mode = allPaymentModes.find((m) => m.id === id);
+ const setter =
+ mode?.type === 0
+ ? setRegistrationPaymentModes
+ : setTuitionPaymentModes;
+ return handleDelete(BE_SCHOOL_PAYMENT_MODES_URL, id, setter);
+ }}
/>
diff --git a/Front-End/src/components/Structure/Tarification/FeesSection.js b/Front-End/src/components/Structure/Tarification/FeesSection.js
index 310a590..c2ef70c 100644
--- a/Front-End/src/components/Structure/Tarification/FeesSection.js
+++ b/Front-End/src/components/Structure/Tarification/FeesSection.js
@@ -9,6 +9,13 @@ import SectionHeader from '@/components/SectionHeader';
import { useEstablishment } from '@/context/EstablishmentContext';
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 = ({
fees,
setFees,
@@ -16,6 +23,7 @@ const FeesSection = ({
handleEdit,
handleDelete,
type,
+ unified = false,
subscriptionMode = false,
selectedFees,
handleFeeSelection,
@@ -29,8 +37,9 @@ const FeesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
- const labelTypeFrais =
- type === 0 ? "Frais d'inscription" : 'Frais de scolarité';
+ // En mode unifié, le type effectif est celui du frais ou celui du formulaire de création
+ const labelTypeFrais = (feeType) =>
+ feeType === 0 ? "Frais d'inscription" : 'Frais de scolarité';
const { selectedEstablishmentId } = useEstablishment();
// Récupération des messages d'erreur
@@ -44,10 +53,8 @@ const FeesSection = ({
name: '',
base_amount: '',
description: '',
- validity_start_date: '',
- validity_end_date: '',
discounts: [],
- type: type,
+ type: unified ? 0 : type,
establishment: selectedEstablishmentId,
});
};
@@ -91,8 +98,8 @@ const FeesSection = ({
const handleUpdateFee = (id, updatedFee) => {
if (updatedFee.name && updatedFee.base_amount) {
handleEdit(id, updatedFee)
- .then((updatedFee) => {
- setFees(fees.map((fee) => (fee.id === id ? updatedFee : fee)));
+ .then((updated) => {
+ setFees(fees.map((fee) => (fee.id === id ? updated : fee)));
setEditingFee(null);
setLocalErrors({});
})
@@ -193,6 +200,21 @@ const FeesSection = ({
handleChange,
'Description'
);
+ case 'TYPE':
+ return (
+
+ );
case 'ACTIONS':
return (
@@ -222,6 +244,7 @@ const FeesSection = ({
return null;
}
} else {
+ const feeLabel = labelTypeFrais(fee.type);
switch (column) {
case 'NOM':
return fee.name;
@@ -231,6 +254,18 @@ const FeesSection = ({
return fee.updated_at_formatted;
case 'DESCRIPTION':
return fee.description;
+ case 'TYPE':
+ return (
+
+ {FEE_TYPE_LABELS[fee.type]}
+
+ );
case 'ACTIONS':
return (
@@ -257,22 +292,20 @@ const FeesSection = ({
onClick={() => {
setRemovePopupVisible(true);
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(() => () => {
handleRemoveFee(fee.id)
.then((data) => {
logger.debug('Success:', data);
- setPopupMessage(
- labelTypeFrais + ' correctement supprimé'
- );
+ setPopupMessage(feeLabel + ' correctement supprimé');
setPopupVisible(true);
setRemovePopupVisible(false);
})
.catch((error) => {
logger.error('Error archiving data:', error);
setPopupMessage(
- 'Erreur lors de la suppression du ' + labelTypeFrais
+ 'Erreur lors de la suppression du ' + feeLabel
);
setPopupVisible(true);
setRemovePopupVisible(false);
@@ -307,42 +340,33 @@ const FeesSection = ({
{ name: 'NOM', label: 'Nom' },
{ name: 'DESCRIPTION', label: 'Description' },
{ name: 'MONTANT', label: 'Montant de base' },
+ ...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: '', label: 'Sélection' },
]
: [
{ name: 'NOM', label: 'Nom' },
{ name: 'MONTANT', label: 'Montant de base' },
{ name: 'DESCRIPTION', label: 'Description' },
+ ...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: 'MISE A JOUR', label: 'Date mise à jour' },
{ name: 'ACTIONS', label: 'Actions' },
];
- let emptyMessage;
- if (type === 0) {
- emptyMessage = (
-
- );
- } else {
- emptyMessage = (
-
- );
- }
+ const emptyMessage = (
+
+ );
return (
{!subscriptionMode && (
diff --git a/Front-End/src/test/FeesSection.test.js b/Front-End/src/test/FeesSection.test.js
new file mode 100644
index 0000000..ca8a754
--- /dev/null
+++ b/Front-End/src/test/FeesSection.test.js
@@ -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
{emptyMessage}
;
+ return (
+
+
+ {data.map((row) => (
+
+ {columns.map((col) => (
+ | {renderCell(row, col.name)} |
+ ))}
+
+ ))}
+
+
+ );
+ }
+);
+
+jest.mock(
+ '@/components/Popup',
+ () =>
+ ({ isOpen, message, onConfirm, onCancel }) =>
+ isOpen ? (
+
+
{message}
+
+
+
+ ) : null
+);
+
+jest.mock('@/components/SectionHeader', () => ({ title, button, onClick }) => (
+
+
{title}
+ {button && }
+
+));
+
+jest.mock('@/components/AlertMessage', () => ({ title, message }) => (
+
+));
+
+jest.mock(
+ '@/components/Form/InputText',
+ () =>
+ ({ name, value, onChange, placeholder }) => (
+
+ )
+);
+
+jest.mock('@/components/Form/CheckBox', () => ({ item, 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(
);
+ expect(screen.getByText('Liste des frais')).toBeInTheDocument();
+ });
+
+ it('affiche les données du frais dans le tableau', () => {
+ render(
);
+ expect(screen.getByText('Frais test')).toBeInTheDocument();
+ expect(screen.getByText('200.00 €')).toBeInTheDocument();
+ });
+
+ it('affiche le bouton Ajouter en mode gestion', () => {
+ render(
);
+ expect(screen.getByText('Ajouter')).toBeInTheDocument();
+ });
+
+ it('affiche le message vide quand la liste est vide', () => {
+ render(
);
+ 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(
);
+ expect(screen.getByText('Liste des frais')).toBeInTheDocument();
+ });
+
+ it('affiche le message vide générique quand la liste est vide', () => {
+ render(
);
+ 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(
);
+ expect(screen.queryByText('Liste des frais')).not.toBeInTheDocument();
+ });
+
+ it("n'affiche pas le bouton Ajouter en mode subscription", () => {
+ render(
);
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ fireEvent.click(screen.getByText('Ajouter'));
+ expect(screen.queryByPlaceholderText('Nom des frais')).toBeInTheDocument();
+ });
+});