feat(frontend): fusion liste des frais et message compte existant [#NEWTS-9]

This commit is contained in:
Luc SORIGNET
2026-03-15 12:09:02 +01:00
parent c296af2c07
commit e30a41a58b
10 changed files with 755 additions and 294 deletions

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

@ -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) {
if (unified) {
[0, 1].forEach((t) =>
handleCreate({
mode: modeId,
type: t,
establishment: selectedEstablishmentId,
}).catch((e) => logger.error(e))
);
} else {
handleCreate({ handleCreate({
mode: modeId, mode: modeId,
type, type,
establishment: selectedEstablishmentId, 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]);
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({ handleCreate({
plan_type: planOption.id, plan_type: planOption.id,
type, type,
establishment: selectedEstablishmentId, 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) {
emptyMessage = (
<AlertMessage <AlertMessage
type="info" type="info"
title="Aucune réduction enregistrée" title="Aucune réduction enregistrée"
message="Aucune réduction sur les frais d'inscription n'a été enregistrée" message="Aucune réduction n'a encore é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),
}))
); );
} else {
setTuitionFees((prevFees) => const setAllFees = (updater) => {
prevFees.map((fee) => ({ const next = typeof updater === 'function' ? updater(allFees) : updater;
...fee, setRegistrationFees(next.filter((f) => f.type === 0));
discounts: fee.discounts.filter((discountId) => discountId !== id), 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 ( 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" />
<span className="mx-4 text-gray-600 font-semibold">
Frais d&apos;inscription
</span>
<hr className="flex-grow border-t-2 border-gray-300" />
</div>
<div className="mt-8 w-4/5">
<FeesSection <FeesSection
fees={registrationFees} fees={allFees}
setFees={setRegistrationFees} setFees={setAllFees}
discounts={registrationDiscounts} unified={true}
handleCreate={(newData) => handleCreate={(feeData) => {
handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setRegistrationFees) const setter =
} feeData.type === 0 ? setRegistrationFees : setTuitionFees;
handleEdit={(id, updatedData) => return handleCreate(BE_SCHOOL_FEES_URL, feeData, setter);
handleEdit( }}
`${BE_SCHOOL_FEES_URL}`, handleEdit={(id, data) => {
id, const fee = allFees.find((f) => f.id === id);
updatedData, const feeType = data.type ?? fee?.type;
setRegistrationFees const setter = feeType === 0 ? setRegistrationFees : setTuitionFees;
) return handleEdit(BE_SCHOOL_FEES_URL, id, data, setter);
} }}
handleDelete={(id) => handleDelete={(id) => {
handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setRegistrationFees) const fee = allFees.find((f) => f.id === id);
} const setter = fee?.type === 0 ? setRegistrationFees : setTuitionFees;
type={0} return handleDelete(BE_SCHOOL_FEES_URL, id, setter);
}}
/> />
</div>
<div className="mt-12 w-4/5"> {/* Tableau unique des réductions */}
<DiscountsSection <DiscountsSection
discounts={registrationDiscounts} discounts={allDiscounts}
setDiscounts={setRegistrationDiscounts} setDiscounts={setAllDiscounts}
handleCreate={(newData) => unified={true}
handleCreate( handleCreate={(data) => {
`${BE_SCHOOL_DISCOUNTS_URL}`, const setter =
newData, data.type === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
setRegistrationDiscounts return handleCreate(BE_SCHOOL_DISCOUNTS_URL, data, setter);
) }}
} handleEdit={(id, data) => {
handleEdit={(id, updatedData) => const discount = allDiscounts.find((d) => d.id === id);
handleEdit( const discountType = data.type ?? discount?.type;
`${BE_SCHOOL_DISCOUNTS_URL}`, const setter =
id, discountType === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
updatedData, return handleEdit(BE_SCHOOL_DISCOUNTS_URL, id, data, setter);
setRegistrationDiscounts }}
) handleDelete={(id) => {
} const discount = allDiscounts.find((d) => d.id === id);
handleDelete={(id) => const setter =
handleDelete( discount?.type === 0
`${BE_SCHOOL_DISCOUNTS_URL}`, ? setRegistrationDiscounts
id, : setTuitionDiscounts;
setRegistrationDiscounts return handleDelete(BE_SCHOOL_DISCOUNTS_URL, id, setter);
) }}
} onDiscountDelete={(id) => {
onDiscountDelete={(id) => handleDiscountDelete(id, 0)} // Retire la réduction des frais concernés
type={0} setAllFees((prevFees) =>
prevFees.map((fee) => ({
...fee,
discounts: fee.discounts.filter((dId) => dId !== id),
}))
);
}}
/> />
</div>
{/* Plans et modes de paiement communs */}
<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) {
emptyMessage = (
<AlertMessage <AlertMessage
type="warning" type="warning"
title="Aucun frais d'inscription enregistré" title="Aucun frais enregistré"
message="Veuillez procéder à la création de nouveaux frais d'inscription" message="Veuillez procéder à la création de nouveaux frais"
/> />
); );
} 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,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();
});
});