diff --git a/backend/app/core/concurrency.py b/backend/app/core/concurrency.py new file mode 100644 index 0000000..2b91da0 --- /dev/null +++ b/backend/app/core/concurrency.py @@ -0,0 +1,27 @@ +"""Protection contre les modifications concurrentes.""" +from datetime import datetime + +from fastapi import HTTPException + + +def version_iso(obj) -> str | None: + valeur = getattr(obj, "modifie_le", None) or getattr(obj, "cree_le", None) + return valeur.isoformat() if valeur else None + + +def _normaliser(valeur: datetime) -> str: + return valeur.isoformat().replace("+00:00", "Z") + + +def verifier_version(obj, version_attendue: str | None = None): + """Refuse l'écriture si le client a ouvert une ancienne version de la donnée.""" + if not version_attendue: + return + actuelle = getattr(obj, "modifie_le", None) or getattr(obj, "cree_le", None) + if not actuelle: + return + if _normaliser(actuelle) != version_attendue.replace("+00:00", "Z"): + raise HTTPException( + status_code=409, + detail="Cette information a été modifiée depuis son affichage. Rechargez les données avant d'enregistrer.", + ) diff --git a/backend/app/main.py b/backend/app/main.py index 2cad063..1aba828 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,6 +28,7 @@ from app.models.push_subscription import PushSubscription # noqa: F401 from app.models.destinataire_contribution import DestinataireContribution # noqa: F401 from app.models.rapport_adopte import RapportAdopte # noqa: F401 from app.models.vente_jeton import VenteJeton # noqa: F401 +from app.models.historique_modification import HistoriqueModification # noqa: F401 @asynccontextmanager @@ -47,6 +48,21 @@ async def lifespan(app: FastAPI): await conn.execute(text("ALTER TABLE ventes_litterature ADD COLUMN IF NOT EXISTS reunion_id UUID REFERENCES reunions(id)")) await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_type VARCHAR(40)")) await conn.execute(text("ALTER TABLE invitations ADD COLUMN IF NOT EXISTS cible_id UUID")) + for table in ( + "propositions", + "depenses", + "collectes", + "reunions", + "pv_reunions", + "reserves", + "litterature", + "destinataires_contributions", + "envois_contributions", + ): + await conn.execute(text( + f"ALTER TABLE {table} " + "ADD COLUMN IF NOT EXISTS modifie_le TIMESTAMPTZ DEFAULT now() NOT NULL" + )) await conn.execute(text( "ALTER TABLE envois_contributions " "ALTER COLUMN destination TYPE VARCHAR(120) USING destination::text" diff --git a/backend/app/models/collecte.py b/backend/app/models/collecte.py index afb444f..2b5f614 100644 --- a/backend/app/models/collecte.py +++ b/backend/app/models/collecte.py @@ -25,3 +25,4 @@ class Collecte(Base): recu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) date_reception: Mapped[date | None] = mapped_column(Date) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/depense.py b/backend/app/models/depense.py index 0c0ef0b..103f95a 100644 --- a/backend/app/models/depense.py +++ b/backend/app/models/depense.py @@ -26,3 +26,4 @@ class Depense(Base): confirme_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) date_confirmation: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/destinataire_contribution.py b/backend/app/models/destinataire_contribution.py index efb2260..2484371 100644 --- a/backend/app/models/destinataire_contribution.py +++ b/backend/app/models/destinataire_contribution.py @@ -18,3 +18,4 @@ class DestinataireContribution(Base): protege: Mapped[bool] = mapped_column(Boolean, default=False) actif: Mapped[bool] = mapped_column(Boolean, default=True) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/envoi_contribution.py b/backend/app/models/envoi_contribution.py index 9fa6143..302ab14 100644 --- a/backend/app/models/envoi_contribution.py +++ b/backend/app/models/envoi_contribution.py @@ -23,3 +23,4 @@ class EnvoiContribution(Base): debitee: Mapped[bool] = mapped_column(Boolean, default=False) # montant débité du compte saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/historique_modification.py b/backend/app/models/historique_modification.py new file mode 100644 index 0000000..1a35e33 --- /dev/null +++ b/backend/app/models/historique_modification.py @@ -0,0 +1,24 @@ +"""Historique métier des modifications importantes.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import DateTime, ForeignKey, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class HistoriqueModification(Base): + __tablename__ = "historique_modifications" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False, index=True) + table_cible: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + objet_id: Mapped[uuid.UUID] = mapped_column(nullable=False, index=True) + action: Mapped[str] = mapped_column(String(120), nullable=False) + raison: Mapped[str] = mapped_column(Text, nullable=False) + membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + admin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + chemin_api: Mapped[str | None] = mapped_column(String(255)) + avant: Mapped[dict | None] = mapped_column(JSON) + apres: Mapped[dict | None] = mapped_column(JSON) + cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True) diff --git a/backend/app/models/litterature.py b/backend/app/models/litterature.py index e39967b..53c4d20 100644 --- a/backend/app/models/litterature.py +++ b/backend/app/models/litterature.py @@ -1,6 +1,7 @@ """Table litterature — Inventaire AA.""" import uuid -from sqlalchemy import String, Integer, Numeric, ForeignKey, Enum as SAEnum +from datetime import datetime, timezone +from sqlalchemy import String, Integer, Numeric, ForeignKey, Enum as SAEnum, DateTime from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -19,3 +20,4 @@ class Litterature(Base): quantite_stock: Mapped[int] = mapped_column(Integer, default=0) seuil_commande: Mapped[int] = mapped_column(Integer, default=2) code_bsg: Mapped[str | None] = mapped_column(String(20)) + modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/proposition.py b/backend/app/models/proposition.py index 465d783..945aa54 100644 --- a/backend/app/models/proposition.py +++ b/backend/app/models/proposition.py @@ -31,3 +31,8 @@ class Proposition(Base): cree_le: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) + modifie_le: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/models/pv_reunion.py b/backend/app/models/pv_reunion.py index 87aca74..e61854f 100644 --- a/backend/app/models/pv_reunion.py +++ b/backend/app/models/pv_reunion.py @@ -16,3 +16,4 @@ class PvReunion(Base): decisions: Mapped[list | None] = mapped_column(JSON) # [{sujet, decision, proposeur, secondeur, statut}] approuve: Mapped[bool] = mapped_column(Boolean, default=False) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/reserve.py b/backend/app/models/reserve.py index 16f6667..62d7190 100644 --- a/backend/app/models/reserve.py +++ b/backend/app/models/reserve.py @@ -16,4 +16,4 @@ class Reserve(Base): note: Mapped[str | None] = mapped_column(Text) active: Mapped[bool] = mapped_column(Boolean, default=True) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - + modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/reunion.py b/backend/app/models/reunion.py index 4ec65bc..ccf8a64 100644 --- a/backend/app/models/reunion.py +++ b/backend/app/models/reunion.py @@ -18,3 +18,4 @@ class Reunion(Base): annonces: Mapped[dict | None] = mapped_column(JSON) note: Mapped[str | None] = mapped_column(Text) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/routers/collectes.py b/backend/app/routers/collectes.py index 49fb071..4eb7b55 100644 --- a/backend/app/routers/collectes.py +++ b/backend/app/routers/collectes.py @@ -1,6 +1,6 @@ """Routes S1 — Collecte 7e Tradition.""" from datetime import date, datetime, time as dt_time, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, extract, func @@ -18,6 +18,7 @@ from app.models.reserve import Reserve from app.models.mouvement_reserve import MouvementReserve 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, verifier_version_historisee router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"]) @@ -44,6 +45,7 @@ async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut: recu_par_prenom=recu.prenom if recu else None, date_reception=c.date_reception, cree_le=c.cree_le, + modifie_le=c.modifie_le, ) @@ -123,6 +125,7 @@ async def saisir_collecte( async def ajuster_collecte( collecte_id: str, req: CollecteUpdate, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): @@ -131,16 +134,26 @@ async def ajuster_collecte( select(Collecte).where( Collecte.id == collecte_id, Collecte.groupe_id == membre.groupe_id, - ) + ).with_for_update() ) collecte = result.scalar_one_or_none() if not collecte: raise HTTPException(status_code=404, detail="Collecte introuvable") + 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") if req.montant is not None: collecte.montant = req.montant + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="collectes", + objet_id=collecte.id, + action="collecte.modifiee", + raison="Montant de collecte ajusté avant confirmation.", + membre_id=membre.id, + ) await db.flush() return await _to_out(collecte, db) @@ -148,6 +161,7 @@ async def ajuster_collecte( @router.post("/{collecte_id}/confirmer", response_model=CollecteOut) async def confirmer_collecte( collecte_id: str, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): @@ -156,11 +170,12 @@ async def confirmer_collecte( select(Collecte).where( Collecte.id == collecte_id, Collecte.groupe_id == membre.groupe_id, - ) + ).with_for_update() ) collecte = result.scalar_one_or_none() if not collecte: raise HTTPException(status_code=404, detail="Collecte introuvable") + await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes") if collecte.statut != "en_attente": raise HTTPException(status_code=400, detail="Cette collecte a déjà été confirmée") if collecte.saisi_par == membre.id: @@ -181,6 +196,15 @@ async def confirmer_collecte( db.add(transaction(collecte.groupe_id, tresorier_id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue")) else: collecte.statut = "confirmee" + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="collectes", + objet_id=collecte.id, + action="collecte.confirmee" if collecte.statut == "confirmee" else "collecte.recue", + raison="Collecte confirmée." if collecte.statut == "confirmee" else "Collecte confirmée et reçue par le trésorier.", + membre_id=membre.id, + ) await db.flush() return await _to_out(collecte, db) @@ -189,6 +213,7 @@ async def confirmer_collecte( @router.post("/{collecte_id}/reception", response_model=CollecteOut) async def accuser_reception( collecte_id: str, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): @@ -197,11 +222,12 @@ async def accuser_reception( select(Collecte).where( Collecte.id == collecte_id, Collecte.groupe_id == membre.groupe_id, - ) + ).with_for_update() ) collecte = result.scalar_one_or_none() if not collecte: raise HTTPException(status_code=404, detail="Collecte introuvable") + await verifier_version_historisee(db, collecte, version_attendue, table_cible="collectes") if collecte.statut == "recu": raise HTTPException(status_code=400, detail="Cette collecte a déjà été reçue") @@ -214,6 +240,15 @@ async def accuser_reception( collecte.recu_par = membre.id collecte.date_reception = date.today() db.add(transaction(collecte.groupe_id, membre.id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue")) + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="collectes", + objet_id=collecte.id, + action="collecte.recue", + raison="Collecte reçue par le trésorier.", + membre_id=membre.id, + ) await db.flush() return await _to_out(collecte, db) @@ -301,6 +336,7 @@ async def soldes_tresorerie( "nom": r.nom, "montant": round(montant, 2), "note": r.note, + "modifie_le": r.modifie_le.isoformat(), }) total_reserves_reelles = sum(r["montant"] for r in reserves_details) @@ -322,6 +358,7 @@ async def soldes_tresorerie( "montant": float(c.montant), "detenteur": det.prenom if det else "?", "statut": c.statut, + "modifie_le": c.modifie_le.isoformat(), }) # ── Ventes de littérature non remises ── diff --git a/backend/app/routers/contributions.py b/backend/app/routers/contributions.py index 6400dc2..3980c26 100644 --- a/backend/app/routers/contributions.py +++ b/backend/app/routers/contributions.py @@ -1,6 +1,6 @@ """Routes M4 — Envoi de contributions aux organismes AA.""" from datetime import date -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, func @@ -14,6 +14,7 @@ from app.models.config_repartition import ConfigRepartition from app.models.membre import Membre from app.models.destinataire_contribution import DestinataireContribution from app.services.finance import position_tresorerie, transaction +from app.services.historique import enregistrer_historique, verifier_version_historisee from app.schemas.phase7 import ( EnvoiContributionCreate, EnvoiContributionOut, CalculRepartitionIn, CalculRepartitionOut, @@ -138,18 +139,29 @@ async def creer_destinataire( async def modifier_destinataire( destinataire_id: str, req: DestinataireContributionUpdate, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): dest = await db.get(DestinataireContribution, destinataire_id) if not dest or dest.groupe_id != membre.groupe_id or not dest.actif: raise HTTPException(status_code=404, detail="Destinataire introuvable") + await verifier_version_historisee(db, dest, version_attendue, table_cible="destinataires_contributions") if dest.protege: raise HTTPException(status_code=400, detail="Ce destinataire est protégé") nom = req.nom.strip() if not nom: raise HTTPException(status_code=400, detail="Nom requis") dest.nom = nom + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="destinataires_contributions", + objet_id=dest.id, + action="destinataire.modifie", + raison="Destinataire de contribution renommé.", + membre_id=membre.id, + ) await db.flush() return DestinataireContributionOut.model_validate(dest) @@ -157,15 +169,26 @@ async def modifier_destinataire( @router.delete("/destinataires/{destinataire_id}") async def supprimer_destinataire( destinataire_id: str, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): dest = await db.get(DestinataireContribution, destinataire_id) if not dest or dest.groupe_id != membre.groupe_id or not dest.actif: raise HTTPException(status_code=404, detail="Destinataire introuvable") + await verifier_version_historisee(db, dest, version_attendue, table_cible="destinataires_contributions") if dest.protege: raise HTTPException(status_code=400, detail="Ce destinataire est protégé") dest.actif = False + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="destinataires_contributions", + objet_id=dest.id, + action="destinataire.supprime", + raison="Destinataire de contribution désactivé.", + membre_id=membre.id, + ) await db.flush() return {"message": "Destinataire supprimé"} @@ -260,12 +283,14 @@ async def enregistrer_envoi( debitee=envoi.debitee, saisi_par_prenom=membre.prenom, cree_le=envoi.cree_le, + modifie_le=envoi.modifie_le, ) @router.post("/{envoi_id}/confirmer-debit", response_model=EnvoiContributionOut) async def confirmer_debit_contribution( envoi_id: str, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): @@ -274,11 +299,12 @@ async def confirmer_debit_contribution( select(EnvoiContribution).where( EnvoiContribution.id == envoi_id, EnvoiContribution.groupe_id == membre.groupe_id, - ) + ).with_for_update() ) envoi = result.scalar_one_or_none() if not envoi: raise HTTPException(status_code=404, detail="Envoi introuvable") + await verifier_version_historisee(db, envoi, version_attendue, table_cible="envois_contributions") if envoi.debitee: raise HTTPException(status_code=400, detail="Contribution déjà débitée") @@ -291,6 +317,15 @@ async def confirmer_debit_contribution( envoi.debitee = True envoi.engagee = True db.add(transaction(membre.groupe_id, membre.id, date.today(), "contribution", "banque", envoi.montant, "credit", "envois_contributions", envoi.id, DESTINATIONS_LABELS.get(envoi.destination, envoi.destination))) + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="envois_contributions", + objet_id=envoi.id, + action="contribution.debit_confirme", + raison="Débit bancaire de la contribution confirmé.", + membre_id=membre.id, + ) await db.flush() saisi = await db.get(Membre, envoi.saisi_par) if envoi.saisi_par else None @@ -306,6 +341,7 @@ async def confirmer_debit_contribution( debitee=envoi.debitee, saisi_par_prenom=saisi.prenom if saisi else None, cree_le=envoi.cree_le, + modifie_le=envoi.modifie_le, ) @@ -338,5 +374,6 @@ async def liste_envois( debitee=e.debitee, saisi_par_prenom=saisi.prenom if saisi else None, cree_le=e.cree_le, + modifie_le=e.modifie_le, )) return out diff --git a/backend/app/routers/depenses.py b/backend/app/routers/depenses.py index e14b235..1512075 100644 --- a/backend/app/routers/depenses.py +++ b/backend/app/routers/depenses.py @@ -1,7 +1,7 @@ """Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier).""" import os from datetime import date, datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi import APIRouter, Depends, Header, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc @@ -12,6 +12,7 @@ from app.models.depense import Depense from app.models.membre import Membre from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate from app.services.finance import position_tresorerie, transaction +from app.services.historique import enregistrer_historique, verifier_version_historisee router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"]) @@ -34,6 +35,7 @@ async def _to_out(d: Depense, db: AsyncSession) -> DepenseOut: saisi_par_prenom=saisi.prenom if saisi else None, confirme=d.confirme_par is not None, cree_le=d.cree_le, + modifie_le=d.modifie_le, ) @@ -80,7 +82,7 @@ async def telecharger_preuve( ): """Attacher une preuve (reçu, photo) à une dépense.""" result = await db.execute( - select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) + select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update() ) depense = result.scalar_one_or_none() if not depense: @@ -111,6 +113,7 @@ async def telecharger_preuve( async def confirmer_depense( depense_id: str, methode: str, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): @@ -120,11 +123,12 @@ async def confirmer_depense( raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}") result = await db.execute( - select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) + select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update() ) depense = result.scalar_one_or_none() if not depense: raise HTTPException(status_code=404, detail="Dépense introuvable") + await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses") if depense.confirme_par is not None: raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée") @@ -150,6 +154,15 @@ async def confirmer_depense( db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "encaisse", depense.montant, "credit", "depenses", depense.id, depense.quoi)) # Si CHEQUE/VIREMENT → engagement, en attente de débit + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="depenses", + objet_id=depense.id, + action="depense.traitee", + raison=f"Dépense traitée par {methode_norm.lower()}.", + membre_id=membre.id, + ) await db.flush() return await _to_out(depense, db) @@ -158,16 +171,18 @@ async def confirmer_depense( @router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut) async def confirmer_debit( depense_id: str, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): """Le trésorier confirme que le chèque/virement a été débité du compte.""" result = await db.execute( - select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) + select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update() ) depense = result.scalar_one_or_none() if not depense: raise HTTPException(status_code=404, detail="Dépense introuvable") + await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses") if depense.paye: raise HTTPException(status_code=400, detail="Déjà confirmée comme payée") if depense.methode not in ("CHEQUE", "VIREMENT"): @@ -182,6 +197,15 @@ async def confirmer_debit( depense.paye = True depense.date_confirmation = datetime.now(timezone.utc) db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "banque", depense.montant, "credit", "depenses", depense.id, depense.quoi)) + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="depenses", + objet_id=depense.id, + action="depense.debit_confirme", + raison="Débit bancaire de la dépense confirmé.", + membre_id=membre.id, + ) await db.flush() return await _to_out(depense, db) @@ -189,16 +213,18 @@ async def confirmer_debit( @router.delete("/{depense_id}") async def rejeter_depense( depense_id: str, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): """Rejeter une dépense soumise : elle est supprimée du registre.""" result = await db.execute( - select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) + select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id).with_for_update() ) depense = result.scalar_one_or_none() if not depense: raise HTTPException(status_code=404, detail="Dépense introuvable") + await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses") if depense.confirme_par is not None: raise HTTPException(status_code=400, detail="Seules les dépenses soumises non traitées peuvent être rejetées") @@ -207,6 +233,15 @@ async def rejeter_depense( if os.path.exists(chemin): os.remove(chemin) + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="depenses", + objet_id=depense.id, + action="depense.rejetee", + raison="Dépense soumise rejetée.", + membre_id=membre.id, + ) await db.delete(depense) await db.flush() return {"message": "Dépense rejetée"} @@ -254,6 +289,7 @@ async def depenses_a_confirmer( async def modifier_depense( depense_id: str, req: DepenseUpdate, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): @@ -264,6 +300,7 @@ async def modifier_depense( depense = result.scalar_one_or_none() if not depense: raise HTTPException(status_code=404, detail="Dépense introuvable") + await verifier_version_historisee(db, depense, version_attendue, table_cible="depenses") if req.paye is not None: depense.paye = req.paye @@ -273,6 +310,15 @@ async def modifier_depense( depense.methode = req.methode.upper() if req.quoi is not None: depense.quoi = req.quoi.strip() + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="depenses", + objet_id=depense.id, + action="depense.modifiee", + raison="Dépense modifiée par le trésorier.", + membre_id=membre.id, + ) await db.flush() return await _to_out(depense, db) diff --git a/backend/app/routers/journal.py b/backend/app/routers/journal.py index ad2e04b..ce4fd50 100644 --- a/backend/app/routers/journal.py +++ b/backend/app/routers/journal.py @@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.security import require_executif from app.models.journal_action import JournalAction +from app.models.historique_modification import HistoriqueModification +from app.models.membre import Membre router = APIRouter(prefix="/journal", tags=["Journal"]) @@ -39,3 +41,37 @@ async def journal_actions( } for item in result.scalars().all() ] + + +@router.get("/historique") +async def historique_modifications( + limite: int = 100, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + limite = max(1, min(limite, 500)) + result = await db.execute( + select(HistoriqueModification) + .where(HistoriqueModification.groupe_id == executif.groupe_id) + .order_by(HistoriqueModification.cree_le.desc()) + .limit(limite) + ) + items = result.scalars().all() + lignes = [] + for item in items: + membre = await db.get(Membre, item.membre_id) if item.membre_id else None + lignes.append({ + "id": str(item.id), + "table_cible": item.table_cible, + "objet_id": str(item.objet_id), + "action": item.action, + "raison": item.raison, + "membre_id": str(item.membre_id) if item.membre_id else None, + "membre_prenom": membre.prenom if membre else None, + "admin_id": str(item.admin_id) if item.admin_id else None, + "chemin_api": item.chemin_api, + "avant": item.avant, + "apres": item.apres, + "cree_le": item.cree_le.isoformat(), + }) + return lignes diff --git a/backend/app/routers/litterature.py b/backend/app/routers/litterature.py index d242a97..549af8a 100644 --- a/backend/app/routers/litterature.py +++ b/backend/app/routers/litterature.py @@ -1,5 +1,5 @@ """Routes C4 / M5 — Littérature : inventaire, ajustement, gestion catalogue.""" -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -10,6 +10,7 @@ from app.models.litterature import Litterature from app.models.affectation import Affectation from app.models.poste import Poste from app.schemas.phase6 import LitteratureCreate, LitteratureOut, LitteratureUpdate, AjustementStock +from app.services.historique import enregistrer_historique, verifier_version_historisee router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"]) @@ -40,6 +41,7 @@ def _to_out(item: Litterature) -> LitteratureOut: seuil_commande=item.seuil_commande, code_bsg=item.code_bsg, stock_bas=item.quantite_stock <= item.seuil_commande, + modifie_le=item.modifie_le, ) @@ -86,6 +88,7 @@ async def ajouter_titre( async def modifier_titre( item_id: str, req: LitteratureUpdate, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_resp_litterature), db: AsyncSession = Depends(get_db), ): @@ -96,9 +99,19 @@ async def modifier_titre( item = result.scalar_one_or_none() if not item: raise HTTPException(status_code=404, detail="Titre introuvable") + await verifier_version_historisee(db, item, version_attendue, table_cible="litterature") for field, value in req.model_dump(exclude_none=True).items(): setattr(item, field, value) + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="litterature", + objet_id=item.id, + action="litterature.modifiee", + raison="Titre de littérature modifié.", + membre_id=membre.id, + ) await db.flush() return _to_out(item) @@ -107,6 +120,7 @@ async def modifier_titre( async def ajuster_stock( item_id: str, req: AjustementStock, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_resp_litterature), db: AsyncSession = Depends(get_db), ): @@ -117,11 +131,24 @@ async def ajuster_stock( item = result.scalar_one_or_none() if not item: raise HTTPException(status_code=404, detail="Titre introuvable") + await verifier_version_historisee(db, item, version_attendue, table_cible="litterature") nouveau_stock = item.quantite_stock + req.quantite if nouveau_stock < 0: raise HTTPException(status_code=400, detail="Le stock ne peut pas être négatif") + ancien_stock = item.quantite_stock item.quantite_stock = nouveau_stock + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="litterature", + objet_id=item.id, + action="litterature.stock_ajuste", + raison=f"Stock de littérature ajusté de {req.quantite}.", + membre_id=membre.id, + avant={"quantite_stock": ancien_stock}, + apres={"quantite_stock": nouveau_stock}, + ) await db.flush() return _to_out(item) diff --git a/backend/app/routers/operations_bancaires.py b/backend/app/routers/operations_bancaires.py index 0867c2c..c750965 100644 --- a/backend/app/routers/operations_bancaires.py +++ b/backend/app/routers/operations_bancaires.py @@ -1,5 +1,5 @@ """Routes — Opérations bancaires (dépôts, retraits).""" -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc @@ -13,6 +13,7 @@ from app.models.reserve import Reserve from app.models.membre import Membre from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut from app.services.finance import position_tresorerie, reserve_disponible, solde_reserve +from app.services.historique import enregistrer_historique, verifier_version_historisee router = APIRouter(prefix="/banque", tags=["Opérations bancaires"]) @@ -121,6 +122,15 @@ async def enregistrer_operation( "debit" if delta > 0 else "credit", abs(delta), )) + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="operations_bancaires", + objet_id=op.id, + action=f"operation.{op.type}", + raison=f"Opération bancaire enregistrée : {op.type}.", + membre_id=membre.id, + ) await db.flush() return OperationBancaireOut( id=op.id, @@ -140,7 +150,7 @@ async def liste_reserves(membre=Depends(get_current_membre), db: AsyncSession = .order_by(Reserve.nom) ) return [ - ReserveOut(id=r.id, nom=r.nom, montant=round(await solde_reserve(r.id, db), 2), note=r.note, active=r.active, cree_le=r.cree_le) + ReserveOut(id=r.id, nom=r.nom, montant=round(await solde_reserve(r.id, db), 2), note=r.note, active=r.active, cree_le=r.cree_le, modifie_le=r.modifie_le) for r in result.scalars().all() ] @@ -186,14 +196,21 @@ async def creer_reserve(req: ReserveCreate, membre=Depends(require_tresorier), d reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=0, note=req.note) db.add(reserve) await db.flush() - return ReserveOut(id=reserve.id, nom=reserve.nom, montant=0, note=reserve.note, active=reserve.active, cree_le=reserve.cree_le) + return ReserveOut(id=reserve.id, nom=reserve.nom, montant=0, note=reserve.note, active=reserve.active, cree_le=reserve.cree_le, modifie_le=reserve.modifie_le) @router.patch("/reserves/{reserve_id}", response_model=ReserveOut) -async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)): +async def modifier_reserve( + reserve_id: str, + req: ReserveUpdate, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): reserve = await db.get(Reserve, reserve_id) if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active: raise HTTPException(status_code=404, detail="Réserve introuvable") + await verifier_version_historisee(db, reserve, version_attendue, table_cible="reserves") ancien_montant = await solde_reserve(reserve.id, db) if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2): raise HTTPException(status_code=400, detail="Modifier le montant d'une réserve via l'action Virement") @@ -204,8 +221,17 @@ async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(r if req.note is not None: reserve.note = req.note.strip() or None reserve.montant = await solde_reserve(reserve.id, db) + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="reserves", + objet_id=reserve.id, + action="reserve.modifiee", + raison="Réserve renommée ou note modifiée.", + membre_id=membre.id, + ) await db.flush() - return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le) + return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le, modifie_le=reserve.modifie_le) @router.get("/reserves/mouvements", response_model=list[ReserveMouvementOut]) @@ -276,19 +302,45 @@ async def transferer_reserve(req: ReserveTransfer, membre=Depends(require_tresor source_id=source.id if source else None, destination_id=destination.id if destination else None, )) + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="mouvements_reserves", + objet_id=source.id if source else (destination.id if destination else membre.groupe_id), + action="reserve.transfert", + raison="Virement entre réserves ou disponible enregistré.", + membre_id=membre.id, + avant={"source_id": req.source_id, "destination_id": req.destination_id}, + apres={"montant": req.montant}, + ) await db.flush() return {"message": "Transfert effectué"} @router.delete("/reserves/{reserve_id}") -async def supprimer_reserve(reserve_id: str, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)): +async def supprimer_reserve( + reserve_id: str, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), + membre=Depends(require_tresorier), + db: AsyncSession = Depends(get_db), +): reserve = await db.get(Reserve, reserve_id) if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active: raise HTTPException(status_code=404, detail="Réserve introuvable") + await verifier_version_historisee(db, reserve, version_attendue, table_cible="reserves") montant = await solde_reserve(reserve.id, db) if round(montant, 2) != 0: raise HTTPException(status_code=400, detail="Une réserve doit être vide avant d'être supprimée") reserve.active = False + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="reserves", + objet_id=reserve.id, + action="reserve.supprimee", + raison="Réserve supprimée après retour à zéro.", + membre_id=membre.id, + ) await db.flush() return {"message": "Réserve supprimée"} diff --git a/backend/app/routers/presences.py b/backend/app/routers/presences.py index 4e048b9..e93ae91 100644 --- a/backend/app/routers/presences.py +++ b/backend/app/routers/presences.py @@ -153,7 +153,7 @@ async def creer_reunion_affaires( ) db.add(reunion) await db.flush() - return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note) + return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note, modifie_le=reunion.modifie_le) @router.get("/reunions", response_model=list[ReunionAffairesOut]) @@ -186,7 +186,7 @@ async def liste_reunions_affaires( present=p.present, )) out.append(ReunionAffairesOut( - id=r.id, date_reunion=r.date_reunion, note=r.note, presences=pres_out, + id=r.id, date_reunion=r.date_reunion, note=r.note, modifie_le=r.modifie_le, presences=pres_out, )) return out diff --git a/backend/app/routers/propositions.py b/backend/app/routers/propositions.py index c448eaf..29afa63 100644 --- a/backend/app/routers/propositions.py +++ b/backend/app/routers/propositions.py @@ -1,6 +1,6 @@ """Routes — Propositions et résolutions (réunions d'affaires).""" from datetime import date, timezone, datetime -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, extract @@ -16,6 +16,7 @@ from app.models.candidature import Candidature from app.models.affectation import Affectation from app.models.poste import Poste from app.schemas.propositions import PropositionCreate, PropositionOut, PropositionVote +from app.services.historique import enregistrer_historique, verifier_version_historisee router = APIRouter(prefix="/propositions", tags=["Propositions et résolutions"]) @@ -41,6 +42,7 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut: note=p.note, candidature_id=p.candidature_id, cree_le=p.cree_le, + modifie_le=p.modifie_le, date_reunion=reunion.date_reunion if reunion else None, ) @@ -79,6 +81,7 @@ async def creer_proposition( async def seconder_proposition( proposition_id: str, membre_id: str | None = None, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): @@ -87,11 +90,12 @@ async def seconder_proposition( select(Proposition).where( Proposition.id == proposition_id, Proposition.groupe_id == membre.groupe_id, - ) + ).with_for_update() ) prop = result.scalar_one_or_none() if not prop: raise HTTPException(status_code=404, detail="Proposition introuvable") + await verifier_version_historisee(db, prop, version_attendue, table_cible="propositions") if prop.statut != "proposee": raise HTTPException(status_code=400, detail="Seule une proposition en attente peut être secondée") @@ -108,6 +112,15 @@ async def seconder_proposition( prop.secondeur_id = secondeur.id prop.statut = "secondee" + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="propositions", + objet_id=prop.id, + action="proposition.secondee", + raison=f"Proposition secondée par {secondeur.prenom}.", + membre_id=membre.id, + ) await db.flush() return await _to_out(prop, db) @@ -116,6 +129,7 @@ async def seconder_proposition( async def voter_proposition( proposition_id: str, req: PropositionVote, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), executif=Depends(require_executif), db: AsyncSession = Depends(get_db), ): @@ -127,11 +141,12 @@ async def voter_proposition( select(Proposition).where( Proposition.id == proposition_id, Proposition.groupe_id == executif.groupe_id, - ) + ).with_for_update() ) prop = result.scalar_one_or_none() if not prop: raise HTTPException(status_code=404, detail="Proposition introuvable") + await verifier_version_historisee(db, prop, version_attendue, table_cible="propositions") if req.statut == "adoptee" and prop.statut != "secondee": raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être transformée en résolution") if req.statut == "rejetee" and prop.statut not in ("proposee", "secondee"): @@ -192,6 +207,15 @@ async def voter_proposition( prop.date_vote = date.today() if req.statut == "adoptee": prop.date_adoption = prop.date_vote + await enregistrer_historique( + db, + groupe_id=executif.groupe_id, + table_cible="propositions", + objet_id=prop.id, + action="proposition.adoptee" if req.statut == "adoptee" else "proposition.rejetee", + raison="Proposition transformée en décision du groupe." if req.statut == "adoptee" else "Proposition rejetée par le groupe.", + membre_id=executif.id, + ) await db.flush() return await _to_out(prop, db) diff --git a/backend/app/routers/pv.py b/backend/app/routers/pv.py index 162d0d3..80aae26 100644 --- a/backend/app/routers/pv.py +++ b/backend/app/routers/pv.py @@ -1,5 +1,5 @@ """Routes M1 — Rédiger un PV de réunion d'affaires.""" -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc @@ -10,6 +10,7 @@ from app.models.pv_reunion import PvReunion 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, verifier_version_historisee router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"]) @@ -25,6 +26,7 @@ async def _to_out(pv: PvReunion, db: AsyncSession) -> PvOut: decisions=pv.decisions, approuve=pv.approuve, cree_le=pv.cree_le, + modifie_le=pv.modifie_le, date_reunion=reunion.date_reunion if reunion else None, ) @@ -102,6 +104,7 @@ async def creer_pv( async def modifier_pv( pv_id: str, req: PvUpdate, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_secretaire), db: AsyncSession = Depends(get_db), ): @@ -114,6 +117,7 @@ async def modifier_pv( pv = result.scalar_one_or_none() if not pv: raise HTTPException(status_code=404, detail="PV introuvable") + await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions") if req.contenu is not None: pv.contenu = req.contenu.strip() @@ -121,6 +125,15 @@ async def modifier_pv( pv.decisions = [d.model_dump() for d in req.decisions] if req.approuve is not None: pv.approuve = req.approuve + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="pv_reunions", + objet_id=pv.id, + action="pv.modifie", + raison="Procès-verbal modifié.", + membre_id=membre.id, + ) await db.flush() return await _to_out(pv, db) @@ -129,6 +142,7 @@ async def modifier_pv( async def adopter_pv( pv_id: str, req: PvAdoption, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_secretaire), db: AsyncSession = Depends(get_db), ): @@ -140,6 +154,7 @@ async def adopter_pv( pv = result.scalar_one_or_none() if not pv: raise HTTPException(status_code=404, detail="PV introuvable") + await verifier_version_historisee(db, pv, version_attendue, table_cible="pv_reunions") if pv.approuve: raise HTTPException(status_code=400, detail="PV déjà adopté") if req.proposeur_id == req.secondeur_id: @@ -169,5 +184,14 @@ async def adopter_pv( }) pv.decisions = decisions pv.approuve = True + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="pv_reunions", + objet_id=pv.id, + action="pv.adopte", + raison="Procès-verbal adopté.", + membre_id=membre.id, + ) await db.flush() return await _to_out(pv, db) diff --git a/backend/app/routers/reunions.py b/backend/app/routers/reunions.py index c2fe8df..80acd87 100644 --- a/backend/app/routers/reunions.py +++ b/backend/app/routers/reunions.py @@ -3,7 +3,7 @@ import io from html import escape from datetime import datetime, timezone from zoneinfo import ZoneInfo -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, extract @@ -20,6 +20,7 @@ from app.models.litterature import Litterature from app.models.vente_jeton import VenteJeton from app.models.vente_litterature import VenteLitterature from app.schemas.phase1 import ReunionCreate, ReunionOut +from app.services.historique import enregistrer_historique, verifier_version_historisee router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"]) @@ -91,6 +92,7 @@ async def creer_rapport_reunion( annonces=reunion.annonces, note=reunion.note, cree_le=reunion.cree_le, + modifie_le=reunion.modifie_le, ) @@ -120,6 +122,7 @@ async def liste_reunions( annonces=r.annonces, note=r.note, cree_le=r.cree_le, + modifie_le=r.modifie_le, )) return out @@ -157,6 +160,7 @@ async def liste_rencontres_details( "montant": float(collecte.montant), "statut": collecte.statut, "detenteur_prenom": detenteur.prenom if detenteur else None, + "modifie_le": collecte.modifie_le, } ventes_lit_result = await db.execute( @@ -220,6 +224,7 @@ async def liste_rencontres_details( "theme": reunion.theme, "annonces": reunion.annonces, "note": reunion.note, + "modifie_le": reunion.modifie_le, "collecte": collecte_out, "ventes_litterature": ventes_litterature, "ventes_jetons": ventes_jetons, @@ -232,6 +237,7 @@ async def liste_rencontres_details( async def modifier_reunion( reunion_id: str, req: ReunionCreate, + version_attendue: str | None = Header(default=None, alias="X-Resource-Version"), membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): @@ -241,6 +247,7 @@ async def modifier_reunion( reunion = result.scalar_one_or_none() if not reunion: raise HTTPException(status_code=404, detail="Réunion introuvable") + await verifier_version_historisee(db, reunion, version_attendue, table_cible="reunions") animateur = None if req.animateur_id: animateur = await db.get(Membre, req.animateur_id) @@ -251,6 +258,15 @@ async def modifier_reunion( reunion.annonces = req.annonces reunion.note = req.note anim = animateur or (await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None) + await enregistrer_historique( + db, + groupe_id=membre.groupe_id, + table_cible="reunions", + objet_id=reunion.id, + action="reunion.modifiee", + raison="Rencontre modifiée.", + membre_id=membre.id, + ) await db.flush() return ReunionOut( id=reunion.id, @@ -261,6 +277,7 @@ async def modifier_reunion( annonces=reunion.annonces, note=reunion.note, cree_le=reunion.cree_le, + modifie_le=reunion.modifie_le, ) @@ -444,4 +461,5 @@ async def detail_reunion( annonces=reunion.annonces, note=reunion.note, cree_le=reunion.cree_le, + modifie_le=reunion.modifie_le, ) diff --git a/backend/app/schemas/phase1.py b/backend/app/schemas/phase1.py index 8b25dbd..9a46747 100644 --- a/backend/app/schemas/phase1.py +++ b/backend/app/schemas/phase1.py @@ -25,6 +25,7 @@ class CollecteOut(BaseModel): recu_par_prenom: Optional[str] = None date_reception: Optional[date] = None cree_le: datetime + modifie_le: datetime model_config = {"from_attributes": True} @@ -93,6 +94,7 @@ class ReserveOut(BaseModel): note: Optional[str] = None active: bool = True cree_le: datetime + modifie_le: datetime model_config = {"from_attributes": True} @@ -136,6 +138,7 @@ class ReunionOut(BaseModel): annonces: Optional[list[str]] = None note: Optional[str] = None cree_le: datetime + modifie_le: datetime model_config = {"from_attributes": True} @@ -162,6 +165,7 @@ class DepenseOut(BaseModel): saisi_par_prenom: Optional[str] = None confirme: bool = False cree_le: datetime + modifie_le: datetime model_config = {"from_attributes": True} diff --git a/backend/app/schemas/phase4.py b/backend/app/schemas/phase4.py index e5cf072..e351bf8 100644 --- a/backend/app/schemas/phase4.py +++ b/backend/app/schemas/phase4.py @@ -32,6 +32,7 @@ class ReunionAffairesOut(BaseModel): id: UUID date_reunion: datetime note: Optional[str] = None + modifie_le: Optional[datetime] = None presences: list[PresenceOut] = [] model_config = {"from_attributes": True} @@ -60,6 +61,7 @@ class PvOut(BaseModel): decisions: Optional[list[dict]] = None approuve: bool cree_le: datetime + modifie_le: datetime date_reunion: Optional[datetime] = None model_config = {"from_attributes": True} diff --git a/backend/app/schemas/phase6.py b/backend/app/schemas/phase6.py index a3d05fb..06fc7b4 100644 --- a/backend/app/schemas/phase6.py +++ b/backend/app/schemas/phase6.py @@ -24,6 +24,7 @@ class LitteratureOut(BaseModel): seuil_commande: int code_bsg: Optional[str] = None stock_bas: bool = False + modifie_le: datetime model_config = {"from_attributes": True} diff --git a/backend/app/schemas/phase7.py b/backend/app/schemas/phase7.py index 74f0d62..2bb0fb8 100644 --- a/backend/app/schemas/phase7.py +++ b/backend/app/schemas/phase7.py @@ -39,6 +39,7 @@ class EnvoiContributionOut(BaseModel): debitee: bool = False saisi_par_prenom: Optional[str] = None cree_le: datetime + modifie_le: datetime model_config = {"from_attributes": True} @@ -68,6 +69,7 @@ class DestinataireContributionOut(BaseModel): nom: str protege: bool = False actif: bool = True + modifie_le: datetime model_config = {"from_attributes": True} diff --git a/backend/app/schemas/propositions.py b/backend/app/schemas/propositions.py index a9cdd56..d7840f8 100644 --- a/backend/app/schemas/propositions.py +++ b/backend/app/schemas/propositions.py @@ -28,6 +28,7 @@ class PropositionOut(BaseModel): note: Optional[str] = None candidature_id: Optional[UUID] = None cree_le: datetime + modifie_le: datetime date_reunion: Optional[datetime] = None model_config = {"from_attributes": True} diff --git a/backend/app/services/historique.py b/backend/app/services/historique.py new file mode 100644 index 0000000..7584130 --- /dev/null +++ b/backend/app/services/historique.py @@ -0,0 +1,82 @@ +"""Service d'historique métier.""" +from fastapi import HTTPException +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.concurrency import version_iso +from app.models.historique_modification import HistoriqueModification +from app.models.membre import Membre + + +async def enregistrer_historique( + db: AsyncSession, + *, + groupe_id, + table_cible: str, + objet_id, + action: str, + raison: str, + membre_id=None, + admin_id=None, + chemin_api: str | None = None, + avant: dict | None = None, + apres: dict | None = None, +) -> HistoriqueModification: + entree = HistoriqueModification( + groupe_id=groupe_id, + table_cible=table_cible, + objet_id=objet_id, + action=action, + raison=raison, + membre_id=membre_id, + admin_id=admin_id, + chemin_api=chemin_api, + avant=avant, + apres=apres, + ) + db.add(entree) + return entree + + +async def dernier_historique(db: AsyncSession, table_cible: str, objet_id) -> HistoriqueModification | None: + result = await db.execute( + select(HistoriqueModification) + .where( + HistoriqueModification.table_cible == table_cible, + HistoriqueModification.objet_id == objet_id, + ) + .order_by(desc(HistoriqueModification.cree_le)) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def verifier_version_historisee( + db: AsyncSession, + obj, + version_attendue: str | None, + *, + table_cible: str, + objet_id=None, +): + if not version_attendue: + return + actuelle = version_iso(obj) + if not actuelle or actuelle.replace("+00:00", "Z") == version_attendue.replace("+00:00", "Z"): + return + + objet = objet_id or getattr(obj, "id", None) + entree = await dernier_historique(db, table_cible, objet) if objet else None + if not entree: + raise HTTPException( + status_code=409, + detail="Cette information a été modifiée depuis son affichage. Rechargez les données avant d'enregistrer.", + ) + + membre = await db.get(Membre, entree.membre_id) if entree.membre_id else None + par = f" par {membre.prenom}" if membre else "" + quand = entree.cree_le.astimezone().strftime("%Y-%m-%d %H:%M") + raise HTTPException( + status_code=409, + detail=f"Cette information a été modifiée{par} le {quand} : {entree.raison}. Rechargez les données avant d'enregistrer.", + ) diff --git a/docs/PRESENTATION_MEMBRES.md b/docs/PRESENTATION_MEMBRES.md new file mode 100644 index 0000000..93dbf74 --- /dev/null +++ b/docs/PRESENTATION_MEMBRES.md @@ -0,0 +1,209 @@ +# Présentation aux membres + +## L'attrait plutôt que la réclame + +Bonjour à toutes et à tous. + +Cette présentation n'a pas pour but de vendre un outil, ni de convaincre qui que ce soit d'aimer l'informatique. Dans AA, nous savons que ce qui dure repose rarement sur la pression. Nous avançons mieux quand les choses sont simples, utiles, et qu'elles servent le groupe. + +L'application que nous vous présentons a été pensée dans cet esprit : aider le groupe à mieux garder sa mémoire, ses décisions, ses responsabilités et sa trésorerie, sans remplacer la conscience de groupe. + +Elle ne décide pas à notre place. Elle ne dirige pas le groupe. Elle ne remplace ni les traditions, ni le bon sens, ni la confiance entre nous. + +Elle sert simplement à mieux soutenir ce que nous faisons déjà. + +## Pourquoi cet outil existe + +Dans un groupe AA, plusieurs petites choses doivent être suivies régulièrement : + +- les rencontres; +- les assemblées d'affaires; +- les présences; +- les procès-verbaux; +- les propositions et décisions; +- les collectes; +- les dépenses; +- les contributions; +- les postes de service; +- la littérature; +- les événements; +- les rapports. + +Quand tout repose sur quelques personnes, des cahiers, des messages texte, des souvenirs ou des fichiers dispersés, il devient facile de perdre le fil. + +L'application vise à réduire cette fragilité. + +Elle aide à ce que l'information du groupe reste disponible, compréhensible et transmissible, même quand les serviteurs changent. + +## Ce que l'application apporte + +Elle apporte surtout de l'ordre. + +Un membre peut consulter les informations utiles au groupe sans avoir à demander à trois personnes différentes. + +Un serviteur peut saisir ce qui concerne son service au bon endroit. + +Un trésorier peut suivre les entrées, les sorties, les réserves et les rapports. + +Un secrétaire peut préparer les assemblées, les présences et les procès-verbaux. + +L'exécutif peut gérer les postes, les accès et les informations du groupe. + +Le groupe peut retrouver ses décisions passées plus facilement. + +## Ce que l'application ne fait pas + +Elle ne remplace pas l'assemblée d'affaires. + +Elle ne remplace pas la discussion. + +Elle ne remplace pas le vote ou le consensus. + +Elle ne remplace pas la responsabilité des serviteurs. + +Elle ne remplace pas la rotation. + +Elle ne remplace pas la confiance. + +Elle donne seulement un endroit commun pour noter ce qui a été fait, ce qui reste à faire, et ce qui a été décidé ensemble. + +## Pour les membres + +Pour un membre, l'utilisation peut rester très simple. + +On peut s'en servir pour : + +- voir les événements; +- consulter la littérature; +- voir les postes à pourvoir; +- proposer sa candidature; +- soumettre une proposition; +- seconder une proposition; +- soumettre une dépense; +- consulter les décisions du groupe; +- consulter les rapports disponibles. + +Personne n'a besoin de tout utiliser. + +Chacun peut commencer par ce qui lui est utile. + +## Pour les serviteurs + +Pour les serviteurs, l'application peut alléger la tâche. + +Elle aide à garder les informations au même endroit : + +- rencontre hebdomadaire; +- collecte; +- ventes; +- jetons et gâteaux; +- assemblée d'affaires; +- présences; +- procès-verbal; +- rapport du trésorier; +- décisions; +- postes à pourvoir. + +L'idée n'est pas d'ajouter du travail, mais de rendre le travail plus clair et plus facile à transmettre. + +## Pour la trésorerie + +La trésorerie est souvent un point sensible, non parce que les gens manquent de bonne volonté, mais parce que l'argent demande de la clarté. + +L'application aide à suivre : + +- l'encaisse; +- le solde bancaire; +- les collectes; +- les dépenses; +- les contributions; +- les réserves; +- les montants engagés; +- le registre; +- l'état des résultats; +- le rapport mensuel. + +Le but est simple : que le groupe puisse comprendre où il en est, sans dépendre seulement de la mémoire d'une personne. + +## Pour les assemblées d'affaires + +Une assemblée d'affaires peut être mieux préparée. + +L'application rassemble : + +- l'ordre du jour; +- le procès-verbal précédent; +- les présences; +- le rapport du trésorier; +- les propositions en cours; +- les décisions prises; +- les contributions; +- les postes à pourvoir. + +Le rapport PDF permet d'imprimer ce qui est nécessaire. Si la technologie fait défaut pendant la réunion, le groupe peut continuer avec le document papier. + +## Respect de l'esprit AA + +L'application a été pensée pour rester au service du groupe. + +Elle favorise : + +- la mémoire collective; +- la transparence; +- la rotation; +- la responsabilité; +- la simplicité; +- l'autonomie du groupe. + +Elle ne cherche pas à faire entrer le groupe dans un moule. Elle doit plutôt s'adapter aux pratiques du groupe, tant que ces pratiques restent cohérentes et compréhensibles. + +## Confidentialité et prudence + +L'application contient des informations du groupe. Elle doit donc être utilisée avec prudence. + +Quelques habitudes simples sont importantes : + +- utiliser son propre compte; +- ne pas partager son PIN; +- se déconnecter sur un appareil partagé; +- éviter d'inscrire des informations inutilement personnelles; +- respecter l'anonymat; +- limiter les accès selon les responsabilités réelles. + +Le principe reste le même : assez d'information pour bien servir le groupe, pas plus que nécessaire. + +## Comment commencer + +Il n'est pas nécessaire que tout le monde maîtrise tout dès le premier jour. + +Une approche simple serait : + +1. Les membres se connectent et consultent l'accueil. +2. Les serviteurs utilisent les modules liés à leur poste. +3. Le trésorier utilise la trésorerie. +4. Le secrétaire utilise les assemblées. +5. Le groupe ajuste les permissions et les habitudes au fil de l'usage. + +L'outil peut grandir dans le groupe tranquillement, par l'utilité qu'il démontre. + +## Ce que nous demandons aux membres + +Nous ne demandons pas aux membres d'adopter une nouveauté par enthousiasme. + +Nous demandons seulement de l'essayer avec ouverture. + +Si l'application aide, elle trouvera naturellement sa place. + +Si quelque chose complique le service au lieu de le simplifier, il faudra le corriger. + +L'application doit rester un outil de service. + +## Conclusion + +Dans AA, nous avons appris que les choses les plus utiles sont souvent les plus simples. + +Cette application ne prétend pas être importante. Le groupe est important. Les membres sont importants. Le message est important. Le service est important. + +Si cet outil peut aider le groupe à mieux se souvenir, mieux se préparer, mieux rendre compte et mieux transmettre les responsabilités, alors il aura rempli son rôle. + +Nous pourrons ensuite laisser l'expérience parler d'elle-même. diff --git a/docs/UTILISATEURS.md b/docs/UTILISATEURS.md index 7a9d0a2..875bb21 100644 --- a/docs/UTILISATEURS.md +++ b/docs/UTILISATEURS.md @@ -1,417 +1,655 @@ -# Guide utilisateur +# Manuel de l'utilisateur -Application du Groupe Méditation pour les membres, les serviteurs et l'exécutif du groupe. +Application du Groupe Méditation, pour les membres AA, les serviteurs et les membres de l'exécutif. -Ce guide explique quoi faire dans l'application, sans détails techniques. +Ce manuel explique les processus de l'application en langage d'usage. Il ne remplace pas les traditions, les procédures du groupe ou la conscience de groupe; il décrit comment l'application aide à les appliquer. -## 1. Accéder à l'application +## 1. Principes généraux + +L'application sert à garder ensemble les informations courantes du groupe : + +- les rencontres hebdomadaires; +- les assemblées d'affaires; +- les propositions, décisions, nominations et contributions; +- les collectes, dépenses, réserves et rapports de trésorerie; +- les membres, postes, mandats et permissions; +- la littérature, les jetons, les événements et les rapports. + +Chaque membre voit les sections auxquelles il a accès selon ses postes actifs. Un membre peut consulter plusieurs informations du groupe. Les actions qui modifient la vie du groupe ou les finances sont réservées aux membres autorisés, généralement les postes exécutifs. + +Le compte `Sysadmin` sert à l'administration initiale et aux interventions exceptionnelles. Il a tous les accès. + +## 2. Connexion et profil ### Se connecter -1. Ouvrir l'application dans le navigateur. -2. Choisir le groupe si plusieurs groupes sont affichés. -3. Entrer son courriel. -4. Entrer son PIN à 4 chiffres. +1. Ouvrir l'application. +2. Choisir le groupe si l'application en présente plusieurs. +3. Saisir son identifiant ou courriel. +4. Saisir son PIN à 4 chiffres. 5. Cocher `Se souvenir de moi` seulement sur un appareil personnel. 6. Appuyer sur `Connexion`. -Si `Se souvenir de moi` n'est pas coché, la session reste limitée à l'onglet ou à la session du navigateur. - -### Créer son compte - -Un nouveau membre doit avoir un code d'invitation. - -1. Sur l'écran de connexion, choisir `J'ai un code d'invitation`. -2. Entrer le code reçu. -3. Entrer les informations demandées. -4. Choisir un PIN à 4 chiffres. -5. Valider l'inscription. - -Le code d'invitation ne peut être utilisé qu'une seule fois et expire automatiquement. +Si la connexion échoue, vérifier l'identifiant, le PIN et le groupe sélectionné. En navigation privée, la session ne réutilise pas les anciens caches ou anciennes données du navigateur. ### Se déconnecter -Depuis l'accueil, appuyer sur `Déconnexion`. +Le bouton `Déconnexion` est disponible dans l'entête des pages. Sur un appareil partagé, il faut toujours se déconnecter. -## 2. Page d'accueil +### Gérer son profil -L'accueil affiche les modules disponibles pour le membre connecté. - -Les modules sont regroupés en trois sections : - -- `Mes tâches` : saisies et actions liées au poste du membre. -- `Consultation` : modules accessibles à tous les membres. -- `Gestion` : modules réservés à l'exécutif ou aux postes autorisés. - -Les accès dépendent des postes actifs du membre. Ils peuvent être ajustés dans `Gestion des postes`. - -## 3. Modules de consultation - -### Rapports PDF - -Permet de consulter ou produire les rapports du groupe : - -- finances; -- procès-verbaux; -- résolutions; -- rapports disponibles selon les données enregistrées. - -### Événements - -Affiche les événements du groupe et du district : - -- anniversaires de sobriété; -- événements créés par l'exécutif; -- résolutions adoptées. - -Les événements passés et futurs sont affichés. Les filtres peuvent être combinés : anniversaires, événements, résolutions. - -Les membres de l'exécutif peuvent créer un événement. - -### Littérature - -Permet de consulter l'inventaire de littérature : - -- titres disponibles; -- prix; -- quantités; -- alertes de stock bas. - -### Gouvernance - -Permet de suivre les propositions non terminées, de soumettre une nouvelle proposition, de seconder une proposition non secondée, et de consulter l'historique des résolutions. - -Une proposition n'a pas de type lors de sa soumission. Les membres de l'exécutif peuvent transformer une proposition secondée en résolution, ou rejeter une proposition en cours. L'application exige alors de remplir les sections « ÉTANT DONNÉ QUE » et « LE GROUPE A DÉCIDÉ DE ». - -### Mon profil - -Permet de modifier : +Le module `Profil` permet de modifier ses informations personnelles : - prénom; - nom; - téléphone; -- courriel; +- courriel ou identifiant; - date d'abstinence; - PIN. -Un courriel déjà utilisé dans le même groupe ne peut pas être repris. +Un membre peut aussi choisir les types de changements pour lesquels il souhaite être notifié, lorsque les notifications sont disponibles sur son appareil. -Une proposition suit normalement ce cycle : +### Notifications -1. proposée; -2. secondée; -3. transformée en résolution par l'exécutif. +Les notifications push sont optionnelles. Le membre choisit les catégories de changements qui l'intéressent depuis son profil. Les notifications dépendent aussi du navigateur, du téléphone et de l'autorisation donnée par l'utilisateur. -Une proposition adoptée devient une résolution numérotée. +## 3. Navigation -### Postes à pourvoir +L'application est organisée par contextes. -Permet de consulter les postes vacants et de proposer sa candidature. +### Accueil -Si un membre est nominé à un poste, il peut accepter ou refuser la nomination. +La page `Accueil` regroupe ce qu'un membre consulte souvent : -### Trésorerie +- choses à faire maintenant; +- état général du groupe; +- événements; +- littérature; +- profil; +- modules de consultation. -La page d'accueil du module affiche l'état des résultats : revenus, charges et excédent ou déficit pour un mois donné. -Le bouton **Télécharger le PDF** génère le rapport mensuel à présenter au groupe. +### Rencontres -L'écran affiche aussi la position courante de trésorerie : +La page `Rencontres` sert aux activités de la rencontre hebdomadaire. Les rencontres sont présentées sous forme de tuiles. Une tuile fermée montre l'essentiel : date, heure, sujet et animation. En l'ouvrant, les boutons liés à cette rencontre apparaissent. -- solde bancaire; -- encaisse; -- montant disponible; -- montants engagés; -- réserves; -- dernières collectes et dépenses. +### Assemblées -Les membres peuvent consulter l'état des résultats et le registre. Les boutons qui modifient les données de trésorerie sont réservés aux postes exécutifs. +La page `Assemblées` sert aux assemblées d'affaires. Les assemblées sont aussi présentées sous forme de tuiles. Chaque assemblée regroupe les présences, l'ordre du jour, le rapport du trésorier, les décisions, les contributions, les postes à pourvoir et le rapport PDF. -## 4. Modules de saisie +### Gestion -### Rencontre hebdomadaire +La page `Gestion` regroupe les modules d'administration : -Formulaire regroupé pour les données de la réunion régulière : thème, annonces, collecte, jeton et vente de littérature. Les sections non remplies sont ignorées. +- gestion des membres; +- gestion des postes; +- gestion des mandats; +- paramètres du groupe; +- trésorerie; +- inventaire; +- journal. -### Collecte 7e Tradition +Les boutons visibles dépendent des permissions du membre. -Ce module est réservé aux postes exécutifs. +## 4. Rencontres hebdomadaires -Permet de saisir une collecte. +### Planifier une rencontre -Règles importantes : +Un membre de l'exécutif peut planifier une rencontre. -- la collecte doit être associée à une date de réunion; -- il ne peut pas y avoir deux collectes pour la même date; -- un autre membre doit confirmer le montant; -- le trésorier doit accuser réception si la collecte n'est pas déjà reçue automatiquement. +1. Aller à `Rencontres`. +2. Appuyer sur `Planifier une rencontre`. +3. Choisir une date permise. +4. Renseigner le sujet, l'animateur et les notes utiles. +5. Créer la rencontre. -Le module offre aussi une liste des collectes existantes. +Les dates offertes respectent le jour de réunion configuré dans les paramètres du groupe. Les dates sont présentées de manière à éviter de choisir accidentellement une mauvaise année. -### Dépense +### Modifier une rencontre -Permet à un membre de soumettre une dépense en son nom. Un membre de l'exécutif peut aussi soumettre une dépense au nom d'un autre membre. +Un membre de l'exécutif peut ouvrir une tuile de rencontre et utiliser l'action de modification pour corriger les informations de la rencontre. -La date de la dépense est la date de soumission. +### Saisir la collecte -La dépense n'est pas considérée comme payée tant que le trésorier ne l'a pas confirmée. +Chaque rencontre peut avoir une seule collecte. -Le trésorier choisit la méthode : +1. Ouvrir la tuile de la rencontre. +2. Appuyer sur `Collecte`. +3. Saisir le montant. +4. Indiquer qui détient l'argent. +5. Enregistrer. -- `COLLECTE` : payé depuis l'encaisse; -- `CHEQUE` ou `VIREMENT` : engagement à confirmer lorsque le débit apparaît. +La collecte suit ensuite son processus de confirmation et de réception par le trésorier. Lorsque le trésorier accuse réception, la transaction utilise la date de la collecte. -Une dépense soumise peut aussi être rejetée. Dans ce cas, elle est supprimée. +### Saisir une vente -## 5. Trésorerie +Les ventes de littérature et les ventes de jetons sont saisies depuis la rencontre. -Module réservé au trésorier ou aux postes autorisés. +1. Ouvrir la tuile de la rencontre. +2. Appuyer sur `Ventes`. +3. Choisir le type de vente. +4. Renseigner l'article, la quantité, le montant et le membre qui détient l'argent. +5. Enregistrer. -### Soldes +L'argent d'une vente est traité comme l'argent d'une collecte : il doit être remis au trésorier avant d'entrer dans l'encaisse. -Affiche : +### Jetons et gâteaux -- solde en banque; -- encaisse; -- montant disponible; -- réserves; -- engagements non débités; -- argent encore en circulation. +Les remises de jetons et les gâteaux servent aux statistiques et à la mémoire du groupe. Elles ne retirent pas automatiquement de jetons de l'inventaire. Seules les ventes de jetons affectent l'inventaire. -### Réceptions +### Rapport PDF de rencontre -Permet de traiter : +Chaque tuile de rencontre offre un bouton `Rapport PDF`. Le rapport est destiné à être imprimé au format lettre. Il rassemble les informations associées à la rencontre. -- collectes à recevoir; -- ventes de littérature remises au trésorier; -- contributions à confirmer comme débitées. +## 5. Assemblées d'affaires -### Registre +### Planifier une assemblée -Affiche les transactions financières. +Un membre de l'exécutif peut planifier une assemblée d'affaires. -On peut filtrer par : +1. Aller à `Assemblées`. +2. Appuyer sur `Planifier une assemblée`. +3. Choisir une date permise. +4. Ajouter une note si nécessaire. +5. Créer l'assemblée. -- mois; -- année; -- type de transaction. +Les dates offertes respectent les paramètres du groupe : jour de réunion et rang de l'assemblée mensuelle, par exemple première, deuxième, troisième, quatrième ou dernière réunion du mois. -Types disponibles : +### Comprendre la tuile d'assemblée -- collectes; -- ventes de littérature; -- dépenses; -- contributions; -- dépôts bancaires; -- retraits bancaires. +Une tuile d'assemblée contient le résumé de l'assemblée. En l'ouvrant, les sections de travail apparaissent : -### Réserves et opérations +- `Invitation`; +- `PV précédent`; +- `Présences`; +- `Rapport trésorier`; +- `Donner au suivant`; +- `Postes à pourvoir`; +- `Décisions`. -Dans l'onglet **Opérations**, il est possible de créer, modifier et supprimer les réserves. +Le bouton `Rapport PDF` produit le procès-verbal et le rapport imprimable de l'assemblée. Le rapport PDF est le document officiel à consulter ou imprimer. -Règle importante : le total des réserves ne peut jamais dépasser le solde en banque. +### Invitation à l'assemblée -Permet d'enregistrer : +L'application peut préparer un texte d'invitation à copier-coller. Ce texte contient un lien invité donnant accès à la page de l'assemblée en lecture seule. -- un dépôt provenant de l'encaisse; -- un don déposé directement à la banque; -- un retrait : transfert de la banque vers l'encaisse; -- un virement entre réserves. +Le lien invité : -Un dépôt provenant de l'encaisse ne peut pas dépasser l'encaisse disponible. Un don augmente directement le solde bancaire et apparaît dans les revenus de l'état des résultats. - -## 6. Envoi contributions - -Module réservé au trésorier. - -Permet d'enregistrer une contribution vers : - -- district; -- région; -- BSG; -- intergroupe. - -Le module demande : - -- destination; -- montant; -- méthode; -- proposeur; -- secondeur. - -Lors de l'enregistrement, l'application crée une résolution de type `contribution`. - -La contribution ne modifie pas les soldes immédiatement. Elle doit être confirmée comme débitée dans `Trésorerie` pour que le solde bancaire soit mis à jour. - -## 7. Procès-verbal, présences et rapport RSG - -### Assemblée d'affaires - -Formulaire regroupé pour créer ou sélectionner une assemblée, noter les présences et rédiger le procès-verbal. - -### Rédiger PV - -Permet de préparer un procès-verbal de réunion d'affaires. - -Les propositions secondées et les décisions peuvent alimenter le contenu du PV. +- ne demande pas d'authentification; +- est contrôlé par une période de validité; +- ne montre pas de lien BBB permanent; +- sert à consulter l'ordre du jour ou les informations de l'assemblée. ### Présences -Permet de noter les membres présents à une réunion d'affaires. +Les présences d'assemblée sont saisies dans la section `Présences`. -### Rapport RSG +1. Ouvrir la tuile de l'assemblée. +2. Appuyer sur `Présences`. +3. Cocher les membres présents. +4. Enregistrer. -Permet au RSG ou au RSG substitut de saisir un rapport destiné au groupe. +Une nouvelle saisie remplace la précédente pour cette assemblée. -## 8. Littérature et jetons +### Procès-verbal précédent -### Vente littérature +Le procès-verbal précédent peut être consulté depuis l'assemblée courante. S'il n'est pas encore adopté, un exécutif peut le corriger puis l'adopter. -Permet d'enregistrer une vente. +L'adoption demande : -Quand une vente est enregistrée : - -- le stock est ajusté; -- le montant devient à remettre au trésorier; -- le trésorier confirme ensuite la réception dans `Trésorerie`. - -### Ajuster inventaire - -Permet au responsable de la littérature d'ajuster les quantités ou d'ajouter des éléments. - -### Remise jeton - -Permet d'enregistrer une remise de jeton. - -### Commander jetons - -Permet de préparer ou suivre les commandes de jetons. - -## 9. Gestion des membres - -Module réservé à l'exécutif ou aux postes autorisés. - -Permet de : - -- voir les membres actifs; -- consulter une fiche membre; -- générer un code d'invitation; -- copier ou partager le code; -- désactiver un membre; -- appliquer le droit à l'oubli. - -### Désactiver un membre - -Le membre ne peut plus se connecter, mais son historique demeure. - -### Droit à l'oubli - -Les données personnelles du membre sont anonymisées. Cette action est irréversible. - -## 10. Gestion des mandats - -Regroupe les actions liées aux mandats : - -- affecter un poste; -- terminer un mandat; -- planifier les rotations. - -### Affecter un poste - -Une affectation demande : - -- le membre; -- le poste; -- la date de début; - un proposeur; -- un secondeur. +- un secondeur; +- le contenu final corrigé au besoin. -Une affectation crée automatiquement une résolution adoptée de type `nomination`. +L'adoption se fait en une seule étape avec le bouton `Adopter`. -### Terminer un mandat +### Rapport du trésorier -Permet de fermer un mandat actif. +Avant de décider les contributions, le groupe doit adopter le rapport du trésorier. -La fin d'un mandat n'est pas automatique. Elle doit être enregistrée manuellement. +1. Ouvrir la tuile de l'assemblée. +2. Appuyer sur `Rapport trésorier`. +3. Choisir un proposeur. +4. Choisir un secondeur. +5. Ajouter une correction ou une note si nécessaire. +6. Appuyer sur `Adopter le rapport`. -## 11. Gestion des postes +Une fois adopté, le rapport est cristallisé : il devient l'historique consultable et imprimable de la période concernée. -Permet de : +### Donner au suivant -- créer des postes; -- abolir des postes; -- gérer les candidatures; -- contrôler les accès aux modules selon les postes. +Les contributions se décident pendant l'assemblée d'affaires. -Les accès ne sont pas accordés directement aux personnes : ils passent par les postes. +1. Le rapport du trésorier doit d'abord être adopté. +2. Ouvrir la section `Donner au suivant`. +3. Choisir le destinataire. +4. Saisir le montant. +5. Choisir la méthode, par exemple chèque ou virement. +6. Choisir le proposeur et le secondeur. +7. Créer l'envoi. -## 12. Paramètres du groupe +L'application crée alors une décision de type `Contribution`. La contribution devient un engagement financier. Elle ne modifie pas le solde bancaire tant que le trésorier ne confirme pas le débit dans la trésorerie. -Permet de modifier les informations générales : +Le destinataire `District 87-16` existe par défaut et ne peut pas être supprimé. + +### Postes à pourvoir pendant l'assemblée + +La section `Postes à pourvoir` affiche les postes vacants. + +Pour un poste vacant, un membre peut : + +- postuler lui-même; +- proposer un autre membre, si l'interface lui en donne le droit. + +Une nomination exige une décision du groupe. Elle demande un proposeur et un secondeur. Une nomination adoptée reçoit un numéro de type `NOM`. + +### Décisions pendant l'assemblée + +La section `Décisions` permet de travailler les propositions. + +Un membre peut soumettre une proposition avec un objet et des détails. Une proposition n'a pas de type au départ. + +Le cycle normal est : + +1. proposition soumise; +2. proposition secondée; +3. adoption ou rejet par l'exécutif selon la décision du groupe. + +Pour adopter une proposition comme résolution, l'exécutif doit renseigner : + +- `ÉTANT DONNÉ QUE`; +- `LE GROUPE A DÉCIDÉ DE`. + +Pour rejeter une proposition, l'exécutif doit aussi renseigner `ÉTANT DONNÉ QUE`, afin de conserver la raison historique du rejet. + +Une résolution adoptée reçoit un numéro de type `RES`. + +## 6. Gouvernance et décisions + +Le module `Gouvernance` présente les décisions du groupe. + +### Soumettre une proposition + +1. Aller dans `Gouvernance` ou dans la section `Décisions` d'une assemblée. +2. Saisir l'objet de la proposition. +3. Ajouter les détails nécessaires. +4. Soumettre. + +L'objet est obligatoire. Les détails servent à comprendre l'intention. + +### Seconder une proposition + +Un membre peut appuyer une proposition avec le bouton `Je seconde`. Un exécutif peut aussi seconder pour un autre membre depuis une liste déroulante. + +Un membre ne doit normalement pas seconder sa propre proposition. Le compte `Sysadmin` peut le faire pour des interventions administratives ou de démonstration. + +### Transformer en décision + +Une proposition secondée peut devenir une décision du groupe. Les types de décisions sont : + +- `Résolution`, préfixe `RES`; +- `Nomination`, préfixe `NOM`; +- `Contribution`, préfixe `DON`. + +Les nominations et contributions sont des décisions du groupe avec leurs propres processus. + +### Historique + +L'historique des résolutions permet de consulter les décisions adoptées et leurs détails. Les rapports PDF de résolutions présentent les décisions prises en groupe. + +## 7. Trésorerie + +Le module `Trésorerie` est consultable par les membres selon les permissions. Les actions de modification sont réservées au trésorier ou aux exécutifs. + +### Page d'accueil de la trésorerie + +La page d'accueil présente : + +- positions de trésorerie; +- réserves; +- éléments à traiter. + +Les positions de trésorerie incluent : + +- solde bancaire; +- encaisse; +- disponible; +- engagements; +- réserves. + +Le montant de l'encaisse est considéré disponible. Le solde disponible n'est pas une réserve : il correspond au solde bancaire moins les réserves et engagements. + +### Résultats + +La page `Résultats` présente l'état des résultats pour un mois choisi : + +- revenus; +- charges; +- excédent ou déficit. + +Le bouton `Rapport PDF` produit le rapport mensuel. Le rapport PDF utilise l'état des résultats du mois précédent et les positions de trésorerie en date du dernier jour du mois précédent. + +### Registre + +Le registre affiche les transactions, les plus récentes d'abord. Il peut être filtré par mois, année et type de transaction. + +Le registre permet de suivre : + +- collectes reçues; +- ventes reçues; +- dépenses payées; +- contributions débitées; +- dépôts; +- retraits; +- virements de réserves. + +### Éléments à traiter + +La section `À traiter` regroupe les mouvements qui attendent une action. + +On peut y trouver : + +- dépenses soumises; +- dépenses bancaires en attente de débit; +- contributions en attente de débit; +- collectes à recevoir; +- ventes à recevoir. + +Tous les membres autorisés peuvent voir ces éléments. Seuls les exécutifs ou le trésorier peuvent confirmer les mouvements. + +### Dépenses + +Un membre peut soumettre une dépense en son nom. + +Un exécutif peut soumettre une dépense au nom d'un autre membre. + +La date normale est la date de soumission. Le compte `Sysadmin` peut saisir une date antérieure pour inscrire des informations à posteriori. + +Le processus est : + +1. le membre soumet la dépense; +2. le trésorier ou l'exécutif la traite; +3. si elle est payée en encaisse, l'encaisse est ajustée immédiatement; +4. si elle est payée par chèque ou virement, elle devient un engagement; +5. le débit bancaire est confirmé plus tard quand il apparaît; +6. la dépense peut être rejetée tant qu'elle n'est pas traitée. + +Une dépense bancaire ne peut pas être acceptée si le solde disponible ne le permet pas. + +### Collectes et ventes reçues + +Quand le trésorier reçoit l'argent d'une collecte ou d'une vente, il accuse réception. L'argent entre alors dans l'encaisse. + +Pour une collecte, la date de transaction est la date de la collecte. + +### Dépôts et dons + +Un dépôt peut provenir de deux sources : + +- l'encaisse; +- un don direct au compte bancaire. + +Un dépôt depuis l'encaisse déplace l'argent de l'encaisse vers la banque. Un don direct augmente la banque sans diminuer l'encaisse. + +### Retraits + +Un retrait déplace de l'argent de la banque vers l'encaisse. Il ne doit pas rendre les réserves impossibles à couvrir. + +### Réserves + +Une réserve représente une partie du solde bancaire affectée à un usage précis. + +Règles importantes : + +- une réserve se crée à zéro; +- son montant ne se modifie pas directement; +- les changements passent par un virement; +- une réserve ne peut être supprimée que si son solde est zéro; +- le total des réserves ne doit pas dépasser le solde bancaire. + +### Virements entre réserves + +La fonction `Virement` permet de déplacer un montant : + +- du disponible vers une réserve; +- d'une réserve vers le disponible; +- d'une réserve vers une autre réserve. + +Ces mouvements sont virtuels, mais ils apparaissent au registre pour conserver l'historique. + +## 8. Postes, mandats et permissions + +### Postes à pourvoir + +Le module `Postes à pourvoir` affiche les postes disponibles sous forme de tuiles. + +En ouvrant un poste, le membre peut voir : + +- description; +- candidats; +- bouton `Postuler`; +- bouton `Proposer`. + +`Postuler` sert à proposer sa propre candidature. `Proposer` sert à proposer un autre membre. + +### Gestion des postes + +La gestion des postes permet aux exécutifs de définir la structure de service du groupe. + +Pour chaque poste, on peut gérer : - nom; -- district; -- région; -- adresse; +- catégorie; +- description; +- attentes; +- permissions; +- abolition ou réactivation. + +Le bouton `Permissions` permet d'associer ou dissocier les modules accessibles à un poste. Les permissions d'un membre viennent de ses postes actifs. + +### Gestion des mandats + +La gestion des mandats sert à suivre les affectations : + +- qui occupe quel poste; +- depuis quelle date; +- quel mandat est terminé; +- quelles rotations sont prévues. + +Terminer un mandat conserve l'historique. Une nouvelle affectation peut ensuite être créée. + +## 9. Membres + +### Gestion des membres + +Les exécutifs peuvent gérer les membres : + +- consulter les membres actifs; +- créer un compte pour un membre; +- générer des invitations; +- désactiver un membre; +- corriger certaines informations; +- utiliser les actions administratives permises. + +Le compte `Sysadmin` est protégé contre la suppression. + +### Invitations de membres + +Une invitation permet à un nouveau membre de créer son compte. Le code d'invitation expire et ne peut servir qu'une seule fois. + +### Impersonification + +Le compte `Sysadmin` peut impersonifier un membre pour diagnostiquer, corriger ou démontrer une situation. Les actions restent journalisées avec l'information d'impersonification. + +## 10. Littérature et jetons + +### Consulter la littérature + +Le module `Littérature` affiche le catalogue : + +- titre; +- catégorie; +- prix; +- quantité; +- alertes de stock bas. + +### Gérer l'inventaire + +Le responsable de la littérature ou l'exécutif peut : + +- ajouter un titre; +- modifier un titre; +- ajuster les quantités; +- suivre les stocks. + +L'inventaire initial peut être chargé depuis le fichier de prix prévu au déploiement. + +### Ventes de littérature + +Les ventes sont saisies lors d'une rencontre. Une vente diminue l'inventaire et crée un montant à remettre au trésorier. Lorsque le trésorier reçoit l'argent, l'encaisse est mise à jour. + +### Jetons + +Les remises de jetons servent aux statistiques. Les ventes de jetons affectent l'inventaire. Les commandes suggérées s'appuient sur l'utilisation récente. + +## 11. Événements et calendrier + +Le module `Événements` affiche : + +- anniversaires de sobriété; +- événements créés par le groupe; +- événements passés et futurs. + +Les filtres peuvent être combinés. Les exécutifs peuvent créer ou supprimer des événements. + +Le calendrier permet aussi de consulter certains éléments liés aux serviteurs et au district, selon les modules disponibles. + +## 12. Rapports PDF + +Les rapports PDF servent à imprimer ou archiver les informations du groupe. + +Les rapports importants sont : + +- rapport de rencontre; +- rapport d'assemblée d'affaires; +- rapport de trésorerie; +- registre des résolutions; +- rapports disponibles selon les modules actifs. + +Le rapport d'assemblée d'affaires tient lieu de procès-verbal imprimable. Il reprend les intrants de l'assemblée. + +Le rapport de trésorerie présente les résultats du mois précédent et les positions de trésorerie à la fin de ce mois. + +## 13. Journal + +Le journal conserve les actions modificatrices faites dans l'application. + +Il aide à répondre aux questions : + +- qui a fait quoi; +- quand l'action a été faite; +- depuis quel chemin de l'application; +- avec quel résultat technique. + +Le journal présente deux niveaux : l'historique métier, qui explique les changements importants en langage courant, et le journal technique, qui montre les appels API enregistrés. + +Les consultations simples ne sont pas journalisées comme actions modificatrices. Le journal est réservé aux exécutifs. + +## 14. Modifications simultanées + +Il peut arriver que deux membres exécutifs travaillent sur la même information en même temps. + +Pour éviter qu'une personne écrase sans le savoir le travail d'une autre, l'application vérifie la version de certaines données sensibles au moment de l'enregistrement : + +- décisions et propositions; +- procès-verbaux; +- collectes; +- dépenses; +- contributions à débiter; +- réserves; +- destinataires de contributions; +- rencontres; +- inventaire de littérature. + +Si une donnée a changé pendant qu'elle était ouverte à l'écran, l'application refuse l'enregistrement et demande de recharger les données. Dans ce cas, il faut revenir à la liste ou recharger la page, relire l'information à jour, puis refaire l'action si elle est encore pertinente. + +Le journal permet ensuite de voir qui a effectué les changements. + +## 15. Paramètres du groupe + +Les paramètres du groupe définissent notamment : + +- nom du groupe; +- coordonnées; - jour de réunion; -- heure; -- format. +- heure de réunion; +- rang de l'assemblée d'affaires mensuelle. -## 13. Bonnes pratiques +Ces paramètres influencent les dates offertes dans les rencontres, assemblées et collectes. Une mauvaise configuration peut rendre certaines dates indisponibles. -- Utiliser son propre compte, jamais celui d'un autre membre. -- Ne pas partager son PIN. +## 16. Données de démonstration et remise à zéro + +Le compte `Sysadmin` dispose d'actions spéciales pour l'administration : + +- remettre la base de données à zéro; +- initialiser une base fictive de démonstration. + +Ces actions effacent les données courantes. Elles sont prévues pour les tests, les démonstrations ou les environnements non productifs. Elles ne doivent pas être utilisées sur une base en production sans décision explicite. + +## 17. Bonnes pratiques + +- Utiliser l'application sur un appareil personnel lorsque possible. - Se déconnecter sur un appareil partagé. -- Saisir les collectes et dépenses dès que possible. -- Confirmer les débits seulement lorsque l'argent est réellement sorti du compte. -- Enregistrer les dépôts quand l'encaisse est réellement déposée à la banque. -- Garder les réserves à jour. -- Utiliser les propositions et résolutions pour conserver une trace claire des décisions. +- Vérifier la date avant de saisir une rencontre, une assemblée ou une collecte. +- Utiliser les rapports PDF pour les documents imprimés. +- Ne pas contourner la conscience de groupe : les décisions importantes doivent passer par proposition, secondeur et adoption. +- Saisir les remises d'argent dès que possible afin que l'encaisse et les rapports restent fiables. +- Garder les postes et permissions à jour pour que chaque membre voie les bons modules. +- Consulter le journal en cas de doute sur une modification. -## 14. Problèmes fréquents +## 18. Résumé des principaux processus -### Je ne peux pas me connecter +### Proposition vers résolution -Vérifier : +1. Un membre soumet une proposition. +2. Un membre la seconde. +3. L'exécutif renseigne les sections décisionnelles. +4. Le groupe adopte ou rejette. +5. L'application conserve l'historique. -- le bon groupe est sélectionné; -- le courriel est exact; -- le PIN contient 4 chiffres; -- le compte n'a pas été désactivé. +### Nomination -### Je ne vois pas un module +1. Un poste est disponible. +2. Un membre postule ou est proposé. +3. Une nomination est décidée par le groupe. +4. L'application crée la décision `NOM`. +5. Le mandat est actif ou refusé selon le traitement. -Les modules dépendent des postes et des accès configurés. +### Contribution -Demander à l'exécutif de vérifier : +1. Le rapport du trésorier est adopté. +2. Le groupe décide une contribution. +3. L'application crée une décision `DON`. +4. La contribution devient un engagement. +5. Le trésorier confirme le débit bancaire. -- vos mandats actifs; -- les accès du poste dans `Gestion des postes`. +### Collecte -### Le dépôt est refusé +1. La collecte est saisie pour une rencontre. +2. Le montant est confirmé. +3. Le trésorier reçoit l'argent. +4. L'encaisse est mise à jour. -Si le dépôt provient de l'encaisse, son montant dépasse probablement l'encaisse disponible. +### Dépense -### Une réserve est refusée +1. Un membre soumet une dépense. +2. Le trésorier ou l'exécutif la traite. +3. Elle est payée par encaisse ou engagée par banque. +4. Le débit bancaire est confirmé si nécessaire. +5. Le registre conserve la transaction. -Le total des réserves dépasserait le solde en banque. +### Réserve -### Une contribution ou dépense ne change pas le solde - -Elle doit être confirmée comme débitée dans `Trésorerie`. - -### Une collecte ne peut pas être saisie - -Causes possibles : - -- date hors jour de réunion; -- collecte déjà enregistrée pour cette date; -- date future. - -## 15. Règles importantes de l'application - -- Les données sont séparées par groupe. -- Un membre se connecte avec son courriel et son PIN. -- Les actions financières sensibles sont réservées au trésorier ou à l'exécutif. -- Les résolutions adoptées sont numérotées automatiquement. -- Les mandats ne se terminent pas seuls. -- Les accès aux modules sont contrôlés par les postes. +1. Une réserve est créée à zéro. +2. Un virement affecte de l'argent à la réserve. +3. Les mouvements sont historisés. +4. La réserve peut être supprimée seulement si son solde revient à zéro. diff --git a/frontend/src/pages/AjusterInventaire.jsx b/frontend/src/pages/AjusterInventaire.jsx index 2c5c17c..f3840bb 100644 --- a/frontend/src/pages/AjusterInventaire.jsx +++ b/frontend/src/pages/AjusterInventaire.jsx @@ -30,7 +30,8 @@ export default function AjusterInventaire() { const ajusterStock = async (itemId, quantite) => { setError(''); try { - await api.patch(`/litterature/${itemId}/stock`, { quantite }); + const item = items.find((i) => i.id === itemId); + await api.patch(`/litterature/${itemId}/stock`, { quantite }, { version: item?.modifie_le }); setSuccess('Stock ajusté ✓'); setTimeout(() => { setSuccess(''); charger(); }, 1000); } catch (e) { diff --git a/frontend/src/pages/AssembleeAffaires.jsx b/frontend/src/pages/AssembleeAffaires.jsx index 38bbfae..c17d274 100644 --- a/frontend/src/pages/AssembleeAffaires.jsx +++ b/frontend/src/pages/AssembleeAffaires.jsx @@ -270,8 +270,9 @@ export default function AssembleeAffaires({ contexte = false }) { const seconder = async (id, membreId = '') => { setError(''); try { + const proposition = propositions.find((p) => p.id === id); const query = membreId ? `?membre_id=${membreId}` : ''; - await api.post(`/propositions/${id}/seconder${query}`); + await api.post(`/propositions/${id}/seconder${query}`, null, { version: proposition?.modifie_le }); setMsg('Proposition secondée'); await charger(); } catch (e) { setError(e.message); } @@ -287,11 +288,12 @@ export default function AssembleeAffaires({ contexte = false }) { } setError(''); try { + const proposition = propositions.find((p) => p.id === id); await api.patch(`/propositions/${id}/voter`, { statut, etant_donne_que: etantDonneQue, groupe_a_decide_de: groupeADecideDe || null, - }); + }, { version: proposition?.modifie_le }); setResolutionForm({ ...resolutionForm, [id]: {} }); setMsg(statut === 'adoptee' ? 'Décision créée' : 'Proposition rejetée'); await charger(); @@ -309,7 +311,7 @@ export default function AssembleeAffaires({ contexte = false }) { await api.patch(`/pv/${pvPrecedent.id}/adopter`, { ...adoption, contenu: pvCorrection.trim(), - }); + }, { version: pvPrecedent.modifie_le }); setMsg('PV précédent adopté'); await charger(); } catch (e) { diff --git a/frontend/src/pages/Collecte.jsx b/frontend/src/pages/Collecte.jsx index af4a50d..d02ee7d 100644 --- a/frontend/src/pages/Collecte.jsx +++ b/frontend/src/pages/Collecte.jsx @@ -69,7 +69,8 @@ export default function Collecte() { const confirmer = async (id) => { setError(''); try { - await api.post(`/collectes/${id}/confirmer`); + const collecte = collectesToutes.find((c) => c.id === id) || collectesMois.find((c) => c.id === id); + await api.post(`/collectes/${id}/confirmer`, null, { version: collecte?.modifie_le }); flash('Montant confirmé ✓'); await charger(); } catch (e) { setError(e.message); } diff --git a/frontend/src/pages/Decisions.jsx b/frontend/src/pages/Decisions.jsx index 86118fc..ff76c05 100644 --- a/frontend/src/pages/Decisions.jsx +++ b/frontend/src/pages/Decisions.jsx @@ -74,8 +74,9 @@ export default function Decisions() { const seconder = async (id, membreId = '') => { setError(''); try { + const proposition = propositions.find((p) => p.id === id); const query = membreId ? `?membre_id=${membreId}` : ''; - await api.post(`/propositions/${id}/seconder${query}`); + await api.post(`/propositions/${id}/seconder${query}`, null, { version: proposition?.modifie_le }); flash('Proposition secondée'); await charger(); } catch (e) { setError(e.message); } @@ -99,11 +100,12 @@ export default function Decisions() { } try { + const proposition = propositions.find((p) => p.id === id); await api.patch(`/propositions/${id}/voter`, { statut: 'adoptee', etant_donne_que: etantDonneQue, groupe_a_decide_de: groupeADecideDe, - }); + }, { version: proposition?.modifie_le }); setResolutionForm({ ...resolutionForm, [id]: { etant_donne_que: '', groupe_a_decide_de: '' } }); flash('Résolution créée'); await charger(); @@ -119,10 +121,11 @@ export default function Decisions() { return; } try { + const proposition = propositions.find((p) => p.id === id); await api.patch(`/propositions/${id}/voter`, { statut: 'rejetee', etant_donne_que: etantDonneQue, - }); + }, { version: proposition?.modifie_le }); flash('Proposition rejetée'); await charger(); } catch (e) { setError(e.message); } diff --git a/frontend/src/pages/JournalActions.jsx b/frontend/src/pages/JournalActions.jsx index a5fd5b2..477575d 100644 --- a/frontend/src/pages/JournalActions.jsx +++ b/frontend/src/pages/JournalActions.jsx @@ -5,12 +5,17 @@ import { api } from '../stores/api'; export default function JournalActions() { const { setPage } = useStore(); const [actions, setActions] = useState([]); + const [historique, setHistorique] = useState([]); + const [tab, setTab] = useState('historique'); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); useEffect(() => { - api.get('/journal/actions?limite=100') - .then(setActions) + Promise.all([ + api.get('/journal/historique?limite=100'), + api.get('/journal/actions?limite=100'), + ]) + .then(([hist, acts]) => { setHistorique(hist); setActions(acts); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }, []); @@ -31,12 +36,40 @@ export default function JournalActions() {
+
+ + +
{loading &&

Chargement...

} {error &&
{error}
} - {!loading && actions.length === 0 && ( + {!loading && tab === 'historique' && historique.length === 0 && (

Aucun changement journalisé.

)} - {actions.map((action) => ( + {!loading && tab === 'technique' && actions.length === 0 && ( +

Aucune action technique journalisée.

+ )} + {tab === 'historique' && historique.map((item) => ( +
+
+
+

{item.raison}

+

{formatDate(item.cree_le)}

+
+ + {item.action} + +
+
+

Membre: {item.membre_prenom || item.membre_id || '—'}

+

Objet: {item.table_cible} · {item.objet_id}

+
+
+ ))} + {tab === 'technique' && actions.map((action) => (
diff --git a/frontend/src/pages/ReunionHebdomadaire.jsx b/frontend/src/pages/ReunionHebdomadaire.jsx index e9d449b..dcc6bb2 100644 --- a/frontend/src/pages/ReunionHebdomadaire.jsx +++ b/frontend/src/pages/ReunionHebdomadaire.jsx @@ -205,7 +205,7 @@ export default function ReunionHebdomadaire({ contexte = false }) { theme: rapport.theme.trim() || 'Rencontre hebdomadaire', annonces: rapport.annonces.split('\n').map((a) => a.trim()).filter(Boolean) || null, note: rapport.note.trim() || null, - }); + }, { version: rencontreOuverte.modifie_le }); flash('Rapport enregistré'); await charger(); } catch (e) { setError(e.message); } diff --git a/frontend/src/pages/Tresorerie.jsx b/frontend/src/pages/Tresorerie.jsx index 8ea633e..64b9869 100644 --- a/frontend/src/pages/Tresorerie.jsx +++ b/frontend/src/pages/Tresorerie.jsx @@ -111,10 +111,10 @@ export default function Tresorerie() { const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); }; - const accuserReception = async (id) => { + const accuserReception = async (id, version) => { setError(''); try { - await api.post(`/collectes/${id}/reception`); + await api.post(`/collectes/${id}/reception`, null, { version }); flash('Réception confirmée ✓'); await charger(); } catch (e) { setError(e.message); } @@ -129,37 +129,37 @@ export default function Tresorerie() { } catch (e) { setError(e.message); } }; - const confirmerContribution = async (id) => { + const confirmerContribution = async (id, version) => { setError(''); try { - await api.post(`/contributions/${id}/confirmer-debit`); + await api.post(`/contributions/${id}/confirmer-debit`, null, { version }); flash('Contribution débitée ✓'); await charger(); } catch (e) { setError(e.message); } }; - const confirmerDepense = async (id, methode) => { + const confirmerDepense = async (id, methode, version) => { setError(''); try { - await api.post(`/depenses/${id}/confirmer?methode=${methode}`); + await api.post(`/depenses/${id}/confirmer?methode=${methode}`, null, { version }); flash('Dépense traitée ✓'); await charger(); } catch (e) { setError(e.message); } }; - const rejeterDepense = async (id) => { + const rejeterDepense = async (id, version) => { setError(''); try { - await api.delete(`/depenses/${id}`); + await api.delete(`/depenses/${id}`, { version }); flash('Dépense rejetée'); await charger(); } catch (e) { setError(e.message); } }; - const confirmerDebitDepense = async (id) => { + const confirmerDebitDepense = async (id, version) => { setError(''); try { - await api.post(`/depenses/${id}/confirmer-debit`); + await api.post(`/depenses/${id}/confirmer-debit`, null, { version }); flash('Débit confirmé ✓'); await charger(); } catch (e) { setError(e.message); } @@ -216,7 +216,8 @@ export default function Tresorerie() { note: reserveNote.trim() || null, }; if (reserveId) { - await api.patch(`/banque/reserves/${reserveId}`, payload); + const reserve = reserves.find((r) => r.id === reserveId); + await api.patch(`/banque/reserves/${reserveId}`, payload, { version: reserve?.modifie_le }); flash('Réserve modifiée ✓'); } else { await api.post('/banque/reserves', { ...payload, montant: 0 }); @@ -231,7 +232,8 @@ export default function Tresorerie() { if (!window.confirm('Supprimer cette réserve ?')) return; setError(''); try { - await api.delete(`/banque/reserves/${id}`); + const reserve = reserves.find((r) => r.id === id); + await api.delete(`/banque/reserves/${id}`, { version: reserve?.modifie_le }); flash('Réserve supprimée ✓'); if (reserveId === id) resetReserveForm(); await charger(); @@ -266,7 +268,10 @@ export default function Tresorerie() { if (!nom) { setError('Nom de destinataire requis'); return; } setError(''); try { - if (destinataireId) await api.patch(`/contributions/destinataires/${destinataireId}`, { nom }); + if (destinataireId) { + const destinataire = destinataires.find((d) => d.id === destinataireId); + await api.patch(`/contributions/destinataires/${destinataireId}`, { nom }, { version: destinataire?.modifie_le }); + } else await api.post('/contributions/destinataires', { nom }); resetDestinataireForm(); setDestinataires(await api.get('/contributions/destinataires')); @@ -278,7 +283,8 @@ export default function Tresorerie() { if (!window.confirm('Supprimer ce destinataire ?')) return; setError(''); try { - await api.delete(`/contributions/destinataires/${id}`); + const destinataire = destinataires.find((d) => d.id === id); + await api.delete(`/contributions/destinataires/${id}`, { version: destinataire?.modifie_le }); setDestinataires(await api.get('/contributions/destinataires')); flash('Destinataire supprimé'); } catch (e) { setError(e.message); } @@ -553,7 +559,7 @@ export default function Tresorerie() { montant={d.montant} meta={`${d.date_depense} · ${d.qui_prenom} · ${d.methode}`} action={estExecutif ? 'Confirmer le débit' : null} - onAction={() => confirmerDebitDepense(d.id)} + onAction={() => confirmerDebitDepense(d.id, d.modifie_le)} /> ))}
@@ -569,7 +575,7 @@ export default function Tresorerie() { montant={c.montant} meta={`${c.date_envoi} · ${c.methode} · Saisi par ${c.saisi_par_prenom || '—'}`} action={estExecutif ? 'Confirmer le débit' : null} - onAction={() => confirmerContribution(c.id)} + onAction={() => confirmerContribution(c.id, c.modifie_le)} /> ))}
@@ -585,7 +591,7 @@ export default function Tresorerie() { montant={c.montant} meta={`Chez ${c.detenteur} · ${STATUT_COL[c.statut]?.label || c.statut}`} action={estExecutif ? 'Accuser réception' : null} - onAction={() => accuserReception(c.id)} + onAction={() => accuserReception(c.id, c.modifie_le)} /> ))}
@@ -989,7 +995,7 @@ export default function Tresorerie() {
À débiter - @@ -1016,7 +1022,7 @@ export default function Tresorerie() { À débiter - @@ -1043,7 +1049,7 @@ export default function Tresorerie() { {st.label} - @@ -1223,11 +1229,11 @@ function DepenseConfirmCard({ depense, soldeDisponible, onConfirm, onReject }) { )}
- -