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',
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',
}

View File

@ -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",
)

View File

@ -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)

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 { 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) {
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))
);
}
};

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 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]);
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 (
<div className="space-y-4">

View File

@ -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 (
<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':
return (
<div className="flex justify-center space-x-2">
@ -259,6 +277,18 @@ const DiscountsSection = ({
return discount.description;
case 'MISE A JOUR':
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':
return (
<div className="flex justify-center space-x-2">
@ -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 = (
const emptyMessage = (
<AlertMessage
type="info"
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 (
<div className="space-y-4">
@ -370,8 +391,8 @@ const DiscountsSection = ({
<SectionHeader
icon={Tag}
discountStyle={true}
title={`${type == 0 ? "Liste des réductions sur les frais d'inscription" : 'Liste des réductions sur les frais de scolarité'}`}
description={`Gérez ${type == 0 ? " vos réductions sur les frais d'inscription" : ' vos réductions sur les frais de scolarité'}`}
title="Liste des réductions"
description="Gérez vos réductions sur les frais d'inscription et de scolarité"
button={!subscriptionMode}
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 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),
}))
// 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 ?? '')
);
} else {
setTuitionFees((prevFees) =>
prevFees.map((fee) => ({
...fee,
discounts: fee.discounts.filter((discountId) => discountId !== id),
}))
);
}
const setAllFees = (updater) => {
const next = typeof updater === 'function' ? updater(allFees) : updater;
setRegistrationFees(next.filter((f) => f.type === 0));
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 (
<div className="w-full">
<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">
Frais d&apos;inscription
</span>
<hr className="flex-grow border-t-2 border-gray-300" />
</div>
<div className="mt-8 w-4/5">
<div className="w-full space-y-12">
{/* Tableau unique des frais */}
<FeesSection
fees={registrationFees}
setFees={setRegistrationFees}
discounts={registrationDiscounts}
handleCreate={(newData) =>
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}
fees={allFees}
setFees={setAllFees}
unified={true}
handleCreate={(feeData) => {
const setter =
feeData.type === 0 ? setRegistrationFees : setTuitionFees;
return handleCreate(BE_SCHOOL_FEES_URL, feeData, setter);
}}
handleEdit={(id, data) => {
const fee = allFees.find((f) => f.id === id);
const feeType = data.type ?? fee?.type;
const setter = feeType === 0 ? setRegistrationFees : setTuitionFees;
return handleEdit(BE_SCHOOL_FEES_URL, id, data, setter);
}}
handleDelete={(id) => {
const fee = allFees.find((f) => f.id === id);
const setter = fee?.type === 0 ? setRegistrationFees : setTuitionFees;
return handleDelete(BE_SCHOOL_FEES_URL, id, setter);
}}
/>
</div>
<div className="mt-12 w-4/5">
{/* Tableau unique des réductions */}
<DiscountsSection
discounts={registrationDiscounts}
setDiscounts={setRegistrationDiscounts}
handleCreate={(newData) =>
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}
discounts={allDiscounts}
setDiscounts={setAllDiscounts}
unified={true}
handleCreate={(data) => {
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),
}))
);
}}
/>
</div>
{/* Plans et modes de paiement communs */}
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="col-span-1 mt-4">
<PaymentPlanSelector
paymentPlans={registrationPaymentPlans}
setPaymentPlans={setRegistrationPaymentPlans}
handleCreate={(newData) =>
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);
}}
/>
</div>
<div className="col-span-1 mt-4">
<PaymentModeSelector
paymentModes={registrationPaymentModes}
setPaymentModes={setRegistrationPaymentModes}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
newData,
setRegistrationPaymentModes
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
id,
setRegistrationPaymentModes
)
}
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}
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);
}}
/>
</div>
</div>

View File

@ -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 (
<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':
return (
<div className="flex justify-center space-x-2">
@ -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 (
<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':
return (
<div className="flex justify-center space-x-2">
@ -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 = (
const emptyMessage = (
<AlertMessage
type="warning"
title="Aucun frais d'inscription enregistré"
message="Veuillez procéder à la création de nouveaux frais d'inscription"
title="Aucun frais enregistré"
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 (
<div className="space-y-4">
{!subscriptionMode && (
<SectionHeader
icon={CreditCard}
title={`${type == 0 ? "Liste des frais d'inscription" : 'Liste des frais de scolarité'}`}
description={`Gérez${type == 0 ? " vos frais d'inscription" : ' vos frais de scolarité'}`}
title="Liste des frais"
description="Gérez vos frais d'inscription et de scolarité"
button={!subscriptionMode}
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();
});
});