From 69309acd1341c4926db1b3ba34959f09ae6ed1cc Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Sun, 31 May 2026 09:02:02 -0400 Subject: [PATCH] =?UTF-8?q?Int=C3=A9grer=20rencontres=20et=20assembl=C3=A9?= =?UTF-8?q?es=20pilot=C3=A9es=20par=20tuiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refond les pages Rencontres et Assemblées autour de tuiles ouvrables avec actions locales, PDF serveur, ventes, collectes, présences, décisions, postes et contributions. Ajoute les invitations publiques d'assemblée en lecture seule avec lien expirant, l'adoption formelle du rapport du trésorier avec proposeur/secondeur, et bloque les contributions tant que ce rapport n'est pas adopté. Uniformise la navigation et les entêtes, retire les modules redondants des contextes, ajoute la création de comptes membres par exécutif et les données démo reliées aux nouveaux processus. Ce commit capture un lot fonctionnel important; une refactorisation ciblée du module Assemblées, puis Trésorerie/Gouvernance, est maintenant pertinente pour isoler les règles métier et faciliter les tests. --- backend/app/main.py | 5 + backend/app/models/invitation.py | 2 + backend/app/models/jeton.py | 1 + backend/app/models/vente_jeton.py | 25 + backend/app/models/vente_litterature.py | 1 + backend/app/routers/accueil.py | 14 +- backend/app/routers/collectes.py | 45 +- backend/app/routers/commandes_jetons.py | 15 +- backend/app/routers/jetons.py | 113 +++- backend/app/routers/membres.py | 44 +- backend/app/routers/postes.py | 1 + backend/app/routers/presences.py | 288 ++++++++- backend/app/routers/propositions.py | 4 +- backend/app/routers/rapports.py | 23 +- backend/app/routers/reunions.py | 328 +++++++++- backend/app/routers/ventes_litterature.py | 12 +- backend/app/schemas/phase1.py | 1 + backend/app/schemas/phase5.py | 10 + backend/app/schemas/phase6.py | 2 + backend/app/schemas/schemas.py | 9 + backend/app/services/admin_database.py | 24 +- frontend/src/App.jsx | 12 +- frontend/src/components/PageHeader.jsx | 37 +- frontend/src/pages/Accueil.jsx | 35 +- frontend/src/pages/AssembleeAffaires.jsx | 668 ++++++++++++++++----- frontend/src/pages/AssembleeInvite.jsx | 56 ++ frontend/src/pages/GererMembres.jsx | 60 ++ frontend/src/pages/GererPostes.jsx | 10 +- frontend/src/pages/Rapports.jsx | 40 +- frontend/src/pages/ReunionHebdomadaire.jsx | 468 ++++++++++----- 30 files changed, 1937 insertions(+), 416 deletions(-) create mode 100644 backend/app/models/vente_jeton.py create mode 100644 frontend/src/pages/AssembleeInvite.jsx 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""" +
    +
    {escape(groupe_nom)}
    +
    {escape(coords)}
    +
    +

    Rapport d'assemblée d'affaires

    +
    +
    Date
    {jour.isoformat()}
    +
    Heure
    {reunion.date_reunion.strftime('%H:%M')}
    +
    Présences
    {len(presences_rows)}
    +
    PV
    {'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}
    +
    +

    Présences

    +

    Réflexions en cours

    +

    Décisions prises

    +

    Postes disponibles

    +

    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"" + f"" + f"" + "
    Montant{float(collecte.montant):.2f} $
    Détenteur{escape(detenteur.prenom if detenteur else '—')}
    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 = ( + "" + + "".join(ventes_lit_rows) + "
    TitreQtéMontantVendu parStatut
    " + if ventes_lit_rows else "

    Aucune vente de littérature.

    " + ) + ventes_jetons_html = ( + "" + + "".join(ventes_jetons_rows) + "
    TypeQtéMontantVendu parStatut
    " + if ventes_jetons_rows else "

    Aucune vente de jetons.

    " + ) + jetons_html = ( + "" + "".join(jetons_rows) + "
    TypeMembreDétail
    " + if jetons_rows else "

    Aucun jeton/gâteau comptabilisé.

    " + ) + html = f""" +
    +
    {escape(groupe_nom)}
    +
    {escape(coords)}
    +
    +

    Rapport de rencontre

    +
    +
    Date
    {reunion.date_reunion.date().isoformat()}
    +
    Heure
    {reunion.date_reunion.strftime('%H:%M')}
    +
    Sujet
    {escape(reunion.theme or 'Rencontre hebdomadaire')}
    +
    Animé par
    {escape(animateur.prenom if animateur else '—')}
    +
    +

    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 ? ( +
    +