mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-03 16:51:26 +00:00
feat(frontend): fusion liste des frais et message compte existant [#NEWTS-9]
This commit is contained in:
@ -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',
|
||||||
}
|
}
|
||||||
@ -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",
|
||||||
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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'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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
211
Front-End/src/test/FeesSection.test.js
Normal file
211
Front-End/src/test/FeesSection.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user