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