From e30a41a58b199b68eca687a159736b2025e9f22d Mon Sep 17 00:00:00 2001 From: Luc SORIGNET Date: Sun, 15 Mar 2026 12:09:02 +0100 Subject: [PATCH] feat(frontend): fusion liste des frais et message compte existant [#NEWTS-9] --- Back-End/N3wtSchool/error.py | 2 +- Back-End/School/tests.py | 67 ++++ Back-End/School/views.py | 9 +- .../src/components/PaymentModeSelector.js | 53 ++- .../src/components/PaymentPlanSelector.js | 79 +++-- .../Tarification/DiscountsSection.js | 63 ++-- .../Structure/Tarification/FeeTypeSection.js | 145 ++++++++ .../Structure/Tarification/FeesManagement.js | 332 +++++++----------- .../Structure/Tarification/FeesSection.js | 88 +++-- Front-End/src/test/FeesSection.test.js | 211 +++++++++++ 10 files changed, 755 insertions(+), 294 deletions(-) create mode 100644 Front-End/src/components/Structure/Tarification/FeeTypeSection.js create mode 100644 Front-End/src/test/FeesSection.test.js 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 }) => ( +
+ {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(); + }); +});