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.services.finance import encaisse, solde_banque, solde_reserve, transaction
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"])
@ -102,6 +103,9 @@ async def saisir_collecte(
raise HTTPException(status_code=404, detail="Détenteur introuvable ou inactif")
if req.reunion_id:
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(
select(Collecte).where(
@ -147,6 +151,10 @@ async def ajuster_collecte(
collecte = result.scalar_one_or_none()
if not collecte:
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")
if collecte.statut != "en_attente":
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.reunion import Reunion
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"])
@ -67,6 +68,9 @@ async def remettre_jeton(
membre_prenom = cible.prenom
if req.reunion_id:
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(
groupe_id=membre.groupe_id,
@ -111,6 +115,9 @@ async def enregistrer_vente_jeton(
raise HTTPException(status_code=404, detail="Vendeur introuvable")
if req.reunion_id:
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(
groupe_id=membre.groupe_id,

View file

@ -5,6 +5,7 @@ from html import escape
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
from zoneinfo import ZoneInfo
@ -22,13 +23,20 @@ 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.models.rapport_adopte import RapportAdopte
from app.schemas.phase4 import (
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"])
class RapportAssembleeAdoption(BaseModel):
proposeur_id: str
secondeur_id: str
def _date_civile_reunion(valeur):
if getattr(valeur, "tzinfo", None):
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)
async def creer_reunion_affaires(
req: ReunionAffairesCreate,
@ -190,8 +321,14 @@ async def liste_reunions_affaires(
membre_prenom=mbr.prenom if mbr else "?",
present=p.present,
))
rapport_adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, r.id, "assemblee")
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
@ -271,109 +408,16 @@ async def rapport_assemblee_pdf(
)).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"<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>"
adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "assemblee")
if adopte:
pdf_bytes = HTML(string=adopte.contenu_html).write_pdf()
filename = f"rapport_assemblee_{reunion.date_reunion.date().isoformat()}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
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>"""
html, jour = await _html_rapport_assemblee(reunion, membre, db)
pdf_bytes = HTML(string=html).write_pdf()
filename = f"rapport_assemblee_{jour.isoformat()}.pdf"
return StreamingResponse(
@ -382,6 +426,55 @@ async def rapport_assemblee_pdf(
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])
async def enregistrer_presences(
@ -400,6 +493,9 @@ async def enregistrer_presences(
reunion = result.scalar_one_or_none()
if not reunion:
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)
existing = await db.execute(

View file

@ -11,6 +11,7 @@ from app.models.reunion import Reunion
from app.models.membre import Membre
from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate
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"])
@ -83,6 +84,9 @@ async def creer_pv(
reunion = result.scalar_one_or_none()
if not reunion:
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
if req.decisions:
@ -117,6 +121,11 @@ async def modifier_pv(
pv = result.scalar_one_or_none()
if not pv:
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")
avant = snapshot(pv, "contenu", "decisions", "approuve")
@ -157,6 +166,11 @@ async def adopter_pv(
pv = result.scalar_one_or_none()
if not pv:
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")
avant = snapshot(pv, "contenu", "decisions", "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.vente_jeton import VenteJeton
from app.models.vente_litterature import VenteLitterature
from app.models.rapport_adopte import RapportAdopte
from app.schemas.phase1 import ReunionCreate, ReunionOut
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"])
@ -31,6 +33,139 @@ def _date_civile_reunion(valeur: datetime):
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)
async def creer_rapport_reunion(
req: ReunionCreate,
@ -216,6 +351,7 @@ async def liste_rencontres_details(
"annees": jeton.annees,
})
rapport_adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre")
rencontres.append({
"id": str(reunion.id),
"type": reunion.type,
@ -229,6 +365,7 @@ async def liste_rencontres_details(
"ventes_litterature": ventes_litterature,
"ventes_jetons": ventes_jetons,
"jetons": jetons,
"rapport_adopte_id": str(rapport_adopte.id) if rapport_adopte else None,
})
return rencontres
@ -247,6 +384,9 @@ async def modifier_reunion(
reunion = result.scalar_one_or_none()
if not reunion:
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")
avant = snapshot(reunion, "animateur_id", "theme", "annonces", "note")
animateur = None
@ -301,137 +441,16 @@ async def rapport_reunion_pdf(
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 = "<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>"
adopte = await rapport_adopte_pour_reunion(db, membre.groupe_id, reunion.id, "rencontre")
if adopte:
pdf_bytes = HTML(string=adopte.contenu_html).write_pdf()
filename = f"rapport_rencontre_{reunion.date_reunion.date().isoformat()}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
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>"""
html, jour = await _html_rapport_rencontre(reunion, membre, db)
pdf_bytes = HTML(string=html).write_pdf()
filename = f"rapport_rencontre_{jour.isoformat()}.pdf"
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)
async def detail_reunion(
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.reunion import Reunion
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"])
@ -73,6 +74,9 @@ async def enregistrer_vente(
raise HTTPException(status_code=404, detail="Vendeur introuvable")
if req.reunion_id:
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
vente = VenteLitterature(

View file

@ -33,6 +33,7 @@ class ReunionAffairesOut(BaseModel):
date_reunion: datetime
note: Optional[str] = None
modifie_le: Optional[datetime] = None
rapport_adopte_id: Optional[UUID] = None
presences: list[PresenceOut] = []
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`;
- `Ventes`;
- `Jetons/Gâteaux`;
- `Clôturer`;
- `Rapport PDF`.
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
Une rencontre a une collecte. Pour saisir une collecte:
@ -124,10 +127,13 @@ Une assemblée peut contenir:
- décisions;
- contributions;
- postes à pourvoir;
- adoption du rapport;
- rapport PDF.
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
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.
Les rapports cristallisés sont téléchargés depuis l'icône de rapport sur les tuiles concernées.
## 13. Gouvernance
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 [pvCorrection, setPvCorrection] = useState('');
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 [adoptionTresorier, setAdoptionTresorier] = useState({ proposeur_id: '', secondeur_id: '', note: '' });
const [invitationTexte, setInvitationTexte] = useState('');
@ -85,6 +86,11 @@ export default function AssembleeAffaires({ contexte = false }) {
proposeur_id: f.proposeur_id || m[0]?.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);
} catch (e) {
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 (
<div className="min-h-screen bg-gray-100">
<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="min-w-0">
<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>
<span className="text-xl text-gray-300">{ouvert ? '⌃' : '⌄'}</span>
</div>
@ -372,15 +397,33 @@ export default function AssembleeAffaires({ contexte = false }) {
{ouvert && (
<div className="p-4 pt-0 space-y-3 border-t border-gray-100">
<div className="grid grid-cols-2 gap-2 print:hidden">
<button onClick={() => genererInvitation(r)} className={boutonAction(action === 'Invitation')}>Invitation</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>}
{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>
</div>
{r.rapport_adopte_id ? (
<div className="rounded-xl bg-green-50 p-3 text-sm font-bold text-green-700 print:hidden">
Rapport adopté. Les données de cette assemblée sont cristallisées.
</div>
) : (
<div className="grid grid-cols-2 gap-2 print:hidden">
<button onClick={() => genererInvitation(r)} className={boutonAction(action === 'Invitation')}>Invitation</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>}
{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 && (
<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);
return (
@ -268,7 +284,7 @@ export default function ReunionHebdomadaire({ contexte = false }) {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<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>
<span className="text-xl text-gray-300">{ouverteId === r.id ? '⌃' : '⌄'}</span>
</div>
@ -284,20 +300,43 @@ export default function ReunionHebdomadaire({ contexte = false }) {
{ouverteId === r.id && (
<div className="p-4 pt-0 space-y-3 border-t border-gray-100">
<Resume rencontre={r} />
<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}
{r.rapport_adopte_id ? (
<div className="rounded-xl bg-green-50 p-3 text-sm font-bold text-green-700">
Rapport clôturé. Les données de cette rencontre sont cristallisées.
</div>
) : (
<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>
))}
{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>
</Card>
)}
{action === 'Collecte' && (
<Card titre="Collecte">