Cristalliser rapports de reunions et assemblees

This commit is contained in:
Daniel Allaire 2026-06-01 19:45:00 -04:00
parent 086bdbd548
commit 14fa8b4174
11 changed files with 567 additions and 257 deletions

View file

@ -21,6 +21,7 @@ from app.models.reunion import Reunion
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
from app.services.finance import encaisse, solde_banque, solde_reserve, transaction from app.services.finance import encaisse, solde_banque, solde_reserve, transaction
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
from app.services.rapports_adoptes import verifier_reunion_non_cristallisee
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"]) router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
@ -102,6 +103,9 @@ async def saisir_collecte(
raise HTTPException(status_code=404, detail="Détenteur introuvable ou inactif") raise HTTPException(status_code=404, detail="Détenteur introuvable ou inactif")
if req.reunion_id: if req.reunion_id:
await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable") await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable")
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, req.reunion_id, "rencontre", "Cette rencontre est clôturée"
)
result = await db.execute( result = await db.execute(
select(Collecte).where( select(Collecte).where(
@ -147,6 +151,10 @@ async def ajuster_collecte(
collecte = result.scalar_one_or_none() collecte = result.scalar_one_or_none()
if not collecte: if not collecte:
raise HTTPException(status_code=404, detail="Collecte introuvable") raise HTTPException(status_code=404, detail="Collecte introuvable")
if collecte.reunion_id:
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, collecte.reunion_id, "rencontre", "Cette rencontre est clôturée"
)
await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes") await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes")
if collecte.statut != "en_attente": if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation") raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation")

View file

@ -15,6 +15,7 @@ from app.models.poste import Poste
from app.models.transaction_comptable import TransactionComptable from app.models.transaction_comptable import TransactionComptable
from app.models.reunion import Reunion from app.models.reunion import Reunion
from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS, VenteJetonCreate from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS, VenteJetonCreate
from app.services.rapports_adoptes import verifier_reunion_non_cristallisee
router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"]) router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"])
@ -67,6 +68,9 @@ async def remettre_jeton(
membre_prenom = cible.prenom membre_prenom = cible.prenom
if req.reunion_id: if req.reunion_id:
await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable") await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable")
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, req.reunion_id, "rencontre", "Cette rencontre est clôturée"
)
jeton = Jeton( jeton = Jeton(
groupe_id=membre.groupe_id, groupe_id=membre.groupe_id,
@ -111,6 +115,9 @@ async def enregistrer_vente_jeton(
raise HTTPException(status_code=404, detail="Vendeur introuvable") raise HTTPException(status_code=404, detail="Vendeur introuvable")
if req.reunion_id: if req.reunion_id:
await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable") await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable")
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, req.reunion_id, "rencontre", "Cette rencontre est clôturée"
)
vente = VenteJeton( vente = VenteJeton(
groupe_id=membre.groupe_id, groupe_id=membre.groupe_id,

View file

@ -5,6 +5,7 @@ from html import escape
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract from sqlalchemy import select, desc, extract
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -22,13 +23,20 @@ from app.models.pv_reunion import PvReunion
from app.models.proposition import Proposition from app.models.proposition import Proposition
from app.models.poste import Poste from app.models.poste import Poste
from app.models.affectation import Affectation from app.models.affectation import Affectation
from app.models.rapport_adopte import RapportAdopte
from app.schemas.phase4 import ( from app.schemas.phase4 import (
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut, PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
) )
from app.services.rapports_adoptes import rapport_adopte_pour_reunion, verifier_reunion_non_cristallisee
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"]) router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
class RapportAssembleeAdoption(BaseModel):
proposeur_id: str
secondeur_id: str
def _date_civile_reunion(valeur): def _date_civile_reunion(valeur):
if getattr(valeur, "tzinfo", None): if getattr(valeur, "tzinfo", None):
return valeur.astimezone(ZoneInfo("America/Toronto")).date() return valeur.astimezone(ZoneInfo("America/Toronto")).date()
@ -112,6 +120,129 @@ async def _assemblee_publique(reunion: Reunion, db: AsyncSession):
} }
async def _html_rapport_assemblee(reunion: Reunion, membre, db: AsyncSession) -> tuple[str, datetime.date]:
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"<li>{escape(mbr.prenom if mbr else '?')}</li>")
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"<li>{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})</li>"
)
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"<li>{escape(r.numero_resolution or '')} {escape(r.sujet)}</li>".strip()
for r in resolutions
]
postes_rows = [f"<li>{escape(p.nom)}</li>" for p in await _postes_vacants(membre.groupe_id, db)]
mois_precedent = jour.month - 1
annee_precedente = jour.year
if mois_precedent == 0:
mois_precedent = 12
annee_precedente -= 1
rapport_tresorier = (await db.execute(
select(RapportAdopte).where(
RapportAdopte.groupe_id == membre.groupe_id,
RapportAdopte.type_rapport == "tresorerie",
RapportAdopte.annee == annee_precedente,
RapportAdopte.mois == mois_precedent,
).limit(1)
)).scalar_one_or_none()
tresorier_html = (
f"<p>Rapport adopté: {escape(rapport_tresorier.titre)}</p>"
if rapport_tresorier else "<p>Rapport du trésorier à adopter avant les contributions.</p>"
)
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,
] if item
])
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
<div class="header">
<div class="groupe">{escape(groupe_nom)}</div>
<div class="coords">{escape(coords)}</div>
</div>
<h1>Rapport d'assemblée d'affaires</h1>
<div class="meta">
<div class="box"><div class="label">Date</div><div class="value">{jour.isoformat()}</div></div>
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
<div class="box"><div class="label">Présences</div><div class="value">{len(presences_rows)}</div></div>
<div class="box"><div class="label">PV</div><div class="value">{'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}</div></div>
</div>
<h2>Présences</h2><ul>{"".join(presences_rows) or "<li>Présences à cocher sur papier.</li>"}</ul>
<h2>Réflexions en cours</h2><ul>{"".join(propositions_rows) or "<li>Aucune proposition secondée non résolue.</li>"}</ul>
<h2>Rapport du trésorier</h2>{tresorier_html}
<h2>Décisions prises</h2><ul>{"".join(resolutions_rows) or "<li>Aucune décision enregistrée pour cette date.</li>"}</ul>
<h2>Postes disponibles</h2><ul>{"".join(postes_rows) or "<li>Aucun poste vacant.</li>"}</ul>
<h2>Procès-verbal</h2><div class="pv">{escape(pv_contenu)}</div>
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
</body></html>"""
return html, jour
@router.post("/reunion", response_model=ReunionAffairesOut) @router.post("/reunion", response_model=ReunionAffairesOut)
async def creer_reunion_affaires( async def creer_reunion_affaires(
req: ReunionAffairesCreate, req: ReunionAffairesCreate,
@ -190,8 +321,14 @@ async def liste_reunions_affaires(
membre_prenom=mbr.prenom if mbr else "?", membre_prenom=mbr.prenom if mbr else "?",
present=p.present, present=p.present,
)) ))
rapport_adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, r.id, "assemblee")
out.append(ReunionAffairesOut( out.append(ReunionAffairesOut(
id=r.id, date_reunion=r.date_reunion, note=r.note, modifie_le=r.modifie_le, presences=pres_out, id=r.id,
date_reunion=r.date_reunion,
note=r.note,
modifie_le=r.modifie_le,
rapport_adopte_id=rapport_adopte.id if rapport_adopte else None,
presences=pres_out,
)) ))
return out return out
@ -271,109 +408,16 @@ async def rapport_assemblee_pdf(
)).scalar_one_or_none() )).scalar_one_or_none()
if not reunion: if not reunion:
raise HTTPException(status_code=404, detail="Assemblée introuvable") raise HTTPException(status_code=404, detail="Assemblée introuvable")
adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "assemblee")
groupe = await db.get(Groupe, membre.groupe_id) if adopte:
jour = _date_civile_reunion(reunion.date_reunion) pdf_bytes = HTML(string=adopte.contenu_html).write_pdf()
filename = f"rapport_assemblee_{reunion.date_reunion.date().isoformat()}.pdf"
presences = (await db.execute( return StreamingResponse(
select(Presence).where(Presence.reunion_id == reunion.id) io.BytesIO(pdf_bytes),
)).scalars().all() media_type="application/pdf",
presences_rows = [] headers={"Content-Disposition": f'inline; filename="{filename}"'},
for presence in presences:
mbr = await db.get(Membre, presence.membre_id)
if presence.present:
presences_rows.append(f"<li>{escape(mbr.prenom if mbr else '?')}</li>")
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"<li>{escape(prop.sujet)} ({escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else 'à confirmer')})</li>"
) )
html, jour = await _html_rapport_assemblee(reunion, membre, db)
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"<li>{escape(r.numero_resolution or '')} {escape(r.sujet)}</li>".strip()
for r in resolutions
]
postes_rows = [f"<li>{escape(p.nom)}</li>" 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,
] if item
])
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
<div class="header">
<div class="groupe">{escape(groupe_nom)}</div>
<div class="coords">{escape(coords)}</div>
</div>
<h1>Rapport d'assemblée d'affaires</h1>
<div class="meta">
<div class="box"><div class="label">Date</div><div class="value">{jour.isoformat()}</div></div>
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
<div class="box"><div class="label">Présences</div><div class="value">{len(presences_rows)}</div></div>
<div class="box"><div class="label">PV</div><div class="value">{'Adopté' if pv and pv.approuve else 'À adopter' if pv else 'À renseigner'}</div></div>
</div>
<h2>Présences</h2><ul>{"".join(presences_rows) or "<li>Présences à cocher sur papier.</li>"}</ul>
<h2>Réflexions en cours</h2><ul>{"".join(propositions_rows) or "<li>Aucune proposition secondée non résolue.</li>"}</ul>
<h2>Décisions prises</h2><ul>{"".join(resolutions_rows) or "<li>Aucune décision enregistrée pour cette date.</li>"}</ul>
<h2>Postes disponibles</h2><ul>{"".join(postes_rows) or "<li>Aucun poste vacant.</li>"}</ul>
<h2>Procès-verbal</h2><div class="pv">{escape(pv_contenu)}</div>
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
</body></html>"""
pdf_bytes = HTML(string=html).write_pdf() pdf_bytes = HTML(string=html).write_pdf()
filename = f"rapport_assemblee_{jour.isoformat()}.pdf" filename = f"rapport_assemblee_{jour.isoformat()}.pdf"
return StreamingResponse( return StreamingResponse(
@ -382,6 +426,55 @@ async def rapport_assemblee_pdf(
headers={"Content-Disposition": f'inline; filename="{filename}"'}, headers={"Content-Disposition": f'inline; filename="{filename}"'},
) )
@router.post("/reunions/{reunion_id}/adopter")
async def adopter_rapport_assemblee(
reunion_id: str,
req: RapportAssembleeAdoption,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Cristalliser le rapport d'assemblée adopté par le groupe."""
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")
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")
if await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "assemblee"):
raise HTTPException(status_code=400, detail="Cette assemblée est déjà adoptée")
html, jour = await _html_rapport_assemblee(reunion, membre, db)
rapport = RapportAdopte(
groupe_id=membre.groupe_id,
adopte_par_id=membre.id,
type_rapport="assemblee",
titre=f"Rapport d'assemblée — {jour.isoformat()}",
annee=jour.year,
mois=jour.month,
contenu_html=html,
donnees={
"reunion_id": str(reunion.id),
"date": jour.isoformat(),
"proposeur_id": str(proposeur.id),
"secondeur_id": str(secondeur.id),
"proposeur_prenom": proposeur.prenom,
"secondeur_prenom": secondeur.prenom,
},
)
db.add(rapport)
await db.flush()
return {"id": str(rapport.id), "titre": rapport.titre, "adopte_le": rapport.adopte_le}
@router.post("/", response_model=list[PresenceOut]) @router.post("/", response_model=list[PresenceOut])
async def enregistrer_presences( async def enregistrer_presences(
@ -400,6 +493,9 @@ async def enregistrer_presences(
reunion = result.scalar_one_or_none() reunion = result.scalar_one_or_none()
if not reunion: if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable") raise HTTPException(status_code=404, detail="Réunion introuvable")
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
)
# Supprimer les présences existantes pour cette réunion (pour pouvoir re-saisir) # Supprimer les présences existantes pour cette réunion (pour pouvoir re-saisir)
existing = await db.execute( existing = await db.execute(

View file

@ -11,6 +11,7 @@ from app.models.reunion import Reunion
from app.models.membre import Membre from app.models.membre import Membre
from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
from app.services.rapports_adoptes import verifier_reunion_non_cristallisee
router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"]) router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"])
@ -83,6 +84,9 @@ async def creer_pv(
reunion = result.scalar_one_or_none() reunion = result.scalar_one_or_none()
if not reunion: if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable") raise HTTPException(status_code=404, detail="Réunion introuvable")
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
)
decisions_data = None decisions_data = None
if req.decisions: if req.decisions:
@ -117,6 +121,11 @@ async def modifier_pv(
pv = result.scalar_one_or_none() pv = result.scalar_one_or_none()
if not pv: if not pv:
raise HTTPException(status_code=404, detail="PV introuvable") raise HTTPException(status_code=404, detail="PV introuvable")
reunion = await db.get(Reunion, pv.reunion_id)
if reunion:
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
)
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions") await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
avant = snapshot(pv, "contenu", "decisions", "approuve") avant = snapshot(pv, "contenu", "decisions", "approuve")
@ -157,6 +166,11 @@ async def adopter_pv(
pv = result.scalar_one_or_none() pv = result.scalar_one_or_none()
if not pv: if not pv:
raise HTTPException(status_code=404, detail="PV introuvable") raise HTTPException(status_code=404, detail="PV introuvable")
reunion = await db.get(Reunion, pv.reunion_id)
if reunion:
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, reunion.id, "assemblee", "Cette assemblée est adoptée"
)
await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions") await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions")
avant = snapshot(pv, "contenu", "decisions", "approuve") avant = snapshot(pv, "contenu", "decisions", "approuve")
if pv.approuve: if pv.approuve:

View file

@ -19,8 +19,10 @@ from app.models.jeton import Jeton
from app.models.litterature import Litterature from app.models.litterature import Litterature
from app.models.vente_jeton import VenteJeton from app.models.vente_jeton import VenteJeton
from app.models.vente_litterature import VenteLitterature from app.models.vente_litterature import VenteLitterature
from app.models.rapport_adopte import RapportAdopte
from app.schemas.phase1 import ReunionCreate, ReunionOut from app.schemas.phase1 import ReunionCreate, ReunionOut
from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee from app.services.historique import enregistrer_historique, snapshot, verifier_version_historisee
from app.services.rapports_adoptes import rapport_adopte_pour_reunion, verifier_reunion_non_cristallisee
router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"]) router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"])
@ -31,6 +33,139 @@ def _date_civile_reunion(valeur: datetime):
return valeur.date() return valeur.date()
async def _html_rapport_rencontre(reunion: Reunion, membre, db: AsyncSession) -> tuple[str, datetime.date]:
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 = "<p>Aucune collecte saisie.</p>"
if collecte:
detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
collecte_html = (
"<table>"
f"<tr><td>Montant</td><td class=\"montant\">{float(collecte.montant):.2f} $</td></tr>"
f"<tr><td>Détenteur</td><td>{escape(detenteur.prenom if detenteur else '—')}</td></tr>"
f"<tr><td>Statut</td><td>{escape(collecte.statut)}</td></tr>"
"</table>"
)
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"<tr><td>{escape(item.titre if item else '?')}</td><td>{vente.quantite}</td>"
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
)
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"<tr><td>{escape(vente.type_jeton)}</td><td>{vente.quantite}</td>"
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
)
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"<tr><td>{escape(jeton.type_jeton)}</td><td>{escape(cible.prenom if cible else 'Anonyme')}</td><td>{escape(detail)}</td></tr>"
)
annonces = reunion.annonces or []
annonces_html = "".join(f"<li>{escape(a)}</li>" for a in annonces) or "<li>Aucune annonce.</li>"
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,
] if item
])
ventes_lit_html = (
"<table><tr><th>Titre</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
+ "".join(ventes_lit_rows) + "</table>"
if ventes_lit_rows else "<p>Aucune vente de littérature.</p>"
)
ventes_jetons_html = (
"<table><tr><th>Type</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
+ "".join(ventes_jetons_rows) + "</table>"
if ventes_jetons_rows else "<p>Aucune vente de jetons.</p>"
)
jetons_html = (
"<table><tr><th>Type</th><th>Membre</th><th>Détail</th></tr>" + "".join(jetons_rows) + "</table>"
if jetons_rows else "<p>Aucun jeton/gâteau comptabilisé.</p>"
)
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
<div class="header">
<div class="groupe">{escape(groupe_nom)}</div>
<div class="coords">{escape(coords)}</div>
</div>
<h1>Rapport de rencontre</h1>
<div class="meta">
<div class="box"><div class="label">Date</div><div class="value">{reunion.date_reunion.date().isoformat()}</div></div>
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
<div class="box"><div class="label">Sujet</div><div class="value">{escape(reunion.theme or 'Rencontre hebdomadaire')}</div></div>
<div class="box"><div class="label">Animé par</div><div class="value">{escape(animateur.prenom if animateur else '—')}</div></div>
</div>
<h2>Annonces</h2><ul>{annonces_html}</ul>
<h2>Notes</h2><div class="note">{escape(reunion.note or 'Aucune note.')}</div>
<h2>Collecte</h2>{collecte_html}
<div class="page-break"></div>
<h2>Ventes de littérature</h2>{ventes_lit_html}
<h2>Ventes de jetons</h2>{ventes_jetons_html}
<h2>Jetons/Gâteaux</h2>{jetons_html}
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
</body></html>"""
return html, jour
@router.post("/", response_model=ReunionOut) @router.post("/", response_model=ReunionOut)
async def creer_rapport_reunion( async def creer_rapport_reunion(
req: ReunionCreate, req: ReunionCreate,
@ -216,6 +351,7 @@ async def liste_rencontres_details(
"annees": jeton.annees, "annees": jeton.annees,
}) })
rapport_adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre")
rencontres.append({ rencontres.append({
"id": str(reunion.id), "id": str(reunion.id),
"type": reunion.type, "type": reunion.type,
@ -229,6 +365,7 @@ async def liste_rencontres_details(
"ventes_litterature": ventes_litterature, "ventes_litterature": ventes_litterature,
"ventes_jetons": ventes_jetons, "ventes_jetons": ventes_jetons,
"jetons": jetons, "jetons": jetons,
"rapport_adopte_id": str(rapport_adopte.id) if rapport_adopte else None,
}) })
return rencontres return rencontres
@ -247,6 +384,9 @@ async def modifier_reunion(
reunion = result.scalar_one_or_none() reunion = result.scalar_one_or_none()
if not reunion: if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable") raise HTTPException(status_code=404, detail="Réunion introuvable")
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, reunion.id, "rencontre", "Cette rencontre est clôturée"
)
await verifier_version_historisee(db, reunion, version_attendue, table_cible="reunions") await verifier_version_historisee(db, reunion, version_attendue, table_cible="reunions")
avant = snapshot(reunion, "animateur_id", "theme", "annonces", "note") avant = snapshot(reunion, "animateur_id", "theme", "annonces", "note")
animateur = None animateur = None
@ -301,137 +441,16 @@ async def rapport_reunion_pdf(
reunion = result.scalar_one_or_none() reunion = result.scalar_one_or_none()
if not reunion: if not reunion:
raise HTTPException(status_code=404, detail="Réunion introuvable") raise HTTPException(status_code=404, detail="Réunion introuvable")
adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre")
groupe = await db.get(Groupe, membre.groupe_id) if adopte:
animateur = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None pdf_bytes = HTML(string=adopte.contenu_html).write_pdf()
jour = reunion.date_reunion.date() filename = f"rapport_rencontre_{reunion.date_reunion.date().isoformat()}.pdf"
return StreamingResponse(
collecte = (await db.execute( io.BytesIO(pdf_bytes),
select(Collecte).where( media_type="application/pdf",
Collecte.groupe_id == membre.groupe_id, headers={"Content-Disposition": f'inline; filename="{filename}"'},
(Collecte.reunion_id == reunion.id) | (Collecte.date_collecte == jour),
).limit(1)
)).scalar_one_or_none()
collecte_html = "<p>Aucune collecte saisie.</p>"
if collecte:
detenteur = await db.get(Membre, collecte.detenteur_id) if collecte.detenteur_id else None
collecte_html = (
"<table>"
f"<tr><td>Montant</td><td class=\"montant\">{float(collecte.montant):.2f} $</td></tr>"
f"<tr><td>Détenteur</td><td>{escape(detenteur.prenom if detenteur else '—')}</td></tr>"
f"<tr><td>Statut</td><td>{escape(collecte.statut)}</td></tr>"
"</table>"
) )
html, jour = await _html_rapport_rencontre(reunion, membre, db)
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"<tr><td>{escape(item.titre if item else '?')}</td><td>{vente.quantite}</td>"
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
)
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"<tr><td>{escape(vente.type_jeton)}</td><td>{vente.quantite}</td>"
f"<td class=\"montant\">{float(vente.montant):.2f} $</td><td>{escape(vendeur.prenom if vendeur else '—')}</td>"
f"<td>{'Reçue' if vente.remis_tresorier else 'À recevoir'}</td></tr>"
)
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"<tr><td>{escape(jeton.type_jeton)}</td><td>{escape(cible.prenom if cible else 'Anonyme')}</td><td>{escape(detail)}</td></tr>"
)
annonces = reunion.annonces or []
annonces_html = "".join(f"<li>{escape(a)}</li>" for a in annonces) or "<li>Aucune annonce.</li>"
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,
] if item
])
ventes_lit_html = (
"<table><tr><th>Titre</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
+ "".join(ventes_lit_rows) + "</table>"
if ventes_lit_rows else "<p>Aucune vente de littérature.</p>"
)
ventes_jetons_html = (
"<table><tr><th>Type</th><th>Qté</th><th>Montant</th><th>Vendu par</th><th>Statut</th></tr>"
+ "".join(ventes_jetons_rows) + "</table>"
if ventes_jetons_rows else "<p>Aucune vente de jetons.</p>"
)
jetons_html = (
"<table><tr><th>Type</th><th>Membre</th><th>Détail</th></tr>" + "".join(jetons_rows) + "</table>"
if jetons_rows else "<p>Aucun jeton/gâteau comptabilisé.</p>"
)
html = f"""<!doctype html><html><head><meta charset="utf-8"><style>{css}</style></head><body>
<div class="header">
<div class="groupe">{escape(groupe_nom)}</div>
<div class="coords">{escape(coords)}</div>
</div>
<h1>Rapport de rencontre</h1>
<div class="meta">
<div class="box"><div class="label">Date</div><div class="value">{reunion.date_reunion.date().isoformat()}</div></div>
<div class="box"><div class="label">Heure</div><div class="value">{reunion.date_reunion.strftime('%H:%M')}</div></div>
<div class="box"><div class="label">Sujet</div><div class="value">{escape(reunion.theme or 'Rencontre hebdomadaire')}</div></div>
<div class="box"><div class="label">Animé par</div><div class="value">{escape(animateur.prenom if animateur else '—')}</div></div>
</div>
<h2>Annonces</h2><ul>{annonces_html}</ul>
<h2>Notes</h2><div class="note">{escape(reunion.note or 'Aucune note.')}</div>
<h2>Collecte</h2>{collecte_html}
<div class="page-break"></div>
<h2>Ventes de littérature</h2>{ventes_lit_html}
<h2>Ventes de jetons</h2>{ventes_jetons_html}
<h2>Jetons/Gâteaux</h2>{jetons_html}
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
</body></html>"""
pdf_bytes = HTML(string=html).write_pdf() pdf_bytes = HTML(string=html).write_pdf()
filename = f"rapport_rencontre_{jour.isoformat()}.pdf" filename = f"rapport_rencontre_{jour.isoformat()}.pdf"
return StreamingResponse( return StreamingResponse(
@ -441,6 +460,40 @@ async def rapport_reunion_pdf(
) )
@router.post("/{reunion_id}/cloturer")
async def cloturer_rencontre(
reunion_id: str,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Cristalliser le rapport d'une rencontre régulière."""
reunion = (await db.execute(
select(Reunion).where(
Reunion.id == reunion_id,
Reunion.groupe_id == membre.groupe_id,
Reunion.type == "reguliere",
)
)).scalar_one_or_none()
if not reunion:
raise HTTPException(status_code=404, detail="Rencontre introuvable")
if await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre"):
raise HTTPException(status_code=400, detail="Cette rencontre est déjà clôturée")
html, jour = await _html_rapport_rencontre(reunion, membre, db)
rapport = RapportAdopte(
groupe_id=membre.groupe_id,
adopte_par_id=membre.id,
type_rapport="rencontre",
titre=f"Rapport de rencontre — {jour.isoformat()}",
annee=jour.year,
mois=jour.month,
contenu_html=html,
donnees={"reunion_id": str(reunion.id), "date": jour.isoformat()},
)
db.add(rapport)
await db.flush()
return {"id": str(rapport.id), "titre": rapport.titre, "adopte_le": rapport.adopte_le}
@router.get("/{reunion_id}", response_model=ReunionOut) @router.get("/{reunion_id}", response_model=ReunionOut)
async def detail_reunion( async def detail_reunion(
reunion_id: str, reunion_id: str,

View file

@ -15,6 +15,7 @@ from app.models.poste import Poste
from app.models.transaction_comptable import TransactionComptable from app.models.transaction_comptable import TransactionComptable
from app.models.reunion import Reunion from app.models.reunion import Reunion
from app.schemas.phase6 import VenteCreate, VenteOut from app.schemas.phase6 import VenteCreate, VenteOut
from app.services.rapports_adoptes import verifier_reunion_non_cristallisee
router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littérature"]) router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littérature"])
@ -73,6 +74,9 @@ async def enregistrer_vente(
raise HTTPException(status_code=404, detail="Vendeur introuvable") raise HTTPException(status_code=404, detail="Vendeur introuvable")
if req.reunion_id: if req.reunion_id:
await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable") await ensure_owned_or_404(db, Reunion, req.reunion_id, membre.groupe_id, "Réunion introuvable")
await verifier_reunion_non_cristallisee(
db, membre.groupe_id, req.reunion_id, "rencontre", "Cette rencontre est clôturée"
)
# Créer la vente # Créer la vente
vente = VenteLitterature( vente = VenteLitterature(

View file

@ -33,6 +33,7 @@ class ReunionAffairesOut(BaseModel):
date_reunion: datetime date_reunion: datetime
note: Optional[str] = None note: Optional[str] = None
modifie_le: Optional[datetime] = None modifie_le: Optional[datetime] = None
rapport_adopte_id: Optional[UUID] = None
presences: list[PresenceOut] = [] presences: list[PresenceOut] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View file

@ -0,0 +1,37 @@
"""Aides pour les rapports cristallisés."""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.rapport_adopte import RapportAdopte
async def rapport_adopte_pour_reunion(
db: AsyncSession,
groupe_id,
reunion_id,
type_rapport: str,
) -> RapportAdopte | None:
result = await db.execute(
select(RapportAdopte).where(
RapportAdopte.groupe_id == groupe_id,
RapportAdopte.type_rapport == type_rapport,
)
)
reunion_id_str = str(reunion_id)
for rapport in result.scalars().all():
if (rapport.donnees or {}).get("reunion_id") == reunion_id_str:
return rapport
return None
async def verifier_reunion_non_cristallisee(
db: AsyncSession,
groupe_id,
reunion_id,
type_rapport: str,
detail: str,
) -> None:
from fastapi import HTTPException
if await rapport_adopte_pour_reunion(db, groupe_id, reunion_id, type_rapport):
raise HTTPException(status_code=400, detail=detail)

View file

@ -82,10 +82,13 @@ Une tuile ouverte donne accès aux actions de la rencontre:
- `Collecte`; - `Collecte`;
- `Ventes`; - `Ventes`;
- `Jetons/Gâteaux`; - `Jetons/Gâteaux`;
- `Clôturer`;
- `Rapport PDF`. - `Rapport PDF`.
Les exécutifs peuvent planifier et modifier une rencontre. Les exécutifs peuvent planifier et modifier une rencontre.
Quand une rencontre est clôturée, son rapport PDF est cristallisé. Les données de cette rencontre ne peuvent plus être changées depuis les modules de saisie.
## 7. Collecte ## 7. Collecte
Une rencontre a une collecte. Pour saisir une collecte: Une rencontre a une collecte. Pour saisir une collecte:
@ -124,10 +127,13 @@ Une assemblée peut contenir:
- décisions; - décisions;
- contributions; - contributions;
- postes à pourvoir; - postes à pourvoir;
- adoption du rapport;
- rapport PDF. - rapport PDF.
La page de l'assemblée sert d'ordre du jour. Le rapport PDF sert de procès-verbal imprimable. La page de l'assemblée sert d'ordre du jour. Le rapport PDF sert de procès-verbal imprimable.
Un exécutif peut adopter le rapport d'assemblée avec un proposeur et un secondeur. Après adoption, le rapport PDF est cristallisé et les données de cette assemblée ne peuvent plus être changées.
## 10. Présences ## 10. Présences
Dans une assemblée, ouvrir `Présences`, cocher les membres présents et enregistrer. Les présences alimentent le rapport PDF. Dans une assemblée, ouvrir `Présences`, cocher les membres présents et enregistrer. Les présences alimentent le rapport PDF.
@ -157,6 +163,8 @@ Avant adoption, le rapport peut être corrigé. Après adoption, il est cristall
Le rapport du trésorier doit être adopté avant de décider du montant des contributions. Le rapport du trésorier doit être adopté avant de décider du montant des contributions.
Les rapports cristallisés sont téléchargés depuis l'icône de rapport sur les tuiles concernées.
## 13. Gouvernance ## 13. Gouvernance
Le module `Gouvernance` rassemble les propositions, réflexions en cours, rejets et décisions. Le module `Gouvernance` rassemble les propositions, réflexions en cours, rejets et décisions.

View file

@ -33,6 +33,7 @@ export default function AssembleeAffaires({ contexte = false }) {
const [pvPrecedent, setPvPrecedent] = useState(null); const [pvPrecedent, setPvPrecedent] = useState(null);
const [pvCorrection, setPvCorrection] = useState(''); const [pvCorrection, setPvCorrection] = useState('');
const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' }); const [adoption, setAdoption] = useState({ proposeur_id: '', secondeur_id: '' });
const [adoptionAssemblee, setAdoptionAssemblee] = useState({ proposeur_id: '', secondeur_id: '' });
const [don, setDon] = useState({ destination: 'District 87-16', montant: '', methode: 'VIREMENT', 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 [adoptionTresorier, setAdoptionTresorier] = useState({ proposeur_id: '', secondeur_id: '', note: '' });
const [invitationTexte, setInvitationTexte] = useState(''); const [invitationTexte, setInvitationTexte] = useState('');
@ -85,6 +86,11 @@ export default function AssembleeAffaires({ contexte = false }) {
proposeur_id: f.proposeur_id || m[0]?.id || '', proposeur_id: f.proposeur_id || m[0]?.id || '',
secondeur_id: f.secondeur_id || m[1]?.id || '', secondeur_id: f.secondeur_id || m[1]?.id || '',
})); }));
setAdoptionAssemblee((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 (!membreProposeId && m[0]) setMembreProposeId(m[0].id);
} catch (e) { } catch (e) {
setError(e.message); setError(e.message);
@ -318,6 +324,25 @@ export default function AssembleeAffaires({ contexte = false }) {
} }
}; };
const adopterAssemblee = async () => {
if (!reunionOuverte) return;
if (!adoptionAssemblee.proposeur_id || !adoptionAssemblee.secondeur_id) {
setError('Proposeur et secondeur requis');
return;
}
setLoading(true);
setError('');
try {
await api.post(`/presences/reunions/${reunionOuverte.id}/adopter`, adoptionAssemblee);
setMsg('Rapport d’assemblée adopté');
setAction('');
await charger();
} catch (e) {
setError(e.message);
}
setLoading(false);
};
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-gray-100">
<PageHeader titre="Assemblées" sousTitre="Assemblées d'affaires et procès-verbaux" retour={!contexte} /> <PageHeader titre="Assemblées" sousTitre="Assemblées d'affaires et procès-verbaux" retour={!contexte} />
@ -357,7 +382,7 @@ export default function AssembleeAffaires({ contexte = false }) {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-extrabold text-gray-900">{labelDateReunion(date)} · {heure(r.date_reunion)}</p> <p className="font-extrabold text-gray-900">{labelDateReunion(date)} · {heure(r.date_reunion)}</p>
<p className="text-sm text-gray-500 truncate">{pv?.approuve ? 'PV adopté' : pv ? 'PV à adopter' : 'PV à renseigner'} · {r.presences?.filter((p) => p.present).length || 0} présence(s)</p> <p className="text-sm text-gray-500 truncate">{r.rapport_adopte_id ? 'Rapport adopté' : pv?.approuve ? 'PV adopté' : pv ? 'PV à adopter' : 'PV à renseigner'} · {r.presences?.filter((p) => p.present).length || 0} présence(s)</p>
</div> </div>
<span className="text-xl text-gray-300">{ouvert ? '⌃' : '⌄'}</span> <span className="text-xl text-gray-300">{ouvert ? '⌃' : '⌄'}</span>
</div> </div>
@ -372,15 +397,33 @@ export default function AssembleeAffaires({ contexte = false }) {
{ouvert && ( {ouvert && (
<div className="p-4 pt-0 space-y-3 border-t border-gray-100"> <div className="p-4 pt-0 space-y-3 border-t border-gray-100">
<div className="grid grid-cols-2 gap-2 print:hidden"> {r.rapport_adopte_id ? (
<button onClick={() => genererInvitation(r)} className={boutonAction(action === 'Invitation')}>Invitation</button> <div className="rounded-xl bg-green-50 p-3 text-sm font-bold text-green-700 print:hidden">
{pvPrecedent && <button onClick={() => setAction(action === 'PV précédent' ? '' : 'PV précédent')} className={boutonAction(action === 'PV précédent')}>PV précédent</button>} Rapport adopté. Les données de cette assemblée sont cristallisées.
{estExecutif && <button onClick={() => setAction(action === 'Présences' ? '' : 'Présences')} className={boutonAction(action === 'Présences')}>Présences</button>} </div>
{estExecutif && <button onClick={() => setAction(action === 'Trésorier' ? '' : 'Trésorier')} className={boutonAction(action === 'Trésorier')}>Rapport trésorier</button>} ) : (
<button onClick={() => setAction(action === 'Donner' ? '' : 'Donner')} className={boutonAction(action === 'Donner')}>Donner au suivant</button> <div className="grid grid-cols-2 gap-2 print:hidden">
<button onClick={() => setAction(action === 'Postes' ? '' : 'Postes')} className={boutonAction(action === 'Postes')}>Postes à pourvoir</button> <button onClick={() => genererInvitation(r)} className={boutonAction(action === 'Invitation')}>Invitation</button>
<button onClick={() => setAction(action === 'Décisions' ? '' : 'Décisions')} className={boutonAction(action === 'Décisions')}>Décisions</button> {pvPrecedent && <button onClick={() => setAction(action === 'PV précédent' ? '' : 'PV précédent')} className={boutonAction(action === 'PV précédent')}>PV précédent</button>}
</div> {estExecutif && <button onClick={() => setAction(action === 'Présences' ? '' : 'Présences')} className={boutonAction(action === 'Présences')}>Présences</button>}
{estExecutif && <button onClick={() => setAction(action === 'Trésorier' ? '' : 'Trésorier')} className={boutonAction(action === 'Trésorier')}>Rapport trésorier</button>}
<button onClick={() => setAction(action === 'Donner' ? '' : 'Donner')} className={boutonAction(action === 'Donner')}>Donner au suivant</button>
<button onClick={() => setAction(action === 'Postes' ? '' : 'Postes')} className={boutonAction(action === 'Postes')}>Postes à pourvoir</button>
<button onClick={() => setAction(action === 'Décisions' ? '' : 'Décisions')} className={boutonAction(action === 'Décisions')}>Décisions</button>
{estExecutif && <button onClick={() => setAction(action === 'Adopter' ? '' : 'Adopter')} className={boutonAction(action === 'Adopter')}>Adopter</button>}
</div>
)}
{action === 'Adopter' && estExecutif && !r.rapport_adopte_id && (
<Card titre="Adopter le rapport d'assemblée">
<p className="text-sm text-gray-600">Le rapport PDF sera cristallisé et ne pourra plus être modifié.</p>
<div className="grid grid-cols-2 gap-2">
<Champ label="Proposeur"><MembreSelect label="Proposeur" value={adoptionAssemblee.proposeur_id} onChange={(v) => setAdoptionAssemblee({ ...adoptionAssemblee, proposeur_id: v })} membres={membres} /></Champ>
<Champ label="Secondeur"><MembreSelect label="Secondeur" value={adoptionAssemblee.secondeur_id} onChange={(v) => setAdoptionAssemblee({ ...adoptionAssemblee, secondeur_id: v })} membres={membres} /></Champ>
</div>
<button onClick={adopterAssemblee} disabled={loading || !adoptionAssemblee.proposeur_id || !adoptionAssemblee.secondeur_id} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">Adopter</button>
</Card>
)}
{action === 'PV précédent' && pvPrecedent && ( {action === 'PV précédent' && pvPrecedent && (
<Card titre="Procès-verbal précédent"> <Card titre="Procès-verbal précédent">

View file

@ -234,6 +234,22 @@ export default function ReunionHebdomadaire({ contexte = false }) {
} }
}; };
const cloturerRencontre = async () => {
if (!rencontreOuverte) return;
setLoading(true);
setError('');
try {
await api.post(`/reunions/${rencontreOuverte.id}/cloturer`);
flash('Rencontre clôturée');
setAction('');
await charger();
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
const heure = (dateIso) => heureIsoSansFuseau(dateIso); const heure = (dateIso) => heureIsoSansFuseau(dateIso);
return ( return (
@ -268,7 +284,7 @@ export default function ReunionHebdomadaire({ contexte = false }) {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-extrabold text-gray-900">{labelDateReunion(dateRencontre(r))} · {heure(r.date_reunion)}</p> <p className="font-extrabold text-gray-900">{labelDateReunion(dateRencontre(r))} · {heure(r.date_reunion)}</p>
<p className="text-sm text-gray-500 truncate">Sujet : {r.theme || 'Rencontre hebdomadaire'} · Animé par : {r.animateur_prenom || '—'}</p> <p className="text-sm text-gray-500 truncate">Sujet : {r.theme || 'Rencontre hebdomadaire'} · Animé par : {r.animateur_prenom || '—'}{r.rapport_adopte_id ? ' · Clôturée' : ''}</p>
</div> </div>
<span className="text-xl text-gray-300">{ouverteId === r.id ? '⌃' : '⌄'}</span> <span className="text-xl text-gray-300">{ouverteId === r.id ? '⌃' : '⌄'}</span>
</div> </div>
@ -284,20 +300,43 @@ export default function ReunionHebdomadaire({ contexte = false }) {
{ouverteId === r.id && ( {ouverteId === r.id && (
<div className="p-4 pt-0 space-y-3 border-t border-gray-100"> <div className="p-4 pt-0 space-y-3 border-t border-gray-100">
<Resume rencontre={r} /> <Resume rencontre={r} />
<div className="grid grid-cols-2 gap-2"> {r.rapport_adopte_id ? (
{['Collecte', 'Ventes', 'Jetons/Gâteaux'].map((nom) => ( <div className="rounded-xl bg-green-50 p-3 text-sm font-bold text-green-700">
<button key={nom} onClick={() => setAction(action === nom ? '' : nom)} Rapport clôturé. Les données de cette rencontre sont cristallisées.
className={`p-3 rounded-xl text-sm font-bold ${action === nom ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}> </div>
{nom} ) : (
<div className="grid grid-cols-2 gap-2">
{['Collecte', 'Ventes', 'Jetons/Gâteaux'].map((nom) => (
<button key={nom} onClick={() => setAction(action === nom ? '' : nom)}
className={`p-3 rounded-xl text-sm font-bold ${action === nom ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
{nom}
</button>
))}
{estExecutif && (
<>
<button onClick={() => setAction(action === 'Modifier' ? '' : 'Modifier')}
className={`p-3 rounded-xl text-sm font-bold ${action === 'Modifier' ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
Modifier
</button>
<button onClick={() => setAction(action === 'Clôturer' ? '' : 'Clôturer')}
className={`p-3 rounded-xl text-sm font-bold ${action === 'Clôturer' ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
Clôturer
</button>
</>
)}
</div>
)}
{action === 'Clôturer' && !r.rapport_adopte_id && (
<Card titre="Clôturer la rencontre">
<p className="text-sm text-gray-600">
Le rapport PDF sera cristallisé. Les saisies de cette rencontre ne pourront plus être modifiées.
</p>
<button onClick={cloturerRencontre} disabled={loading} className="w-full p-3 bg-green-600 text-white rounded-xl text-sm font-bold disabled:opacity-40">
Clôturer
</button> </button>
))} </Card>
{estExecutif && ( )}
<button onClick={() => setAction(action === 'Modifier' ? '' : 'Modifier')}
className={`p-3 rounded-xl text-sm font-bold ${action === 'Modifier' ? 'bg-aa-blue text-white' : 'bg-gray-50 text-gray-700'}`}>
Modifier
</button>
)}
</div>
{action === 'Collecte' && ( {action === 'Collecte' && (
<Card titre="Collecte"> <Card titre="Collecte">