diff --git a/backend/app/main.py b/backend/app/main.py
index 18d6d43..2cad063 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -27,6 +27,7 @@ from app.models.journal_action import JournalAction # noqa: F401
from app.models.push_subscription import PushSubscription # noqa: F401
from app.models.destinataire_contribution import DestinataireContribution # noqa: F401
from app.models.rapport_adopte import RapportAdopte # noqa: F401
+from app.models.vente_jeton import VenteJeton # noqa: F401
@asynccontextmanager
@@ -42,6 +43,10 @@ async def lifespan(app: FastAPI):
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT"))
await conn.execute(text("ALTER TABLE propositions DROP COLUMN IF EXISTS type_proposition"))
await conn.execute(text("ALTER TABLE membres ADD COLUMN IF NOT EXISTS notification_preferences JSON"))
+ await conn.execute(text("ALTER TABLE jetons ADD COLUMN IF NOT EXISTS reunion_id UUID REFERENCES reunions(id)"))
+ await conn.execute(text("ALTER TABLE ventes_litterature ADD COLUMN IF NOT EXISTS reunion_id UUID REFERENCES reunions(id)"))
+ await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_type VARCHAR(40)"))
+ await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_id UUID"))
await conn.execute(text(
"ALTER TABLE envois_contributions "
"ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text"
diff --git a/backend/app/models/invitation.py b/backend/app/models/invitation.py
index a1ac47d..51c9ba8 100644
--- a/backend/app/models/invitation.py
+++ b/backend/app/models/invitation.py
@@ -16,3 +16,5 @@ class Invitation(Base):
utilise_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
expire_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
utilise: Mapped[bool] = mapped_column(Boolean, default=False)
+ cible_type: Mapped[str | None] = mapped_column(String(40))
+ cible_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
diff --git a/backend/app/models/jeton.py b/backend/app/models/jeton.py
index 832682d..63f2ff3 100644
--- a/backend/app/models/jeton.py
+++ b/backend/app/models/jeton.py
@@ -11,6 +11,7 @@ class Jeton(Base):
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
+ reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"))
membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
type_jeton: Mapped[str] = mapped_column(
SAEnum(
diff --git a/backend/app/models/vente_jeton.py b/backend/app/models/vente_jeton.py
new file mode 100644
index 0000000..d65064a
--- /dev/null
+++ b/backend/app/models/vente_jeton.py
@@ -0,0 +1,25 @@
+"""Ventes de jetons, distinctes des remises statistiques."""
+import uuid
+from datetime import date, datetime, timezone
+
+from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+
+
+class VenteJeton(Base):
+ __tablename__ = "ventes_jetons"
+
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
+ groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False, index=True)
+ reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"))
+ type_jeton: Mapped[str] = mapped_column(String(30), nullable=False)
+ quantite: Mapped[int] = mapped_column(Integer, nullable=False)
+ montant: Mapped[float] = mapped_column(Numeric(8, 2), nullable=False)
+ date_vente: Mapped[date] = mapped_column(Date, nullable=False)
+ vendu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
+ remis_tresorier: Mapped[bool] = mapped_column(Boolean, default=False)
+ recu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
+ date_remise: Mapped[date | None] = mapped_column(Date)
+ cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
diff --git a/backend/app/models/vente_litterature.py b/backend/app/models/vente_litterature.py
index e4c0b36..c2c9cef 100644
--- a/backend/app/models/vente_litterature.py
+++ b/backend/app/models/vente_litterature.py
@@ -12,6 +12,7 @@ class VenteLitterature(Base):
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
litterature_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("litterature.id"), nullable=False)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
+ reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"))
quantite: Mapped[int] = mapped_column(Integer, nullable=False)
montant: Mapped[float] = mapped_column(Numeric(8, 2), nullable=False)
date_vente: Mapped[date] = mapped_column(Date, nullable=False)
diff --git a/backend/app/routers/accueil.py b/backend/app/routers/accueil.py
index cdfc67d..b335a7f 100644
--- a/backend/app/routers/accueil.py
+++ b/backend/app/routers/accueil.py
@@ -25,8 +25,8 @@ MODULES_CONSULTATION = [
]
MODULES_PAR_CODE = {m.code: m for m in MODULES_CONSULTATION}
-MODULES_PAR_CODE["S0"] = ModuleTuile(code="S0", nom="Rencontre hebdomadaire", description="Saisies du contexte rĂ©gulier", icone="đïž", couleur="green")
-MODULES_PAR_CODE["M0"] = ModuleTuile(code="M0", nom="AssemblĂ©e d'affaires", description="PrĂ©sences et procĂšs-verbal", icone="đïž", couleur="indigo")
+MODULES_PAR_CODE["S0"] = ModuleTuile(code="S0", nom="Rencontres", description="Saisies, ventes, jetons et rapports", icone="đïž", couleur="green")
+MODULES_PAR_CODE["M0"] = ModuleTuile(code="M0", nom="AssemblĂ©es", description="PrĂ©sences, procĂšs-verbal et rapports", icone="đïž", couleur="indigo")
MODULES_PAR_CODE["S1"] = ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="đ°", couleur="green")
MODULES_SAISIE_TOUS = [
@@ -48,9 +48,7 @@ MODULES_SAISIE = {
"RSG Substitut(e)": [
ModuleTuile(code="M3", nom="Rapport RSG", description="Compte-rendu district", icone="đ", couleur="blue"),
],
- "Animateur(trice)": [
- ModuleTuile(code="S4", nom="Rapport rĂ©union", description="ThĂšme et annonces", icone="đ€", couleur="purple"),
- ],
+ "Animateur(trice)": [],
"Responsable littérature": [
ModuleTuile(code="S3", nom="Vente littĂ©rature", description="Enregistrer une vente", icone="đ", couleur="purple"),
ModuleTuile(code="M5", nom="Ajuster inventaire", description="Mettre Ă jour le stock", icone="đŠ", couleur="orange"),
@@ -83,7 +81,7 @@ async def accueil(
if is_sysadmin(membre):
modules_saisie_sysadmin = []
codes_vus = set()
- for module in [MODULES_PAR_CODE["S1"], MODULES_PAR_CODE["S0"], MODULES_PAR_CODE["M0"], *MODULES_SAISIE_TOUS, *[m for mods in MODULES_SAISIE.values() for m in mods]]:
+ for module in [MODULES_PAR_CODE["S0"], MODULES_PAR_CODE["M0"], *MODULES_SAISIE_TOUS, *[m for mods in MODULES_SAISIE.values() for m in mods]]:
if module.code not in codes_vus:
modules_saisie_sysadmin.append(module)
codes_vus.add(module.code)
@@ -152,10 +150,8 @@ async def accueil(
modules_saisie.append(m)
codes_vus.add(m.code)
if "executif" in postes_categories and "S1" not in codes_vus:
- modules_saisie.insert(0, MODULES_PAR_CODE["M0"])
modules_saisie.insert(0, MODULES_PAR_CODE["S0"])
- modules_saisie.insert(0, ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="đ°", couleur="green"))
- codes_vus.add("S1")
+ modules_saisie.insert(1, MODULES_PAR_CODE["M0"])
codes_vus.add("S0")
codes_vus.add("M0")
diff --git a/backend/app/routers/collectes.py b/backend/app/routers/collectes.py
index 4c8afbc..49fb071 100644
--- a/backend/app/routers/collectes.py
+++ b/backend/app/routers/collectes.py
@@ -249,6 +249,7 @@ async def soldes_tresorerie(
):
"""Soldes détaillés : relevé bancaire, sommes engagées, réserves, disponible."""
from app.models.vente_litterature import VenteLitterature
+ from app.models.vente_jeton import VenteJeton
gid = membre.groupe_id
result = await db.execute(
@@ -346,6 +347,27 @@ async def soldes_tresorerie(
"vendu_par": vendeur.prenom if vendeur else "?",
})
+ result = await db.execute(
+ select(VenteJeton).where(
+ VenteJeton.groupe_id == gid,
+ VenteJeton.remis_tresorier == False,
+ )
+ )
+ ventes_jetons_non_remises = result.scalars().all()
+ details_ventes_jetons = []
+ total_ventes_jetons_non_remises = 0.0
+ for v in ventes_jetons_non_remises:
+ vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None
+ total_ventes_jetons_non_remises += float(v.montant)
+ details_ventes_jetons.append({
+ "id": str(v.id),
+ "date": str(v.date_vente),
+ "montant": float(v.montant),
+ "titre": f"Jetons {v.type_jeton} (x{v.quantite})",
+ "vendu_par": vendeur.prenom if vendeur else "?",
+ "type": "jeton",
+ })
+
return {
"solde_banque": round(solde_banque_actuel, 2),
"date_releve": date_releve,
@@ -359,8 +381,8 @@ async def soldes_tresorerie(
"reserves_details": reserves_details,
"en_possession": round(total_en_circulation, 2),
"en_possession_details": details_circulation,
- "ventes_non_remises": round(total_ventes_non_remises, 2),
- "ventes_non_remises_details": details_ventes,
+ "ventes_non_remises": round(total_ventes_non_remises + total_ventes_jetons_non_remises, 2),
+ "ventes_non_remises_details": details_ventes + details_ventes_jetons,
}
@@ -376,6 +398,7 @@ async def registre_transactions(
from app.models.vente_litterature import VenteLitterature
from app.models.litterature import Litterature
from app.models.transaction_comptable import TransactionComptable
+ from app.models.vente_jeton import VenteJeton
gid = membre.groupe_id
transactions = []
@@ -430,6 +453,24 @@ async def registre_transactions(
"par": "Littérature",
})
+ result = await db.execute(
+ select(VenteJeton).where(
+ VenteJeton.groupe_id == gid,
+ VenteJeton.remis_tresorier == True,
+ )
+ )
+ for v in result.scalars().all():
+ if filtre_mois(v.date_vente):
+ vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None
+ ajouter_transaction({
+ "date": str(v.date_vente),
+ "type": "vente_jeton",
+ "description": f"Vente de jetons : {v.type_jeton} (x{v.quantite})",
+ "entree": float(v.montant),
+ "sortie": 0,
+ "par": vendeur.prenom if vendeur else "Jetons",
+ })
+
# 3. Dépenses payées
result = await db.execute(
select(Depense).where(Depense.groupe_id == gid, Depense.paye == True)
diff --git a/backend/app/routers/commandes_jetons.py b/backend/app/routers/commandes_jetons.py
index 0d16708..cd9177c 100644
--- a/backend/app/routers/commandes_jetons.py
+++ b/backend/app/routers/commandes_jetons.py
@@ -7,7 +7,7 @@ from sqlalchemy import select, func
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.core.roles import membre_a_module
-from app.models.jeton import Jeton
+from app.models.vente_jeton import VenteJeton
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase5 import StockJetonsOut, TYPES_JETONS
@@ -37,17 +37,18 @@ async def etat_stock_jetons(
membre=Depends(require_resp_jetons),
db: AsyncSession = Depends(get_db),
):
- """M6 â Ătat du stock de jetons (basĂ© sur l'utilisation du dernier mois)."""
+ """M6 â Ătat du stock de jetons (basĂ© sur les ventes, pas les remises)."""
il_y_a_30_jours = date.today() - timedelta(days=30)
- # Compter les jetons remis par type dans les 30 derniers jours
+ # Compter les jetons vendus par type dans les 30 derniers jours.
+ # Les remises sont statistiques et ne décrémentent pas l'inventaire.
result = await db.execute(
- select(Jeton.type_jeton, func.count(Jeton.id))
+ select(VenteJeton.type_jeton, func.coalesce(func.sum(VenteJeton.quantite), 0))
.where(
- Jeton.groupe_id == membre.groupe_id,
- Jeton.date_remise >= il_y_a_30_jours,
+ VenteJeton.groupe_id == membre.groupe_id,
+ VenteJeton.date_vente >= il_y_a_30_jours,
)
- .group_by(Jeton.type_jeton)
+ .group_by(VenteJeton.type_jeton)
)
usage = {row[0]: row[1] for row in result.all()}
diff --git a/backend/app/routers/jetons.py b/backend/app/routers/jetons.py
index 178537e..32ba1b7 100644
--- a/backend/app/routers/jetons.py
+++ b/backend/app/routers/jetons.py
@@ -7,10 +7,12 @@ from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.core.roles import membre_a_module
from app.models.jeton import Jeton
+from app.models.vente_jeton import VenteJeton
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
-from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS
+from app.models.transaction_comptable import TransactionComptable
+from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS, VenteJetonCreate
router = APIRouter(prefix="/jetons", tags=["S2 â Remise de jeton"])
@@ -62,6 +64,7 @@ async def remettre_jeton(
jeton = Jeton(
groupe_id=membre.groupe_id,
+ reunion_id=req.reunion_id,
membre_id=req.membre_id,
type_jeton=req.type_jeton,
date_remise=req.date_remise,
@@ -82,6 +85,114 @@ async def remettre_jeton(
)
+@router.post("/ventes")
+async def enregistrer_vente_jeton(
+ req: VenteJetonCreate,
+ membre=Depends(require_resp_jetons),
+ db: AsyncSession = Depends(get_db),
+):
+ """Enregistrer une vente de jetons. Les remises ne touchent pas l'inventaire."""
+ types_valides = [t["value"] for t in TYPES_JETONS]
+ if req.type_jeton not in types_valides:
+ raise HTTPException(status_code=400, detail=f"Type de jeton invalide. Valeurs: {types_valides}")
+ if req.quantite <= 0:
+ raise HTTPException(status_code=400, detail="La quantitĂ© doit ĂȘtre positive")
+ if req.montant < 0:
+ raise HTTPException(status_code=400, detail="Le montant ne peut pas ĂȘtre nĂ©gatif")
+ vendeur_id = req.vendu_par_id or membre.id
+ vendeur = await db.get(Membre, vendeur_id)
+ if not vendeur or vendeur.groupe_id != membre.groupe_id or not vendeur.actif:
+ raise HTTPException(status_code=404, detail="Vendeur introuvable")
+
+ vente = VenteJeton(
+ groupe_id=membre.groupe_id,
+ reunion_id=req.reunion_id,
+ type_jeton=req.type_jeton,
+ quantite=req.quantite,
+ montant=req.montant,
+ date_vente=req.date_vente,
+ vendu_par=vendeur_id,
+ )
+ db.add(vente)
+ await db.flush()
+ return {
+ "id": str(vente.id),
+ "type_jeton": vente.type_jeton,
+ "quantite": vente.quantite,
+ "montant": float(vente.montant),
+ "date_vente": str(vente.date_vente),
+ "vendu_par_prenom": vendeur.prenom,
+ "remis_tresorier": vente.remis_tresorier,
+ }
+
+
+@router.get("/ventes/a-recevoir")
+async def ventes_jetons_a_recevoir(
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(VenteJeton)
+ .where(VenteJeton.groupe_id == membre.groupe_id, VenteJeton.remis_tresorier == False)
+ .order_by(desc(VenteJeton.date_vente))
+ )
+ out = []
+ for vente in result.scalars().all():
+ vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
+ out.append({
+ "id": str(vente.id),
+ "type_jeton": vente.type_jeton,
+ "quantite": vente.quantite,
+ "montant": float(vente.montant),
+ "date_vente": str(vente.date_vente),
+ "vendu_par_prenom": vendeur.prenom if vendeur else None,
+ })
+ return out
+
+
+@router.post("/ventes/{vente_id}/recevoir")
+async def confirmer_reception_vente_jeton(
+ vente_id: str,
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ if not is_sysadmin(membre) and not await membre_a_module(membre.id, "S1T", db):
+ result = await db.execute(
+ select(Affectation).join(Poste).where(
+ Affectation.membre_id == membre.id,
+ Affectation.statut == "actif",
+ (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
+ )
+ )
+ if not result.scalars().first():
+ raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
+
+ vente = await db.get(VenteJeton, vente_id)
+ if not vente or vente.groupe_id != membre.groupe_id:
+ raise HTTPException(status_code=404, detail="Vente introuvable")
+ if vente.remis_tresorier:
+ raise HTTPException(status_code=400, detail="Déjà reçue")
+
+ from datetime import date as date_type
+ vente.remis_tresorier = True
+ vente.recu_par = membre.id
+ vente.date_remise = date_type.today()
+ db.add(TransactionComptable(
+ groupe_id=vente.groupe_id,
+ date_transaction=vente.date_vente,
+ type="vente_jeton",
+ compte="encaisse",
+ montant=vente.montant,
+ sens="debit",
+ source_table="ventes_jetons",
+ source_id=vente.id,
+ note="Vente de jetons reçue",
+ cree_par=membre.id,
+ ))
+ await db.flush()
+ return {"message": "Réception confirmée"}
+
+
@router.get("/", response_model=list[JetonOut])
async def liste_jetons(
limite: int = 30,
diff --git a/backend/app/routers/membres.py b/backend/app/routers/membres.py
index be2900d..47defc0 100644
--- a/backend/app/routers/membres.py
+++ b/backend/app/routers/membres.py
@@ -6,7 +6,7 @@ from sqlalchemy import select, func
from app.core.database import get_db
from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin, is_sysadmin
from app.models.membre import Membre
-from app.schemas.schemas import MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest
+from app.schemas.schemas import MembreCreateAdmin, MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest
router = APIRouter(prefix="/membres", tags=["Membres"])
@@ -107,6 +107,48 @@ async def liste_membres(
return [MembreComplet.model_validate(m) for m in result.scalars().all()]
+@router.post("/", response_model=MembreComplet)
+async def creer_membre(
+ req: MembreCreateAdmin,
+ executif=Depends(require_executif),
+ db: AsyncSession = Depends(get_db),
+):
+ """Créer un compte membre sans invitation."""
+ prenom = req.prenom.strip()
+ telephone = req.telephone.strip()
+ courriel = req.courriel.strip().lower()
+ if not prenom:
+ raise HTTPException(status_code=400, detail="Le prénom est obligatoire")
+ if not telephone:
+ raise HTTPException(status_code=400, detail="Le téléphone est obligatoire")
+ if not courriel or "@" not in courriel:
+ raise HTTPException(status_code=400, detail="Courriel invalide")
+ if not req.pin.isdigit() or len(req.pin) != 4:
+ raise HTTPException(status_code=400, detail="Le PIN doit ĂȘtre composĂ© de 4 chiffres")
+ result = await db.execute(
+ select(Membre).where(
+ func.lower(Membre.courriel) == courriel,
+ Membre.groupe_id == executif.groupe_id,
+ Membre.actif == True,
+ )
+ )
+ if result.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="Ce courriel est déjà utilisé dans ce groupe")
+ membre = Membre(
+ groupe_id=executif.groupe_id,
+ prenom=prenom.title(),
+ nom=req.nom.strip().title() if req.nom and req.nom.strip() else None,
+ telephone=telephone,
+ courriel=courriel,
+ date_abstinence=req.date_abstinence,
+ pin_hash=hash_pin(req.pin),
+ actif=True,
+ )
+ db.add(membre)
+ await db.flush()
+ return MembreComplet.model_validate(membre)
+
+
@router.patch("/{membre_id}/desactiver")
async def desactiver_membre(
membre_id: str,
diff --git a/backend/app/routers/postes.py b/backend/app/routers/postes.py
index f014947..b436cf9 100644
--- a/backend/app/routers/postes.py
+++ b/backend/app/routers/postes.py
@@ -571,6 +571,7 @@ async def affectations_groupe(executif=Depends(require_executif), db: AsyncSessi
mbr = await db.get(Membre, a.membre_id)
out.append({
"id": str(a.id),
+ "membre_id": str(a.membre_id),
"membre_prenom": mbr.prenom if mbr else "?",
"poste_nom": poste.nom if poste else "?",
"poste_categorie": poste.categorie if poste else "?",
diff --git a/backend/app/routers/presences.py b/backend/app/routers/presences.py
index 3696e5c..4e048b9 100644
--- a/backend/app/routers/presences.py
+++ b/backend/app/routers/presences.py
@@ -1,16 +1,27 @@
"""Routes M2 â PrĂ©sences en rĂ©union d'affaires."""
+import io
+import secrets
+from html import escape
+from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
+from zoneinfo import ZoneInfo
from app.core.database import get_db
from app.core.roles import require_secretaire
from app.core.reunions import date_assemblee_affaires_du_mois, jour_reunion_attendu
-from app.core.security import get_current_membre
+from app.core.security import get_current_membre, require_executif
+from app.models.invitation import Invitation
from app.models.reunion import Reunion
from app.models.groupe import Groupe
from app.models.presence import Presence
from app.models.membre import Membre
+from app.models.pv_reunion import PvReunion
+from app.models.proposition import Proposition
+from app.models.poste import Poste
+from app.models.affectation import Affectation
from app.schemas.phase4 import (
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
)
@@ -18,6 +29,84 @@ from app.schemas.phase4 import (
router = APIRouter(prefix="/presences", tags=["M2 â PrĂ©sences affaires"])
+def _date_civile_reunion(valeur):
+ if getattr(valeur, "tzinfo", None):
+ return valeur.astimezone(ZoneInfo("America/Toronto")).date()
+ return valeur.date()
+
+
+async def _postes_vacants(groupe_id, db: AsyncSession):
+ result = await db.execute(select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage))
+ postes = result.scalars().all()
+ vacants = []
+ for poste in postes:
+ affectation = (await db.execute(
+ select(Affectation).where(
+ Affectation.groupe_id == groupe_id,
+ Affectation.poste_id == poste.id,
+ Affectation.statut == "actif",
+ ).limit(1)
+ )).scalar_one_or_none()
+ if not affectation:
+ vacants.append(poste)
+ return vacants
+
+
+async def _assemblee_publique(reunion: Reunion, db: AsyncSession):
+ groupe = await db.get(Groupe, reunion.groupe_id)
+ jour = _date_civile_reunion(reunion.date_reunion)
+ pv = (await db.execute(
+ select(PvReunion).where(PvReunion.reunion_id == reunion.id).order_by(desc(PvReunion.cree_le)).limit(1)
+ )).scalar_one_or_none()
+ propositions = (await db.execute(
+ select(Proposition).where(
+ Proposition.groupe_id == reunion.groupe_id,
+ Proposition.statut.in_(["proposee", "secondee", "rejetee"]),
+ ).order_by(Proposition.cree_le)
+ )).scalars().all()
+ resolutions = (await db.execute(
+ select(Proposition).where(
+ Proposition.groupe_id == reunion.groupe_id,
+ Proposition.statut == "adoptee",
+ Proposition.date_vote == jour,
+ ).order_by(Proposition.numero_resolution)
+ )).scalars().all()
+ postes = await _postes_vacants(reunion.groupe_id, db)
+ return {
+ "groupe": {
+ "nom": groupe.nom if groupe else "Groupe",
+ "adresse_local": groupe.adresse_local if groupe else None,
+ },
+ "assemblee": {
+ "id": str(reunion.id),
+ "date_reunion": reunion.date_reunion,
+ "note": reunion.note,
+ },
+ "pv": {
+ "approuve": pv.approuve,
+ "contenu": pv.contenu,
+ } if pv else None,
+ "propositions": [
+ {
+ "id": str(p.id),
+ "sujet": p.sujet,
+ "description": p.description,
+ "statut": p.statut,
+ }
+ for p in propositions
+ ],
+ "decisions_du_jour": [
+ {
+ "numero_resolution": p.numero_resolution,
+ "sujet": p.sujet,
+ "decision": p.groupe_a_decide_de or p.sujet,
+ }
+ for p in resolutions
+ ],
+ "postes_disponibles": [{"id": str(p.id), "nom": p.nom, "description": p.description} for p in postes],
+ }
+
+
@router.post("/reunion", response_model=ReunionAffairesOut)
async def creer_reunion_affaires(
req: ReunionAffairesCreate,
@@ -29,12 +118,13 @@ async def creer_reunion_affaires(
# ââ Validation : rĂ©union d'affaires prĂ©vue dans les paramĂštres du groupe ââ
dt = req.date_reunion
+ date_civile = _date_civile_reunion(dt)
if groupe.jour_reunion:
jour_attendu = jour_reunion_attendu(groupe)
if jour_attendu is not None:
ordre = groupe.assemblee_affaires_ordre or "premiere"
- date_attendue = date_assemblee_affaires_du_mois(dt.year, dt.month, jour_attendu, ordre)
- if dt.date() != date_attendue:
+ date_attendue = date_assemblee_affaires_du_mois(date_civile.year, date_civile.month, jour_attendu, ordre)
+ if date_civile != date_attendue:
raise HTTPException(
status_code=400,
detail=f"La réunion d'affaires doit avoir lieu à la date prévue ({date_attendue.isoformat()})",
@@ -45,8 +135,8 @@ async def creer_reunion_affaires(
select(Reunion).where(
Reunion.groupe_id == membre.groupe_id,
Reunion.type == "affaires",
- extract("year", Reunion.date_reunion) == dt.year,
- extract("month", Reunion.date_reunion) == dt.month,
+ extract("year", Reunion.date_reunion) == date_civile.year,
+ extract("month", Reunion.date_reunion) == date_civile.month,
)
)
if result.scalars().first():
@@ -101,6 +191,194 @@ async def liste_reunions_affaires(
return out
+@router.post("/reunions/{reunion_id}/invitation")
+async def creer_invitation_assemblee(
+ reunion_id: str,
+ membre=Depends(require_executif),
+ db: AsyncSession = Depends(get_db),
+):
+ reunion = (await db.execute(
+ select(Reunion).where(
+ Reunion.id == reunion_id,
+ Reunion.groupe_id == membre.groupe_id,
+ Reunion.type == "affaires",
+ )
+ )).scalar_one_or_none()
+ if not reunion:
+ raise HTTPException(status_code=404, detail="Assemblée introuvable")
+ code = secrets.token_hex(6).upper()
+ invitation = Invitation(
+ groupe_id=membre.groupe_id,
+ code=code,
+ cree_par=membre.id,
+ expire_le=datetime.now(timezone.utc) + timedelta(days=14),
+ cible_type="assemblee",
+ cible_id=reunion.id,
+ )
+ db.add(invitation)
+ await db.flush()
+ return {
+ "code": invitation.code,
+ "expire_le": invitation.expire_le,
+ "path": f"/invite/assemblee/{invitation.code}",
+ }
+
+
+@router.get("/invite/{code}")
+async def lire_assemblee_invite(
+ code: str,
+ db: AsyncSession = Depends(get_db),
+):
+ invitation = (await db.execute(
+ select(Invitation).where(
+ Invitation.code == code,
+ Invitation.cible_type == "assemblee",
+ )
+ )).scalar_one_or_none()
+ if not invitation:
+ raise HTTPException(status_code=404, detail="Invitation introuvable")
+ if invitation.expire_le < datetime.now(timezone.utc):
+ raise HTTPException(status_code=410, detail="Invitation expirée")
+ reunion = await db.get(Reunion, invitation.cible_id)
+ if not reunion or reunion.type != "affaires":
+ raise HTTPException(status_code=404, detail="Assemblée introuvable")
+ return await _assemblee_publique(reunion, db)
+
+
+@router.get("/reunions/{reunion_id}/rapport.pdf")
+async def rapport_assemblee_pdf(
+ reunion_id: str,
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ """Produire le rapport PDF d'une assemblée d'affaires."""
+ try:
+ from weasyprint import HTML
+ except ImportError:
+ raise HTTPException(status_code=500, detail="WeasyPrint non installé")
+
+ reunion = (await db.execute(
+ select(Reunion).where(
+ Reunion.id == reunion_id,
+ Reunion.groupe_id == membre.groupe_id,
+ Reunion.type == "affaires",
+ )
+ )).scalar_one_or_none()
+ if not reunion:
+ raise HTTPException(status_code=404, detail="Assemblée introuvable")
+
+ groupe = await db.get(Groupe, membre.groupe_id)
+ jour = _date_civile_reunion(reunion.date_reunion)
+
+ presences = (await db.execute(
+ select(Presence).where(Presence.reunion_id == reunion.id)
+ )).scalars().all()
+ presences_rows = []
+ for presence in presences:
+ mbr = await db.get(Membre, presence.membre_id)
+ if presence.present:
+ presences_rows.append(f"
{escape(mbr.prenom if mbr else '?')}")
+
+ pv = (await db.execute(
+ select(PvReunion).where(PvReunion.reunion_id == reunion.id).order_by(desc(PvReunion.cree_le)).limit(1)
+ )).scalar_one_or_none()
+
+ propositions = (await db.execute(
+ select(Proposition).where(
+ Proposition.groupe_id == membre.groupe_id,
+ Proposition.statut == "secondee",
+ ).order_by(Proposition.cree_le)
+ )).scalars().all()
+ propositions_rows = []
+ for prop in propositions:
+ proposeur = await db.get(Membre, prop.proposeur_id) if prop.proposeur_id else None
+ secondeur = await db.get(Membre, prop.secondeur_id) if prop.secondeur_id else None
+ propositions_rows.append(
+ f"{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else 'â')} / {escape(secondeur.prenom if secondeur else 'Ă confirmer')})"
+ )
+
+ resolutions = (await db.execute(
+ select(Proposition).where(
+ Proposition.groupe_id == membre.groupe_id,
+ Proposition.statut == "adoptee",
+ Proposition.date_vote == jour,
+ ).order_by(Proposition.numero_resolution)
+ )).scalars().all()
+ resolutions_rows = [
+ f"{escape(r.numero_resolution or '')} {escape(r.sujet)}".strip()
+ for r in resolutions
+ ]
+
+ postes_rows = [f"{escape(p.nom)}" for p in await _postes_vacants(membre.groupe_id, db)]
+
+ pv_contenu = pv.contenu if pv else "\n".join([
+ "Ouverture de l'assemblée.",
+ "",
+ "Lecture du procÚs-verbal précédent.",
+ "",
+ "Réflexions en cours:",
+ *[f"- {p.sujet}" for p in propositions],
+ "",
+ "Décisions prises pendant cette assemblée:",
+ *[f"- {r.numero_resolution or ''} {r.sujet}".strip() for r in resolutions],
+ "",
+ "Postes disponibles:",
+ *[f"- {p.nom}" for p in await _postes_vacants(membre.groupe_id, db)],
+ ])
+
+ css = """
+ body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
+ .header { border-bottom: 3px solid #3f3c8f; padding-bottom: 12pt; margin-bottom: 18pt; }
+ .groupe { color: #3f3c8f; font-size: 20pt; font-weight: 800; }
+ .coords { color: #667; font-size: 9pt; margin-top: 3pt; }
+ h1 { font-size: 16pt; color: #3f3c8f; margin: 0 0 12pt; }
+ h2 { font-size: 12pt; color: #3f3c8f; margin: 16pt 0 6pt; border-bottom: 1px solid #dedcf4; padding-bottom: 3pt; }
+ .meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
+ .box { border: 1px solid #dedcf4; border-radius: 6pt; padding: 8pt; background: #fafaff; }
+ .label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
+ .value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
+ ul { margin-top: 4pt; }
+ li { margin-bottom: 3pt; }
+ .pv { white-space: pre-wrap; border: 1px solid #e5e7eb; border-radius: 6pt; padding: 8pt; background: #fcfcfd; }
+ .footer { margin-top: 20pt; color: #889; font-size: 8pt; text-align: center; }
+ """
+ groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
+ coords = " · ".join([
+ item for item in [
+ groupe.adresse_local if groupe else None,
+ f"District {groupe.district}" if groupe and groupe.district else None,
+ f"Région {groupe.region}" if groupe and groupe.region else None,
+ ] if item
+ ])
+ html = f"""
+
+ Rapport d'assemblée d'affaires
+
+ Présences
{"".join(presences_rows) or "- Présences à cocher sur papier.
"}
+ Réflexions en cours
{"".join(propositions_rows) or "- Aucune proposition secondée non résolue.
"}
+ Décisions prises
{"".join(resolutions_rows) or "- Aucune décision enregistrée pour cette date.
"}
+ Postes disponibles
{"".join(postes_rows) or "- Aucun poste vacant.
"}
+ ProcĂšs-verbal
{escape(pv_contenu)}
+
+ """
+
+ pdf_bytes = HTML(string=html).write_pdf()
+ filename = f"rapport_assemblee_{jour.isoformat()}.pdf"
+ return StreamingResponse(
+ io.BytesIO(pdf_bytes),
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'inline; filename="{filename}"'},
+ )
+
+
@router.post("/", response_model=list[PresenceOut])
async def enregistrer_presences(
req: PresencesBatch,
diff --git a/backend/app/routers/propositions.py b/backend/app/routers/propositions.py
index 290ec0d..c448eaf 100644
--- a/backend/app/routers/propositions.py
+++ b/backend/app/routers/propositions.py
@@ -7,7 +7,7 @@ from sqlalchemy import select, desc, extract
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.decision_types import type_decision_depuis_proposition
-from app.core.security import get_current_membre, require_executif
+from app.core.security import get_current_membre, is_sysadmin, require_executif
from app.core.roles import membre_a_poste_ou_executif
from app.models.proposition import Proposition
from app.models.reunion import Reunion
@@ -103,7 +103,7 @@ async def seconder_proposition(
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
- if prop.proposeur_id == secondeur.id:
+ if prop.proposeur_id == secondeur.id and not is_sysadmin(membre):
raise HTTPException(status_code=400, detail="On ne peut pas seconder sa propre proposition")
prop.secondeur_id = secondeur.id
diff --git a/backend/app/routers/rapports.py b/backend/app/routers/rapports.py
index 1b90884..84d99c0 100644
--- a/backend/app/routers/rapports.py
+++ b/backend/app/routers/rapports.py
@@ -30,6 +30,8 @@ MOIS_FR = [
class RapportTresorerieAdoption(BaseModel):
note: str | None = None
+ proposeur_id: str | None = None
+ secondeur_id: str | None = None
def _texte_decision_resolution(p: Proposition) -> str:
@@ -856,6 +858,17 @@ async def adopter_rapport_tresorerie(
db: AsyncSession = Depends(get_db),
):
"""Cristalliser le rapport du trésorier adopté par le groupe."""
+ if not req.proposeur_id or not req.secondeur_id:
+ raise HTTPException(status_code=400, detail="Proposeur et secondeur requis")
+ if req.proposeur_id == req.secondeur_id:
+ raise HTTPException(status_code=400, detail="Le proposeur et le secondeur doivent ĂȘtre diffĂ©rents")
+ proposeur = await db.get(Membre, req.proposeur_id)
+ secondeur = await db.get(Membre, req.secondeur_id)
+ if not proposeur or proposeur.groupe_id != membre.groupe_id or not proposeur.actif:
+ raise HTTPException(status_code=404, detail="Proposeur introuvable ou inactif")
+ if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
+ raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
+
result = await db.execute(
select(RapportAdopte).where(
RapportAdopte.groupe_id == membre.groupe_id,
@@ -867,7 +880,15 @@ async def adopter_rapport_tresorerie(
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce rapport est déjà adopté")
- html_content, _, donnees = await _html_etat_resultats(annee, mois, membre, db, req.note)
+ note = req.note or ""
+ note_formelle = (
+ f"{note.strip()}\n\n" if note.strip() else ""
+ ) + f"Rapport adopté sur proposition de {proposeur.prenom}, secondée par {secondeur.prenom}."
+ html_content, _, donnees = await _html_etat_resultats(annee, mois, membre, db, note_formelle)
+ donnees["proposeur_id"] = str(proposeur.id)
+ donnees["secondeur_id"] = str(secondeur.id)
+ donnees["proposeur_prenom"] = proposeur.prenom
+ donnees["secondeur_prenom"] = secondeur.prenom
rapport = RapportAdopte(
groupe_id=membre.groupe_id,
adopte_par_id=membre.id,
diff --git a/backend/app/routers/reunions.py b/backend/app/routers/reunions.py
index 3b710ec..c2fe8df 100644
--- a/backend/app/routers/reunions.py
+++ b/backend/app/routers/reunions.py
@@ -1,6 +1,10 @@
"""Routes S4 â Rapport de rĂ©union."""
+import io
+from html import escape
from datetime import datetime, timezone
+from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
@@ -8,13 +12,24 @@ from app.core.database import get_db
from app.core.reunions import jour_reunion_attendu
from app.core.security import get_current_membre, require_executif
from app.models.reunion import Reunion
+from app.models.collecte import Collecte
from app.models.groupe import Groupe
from app.models.membre import Membre
+from app.models.jeton import Jeton
+from app.models.litterature import Litterature
+from app.models.vente_jeton import VenteJeton
+from app.models.vente_litterature import VenteLitterature
from app.schemas.phase1 import ReunionCreate, ReunionOut
router = APIRouter(prefix="/reunions", tags=["S4 â Rapport de rĂ©union"])
+def _date_civile_reunion(valeur: datetime):
+ if valeur.tzinfo:
+ return valeur.astimezone(ZoneInfo("America/Toronto")).date()
+ return valeur.date()
+
+
@router.post("/", response_model=ReunionOut)
async def creer_rapport_reunion(
req: ReunionCreate,
@@ -26,15 +41,16 @@ async def creer_rapport_reunion(
# ââ Validation : rĂ©union rĂ©guliĂšre ââ
if req.type == "reguliere" and groupe.jour_reunion:
+ date_civile = _date_civile_reunion(req.date_reunion)
jour_attendu = jour_reunion_attendu(groupe)
- jour_soumis = req.date_reunion.weekday()
+ jour_soumis = date_civile.weekday()
if jour_attendu is not None and jour_soumis != jour_attendu:
raise HTTPException(
status_code=400,
detail=f"La réunion réguliÚre doit avoir lieu le {groupe.jour_reunion}",
)
# Une seule réguliÚre par semaine ISO
- iso_year, iso_week, _ = req.date_reunion.isocalendar()
+ iso_year, iso_week, _ = date_civile.isocalendar()
result = await db.execute(
select(Reunion).where(
Reunion.groupe_id == membre.groupe_id,
@@ -49,11 +65,17 @@ async def creer_rapport_reunion(
detail=f"Il y a déjà une réunion réguliÚre pour la semaine {iso_week}",
)
+ animateur = membre
+ if req.animateur_id:
+ animateur = await db.get(Membre, req.animateur_id)
+ if not animateur or animateur.groupe_id != membre.groupe_id or not animateur.actif:
+ raise HTTPException(status_code=404, detail="Animateur introuvable ou inactif")
+
reunion = Reunion(
groupe_id=membre.groupe_id,
type=req.type,
date_reunion=req.date_reunion,
- animateur_id=membre.id,
+ animateur_id=animateur.id,
theme=req.theme,
annonces=req.annonces,
note=req.note,
@@ -64,7 +86,7 @@ async def creer_rapport_reunion(
id=reunion.id,
type=reunion.type,
date_reunion=reunion.date_reunion,
- animateur_prenom=membre.prenom,
+ animateur_prenom=animateur.prenom,
theme=reunion.theme,
annonces=reunion.annonces,
note=reunion.note,
@@ -102,6 +124,304 @@ async def liste_reunions(
return out
+@router.get("/rencontres/details")
+async def liste_rencontres_details(
+ limite: int = 80,
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ """Lister les rencontres réguliÚres avec leurs saisies associées."""
+ result = await db.execute(
+ select(Reunion)
+ .where(Reunion.groupe_id == membre.groupe_id, Reunion.type == "reguliere")
+ .order_by(desc(Reunion.date_reunion))
+ .limit(limite)
+ )
+ rencontres = []
+ for reunion in result.scalars().all():
+ jour = reunion.date_reunion.date()
+ animateur = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None
+
+ collecte_result = await db.execute(
+ select(Collecte).where(
+ Collecte.groupe_id == membre.groupe_id,
+ (Collecte.reunion_id == reunion.id) | (Collecte.date_collecte == jour),
+ ).limit(1)
+ )
+ collecte = collecte_result.scalar_one_or_none()
+ collecte_out = None
+ if collecte:
+ detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
+ collecte_out = {
+ "id": str(collecte.id),
+ "montant": float(collecte.montant),
+ "statut": collecte.statut,
+ "detenteur_prenom": detenteur.prenom if detenteur else None,
+ }
+
+ ventes_lit_result = await db.execute(
+ select(VenteLitterature).where(
+ VenteLitterature.groupe_id == membre.groupe_id,
+ (VenteLitterature.reunion_id == reunion.id) | (VenteLitterature.date_vente == jour),
+ )
+ )
+ ventes_litterature = []
+ for vente in ventes_lit_result.scalars().all():
+ item = await db.get(Litterature, vente.litterature_id)
+ vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
+ ventes_litterature.append({
+ "id": str(vente.id),
+ "titre": item.titre if item else "?",
+ "quantite": vente.quantite,
+ "montant": float(vente.montant),
+ "vendu_par_prenom": vendeur.prenom if vendeur else None,
+ "remis_tresorier": vente.remis_tresorier,
+ })
+
+ ventes_jetons_result = await db.execute(
+ select(VenteJeton).where(
+ VenteJeton.groupe_id == membre.groupe_id,
+ (VenteJeton.reunion_id == reunion.id) | (VenteJeton.date_vente == jour),
+ )
+ )
+ ventes_jetons = []
+ for vente in ventes_jetons_result.scalars().all():
+ vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
+ ventes_jetons.append({
+ "id": str(vente.id),
+ "type_jeton": vente.type_jeton,
+ "quantite": vente.quantite,
+ "montant": float(vente.montant),
+ "vendu_par_prenom": vendeur.prenom if vendeur else None,
+ "remis_tresorier": vente.remis_tresorier,
+ })
+
+ jetons_result = await db.execute(
+ select(Jeton).where(
+ Jeton.groupe_id == membre.groupe_id,
+ (Jeton.reunion_id == reunion.id) | (Jeton.date_remise == jour),
+ )
+ )
+ jetons = []
+ for jeton in jetons_result.scalars().all():
+ cible = await db.get(Membre, jeton.membre_id) if jeton.membre_id else None
+ jetons.append({
+ "id": str(jeton.id),
+ "type_jeton": jeton.type_jeton,
+ "membre_prenom": cible.prenom if cible else None,
+ "annees": jeton.annees,
+ })
+
+ rencontres.append({
+ "id": str(reunion.id),
+ "type": reunion.type,
+ "date_reunion": reunion.date_reunion,
+ "animateur_prenom": animateur.prenom if animateur else None,
+ "theme": reunion.theme,
+ "annonces": reunion.annonces,
+ "note": reunion.note,
+ "collecte": collecte_out,
+ "ventes_litterature": ventes_litterature,
+ "ventes_jetons": ventes_jetons,
+ "jetons": jetons,
+ })
+ return rencontres
+
+
+@router.patch("/{reunion_id}", response_model=ReunionOut)
+async def modifier_reunion(
+ reunion_id: str,
+ req: ReunionCreate,
+ membre=Depends(require_executif),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(Reunion).where(Reunion.id == reunion_id, Reunion.groupe_id == membre.groupe_id)
+ )
+ reunion = result.scalar_one_or_none()
+ if not reunion:
+ raise HTTPException(status_code=404, detail="Réunion introuvable")
+ animateur = None
+ if req.animateur_id:
+ animateur = await db.get(Membre, req.animateur_id)
+ if not animateur or animateur.groupe_id != membre.groupe_id or not animateur.actif:
+ raise HTTPException(status_code=404, detail="Animateur introuvable ou inactif")
+ reunion.animateur_id = animateur.id
+ reunion.theme = req.theme
+ reunion.annonces = req.annonces
+ reunion.note = req.note
+ anim = animateur or (await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None)
+ await db.flush()
+ return ReunionOut(
+ id=reunion.id,
+ type=reunion.type,
+ date_reunion=reunion.date_reunion,
+ animateur_prenom=anim.prenom if anim else None,
+ theme=reunion.theme,
+ annonces=reunion.annonces,
+ note=reunion.note,
+ cree_le=reunion.cree_le,
+ )
+
+
+@router.get("/{reunion_id}/rapport.pdf")
+async def rapport_reunion_pdf(
+ reunion_id: str,
+ membre=Depends(get_current_membre),
+ db: AsyncSession = Depends(get_db),
+):
+ try:
+ from weasyprint import HTML
+ except ImportError:
+ raise HTTPException(status_code=500, detail="WeasyPrint non installé")
+
+ result = await db.execute(
+ select(Reunion).where(Reunion.id == reunion_id, Reunion.groupe_id == membre.groupe_id)
+ )
+ reunion = result.scalar_one_or_none()
+ if not reunion:
+ raise HTTPException(status_code=404, detail="Réunion introuvable")
+
+ groupe = await db.get(Groupe, membre.groupe_id)
+ animateur = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None
+ jour = reunion.date_reunion.date()
+
+ collecte = (await db.execute(
+ select(Collecte).where(
+ Collecte.groupe_id == membre.groupe_id,
+ (Collecte.reunion_id == reunion.id) | (Collecte.date_collecte == jour),
+ ).limit(1)
+ )).scalar_one_or_none()
+ collecte_html = "Aucune collecte saisie.
"
+ if collecte:
+ detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
+ collecte_html = (
+ ""
+ f"| Montant | {float(collecte.montant):.2f} $ |
"
+ f"| DĂ©tenteur | {escape(detenteur.prenom if detenteur else 'â')} |
"
+ f"| Statut | {escape(collecte.statut)} |
"
+ "
"
+ )
+
+ ventes_lit_rows = []
+ ventes_lit_result = await db.execute(
+ select(VenteLitterature).where(
+ VenteLitterature.groupe_id == membre.groupe_id,
+ (VenteLitterature.reunion_id == reunion.id) | (VenteLitterature.date_vente == jour),
+ )
+ )
+ for vente in ventes_lit_result.scalars().all():
+ item = await db.get(Litterature, vente.litterature_id)
+ vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
+ ventes_lit_rows.append(
+ f"| {escape(item.titre if item else '?')} | {vente.quantite} | "
+ f"{float(vente.montant):.2f} $ | {escape(vendeur.prenom if vendeur else 'â')} | "
+ f"{'Reçue' if vente.remis_tresorier else 'à recevoir'} |
"
+ )
+
+ ventes_jetons_rows = []
+ ventes_jetons_result = await db.execute(
+ select(VenteJeton).where(
+ VenteJeton.groupe_id == membre.groupe_id,
+ (VenteJeton.reunion_id == reunion.id) | (VenteJeton.date_vente == jour),
+ )
+ )
+ for vente in ventes_jetons_result.scalars().all():
+ vendeur = await db.get(Membre, vente.vendu_par) if vente.vendu_par else None
+ ventes_jetons_rows.append(
+ f"| {escape(vente.type_jeton)} | {vente.quantite} | "
+ f"{float(vente.montant):.2f} $ | {escape(vendeur.prenom if vendeur else 'â')} | "
+ f"{'Reçue' if vente.remis_tresorier else 'à recevoir'} |
"
+ )
+
+ jetons_rows = []
+ jetons_result = await db.execute(
+ select(Jeton).where(
+ Jeton.groupe_id == membre.groupe_id,
+ (Jeton.reunion_id == reunion.id) | (Jeton.date_remise == jour),
+ )
+ )
+ for jeton in jetons_result.scalars().all():
+ cible = await db.get(Membre, jeton.membre_id) if jeton.membre_id else None
+ detail = f"{jeton.annees} ans" if jeton.annees else ""
+ jetons_rows.append(
+ f"| {escape(jeton.type_jeton)} | {escape(cible.prenom if cible else 'Anonyme')} | {escape(detail)} |
"
+ )
+
+ annonces = reunion.annonces or []
+ annonces_html = "".join(f"{escape(a)}" for a in annonces) or "Aucune annonce."
+ css = """
+ body { font-family: 'DejaVu Sans', sans-serif; font-size: 10.5pt; color: #27323a; margin: 1.6cm; }
+ .header { border-bottom: 3px solid #1a5276; padding-bottom: 12pt; margin-bottom: 18pt; }
+ .groupe { color: #1a5276; font-size: 20pt; font-weight: 800; }
+ .coords { color: #667; font-size: 9pt; margin-top: 3pt; }
+ h1 { font-size: 16pt; color: #1a5276; margin: 0 0 12pt; }
+ h2 { font-size: 12pt; color: #1a5276; margin: 16pt 0 6pt; border-bottom: 1px solid #d8e2ea; padding-bottom: 3pt; }
+ .meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8pt; }
+ .box { border: 1px solid #d8e2ea; border-radius: 6pt; padding: 8pt; background: #f8fafc; }
+ .label { font-size: 8pt; color: #667; text-transform: uppercase; font-weight: 700; }
+ .value { font-size: 11pt; font-weight: 700; margin-top: 2pt; }
+ table { width: 100%; border-collapse: collapse; margin-top: 5pt; }
+ th { text-align: left; background: #1a5276; color: white; padding: 5pt; font-size: 8.5pt; }
+ td { border-bottom: 1px solid #e5e9ef; padding: 5pt; vertical-align: top; }
+ .montant { text-align: right; white-space: nowrap; }
+ .note { white-space: pre-wrap; }
+ .page-break { break-before: page; page-break-before: always; }
+ .footer { margin-top: 20pt; color: #889; font-size: 8pt; text-align: center; }
+ """
+ groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
+ coords = " · ".join([
+ item for item in [
+ groupe.adresse_local if groupe else None,
+ f"District {groupe.district}" if groupe and groupe.district else None,
+ f"Région {groupe.region}" if groupe and groupe.region else None,
+ ] if item
+ ])
+ ventes_lit_html = (
+ "| Titre | Qté | Montant | Vendu par | Statut |
"
+ + "".join(ventes_lit_rows) + "
"
+ if ventes_lit_rows else "Aucune vente de littérature.
"
+ )
+ ventes_jetons_html = (
+ "| Type | Qté | Montant | Vendu par | Statut |
"
+ + "".join(ventes_jetons_rows) + "
"
+ if ventes_jetons_rows else "Aucune vente de jetons.
"
+ )
+ jetons_html = (
+ "| Type | Membre | Détail |
" + "".join(jetons_rows) + "
"
+ if jetons_rows else "Aucun jeton/gùteau comptabilisé.
"
+ )
+ html = f"""
+
+ Rapport de rencontre
+
+ Annonces
+ Notes
{escape(reunion.note or 'Aucune note.')}
+ Collecte
{collecte_html}
+
+ Ventes de littérature
{ventes_lit_html}
+ Ventes de jetons
{ventes_jetons_html}
+ Jetons/GĂąteaux
{jetons_html}
+
+ """
+
+ pdf_bytes = HTML(string=html).write_pdf()
+ filename = f"rapport_rencontre_{jour.isoformat()}.pdf"
+ return StreamingResponse(
+ io.BytesIO(pdf_bytes),
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'inline; filename="{filename}"'},
+ )
+
+
@router.get("/{reunion_id}", response_model=ReunionOut)
async def detail_reunion(
reunion_id: str,
diff --git a/backend/app/routers/ventes_litterature.py b/backend/app/routers/ventes_litterature.py
index 0ded228..420ebb3 100644
--- a/backend/app/routers/ventes_litterature.py
+++ b/backend/app/routers/ventes_litterature.py
@@ -63,14 +63,20 @@ async def enregistrer_vente(
# Décrémenter le stock
item.quantite_stock -= req.quantite
+ vendeur_id = req.vendu_par_id or membre.id
+ vendeur = await db.get(Membre, vendeur_id)
+ if not vendeur or vendeur.groupe_id != membre.groupe_id or not vendeur.actif:
+ raise HTTPException(status_code=404, detail="Vendeur introuvable")
+
# Créer la vente
vente = VenteLitterature(
litterature_id=req.litterature_id,
groupe_id=membre.groupe_id,
+ reunion_id=req.reunion_id,
quantite=req.quantite,
montant=req.montant,
date_vente=req.date_vente,
- vendu_par=membre.id,
+ vendu_par=vendeur_id,
)
db.add(vente)
await db.flush()
@@ -81,7 +87,7 @@ async def enregistrer_vente(
quantite=vente.quantite,
montant=float(vente.montant),
date_vente=vente.date_vente,
- vendu_par_prenom=membre.prenom,
+ vendu_par_prenom=vendeur.prenom,
cree_le=vente.cree_le,
)
@@ -184,7 +190,7 @@ async def confirmer_reception_vente(
vente.date_remise = date_type.today()
db.add(TransactionComptable(
groupe_id=vente.groupe_id,
- date_transaction=vente.date_remise,
+ date_transaction=vente.date_vente,
type="vente_litterature",
compte="encaisse",
montant=vente.montant,
diff --git a/backend/app/schemas/phase1.py b/backend/app/schemas/phase1.py
index 2faa439..8b25dbd 100644
--- a/backend/app/schemas/phase1.py
+++ b/backend/app/schemas/phase1.py
@@ -121,6 +121,7 @@ class SoldesOut(BaseModel):
class ReunionCreate(BaseModel):
type: str = "reguliere"
date_reunion: datetime
+ animateur_id: Optional[UUID] = None
theme: Optional[str] = None
annonces: Optional[list[str]] = None
note: Optional[str] = None
diff --git a/backend/app/schemas/phase5.py b/backend/app/schemas/phase5.py
index 845f3b5..be5fcc6 100644
--- a/backend/app/schemas/phase5.py
+++ b/backend/app/schemas/phase5.py
@@ -8,11 +8,21 @@ from typing import Optional
# ââ S2 Remise de jeton ââ
class JetonCreate(BaseModel):
membre_id: Optional[UUID] = None # None pour jeton de désir anonyme
+ reunion_id: Optional[UUID] = None
type_jeton: str # desir, 24h, 1mois, 2mois, 3mois, 6mois, 9mois, 1an, multiple
date_remise: date
annees: Optional[int] = None # requis si type_jeton='multiple'
+class VenteJetonCreate(BaseModel):
+ reunion_id: Optional[UUID] = None
+ type_jeton: str
+ quantite: int
+ montant: float
+ date_vente: date
+ vendu_par_id: Optional[UUID] = None
+
+
class JetonOut(BaseModel):
id: UUID
membre_prenom: Optional[str] = None
diff --git a/backend/app/schemas/phase6.py b/backend/app/schemas/phase6.py
index e8514af..a3d05fb 100644
--- a/backend/app/schemas/phase6.py
+++ b/backend/app/schemas/phase6.py
@@ -44,9 +44,11 @@ class AjustementStock(BaseModel):
# ââ S3 Vente littĂ©rature ââ
class VenteCreate(BaseModel):
litterature_id: UUID
+ reunion_id: Optional[UUID] = None
quantite: int
montant: float
date_vente: date
+ vendu_par_id: Optional[UUID] = None
class VenteOut(BaseModel):
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py
index 3c0b9c0..0785952 100644
--- a/backend/app/schemas/schemas.py
+++ b/backend/app/schemas/schemas.py
@@ -58,6 +58,15 @@ class MembreUpdate(BaseModel):
date_abstinence: Optional[date] = None
+class MembreCreateAdmin(BaseModel):
+ prenom: str
+ nom: Optional[str] = None
+ telephone: str
+ courriel: str
+ pin: str
+ date_abstinence: Optional[date] = None
+
+
class ChangerPinRequest(BaseModel):
ancien_pin: str
nouveau_pin: str
diff --git a/backend/app/services/admin_database.py b/backend/app/services/admin_database.py
index 1641b31..17dd5e2 100644
--- a/backend/app/services/admin_database.py
+++ b/backend/app/services/admin_database.py
@@ -35,6 +35,7 @@ from app.models.reserve import Reserve
from app.models.reunion import Reunion
from app.models.transaction_comptable import TransactionComptable
from app.models.vente_litterature import VenteLitterature
+from app.models.vente_jeton import VenteJeton
POSTES = [
@@ -332,12 +333,31 @@ async def _ajouter_historique_demo(db: AsyncSession, groupe: Groupe, membres: di
item = litterature[index % len(litterature)]
montant = float(item.prix_coutant or 2) * 2
item.quantite_stock = max(0, item.quantite_stock - 2)
- vente = VenteLitterature(groupe_id=groupe.id, litterature_id=item.id, quantite=2, montant=montant, date_vente=jour, vendu_par=membres["karim.demo@example.test"].id, remis_tresorier=True, recu_par=tresorier.id, date_remise=jour)
+ vente = VenteLitterature(groupe_id=groupe.id, reunion_id=reunion.id, litterature_id=item.id, quantite=2, montant=montant, date_vente=jour, vendu_par=membres["karim.demo@example.test"].id, remis_tresorier=True, recu_par=tresorier.id, date_remise=jour)
db.add(vente)
await db.flush()
encaisse += montant
db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="vente_litterature", compte="encaisse", montant=montant, sens="debit", source_table="ventes_litterature", source_id=vente.id, note="Vente de littérature", cree_par=tresorier.id))
+ if index % 12 == 6:
+ montant = 6
+ vente_jeton = VenteJeton(
+ groupe_id=groupe.id,
+ reunion_id=reunion.id,
+ type_jeton=["desir", "1mois", "1an"][index % 3],
+ quantite=2,
+ montant=montant,
+ date_vente=jour,
+ vendu_par=membres["melanie.demo@example.test"].id,
+ remis_tresorier=True,
+ recu_par=tresorier.id,
+ date_remise=jour,
+ )
+ db.add(vente_jeton)
+ await db.flush()
+ encaisse += montant
+ db.add(TransactionComptable(groupe_id=groupe.id, date_transaction=jour, type="vente_jeton", compte="encaisse", montant=montant, sens="debit", source_table="ventes_jetons", source_id=vente_jeton.id, note="Vente de jetons", cree_par=tresorier.id))
+
if index % 13 == 4:
montant = 125
if banque >= montant:
@@ -359,7 +379,7 @@ async def _ajouter_historique_demo(db: AsyncSession, groupe: Groupe, membres: di
candidature.proposition_id = decision.id
if index % 10 == 5:
- db.add(Jeton(groupe_id=groupe.id, membre_id=membres_actifs[index % len(membres_actifs)].id, type_jeton="multiple", date_remise=jour, annees=1 + index % 5, remis_par=membres["melanie.demo@example.test"].id))
+ db.add(Jeton(groupe_id=groupe.id, reunion_id=reunion.id, membre_id=membres_actifs[index % len(membres_actifs)].id, type_jeton="multiple", date_remise=jour, annees=1 + index % 5, remis_par=membres["melanie.demo@example.test"].id))
if index % 17 == 7:
reserve = reserves[["Loyer", "Littérature", "Prudentielle"][index % 3]]
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index bd30a06..d7a43bd 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -28,11 +28,13 @@ import Impliquer from './pages/Impliquer';
import GererPostes from './pages/GererPostes';
import ReunionHebdomadaire from './pages/ReunionHebdomadaire';
import AssembleeAffaires from './pages/AssembleeAffaires';
+import AssembleeInvite from './pages/AssembleeInvite';
import JournalActions from './pages/JournalActions';
import BottomNav from './components/BottomNav';
export default function App() {
- const { token, page, loadAccueil, loadGroupes } = useStore();
+ const { token, page, navContext, loadAccueil, loadGroupes } = useStore();
+ const chemin = window.location.pathname;
useEffect(() => {
const handleUnauthorized = () => useStore.getState().logout();
@@ -59,7 +61,11 @@ export default function App() {
if (token) {
let pageActive;
switch (page) {
- case 'accueil': pageActive = ; break;
+ case 'accueil':
+ if (navContext === 'reunion') pageActive = ;
+ else if (navContext === 'affaires') pageActive = ;
+ else pageActive = ;
+ break;
case 'profil': pageActive = ; break;
case 'S0': pageActive = ; break;
case 'M0': pageActive = ; break;
@@ -98,6 +104,8 @@ export default function App() {
);
}
+ if (chemin.startsWith('/invite/assemblee/')) return ;
+
// Pages publiques
switch (page) {
case 'inscription': return ;
diff --git a/frontend/src/components/PageHeader.jsx b/frontend/src/components/PageHeader.jsx
index e38f2cd..8412039 100644
--- a/frontend/src/components/PageHeader.jsx
+++ b/frontend/src/components/PageHeader.jsx
@@ -1,21 +1,32 @@
import React from 'react';
import { useStore } from '../stores/store';
-export default function PageHeader({ titre, sousTitre }) {
- const { setPage } = useStore();
+export default function PageHeader({ titre, sousTitre, retour = true, action = null }) {
+ const { membre, setPage, logout } = useStore();
return (
-
-
-
-
{titre}
- {sousTitre &&
{sousTitre}
}
+
+
+
+ {retour && (
+
+ )}
+
+
Bonjour {membre?.prenom}
+
{titre}
+ {sousTitre &&
{sousTitre}
}
+
+
+
+ {action &&
{action}
}
);
}
diff --git a/frontend/src/pages/Accueil.jsx b/frontend/src/pages/Accueil.jsx
index 7d19a8c..26ab5c1 100644
--- a/frontend/src/pages/Accueil.jsx
+++ b/frontend/src/pages/Accueil.jsx
@@ -12,14 +12,15 @@ const COULEURS = {
gray: 'bg-gray-50 border-gray-200 text-gray-700',
};
-const NAVIGABLE = ['C7', 'S0', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C9', 'G2', 'G9', 'G11', 'G12', 'G13', 'M0', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
+const NAVIGABLE = ['C7', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C9', 'G2', 'G9', 'G11', 'G12', 'G13', 'M0', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
+const MODULES_CONTEXTE = new Set(['S0', 'M0', 'M1', 'M2']);
const CONTEXTES = {
accueil: {
titre: 'Accueil',
description: "Ătat gĂ©nĂ©ral et consultations courantes.",
sections: [
- { titre: 'Ă faire maintenant', codes: ['S5', 'C5', 'C9', 'S0'] },
+ { titre: 'Ă faire maintenant', codes: ['S5', 'C5', 'C9'] },
{ titre: 'Ătat', codes: ['S1T', 'C2', 'C1', 'C4'] },
{ titre: 'Mon espace', codes: ['C7'] },
],
@@ -27,20 +28,12 @@ const CONTEXTES = {
reunion: {
titre: 'Rencontres',
description: 'Activités liées aux rencontres hebdomadaires.',
- sections: [
- { titre: 'Rapport de réunion', codes: ['S0', 'S4'] },
- { titre: 'Saisies', codes: ['S1', 'S2', 'S3'] },
- { titre: 'Références', codes: ['C2', 'C4'] },
- ],
+ sections: [],
},
affaires: {
titre: 'Assemblées',
- description: "Activités liées aux assemblées d'affaires.",
- sections: [
- { titre: "Assemblée d'affaires", codes: ['M0', 'M2', 'M1', 'M3'] },
- { titre: 'Gouvernance', codes: ['C5', 'C1'] },
- { titre: 'Postes Ă pourvoir', codes: ['C9'] },
- ],
+ description: "Assemblées d'affaires.",
+ sections: [],
},
gestion: {
titre: 'Gestion',
@@ -48,7 +41,7 @@ const CONTEXTES = {
sections: [
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
{ titre: 'Membres et structure', codes: ['G2', 'G11', 'G12', 'G9', 'G13'] },
- { titre: 'Inventaire', codes: ['C4', 'M5', 'M6'] },
+ { titre: 'Inventaire', codes: ['M5', 'M6'] },
],
},
};
@@ -81,7 +74,6 @@ function Tuile({ module, onClick }) {
export default function Accueil() {
const {
membre,
- postes,
logout,
modulesConsultation,
modulesSaisie,
@@ -97,7 +89,7 @@ export default function Accueil() {
...(modulesSaisie || []),
...(modulesConsultation || []),
...(modulesGestion || []),
- ].forEach((m) => map.set(m.code, m));
+ ].filter((m) => !MODULES_CONTEXTE.has(m.code)).forEach((m) => map.set(m.code, m));
return Array.from(map.values());
}, [modulesSaisie, modulesConsultation, modulesGestion]);
@@ -131,18 +123,9 @@ export default function Accueil() {
{contexte.description}
- {postes.length > 0 && (
-
- {postes.map((p) => (
-
- {p}
-
- ))}
-
- )}
diff --git a/frontend/src/pages/AssembleeAffaires.jsx b/frontend/src/pages/AssembleeAffaires.jsx
index 59248dc..6d59e5c 100644
--- a/frontend/src/pages/AssembleeAffaires.jsx
+++ b/frontend/src/pages/AssembleeAffaires.jsx
@@ -5,40 +5,65 @@ import PageHeader from '../components/PageHeader';
import { dateHeureReunionIso, datesAssembleesAffaires, dateLocale, labelDateReunion } from '../utils/datesReunion';
const INPUT = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue';
+const METHODES = [['VIREMENT', 'Virement'], ['CHEQUE', 'ChĂšque']];
+const STATUTS = {
+ proposee: ['Ă seconder', 'bg-yellow-100 text-yellow-700'],
+ secondee: ['Secondée', 'bg-blue-100 text-blue-700'],
+ rejetee: ['Rejetée', 'bg-red-100 text-red-700'],
+};
-export default function AssembleeAffaires() {
+export default function AssembleeAffaires({ contexte = false }) {
const { postes, groupe } = useStore();
- const estExecutif = postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(iÚre)', 'Secrétaire'].includes(p));
- const choixDates = datesAssembleesAffaires(groupe);
+ const estExecutif = postes.some((p) => ['Sysadmin', 'RSG', 'RSG Substitut(e)', 'Trésorier(iÚre)', 'Secrétaire'].includes(p));
+ const choixDatesChrono = datesAssembleesAffaires(groupe);
+ const choixDates = [...choixDatesChrono].reverse();
+ const prochaineDate = choixDatesChrono.find((d) => d >= dateLocale()) || choixDatesChrono[choixDatesChrono.length - 1] || dateLocale();
+
const [membres, setMembres] = useState([]);
const [reunions, setReunions] = useState([]);
const [pvs, setPvs] = useState([]);
const [propositions, setPropositions] = useState([]);
const [resolutions, setResolutions] = useState([]);
const [postesVacants, setPostesVacants] = useState([]);
- const [reunionId, setReunionId] = useState('');
- const [dateSelection, setDateSelection] = useState(dateLocale());
- const [dateReunion, setDateReunion] = useState(dateLocale());
+ const [soldes, setSoldes] = useState(null);
+ const [destinataires, setDestinataires] = useState([]);
+ const [rapportsTresorerie, setRapportsTresorerie] = useState([]);
+ const [ouverteId, setOuverteId] = useState('');
+ const [action, setAction] = useState('');
+ const [planifier, setPlanifier] = useState(false);
+ const [dateReunion, setDateReunion] = useState(prochaineDate);
const [note, setNote] = useState('');
const [presencesMap, setPresencesMap] = useState({});
const [contenu, setContenu] = useState('');
const [pvPrecedent, setPvPrecedent] = useState(null);
const [pvCorrection, setPvCorrection] = useState('');
- const [showPvPrecedent, setShowPvPrecedent] = useState(false);
const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' });
+ const [don, setDon] = useState({ destination: 'District 87-16', montant: '', methode: 'VIREMENT', proposeur_id: '', secondeur_id: '' });
+ const [adoptionTresorier, setAdoptionTresorier] = useState({ proposeur_id: '', secondeur_id: '', note: '' });
+ const [invitationTexte, setInvitationTexte] = useState('');
+ const [posteOuvert, setPosteOuvert] = useState('');
+ const [posteMode, setPosteMode] = useState('');
+ const [motivation, setMotivation] = useState('');
+ const [membreProposeId, setMembreProposeId] = useState('');
+ const [nouvelleProp, setNouvelleProp] = useState({ sujet: '', description: '' });
+ const [resolutionForm, setResolutionForm] = useState({});
+ const [seconderPour, setSeconderPour] = useState({});
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const charger = async () => {
try {
- const [m, r, pv, props, res, postesDispo] = await Promise.all([
+ const [m, r, pv, props, res, postesDispo, s, dests, rapports] = await Promise.all([
api.get('/membres/actifs'),
api.get('/presences/reunions'),
api.get('/pv/'),
- api.get('/propositions/?statut=secondee&limite=100'),
+ api.get('/propositions/?limite=100'),
api.get('/propositions/resolutions'),
api.get('/postes/disponibles'),
+ api.get('/collectes/soldes'),
+ api.get('/contributions/destinataires'),
+ api.get('/rapports/adoptes?type_rapport=tresorerie'),
]);
setMembres(m);
setReunions(r);
@@ -46,70 +71,238 @@ export default function AssembleeAffaires() {
setPropositions(props);
setResolutions(res);
setPostesVacants(postesDispo);
+ setSoldes(s);
+ setDestinataires(dests);
+ setRapportsTresorerie(rapports);
const map = {};
m.forEach((mb) => { map[mb.id] = true; });
setPresencesMap(map);
- const duJour = r.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
- if (duJour) setReunionId(duJour.id);
- } catch (e) { setError(e.message); }
+ const defaut = dests.find((x) => x.nom === 'District 87-16') || dests[0];
+ setDon((f) => ({
+ ...f,
+ destination: f.destination || defaut?.nom || 'District 87-16',
+ montant: f.montant || (s?.disponible > 0 ? s.disponible.toFixed(2) : ''),
+ proposeur_id: f.proposeur_id || m[0]?.id || '',
+ secondeur_id: f.secondeur_id || m[1]?.id || '',
+ }));
+ setAdoptionTresorier((f) => ({
+ ...f,
+ proposeur_id: f.proposeur_id || m[0]?.id || '',
+ secondeur_id: f.secondeur_id || m[1]?.id || '',
+ }));
+ if (!membreProposeId && m[0]) setMembreProposeId(m[0].id);
+ if (!ouverteId && r.length) setOuverteId(r[0].id);
+ } catch (e) {
+ setError(e.message);
+ }
};
useEffect(() => { charger(); }, []);
useEffect(() => {
- if (choixDates.length > 0) {
- if (!choixDates.includes(dateSelection)) setDateSelection(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
- if (!choixDates.includes(dateReunion)) setDateReunion(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
- }
+ if (choixDates.length > 0 && !choixDates.includes(dateReunion)) setDateReunion(prochaineDate);
}, [groupe?.jour_reunion, groupe?.assemblee_affaires_ordre]);
+
+ const reunionOuverte = reunions.find((r) => r.id === ouverteId);
+ const dateOuverte = reunionOuverte?.date_reunion?.slice(0, 10) || prochaineDate;
+ const pvJour = pvs.find((p) => p.date_reunion?.slice(0, 10) === dateOuverte);
+ const resolutionsJour = resolutions.filter((r) => (r.date_vote || r.cree_le?.slice(0, 10)) === dateOuverte);
+ const nbPresents = Object.values(presencesMap).filter(Boolean).length;
+ const periodeTresorier = moisPrecedent(dateOuverte);
+ const rapportTresorierAdopte = rapportsTresorerie.find((r) => r.annee === periodeTresorier.annee && r.mois === periodeTresorier.mois);
+
useEffect(() => {
- const duJour = reunions.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
- setReunionId(duJour?.id || '');
- const precedents = pvs.filter((p) => p.date_reunion && p.date_reunion.slice(0, 10) < dateSelection);
+ if (!reunionOuverte) return;
+ const precedents = pvs
+ .filter((p) => p.date_reunion && p.date_reunion.slice(0, 10) < dateOuverte)
+ .sort((a, b) => b.date_reunion.localeCompare(a.date_reunion));
const precedent = precedents[0] || null;
setPvPrecedent(precedent);
setPvCorrection(precedent?.contenu || '');
- }, [dateSelection, reunions, pvs]);
+ setContenu(pvJour?.contenu || textePvAuto(precedent, propositions, resolutionsJour, postesVacants));
+ }, [ouverteId, pvs.length, propositions.length, resolutions.length, postesVacants.length]);
- const reunion = reunions.find((r) => r.id === reunionId);
- const pvJour = pvs.find((p) => p.date_reunion?.slice(0, 10) === dateSelection);
- const resolutionsJour = resolutions.filter((r) => (r.date_vote || r.cree_le?.slice(0, 10)) === dateSelection);
- const nbPresents = Object.values(presencesMap).filter(Boolean).length;
-
- const toggle = (membreId) => setPresencesMap({ ...presencesMap, [membreId]: !presencesMap[membreId] });
- const imprimer = () => window.print();
-
- const creerRapport = async () => {
- const res = await api.post('/presences/reunion', {
- date_reunion: dateHeureReunionIso(dateReunion, groupe),
- note: note.trim() || null,
- });
- setReunionId(res.id);
- setReunions([res, ...reunions]);
- setDateSelection(res.date_reunion.slice(0, 10));
- return res.id;
+ const ouvrir = (id) => {
+ setOuverteId(ouverteId === id ? '' : id);
+ setAction('');
};
- const enregistrer = async () => {
+ const toggle = (membreId) => setPresencesMap({ ...presencesMap, [membreId]: !presencesMap[membreId] });
+ const telechargerRapport = async (assemblee) => {
+ setLoading(true);
+ setError('');
+ try {
+ const token = api.getToken();
+ const res = await fetch(`/api/presences/reunions/${assemblee.id}/rapport.pdf`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error('Erreur de génération du rapport PDF');
+ const blob = await res.blob();
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `rapport_assemblee_${assemblee.date_reunion?.slice(0, 10)}.pdf`;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ } catch (e) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const creerRapport = async () => {
+ setError('');
+ setMsg('');
+ setLoading(true);
+ try {
+ const res = await api.post('/presences/reunion', {
+ date_reunion: dateHeureReunionIso(dateReunion, groupe),
+ note: note.trim() || null,
+ });
+ setMsg('Assemblée créée');
+ setPlanifier(false);
+ setNote('');
+ await charger();
+ setOuverteId(res.id);
+ } catch (e) {
+ setError(e.message);
+ }
+ setLoading(false);
+ };
+
+ const enregistrer = async () => {
+ if (!reunionOuverte) return;
setError('');
setMsg('');
setLoading(true);
try {
- const id = reunionId || await creerRapport();
const presences = Object.entries(presencesMap).map(([membre_id, present]) => ({ membre_id, present }));
- await api.post('/presences/', { reunion_id: id, presences });
+ await api.post('/presences/', { reunion_id: reunionOuverte.id, presences });
if (contenu.trim()) {
await api.post('/pv/', {
- reunion_id: id,
+ reunion_id: reunionOuverte.id,
contenu: contenu.trim(),
decisions: null,
});
}
setMsg(contenu.trim() ? 'Présences et PV enregistrés' : 'Présences enregistrées');
await charger();
+ } catch (e) {
+ setError(e.message);
+ }
+ setLoading(false);
+ };
+
+ const enregistrerDon = async () => {
+ if (!rapportTresorierAdopte) { setError("Le rapport du trĂ©sorier doit ĂȘtre adoptĂ© avant de dĂ©cider des contributions"); return; }
+ if (!don.montant || parseFloat(don.montant) <= 0) { setError('Montant invalide'); return; }
+ if (!don.proposeur_id || !don.secondeur_id) { setError('Proposeur et secondeur requis'); return; }
+ if (don.proposeur_id === don.secondeur_id) { setError('Le proposeur et le secondeur doivent ĂȘtre diffĂ©rents'); return; }
+ setLoading(true); setError('');
+ try {
+ await api.post('/contributions/', {
+ date_envoi: dateOuverte,
+ destination: don.destination,
+ montant: parseFloat(don.montant),
+ methode: don.methode,
+ proposeur_id: don.proposeur_id,
+ secondeur_id: don.secondeur_id,
+ });
+ setMsg('Don engagé');
+ setDon({ ...don, montant: '' });
+ await charger();
} catch (e) { setError(e.message); }
setLoading(false);
};
+ const adopterRapportTresorier = async () => {
+ if (!adoptionTresorier.proposeur_id || !adoptionTresorier.secondeur_id) { setError('Proposeur et secondeur requis'); return; }
+ setLoading(true); setError('');
+ try {
+ await api.post(`/rapports/etat-resultats/${periodeTresorier.annee}/${periodeTresorier.mois}/adopter`, adoptionTresorier);
+ setMsg('Rapport du trésorier adopté');
+ await charger();
+ } catch (e) { setError(e.message); }
+ setLoading(false);
+ };
+
+ const genererInvitation = async (assemblee) => {
+ setError('');
+ try {
+ const inv = await api.post(`/presences/reunions/${assemblee.id}/invitation`);
+ const url = `${window.location.origin}${inv.path}`;
+ setInvitationTexte([
+ `Invitation à l'assemblée d'affaires du ${labelDateReunion(assemblee.date_reunion.slice(0, 10))}`,
+ '',
+ "Vous pouvez consulter l'ordre du jour en lecture seule ici :",
+ url,
+ '',
+ `Lien valide jusqu'au ${new Date(inv.expire_le).toLocaleString('fr-CA')}.`,
+ ].join('\n'));
+ setAction('Invitation');
+ } catch (e) { setError(e.message); }
+ };
+
+ const soumettreCandidature = async (posteId) => {
+ setError('');
+ try {
+ if (posteMode === 'proposer') {
+ await api.post(`/postes/candidatures/proposer?poste_id=${posteId}&membre_id=${membreProposeId}&motivation=${encodeURIComponent(motivation.trim())}`);
+ setMsg('Membre proposé');
+ } else {
+ await api.post(`/postes/candidatures?poste_id=${posteId}&motivation=${encodeURIComponent(motivation.trim())}`);
+ setMsg('Candidature soumise');
+ }
+ setPosteMode('');
+ setMotivation('');
+ await charger();
+ } catch (e) { setError(e.message); }
+ };
+
+ const soumettreProposition = async () => {
+ if (!nouvelleProp.sujet.trim()) { setError("L'objet est obligatoire"); return; }
+ setError('');
+ try {
+ await api.post('/propositions/', {
+ sujet: nouvelleProp.sujet.trim(),
+ description: nouvelleProp.description.trim() || null,
+ });
+ setNouvelleProp({ sujet: '', description: '' });
+ setMsg('Proposition soumise');
+ await charger();
+ } catch (e) { setError(e.message); }
+ };
+
+ const seconder = async (id, membreId = '') => {
+ setError('');
+ try {
+ const query = membreId ? `?membre_id=${membreId}` : '';
+ await api.post(`/propositions/${id}/seconder${query}`);
+ setMsg('Proposition secondée');
+ await charger();
+ } catch (e) { setError(e.message); }
+ };
+
+ const voter = async (id, statut) => {
+ const form = resolutionForm[id] || {};
+ const etantDonneQue = (form.etant_donne_que || '').trim();
+ const groupeADecideDe = (form.groupe_a_decide_de || '').trim();
+ if (!etantDonneQue || (statut === 'adoptee' && !groupeADecideDe)) {
+ setError(statut === 'adoptee' ? 'Les deux sections de dĂ©cision sont obligatoires' : 'La section "ĂTANT DONNĂ QUE" est obligatoire');
+ return;
+ }
+ setError('');
+ try {
+ await api.patch(`/propositions/${id}/voter`, {
+ statut,
+ etant_donne_que: etantDonneQue,
+ groupe_a_decide_de: groupeADecideDe || null,
+ });
+ setResolutionForm({ ...resolutionForm, [id]: {} });
+ setMsg(statut === 'adoptee' ? 'Décision créée' : 'Proposition rejetée');
+ await charger();
+ } catch (e) { setError(e.message); }
+ };
+
const adopterPvPrecedent = async () => {
if (!pvPrecedent) return;
if (!adoption.proposeur_id || !adoption.secondeur_id) {
@@ -124,11 +317,232 @@ export default function AssembleeAffaires() {
});
setMsg('PV précédent adopté');
await charger();
- } catch (e) { setError(e.message); }
+ } catch (e) {
+ setError(e.message);
+ }
};
- const textePvAuto = [
- 'Ouverture de lâassemblĂ©e.',
+ return (
+
+
+
+ {msg &&
{msg}
}
+ {error &&
{error}
}
+
+ {estExecutif && (
+
+ )}
+
+ {planifier && estExecutif && (
+
+
+ setNote(e.target.value)} placeholder="Note optionnelle" className={INPUT} />
+
+
+ )}
+
+
+ {reunions.length === 0 && (
+
+
Aucune assemblée planifiée.
+ {estExecutif &&
Utilisez le bouton en haut de la page pour créer la premiÚre assemblée.
}
+
+ )}
+ {reunions.map((r) => {
+ const date = r.date_reunion?.slice(0, 10);
+ const ouvert = ouverteId === r.id;
+ const pv = pvs.find((p) => p.date_reunion?.slice(0, 10) === date);
+ return (
+
+
+
+
+
+
+ {ouvert && (
+
+
+
+ {pvPrecedent && }
+ {estExecutif && }
+ {estExecutif && }
+
+
+
+
+
+ {action === 'PV précédent' && pvPrecedent && (
+
+ {new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}
+ {estExecutif && !pvPrecedent.approuve ? (
+
+ ) : (
+ {pvPrecedent.contenu}
+ )}
+
+ )}
+
+ {action === 'Invitation' && (
+
+
+
+
+ )}
+
+ {action === 'Présences' && estExecutif && (
+
+ {membres.map((m) => (
+
+ ))}
+
+
+ )}
+
+ {action === 'Trésorier' && estExecutif && (
+
+ {rapportTresorierAdopte ? (
+ Rapport adoptĂ©. Les contributions peuvent ĂȘtre dĂ©cidĂ©es.
+ ) : (
+ <>
+ Ce rapport doit ĂȘtre adoptĂ© avant de dĂ©cider du montant des contributions.
+
+ setAdoptionTresorier({ ...adoptionTresorier, proposeur_id: v })} membres={membres} />
+ setAdoptionTresorier({ ...adoptionTresorier, secondeur_id: v })} membres={membres} />
+
+
+
+ >
+ )}
+
+ )}
+
+ {action === 'Donner' && (
+
+ {!rapportTresorierAdopte && Adoptez d'abord le rapport du trésorier.
}
+ {soldes && Disponible : {soldes.disponible.toFixed(2)} $
}
+
+
+ setDon({ ...don, montant: e.target.value })} className={INPUT} />
+
+
+
+ setDon({ ...don, proposeur_id: v })} membres={membres} />
+ setDon({ ...don, secondeur_id: v })} membres={membres} />
+
+
+
+ )}
+
+ {action === 'Postes' && (
+
+ {postesVacants.length === 0 ? Aucun poste vacant.
: postesVacants.map((p) => {
+ const ouvertPoste = posteOuvert === p.id;
+ return (
+
+
+ {ouvertPoste && (
+
+ {p.candidats?.length ?
Candidats : {p.candidats.map((c) => c.prenom).join(', ')}
:
Aucun candidat.
}
+ {posteMode === 'proposer' &&
}
+ {posteMode &&
+ )}
+
+ );
+ })}
+
+ )}
+
+ {action === 'Décisions' && (
+
+ setNouvelleProp({ ...nouvelleProp, sujet: e.target.value })} className={INPUT} placeholder="Nouvelle proposition" />
+
+
+
+ {propositions.filter((p) => ['proposee', 'secondee', 'rejetee'].includes(p.statut)).map((p) => (
+
setResolutionForm({ ...resolutionForm, [p.id]: { ...(resolutionForm[p.id] || {}), [champ]: valeur } })} seconderPour={seconderPour[p.id] || ''} setSeconderPour={(v) => setSeconderPour({ ...seconderPour, [p.id]: v })} seconder={seconder} voter={voter} />
+ ))}
+ {propositions.filter((p) => ['proposee', 'secondee', 'rejetee'].includes(p.statut)).length === 0 && Aucune proposition Ă traiter.
}
+
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
+
+function RapportAssemblee({ date, reunion, pvJour, pvPrecedent, propositions, resolutionsJour, postesVacants }) {
+ const contenuAuto = textePvAuto(pvPrecedent, propositions, resolutionsJour, postesVacants);
+ return (
+
+
Rapport d'assemblée d'affaires
+
{labelDateReunion(date)}
+
+ {reunion?.presences?.length ? reunion.presences.filter((p) => p.present).map((p) => {p.membre_prenom}) : Présences à cocher sur papier}
+
+
+ {propositions.length ? propositions.map((p) => {p.sujet} ({p.proposeur_prenom} / {p.secondeur_prenom || 'à confirmer'})) : Aucune proposition secondée non résolue}
+
+
+ {resolutionsJour.length ? resolutionsJour.map((r) => {r.numero_resolution} - {r.sujet}) : Aucune décision enregistrée pour cette date}
+
+
+ {postesVacants.length ? postesVacants.map((p) => {p.nom}) : Aucun poste vacant}
+
+
+
ProcĂšs-verbal
+
{pvJour?.contenu || contenuAuto}
+
+
+ );
+}
+
+function textePvAuto(pvPrecedent, propositions, resolutionsJour, postesVacants) {
+ return [
+ "Ouverture de l'assemblée.",
'',
pvPrecedent ? `Lecture du procÚs-verbal précédent (${new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}).` : 'Lecture du procÚs-verbal précédent.',
'',
@@ -141,123 +555,70 @@ export default function AssembleeAffaires() {
'Postes disponibles:',
...postesVacants.map((p) => `- ${p.nom}`),
].join('\n');
+}
+function moisPrecedent(dateStr) {
+ const d = new Date(`${dateStr}T12:00:00`);
+ d.setMonth(d.getMonth() - 1);
+ const annee = d.getFullYear();
+ const mois = d.getMonth() + 1;
+ const noms = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
+ return { annee, mois, libelle: `${noms[mois]} ${annee}` };
+}
+
+function DecisionItem({ p, membres, estExecutif, form, setForm, seconderPour, setSeconderPour, seconder, voter }) {
+ const [label, cls] = STATUTS[p.statut] || STATUTS.proposee;
+ const motif = (form.etant_donne_que || '').trim();
+ const decision = (form.groupe_a_decide_de || '').trim();
return (
-
-
-
- {msg &&
{msg}
}
- {error &&
{error}
}
+
+
+
+
{p.sujet}
+ {p.description &&
{p.description}
}
+
Proposée par {p.proposeur_prenom}{p.secondeur_prenom ? ` · Secondée par ${p.secondeur_prenom}` : ''}
+
+
{label}
+
-
-
-
-
+ {p.statut === 'proposee' && (
+
+ )}
- {showPvPrecedent && pvPrecedent && (
-
- {new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}
- {estExecutif && !pvPrecedent.approuve && (
-
- )}
- {(!estExecutif || pvPrecedent.approuve) && (
- {pvPrecedent.contenu}
- )}
-
- )}
-
-
-
Rapport dâassemblĂ©e dâaffaires
-
{dateSelection}
-
- {reunion?.presences?.length ? reunion.presences.filter((p) => p.present).map((p) => {p.membre_prenom}) : Présences à cocher sur papier}
-
-
- {propositions.length ? propositions.map((p) => {p.sujet} ({p.proposeur_prenom} / {p.secondeur_prenom || 'à confirmer'})) : Aucune proposition secondée non résolue}
-
-
- {resolutionsJour.length ? resolutionsJour.map((r) => {r.numero_resolution} â {r.sujet}) : Aucune dĂ©cision enregistrĂ©e pour cette date}
-
-
- {postesVacants.length ? postesVacants.map((p) => {p.nom}) : Aucun poste vacant}
-
-
-
ProcĂšs-verbal
-
{pvJour?.contenu || textePvAuto}
+ {p.statut === 'secondee' && estExecutif && (
+
-
- {estExecutif && (
- <>
-
- {reunions.length > 0 && (
-
-
-
- )}
- {!reunionId && (
- <>
-
- setNote(e.target.value)} placeholder="Note optionnelle" className={INPUT} />
-
- >
- )}
-
-
-
- {membres.map((m) => (
-
- ))}
-
-
-
-
-
-
- >
- )}
-
+ )}
);
}
+function boutonAction(actif) {
+ return `p-3 rounded-xl text-sm font-bold ${actif ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`;
+}
+
+function heure(dateIso) {
+ return dateIso ? new Date(dateIso).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) : '--:--';
+}
+
function Card({ titre, children }) {
return {titre}
{children};
}
@@ -280,3 +641,12 @@ function SelectDate({ value, onChange, dates }) {
);
}
+
+function MembreSelect({ label, value, onChange, membres }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/AssembleeInvite.jsx b/frontend/src/pages/AssembleeInvite.jsx
new file mode 100644
index 0000000..29593d2
--- /dev/null
+++ b/frontend/src/pages/AssembleeInvite.jsx
@@ -0,0 +1,56 @@
+import React, { useEffect, useState } from 'react';
+import { labelDateReunion } from '../utils/datesReunion';
+
+export default function AssembleeInvite() {
+ const code = window.location.pathname.split('/').filter(Boolean).pop();
+ const [data, setData] = useState(null);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ fetch(`/api/presences/invite/${code}`)
+ .then(async (res) => {
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body.detail || 'Invitation invalide');
+ }
+ return res.json();
+ })
+ .then(setData)
+ .catch((e) => setError(e.message));
+ }, [code]);
+
+ if (error) {
+ return ;
+ }
+ if (!data) return Chargement...
;
+
+ const date = data.assemblee.date_reunion.slice(0, 10);
+ return (
+
+
+
{data.groupe.nom}
+
Assemblée d'affaires
+
{labelDateReunion(date)}
+
+
+
+ {data.propositions.length ? data.propositions.map((p) => {p.sujet} ({p.statut})) : Aucune proposition Ă traiter.}
+
+
+ {data.decisions_du_jour.length ? data.decisions_du_jour.map((d, i) => {d.numero_resolution || ''} {d.decision}) : Aucune décision enregistrée.}
+
+
+ {data.postes_disponibles.length ? data.postes_disponibles.map((p) => {p.nom}) : Aucun poste vacant.}
+
+
+
ProcĂšs-verbal
+
{data.pv?.contenu || "Le procÚs-verbal sera renseigné pendant l'assemblée."}
+
+
+
+ );
+}
+
+function Section({ titre, children }) {
+ return ;
+}
diff --git a/frontend/src/pages/GererMembres.jsx b/frontend/src/pages/GererMembres.jsx
index 689b71b..b65a093 100644
--- a/frontend/src/pages/GererMembres.jsx
+++ b/frontend/src/pages/GererMembres.jsx
@@ -14,6 +14,16 @@ export default function GererMembres() {
const [expiration, setExpiration] = useState(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [copied, setCopied] = useState(false);
+ const [showCreate, setShowCreate] = useState(false);
+ const [createLoading, setCreateLoading] = useState(false);
+ const [createForm, setCreateForm] = useState({
+ prenom: '',
+ nom: '',
+ telephone: '',
+ courriel: '',
+ pin: '',
+ date_abstinence: '',
+ });
const charger = async () => {
setLoading(true);
@@ -76,6 +86,26 @@ export default function GererMembres() {
} catch (e) { alert(e.message); }
};
+ const creerCompte = async () => {
+ setCreateLoading(true);
+ try {
+ const payload = {
+ ...createForm,
+ nom: createForm.nom.trim() || null,
+ date_abstinence: createForm.date_abstinence || null,
+ };
+ const res = await api.post('/membres/', payload);
+ setMsg(`Compte créé pour ${res.prenom}`);
+ setShowCreate(false);
+ setCreateForm({ prenom: '', nom: '', telephone: '', courriel: '', pin: '', date_abstinence: '' });
+ await charger();
+ setTimeout(() => setMsg(''), 3000);
+ } catch (e) {
+ alert(e.message);
+ }
+ setCreateLoading(false);
+ };
+
// Vue détail d'un membre
if (detail) {
return (
@@ -197,6 +227,32 @@ export default function GererMembres() {
)}
+
+
+ {showCreate && (
+
+ )}
+
+
{loading ? (
Chargement...
) : (
@@ -215,3 +271,7 @@ export default function GererMembres() {
);
}
+
+function Champ({ label, children }) {
+ return
;
+}
diff --git a/frontend/src/pages/GererPostes.jsx b/frontend/src/pages/GererPostes.jsx
index 6404200..9a3b548 100644
--- a/frontend/src/pages/GererPostes.jsx
+++ b/frontend/src/pages/GererPostes.jsx
@@ -9,21 +9,21 @@ const CATEGORIES = [
];
const MODULES = [
- ['S0', 'Rencontre hebdomadaire'], ['M0', "Assemblée d'affaires"],
- ['S1', 'Collecte 7e'], ['C5', 'Gouvernance'],
+ ['S0', 'Rencontres'], ['M0', "Assemblée d'affaires"],
+ ['C5', 'Gouvernance'],
['S1T', 'Trésorerie'], ['M4', 'Donner au suivant'],
['M1', 'Rédiger PV'], ['M2', 'Présences'], ['M3', 'Rapport RSG'],
['S2', 'Remise jeton'], ['M6', 'Commander jetons'],
- ['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'], ['S4', 'Rapport réunion'],
+ ['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'],
['G2', 'Gestion membres'], ['G9', 'ParamĂštres'],
['G11', 'Gestion mandats'], ['G12', 'Gestion postes'],
];
const GROUPES_MODULES = [
- { titre: 'Réunion', codes: ['S0', 'S1', 'S2', 'S3', 'S4'] },
+ { titre: 'Réunion', codes: ['S0'] },
{ titre: 'Affaires', codes: ['M0', 'M1', 'M2', 'M3', 'C5'] },
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
- { titre: 'Inventaire', codes: ['C4', 'M5', 'M6'] },
+ { titre: 'Inventaire', codes: ['M5', 'M6'] },
{ titre: 'Administration', codes: ['G2', 'G9', 'G11', 'G12', 'G13'] },
];
diff --git a/frontend/src/pages/Rapports.jsx b/frontend/src/pages/Rapports.jsx
index e8a9e09..31788bf 100644
--- a/frontend/src/pages/Rapports.jsx
+++ b/frontend/src/pages/Rapports.jsx
@@ -2,23 +2,13 @@ import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
-const MOIS_FR = [
- '', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
- 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre',
-];
-
export default function Rapports() {
- const [disponibles, setDisponibles] = useState([]);
const [anneesResolutions, setAnneesResolutions] = useState([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(null);
useEffect(() => {
- Promise.all([
- api.get('/rapports/disponibles'),
- api.get('/rapports/resolutions/annees'),
- ]).then(([dispo, annees]) => {
- setDisponibles(dispo);
+ api.get('/rapports/resolutions/annees').then((annees) => {
setAnneesResolutions(annees);
setLoading(false);
}).catch(() => setLoading(false));
@@ -44,8 +34,7 @@ export default function Rapports() {
setGenerating(null);
};
- const rapportsMensuels = disponibles.filter((m) => m.reunions);
- const vide = rapportsMensuels.length === 0 && anneesResolutions.length === 0;
+ const vide = anneesResolutions.length === 0;
return (
@@ -62,31 +51,6 @@ export default function Rapports() {
) : (
<>
- {/* Rapports mensuels */}
- {rapportsMensuels.map((m) => (
-
-
- {MOIS_FR[m.mois]} {m.annee}
-
-
- {m.reunions && (
-
- )}
-
-
- ))}
-
{/* Registre des résolutions */}
{anneesResolutions.length > 0 && (
diff --git a/frontend/src/pages/ReunionHebdomadaire.jsx b/frontend/src/pages/ReunionHebdomadaire.jsx
index 6a15f42..5f420f6 100644
--- a/frontend/src/pages/ReunionHebdomadaire.jsx
+++ b/frontend/src/pages/ReunionHebdomadaire.jsx
@@ -7,190 +7,368 @@ import { dateHeureReunionIso, datesReunion, dateLocale, labelDateReunion } from
const TYPES_JETONS = [['desir', 'Désir'], ['24h', '24 heures'], ['1mois', '1 mois'], ['2mois', '2 mois'], ['3mois', '3 mois'], ['6mois', '6 mois'], ['9mois', '9 mois'], ['1an', '1 an'], ['multiple', 'Multiple']];
const INPUT = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue';
-export default function ReunionHebdomadaire() {
+export default function ReunionHebdomadaire({ contexte = false }) {
const { postes, groupe } = useStore();
- const estExecutif = postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(iÚre)', 'Secrétaire'].includes(p));
- const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
+ const estExecutif = postes.some((p) => ['Sysadmin', 'RSG', 'RSG Substitut(e)', 'Trésorier(iÚre)', 'Secrétaire'].includes(p));
+ const choixDatesChrono = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
+ const choixDates = [...choixDatesChrono].reverse();
+ const prochaineDate = choixDatesChrono.find((d) => d >= dateLocale()) || choixDatesChrono[choixDatesChrono.length - 1] || dateLocale();
const [membres, setMembres] = useState([]);
+ const [animateurDefautId, setAnimateurDefautId] = useState('');
const [catalogue, setCatalogue] = useState([]);
- const [reunions, setReunions] = useState([]);
- const [dateSelection, setDateSelection] = useState(dateLocale());
- const [dateReunion, setDateReunion] = useState(dateLocale());
- const [theme, setTheme] = useState('');
- const [annonces, setAnnonces] = useState('');
- const [note, setNote] = useState('');
- const [collecteMontant, setCollecteMontant] = useState('');
- const [detenteurId, setDetenteurId] = useState('');
- const [jetonType, setJetonType] = useState('');
- const [jetonMembreId, setJetonMembreId] = useState('');
- const [jetonAnnees, setJetonAnnees] = useState('');
- const [litteratureId, setLitteratureId] = useState('');
- const [quantite, setQuantite] = useState('1');
- const [venteMontant, setVenteMontant] = useState('');
+ const [rencontres, setRencontres] = useState([]);
+ const [ouverteId, setOuverteId] = useState(null);
+ const [action, setAction] = useState('');
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
- const [rapportVisible, setRapportVisible] = useState(false);
+ const [planifier, setPlanifier] = useState(false);
+ const [plan, setPlan] = useState({ date: prochaineDate, animateur_id: '', theme: '', annonces: '', note: '' });
+ const [collecte, setCollecte] = useState({ montant: '', detenteur_id: '' });
+ const [vente, setVente] = useState({ type: 'litterature', litterature_id: '', type_jeton: 'desir', quantite: '1', montant: '', vendu_par_id: '' });
+ const [jeton, setJeton] = useState({ type_jeton: '', membre_id: '', annees: '' });
+ const [rapport, setRapport] = useState({ animateur_id: '', theme: '', annonces: '', note: '' });
const charger = async () => {
try {
const [m, l, r] = await Promise.all([
api.get('/membres/actifs'),
api.get('/litterature/'),
- api.get('/reunions/?limite=80'),
+ api.get('/reunions/rencontres/details?limite=80'),
]);
+ let animateurId = '';
+ if (estExecutif) {
+ try {
+ const affectations = await api.get('/postes/affectations/groupe');
+ const affectationAnimateur = affectations.find((a) => (a.poste_nom || '').toLowerCase().includes('animateur'));
+ animateurId = affectationAnimateur?.membre_id || '';
+ } catch {
+ animateurId = '';
+ }
+ }
setMembres(m);
setCatalogue(l);
- setReunions(r.filter((x) => x.type === 'reguliere'));
- if (m.length > 0 && !detenteurId) setDetenteurId(m[0].id);
+ setRencontres(r);
+ setAnimateurDefautId(animateurId);
+ if (m.length > 0) {
+ const fallbackAnimateur = animateurId || m[0].id;
+ setPlan((f) => ({ ...f, animateur_id: f.animateur_id || fallbackAnimateur }));
+ setRapport((f) => ({ ...f, animateur_id: f.animateur_id || fallbackAnimateur }));
+ setCollecte((f) => ({ ...f, detenteur_id: f.detenteur_id || m[0].id }));
+ setVente((f) => ({ ...f, vendu_par_id: f.vendu_par_id || m[0].id }));
+ }
} catch (e) { setError(e.message); }
};
useEffect(() => { charger(); }, []);
- useEffect(() => {
- if (choixDates.length > 0) {
- if (!choixDates.includes(dateSelection)) setDateSelection(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
- if (!choixDates.includes(dateReunion)) setDateReunion(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
- }
- }, [groupe?.jour_reunion]);
- const rapport = reunions.find((r) => r.date_reunion?.slice(0, 10) === dateSelection);
- const dateSimple = () => dateReunion;
+ const flash = (texte) => { setMsg(texte); setTimeout(() => setMsg(''), 2500); };
+ const dateRencontre = (r) => r.date_reunion?.slice(0, 10);
+ const rencontreOuverte = rencontres.find((r) => r.id === ouverteId);
+
+ const ouvrir = (id) => {
+ const prochaine = ouverteId === id ? null : id;
+ setOuverteId(prochaine);
+ setAction('');
+ const r = rencontres.find((x) => x.id === id);
+ const animateur = membres.find((m) => m.prenom === r?.animateur_prenom);
+ setRapport({
+ animateur_id: animateur?.id || animateurDefautId || membres[0]?.id || '',
+ theme: r?.theme || '',
+ annonces: (r?.annonces || []).join('\n'),
+ note: r?.note || '',
+ });
+ };
const choisirLitterature = (id) => {
- setLitteratureId(id);
const item = catalogue.find((c) => c.id === id);
- if (item?.prix_coutant) setVenteMontant((item.prix_coutant * parseInt(quantite || '1')).toFixed(2));
+ const quantite = parseInt(vente.quantite || '1');
+ setVente({
+ ...vente,
+ litterature_id: id,
+ montant: item?.prix_coutant ? (item.prix_coutant * quantite).toFixed(2) : vente.montant,
+ });
};
const changerQuantite = (valeur) => {
- setQuantite(valeur);
- const item = catalogue.find((c) => c.id === litteratureId);
- if (item?.prix_coutant && valeur) setVenteMontant((item.prix_coutant * parseInt(valeur)).toFixed(2));
+ const item = catalogue.find((c) => c.id === vente.litterature_id);
+ const qte = parseInt(valeur || '0');
+ setVente({
+ ...vente,
+ quantite: valeur,
+ montant: vente.type === 'litterature' && item?.prix_coutant && qte > 0 ? (item.prix_coutant * qte).toFixed(2) : vente.montant,
+ });
};
- const imprimer = () => window.print();
-
- const enregistrer = async () => {
- setError('');
- setMsg('');
+ const creerRencontre = async () => {
setLoading(true);
- const faits = [];
+ setError('');
try {
- if (theme.trim() || annonces.trim() || note.trim()) {
- const annoncesList = annonces.split('\n').map((a) => a.trim()).filter(Boolean);
- await api.post('/reunions/', {
- type: 'reguliere',
- date_reunion: dateHeureReunionIso(dateReunion, groupe),
- theme: theme.trim() || 'Rencontre hebdomadaire',
- annonces: annoncesList.length ? annoncesList : null,
- note: note.trim() || null,
- });
- faits.push('rapport');
- }
- if (collecteMontant) {
- await api.post('/collectes/', { date_collecte: dateSimple(), montant: parseFloat(collecteMontant), detenteur_id: detenteurId });
- faits.push('collecte');
- }
- if (jetonType) {
- const body = { type_jeton: jetonType, date_remise: dateSimple() };
- if (jetonMembreId) body.membre_id = jetonMembreId;
- if (jetonType === 'multiple') body.annees = parseInt(jetonAnnees);
- await api.post('/jetons/', body);
- faits.push('jeton');
- }
- if (litteratureId) {
- await api.post('/ventes-litterature/', { litterature_id: litteratureId, quantite: parseInt(quantite), montant: parseFloat(venteMontant), date_vente: dateSimple() });
- faits.push('littérature');
- }
- if (faits.length === 0) setError('Remplissez au moins une section');
- else {
- setMsg(`Enregistré : ${faits.join(', ')}`);
- setDateSelection(dateSimple());
- await charger();
- }
+ await api.post('/reunions/', {
+ type: 'reguliere',
+ date_reunion: dateHeureReunionIso(plan.date, groupe),
+ animateur_id: plan.animateur_id || animateurDefautId || null,
+ theme: plan.theme.trim() || 'Rencontre hebdomadaire',
+ annonces: plan.annonces.split('\n').map((a) => a.trim()).filter(Boolean) || null,
+ note: plan.note.trim() || null,
+ });
+ setPlanifier(false);
+ setPlan({ date: prochaineDate, animateur_id: animateurDefautId, theme: '', annonces: '', note: '' });
+ flash('Rencontre planifiée');
+ await charger();
} catch (e) { setError(e.message); }
setLoading(false);
};
+ const enregistrerCollecte = async () => {
+ if (!rencontreOuverte || !collecte.montant || !collecte.detenteur_id) { setError('Montant et détenteur requis'); return; }
+ setLoading(true);
+ setError('');
+ try {
+ await api.post('/collectes/', {
+ reunion_id: rencontreOuverte.id,
+ date_collecte: dateRencontre(rencontreOuverte),
+ montant: parseFloat(collecte.montant),
+ detenteur_id: collecte.detenteur_id,
+ });
+ setCollecte({ ...collecte, montant: '' });
+ flash('Collecte saisie');
+ await charger();
+ } catch (e) { setError(e.message); }
+ setLoading(false);
+ };
+
+ const enregistrerVente = async () => {
+ if (!rencontreOuverte || !vente.montant || !vente.vendu_par_id) { setError('Montant et vendeur requis'); return; }
+ const quantite = parseInt(vente.quantite || '0');
+ if (quantite <= 0) { setError('Quantité invalide'); return; }
+ setLoading(true);
+ setError('');
+ try {
+ if (vente.type === 'litterature') {
+ if (!vente.litterature_id) { setError('Sélectionnez un titre'); setLoading(false); return; }
+ await api.post('/ventes-litterature/', {
+ reunion_id: rencontreOuverte.id,
+ litterature_id: vente.litterature_id,
+ quantite,
+ montant: parseFloat(vente.montant),
+ date_vente: dateRencontre(rencontreOuverte),
+ vendu_par_id: vente.vendu_par_id,
+ });
+ } else {
+ await api.post('/jetons/ventes', {
+ reunion_id: rencontreOuverte.id,
+ type_jeton: vente.type_jeton,
+ quantite,
+ montant: parseFloat(vente.montant),
+ date_vente: dateRencontre(rencontreOuverte),
+ vendu_par_id: vente.vendu_par_id,
+ });
+ }
+ setVente({ ...vente, litterature_id: '', quantite: '1', montant: '' });
+ flash('Vente saisie');
+ await charger();
+ } catch (e) { setError(e.message); }
+ setLoading(false);
+ };
+
+ const enregistrerJeton = async () => {
+ if (!rencontreOuverte || !jeton.type_jeton) { setError('Sélectionnez un type de jeton'); return; }
+ if (jeton.type_jeton !== 'desir' && !jeton.membre_id) { setError('Sélectionnez un membre'); return; }
+ if (jeton.type_jeton === 'multiple' && (!jeton.annees || parseInt(jeton.annees) < 2)) { setError("Nombre d'années requis"); return; }
+ setLoading(true);
+ setError('');
+ try {
+ const body = {
+ reunion_id: rencontreOuverte.id,
+ type_jeton: jeton.type_jeton,
+ date_remise: dateRencontre(rencontreOuverte),
+ };
+ if (jeton.membre_id) body.membre_id = jeton.membre_id;
+ if (jeton.type_jeton === 'multiple') body.annees = parseInt(jeton.annees);
+ await api.post('/jetons/', body);
+ setJeton({ type_jeton: '', membre_id: '', annees: '' });
+ flash('Jeton/gùteau comptabilisé');
+ await charger();
+ } catch (e) { setError(e.message); }
+ setLoading(false);
+ };
+
+ const enregistrerRapport = async () => {
+ if (!rencontreOuverte) return;
+ setLoading(true);
+ setError('');
+ try {
+ await api.patch(`/reunions/${rencontreOuverte.id}`, {
+ type: 'reguliere',
+ date_reunion: rencontreOuverte.date_reunion,
+ animateur_id: rapport.animateur_id || null,
+ theme: rapport.theme.trim() || 'Rencontre hebdomadaire',
+ annonces: rapport.annonces.split('\n').map((a) => a.trim()).filter(Boolean) || null,
+ note: rapport.note.trim() || null,
+ });
+ flash('Rapport enregistré');
+ await charger();
+ } catch (e) { setError(e.message); }
+ setLoading(false);
+ };
+
+ const telechargerRapport = async (rencontre) => {
+ setLoading(true);
+ setError('');
+ try {
+ const token = api.getToken();
+ const res = await fetch(`/api/reunions/${rencontre.id}/rapport.pdf`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error('Erreur de génération du rapport PDF');
+ const blob = await res.blob();
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `rapport_rencontre_${dateRencontre(rencontre)}.pdf`;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ } catch (e) {
+ setError(e.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const heure = (dateIso) => (dateIso ? new Date(dateIso).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) : 'â');
+
return (
-
+
{msg &&
{msg}
}
{error &&
{error}
}
-
-
-
-
-
- {rapportVisible && }
-
-
- {rapportVisible &&
-
Rapport de réunion hebdomadaire
-
{dateSelection}
- {rapport ? (
-
-
ThĂšme : {rapport.theme || '____________________________'}
-
-
Annonces :
- {(rapport.annonces || []).length > 0 ? (
-
{rapport.annonces.map((a, i) => - {a}
)}
- ) :
{"\n\n\n"}
}
-
-
Notes : {rapport.note || '____________________________________________'}
-
- ) : (
-
-
ThĂšme : ____________________________________________
-
Annonces :
-
-
Notes :
-
-
- )}
-
-
Collecte : __________ $
-
Détenteur : _______________
-
Jeton : __________________
-
Littérature : __________ $
-
-
}
-
{estExecutif && (
- <>
-
-
- setTheme(e.target.value)} placeholder="ThÚme abordé" className={INPUT} />
-
-
-
-
-
- setCollecteMontant(e.target.value)} placeholder="0.00" className={INPUT} />
-
-
-
-
- {jetonType === 'multiple' && setJetonAnnees(e.target.value)} className={INPUT} />}
-
-
- changerQuantite(e.target.value)} className={INPUT} />
- setVenteMontant(e.target.value)} placeholder="0.00" className={INPUT} />
-
-
-
- >
+
)}
+
+ {planifier && (
+
+ setPlan({ ...plan, date: v })} dates={choixDates} />
+ setPlan({ ...plan, animateur_id: v })} membres={membres} />
+ setPlan({ ...plan, theme: e.target.value })} className={INPUT} />
+
+
+
+
+ )}
+
+
+ {rencontres.map((r) => (
+
+
+
+
+
+
+ {ouverteId === r.id && (
+
+
+
+ {['Collecte', 'Ventes', 'Jetons/GĂąteaux'].map((nom) => (
+
+ ))}
+ {estExecutif && (
+
+ )}
+
+
+ {action === 'Collecte' && (
+
+ {r.collecte ? Collecte déjà saisie pour cette rencontre.
: (
+ <>
+ setCollecte({ ...collecte, montant: e.target.value })} className={INPUT} />
+ setCollecte({ ...collecte, detenteur_id: v })} membres={membres} />
+
+ >
+ )}
+
+ )}
+
+ {action === 'Ventes' && (
+
+
+ {['litterature', 'jeton'].map((type) => )}
+
+ {vente.type === 'litterature' ? (
+
+ ) : (
+
+ )}
+
+ changerQuantite(e.target.value)} className={INPUT} />
+ setVente({ ...vente, montant: e.target.value })} className={INPUT} />
+
+ setVente({ ...vente, vendu_par_id: v })} membres={membres} />
+
+
+ )}
+
+ {action === 'Jetons/GĂąteaux' && (
+
+
+ setJeton({ ...jeton, membre_id: v })} membres={membres} empty="Anonyme / non applicable" />
+ {jeton.type_jeton === 'multiple' && setJeton({ ...jeton, annees: e.target.value })} className={INPUT} />}
+
+
+ )}
+
+ {action === 'Modifier' && (
+
+ setRapport({ ...rapport, animateur_id: v })} membres={membres} />
+ setRapport({ ...rapport, theme: e.target.value })} className={INPUT} />
+
+
+
+
+ )}
+
+ )}
+
+ ))}
+
);
}
+function Resume({ rencontre }) {
+ return (
+
+
Animateur : {rencontre.animateur_prenom || 'â'}
+ {rencontre.collecte &&
Collecte : {rencontre.collecte.montant.toFixed(2)} $ ({rencontre.collecte.statut}) dĂ©tenue par {rencontre.collecte.detenteur_prenom || 'â'}
}
+ {rencontre.ventes_litterature.map((v) =>
Vente littĂ©rature : {v.titre} x{v.quantite}, {v.montant.toFixed(2)} $ par {v.vendu_par_prenom || 'â'}
)}
+ {rencontre.ventes_jetons.map((v) =>
Vente jeton : {v.type_jeton} x{v.quantite}, {v.montant.toFixed(2)} $ par {v.vendu_par_prenom || 'â'}
)}
+ {rencontre.jetons.map((j) =>
Jeton/gĂąteau : {j.membre_prenom || 'Anonyme'} â {j.type_jeton}{j.annees ? ` (${j.annees} ans)` : ''}
)}
+
+ );
+}
+
function Card({ titre, children }) {
return
{titre}
{children};
}
@@ -199,13 +377,11 @@ function Champ({ label, children }) {
return
;
}
-function SelectDate({ value, onChange, dates }) {
- if (!dates.length) {
- return
Configurez le jour de réunion dans les paramÚtres du groupe.
;
- }
- return (
-
- );
+function MembreSelect({ value, onChange, membres, empty = 'Sélectionner' }) {
+ return
;
+}
+
+function SelectDate({ value, onChange, dates }) {
+ if (!dates.length) return
Configurez le jour de réunion.
;
+ return
;
}