Cristalliser rapports de reunions et assemblees
This commit is contained in:
parent
086bdbd548
commit
14fa8b4174
11 changed files with 567 additions and 257 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
37
backend/app/services/rapports_adoptes.py
Normal file
37
backend/app/services/rapports_adoptes.py
Normal 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)
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue