diff --git a/.gitignore b/.gitignore index 557d6ac..fb5bcbe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ dist/ +__pycache__/ +*.py[cod] *.retry ansible/group_vars/vault.yml diff --git a/README.md b/README.md index e082767..3fe26f7 100644 --- a/README.md +++ b/README.md @@ -11,29 +11,28 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. | Frontend | React 18 + Vite (PWA installable) | | UI | Tailwind CSS (mobile-first) | | État | Zustand | -| Backend | FastAPI (Python) async, 82 routes | -| DB | PostgreSQL + SQLAlchemy async (23+ tables) | +| Backend | FastAPI (Python) async | +| DB | PostgreSQL + SQLAlchemy async | | Auth | JWT 30 jours + bcrypt 12 rounds (prénom + PIN) | | PDF | WeasyPrint | | Serveur | NGINX (statiques + reverse proxy HTTPS) | ## Modules -### Consultation (C1-C7) — Tous les membres +### Consultation — Tous les membres - **C1** Rapports PDF — finances, PV, résolutions, rapport complet -- **C2** Événements à venir — anniversaires de sobriété et événements ad hoc du district +- **C2** Événements — anniversaires de sobriété, événements et résolutions - **C4** Littérature — inventaire avec prix, alerte stock bas -- **C5** Décisions en cours — suivi des résolutions -- **C6** Votes ouverts — sondages de conscience de groupe, vote anonyme +- **C5** Gouvernance — décisions du groupe, propositions, résolutions, nominations et contributions - **C7** Mon profil — modifier, changer PIN, notifications -- **C8** Propositions — soumettre, seconder, voter (créables en tout temps) -- **C9** S'impliquer — postes vacants, candidatures +- **C9** Postes à pourvoir — postes vacants, candidatures ### Saisie hebdomadaire (S1-S5) -- **S1** Collecte 7e Tradition — saisie le jour de réunion, confirmation par un second membre, historique mensuel avec totaux -- **S1T** Trésorerie — soldes (banque + encaisse + disponible après réserves), réception des collectes et ventes de littérature, registre détaillé des transactions, opérations bancaires (dépôt/retrait/relevé) +- **S0** Réunion hebdomadaire — formulaire regroupé pour rapport, collecte, jeton et littérature +- **S1** Collecte 7e Tradition — réservé aux postes exécutifs +- **S1T** Trésorerie — soldes (banque + encaisse + réserves affectées), réception des collectes et ventes de littérature, registre détaillé des transactions, opérations bancaires (dépôt/retrait/relevé) - **S2** Remise de jeton — 9 types, historique - **S3** Vente littérature — stock auto-décrémenté, montant à remettre au trésorier - **S4** Rapport de réunion — thème + annonces @@ -41,6 +40,7 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. ### Saisie mensuelle (M1-M6) +- **M0** Assemblée d'affaires — formulaire regroupé pour réunion, présences et PV - **M1** Rédiger PV — auto-renseigné depuis les propositions secondées, décisions structurées - **M2** Présences réunion d'affaires - **M3** Rapport RSG — template 3 sections @@ -50,27 +50,23 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. ### Gestion — Exécutif -- **G1** Inviter un membre — code 48h -- **G2** Gérer les membres — fiche complète, droit à l'oubli -- **G6** Lancer un sondage — oui/non, choix multiple, unanimité +- **G2** Gestion des membres — membres, invitations, droit à l'oubli - **G9** Paramètres du groupe — nom, horaire, adresse, format - **G11** Gestion des mandats — affecter, terminer, planifier les rotations - **G12** Gestion des postes — créer, abolir, candidatures, accès aux modules ## Trésorerie -Le calcul des soldes repose sur le relevé bancaire mensuel : +La source de vérité financière est le registre `transactions_comptables`. -- **Solde banque** = dernier relevé + dépôts − retraits depuis le relevé -- **Disponible** = solde banque − engagements non débités − réserves -- **Réserves** = réserve prudente (500 $) + réserve anniversaire (20 $/mois) -- **Encaisse** = collectes reçues + ventes remises − dépôts − dépenses cash +- **Banque** et **encaisse** sont calculées depuis les écritures comptables. +- **Réserves** est calculé depuis l'historique `mouvements_reserves`. +- Toute dépense ou contribution par chèque/virement reste un engagement jusqu'à confirmation du débit. +- Les rapports mensuels doivent s'appuyer sur ce registre. -Toute dépense par chèque ou virement est un engagement. Le trésorier confirme le débit lorsque l'argent quitte effectivement le compte. +## Gouvernance -## Propositions et résolutions - -Les propositions peuvent être créées en tout temps par n'importe quel membre, sans nécessiter une réunion d'affaires. Le cycle est : proposée → secondée → votée (adoptée/rejetée). Lorsqu'adoptée, la proposition devient une résolution numérotée (RES-2026-001) et peut être associée à l'assemblée d'affaires lors du vote. +Les propositions peuvent être créées en tout temps par n'importe quel membre, sans type initial. Le cycle est : proposée → secondée → transformée en résolution par l'exécutif. Lorsqu'adoptée, la proposition devient une décision numérotée avec les préfixes `RES` (résolution), `NOM` (nomination) ou `DON` (contribution), et les sections « ÉTANT DONNÉ QUE » et « LE GROUPE A DÉCIDÉ DE ». Le PV de la réunion d'affaires s'auto-renseigne depuis les propositions secondées en attente de vote. @@ -87,9 +83,10 @@ Le PV de la réunion d'affaires s'auto-renseigne depuis les propositions second ``` backend/ API FastAPI (Python) app/ - core/ Config, DB, sécurité - models/ Modèles SQLAlchemy (23+ tables) - routers/ Routes API (26 groupes) + core/ Config, DB, sécurité, rôles, règles communes + services/ Logique métier réutilisable + models/ Modèles SQLAlchemy + routers/ Routes API schemas/ Schémas Pydantic frontend/ React + Vite + Tailwind src/ @@ -103,6 +100,7 @@ docs/ DEPLOIEMENT.md, RUNBOOK.md ## Documentation - [Guide utilisateur](docs/UTILISATEURS.md) +- [Architecture technique](docs/ARCHITECTURE.md) - [Déploiement Ansible](docs/ANSIBLE.md) - [Runbook d'exploitation](docs/RUNBOOK.md) diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index e1345ac..1df9dd3 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -33,6 +33,9 @@ groupe_meditation_database_url: >- groupe_meditation_cors_origins: - "https://{{ groupe_meditation_domain }}" groupe_meditation_invitation_expire_hours: 48 +groupe_meditation_vapid_public_key: "" +groupe_meditation_vapid_private_key_b64: "" +groupe_meditation_vapid_subject: "mailto:admin@{{ groupe_meditation_domain }}" # Initialisation applicative. Le seed existant est interactif; Ansible lui # fournit ces valeurs seulement si la base ne contient encore aucun groupe. @@ -52,4 +55,3 @@ groupe_meditation_certbot_email: admin@example.com # Sauvegardes. groupe_meditation_backup_dir: /var/backups/groupe-meditation groupe_meditation_backup_retention_days: 30 - diff --git a/ansible/roles/app/templates/env.j2 b/ansible/roles/app/templates/env.j2 index 959a268..5bf8f63 100644 --- a/ansible/roles/app/templates/env.j2 +++ b/ansible/roles/app/templates/env.j2 @@ -3,4 +3,6 @@ DATABASE_URL={{ groupe_meditation_database_url }} JWT_SECRET={{ groupe_meditation_jwt_secret }} CORS_ORIGINS={{ groupe_meditation_cors_origins | to_json }} INVITATION_EXPIRE_HOURS={{ groupe_meditation_invitation_expire_hours }} - +VAPID_PUBLIC_KEY={{ groupe_meditation_vapid_public_key }} +VAPID_PRIVATE_KEY_B64={{ groupe_meditation_vapid_private_key_b64 }} +VAPID_SUBJECT={{ groupe_meditation_vapid_subject }} diff --git a/ansible/roles/nginx/templates/groupe-meditation.conf.j2 b/ansible/roles/nginx/templates/groupe-meditation.conf.j2 index 5e6a345..d45f833 100644 --- a/ansible/roles/nginx/templates/groupe-meditation.conf.j2 +++ b/ansible/roles/nginx/templates/groupe-meditation.conf.j2 @@ -18,9 +18,24 @@ server { root {{ groupe_meditation_web_root }}; index index.html; + location = /index.html { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + } + + location ~ ^/(sw\.js|push-sw\.js|registerSW\.js|manifest\.webmanifest)$ { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + try_files $uri =404; + } + location ~* \.(js|css|png|jpg|svg|woff2|ico)$ { expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "public, immutable" always; try_files $uri =404; } @@ -28,10 +43,5 @@ server { try_files $uri $uri/ /index.html; } - location = /index.html { - add_header Cache-Control "no-cache, no-store, must-revalidate"; - } - client_max_body_size 5M; } - diff --git a/backend/app/core/audit.py b/backend/app/core/audit.py new file mode 100644 index 0000000..ea6b88f --- /dev/null +++ b/backend/app/core/audit.py @@ -0,0 +1,78 @@ +"""Journalisation automatique des changements API.""" +from jose import JWTError, jwt +from sqlalchemy import select +from starlette.middleware.base import BaseHTTPMiddleware + +from app.core.config import settings +from app.core.database import async_session +from app.models.journal_action import JournalAction +from app.models.membre import Membre +from app.services.notifications import notify_group_change + + +MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} +SKIPPED_PREFIXES = ( + "/api/auth/connexion", + "/api/health", +) + + +def _token_payload(request): + header = request.headers.get("authorization") or "" + if not header.lower().startswith("bearer "): + return None + token = header.split(" ", 1)[1].strip() + try: + return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) + except JWTError: + return None + + +class AuditMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + path = request.url.path + if ( + request.method not in MUTATING_METHODS + or not path.startswith("/api/") + or any(path.startswith(prefix) for prefix in SKIPPED_PREFIXES) + or response.status_code >= 400 + ): + return response + + payload = _token_payload(request) + if not payload or not payload.get("sub"): + return response + + async with async_session() as db: + membre = None + result = await db.execute(select(Membre).where(Membre.id == payload["sub"])) + membre = result.scalar_one_or_none() + if not membre: + return response + + details = { + "query": dict(request.query_params), + "impersonification": bool(payload.get("adm")), + } + db.add(JournalAction( + groupe_id=membre.groupe_id, + membre_id=membre.id, + admin_id=payload.get("adm"), + methode=request.method, + chemin=path, + statut_http=response.status_code, + adresse_ip=request.client.host if request.client else None, + user_agent=(request.headers.get("user-agent") or "")[:255] or None, + details=details, + )) + await notify_group_change( + db, + groupe_id=membre.groupe_id, + actor_id=membre.id, + title="Changement dans l'application", + body=f"{membre.prenom} a enregistré une modification.", + url="/", + ) + await db.commit() + return response diff --git a/backend/app/core/config.py b/backend/app/core/config.py index fd17bbd..906776f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -17,6 +17,9 @@ class Settings(BaseSettings): "https://groupe-meditation.87-16.org", ] INVITATION_EXPIRE_HOURS: int = 48 + VAPID_PUBLIC_KEY: str | None = None + VAPID_PRIVATE_KEY_B64: str | None = None + VAPID_SUBJECT: str = "mailto:admin@groupe-meditation.local" class Config: env_file = str(_ENV_FILE) diff --git a/backend/app/core/decisions.py b/backend/app/core/decisions.py new file mode 100644 index 0000000..6ba70b9 --- /dev/null +++ b/backend/app/core/decisions.py @@ -0,0 +1,24 @@ +"""Utilitaires pour la numérotation des décisions du groupe.""" +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.proposition import Proposition + +PREFIXES_DECISION = { + "resolution": "RES", + "nomination": "NOM", + "contribution": "DON", +} + + +async def prochain_numero_decision(groupe_id, type_decision: str, annee: int, db: AsyncSession) -> str: + prefixe = PREFIXES_DECISION.get(type_decision, "RES") + result = await db.execute( + select(func.count(Proposition.id)).where( + Proposition.groupe_id == groupe_id, + Proposition.numero_resolution.isnot(None), + Proposition.numero_resolution.like(f"{prefixe}-{annee}-%"), + ) + ) + count = result.scalar() or 0 + return f"{prefixe}-{annee}-{count + 1:03d}" diff --git a/backend/app/core/reunions.py b/backend/app/core/reunions.py new file mode 100644 index 0000000..3bf94bb --- /dev/null +++ b/backend/app/core/reunions.py @@ -0,0 +1,38 @@ +"""Règles communes liées aux dates de réunion.""" +from datetime import date as dt_date +from calendar import monthrange + +JOURS_SEMAINE = { + "lundi": 0, + "mardi": 1, + "mercredi": 2, + "jeudi": 3, + "vendredi": 4, + "samedi": 5, + "dimanche": 6, +} + + +def jour_reunion_attendu(groupe) -> int | None: + if not getattr(groupe, "jour_reunion", None): + return None + return JOURS_SEMAINE.get(groupe.jour_reunion.lower()) + + +def date_correspond_au_jour_reunion(groupe, date_reunion: dt_date) -> bool: + jour = jour_reunion_attendu(groupe) + return True if jour is None else date_reunion.weekday() == jour + + +def date_assemblee_affaires_du_mois(annee: int, mois: int, jour_cible: int, ordre: str) -> dt_date: + dates = [] + for jour in range(1, monthrange(annee, mois)[1] + 1): + date = dt_date(annee, mois, jour) + if date.weekday() == jour_cible: + dates.append(date) + if not dates: + return dt_date(annee, mois, 1) + if ordre == "derniere": + return dates[-1] + index = {"premiere": 0, "deuxieme": 1, "troisieme": 2, "quatrieme": 3}.get(ordre, 0) + return dates[index] if index < len(dates) else dates[-1] diff --git a/backend/app/core/roles.py b/backend/app/core/roles.py new file mode 100644 index 0000000..5878cd0 --- /dev/null +++ b/backend/app/core/roles.py @@ -0,0 +1,51 @@ +"""Vérifications de rôles applicatifs.""" +from fastapi import Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_membre, is_sysadmin +from app.models.affectation import Affectation +from app.models.poste import Poste + + +async def membre_a_poste_ou_executif(membre_id, libelle_poste: str, db: AsyncSession) -> bool: + from app.models.membre import Membre + membre = await db.get(Membre, membre_id) + if membre and is_sysadmin(membre): + return True + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre_id, + Affectation.statut == "actif", + (Poste.nom.ilike(f"%{libelle_poste}%") | (Poste.categorie == "executif")), + ) + ) + return result.scalars().first() is not None + + +async def require_tresorier( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + if not await membre_a_poste_ou_executif(membre.id, "trésorier", db): + raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif") + return membre + + +async def require_secretaire( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + if not await membre_a_poste_ou_executif(membre.id, "secrétaire", db): + raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif") + return membre + + +async def require_rsg( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + if not await membre_a_poste_ou_executif(membre.id, "RSG", db): + raise HTTPException(status_code=403, detail="Réservé au RSG ou à l'exécutif") + return membre diff --git a/backend/app/core/security.py b/backend/app/core/security.py index b0c4fb6..4af9b2e 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -21,10 +21,12 @@ def verify_pin(pin: str, hashed: str) -> bool: return bcrypt.checkpw(pin.encode("utf-8"), hashed.encode("utf-8")) -def create_token(membre_id: str, groupe_id: str, expire_jours: int = None) -> str: +def create_token(membre_id: str, groupe_id: str, expire_jours: int = None, admin_id: str | None = None) -> str: jours = expire_jours or settings.JWT_EXPIRE_DAYS expire = datetime.now(timezone.utc) + timedelta(days=jours) payload = {"sub": str(membre_id), "grp": str(groupe_id), "exp": expire} + if admin_id: + payload["adm"] = str(admin_id) return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) @@ -35,6 +37,10 @@ def decode_token(token: str) -> dict: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token invalide ou expiré") +def is_sysadmin(membre) -> bool: + return bool(getattr(membre, "is_sysadmin", False)) + + async def get_current_membre( credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db), @@ -51,6 +57,8 @@ async def get_current_membre( raise HTTPException(status_code=401, detail="Membre introuvable ou inactif") if groupe_id and str(membre.groupe_id) != str(groupe_id): raise HTTPException(status_code=401, detail="Session invalide pour ce groupe") + if payload.get("adm"): + setattr(membre, "_sysadmin_session", True) return membre @@ -58,6 +66,8 @@ async def require_executif( membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): + if is_sysadmin(membre): + return membre from app.models.affectation import Affectation from app.models.poste import Poste result = await db.execute( diff --git a/backend/app/main.py b/backend/app/main.py index b1aedf6..8da8579 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,10 +6,12 @@ District 87-16, Région 87 from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text from app.core.config import settings +from app.core.audit import AuditMiddleware from app.core.database import engine, Base -from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, propositions, operations_bancaires, evenements +from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, propositions, operations_bancaires, evenements, notifications, journal # Import models pour que create_all crée toutes les tables from app.models.sync_log import SyncLog # noqa: F401 @@ -19,12 +21,27 @@ from app.models.evenement import Evenement # noqa: F401 from app.models.candidature import Candidature # noqa: F401 from app.models.poste_module import PosteModule # noqa: F401 from app.models.reserve import Reserve # noqa: F401 +from app.models.transaction_comptable import TransactionComptable # noqa: F401 +from app.models.mouvement_reserve import MouvementReserve # noqa: F401 +from app.models.journal_action import JournalAction # noqa: F401 +from app.models.push_subscription import PushSubscription # noqa: F401 @asynccontextmanager async def lifespan(app: FastAPI): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await conn.execute(text( + "ALTER TABLE groupes " + "ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'" + )) + await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS date_adoption DATE")) + await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS etant_donne_que TEXT")) + await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT")) + await conn.execute(text("DROP TABLE IF EXISTS votes")) + await conn.execute(text("DROP TABLE IF EXISTS options_sondage")) + await conn.execute(text("DROP TABLE IF EXISTS sondages")) + await conn.execute(text("DROP TABLE IF EXISTS decisions")) yield await engine.dispose() @@ -43,6 +60,7 @@ app.add_middleware( allow_methods=["*"], allow_headers=["*"], ) +app.add_middleware(AuditMiddleware) # Montage des routes app.include_router(auth.router, prefix="/api") @@ -53,7 +71,6 @@ app.include_router(invitations.router, prefix="/api") app.include_router(collectes.router, prefix="/api") app.include_router(reunions.router, prefix="/api") app.include_router(depenses.router, prefix="/api") -app.include_router(decisions.router, prefix="/api") app.include_router(calendrier.router, prefix="/api") app.include_router(rapports.router, prefix="/api") app.include_router(rotations.router, prefix="/api") @@ -68,10 +85,11 @@ app.include_router(litterature.router, prefix="/api") app.include_router(ventes_litterature.router, prefix="/api") app.include_router(contributions.router, prefix="/api") app.include_router(config_repartition.router, prefix="/api") -app.include_router(sondages.router, prefix="/api") app.include_router(propositions.router, prefix="/api") app.include_router(operations_bancaires.router, prefix="/api") app.include_router(evenements.router, prefix="/api") +app.include_router(notifications.router, prefix="/api") +app.include_router(journal.router, prefix="/api") @app.get("/api/health") diff --git a/backend/app/models/decision.py b/backend/app/models/decision.py deleted file mode 100644 index 0f1ee4a..0000000 --- a/backend/app/models/decision.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Table decisions — suivi des décisions de réunion d'affaires.""" -import uuid -from datetime import date, datetime, timezone -from sqlalchemy import String, Date, DateTime, Text, ForeignKey, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Decision(Base): - __tablename__ = "decisions" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id")) - sujet: Mapped[str] = mapped_column(String(255), nullable=False) - description: Mapped[str | None] = mapped_column(Text) - statut: Mapped[str] = mapped_column( - SAEnum("en_attente", "en_cours", "complete", name="statut_decision"), - default="en_attente", - ) - responsable_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - proposeur: Mapped[str | None] = mapped_column(String(100)) - secondeur: Mapped[str | None] = mapped_column(String(100)) - date_decision: Mapped[date] = mapped_column(Date, nullable=False) - date_completion: Mapped[date | None] = mapped_column(Date) - cree_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)) diff --git a/backend/app/models/groupe.py b/backend/app/models/groupe.py index ef36382..f5b67e6 100644 --- a/backend/app/models/groupe.py +++ b/backend/app/models/groupe.py @@ -16,6 +16,7 @@ class Groupe(Base): adresse_local: Mapped[str | None] = mapped_column(Text) jour_reunion: Mapped[str | None] = mapped_column(String(10)) heure_reunion: Mapped[time | None] = mapped_column(Time) + assemblee_affaires_ordre: Mapped[str] = mapped_column(String(20), default="premiere") format_reunion: Mapped[str | None] = mapped_column(Text) lien_aa87: Mapped[str] = mapped_column(String(255), default="https://aa87.org") cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/journal_action.py b/backend/app/models/journal_action.py new file mode 100644 index 0000000..5cf92af --- /dev/null +++ b/backend/app/models/journal_action.py @@ -0,0 +1,22 @@ +"""Journal d'audit des actions membres.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, ForeignKey, JSON, Integer +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class JournalAction(Base): + __tablename__ = "journal_actions" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("groupes.id"), index=True) + membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"), index=True) + admin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"), index=True) + methode: Mapped[str] = mapped_column(String(10), nullable=False) + chemin: Mapped[str] = mapped_column(String(255), nullable=False) + statut_http: Mapped[int] = mapped_column(Integer, nullable=False) + adresse_ip: Mapped[str | None] = mapped_column(String(64)) + user_agent: Mapped[str | None] = mapped_column(String(255)) + details: 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/membre.py b/backend/app/models/membre.py index 73c608e..4fe3943 100644 --- a/backend/app/models/membre.py +++ b/backend/app/models/membre.py @@ -27,3 +27,7 @@ class Membre(Base): groupe = relationship("Groupe", back_populates="membres") affectations = relationship("Affectation", back_populates="membre", foreign_keys="Affectation.membre_id") + + @property + def is_sysadmin(self) -> bool: + return (self.courriel or "").strip().lower() == "sysadmin" or bool(getattr(self, "_sysadmin_session", False)) diff --git a/backend/app/models/mouvement_reserve.py b/backend/app/models/mouvement_reserve.py new file mode 100644 index 0000000..8de9e49 --- /dev/null +++ b/backend/app/models/mouvement_reserve.py @@ -0,0 +1,20 @@ +"""Historique des mouvements entre réserves.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class MouvementReserve(Base): + __tablename__ = "mouvements_reserves" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + source_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reserves.id"), nullable=True) + destination_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reserves.id"), nullable=True) + montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + type: Mapped[str] = mapped_column(String(40), nullable=False) + note: Mapped[str | None] = mapped_column(Text) + cree_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)) diff --git a/backend/app/models/option_sondage.py b/backend/app/models/option_sondage.py deleted file mode 100644 index 77b9fc1..0000000 --- a/backend/app/models/option_sondage.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Table options_sondage — Options de vote.""" -import uuid -from sqlalchemy import String, Integer, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.core.database import Base - - -class OptionSondage(Base): - __tablename__ = "options_sondage" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False) - texte: Mapped[str] = mapped_column(String(200), nullable=False) - ordre: Mapped[int] = mapped_column(Integer, default=0) - - sondage = relationship("Sondage", back_populates="options") diff --git a/backend/app/models/proposition.py b/backend/app/models/proposition.py index 41f03ce..0014347 100644 --- a/backend/app/models/proposition.py +++ b/backend/app/models/proposition.py @@ -13,7 +13,7 @@ class Proposition(Base): groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"), nullable=True) type_proposition: Mapped[str] = mapped_column( - String(20), default="generale", # generale, nomination, contribution + String(20), default="proposition", # proposition, resolution, nomination, contribution ) sujet: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text) @@ -23,8 +23,11 @@ class Proposition(Base): SAEnum("proposee", "secondee", "adoptee", "rejetee", name="statut_proposition"), default="proposee", ) - numero_resolution: Mapped[str | None] = mapped_column(String(20)) # RES-2026-001 + numero_resolution: Mapped[str | None] = mapped_column(String(20)) # RES/NOM/DON-2026-001 date_vote: Mapped[date | None] = mapped_column(Date) + date_adoption: Mapped[date | None] = mapped_column(Date) + etant_donne_que: Mapped[str | None] = mapped_column(Text) + groupe_a_decide_de: Mapped[str | None] = mapped_column(Text) note: Mapped[str | None] = mapped_column(Text) # Lien vers la candidature (si type=nomination) candidature_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("candidatures.id")) diff --git a/backend/app/models/push_subscription.py b/backend/app/models/push_subscription.py new file mode 100644 index 0000000..598836a --- /dev/null +++ b/backend/app/models/push_subscription.py @@ -0,0 +1,22 @@ +"""Abonnements Web Push des appareils membres.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, DateTime, ForeignKey, JSON, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class PushSubscription(Base): + __tablename__ = "push_subscriptions" + __table_args__ = ( + UniqueConstraint("endpoint", name="uq_push_subscription_endpoint"), + ) + + 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) + membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False, index=True) + endpoint: Mapped[str] = mapped_column(String(500), nullable=False) + abonnement: Mapped[dict] = mapped_column(JSON, nullable=False) + user_agent: Mapped[str | None] = mapped_column(String(255)) + 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/sondage.py b/backend/app/models/sondage.py deleted file mode 100644 index b89834e..0000000 --- a/backend/app/models/sondage.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Table sondages — Votes de conscience de groupe.""" -import uuid -from datetime import datetime, timezone -from sqlalchemy import String, Text, DateTime, ForeignKey, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.core.database import Base - - -class Sondage(Base): - __tablename__ = "sondages" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) - titre: Mapped[str] = mapped_column(String(200), nullable=False) - description: Mapped[str | None] = mapped_column(Text) - type: Mapped[str] = mapped_column( - SAEnum("oui_non", "choix_multiple", "unanimite", name="type_sondage"), - nullable=False, - ) - cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) - date_ouverture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - date_fermeture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - statut: Mapped[str] = mapped_column( - SAEnum("brouillon", "ouvert", "ferme", name="statut_sondage"), - default="brouillon", - ) - cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) - - options = relationship("OptionSondage", back_populates="sondage", cascade="all, delete-orphan") diff --git a/backend/app/models/transaction_comptable.py b/backend/app/models/transaction_comptable.py new file mode 100644 index 0000000..60e269b --- /dev/null +++ b/backend/app/models/transaction_comptable.py @@ -0,0 +1,23 @@ +"""Registre comptable central.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class TransactionComptable(Base): + __tablename__ = "transactions_comptables" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + date_transaction: Mapped[date] = mapped_column(Date, nullable=False) + type: Mapped[str] = mapped_column(String(40), nullable=False) + compte: Mapped[str] = mapped_column(String(40), nullable=False) + montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + sens: Mapped[str] = mapped_column(String(10), nullable=False) # debit ou credit + source_table: Mapped[str | None] = mapped_column(String(80)) + source_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True) + note: Mapped[str | None] = mapped_column(Text) + cree_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)) diff --git a/backend/app/models/vote.py b/backend/app/models/vote.py deleted file mode 100644 index e04d850..0000000 --- a/backend/app/models/vote.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Table votes — Votes anonymes.""" -import uuid -from datetime import datetime, timezone -from sqlalchemy import DateTime, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column -from app.core.database import Base - - -class Vote(Base): - __tablename__ = "votes" - - id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) - sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False) - option_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("options_sondage.id", ondelete="CASCADE"), nullable=False) - membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) - vote_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/routers/accueil.py b/backend/app/routers/accueil.py index 784f141..7bc78f5 100644 --- a/backend/app/routers/accueil.py +++ b/backend/app/routers/accueil.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.database import get_db -from app.core.security import get_current_membre +from app.core.security import get_current_membre, is_sysadmin from app.models.affectation import Affectation from app.models.poste import Poste from app.models.groupe import Groupe @@ -18,26 +18,24 @@ MODULES_CONSULTATION = [ ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, résolutions", icone="📄", couleur="blue"), ModuleTuile(code="C2", nom="Événements", description="Anniversaires et événements", icone="📅", couleur="green"), ModuleTuile(code="C4", nom="Littérature", description="Inventaire et prix", icone="📚", couleur="purple"), - ModuleTuile(code="C5", nom="Décisions en cours", description="Suivi réunion d'affaires", icone="📌", couleur="red"), - ModuleTuile(code="C6", nom="Votes ouverts", description="Conscience de groupe", icone="🗳️", couleur="indigo"), + ModuleTuile(code="C5", nom="Gouvernance", description="Décisions du groupe", icone="📌", couleur="red"), ModuleTuile(code="C7", nom="Mon profil", description="Modifier, PIN, notifications", icone="👤", couleur="gray"), - ModuleTuile(code="C8", nom="Propositions", description="Soumettre, seconder, résolutions", icone="📋", couleur="orange"), ModuleTuile(code="C9", nom="Postes à pourvoir", description="Postes disponibles, candidature", icone="🙋", couleur="yellow"), - ModuleTuile(code="C10", nom="État financier", description="Résultats et transactions", icone="📊", couleur="blue"), + ModuleTuile(code="S1T", nom="Trésorerie", description="État des résultats, soldes, registre", icone="🏦", couleur="green"), ] MODULES_PAR_CODE = {m.code: m for m in MODULES_CONSULTATION} +MODULES_PAR_CODE["S0"] = ModuleTuile(code="S0", nom="Rencontre hebdomadaire", description="Saisies du contexte régulier", icone="🗓️", couleur="green") +MODULES_PAR_CODE["M0"] = ModuleTuile(code="M0", nom="Assemblée d'affaires", description="Présences et procès-verbal", icone="🏛️", couleur="indigo") +MODULES_PAR_CODE["S1"] = ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green") -# Modules de saisie visibles par TOUS les membres MODULES_SAISIE_TOUS = [ - ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"), ModuleTuile(code="S5", nom="Dépense", description="Soumettre une dépense", icone="💸", couleur="red"), ] # Modules de saisie par poste MODULES_SAISIE = { "Trésorier(ière)": [ - ModuleTuile(code="S1T", nom="Trésorerie", description="Réceptions, dépôts, soldes", icone="🏦", couleur="green"), ModuleTuile(code="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"), ], "Secrétaire": [ @@ -65,10 +63,10 @@ MODULES_SAISIE = { MODULES_GESTION = [ ModuleTuile(code="G2", nom="Gestion des membres", description="Membres, invitations, désactivation", icone="👥", couleur="blue"), - ModuleTuile(code="G6", nom="Lancer un sondage", description="Conscience de groupe", icone="📊", couleur="indigo"), ModuleTuile(code="G9", nom="Paramètres groupe", description="Nom, horaire, adresse", icone="🔧", couleur="gray"), ModuleTuile(code="G11", nom="Gestion des mandats", description="Affecter, terminer, rotations", icone="🎯", couleur="purple"), ModuleTuile(code="G12", nom="Gestion des postes", description="Postes, candidatures, accès", icone="📋", couleur="purple"), + ModuleTuile(code="G13", nom="Journal", description="Changements enregistrés", icone="🧾", couleur="gray"), ] for _modules in [MODULES_SAISIE_TOUS, MODULES_GESTION, *MODULES_SAISIE.values()]: @@ -82,6 +80,34 @@ async def accueil( db: AsyncSession = Depends(get_db), ): """Écran d'accueil dynamique — retourne les modules selon les postes du membre.""" + if is_sysadmin(membre): + modules_saisie_sysadmin = [] + codes_vus = set() + for module in [MODULES_PAR_CODE["S1"], MODULES_PAR_CODE["S0"], MODULES_PAR_CODE["M0"], *MODULES_SAISIE_TOUS, *[m for mods in MODULES_SAISIE.values() for m in mods]]: + if module.code not in codes_vus: + modules_saisie_sysadmin.append(module) + codes_vus.add(module.code) + groupe = await db.get(Groupe, membre.groupe_id) + groupe_info = None + if groupe: + groupe_info = { + "id": str(groupe.id), + "nom": groupe.nom, + "jour_reunion": groupe.jour_reunion, + "heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None, + "assemblee_affaires_ordre": groupe.assemblee_affaires_ordre, + "adresse_local": groupe.adresse_local, + } + return AccueilResponse( + membre=MembrePublic.model_validate(membre), + postes=["Sysadmin"], + est_executif=True, + modules_consultation=MODULES_CONSULTATION, + modules_saisie=modules_saisie_sysadmin, + modules_gestion=MODULES_GESTION, + groupe=groupe_info, + ) + # Récupérer les affectations actives result = await db.execute( select(Affectation).join(Poste).where( @@ -106,13 +132,15 @@ async def accueil( # Modules de saisie : collecte pour tous + modules par poste modules_saisie = list(MODULES_SAISIE_TOUS) - codes_vus = {"S1", "S5"} + codes_vus = {"S5"} result = await db.execute(select(PosteModule).where(PosteModule.poste_id.in_(poste_ids))) if poste_ids else None modules_configures = result.scalars().all() if result else [] if modules_configures: codes = sorted({pm.module_code for pm in modules_configures}) for code in codes: + if code == "S1" and "executif" not in postes_categories: + continue m = MODULES_PAR_CODE.get(code) if m and not code.startswith("G") and m.code not in codes_vus: modules_saisie.append(m) @@ -123,6 +151,13 @@ async def accueil( if m.code not in codes_vus: modules_saisie.append(m) codes_vus.add(m.code) + if "executif" in postes_categories and "S1" not in codes_vus: + modules_saisie.insert(0, MODULES_PAR_CODE["M0"]) + modules_saisie.insert(0, MODULES_PAR_CODE["S0"]) + modules_saisie.insert(0, ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green")) + codes_vus.add("S1") + codes_vus.add("S0") + codes_vus.add("M0") # Modules de gestion : seulement si exécutif if modules_configures: @@ -143,12 +178,14 @@ async def accueil( "nom": groupe.nom, "jour_reunion": groupe.jour_reunion, "heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None, + "assemblee_affaires_ordre": groupe.assemblee_affaires_ordre, "adresse_local": groupe.adresse_local, } return AccueilResponse( membre=MembrePublic.model_validate(membre), postes=postes_noms, + est_executif="executif" in postes_categories, modules_consultation=modules_consultation, modules_saisie=modules_saisie, modules_gestion=modules_gestion, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index fce6d16..67f22c9 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.core.database import get_db -from app.core.security import hash_pin, verify_pin, create_token +from app.core.security import get_current_membre, hash_pin, verify_pin, create_token, is_sysadmin from app.models.membre import Membre from app.models.groupe import Groupe from app.models.invitation import Invitation @@ -36,6 +36,7 @@ async def liste_groupes(db: AsyncSession = Depends(get_db)): "adresse_local": g.adresse_local, "jour_reunion": g.jour_reunion, "heure_reunion": g.heure_reunion.strftime("%H:%M") if g.heure_reunion else None, + "assemblee_affaires_ordre": g.assemblee_affaires_ordre, "format_reunion": g.format_reunion, } for g in groupes] @@ -63,6 +64,25 @@ async def connexion(req: ConnexionRequest, db: AsyncSession = Depends(get_db)): ) +@router.post("/impersoner/{membre_id}", response_model=TokenResponse) +async def impersoner_membre( + membre_id: str, + admin=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + if not is_sysadmin(admin): + raise HTTPException(status_code=403, detail="Réservé au sysadmin") + cible = await db.get(Membre, membre_id) + if not cible or cible.groupe_id != admin.groupe_id or not cible.actif: + raise HTTPException(status_code=404, detail="Membre introuvable ou inactif") + setattr(cible, "_sysadmin_session", True) + token = create_token(str(cible.id), str(cible.groupe_id), admin_id=str(admin.id)) + return TokenResponse( + access_token=token, + membre=MembrePublic.model_validate(cible), + ) + + @router.post("/inscription", response_model=TokenResponse) async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db)): # Valider le code d'invitation diff --git a/backend/app/routers/collectes.py b/backend/app/routers/collectes.py index 7a7be62..4c8afbc 100644 --- a/backend/app/routers/collectes.py +++ b/backend/app/routers/collectes.py @@ -1,11 +1,13 @@ """Routes S1 — Collecte 7e Tradition.""" -from datetime import date, datetime, time as dt_time, timezone +from datetime import date, datetime, time as dt_time, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, extract, func from app.core.database import get_db -from app.core.security import get_current_membre, require_executif +from app.core.roles import membre_a_poste_ou_executif, require_tresorier +from app.core.reunions import JOURS_SEMAINE +from app.core.security import get_current_membre, require_executif, is_sysadmin from app.models.collecte import Collecte from app.models.groupe import Groupe from app.models.membre import Membre @@ -13,34 +15,16 @@ from app.models.depense import Depense from app.models.envoi_contribution import EnvoiContribution from app.models.operation_bancaire import OperationBancaire from app.models.reserve import Reserve -from app.models.affectation import Affectation -from app.models.poste import Poste +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 router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"]) -JOURS_SEMAINE = { - "lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3, - "vendredi": 4, "samedi": 5, "dimanche": 6, -} - async def _est_tresorier(membre_id, groupe_id, db: AsyncSession) -> bool: """Vérifie si un membre est trésorier ou exécutif.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre_id, - Affectation.statut == "actif", - (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), - ) - ) - return result.scalars().first() is not None - - -async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - if not await _est_tresorier(membre.id, membre.groupe_id, db): - raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif") - return membre + return await membre_a_poste_ou_executif(membre_id, "trésorier", db) async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut: @@ -66,28 +50,41 @@ async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut: @router.post("/", response_model=CollecteOut) async def saisir_collecte( req: CollecteCreate, - membre=Depends(get_current_membre), + membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): - """S1 — N'importe quel membre saisit la collecte. Rétroactif possible sur les dates de réunion.""" + """S1 — Un membre exécutif saisit la collecte.""" groupe = await db.get(Groupe, membre.groupe_id) aujourdhui = date.today() + retro_sysadmin = is_sysadmin(membre) # Validation : pas dans le futur - if req.date_collecte > aujourdhui: + if req.date_collecte > aujourdhui and not retro_sysadmin: raise HTTPException(status_code=400, detail="La date ne peut pas être dans le futur") # Validation : la date doit tomber un jour de réunion if groupe.jour_reunion: jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower()) - if jour_attendu is not None and req.date_collecte.weekday() != jour_attendu: + if jour_attendu is not None and req.date_collecte.weekday() != jour_attendu and not retro_sysadmin: raise HTTPException( status_code=400, detail=f"La collecte doit avoir lieu un {groupe.jour_reunion}", ) + if jour_attendu is not None and not retro_sysadmin: + dernieres_reunions = [] + d = aujourdhui + while len(dernieres_reunions) < 52: + if d.weekday() == jour_attendu: + dernieres_reunions.append(d) + d -= timedelta(days=1) + if req.date_collecte not in dernieres_reunions: + raise HTTPException( + status_code=400, + detail="La date doit faire partie des 52 dernières rencontres du groupe", + ) # Validation : si c'est aujourd'hui, pas avant l'heure de la réunion - if req.date_collecte == aujourdhui and groupe.heure_reunion: + if req.date_collecte == aujourdhui and groupe.heure_reunion and not retro_sysadmin: now = datetime.now(timezone.utc) if isinstance(groupe.heure_reunion, dt_time) and now.time() < groupe.heure_reunion: raise HTTPException( @@ -126,10 +123,10 @@ async def saisir_collecte( async def ajuster_collecte( collecte_id: str, req: CollecteUpdate, - membre=Depends(get_current_membre), + membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): - """Ajuster le montant (exécutif ou celui qui a saisi, avant confirmation).""" + """Ajuster le montant avant confirmation.""" result = await db.execute( select(Collecte).where( Collecte.id == collecte_id, @@ -142,11 +139,6 @@ async def ajuster_collecte( if collecte.statut != "en_attente": raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation") - # Vérifier : exécutif ou saisi_par - est_exec = await _est_tresorier(membre.id, membre.groupe_id, db) - if collecte.saisi_par != membre.id and not est_exec: - raise HTTPException(status_code=403, detail="Seul un exécutif ou la personne qui a saisi peut modifier") - if req.montant is not None: collecte.montant = req.montant await db.flush() @@ -156,7 +148,7 @@ async def ajuster_collecte( @router.post("/{collecte_id}/confirmer", response_model=CollecteOut) async def confirmer_collecte( collecte_id: str, - membre=Depends(get_current_membre), + membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): """Un second membre confirme le montant. Si le trésorier est impliqué → réception auto.""" @@ -186,6 +178,7 @@ async def confirmer_collecte( collecte.statut = "recu" collecte.recu_par = tresorier_id collecte.date_reception = date.today() + 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" @@ -196,7 +189,7 @@ async def confirmer_collecte( @router.post("/{collecte_id}/reception", response_model=CollecteOut) async def accuser_reception( collecte_id: str, - membre=Depends(_require_tresorier), + membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): """Le trésorier accuse réception (pour les cas sans trésorier impliqué).""" @@ -220,6 +213,7 @@ async def accuser_reception( collecte.statut = "recu" 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 db.flush() return await _to_out(collecte, db) @@ -230,7 +224,7 @@ async def liste_collectes( mois: int | None = None, annee: int | None = None, limite: int = 20, - membre=Depends(get_current_membre), + membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): query = select(Collecte).where(Collecte.groupe_id == membre.groupe_id) @@ -257,8 +251,6 @@ async def soldes_tresorerie( from app.models.vente_litterature import VenteLitterature gid = membre.groupe_id - - # ── Dernier relevé bancaire ── result = await db.execute( select(OperationBancaire).where( OperationBancaire.groupe_id == gid, @@ -266,54 +258,10 @@ async def soldes_tresorerie( ).order_by(desc(OperationBancaire.date_operation)).limit(1) ) dernier_releve = result.scalar_one_or_none() - solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0 date_releve = str(dernier_releve.date_operation) if dernier_releve else None - # ── Dépôts et retraits depuis le dernier relevé ── - filtre_depuis = dernier_releve.date_operation if dernier_releve else None - - # Dépôts après relevé - q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == gid, - OperationBancaire.type == "depot", - ) - if filtre_depuis: - q = q.where(OperationBancaire.date_operation > filtre_depuis) - result = await db.execute(q) - depots_depuis = float(result.scalar()) - - # Retraits après relevé - q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == gid, - OperationBancaire.type == "retrait", - ) - if filtre_depuis: - q = q.where(OperationBancaire.date_operation > filtre_depuis) - result = await db.execute(q) - retraits_depuis = float(result.scalar()) - - # Contributions débitées après relevé - q = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( - EnvoiContribution.groupe_id == gid, - EnvoiContribution.debitee == True, - ) - if filtre_depuis: - q = q.where(EnvoiContribution.date_envoi > filtre_depuis) - result = await db.execute(q) - contributions_debitees_depuis = float(result.scalar()) - - # Dépenses bancaires débitées après relevé - q = select(func.coalesce(func.sum(Depense.montant), 0)).where( - Depense.groupe_id == gid, - Depense.paye == True, - Depense.methode.in_(["VIREMENT", "CHEQUE"]), - ) - if filtre_depuis: - q = q.where(Depense.date_confirmation > filtre_depuis) - result = await db.execute(q) - depenses_bancaires_depuis = float(result.scalar()) - - solde_banque = solde_releve + depots_depuis - retraits_depuis - contributions_debitees_depuis - depenses_bancaires_depuis + solde_banque_actuel = await solde_banque(gid, db) + encaisse_actuelle = await encaisse(gid, db) # ── Dépenses engagées non payées (chèque/virement votés mais pas encore débités) ── result = await db.execute( @@ -336,7 +284,6 @@ async def soldes_tresorerie( contributions_non_debitees = float(result.scalar()) engagements_non_debites += contributions_non_debitees - # ── Réserves ── result = await db.execute( select(Reserve).where( Reserve.groupe_id == gid, @@ -344,49 +291,20 @@ async def soldes_tresorerie( ).order_by(Reserve.nom) ) reserves = result.scalars().all() - reserves_details = [ - { + reserves_details = [] + for r in reserves: + montant = await solde_reserve(r.id, db) + r.montant = montant + reserves_details.append({ "id": str(r.id), "nom": r.nom, - "montant": float(r.montant), + "montant": round(montant, 2), "note": r.note, - } - for r in reserves - ] - total_reserves = sum(r["montant"] for r in reserves_details) + }) + total_reserves_reelles = sum(r["montant"] for r in reserves_details) # ── Somme disponible ── - disponible = solde_banque - engagements_non_debites - total_reserves - - # ── Encaisse (cash reçu par le trésorier, pas déposé) ── - result = await db.execute( - select(func.coalesce(func.sum(Collecte.montant), 0)).where( - Collecte.groupe_id == gid, Collecte.statut == "recu")) - total_collectes_recues = float(result.scalar()) - - result = await db.execute( - select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot")) - total_depots = float(result.scalar()) - - result = await db.execute( - select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait")) - total_retraits = float(result.scalar()) - - result = await db.execute( - select(func.coalesce(func.sum(Depense.montant), 0)).where( - Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE")) - depenses_cash = float(result.scalar()) - - # Ventes de littérature remises au trésorier - result = await db.execute( - select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where( - VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True)) - ventes_recues = float(result.scalar()) - - encaisse = total_collectes_recues + ventes_recues + total_retraits - total_depots - depenses_cash - + disponible = solde_banque_actuel + encaisse_actuelle - engagements_non_debites - total_reserves_reelles # ── Argent en circulation (collectes non remises au trésorier) ── result = await db.execute( select(Collecte).where( @@ -398,6 +316,7 @@ async def soldes_tresorerie( det = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None total_en_circulation += float(c.montant) details_circulation.append({ + "id": str(c.id), "date": str(c.date_collecte), "montant": float(c.montant), "detenteur": det.prenom if det else "?", @@ -428,14 +347,15 @@ async def soldes_tresorerie( }) return { - "solde_banque": round(solde_banque, 2), + "solde_banque": round(solde_banque_actuel, 2), "date_releve": date_releve, - "encaisse": round(encaisse, 2), + "encaisse": round(encaisse_actuelle, 2), "disponible": round(disponible, 2), "engagements_non_debites": round(engagements_non_debites, 2), "reserve_prudente": 0, "reserve_anniversaire": 0, - "total_reserves": round(total_reserves, 2), + "total_reserves": round(total_reserves_reelles, 2), + "total_reserves_reelles": round(total_reserves_reelles, 2), "reserves_details": reserves_details, "en_possession": round(total_en_circulation, 2), "en_possession_details": details_circulation, @@ -455,9 +375,11 @@ async def registre_transactions( """Registre détaillé de toutes les transactions financières du groupe.""" from app.models.vente_litterature import VenteLitterature from app.models.litterature import Litterature + from app.models.transaction_comptable import TransactionComptable gid = membre.groupe_id transactions = [] + dons_operation_ids = set() # Filtres date def filtre_mois(d): @@ -544,7 +466,30 @@ async def registre_transactions( "par": "Trésorier", }) - # 5. Opérations bancaires (dépôts/retraits) + # 5. Dons déposés directement à la banque + result = await db.execute( + select(TransactionComptable).where( + TransactionComptable.groupe_id == gid, + TransactionComptable.type == "don", + TransactionComptable.compte == "banque", + TransactionComptable.sens == "debit", + ).order_by(TransactionComptable.date_transaction) + ) + for don in result.scalars().all(): + if filtre_mois(don.date_transaction): + m = await db.get(Membre, don.cree_par) if don.cree_par else None + if don.source_id: + dons_operation_ids.add(don.source_id) + ajouter_transaction({ + "date": str(don.date_transaction), + "type": "don", + "description": f"Don déposé directement{(' — ' + don.note) if don.note else ''}", + "entree": float(don.montant), + "sortie": 0, + "par": m.prenom if m else "?", + }) + + # 6. Opérations bancaires (dépôts/retraits) result = await db.execute( select(OperationBancaire).where( OperationBancaire.groupe_id == gid, @@ -552,6 +497,8 @@ async def registre_transactions( ).order_by(OperationBancaire.date_operation) ) for op in result.scalars().all(): + if op.id in dons_operation_ids: + continue if filtre_mois(op.date_operation): m = await db.get(Membre, op.effectue_par) is_depot = op.type == "depot" @@ -564,5 +511,27 @@ async def registre_transactions( "par": m.prenom if m else "?", }) - transactions.sort(key=lambda t: t["date"]) + # 7. Mouvements virtuels entre le disponible et les réserves + result = await db.execute( + select(MouvementReserve).where(MouvementReserve.groupe_id == gid) + .order_by(MouvementReserve.cree_le) + ) + for mv in result.scalars().all(): + date_mouvement = mv.cree_le.date() + if filtre_mois(date_mouvement): + source = await db.get(Reserve, mv.source_id) if mv.source_id else None + destination = await db.get(Reserve, mv.destination_id) if mv.destination_id else None + createur = await db.get(Membre, mv.cree_par) if mv.cree_par else None + source_nom = source.nom if source else "Disponible" + destination_nom = destination.nom if destination else "Disponible" + ajouter_transaction({ + "date": str(date_mouvement), + "type": "reserve_virement", + "description": f"Virement de réserve : {source_nom} → {destination_nom} ({float(mv.montant):.2f} $)", + "entree": 0, + "sortie": 0, + "par": createur.prenom if createur else "?", + }) + + transactions.sort(key=lambda t: t["date"], reverse=True) return transactions diff --git a/backend/app/routers/commandes_jetons.py b/backend/app/routers/commandes_jetons.py index 2f0aff2..c8cadb5 100644 --- a/backend/app/routers/commandes_jetons.py +++ b/backend/app/routers/commandes_jetons.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.core.database import get_db -from app.core.security import get_current_membre +from app.core.security import get_current_membre, is_sysadmin from app.models.jeton import Jeton from app.models.affectation import Affectation from app.models.poste import Poste @@ -17,6 +17,8 @@ router = APIRouter(prefix="/commandes-jetons", tags=["M6 — Commander jetons"]) async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): """Vérifie que le membre est responsable des jetons.""" + if is_sysadmin(membre): + return membre result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, diff --git a/backend/app/routers/contributions.py b/backend/app/routers/contributions.py index 18507ff..365f14f 100644 --- a/backend/app/routers/contributions.py +++ b/backend/app/routers/contributions.py @@ -2,15 +2,17 @@ from datetime import date from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, func +from sqlalchemy import select, desc from app.core.database import get_db +from app.core.decisions import prochain_numero_decision +from app.core.roles import require_tresorier +from app.core.security import is_sysadmin from app.core.security import get_current_membre from app.models.envoi_contribution import EnvoiContribution from app.models.config_repartition import ConfigRepartition from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste +from app.services.finance import solde_banque, total_reserves, transaction from app.schemas.phase7 import ( EnvoiContributionCreate, EnvoiContributionOut, CalculRepartitionIn, CalculRepartitionOut, @@ -28,33 +30,6 @@ DEFAUT_REPARTITION = { } -async def _prochain_numero_resolution(groupe_id, annee: int, db: AsyncSession) -> str: - from app.models.proposition import Proposition - - result = await db.execute( - select(func.count(Proposition.id)).where( - Proposition.groupe_id == groupe_id, - Proposition.numero_resolution.isnot(None), - Proposition.numero_resolution.like(f"RES-{annee}-%"), - ) - ) - count = result.scalar() or 0 - return f"RES-{annee}-{count + 1:03d}" - - -async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - Poste.nom.ilike("%trésorier%"), - ) - ) - if not result.scalars().first(): - raise HTTPException(status_code=403, detail="Réservé au trésorier") - return membre - - async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]: """Récupère la répartition active ou utilise les défauts.""" result = await db.execute( @@ -123,7 +98,8 @@ async def enregistrer_envoi( if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif: raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif") - numero_resolution = await _prochain_numero_resolution(membre.groupe_id, date.today().year, db) + date_decision = req.date_envoi if is_sysadmin(membre) else date.today() + numero_resolution = await prochain_numero_decision(membre.groupe_id, "contribution", date_decision.year, db) resolution = Proposition( groupe_id=membre.groupe_id, type_proposition="contribution", @@ -136,7 +112,9 @@ async def enregistrer_envoi( secondeur_id=req.secondeur_id, statut="adoptee", numero_resolution=numero_resolution, - date_vote=date.today(), + date_vote=date_decision, + date_adoption=date_decision, + groupe_a_decide_de=f"Envoyer {req.montant:.2f} $ à {DESTINATIONS_LABELS[req.destination]}.", ) db.add(resolution) await db.flush() @@ -189,17 +167,16 @@ async def confirmer_debit_contribution( if envoi.debitee: raise HTTPException(status_code=400, detail="Contribution déjà débitée") - from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves - - solde_banque = await _solde_banque_brut(membre.groupe_id, db) - if float(envoi.montant) > solde_banque: + solde_banque_actuel = await solde_banque(membre.groupe_id, db) + if float(envoi.montant) > solde_banque_actuel: raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit") - total_reserves = await _total_reserves(membre.groupe_id, db) - if solde_banque - float(envoi.montant) < total_reserves: + total_reserves_actuel = await total_reserves(membre.groupe_id, db) + if solde_banque_actuel - float(envoi.montant) < total_reserves_actuel: raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque") 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 db.flush() saisi = await db.get(Membre, envoi.saisi_par) if envoi.saisi_par else None diff --git a/backend/app/routers/decisions.py b/backend/app/routers/decisions.py deleted file mode 100644 index 52ade4a..0000000 --- a/backend/app/routers/decisions.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Routes C5 — Décisions en cours.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc - -from app.core.database import get_db -from app.core.security import get_current_membre, require_executif -from app.models.decision import Decision -from app.models.membre import Membre -from app.schemas.phase2 import DecisionCreate, DecisionOut, DecisionUpdate - -router = APIRouter(prefix="/decisions", tags=["C5 — Décisions en cours"]) - - -async def _to_out(d: Decision, db: AsyncSession) -> DecisionOut: - resp = await db.get(Membre, d.responsable_id) if d.responsable_id else None - return DecisionOut( - id=d.id, - sujet=d.sujet, - description=d.description, - statut=d.statut, - responsable_prenom=resp.prenom if resp else None, - proposeur=d.proposeur, - secondeur=d.secondeur, - date_decision=d.date_decision, - date_completion=d.date_completion, - cree_le=d.cree_le, - ) - - -@router.get("/", response_model=list[DecisionOut]) -async def liste_decisions( - statut: str | None = None, - limite: int = 50, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C5 — Lister les décisions du groupe avec filtre optionnel par statut.""" - query = select(Decision).where(Decision.groupe_id == membre.groupe_id) - if statut: - query = query.where(Decision.statut == statut) - query = query.order_by(desc(Decision.date_decision)).limit(limite) - result = await db.execute(query) - return [await _to_out(d, db) for d in result.scalars().all()] - - -@router.get("/compteur") -async def compteur_decisions( - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Nombre de décisions ouvertes (pour le badge sur la tuile).""" - result = await db.execute( - select(Decision).where( - Decision.groupe_id == membre.groupe_id, - Decision.statut.in_(["en_attente", "en_cours"]), - ) - ) - return {"ouvertes": len(result.scalars().all())} - - -@router.post("/", response_model=DecisionOut) -async def creer_decision( - req: DecisionCreate, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """Créer une décision (exécutif — typiquement via le PV).""" - decision = Decision( - groupe_id=executif.groupe_id, - reunion_id=req.reunion_id, - sujet=req.sujet.strip(), - description=req.description, - statut=req.statut, - responsable_id=req.responsable_id, - proposeur=req.proposeur, - secondeur=req.secondeur, - date_decision=req.date_decision, - cree_par=executif.id, - ) - db.add(decision) - await db.flush() - return await _to_out(decision, db) - - -@router.patch("/{decision_id}", response_model=DecisionOut) -async def modifier_decision( - decision_id: str, - req: DecisionUpdate, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """Modifier le statut d'une décision (exécutif).""" - result = await db.execute( - select(Decision).where(Decision.id == decision_id, Decision.groupe_id == executif.groupe_id) - ) - decision = result.scalar_one_or_none() - if not decision: - raise HTTPException(status_code=404, detail="Décision introuvable") - - if req.statut is not None: - decision.statut = req.statut - if req.description is not None: - decision.description = req.description - if req.responsable_id is not None: - decision.responsable_id = req.responsable_id - if req.date_completion is not None: - decision.date_completion = req.date_completion - await db.flush() - return await _to_out(decision, db) diff --git a/backend/app/routers/depenses.py b/backend/app/routers/depenses.py index 9fd863e..9d3a682 100644 --- a/backend/app/routers/depenses.py +++ b/backend/app/routers/depenses.py @@ -1,42 +1,24 @@ """Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier).""" import os -import uuid as uuid_mod -from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from datetime import date, datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from app.core.database import get_db -from app.core.security import get_current_membre +from app.core.roles import membre_a_poste_ou_executif, require_tresorier +from app.core.security import get_current_membre, is_sysadmin from app.models.depense import Depense from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate +from app.services.finance import encaisse, engagements_non_debites, solde_banque, total_reserves, transaction router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"]) -METHODES_VALIDES = {"DU", "VIREMENT", "CHEQUE", "COLLECTE"} +METHODES_VALIDES = {"VIREMENT", "CHEQUE", "COLLECTE"} UPLOAD_DIR = "/opt/groupe-meditation/uploads/preuves" -async def _est_tresorier_ou_executif(membre_id, db: AsyncSession) -> bool: - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre_id, - Affectation.statut == "actif", - (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), - ) - ) - return result.scalars().first() is not None - - -async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - if not await _est_tresorier_ou_executif(membre.id, db): - raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif") - return membre - - async def _to_out(d: Depense, db: AsyncSession) -> DepenseOut: qui = await db.get(Membre, d.qui) saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None @@ -61,12 +43,21 @@ async def soumettre_depense( membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """S5 — N'importe quel membre soumet une dépense courante.""" + """S5 — Un membre soumet une dépense pour lui-même; un exécutif peut le faire pour un autre.""" + qui_id = membre.id + if req.membre_id and req.membre_id != membre.id: + if not await membre_a_poste_ou_executif(membre.id, "trésorier", db): + raise HTTPException(status_code=403, detail="Seul un exécutif peut soumettre une dépense pour un autre membre") + beneficiaire = await db.get(Membre, req.membre_id) + if not beneficiaire or beneficiaire.groupe_id != membre.groupe_id or not beneficiaire.actif: + raise HTTPException(status_code=404, detail="Membre introuvable ou inactif") + qui_id = beneficiaire.id + depense = Depense( groupe_id=membre.groupe_id, - date_depense=req.date_depense, + date_depense=req.date_depense if req.date_depense and is_sysadmin(membre) else date.today(), montant=req.montant, - qui=membre.id, # le membre qui soumet est celui qui a payé + qui=qui_id, quoi=req.quoi.strip(), paye=False, methode=None, # sera renseigné par le trésorier @@ -110,7 +101,7 @@ async def telecharger_preuve( async def confirmer_depense( depense_id: str, methode: str, - membre=Depends(_require_tresorier), + membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): """Le trésorier confirme la dépense et renseigne le mode de paiement.""" @@ -124,13 +115,23 @@ async def confirmer_depense( depense = result.scalar_one_or_none() if not depense: raise HTTPException(status_code=404, detail="Dépense introuvable") + if depense.confirme_par is not None: + raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée") if methode_norm == "COLLECTE": - from app.routers.operations_bancaires import _encaisse - - encaisse = await _encaisse(membre.groupe_id, db) - if float(depense.montant) > encaisse: + solde_encaisse = await encaisse(membre.groupe_id, db) + if float(depense.montant) > solde_encaisse: raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense") + elif methode_norm in ("CHEQUE", "VIREMENT"): + solde_banque_actuel = await solde_banque(membre.groupe_id, db) + total_reserves_actuel = await total_reserves(membre.groupe_id, db) + engagements = await engagements_non_debites(membre.groupe_id, db) + disponible = solde_banque_actuel - total_reserves_actuel - engagements + if float(depense.montant) > disponible: + raise HTTPException( + status_code=400, + detail="Le solde bancaire disponible est insuffisant pour engager cette dépense", + ) depense.methode = methode_norm depense.confirme_par = membre.id @@ -139,8 +140,8 @@ async def confirmer_depense( # Si cash (COLLECTE) → payé immédiatement if methode_norm == "COLLECTE": depense.paye = True + db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "encaisse", depense.montant, "credit", "depenses", depense.id, depense.quoi)) - # Si DU → le groupe doit rembourser, pas encore payé # Si CHEQUE/VIREMENT → engagement, en attente de débit await db.flush() @@ -150,7 +151,7 @@ async def confirmer_depense( @router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut) async def confirmer_debit( depense_id: str, - membre=Depends(_require_tresorier), + membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): """Le trésorier confirme que le chèque/virement a été débité du compte.""" @@ -165,20 +166,46 @@ async def confirmer_debit( if depense.methode not in ("CHEQUE", "VIREMENT"): raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit") - from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves - - solde_banque = await _solde_banque_brut(membre.groupe_id, db) - if float(depense.montant) > solde_banque: + solde_banque_actuel = await solde_banque(membre.groupe_id, db) + if float(depense.montant) > solde_banque_actuel: raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit") - total_reserves = await _total_reserves(membre.groupe_id, db) - if solde_banque - float(depense.montant) < total_reserves: + total_reserves_actuel = await total_reserves(membre.groupe_id, db) + if solde_banque_actuel - float(depense.montant) < total_reserves_actuel: raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque") 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 db.flush() return await _to_out(depense, db) +@router.delete("/{depense_id}") +async def rejeter_depense( + depense_id: str, + 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) + ) + depense = result.scalar_one_or_none() + if not depense: + raise HTTPException(status_code=404, detail="Dépense introuvable") + 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") + + if depense.preuve_nom: + chemin = os.path.join(UPLOAD_DIR, depense.preuve_nom) + if os.path.exists(chemin): + os.remove(chemin) + + await db.delete(depense) + await db.flush() + return {"message": "Dépense rejetée"} + + @router.get("/", response_model=list[DepenseOut]) async def liste_depenses( limite: int = 30, @@ -204,7 +231,7 @@ async def liste_depenses( @router.get("/a-confirmer") async def depenses_a_confirmer( - membre=Depends(_require_tresorier), + membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): """Dépenses soumises en attente de confirmation par le trésorier.""" @@ -221,7 +248,7 @@ async def depenses_a_confirmer( async def modifier_depense( depense_id: str, req: DepenseUpdate, - membre=Depends(_require_tresorier), + membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): """Modifier une dépense (trésorier).""" diff --git a/backend/app/routers/evenements.py b/backend/app/routers/evenements.py index 35f55c7..2373dc3 100644 --- a/backend/app/routers/evenements.py +++ b/backend/app/routers/evenements.py @@ -1,8 +1,8 @@ -"""Routes — Événements (anniversaires + ad hoc + résolutions).""" +"""Routes — Événements (anniversaires + ad hoc).""" from datetime import date, timedelta from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc +from sqlalchemy import select from app.core.database import get_db from app.core.security import get_current_membre, require_executif @@ -11,7 +11,6 @@ from app.models.membre import Membre from app.models.affectation import Affectation from app.models.poste import Poste from app.models.evenement import Evenement -from app.models.proposition import Proposition router = APIRouter(prefix="/evenements", tags=["Événements"]) @@ -31,7 +30,7 @@ async def evenements_a_venir( membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """Tous les événements : anniversaires, ad hoc et résolutions.""" + """Tous les événements : anniversaires et ad hoc.""" aujourdhui = date.today() debut = aujourdhui - timedelta(days=jours_avant) limite = aujourdhui + timedelta(days=jours) @@ -84,24 +83,6 @@ async def evenements_a_venir( "groupe_nom": groupe_nom, }) - # ── 3. Résolutions adoptées du groupe ── - result = await db.execute( - select(Proposition).where( - Proposition.groupe_id == membre.groupe_id, - Proposition.statut == "adoptee", - ).order_by(desc(Proposition.date_vote)) - ) - for p in result.scalars().all(): - date_res = str(p.date_vote) if p.date_vote else str(p.cree_le.date()) - evenements.append({ - "type": "resolution", - "date": date_res, - "titre": f"📜 {p.numero_resolution or 'RES'} — {p.sujet}", - "detail": p.description or p.note, - "lieu": None, - "groupe_nom": groupe.nom if groupe else "?", - }) - # Trier par date evenements.sort(key=lambda e: e["date"]) return evenements diff --git a/backend/app/routers/jetons.py b/backend/app/routers/jetons.py index bb24a88..337c765 100644 --- a/backend/app/routers/jetons.py +++ b/backend/app/routers/jetons.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from app.core.database import get_db -from app.core.security import get_current_membre +from app.core.security import get_current_membre, is_sysadmin from app.models.jeton import Jeton from app.models.membre import Membre from app.models.affectation import Affectation @@ -16,11 +16,13 @@ router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"]) async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): """Vérifie que le membre est responsable des jetons.""" + if is_sysadmin(membre): + return membre result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, Affectation.statut == "actif", - Poste.nom.ilike("%jeton%"), + (Poste.nom.ilike("%jeton%") | (Poste.categorie == "executif")), ) ) if not result.scalars().first(): diff --git a/backend/app/routers/journal.py b/backend/app/routers/journal.py new file mode 100644 index 0000000..ad2e04b --- /dev/null +++ b/backend/app/routers/journal.py @@ -0,0 +1,41 @@ +"""Consultation du journal d'audit.""" +from fastapi import APIRouter, Depends +from sqlalchemy import select +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 + +router = APIRouter(prefix="/journal", tags=["Journal"]) + + +@router.get("/actions") +async def journal_actions( + limite: int = 100, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + limite = max(1, min(limite, 500)) + result = await db.execute( + select(JournalAction) + .where(JournalAction.groupe_id == executif.groupe_id) + .order_by(JournalAction.cree_le.desc()) + .limit(limite) + ) + return [ + { + "id": str(item.id), + "groupe_id": str(item.groupe_id) if item.groupe_id else None, + "membre_id": str(item.membre_id) if item.membre_id else None, + "admin_id": str(item.admin_id) if item.admin_id else None, + "methode": item.methode, + "chemin": item.chemin, + "statut_http": item.statut_http, + "adresse_ip": item.adresse_ip, + "user_agent": item.user_agent, + "details": item.details, + "cree_le": item.cree_le.isoformat(), + } + for item in result.scalars().all() + ] diff --git a/backend/app/routers/litterature.py b/backend/app/routers/litterature.py index 9038752..de4f67c 100644 --- a/backend/app/routers/litterature.py +++ b/backend/app/routers/litterature.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.database import get_db -from app.core.security import get_current_membre +from app.core.security import get_current_membre, is_sysadmin from app.models.litterature import Litterature from app.models.affectation import Affectation from app.models.poste import Poste @@ -15,6 +15,8 @@ router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"]) async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): """Vérifie que le membre est responsable de la littérature.""" + if is_sysadmin(membre): + return membre result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py new file mode 100644 index 0000000..6828928 --- /dev/null +++ b/backend/app/routers/notifications.py @@ -0,0 +1,91 @@ +"""Routes de notifications Web Push.""" +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.push_subscription import PushSubscription +from app.services.notifications import notifications_configured, send_to_subscriptions + +router = APIRouter(prefix="/notifications", tags=["Notifications"]) + + +class PushSubscriptionIn(BaseModel): + endpoint: str + expirationTime: int | None = None + keys: dict + + +@router.get("/configuration") +async def configuration_notifications(): + return { + "enabled": notifications_configured(), + "public_key": settings.VAPID_PUBLIC_KEY, + } + + +@router.post("/abonnement") +async def enregistrer_abonnement( + abonnement: PushSubscriptionIn, + request: Request, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + if not notifications_configured(): + raise HTTPException(status_code=400, detail="Les notifications ne sont pas configurées sur ce serveur") + result = await db.execute(select(PushSubscription).where(PushSubscription.endpoint == abonnement.endpoint)) + sub = result.scalar_one_or_none() + if sub: + sub.groupe_id = membre.groupe_id + sub.membre_id = membre.id + sub.abonnement = abonnement.model_dump() + sub.user_agent = (request.headers.get("user-agent") or "")[:255] or None + else: + db.add(PushSubscription( + groupe_id=membre.groupe_id, + membre_id=membre.id, + endpoint=abonnement.endpoint, + abonnement=abonnement.model_dump(), + user_agent=(request.headers.get("user-agent") or "")[:255] or None, + )) + membre.push_sub = abonnement.model_dump() + await db.flush() + return {"message": "Notifications activées"} + + +@router.delete("/abonnement") +async def retirer_abonnement( + endpoint: str, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PushSubscription).where( + PushSubscription.endpoint == endpoint, + PushSubscription.membre_id == membre.id, + ) + ) + sub = result.scalar_one_or_none() + if sub: + await db.delete(sub) + membre.push_sub = None + await db.flush() + return {"message": "Notifications désactivées"} + + +@router.post("/test") +async def envoyer_test( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(PushSubscription).where(PushSubscription.membre_id == membre.id)) + sent = await send_to_subscriptions(db, result.scalars().all(), { + "title": "Notifications activées", + "body": "Cet appareil recevra les avis du groupe.", + "url": "/", + }) + await db.flush() + return {"message": "Notification de test envoyée", "sent": sent} diff --git a/backend/app/routers/operations_bancaires.py b/backend/app/routers/operations_bancaires.py index c662fc8..79b0e25 100644 --- a/backend/app/routers/operations_bancaires.py +++ b/backend/app/routers/operations_bancaires.py @@ -1,128 +1,87 @@ """Routes — Opérations bancaires (dépôts, retraits).""" from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, func +from sqlalchemy import select, desc from app.core.database import get_db +from app.core.roles import require_tresorier from app.core.security import get_current_membre from app.models.operation_bancaire import OperationBancaire -from app.models.collecte import Collecte -from app.models.depense import Depense +from app.models.transaction_comptable import TransactionComptable +from app.models.mouvement_reserve import MouvementReserve from app.models.reserve import Reserve -from app.models.vente_litterature import VenteLitterature from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveUpdate +from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut +from app.services.finance import encaisse, reserve_disponible, solde_banque, solde_reserve, total_reserves router = APIRouter(prefix="/banque", tags=["Opérations bancaires"]) -async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est trésorier ou exécutif.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), - ) +def _mouvement_reserve(groupe_id, membre_id, montant, type_mouvement, source_id=None, destination_id=None, note=None): + return MouvementReserve( + groupe_id=groupe_id, + source_id=source_id, + destination_id=destination_id, + montant=montant, + type=type_mouvement, + note=note, + cree_par=membre_id, ) - if not result.scalars().first(): - raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif") - return membre -async def _encaisse(groupe_id, db: AsyncSession) -> float: - total_collectes = float((await db.execute( - select(func.coalesce(func.sum(Collecte.montant), 0)).where( - Collecte.groupe_id == groupe_id, Collecte.statut == "recu") - )).scalar()) - ventes_recues = float((await db.execute( - select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where( - VenteLitterature.groupe_id == groupe_id, VenteLitterature.remis_tresorier == True) - )).scalar()) - retraits = float((await db.execute( - select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait") - )).scalar()) - depots = float((await db.execute( - select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "depot") - )).scalar()) - depenses_cash = float((await db.execute( - select(func.coalesce(func.sum(Depense.montant), 0)).where( - Depense.groupe_id == groupe_id, Depense.paye == True, Depense.methode == "COLLECTE") - )).scalar()) - return total_collectes + ventes_recues + retraits - depots - depenses_cash - - -async def _solde_banque_brut(groupe_id, db: AsyncSession) -> float: - from app.models.envoi_contribution import EnvoiContribution - - result = await db.execute( - select(OperationBancaire).where( - OperationBancaire.groupe_id == groupe_id, - OperationBancaire.type == "releve", - ).order_by(desc(OperationBancaire.date_operation)).limit(1) +def _ligne_comptable(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str): + return TransactionComptable( + groupe_id=groupe_id, + date_transaction=op.date_operation, + type=op.type, + compte=compte, + montant=op.montant, + sens=sens, + source_table="operations_bancaires", + source_id=op.id, + note=op.note, + cree_par=membre_id, ) - dernier_releve = result.scalar_one_or_none() - solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0 - filtre_depuis = dernier_releve.date_operation if dernier_releve else None - q_depots = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "depot") - q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait") - q_contribs = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( - EnvoiContribution.groupe_id == groupe_id, EnvoiContribution.debitee == True) - q_depenses = select(func.coalesce(func.sum(Depense.montant), 0)).where( - Depense.groupe_id == groupe_id, - Depense.paye == True, - Depense.methode.in_(["VIREMENT", "CHEQUE"]), + +def _ligne_comptable_montant(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str, montant: float): + return TransactionComptable( + groupe_id=groupe_id, + date_transaction=op.date_operation, + type=op.type, + compte=compte, + montant=montant, + sens=sens, + source_table="operations_bancaires", + source_id=op.id, + note=op.note, + cree_par=membre_id, ) - if filtre_depuis: - q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis) - q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis) - q_contribs = q_contribs.where(EnvoiContribution.date_envoi > filtre_depuis) - q_depenses = q_depenses.where(Depense.date_confirmation > filtre_depuis) - - depots = float((await db.execute(q_depots)).scalar()) - retraits = float((await db.execute(q_retraits)).scalar()) - contribs = float((await db.execute(q_contribs)).scalar()) - depenses = float((await db.execute(q_depenses)).scalar()) - return solde_releve + depots - retraits - contribs - depenses - - -async def _total_reserves(groupe_id, db: AsyncSession, exclure_id: str | None = None) -> float: - query = select(func.coalesce(func.sum(Reserve.montant), 0)).where( - Reserve.groupe_id == groupe_id, - Reserve.active == True, - ) - if exclure_id: - query = query.where(Reserve.id != exclure_id) - return float((await db.execute(query)).scalar()) @router.post("/", response_model=OperationBancaireOut) async def enregistrer_operation( req: OperationBancaireCreate, - membre=Depends(_require_tresorier), + membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): """Enregistrer un dépôt ou retrait bancaire (trésorier).""" if req.type not in ("depot", "retrait", "releve"): raise HTTPException(status_code=400, detail="Type doit être 'depot', 'retrait' ou 'releve'") + provenance_depot = (req.provenance_depot or "encaisse").lower() + if req.type == "depot" and provenance_depot not in ("encaisse", "don"): + raise HTTPException(status_code=400, detail="Provenance du dépôt invalide") if req.montant <= 0: raise HTTPException(status_code=400, detail="Le montant doit être positif") - solde_banque = await _solde_banque_brut(membre.groupe_id, db) - total_reserves = await _total_reserves(membre.groupe_id, db) - if req.type == "depot" and req.montant > await _encaisse(membre.groupe_id, db): + solde_banque_actuel = await solde_banque(membre.groupe_id, db) + total_reserves_actuel = await total_reserves(membre.groupe_id, db) + if req.type == "depot" and provenance_depot == "encaisse" and req.montant > await encaisse(membre.groupe_id, db): raise HTTPException(status_code=400, detail="Le dépôt ne peut pas dépasser l'encaisse disponible") - if req.type == "retrait" and req.montant > solde_banque: + if req.type == "retrait" and req.montant > solde_banque_actuel: raise HTTPException(status_code=400, detail="Le retrait ne peut pas dépasser le solde en banque") - if req.type == "retrait" and solde_banque - req.montant < total_reserves: + if req.type == "retrait" and solde_banque_actuel - req.montant < total_reserves_actuel: raise HTTPException(status_code=400, detail="Le retrait ferait dépasser les réserves par rapport au solde en banque") - if req.type == "releve" and req.montant < total_reserves: + if req.type == "releve" and req.montant < total_reserves_actuel: raise HTTPException(status_code=400, detail="Le relevé ne peut pas être inférieur au total des réserves") op = OperationBancaire( @@ -131,10 +90,39 @@ async def enregistrer_operation( montant=req.montant, date_operation=req.date_operation, effectue_par=membre.id, - note=req.note, + note=req.note or ("Don déposé directement au compte bancaire" if provenance_depot == "don" else None), ) db.add(op) await db.flush() + if op.type == "depot": + if provenance_depot == "don": + db.add(TransactionComptable( + groupe_id=membre.groupe_id, + date_transaction=op.date_operation, + type="don", + compte="banque", + montant=op.montant, + sens="debit", + source_table="operations_bancaires", + source_id=op.id, + note=op.note, + cree_par=membre.id, + )) + else: + db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "debit")) + db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "credit")) + elif op.type == "retrait": + db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "credit")) + db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "debit")) + else: + delta = req.montant - solde_banque_actuel + if delta != 0: + db.add(_ligne_comptable_montant( + membre.groupe_id, membre.id, op, "banque", + "debit" if delta > 0 else "credit", + abs(delta), + )) + await db.flush() return OperationBancaireOut( id=op.id, type=op.type, @@ -153,55 +141,154 @@ async def liste_reserves(membre=Depends(get_current_membre), db: AsyncSession = .order_by(Reserve.nom) ) return [ - ReserveOut(id=r.id, nom=r.nom, montant=float(r.montant), 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) for r in result.scalars().all() ] +@router.get("/registre-comptable", response_model=list[TransactionComptableOut]) +async def registre_comptable( + limite: int = 200, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(TransactionComptable) + .where(TransactionComptable.groupe_id == membre.groupe_id) + .order_by(desc(TransactionComptable.date_transaction), desc(TransactionComptable.cree_le)) + .limit(limite) + ) + transactions = result.scalars().all() + out = [] + for tx in transactions: + createur = await db.get(Membre, tx.cree_par) if tx.cree_par else None + out.append(TransactionComptableOut( + id=tx.id, + date_transaction=tx.date_transaction, + type=tx.type, + compte=tx.compte, + montant=float(tx.montant), + sens=tx.sens, + source_table=tx.source_table, + source_id=tx.source_id, + note=tx.note, + cree_par_prenom=createur.prenom if createur else None, + cree_le=tx.cree_le, + )) + return out + + @router.post("/reserves", response_model=ReserveOut) -async def creer_reserve(req: ReserveCreate, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)): +async def creer_reserve(req: ReserveCreate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)): if not req.nom.strip(): raise HTTPException(status_code=400, detail="Nom requis") - if req.montant < 0: - raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul") - total = await _total_reserves(membre.groupe_id, db) + req.montant - solde_banque = await _solde_banque_brut(membre.groupe_id, db) - if total > solde_banque: - raise HTTPException(status_code=400, detail="Le total des réserves ne peut pas dépasser le solde en banque") - reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=req.montant, note=req.note) + if req.montant != 0: + raise HTTPException(status_code=400, detail="Créer la réserve à zéro, puis utiliser un virement pour l'approvisionner") + 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=float(reserve.montant), 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) @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, 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") - montant = float(reserve.montant) if req.montant is None else req.montant - if montant < 0: - raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul") - total = await _total_reserves(membre.groupe_id, db, reserve_id) + montant - solde_banque = await _solde_banque_brut(membre.groupe_id, db) - if total > solde_banque: - raise HTTPException(status_code=400, detail="Le total des réserves ne peut pas dépasser le solde en banque") + 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") if req.nom is not None: if not req.nom.strip(): raise HTTPException(status_code=400, detail="Nom requis") reserve.nom = req.nom.strip() - reserve.montant = montant if req.note is not None: reserve.note = req.note.strip() or None + reserve.montant = await solde_reserve(reserve.id, db) 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) +@router.get("/reserves/mouvements", response_model=list[ReserveMouvementOut]) +async def liste_mouvements_reserves( + limite: int = 100, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(MouvementReserve) + .where(MouvementReserve.groupe_id == membre.groupe_id) + .order_by(desc(MouvementReserve.cree_le)) + .limit(limite) + ) + mouvements = result.scalars().all() + out = [] + for mv in mouvements: + source = await db.get(Reserve, mv.source_id) if mv.source_id else None + destination = await db.get(Reserve, mv.destination_id) if mv.destination_id else None + createur = await db.get(Membre, mv.cree_par) if mv.cree_par else None + out.append(ReserveMouvementOut( + id=mv.id, + source_id=mv.source_id, + source_nom=source.nom if source else "Disponible", + destination_id=mv.destination_id, + destination_nom=destination.nom if destination else "Disponible", + montant=float(mv.montant), + type=mv.type, + note=mv.note, + cree_par_prenom=createur.prenom if createur else None, + cree_le=mv.cree_le, + )) + return out + + +@router.post("/reserves/transfert") +async def transferer_reserve(req: ReserveTransfer, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)): + if req.montant <= 0: + raise HTTPException(status_code=400, detail="Le montant doit être positif") + if req.source_id == req.destination_id: + raise HTTPException(status_code=400, detail="La source et la destination doivent être différentes") + + source = None if req.source_id == "disponible" else await db.get(Reserve, req.source_id) + destination = None if req.destination_id == "disponible" else await db.get(Reserve, req.destination_id) + + if req.source_id != "disponible" and (not source or source.groupe_id != membre.groupe_id or not source.active): + raise HTTPException(status_code=404, detail="Réserve source introuvable") + if req.destination_id != "disponible" and (not destination or destination.groupe_id != membre.groupe_id or not destination.active): + raise HTTPException(status_code=404, detail="Réserve destination introuvable") + + if req.source_id == "disponible": + disponible = await reserve_disponible(membre.groupe_id, db) + if req.montant > disponible: + raise HTTPException(status_code=400, detail="La réserve disponible est insuffisante") + elif req.montant > await solde_reserve(source.id, db): + raise HTTPException(status_code=400, detail="La réserve source est insuffisante") + + if source: + source.montant = await solde_reserve(source.id, db) - req.montant + if destination: + destination.montant = await solde_reserve(destination.id, db) + req.montant + + db.add(_mouvement_reserve( + membre.groupe_id, + membre.id, + req.montant, + "transfert", + source_id=source.id if source else None, + destination_id=destination.id if destination else None, + )) + 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, 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") + 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 db.flush() return {"message": "Réserve supprimée"} diff --git a/backend/app/routers/parametres.py b/backend/app/routers/parametres.py index cef229f..1d682cb 100644 --- a/backend/app/routers/parametres.py +++ b/backend/app/routers/parametres.py @@ -10,6 +10,8 @@ from app.schemas.phase3 import GroupeUpdate, GroupeDetail router = APIRouter(prefix="/groupe", tags=["G9 — Paramètres"]) +ORDRES_ASSEMBLEE = {"premiere", "deuxieme", "troisieme", "quatrieme", "derniere"} + @router.get("/", response_model=GroupeDetail) async def detail_groupe( @@ -45,6 +47,10 @@ async def modifier_groupe( groupe.heure_reunion = None else: groupe.heure_reunion = None + if req.assemblee_affaires_ordre is not None: + ordre = req.assemblee_affaires_ordre.strip() + if ordre in ORDRES_ASSEMBLEE: + groupe.assemblee_affaires_ordre = ordre if req.format_reunion is not None: groupe.format_reunion = req.format_reunion.strip() if req.lien_aa87 is not None: diff --git a/backend/app/routers/postes.py b/backend/app/routers/postes.py index 7e4f714..9b6fd51 100644 --- a/backend/app/routers/postes.py +++ b/backend/app/routers/postes.py @@ -2,9 +2,10 @@ from datetime import date from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func +from sqlalchemy import select from app.core.database import get_db +from app.core.decisions import prochain_numero_decision from app.core.security import get_current_membre, require_executif from app.models.poste import Poste from app.models.affectation import Affectation @@ -16,20 +17,6 @@ from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, Termin router = APIRouter(prefix="/postes", tags=["Postes et affectations"]) -async def _prochain_numero_resolution(groupe_id, annee: int, db: AsyncSession) -> str: - from app.models.proposition import Proposition - - result = await db.execute( - select(func.count(Proposition.id)).where( - Proposition.groupe_id == groupe_id, - Proposition.numero_resolution.isnot(None), - Proposition.numero_resolution.like(f"RES-{annee}-%"), - ) - ) - count = result.scalar() or 0 - return f"RES-{annee}-{count + 1:03d}" - - # ── Postes ── @router.get("/", response_model=list[PosteOut]) @@ -501,7 +488,7 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe if result.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Ce poste est déjà affecté") - numero_resolution = await _prochain_numero_resolution(executif.groupe_id, req.date_debut.year, db) + numero_resolution = await prochain_numero_decision(executif.groupe_id, "nomination", req.date_debut.year, db) resolution = Proposition( groupe_id=executif.groupe_id, type_proposition="nomination", @@ -512,6 +499,8 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe statut="adoptee", numero_resolution=numero_resolution, date_vote=req.date_debut, + date_adoption=req.date_debut, + groupe_a_decide_de=f"Nommer {cible.prenom} au poste de {poste.nom}.", ) db.add(resolution) await db.flush() diff --git a/backend/app/routers/presences.py b/backend/app/routers/presences.py index 5656751..3696e5c 100644 --- a/backend/app/routers/presences.py +++ b/backend/app/routers/presences.py @@ -1,50 +1,22 @@ """Routes M2 — Présences en réunion d'affaires.""" -from datetime import date as dt_date -from calendar import monthrange from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, extract from app.core.database import get_db +from app.core.roles import require_secretaire +from app.core.reunions import date_assemblee_affaires_du_mois, jour_reunion_attendu from app.core.security import get_current_membre from app.models.reunion import Reunion from app.models.groupe import Groupe from app.models.presence import Presence from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste from app.schemas.phase4 import ( PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut, ) router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"]) -JOURS_SEMAINE = { - "lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3, - "vendredi": 4, "samedi": 5, "dimanche": 6, -} - - -def _premier_jour_du_mois(annee: int, mois: int, jour_cible: int) -> dt_date: - """Retourne la date du premier 'jour_cible' (0=lundi) du mois.""" - premier = dt_date(annee, mois, 1) - diff = (jour_cible - premier.weekday()) % 7 - return dt_date(annee, mois, 1 + diff) - - -async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)): - """Vérifie que le membre est secrétaire ou exécutif.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - (Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")), - ) - ) - if not result.scalars().first(): - raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif") - return membre - @router.post("/reunion", response_model=ReunionAffairesOut) async def creer_reunion_affaires( @@ -55,16 +27,17 @@ async def creer_reunion_affaires( """Créer une réunion d'affaires (point de départ pour présences et PV).""" groupe = await db.get(Groupe, membre.groupe_id) - # ── Validation : premier jour de réunion du mois, une seule par mois ── + # ── Validation : réunion d'affaires prévue dans les paramètres du groupe ── dt = req.date_reunion if groupe.jour_reunion: - jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower()) + jour_attendu = jour_reunion_attendu(groupe) if jour_attendu is not None: - premier = _premier_jour_du_mois(dt.year, dt.month, jour_attendu) - if dt.date() != premier: + ordre = groupe.assemblee_affaires_ordre or "premiere" + date_attendue = date_assemblee_affaires_du_mois(dt.year, dt.month, jour_attendu, ordre) + if dt.date() != date_attendue: raise HTTPException( status_code=400, - detail=f"La réunion d'affaires doit avoir lieu le premier {groupe.jour_reunion} du mois ({premier.isoformat()})", + detail=f"La réunion d'affaires doit avoir lieu à la date prévue ({date_attendue.isoformat()})", ) # Une seule réunion d'affaires par mois diff --git a/backend/app/routers/propositions.py b/backend/app/routers/propositions.py index 6e55934..823e707 100644 --- a/backend/app/routers/propositions.py +++ b/backend/app/routers/propositions.py @@ -2,9 +2,10 @@ from datetime import date, timezone, datetime from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, extract, func +from sqlalchemy import select, desc, extract from app.core.database import get_db +from app.core.decisions import prochain_numero_decision from app.core.security import get_current_membre, require_executif from app.models.proposition import Proposition from app.models.reunion import Reunion @@ -24,7 +25,7 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut: return PropositionOut( id=p.id, reunion_id=p.reunion_id, - type_proposition=p.type_proposition or "generale", + type_proposition=p.type_proposition or "proposition", sujet=p.sujet, description=p.description, proposeur_prenom=proposeur.prenom if proposeur else "?", @@ -32,6 +33,9 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut: statut=p.statut, numero_resolution=p.numero_resolution, date_vote=p.date_vote, + date_adoption=p.date_adoption, + etant_donne_que=p.etant_donne_que, + groupe_a_decide_de=p.groupe_a_decide_de, note=p.note, candidature_id=p.candidature_id, cree_le=p.cree_le, @@ -39,19 +43,6 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut: ) -async def _prochain_numero(groupe_id, annee: int, db: AsyncSession) -> str: - """Génère le prochain numéro de résolution : RES-2026-001.""" - result = await db.execute( - select(func.count(Proposition.id)).where( - Proposition.groupe_id == groupe_id, - Proposition.numero_resolution.isnot(None), - Proposition.numero_resolution.like(f"RES-{annee}-%"), - ) - ) - count = result.scalar() or 0 - return f"RES-{annee}-{count + 1:03d}" - - @router.post("/", response_model=PropositionOut) async def creer_proposition( req: PropositionCreate, @@ -72,7 +63,7 @@ async def creer_proposition( prop = Proposition( groupe_id=membre.groupe_id, reunion_id=req.reunion_id, - type_proposition=req.type_proposition or "generale", + type_proposition="proposition", sujet=req.sujet.strip(), description=req.description, proposeur_id=membre.id, @@ -130,18 +121,28 @@ async def voter_proposition( prop = result.scalar_one_or_none() if not prop: raise HTTPException(status_code=404, detail="Proposition introuvable") - if prop.statut != "secondee": - raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être mise au vote") - - prop.statut = req.statut - prop.date_vote = date.today() - if req.note: - prop.note = req.note.strip() + 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"): + raise HTTPException(status_code=400, detail="Seule une proposition en cours peut être rejetée") # Attribuer un numéro de résolution si adoptée if req.statut == "adoptee": - prop.numero_resolution = await _prochain_numero( - executif.groupe_id, date.today().year, db + etant_donne_que = (req.etant_donne_que or "").strip() + groupe_a_decide_de = (req.groupe_a_decide_de or "").strip() + if not etant_donne_que or not groupe_a_decide_de: + raise HTTPException( + status_code=400, + detail='Les sections "ÉTANT DONNÉ QUE" et "LE GROUPE A DÉCIDÉ DE" sont obligatoires', + ) + + prop.etant_donne_que = etant_donne_que + prop.groupe_a_decide_de = groupe_a_decide_de + prop.note = None + if prop.type_proposition not in ("nomination", "contribution"): + prop.type_proposition = "resolution" + prop.numero_resolution = await prochain_numero_decision( + executif.groupe_id, prop.type_proposition, date.today().year, db ) if req.reunion_id and not prop.reunion_id: prop.reunion_id = req.reunion_id @@ -162,12 +163,20 @@ async def voter_proposition( db.add(affectation) elif req.statut == "rejetee": + if req.note: + prop.note = req.note.strip() + # Si nomination rejetée, mettre la candidature en refusée if prop.type_proposition == "nomination" and prop.candidature_id: cand = await db.get(Candidature, prop.candidature_id) if cand: cand.statut = "refusee" + prop.statut = req.statut + prop.date_vote = date.today() + if req.statut == "adoptee": + prop.date_adoption = prop.date_vote + await db.flush() return await _to_out(prop, db) @@ -211,7 +220,7 @@ async def registre_resolutions( query = query.where(extract("year", Proposition.cree_le) == annee) if type_proposition: query = query.where(Proposition.type_proposition == type_proposition) - query = query.order_by(Proposition.numero_resolution) + query = query.order_by(Proposition.date_vote, Proposition.cree_le, Proposition.numero_resolution) result = await db.execute(query) return [await _to_out(p, db) for p in result.scalars().all()] diff --git a/backend/app/routers/pv.py b/backend/app/routers/pv.py index 03713ab..5eef6bb 100644 --- a/backend/app/routers/pv.py +++ b/backend/app/routers/pv.py @@ -4,31 +4,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from app.core.database import get_db +from app.core.roles import require_secretaire from app.core.security import get_current_membre from app.models.pv_reunion import PvReunion from app.models.reunion import Reunion from app.models.membre import Membre -from app.models.decision import Decision -from app.models.affectation import Affectation -from app.models.poste import Poste -from app.schemas.phase4 import PvCreate, PvOut, PvUpdate +from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"]) -async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)): - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - (Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")), - ) - ) - if not result.scalars().first(): - raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif") - return membre - - async def _to_out(pv: PvReunion, db: AsyncSession) -> PvOut: redacteur = await db.get(Membre, pv.redacteur_id) reunion = await db.get(Reunion, pv.reunion_id) if pv.reunion_id else None @@ -110,23 +95,6 @@ async def creer_pv( db.add(pv) await db.flush() - # Créer les décisions dans la table decisions (pour C5) - if req.decisions: - for d in req.decisions: - decision = Decision( - groupe_id=membre.groupe_id, - reunion_id=req.reunion_id, - sujet=d.sujet, - description=d.decision, - statut=d.statut, - proposeur=d.proposeur, - secondeur=d.secondeur, - date_decision=reunion.date_reunion.date() if reunion.date_reunion else None, - cree_par=membre.id, - ) - db.add(decision) - await db.flush() - return await _to_out(pv, db) @@ -155,3 +123,35 @@ async def modifier_pv( pv.approuve = req.approuve await db.flush() return await _to_out(pv, db) + + +@router.patch("/{pv_id}/adopter", response_model=PvOut) +async def adopter_pv( + pv_id: str, + req: PvAdoption, + membre=Depends(require_secretaire), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(PvReunion) + .join(Reunion, PvReunion.reunion_id == Reunion.id) + .where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id) + ) + pv = result.scalar_one_or_none() + if not pv: + raise HTTPException(status_code=404, detail="PV introuvable") + if not req.proposeur.strip() or not req.secondeur.strip(): + raise HTTPException(status_code=400, detail="Proposeur et secondeur requis") + + decisions = list(pv.decisions or []) + decisions.append({ + "sujet": "Adoption du procès-verbal précédent", + "decision": "Le procès-verbal précédent est adopté.", + "proposeur": req.proposeur.strip(), + "secondeur": req.secondeur.strip(), + "statut": "complete", + }) + pv.decisions = decisions + pv.approuve = True + await db.flush() + return await _to_out(pv, db) diff --git a/backend/app/routers/rapports.py b/backend/app/routers/rapports.py index 46c53b5..72be14a 100644 --- a/backend/app/routers/rapports.py +++ b/backend/app/routers/rapports.py @@ -1,6 +1,7 @@ """Routes C1 — Rapports PDF (finances, PV, rapport RSG).""" import io -from datetime import date, datetime +from html import escape +from datetime import date, datetime, time, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -24,6 +25,19 @@ MOIS_FR = [ ] +def _texte_decision_resolution(p: Proposition) -> str: + """Retourne le texte décisionnel si la résolution utilise le format structuré.""" + if p.groupe_a_decide_de: + return p.groupe_a_decide_de + if not p.note: + return p.sujet + marqueur = "LE GROUPE A DÉCIDÉ DE" + if marqueur not in p.note: + return p.sujet + decision = p.note.split(marqueur, 1)[1].strip() + return decision or p.sujet + + def _css(): return """ body { font-family: 'DejaVu Sans', sans-serif; font-size: 11pt; color: #333; margin: 2cm; } @@ -355,33 +369,34 @@ async def rapport_resolutions( for p in propositions: proposeur = await db.get(Membre, p.proposeur_id) if p.proposeur_id else None secondeur = await db.get(Membre, p.secondeur_id) if p.secondeur_id else None - badge = '✓ Adoptée' if p.statut == "adoptee" else '✗ Rejetée' + numero_date = f"{escape(p.numero_resolution or '—')}
{escape(str(p.date_adoption or p.date_vote or '—'))}" + resolution = escape(_texte_decision_resolution(p)).replace("\n", "
") + proposeurs = f"{escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else '—')}" rows += f""" - {p.numero_resolution or '—'} - {p.date_vote or '—'} - {p.sujet} - {proposeur.prenom if proposeur else '—'} - {secondeur.prenom if secondeur else '—'} - {badge} + {numero_date} + {resolution} + {proposeurs} """ - nb_adoptees = sum(1 for p in propositions if p.statut == "adoptee") - nb_rejetees = sum(1 for p in propositions if p.statut == "rejetee") - html_content = f""" +
{groupe.nom if groupe else 'Groupe'}
District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}

Registre des résolutions — {annee}

+

Décisions prises en Groupe

- - {rows if rows else ''} + + + + + + + {rows if rows else ''}
N°DateSujetProposeurSecondeurRésultat
Aucune résolution pour cette année
N° / dateRésolutionProposeur / secondeur
Aucune résolution pour cette année
-

{nb_adoptees} résolution(s) adoptée(s), {nb_rejetees} rejetée(s)

- @@ -399,18 +414,27 @@ async def rapport_resolutions( @router.get("/etat-financier") async def etat_financier_temps_reel( + annee: int | None = None, + mois: int | None = None, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """État des résultats et transactions en temps réel (JSON).""" + """État des résultats par période et soldes courants (JSON).""" from sqlalchemy import func, desc from app.models.operation_bancaire import OperationBancaire from app.models.reserve import Reserve + from app.models.transaction_comptable import TransactionComptable from app.models.vente_litterature import VenteLitterature - from app.models.litterature import Litterature + from app.services.finance import encaisse as solde_encaisse + from app.services.finance import engagements_non_debites + from app.services.finance import solde_banque as solde_banque_actuel + from app.services.finance import solde_reserve gid = membre.groupe_id groupe = await db.get(Groupe, gid) + aujourd_hui = date.today() + annee = annee or aujourd_hui.year + mois = mois or aujourd_hui.month # ── Dernier relevé bancaire ── result = await db.execute( @@ -422,81 +446,71 @@ async def etat_financier_temps_reel( dernier_releve = result.scalar_one_or_none() solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0 - # ── Revenus ── + # ── État des résultats : période demandée ── result = await db.execute( select(func.coalesce(func.sum(Collecte.montant), 0)).where( - Collecte.groupe_id == gid, Collecte.statut == "recu")) + Collecte.groupe_id == gid, + Collecte.statut == "recu", + extract("year", Collecte.date_reception) == annee, + extract("month", Collecte.date_reception) == mois, + ) + ) total_collectes = float(result.scalar()) result = await db.execute( select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where( - VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True)) + VenteLitterature.groupe_id == gid, + VenteLitterature.remis_tresorier == True, + extract("year", VenteLitterature.date_remise) == annee, + extract("month", VenteLitterature.date_remise) == mois, + ) + ) total_ventes = float(result.scalar()) - revenus = total_collectes + total_ventes + result = await db.execute( + select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( + TransactionComptable.groupe_id == gid, + TransactionComptable.type == "don", + TransactionComptable.compte == "banque", + TransactionComptable.sens == "debit", + extract("year", TransactionComptable.date_transaction) == annee, + extract("month", TransactionComptable.date_transaction) == mois, + ) + ) + total_dons = float(result.scalar()) + + revenus = total_collectes + total_ventes + total_dons - # ── Dépenses ── result = await db.execute( select(func.coalesce(func.sum(Depense.montant), 0)).where( - Depense.groupe_id == gid, Depense.paye == True)) + Depense.groupe_id == gid, + Depense.paye == True, + extract("year", Depense.date_confirmation) == annee, + extract("month", Depense.date_confirmation) == mois, + ) + ) total_depenses_payees = float(result.scalar()) result = await db.execute( select(func.coalesce(func.sum(Depense.montant), 0)).where( - Depense.groupe_id == gid, Depense.paye == False, Depense.confirme_par.isnot(None))) + Depense.groupe_id == gid, + Depense.paye == False, + Depense.confirme_par.isnot(None), + ) + ) total_engagements = float(result.scalar()) - # ── Contributions ── result = await db.execute( select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( EnvoiContribution.groupe_id == gid, EnvoiContribution.debitee == True, + extract("year", EnvoiContribution.date_envoi) == annee, + extract("month", EnvoiContribution.date_envoi) == mois, )) total_contributions = float(result.scalar()) - # ── Encaisse ── - result = await db.execute( - select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot")) - total_depots = float(result.scalar()) - - result = await db.execute( - select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait")) - total_retraits = float(result.scalar()) - - result = await db.execute( - select(func.coalesce(func.sum(Depense.montant), 0)).where( - Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE")) - depenses_cash = float(result.scalar()) - - encaisse = total_collectes + total_ventes + total_retraits - total_depots - depenses_cash - - # ── Solde banque ── - filtre_depuis = dernier_releve.date_operation if dernier_releve else None - q_depots = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot") - q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( - OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait") - q_depenses_bancaires = select(func.coalesce(func.sum(Depense.montant), 0)).where( - Depense.groupe_id == gid, - Depense.paye == True, - Depense.methode.in_(["VIREMENT", "CHEQUE"]), - ) - q_contributions = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( - EnvoiContribution.groupe_id == gid, - EnvoiContribution.debitee == True, - ) - if filtre_depuis: - q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis) - q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis) - q_depenses_bancaires = q_depenses_bancaires.where(Depense.date_confirmation > filtre_depuis) - q_contributions = q_contributions.where(EnvoiContribution.date_envoi > filtre_depuis) - depots_depuis = float((await db.execute(q_depots)).scalar()) - retraits_depuis = float((await db.execute(q_retraits)).scalar()) - depenses_bancaires_depuis = float((await db.execute(q_depenses_bancaires)).scalar()) - contributions_depuis = float((await db.execute(q_contributions)).scalar()) - solde_banque = solde_releve + depots_depuis - retraits_depuis - depenses_bancaires_depuis - contributions_depuis + encaisse = await solde_encaisse(gid, db) + solde_banque = await solde_banque_actuel(gid, db) # ── Réserves ── result = await db.execute( @@ -505,14 +519,18 @@ async def etat_financier_temps_reel( Reserve.active == True, ).order_by(Reserve.nom) ) - reserves_details = [ - {"id": str(r.id), "nom": r.nom, "montant": float(r.montant), "note": r.note} - for r in result.scalars().all() - ] + reserves_details = [] + for reserve in result.scalars().all(): + reserves_details.append({ + "id": str(reserve.id), + "nom": reserve.nom, + "montant": round(await solde_reserve(reserve.id, db), 2), + "note": reserve.note, + }) total_reserves = sum(r["montant"] for r in reserves_details) - disponible = solde_banque - total_engagements - total_reserves - + engagements_courants = await engagements_non_debites(gid, db) + disponible = solde_banque + encaisse - engagements_courants - total_reserves # ── Dernières transactions ── result = await db.execute( select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu") @@ -531,6 +549,11 @@ async def etat_financier_temps_reel( return { "groupe": groupe.nom if groupe else "?", + "periode": { + "annee": annee, + "mois": mois, + "libelle": f"{MOIS_FR[mois]} {annee}", + }, "date_releve": str(dernier_releve.date_operation) if dernier_releve else None, "solde_releve": round(solde_releve, 2), "solde_banque": round(solde_banque, 2), @@ -539,19 +562,246 @@ async def etat_financier_temps_reel( "revenus": { "collectes": round(total_collectes, 2), "ventes_litterature": round(total_ventes, 2), + "dons": round(total_dons, 2), "total": round(revenus, 2), }, "sorties": { "depenses_payees": round(total_depenses_payees, 2), - "engagements": round(total_engagements, 2), + "engagements": round(engagements_courants, 2), "contributions": round(total_contributions, 2), "total": round(total_depenses_payees + total_contributions, 2), }, "reserves": { "details": reserves_details, "total": round(total_reserves, 2), + "total_reelles": round(total_reserves, 2), }, "resultat_net": round(revenus - total_depenses_payees - total_contributions, 2), "dernieres_collectes": dernieres_collectes, "dernieres_depenses": dernieres_depenses, } + + +@router.get("/etat-resultats/{annee}/{mois}") +async def rapport_etat_resultats( + annee: int, + mois: int, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """PDF mensuel: état des résultats et position de trésorerie.""" + try: + from weasyprint import HTML + except ImportError: + raise HTTPException(status_code=500, detail="WeasyPrint non installé") + + if mois < 1 or mois > 12: + raise HTTPException(status_code=400, detail="Mois invalide") + + from sqlalchemy import func, desc + from app.models.operation_bancaire import OperationBancaire + from app.models.mouvement_reserve import MouvementReserve + from app.models.reserve import Reserve + from app.models.transaction_comptable import TransactionComptable + from app.models.vente_litterature import VenteLitterature + + gid = membre.groupe_id + groupe = await db.get(Groupe, gid) + fin_mois = (date(annee + 1, 1, 1) if mois == 12 else date(annee, mois + 1, 1)) - timedelta(days=1) + fin_mois_dt = datetime.combine(fin_mois, time.max).replace(tzinfo=timezone.utc) + + async def solde_compte_a_date(compte: str) -> float: + debits = float((await db.execute( + select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( + TransactionComptable.groupe_id == gid, + TransactionComptable.compte == compte, + TransactionComptable.sens == "debit", + TransactionComptable.date_transaction <= fin_mois, + ) + )).scalar()) + credits = float((await db.execute( + select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( + TransactionComptable.groupe_id == gid, + TransactionComptable.compte == compte, + TransactionComptable.sens == "credit", + TransactionComptable.date_transaction <= fin_mois, + ) + )).scalar()) + return debits - credits + + async def solde_reserve_a_date(reserve_id) -> float: + entrees = float((await db.execute( + select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where( + MouvementReserve.destination_id == reserve_id, + MouvementReserve.cree_le <= fin_mois_dt, + ) + )).scalar()) + sorties = float((await db.execute( + select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where( + MouvementReserve.source_id == reserve_id, + MouvementReserve.cree_le <= fin_mois_dt, + ) + )).scalar()) + return entrees - sorties + + async def engagements_a_date() -> float: + depenses = float((await db.execute( + select(func.coalesce(func.sum(Depense.montant), 0)).where( + Depense.groupe_id == gid, + Depense.paye == False, + Depense.methode.in_(["VIREMENT", "CHEQUE"]), + Depense.date_confirmation <= fin_mois_dt, + ) + )).scalar()) + contributions = float((await db.execute( + select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( + EnvoiContribution.groupe_id == gid, + EnvoiContribution.engagee == True, + EnvoiContribution.debitee == False, + EnvoiContribution.date_envoi <= fin_mois, + ) + )).scalar()) + return depenses + contributions + + total_collectes = float((await db.execute( + select(func.coalesce(func.sum(Collecte.montant), 0)).where( + Collecte.groupe_id == gid, + Collecte.statut == "recu", + extract("year", Collecte.date_reception) == annee, + extract("month", Collecte.date_reception) == mois, + ) + )).scalar()) + total_ventes = float((await db.execute( + select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where( + VenteLitterature.groupe_id == gid, + VenteLitterature.remis_tresorier == True, + extract("year", VenteLitterature.date_remise) == annee, + extract("month", VenteLitterature.date_remise) == mois, + ) + )).scalar()) + total_dons = float((await db.execute( + select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( + TransactionComptable.groupe_id == gid, + TransactionComptable.type == "don", + TransactionComptable.compte == "banque", + TransactionComptable.sens == "debit", + extract("year", TransactionComptable.date_transaction) == annee, + extract("month", TransactionComptable.date_transaction) == mois, + ) + )).scalar()) + total_depenses = float((await db.execute( + select(func.coalesce(func.sum(Depense.montant), 0)).where( + Depense.groupe_id == gid, + Depense.paye == True, + extract("year", Depense.date_confirmation) == annee, + extract("month", Depense.date_confirmation) == mois, + ) + )).scalar()) + total_contributions = float((await db.execute( + select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( + EnvoiContribution.groupe_id == gid, + EnvoiContribution.debitee == True, + extract("year", EnvoiContribution.date_envoi) == annee, + extract("month", EnvoiContribution.date_envoi) == mois, + ) + )).scalar()) + + revenus = total_collectes + total_ventes + total_dons + charges = total_depenses + total_contributions + resultat = revenus - charges + banque = await solde_compte_a_date("banque") + caisse = await solde_compte_a_date("encaisse") + engagements = await engagements_a_date() + + result = await db.execute( + select(Reserve).where(Reserve.groupe_id == gid).order_by(Reserve.nom) + ) + reserves = [] + total_reserves = 0.0 + for reserve in result.scalars().all(): + montant = await solde_reserve_a_date(reserve.id) + if not reserve.active and round(montant, 2) == 0: + continue + total_reserves += montant + reserves.append((reserve.nom, montant)) + disponible = banque + caisse - engagements - total_reserves + + result = await db.execute( + select(OperationBancaire).where( + OperationBancaire.groupe_id == gid, + OperationBancaire.type == "releve", + OperationBancaire.date_operation <= fin_mois, + ).order_by(desc(OperationBancaire.date_operation)).limit(1) + ) + dernier_releve = result.scalar_one_or_none() + + rows_reserves = "".join( + f"{escape(nom)}{montant:.2f} $" + for nom, montant in reserves + ) or "Aucune réserve enregistrée" + + css = _css() + """ + .montant { text-align: right; white-space: nowrap; } + .positif { color: #1e8449; } + .negatif { color: #c0392b; } + .note { color: #777; font-size: 9pt; } + """ + signe = "+" if resultat >= 0 else "" + classe_resultat = "positif" if resultat >= 0 else "negatif" + + groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe" + district = str(groupe.district if groupe and groupe.district else "87-16") + region = str(groupe.region if groupe and groupe.region else "87") + + html_content = f""" +
+
{escape(groupe_nom)}
+
District {escape(district)} · Région {escape(region)}
+
+

État des résultats — {MOIS_FR[mois]} {annee}

+ +

Revenus

+ + + + + +
Collectes 7e Tradition{total_collectes:.2f} $
Ventes de littérature{total_ventes:.2f} $
Dons déposés directement{total_dons:.2f} $
Total des revenus{revenus:.2f} $
+ +

Charges

+ + + + +
Dépenses payées{total_depenses:.2f} $
Contributions envoyées{total_contributions:.2f} $
Total des charges{charges:.2f} $
+ +

{'Excédent' if resultat >= 0 else 'Déficit'} : {signe}{resultat:.2f} $

+ +

Position de trésorerie

+ + + + + + +
Solde bancaire au {fin_mois.isoformat()}{banque:.2f} $
Encaisse au {fin_mois.isoformat()}{caisse:.2f} $
Engagements non débités{engagements:.2f} $
Réserves{total_reserves:.2f} $
Disponible au {fin_mois.isoformat()}{disponible:.2f} $
+ {f'

Dernier relevé bancaire : {dernier_releve.date_operation}

' if dernier_releve else ''} + +

Réserves

+ + {rows_reserves} + +
Total des réserves{total_reserves:.2f} $
+ + + """ + + pdf_bytes = HTML(string=html_content).write_pdf() + filename = f"etat_resultats_{groupe_nom.replace(' ', '_')}_{annee}_{mois:02d}.pdf" + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) diff --git a/backend/app/routers/rapports_rsg.py b/backend/app/routers/rapports_rsg.py index 36f7c65..f90454d 100644 --- a/backend/app/routers/rapports_rsg.py +++ b/backend/app/routers/rapports_rsg.py @@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from app.core.database import get_db -from app.core.security import get_current_membre +from app.core.security import get_current_membre, is_sysadmin from app.models.rapport_rsg import RapportRsg from app.models.membre import Membre from app.models.affectation import Affectation @@ -16,6 +16,8 @@ router = APIRouter(prefix="/rapports-rsg", tags=["M3 — Rapport RSG"]) async def require_rsg(membre=Depends(get_current_membre), db=Depends(get_db)): """Vérifie que le membre est RSG, RSG substitut ou exécutif.""" + if is_sysadmin(membre): + return membre result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, diff --git a/backend/app/routers/reunions.py b/backend/app/routers/reunions.py index 562a72e..3b710ec 100644 --- a/backend/app/routers/reunions.py +++ b/backend/app/routers/reunions.py @@ -5,40 +5,20 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, extract from app.core.database import get_db -from app.core.security import get_current_membre +from app.core.reunions import jour_reunion_attendu +from app.core.security import get_current_membre, require_executif from app.models.reunion import Reunion from app.models.groupe import Groupe from app.models.membre import Membre -from app.models.affectation import Affectation -from app.models.poste import Poste from app.schemas.phase1 import ReunionCreate, ReunionOut router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"]) -JOURS_SEMAINE = { - "lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3, - "vendredi": 4, "samedi": 5, "dimanche": 6, -} - - -async def require_animateur(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est animateur ou exécutif.""" - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - (Poste.nom.ilike("%animateur%") | (Poste.categorie == "executif")), - ) - ) - if not result.scalars().first(): - raise HTTPException(status_code=403, detail="Réservé à l'animateur ou à l'exécutif") - return membre - @router.post("/", response_model=ReunionOut) async def creer_rapport_reunion( req: ReunionCreate, - membre=Depends(require_animateur), + membre=Depends(require_executif), db: AsyncSession = Depends(get_db), ): """S4 — L'animateur note le thème et les annonces.""" @@ -46,7 +26,7 @@ async def creer_rapport_reunion( # ── Validation : réunion régulière ── if req.type == "reguliere" and groupe.jour_reunion: - jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower()) + jour_attendu = jour_reunion_attendu(groupe) jour_soumis = req.date_reunion.weekday() if jour_attendu is not None and jour_soumis != jour_attendu: raise HTTPException( diff --git a/backend/app/routers/sondages.py b/backend/app/routers/sondages.py deleted file mode 100644 index 654c341..0000000 --- a/backend/app/routers/sondages.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Routes G6/C6 — Sondages et votes de conscience de groupe.""" -from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, func - -from app.core.database import get_db -from app.core.security import get_current_membre, require_executif -from app.models.sondage import Sondage -from app.models.option_sondage import OptionSondage -from app.models.vote import Vote -from app.models.membre import Membre -from app.schemas.phase8 import SondageCreate, SondageOut, SondageUpdate, OptionOut, VoteCreate - -router = APIRouter(prefix="/sondages", tags=["G6/C6 — Sondages et votes"]) - -TYPES_VALIDES = {"oui_non", "choix_multiple", "unanimite"} - - -async def _build_sondage_out(sondage: Sondage, membre_id, db: AsyncSession) -> SondageOut: - """Construit la sortie complète d'un sondage avec comptage des votes.""" - cree = await db.get(Membre, sondage.cree_par) if sondage.cree_par else None - - # Options avec comptage - result = await db.execute( - select(OptionSondage) - .where(OptionSondage.sondage_id == sondage.id) - .order_by(OptionSondage.ordre) - ) - options = result.scalars().all() - - options_out = [] - total = 0 - for opt in options: - count_result = await db.execute( - select(func.count(Vote.id)).where(Vote.option_id == opt.id) - ) - nb = count_result.scalar() or 0 - total += nb - options_out.append(OptionOut( - id=opt.id, texte=opt.texte, ordre=opt.ordre, nb_votes=nb - )) - - # Le membre a-t-il voté? - vote_result = await db.execute( - select(Vote).where(Vote.sondage_id == sondage.id, Vote.membre_id == membre_id) - ) - a_vote = vote_result.scalar_one_or_none() is not None - - return SondageOut( - id=sondage.id, - titre=sondage.titre, - description=sondage.description, - type=sondage.type, - cree_par_prenom=cree.prenom if cree else None, - date_ouverture=sondage.date_ouverture, - date_fermeture=sondage.date_fermeture, - statut=sondage.statut, - options=options_out, - total_votes=total, - a_vote=a_vote, - ) - - -@router.post("/", response_model=SondageOut) -async def creer_sondage( - req: SondageCreate, - membre=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G6 — Créer un sondage de conscience de groupe.""" - if req.type not in TYPES_VALIDES: - raise HTTPException(status_code=400, detail=f"Type invalide. Choix: {list(TYPES_VALIDES)}") - - if req.date_fermeture <= req.date_ouverture: - raise HTTPException(status_code=400, detail="La date de fermeture doit être après l'ouverture") - - # Pour oui_non, créer automatiquement les options - options_data = req.options - if req.type == "oui_non" and len(options_data) == 0: - from app.schemas.phase8 import OptionCreate - options_data = [OptionCreate(texte="Oui"), OptionCreate(texte="Non")] - - if len(options_data) < 2: - raise HTTPException(status_code=400, detail="Au moins 2 options sont requises") - - sondage = Sondage( - groupe_id=membre.groupe_id, - titre=req.titre.strip(), - description=req.description, - type=req.type, - cree_par=membre.id, - date_ouverture=req.date_ouverture, - date_fermeture=req.date_fermeture, - statut="brouillon", - ) - db.add(sondage) - await db.flush() - - for i, opt in enumerate(options_data): - option = OptionSondage( - sondage_id=sondage.id, - texte=opt.texte.strip(), - ordre=i, - ) - db.add(option) - await db.flush() - - return await _build_sondage_out(sondage, membre.id, db) - - -@router.get("/", response_model=list[SondageOut]) -async def liste_sondages( - statut: str | None = None, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """Lister les sondages du groupe.""" - query = select(Sondage).where(Sondage.groupe_id == membre.groupe_id) - if statut: - query = query.where(Sondage.statut == statut) - query = query.order_by(desc(Sondage.date_ouverture)) - - result = await db.execute(query) - sondages = result.scalars().all() - - return [await _build_sondage_out(s, membre.id, db) for s in sondages] - - -@router.get("/{sondage_id}", response_model=SondageOut) -async def detail_sondage( - sondage_id: str, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - result = await db.execute( - select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id) - ) - sondage = result.scalar_one_or_none() - if not sondage: - raise HTTPException(status_code=404, detail="Sondage introuvable") - return await _build_sondage_out(sondage, membre.id, db) - - -@router.patch("/{sondage_id}", response_model=SondageOut) -async def modifier_statut_sondage( - sondage_id: str, - req: SondageUpdate, - membre=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """Ouvrir ou fermer un sondage.""" - result = await db.execute( - select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id) - ) - sondage = result.scalar_one_or_none() - if not sondage: - raise HTTPException(status_code=404, detail="Sondage introuvable") - - if req.statut and req.statut in ("brouillon", "ouvert", "ferme"): - sondage.statut = req.statut - await db.flush() - - return await _build_sondage_out(sondage, membre.id, db) - - -@router.post("/{sondage_id}/voter") -async def voter( - sondage_id: str, - req: VoteCreate, - membre=Depends(get_current_membre), - db: AsyncSession = Depends(get_db), -): - """C6 — Voter dans un sondage ouvert. Un membre = un vote.""" - result = await db.execute( - select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id) - ) - sondage = result.scalar_one_or_none() - if not sondage: - raise HTTPException(status_code=404, detail="Sondage introuvable") - - if sondage.statut != "ouvert": - raise HTTPException(status_code=400, detail="Ce sondage n'est pas ouvert au vote") - - now = datetime.now(timezone.utc) - if now < sondage.date_ouverture or now > sondage.date_fermeture: - raise HTTPException(status_code=400, detail="La période de vote est terminée ou pas encore ouverte") - - # Vérifier qu'il n'a pas déjà voté - existing = await db.execute( - select(Vote).where(Vote.sondage_id == sondage.id, Vote.membre_id == membre.id) - ) - if existing.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Vous avez déjà voté dans ce sondage") - - # Vérifier que l'option appartient au sondage - opt_result = await db.execute( - select(OptionSondage).where( - OptionSondage.id == req.option_id, - OptionSondage.sondage_id == sondage.id, - ) - ) - if not opt_result.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Option invalide") - - vote = Vote( - sondage_id=sondage.id, - option_id=req.option_id, - membre_id=membre.id, - ) - db.add(vote) - await db.flush() - - return {"message": "Vote enregistré", "option_id": str(req.option_id)} diff --git a/backend/app/routers/ventes_litterature.py b/backend/app/routers/ventes_litterature.py index bd55dae..d60e7b2 100644 --- a/backend/app/routers/ventes_litterature.py +++ b/backend/app/routers/ventes_litterature.py @@ -4,12 +4,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from app.core.database import get_db -from app.core.security import get_current_membre +from app.core.security import get_current_membre, is_sysadmin from app.models.litterature import Litterature from app.models.vente_litterature import VenteLitterature from app.models.membre import Membre from app.models.affectation import Affectation from app.models.poste import Poste +from app.models.transaction_comptable import TransactionComptable from app.schemas.phase6 import VenteCreate, VenteOut router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littérature"]) @@ -17,11 +18,13 @@ router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littératur async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): """Vérifie que le membre est responsable de la littérature.""" + if is_sysadmin(membre): + return membre result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, Affectation.statut == "actif", - Poste.nom.ilike("%littérature%"), + (Poste.nom.ilike("%littérature%") | (Poste.categorie == "executif")), ) ) if not result.scalars().first(): @@ -151,15 +154,16 @@ async def confirmer_reception_vente( ): """Le trésorier confirme la réception du montant de la vente.""" # Vérifier que c'est le trésorier ou exécutif - result = await db.execute( - select(Affectation).join(Poste).where( - Affectation.membre_id == membre.id, - Affectation.statut == "actif", - (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), + if not is_sysadmin(membre): + result = await db.execute( + select(Affectation).join(Poste).where( + Affectation.membre_id == membre.id, + Affectation.statut == "actif", + (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), + ) ) - ) - if not result.scalars().first(): - raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif") + if not result.scalars().first(): + raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif") result = await db.execute( select(VenteLitterature).where( @@ -177,5 +181,17 @@ async def confirmer_reception_vente( vente.remis_tresorier = True vente.recu_par = membre.id vente.date_remise = date_type.today() + db.add(TransactionComptable( + groupe_id=vente.groupe_id, + date_transaction=vente.date_remise, + type="vente_litterature", + compte="encaisse", + montant=vente.montant, + sens="debit", + source_table="ventes_litterature", + source_id=vente.id, + note="Vente de littérature reçue", + cree_par=membre.id, + )) await db.flush() return {"message": "Réception confirmée"} diff --git a/backend/app/schemas/phase1.py b/backend/app/schemas/phase1.py index 5abebbf..2faa439 100644 --- a/backend/app/schemas/phase1.py +++ b/backend/app/schemas/phase1.py @@ -36,6 +36,7 @@ class CollecteUpdate(BaseModel): # ── Opérations bancaires ── class OperationBancaireCreate(BaseModel): type: str = "depot" + provenance_depot: Optional[str] = "encaisse" montant: float date_operation: date note: Optional[str] = None @@ -53,6 +54,20 @@ class OperationBancaireOut(BaseModel): model_config = {"from_attributes": True} +class TransactionComptableOut(BaseModel): + id: UUID + date_transaction: date + type: str + compte: str + montant: float + sens: str + source_table: Optional[str] = None + source_id: Optional[UUID] = None + note: Optional[str] = None + cree_par_prenom: Optional[str] = None + cree_le: datetime + + class ReserveCreate(BaseModel): nom: str montant: float @@ -65,6 +80,12 @@ class ReserveUpdate(BaseModel): note: Optional[str] = None +class ReserveTransfer(BaseModel): + source_id: str + destination_id: str + montant: float + + class ReserveOut(BaseModel): id: UUID nom: str @@ -76,6 +97,19 @@ class ReserveOut(BaseModel): model_config = {"from_attributes": True} +class ReserveMouvementOut(BaseModel): + id: UUID + source_id: Optional[UUID] = None + source_nom: Optional[str] = None + destination_id: Optional[UUID] = None + destination_nom: Optional[str] = None + montant: float + type: str + note: Optional[str] = None + cree_par_prenom: Optional[str] = None + cree_le: datetime + + class SoldesOut(BaseModel): encaisse: float # cash reçu par trésorier, pas encore déposé compte_bancaire: float # dépôts - dépenses payées - contributions @@ -107,10 +141,11 @@ class ReunionOut(BaseModel): # ── S5 Dépense (ouvert à tous) ── class DepenseCreate(BaseModel): - date_depense: date montant: float quoi: str - # qui = auto (membre courant) + membre_id: Optional[UUID] = None + date_depense: Optional[date] = None + # qui = auto, sauf si un exécutif soumet pour un autre membre # methode renseignée par le trésorier à la confirmation diff --git a/backend/app/schemas/phase3.py b/backend/app/schemas/phase3.py index bab1b86..961899b 100644 --- a/backend/app/schemas/phase3.py +++ b/backend/app/schemas/phase3.py @@ -30,6 +30,7 @@ class GroupeUpdate(BaseModel): adresse_local: Optional[str] = None jour_reunion: Optional[str] = None heure_reunion: Optional[str] = None # frontend envoie "HH:MM", parsé dans parametres.py + assemblee_affaires_ordre: Optional[str] = None format_reunion: Optional[str] = None lien_aa87: Optional[str] = None @@ -42,6 +43,7 @@ class GroupeDetail(BaseModel): adresse_local: Optional[str] = None jour_reunion: Optional[str] = None heure_reunion: Optional[time] = None # sérialisé "HH:MM:SS" par pydantic + assemblee_affaires_ordre: Optional[str] = "premiere" format_reunion: Optional[str] = None lien_aa87: str cree_le: datetime diff --git a/backend/app/schemas/phase4.py b/backend/app/schemas/phase4.py index c4e2be3..6cfcfb8 100644 --- a/backend/app/schemas/phase4.py +++ b/backend/app/schemas/phase4.py @@ -71,6 +71,11 @@ class PvUpdate(BaseModel): approuve: Optional[bool] = None +class PvAdoption(BaseModel): + proposeur: str + secondeur: str + + # ── M3 Rapport RSG ── class RapportRsgCreate(BaseModel): reunion_dist: date diff --git a/backend/app/schemas/phase8.py b/backend/app/schemas/phase8.py deleted file mode 100644 index be820f3..0000000 --- a/backend/app/schemas/phase8.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Schémas Phase 8 — Votes et sondages.""" -from pydantic import BaseModel -from datetime import datetime -from uuid import UUID -from typing import Optional - - -# ── G6 Lancer un sondage ── -class OptionCreate(BaseModel): - texte: str - - -class SondageCreate(BaseModel): - titre: str - description: Optional[str] = None - type: str = "oui_non" # oui_non, choix_multiple, unanimite - date_ouverture: datetime - date_fermeture: datetime - options: list[OptionCreate] = [] - - -class OptionOut(BaseModel): - id: UUID - texte: str - ordre: int - nb_votes: int = 0 - - model_config = {"from_attributes": True} - - -class SondageOut(BaseModel): - id: UUID - titre: str - description: Optional[str] = None - type: str - cree_par_prenom: Optional[str] = None - date_ouverture: datetime - date_fermeture: datetime - statut: str - options: list[OptionOut] = [] - total_votes: int = 0 - a_vote: bool = False # le membre courant a-t-il voté? - - model_config = {"from_attributes": True} - - -class SondageUpdate(BaseModel): - statut: Optional[str] = None # ouvert, ferme - - -# ── C6 Voter ── -class VoteCreate(BaseModel): - option_id: UUID - diff --git a/backend/app/schemas/propositions.py b/backend/app/schemas/propositions.py index c176206..cf22116 100644 --- a/backend/app/schemas/propositions.py +++ b/backend/app/schemas/propositions.py @@ -9,13 +9,12 @@ class PropositionCreate(BaseModel): reunion_id: Optional[UUID] = None sujet: str description: Optional[str] = None - type_proposition: str = "generale" # generale, nomination, contribution class PropositionOut(BaseModel): id: UUID reunion_id: Optional[UUID] = None - type_proposition: str = "generale" + type_proposition: str = "proposition" sujet: str description: Optional[str] = None proposeur_prenom: str @@ -23,6 +22,9 @@ class PropositionOut(BaseModel): statut: str numero_resolution: Optional[str] = None date_vote: Optional[date] = None + date_adoption: Optional[date] = None + etant_donne_que: Optional[str] = None + groupe_a_decide_de: Optional[str] = None note: Optional[str] = None candidature_id: Optional[UUID] = None cree_le: datetime @@ -34,4 +36,6 @@ class PropositionOut(BaseModel): class PropositionVote(BaseModel): statut: str # adoptee ou rejetee note: Optional[str] = None + etant_donne_que: Optional[str] = None + groupe_a_decide_de: Optional[str] = None reunion_id: Optional[UUID] = None diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index e268ad6..a6996a8 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -36,6 +36,7 @@ class MembrePublic(BaseModel): courriel: str groupe_id: UUID date_abstinence: Optional[date] = None + is_sysadmin: bool = False model_config = {"from_attributes": True} @@ -123,6 +124,7 @@ class ModuleTuile(BaseModel): class AccueilResponse(BaseModel): membre: MembrePublic postes: list[str] + est_executif: bool = False modules_consultation: list[ModuleTuile] modules_saisie: list[ModuleTuile] modules_gestion: list[ModuleTuile] @@ -148,5 +150,6 @@ class GroupeOut(BaseModel): district: str region: str jour_reunion: Optional[str] = None + assemblee_affaires_ordre: Optional[str] = "premiere" model_config = {"from_attributes": True} diff --git a/backend/app/services/finance.py b/backend/app/services/finance.py new file mode 100644 index 0000000..e3a5c27 --- /dev/null +++ b/backend/app/services/finance.py @@ -0,0 +1,111 @@ +"""Services financiers: registre comptable, soldes et réserves.""" +from sqlalchemy import desc, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.depense import Depense +from app.models.envoi_contribution import EnvoiContribution +from app.models.mouvement_reserve import MouvementReserve +from app.models.reserve import Reserve +from app.models.transaction_comptable import TransactionComptable + + +async def solde_compte(groupe_id, compte: str, db: AsyncSession) -> float: + debits = float((await db.execute( + select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( + TransactionComptable.groupe_id == groupe_id, + TransactionComptable.compte == compte, + TransactionComptable.sens == "debit", + ) + )).scalar()) + credits = float((await db.execute( + select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where( + TransactionComptable.groupe_id == groupe_id, + TransactionComptable.compte == compte, + TransactionComptable.sens == "credit", + ) + )).scalar()) + return debits - credits + + +async def encaisse(groupe_id, db: AsyncSession) -> float: + return await solde_compte(groupe_id, "encaisse", db) + + +async def solde_banque(groupe_id, db: AsyncSession) -> float: + return await solde_compte(groupe_id, "banque", db) + + +async def engagements_non_debites(groupe_id, db: AsyncSession) -> float: + depenses = float((await db.execute( + select(func.coalesce(func.sum(Depense.montant), 0)).where( + Depense.groupe_id == groupe_id, + Depense.paye == False, + Depense.methode.in_(["VIREMENT", "CHEQUE"]), + ) + )).scalar()) + contributions = float((await db.execute( + select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( + EnvoiContribution.groupe_id == groupe_id, + EnvoiContribution.engagee == True, + EnvoiContribution.debitee == False, + ) + )).scalar()) + return depenses + contributions + + +async def solde_reserve(reserve_id, db: AsyncSession) -> float: + entrees = float((await db.execute( + select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where( + MouvementReserve.destination_id == reserve_id, + ) + )).scalar()) + sorties = float((await db.execute( + select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where( + MouvementReserve.source_id == reserve_id, + ) + )).scalar()) + return entrees - sorties + + +async def total_reserves(groupe_id, db: AsyncSession, exclure_id: str | None = None) -> float: + result = await db.execute(select(Reserve).where(Reserve.groupe_id == groupe_id, Reserve.active == True)) + total = 0.0 + for reserve in result.scalars().all(): + if exclure_id and str(reserve.id) == str(exclure_id): + continue + total += await solde_reserve(reserve.id, db) + return total + + +async def reserve_disponible(groupe_id, db: AsyncSession) -> float: + return ( + await solde_banque(groupe_id, db) + - await total_reserves(groupe_id, db) + - await engagements_non_debites(groupe_id, db) + ) + + +async def dernier_releve(groupe_id, db: AsyncSession): + from app.models.operation_bancaire import OperationBancaire + result = await db.execute( + select(OperationBancaire).where( + OperationBancaire.groupe_id == groupe_id, + OperationBancaire.type == "releve", + ).order_by(desc(OperationBancaire.date_operation)).limit(1) + ) + return result.scalar_one_or_none() + + +def transaction(groupe_id, membre_id, date_transaction, type_, compte, montant, sens, source_table=None, source_id=None, note=None): + return TransactionComptable( + groupe_id=groupe_id, + date_transaction=date_transaction, + type=type_, + compte=compte, + montant=montant, + sens=sens, + source_table=source_table, + source_id=source_id, + note=note, + cree_par=membre_id, + ) diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..1fe8917 --- /dev/null +++ b/backend/app/services/notifications.py @@ -0,0 +1,76 @@ +"""Envoi des notifications Web Push.""" +import asyncio +import base64 +import json +from tempfile import NamedTemporaryFile +from typing import Iterable + +from pywebpush import WebPushException, webpush +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.models.push_subscription import PushSubscription + + +def notifications_configured() -> bool: + return bool(settings.VAPID_PUBLIC_KEY and settings.VAPID_PRIVATE_KEY_B64) + + +def _private_key_pem() -> str: + if not settings.VAPID_PRIVATE_KEY_B64: + return "" + return base64.b64decode(settings.VAPID_PRIVATE_KEY_B64).decode() + + +def _send_sync(abonnement: dict, payload: dict) -> None: + private_key = _private_key_pem() + with NamedTemporaryFile("w", suffix=".pem") as key_file: + key_file.write(private_key) + key_file.flush() + webpush( + subscription_info=abonnement, + data=json.dumps(payload, ensure_ascii=False), + vapid_private_key=key_file.name, + vapid_claims={"sub": settings.VAPID_SUBJECT}, + ) + + +async def send_to_subscriptions(db: AsyncSession, subscriptions: Iterable[PushSubscription], payload: dict) -> int: + if not notifications_configured(): + return 0 + sent = 0 + for sub in subscriptions: + try: + await asyncio.to_thread(_send_sync, sub.abonnement, payload) + sent += 1 + except WebPushException as exc: + if getattr(exc.response, "status_code", None) in {404, 410}: + await db.delete(sub) + except Exception: + continue + return sent + + +async def notify_group_change( + db: AsyncSession, + *, + groupe_id, + actor_id, + title: str, + body: str, + url: str = "/", +) -> int: + if not notifications_configured() or not groupe_id: + return 0 + result = await db.execute( + select(PushSubscription).where( + PushSubscription.groupe_id == groupe_id, + PushSubscription.membre_id != actor_id, + ) + ) + return await send_to_subscriptions(db, result.scalars().all(), { + "title": title, + "body": body, + "url": url, + }) diff --git a/data/import/Rapport de trésorerie.xlsx b/data/import/Rapport de trésorerie.xlsx new file mode 100644 index 0000000..b52f543 Binary files /dev/null and b/data/import/Rapport de trésorerie.xlsx differ diff --git a/deploy/seed.py b/deploy/seed.py index e0941b4..b51d63c 100644 --- a/deploy/seed.py +++ b/deploy/seed.py @@ -26,7 +26,6 @@ from app.models.depense import Depense from app.models.reunion import Reunion from app.models.presence import Presence from app.models.pv_reunion import PvReunion -from app.models.decision import Decision from app.models.rotation import Rotation from app.models.rapport_rsg import RapportRsg from app.models.jeton import Jeton @@ -34,9 +33,6 @@ from app.models.litterature import Litterature from app.models.vente_litterature import VenteLitterature from app.models.envoi_contribution import EnvoiContribution from app.models.config_repartition import ConfigRepartition -from app.models.sondage import Sondage -from app.models.option_sondage import OptionSondage -from app.models.vote import Vote from app.models.sync_log import SyncLog from app.models.proposition import Proposition from app.models.operation_bancaire import OperationBancaire @@ -44,6 +40,8 @@ from app.models.evenement import Evenement from app.models.candidature import Candidature from app.models.poste_module import PosteModule from app.models.reserve import Reserve +from app.models.transaction_comptable import TransactionComptable +from app.models.mouvement_reserve import MouvementReserve POSTES = [ @@ -103,7 +101,7 @@ async def seed(): nom_membre = input(" Nom (optionnel, Entrée pour passer): ").strip() nom_membre = nom_membre.title() if nom_membre else None courriel = input(" Courriel: ").strip().lower() - while not courriel or "@" not in courriel: + while not courriel or ("@" not in courriel and courriel != "sysadmin"): courriel = input(" Courriel invalide. Réessayer: ").strip().lower() telephone = input(" Téléphone: ").strip() while not telephone: @@ -112,12 +110,14 @@ async def seed(): while not pin.isdigit() or len(pin) != 4: pin = input(" PIN invalide. 4 chiffres: ").strip() - print("\n Postes disponibles:") - for i, (nom_poste, _, _, _, _, _) in enumerate(POSTES[:4], 1): - print(f" {i}. {nom_poste}") - choix = input(" Votre poste (1-4): ").strip() - poste_idx = int(choix) - 1 if choix.isdigit() and 1 <= int(choix) <= 4 else 0 - poste_choisi = POSTES[poste_idx][0] + poste_choisi = None + if courriel != "sysadmin": + print("\n Postes disponibles:") + for i, (nom_poste, _, _, _, _, _) in enumerate(POSTES[:4], 1): + print(f" {i}. {nom_poste}") + choix = input(" Votre poste (1-4): ").strip() + poste_idx = int(choix) - 1 if choix.isdigit() and 1 <= int(choix) <= 4 else 0 + poste_choisi = POSTES[poste_idx][0] membre = Membre( groupe_id=groupe.id, @@ -130,15 +130,15 @@ async def seed(): db.add(membre) await db.flush() - # ── Affectation ── - affectation = Affectation( - groupe_id=groupe.id, - membre_id=membre.id, - poste_id=postes_map[poste_choisi].id, - date_debut=date.today(), - statut="actif", - ) - db.add(affectation) + if poste_choisi: + affectation = Affectation( + groupe_id=groupe.id, + membre_id=membre.id, + poste_id=postes_map[poste_choisi].id, + date_debut=date.today(), + statut="actif", + ) + db.add(affectation) # ── Première invitation ── code = secrets.token_hex(6).upper() @@ -174,7 +174,7 @@ async def seed(): print(f"\n═══ Initialisation terminée ═══") print(f" Membre: {prenom} ({courriel})") print(f" Groupe: Groupe Méditation") - print(f" Poste: {poste_choisi}") + print(f" Poste: {poste_choisi or 'Sysadmin'}") print(f"\n Code d'invitation pour les prochains membres: {code}") print(f" (Expire dans 7 jours)") print(f"\n Connectez-vous sur l'app avec:") diff --git a/docs/ANSIBLE.md b/docs/ANSIBLE.md index 0d732a0..15bcb78 100644 --- a/docs/ANSIBLE.md +++ b/docs/ANSIBLE.md @@ -114,6 +114,12 @@ ssh ansible@groupe-meditation.87-16.org 'sudo journalctl -u groupe-meditation -n - Site NGINX reverse proxy + SPA. - Sauvegarde quotidienne dans `/var/backups/groupe-meditation`. +## Documents liés + +- [Architecture technique](ARCHITECTURE.md) +- [Runbook d'exploitation](RUNBOOK.md) +- [Guide utilisateur](UTILISATEURS.md) + ## Notes importantes - Le playbook ne modifie pas le code applicatif. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f903e2c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,52 @@ +# Architecture technique + +## Sources de vérité + +- `propositions` porte les propositions, résolutions, nominations et contributions. +- Une décision de groupe est une proposition adoptée avec `date_adoption`, `numero_resolution`, `etant_donne_que` et `groupe_a_decide_de`. +- `transactions_comptables` est la source des soldes financiers. +- `mouvements_reserves` est la source des soldes de réserves. +- `reserves.montant` est un cache d'affichage; il ne doit pas devenir la source de vérité. + +## Backend + +- `app/core`: configuration, sécurité, rôles, règles communes. +- `app/services`: logique métier réutilisable, notamment `finance.py`. +- `app/models`: tables SQLAlchemy. +- `app/routers`: endpoints FastAPI. Les routers doivent orchestrer, pas contenir les calculs centraux. +- `app/schemas`: contrats Pydantic d'entrée/sortie. + +Règle de maintenance: un router ne doit pas importer un autre router pour obtenir une logique métier. Extraire dans `app/services` ou `app/core`. + +## Frontend + +- `src/pages`: écrans applicatifs. +- `src/components`: composants partagés. +- `src/utils`: calculs purs et helpers. +- `src/stores`: état global et client API. + +Règle de maintenance: si un contrôle ou calcul apparaît dans plusieurs pages, l'extraire dans `components` ou `utils`. + +## Comptabilité + +Les écritures comptables utilisent: + +- `compte`: `banque` ou `encaisse`; +- `sens`: `debit` augmente le compte, `credit` le diminue; +- `source_table` et `source_id`: lien vers l'objet métier. + +Exemples: + +- Collecte reçue: débit `encaisse`. +- Vente de littérature reçue: débit `encaisse`. +- Dépôt bancaire: débit `banque`, crédit `encaisse`. +- Retrait bancaire: crédit `banque`, débit `encaisse`. +- Dépense cash: crédit `encaisse`. +- Dépense par chèque/virement débitée: crédit `banque`. +- Contribution débitée: crédit `banque`. + +## Déploiement + +La voie normale est Ansible. Voir [ANSIBLE.md](ANSIBLE.md). + +Le déploiement installe PostgreSQL, le backend FastAPI, le frontend compilé, NGINX, systemd et les sauvegardes. diff --git a/docs/DEPLOIEMENT.md b/docs/DEPLOIEMENT.md index 5692308..c456791 100644 --- a/docs/DEPLOIEMENT.md +++ b/docs/DEPLOIEMENT.md @@ -1,389 +1,7 @@ -# Guide de déploiement — Groupe Méditation +# Déploiement -## Debian Bookworm + NGINX + PostgreSQL +La méthode supportée est le déploiement Ansible depuis ce dépôt. -Testé sur Debian 12 (Bookworm) avec un utilisateur `ansible` ayant accès sudo. +Voir [ANSIBLE.md](ANSIBLE.md) pour la procédure complète. -Principe : on utilise ce que Debian fournit. Pas de dépôt externe. - ---- - -## 0. Prérequis système - -```bash -# Définir un mot de passe pour l'utilisateur ansible (indispensable pour récupération console) -sudo passwd ansible - -sudo apt update && sudo apt upgrade -y -sudo apt install -y curl ca-certificates gnupg -``` - ---- - -## 1. Installer PostgreSQL - -On installe le PostgreSQL fourni par Debian Bookworm (version 15) : - -```bash -sudo apt install -y postgresql postgresql-client -sudo systemctl enable postgresql -sudo systemctl start postgresql -``` - -Créer l'utilisateur et la base. **Choisissez votre mot de passe maintenant** — il sera réutilisé à l'étape 6 : - -```bash -sudo -u postgres psql << 'SQL' -CREATE USER grpmed WITH PASSWORD 'votre_mot_de_passe_ici'; -CREATE DATABASE groupe_meditation OWNER grpmed; -GRANT ALL PRIVILEGES ON DATABASE groupe_meditation TO grpmed; -\c groupe_meditation -GRANT ALL ON SCHEMA public TO grpmed; -SQL -``` - -Vérifier la connexion. **Important : utiliser `127.0.0.1`, pas `localhost`** (asyncpg résout `localhost` en IPv6 `::1` sur Debian Bookworm, tandis que PostgreSQL n'écoute que sur `127.0.0.1`) : - -```bash -PGPASSWORD='votre_mot_de_passe_ici' psql -U grpmed -h 127.0.0.1 -d groupe_meditation -c "SELECT 1;" -``` - ---- - -## 2. Installer Node.js - -On installe le Node.js fourni par Debian Bookworm (version 18) : - -```bash -sudo apt install -y nodejs npm -``` - -Vérifier : - -```bash -node --version -npm --version -``` - ---- - -## 3. Installer Python et les dépendances système - -```bash -sudo apt install -y python3 python3-pip python3-venv - -# Dépendances système pour WeasyPrint (rapports PDF) -sudo apt install -y libpango-1.0-0 libpangocairo-1.0-0 \ - libgdk-pixbuf2.0-0 libffi-dev libcairo2 -``` - ---- - -## 4. Installer NGINX - -```bash -sudo apt install -y nginx -sudo systemctl enable nginx -sudo systemctl start nginx -``` - ---- - -## 5. Déployer le code source - -```bash -# Copier l'archive sur le serveur, puis : -cd ~ -unzip groupe-meditation-v12.zip - -# Backend -sudo mkdir -p /opt/groupe-meditation -sudo cp -r ~/groupe-meditation/backend/ /opt/groupe-meditation/backend/ -sudo cp -r ~/groupe-meditation/deploy/ /opt/groupe-meditation/deploy/ - -# Environnement virtuel Python -cd /opt/groupe-meditation/backend -sudo python3 -m venv venv -sudo /opt/groupe-meditation/backend/venv/bin/pip install -r requirements.txt -``` - ---- - -## 6. Configurer le backend - -Générer le secret JWT : - -```bash -openssl rand -base64 48 -``` - -Créer le `.env` : - -```bash -cd /opt/groupe-meditation/backend -sudo cp .env.example .env -sudo nano .env -``` - -Modifier `.env` avec le **même mot de passe** que l'étape 1. - -**⚠️ Utiliser `127.0.0.1`, jamais `localhost`** dans `DATABASE_URL` : - -``` -DATABASE_URL=postgresql+asyncpg://grpmed:votre_mot_de_passe_ici@127.0.0.1:5432/groupe_meditation -JWT_SECRET=valeur_generee_ci-dessus -CORS_ORIGINS=["https://groupe-meditation.87-16.org"] -INVITATION_EXPIRE_HOURS=48 -``` - -**Permissions du `.env` — obligatoire** (le service tourne en `www-data`) : - -```bash -sudo chown www-data:www-data /opt/groupe-meditation/backend/.env -sudo chmod 640 /opt/groupe-meditation/backend/.env -``` - ---- - -## 7. Initialiser la base de données - -**Le seed doit être lancé depuis le dossier `backend/`** pour que le fichier `.env` soit trouvé : - -```bash -cd /opt/groupe-meditation/backend -/opt/groupe-meditation/backend/venv/bin/python ../deploy/seed.py -``` - -Si le `.env` n'est pas trouvé, le script utilisera les valeurs par défaut de `config.py` (qui ont un autre mot de passe) et la connexion échouera. - -Le script crée **interactivement** : -- Les tables (23+) -- Les 10 postes prédéfinis -- La littérature de base (5 titres) -- Le premier groupe et le premier membre -- Un code d'invitation valide 7 jours - ---- - -## 7b. Mise à jour d'une base existante (v11 → v12) - -Si la base existe déjà, exécuter ces commandes SQL **avant** de relancer le service : - -```bash -PGPASSWORD='votre_mot_de_passe_ici' psql -U grpmed -h 127.0.0.1 -d groupe_meditation << 'SQL' --- ══ Membre : courriel obligatoire, nom optionnel, téléphone obligatoire ══ --- Remplir les courriels manquants avant de rendre NOT NULL -UPDATE membres SET courriel = CONCAT(LOWER(prenom), '_', LEFT(id::text, 4), '@temp.local') - WHERE courriel IS NULL OR courriel = ''; -UPDATE membres SET telephone = '000-000-0000' - WHERE telephone IS NULL OR telephone = ''; -ALTER TABLE membres ALTER COLUMN courriel SET NOT NULL; -ALTER TABLE membres ALTER COLUMN telephone SET NOT NULL; -ALTER TABLE membres ALTER COLUMN nom DROP NOT NULL; --- Contrainte d'unicité courriel+groupe -ALTER TABLE membres ADD CONSTRAINT uq_courriel_groupe UNIQUE (courriel, groupe_id); - --- ══ Tâches ══ -CREATE TABLE IF NOT EXISTS taches ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - nom VARCHAR(200) NOT NULL, - informations TEXT, - ordre_affichage INTEGER DEFAULT 99, - cree_le TIMESTAMPTZ DEFAULT NOW() -); -CREATE TABLE IF NOT EXISTS postes_taches ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - poste_id UUID NOT NULL REFERENCES postes(id), - tache_id UUID NOT NULL REFERENCES taches(id), - cree_le TIMESTAMPTZ DEFAULT NOW() -); - --- ══ Poste : champ informations ══ -ALTER TABLE postes ADD COLUMN IF NOT EXISTS informations TEXT; - --- ══ Dépenses : preuve, methode nullable ══ -ALTER TABLE depenses ADD COLUMN IF NOT EXISTS preuve_nom VARCHAR(255); -ALTER TABLE depenses ALTER COLUMN methode DROP NOT NULL; - --- ══ Candidatures : type, nomination, retrait ══ --- Ajouter les nouvelles valeurs au type ENUM -DO $$ BEGIN - ALTER TYPE statut_candidature ADD VALUE IF NOT EXISTS 'retiree'; -EXCEPTION WHEN duplicate_object THEN NULL; -END $$; -DO $$ BEGIN - CREATE TYPE type_candidature AS ENUM ('candidature', 'nomination'); -EXCEPTION WHEN duplicate_object THEN NULL; -END $$; -ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'candidature'; -ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS nomme_par UUID REFERENCES membres(id); -ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS acceptee_par_membre BOOLEAN; -ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS proposition_id UUID REFERENCES propositions(id); - --- ══ Envoi contributions : résolution, engagement ══ -ALTER TABLE envois_contributions ADD COLUMN IF NOT EXISTS resolution_id UUID REFERENCES propositions(id); -ALTER TABLE envois_contributions ADD COLUMN IF NOT EXISTS engagee BOOLEAN DEFAULT false; -ALTER TABLE envois_contributions ADD COLUMN IF NOT EXISTS debitee BOOLEAN DEFAULT false; - --- ══ Propositions : type et lien candidature ══ -ALTER TABLE propositions ADD COLUMN IF NOT EXISTS type_proposition VARCHAR(20) DEFAULT 'generale'; -ALTER TABLE propositions ADD COLUMN IF NOT EXISTS candidature_id UUID REFERENCES candidatures(id); - --- ══ Répertoire des preuves ══ --- Créer le répertoire manuellement : --- sudo mkdir -p /opt/groupe-meditation/uploads/preuves --- sudo chown www-data:www-data /opt/groupe-meditation/uploads/preuves -SQL -``` - -Ensuite créer le répertoire des preuves et relancer : - -```bash -sudo mkdir -p /opt/groupe-meditation/uploads/preuves -sudo chown www-data:www-data /opt/groupe-meditation/uploads/preuves -sudo systemctl restart groupe-meditation -``` - ---- - -## 8. Créer le service systemd - -```bash -sudo cp /opt/groupe-meditation/deploy/groupe-meditation.service /etc/systemd/system/ -sudo chown -R www-data:www-data /opt/groupe-meditation -sudo systemctl daemon-reload -sudo systemctl enable groupe-meditation -sudo systemctl start groupe-meditation -``` - -Vérifier : - -```bash -sudo systemctl status groupe-meditation -curl -s http://127.0.0.1:8000/api/health -``` - -Doit retourner `{"status":"ok","app":"Groupe Méditation","version":"0.1.0"}` - ---- - -## 9. Construire et déployer le frontend - -```bash -cd ~/groupe-meditation/frontend -npm install -npm run build - -sudo mkdir -p /var/www/groupe-meditation -sudo cp -r dist/* /var/www/groupe-meditation/ -sudo chown -R www-data:www-data /var/www/groupe-meditation -``` - ---- - -## 10. Configurer NGINX - -NGINX fait tout : fichiers statiques du frontend + reverse proxy vers FastAPI. - -```bash -sudo cp ~/groupe-meditation/deploy/nginx-groupe-meditation.conf \ - /etc/nginx/sites-available/groupe-meditation -sudo ln -sf /etc/nginx/sites-available/groupe-meditation /etc/nginx/sites-enabled/ -sudo rm -f /etc/nginx/sites-enabled/default - -sudo nginx -t -sudo systemctl reload nginx -``` - -Tester en HTTP : - -```bash -curl -s http://groupe-meditation.87-16.org/api/health -``` - ---- - -## 11. Certificat TLS (Let's Encrypt) - -```bash -sudo apt install -y certbot python3-certbot-nginx -sudo certbot --nginx -d groupe-meditation.87-16.org -``` - -Certbot modifie automatiquement la config NGINX pour ajouter HTTPS et la redirection 80→443. - ---- - -## 12. Sauvegarde automatique - -```bash -sudo cp ~/groupe-meditation/deploy/backup.sh /etc/cron.daily/groupe-meditation-backup -sudo chmod +x /etc/cron.daily/groupe-meditation-backup -``` - ---- - -## 13. Vérification finale - -```bash -# API -curl -s https://groupe-meditation.87-16.org/api/health | python3 -m json.tool - -# Documentation OpenAPI -# https://groupe-meditation.87-16.org/api/docs - -# Frontend -# https://groupe-meditation.87-16.org -``` - ---- - -## Mise à jour de l'application - -```bash -# 1. Copier les nouveaux fichiers -cd ~ -unzip groupe-meditation-vXX.zip - -# 2. Backend -sudo cp -r ~/groupe-meditation/backend/ /opt/groupe-meditation/backend/ -cd /opt/groupe-meditation/backend -sudo venv/bin/pip install -r requirements.txt - -# 3. Migrations SQL si nécessaire (voir section 7b) - -# 4. Permissions -sudo chown -R www-data:www-data /opt/groupe-meditation -sudo chown www-data:www-data /opt/groupe-meditation/backend/.env -sudo chmod 640 /opt/groupe-meditation/backend/.env - -# 5. Relancer le service -sudo systemctl restart groupe-meditation - -# 6. Frontend -cd ~/groupe-meditation/frontend -npm install && npm run build -sudo cp -r dist/* /var/www/groupe-meditation/ -``` - ---- - -## Dépannage - -| Problème | Commande / Solution | -|----------|---------------------| -| API ne démarre pas | `sudo journalctl -u groupe-meditation -f` | -| Workers bloqués au démarrage | `DATABASE_URL` doit utiliser `127.0.0.1` (pas `localhost`) | -| `InvalidPasswordError` au seed | Lancer depuis `backend/`, vérifier `.env` lisible | -| `InvalidPasswordError` à l'API | `sudo chown www-data:www-data .env && sudo chmod 640 .env` | -| NGINX 502 Bad Gateway | `sudo systemctl status groupe-meditation` — Uvicorn démarré ? | -| NGINX 504 Gateway Timeout | Worker bloqué DB — vérifier `127.0.0.1` dans `DATABASE_URL` | -| WeasyPrint échoue | `sudo apt install libpango-1.0-0 libpangocairo-1.0-0` | -| `seed.py ImportError` | Vérifier les noms de classes dans `models/` | -| Console inaccessible | `sudo passwd ansible` — à faire en tout début | -| `type "..." does not exist` après ALTER | Relancer le service pour que `create_all` crée les types manquants | -| Nouvelle colonne absente | Exécuter les ALTER TABLE de la section 7b | - ---- - -**Hébergé au Québec · 100 % logiciel libre · Zéro traçage** +Ce fichier est conservé comme point d'entrée historique afin d'éviter deux procédures concurrentes. diff --git a/docs/Liste-de-prix-rev.-Fevrier-2026.pdf b/docs/Liste-de-prix-rev.-Fevrier-2026.pdf new file mode 100644 index 0000000..5674ce1 Binary files /dev/null and b/docs/Liste-de-prix-rev.-Fevrier-2026.pdf differ diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 5712454..753a252 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -42,13 +42,13 @@ ## Trésorerie (S1T) -Le trésorier dispose de quatre onglets. +Le module Trésorerie s'ouvre sur l'état des résultats. Les opérations de modification sont réservées aux postes exécutifs. ### Soldes -- **Solde banque** : basé sur le dernier relevé + dépôts/retraits enregistrés depuis -- **Disponible** : solde banque − engagements non débités − réserves +- **Solde banque** : calculé depuis le registre comptable +- **Disponible** : banque − engagements non débités − réserves affectées - **Encaisse** : cash reçu mais pas encore déposé à la banque -- **Réserves** : prudente (500 $) + anniversaire (+20 $/mois depuis la création du groupe) +- **Réserves** : soldes calculés depuis l'historique des mouvements de réserves - **En circulation** : collectes pas encore remises au trésorier ### À recevoir @@ -57,18 +57,18 @@ Deux catégories : 2. **Ventes de littérature** non remises — le responsable de la littérature vend, puis le trésorier confirme qu'il a reçu l'argent via « Confirmer la remise » ### Registre -Journal détaillé de toutes les transactions du mois courant : collectes, ventes, dépenses, contributions, opérations bancaires. Avec totaux entrées/sorties. +Journal détaillé des transactions : collectes, ventes, dépenses, contributions et opérations bancaires. Le registre comptable est la source de vérité des soldes. -### Opération bancaire -Trois types : -- **Dépôt** : enregistrer un dépôt bancaire -- **Retrait** : enregistrer un retrait -- **Relevé** : saisir le solde du relevé mensuel de la banque (sert de base au calcul des soldes) +### Opérations +Trois fonctions : +- **Dépôt** : enregistrer un dépôt bancaire provenant de l'encaisse ou d'un don direct +- **Retrait** : enregistrer un retrait de la banque vers l'encaisse +- **Virement** : transférer des montants entre les réserves ### Flux mensuel recommandé -1. Recevoir le relevé bancaire → enregistrer un **relevé** dans S1T -2. Vérifier que le solde correspond → les écarts signalent des opérations non enregistrées -3. Préparer le rapport financier pour la réunion d'affaires +1. Recevoir le relevé bancaire → vérifier le registre de trésorerie +2. Vérifier que le solde correspond → les écarts signalent des opérations non enregistrées ou une correction à saisir +3. Préparer l'état des résultats pour la réunion d'affaires --- @@ -88,17 +88,20 @@ Lorsque l'argent quitte effectivement le compte : --- -## Propositions et résolutions (C8) +## Gouvernance (C5) ### Soumettre une proposition 1. N'importe quel membre peut soumettre une proposition **en tout temps** (pas besoin d'une réunion d'affaires) 2. Le sujet est obligatoire ; la réunion d'affaires est optionnelle 3. Un autre membre doit **seconder** la proposition pour qu'elle soit recevable -### Voter une résolution -1. L'exécutif vote **Adopter** ou **Rejeter** lors de la réunion d'affaires -2. Si adoptée, un numéro de résolution est attribué (RES-2026-001) -3. La proposition est automatiquement associée à la réunion d'affaires lors du vote +### Transformer en résolution +1. L'exécutif transforme une proposition secondée en résolution +2. Les sections **ÉTANT DONNÉ QUE** et **LE GROUPE A DÉCIDÉ DE** sont obligatoires +3. Si adoptée, un numéro est attribué : `RES` pour une résolution, `NOM` pour une nomination, `DON` pour une contribution + +### Rejeter une proposition +Un membre de l'exécutif peut rejeter une proposition en cours depuis C5. ### Lien avec le PV Lors de la rédaction du PV (M1), le secrétaire peut **importer les propositions secondées** en un clic. Elles pré-remplissent la section décisions avec le sujet, le proposeur et le secondeur. @@ -108,14 +111,14 @@ Lors de la rédaction du PV (M1), le secrétaire peut **importer les proposition ## Réunion d'affaires mensuelle ### Avant la réunion -- Les membres soumettent leurs propositions via C8 (en tout temps) +- Les membres soumettent leurs propositions via C5 (en tout temps) - D'autres membres secondent les propositions recevables ### Pendant la réunion 1. **Secrétaire** : M2 (présences) → M1 (PV avec import des propositions) 2. **RSG** : M3 (rapport du district) -3. **Trésorier** : présente le rapport financier (depuis le registre S1T) -4. **Exécutif** : vote les propositions secondées (C8) +3. **Trésorier** : présente l'état des résultats et la position de trésorerie +4. **Exécutif** : transforme les propositions secondées en résolutions (C5) ### Après la réunion Le secrétaire finalise le PV avec les décisions votées. @@ -135,9 +138,8 @@ Le secrétaire finalise le PV avec les décisions votées. | Rapport | URL API | Contenu | |---------|---------|---------| -| Financier mensuel | `/api/rapports/finances/{annee}/{mois}` | Collectes + dépenses + solde | +| État des résultats | `/api/rapports/etat-resultats/{annee}/{mois}` | Revenus + charges + excédent/déficit + trésorerie | | Réunions du mois | `/api/rapports/reunions/{annee}/{mois}` | Réunions + thèmes | -| Financier complet | `/api/rapports/financier-complet/{annee}/{mois}` | Collectes + dépenses + contributions + impayés | > **Note :** les routes PDF nécessitent WeasyPrint installé sur le serveur. @@ -213,7 +215,7 @@ sudo systemctl start groupe-meditation | Module | RSG | RSG sub | Trés. | Secr. | Anim. | Litt. | Jetons | Tous | |--------|:---:|:-------:|:-----:|:-----:|:-----:|:-----:|:------:|:----:| | C1-C9 Consultation | | | | | | | | ✓ | -| S1 Collecte | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| S1 Collecte | ✓ | ✓ | ✓ | ✓ | | | | | | S1T Trésorerie | | | ✓ | | | | | | | S2 Remise jeton | | | | | | | ✓ | | | S3 Vente litt. | | | | | | ✓ | | | diff --git a/docs/UTILISATEURS.md b/docs/UTILISATEURS.md index c895bbe..7a9d0a2 100644 --- a/docs/UTILISATEURS.md +++ b/docs/UTILISATEURS.md @@ -77,13 +77,11 @@ Permet de consulter l'inventaire de littérature : - quantités; - alertes de stock bas. -### Décisions en cours +### Gouvernance -Affiche le suivi des décisions et propositions non terminées. +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. -### Votes ouverts - -Permet aux membres de voter dans les sondages ouverts. +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 @@ -98,21 +96,11 @@ Permet de modifier : Un courriel déjà utilisé dans le même groupe ne peut pas être repris. -### Propositions - -Permet de : - -- soumettre une proposition; -- consulter les propositions; -- seconder une proposition; -- suivre le statut des résolutions. - Une proposition suit normalement ce cycle : 1. proposée; 2. secondée; -3. votée; -4. adoptée ou rejetée. +3. transformée en résolution par l'exécutif. Une proposition adoptée devient une résolution numérotée. @@ -122,24 +110,32 @@ Permet de consulter les postes vacants et de proposer sa candidature. Si un membre est nominé à un poste, il peut accepter ou refuser la nomination. -### État financier +### Trésorerie -Affiche une vue rapide : +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. + +L'écran affiche aussi la position courante de trésorerie : - solde bancaire; - encaisse; - montant disponible; -- résultat net; -- revenus; -- sorties; +- montants engagés; - réserves; -- dernières collectes; -- dernières dépenses. +- dernières collectes et dépenses. -## 4. Modules de saisie accessibles à tous +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. + +## 4. Modules de saisie + +### Rencontre hebdomadaire + +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. ### Collecte 7e Tradition +Ce module est réservé aux postes exécutifs. + Permet de saisir une collecte. Règles importantes : @@ -153,15 +149,18 @@ Le module offre aussi une liste des collectes existantes. ### Dépense -Permet à un membre de soumettre une dépense. +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. + +La date de la dépense est la date de soumission. La dépense n'est pas considérée comme payée tant que le trésorier ne l'a pas confirmée. Le trésorier choisit la méthode : - `COLLECTE` : payé depuis l'encaisse; -- `CHEQUE` ou `VIREMENT` : engagement à confirmer lorsque le débit apparaît; -- `DU` : montant dû au membre. +- `CHEQUE` ou `VIREMENT` : engagement à confirmer lorsque le débit apparaît. + +Une dépense soumise peut aussi être rejetée. Dans ce cas, elle est supprimée. ## 5. Trésorerie @@ -205,21 +204,20 @@ Types disponibles : - dépôts bancaires; - retraits bancaires. -### Réserves +### Réserves et opérations -Permet de créer, modifier et supprimer les réserves. +Dans l'onglet **Opérations**, il est possible de créer, modifier et supprimer les réserves. Règle importante : le total des réserves ne peut jamais dépasser le solde en banque. -### Opérations bancaires - Permet d'enregistrer : -- un dépôt : transfert de l'encaisse vers la banque; +- 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 relevé : solde réel du compte bancaire à une date donnée. +- un virement entre réserves. -Le dépôt ne peut pas dépasser l'encaisse disponible. +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 @@ -246,6 +244,10 @@ La contribution ne modifie pas les soldes immédiatement. Elle doit être confir ## 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. @@ -354,17 +356,7 @@ Permet de modifier les informations générales : - heure; - format. -## 13. Sondages - -### Lancer un sondage - -Permet à l'exécutif de créer un sondage. - -### Votes ouverts - -Permet aux membres de voter. - -## 14. Bonnes pratiques +## 13. Bonnes pratiques - Utiliser son propre compte, jamais celui d'un autre membre. - Ne pas partager son PIN. @@ -375,7 +367,7 @@ Permet aux membres de voter. - Garder les réserves à jour. - Utiliser les propositions et résolutions pour conserver une trace claire des décisions. -## 15. Problèmes fréquents +## 14. Problèmes fréquents ### Je ne peux pas me connecter @@ -397,7 +389,7 @@ Demander à l'exécutif de vérifier : ### Le dépôt est refusé -Le montant du dépôt dépasse probablement l'encaisse disponible. +Si le dépôt provient de l'encaisse, son montant dépasse probablement l'encaisse disponible. ### Une réserve est refusée @@ -415,7 +407,7 @@ Causes possibles : - collecte déjà enregistrée pour cette date; - date future. -## 16. Règles importantes de l'application +## 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. @@ -423,4 +415,3 @@ Causes possibles : - 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. - diff --git a/frontend/public/push-sw.js b/frontend/public/push-sw.js new file mode 100644 index 0000000..1304691 --- /dev/null +++ b/frontend/public/push-sw.js @@ -0,0 +1,24 @@ +self.addEventListener('push', (event) => { + let data = {}; + if (event.data) { + try { + data = event.data.json(); + } catch { + data = { title: 'Groupe Méditation', body: event.data.text() }; + } + } + const title = data.title || 'Groupe Méditation'; + const options = { + body: data.body || 'Nouvelle notification', + icon: '/icon-192.png', + badge: '/icon-192.png', + data: { url: data.url || '/' }, + }; + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = event.notification.data?.url || '/'; + event.waitUntil(clients.openWindow(url)); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cbddf16..bd30a06 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -23,13 +23,13 @@ import Inventaire from './pages/Inventaire'; import AjusterInventaire from './pages/AjusterInventaire'; import EnvoiContributions from './pages/EnvoiContributions'; import ConfigRepartition from './pages/ConfigRepartition'; -import LancerSondage from './pages/LancerSondage'; -import VotesOuverts from './pages/VotesOuverts'; -import Propositions from './pages/Propositions'; import Tresorerie from './pages/Tresorerie'; import Impliquer from './pages/Impliquer'; import GererPostes from './pages/GererPostes'; -import EtatFinancier from './pages/EtatFinancier'; +import ReunionHebdomadaire from './pages/ReunionHebdomadaire'; +import AssembleeAffaires from './pages/AssembleeAffaires'; +import JournalActions from './pages/JournalActions'; +import BottomNav from './components/BottomNav'; export default function App() { const { token, page, loadAccueil, loadGroupes } = useStore(); @@ -57,37 +57,45 @@ export default function App() { // Pages protégées if (token) { + let pageActive; switch (page) { - case 'accueil': return ; - case 'profil': return ; - case 'S1': return ; - case 'S1T': return ; - case 'S4': return ; - case 'S5': return ; - case 'C1': return ; - case 'C2': return ; - case 'C5': return ; - case 'G2': return ; - case 'G9': return ; - case 'G11': return ; - case 'G12': return ; - case 'M1': return ; - case 'M2': return ; - case 'M3': return ; - case 'S2': return ; - case 'M6': return ; - case 'S3': return ; - case 'C4': return ; - case 'M5': return ; - case 'M4': return ; - case 'G8': return ; - case 'G6': return ; - case 'C6': return ; - case 'C8': return ; - case 'C9': return ; - case 'C10': return ; - default: return ; + case 'accueil': pageActive = ; break; + case 'profil': pageActive = ; break; + case 'S0': pageActive = ; break; + case 'M0': pageActive = ; break; + case 'S1': pageActive = ; break; + case 'S1T': pageActive = ; break; + case 'S4': pageActive = ; break; + case 'S5': pageActive = ; break; + case 'C1': pageActive = ; break; + case 'C2': pageActive = ; break; + case 'C5': pageActive = ; break; + case 'G2': pageActive = ; break; + case 'G9': pageActive = ; break; + case 'G11': pageActive = ; break; + case 'G12': pageActive = ; break; + case 'G13': pageActive = ; break; + case 'M1': pageActive = ; break; + case 'M2': pageActive = ; break; + case 'M3': pageActive = ; break; + case 'S2': pageActive = ; break; + case 'M6': pageActive = ; break; + case 'S3': pageActive = ; break; + case 'C4': pageActive = ; break; + case 'M5': pageActive = ; break; + case 'M4': pageActive = ; break; + case 'G8': pageActive = ; break; + case 'C9': pageActive = ; break; + case 'C10': pageActive = ; break; + default: pageActive = ; } + const afficherNavigationContextes = page === 'accueil'; + return ( + <> +
{pageActive}
+ {afficherNavigationContextes && } + + ); } // Pages publiques diff --git a/frontend/src/components/BottomNav.jsx b/frontend/src/components/BottomNav.jsx new file mode 100644 index 0000000..d6b7a14 --- /dev/null +++ b/frontend/src/components/BottomNav.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useStore } from '../stores/store'; + +const NAV_CONTEXTS = [ + { id: 'accueil', label: 'Accueil', icon: '⌂' }, + { id: 'reunion', label: 'Réunion', icon: '□' }, + { id: 'affaires', label: 'Affaires', icon: '◇' }, + { id: 'gestion', label: 'Gestion', icon: '○' }, +]; + +export default function BottomNav() { + const { navContext, setNavContext, setPage } = useStore(); + + const ouvrirContexte = (id) => { + setNavContext(id); + setPage('accueil'); + }; + + return ( + + ); +} diff --git a/frontend/src/components/MeetingDateSelect.jsx b/frontend/src/components/MeetingDateSelect.jsx new file mode 100644 index 0000000..66a34d1 --- /dev/null +++ b/frontend/src/components/MeetingDateSelect.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { labelDateReunion } from '../utils/datesReunion'; + +const SELECT_CLASS = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue'; + +export default function MeetingDateSelect({ dates, value, onChange, className = SELECT_CLASS }) { + if (!dates.length) { + return ( +
+ Configurez le jour de réunion dans les paramètres du groupe. +
+ ); + } + return ( + + ); +} diff --git a/frontend/src/components/PageHeader.jsx b/frontend/src/components/PageHeader.jsx index 5edef2a..1214f70 100644 --- a/frontend/src/components/PageHeader.jsx +++ b/frontend/src/components/PageHeader.jsx @@ -4,11 +4,17 @@ import { useStore } from '../stores/store'; export default function PageHeader({ titre, sousTitre }) { const { setPage } = useStore(); return ( -
- -
-

{titre}

- {sousTitre &&

{sousTitre}

} +
+ +
+

{titre}

+ {sousTitre &&

{sousTitre}

}
); diff --git a/frontend/src/index.css b/frontend/src/index.css index 46e5715..0b0eec0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,6 +10,7 @@ body { font-family: 'DM Sans', sans-serif; -webkit-font-smoothing: antialiased; + overscroll-behavior-y: contain; } button:active { @@ -20,3 +21,18 @@ button:active { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } } + +@layer utilities { + .pb-safe { + padding-bottom: max(0.5rem, env(safe-area-inset-bottom)); + } + + .app-bottom-space { + padding-bottom: calc(5.5rem + env(safe-area-inset-bottom)); + } + + .tap-target { + min-height: 48px; + min-width: 48px; + } +} diff --git a/frontend/src/pages/Accueil.jsx b/frontend/src/pages/Accueil.jsx index 5cb0c52..7eebd45 100644 --- a/frontend/src/pages/Accueil.jsx +++ b/frontend/src/pages/Accueil.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { useStore } from '../stores/store'; const COULEURS = { @@ -12,18 +12,65 @@ const COULEURS = { gray: 'bg-gray-50 border-gray-200 text-gray-700', }; +const NAVIGABLE = ['C7', 'S0', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C9', 'G2', 'G9', 'G11', 'G12', 'G13', 'M0', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6']; + +const CONTEXTES = { + accueil: { + titre: 'Accueil', + description: 'Les objets les plus utiles au quotidien.', + sections: [ + { titre: 'À faire maintenant', codes: ['S5', 'C5', 'S1T', 'S0'] }, + { titre: 'Consulter', codes: ['C2', 'S1T', 'C1', 'C4'] }, + { titre: 'Mon espace', codes: ['C7', 'C9'] }, + ], + }, + reunion: { + titre: 'Réunion', + description: 'Ce qui sert pendant une réunion régulière.', + sections: [ + { titre: 'Rapport de réunion', codes: ['S0', 'S4'] }, + { titre: 'Saisies', codes: ['S1', 'S2', 'S3'] }, + { titre: 'Références', codes: ['C2', 'C4'] }, + ], + }, + affaires: { + titre: 'Affaires', + description: 'Décisions, assemblées et vie de groupe.', + sections: [ + { titre: "Assemblée d'affaires", codes: ['M0', 'M2', 'M1', 'M3'] }, + { titre: 'Gouvernance', codes: ['C5', 'C1'] }, + { titre: 'Postes à pourvoir', codes: ['C9'] }, + ], + }, + gestion: { + titre: 'Gestion', + description: 'Administration, finances et structure du groupe.', + sections: [ + { titre: 'Finances', codes: ['S1T', 'S5', 'M4'] }, + { titre: 'Membres et structure', codes: ['G2', 'G11', 'G12', 'G9', 'G13'] }, + { titre: 'Inventaire', codes: ['C4', 'M5', 'M6'] }, + ], + }, +}; + +function ouvrirModule(module) { + if (module.code === 'C7') useStore.setState({ page: 'profil' }); + else if (NAVIGABLE.includes(module.code)) useStore.setState({ page: module.code }); + else alert(`Module ${module.code} - ${module.nom}\n\n(Sera disponible dans les prochaines phases)`); +} + function Tuile({ module, onClick }) { const cls = COULEURS[module.couleur] || COULEURS.gray; return (
{postes.length > 0 && ( @@ -77,11 +144,49 @@ export default function Accueil() { )}
- {/* Modules */} -
-
-
-
+
+ setRecherche(e.target.value)} + placeholder="Chercher un module" + className="w-full min-h-[48px] px-4 bg-white rounded-2xl border-0 shadow-sm text-base focus:ring-2 focus:ring-aa-blue" + /> + + {q ? ( +
+

+ Résultats +

+ {resultatsRecherche.length === 0 ? ( +
+ Aucun module trouvé +
+ ) : ( +
+ {resultatsRecherche.map((m) => ouvrirModule(m)} />)} +
+ )} +
+ ) : ( + <> + {sections.length === 0 && ( +
+ Aucun module accessible dans ce contexte +
+ )} + {sections.map((section) => ( +
+

+ {section.titre} +

+
+ {section.modules.map((m) => ouvrirModule(m)} />)} +
+
+ ))} + + )}
); diff --git a/frontend/src/pages/AffecterPostes.jsx b/frontend/src/pages/AffecterPostes.jsx index 7f105dc..cf13d5a 100644 --- a/frontend/src/pages/AffecterPostes.jsx +++ b/frontend/src/pages/AffecterPostes.jsx @@ -1,16 +1,21 @@ import React, { useState, useEffect } from 'react'; import { api } from '../stores/api'; +import { useStore } from '../stores/store'; import PageHeader from '../components/PageHeader'; import SuccessCheck from '../components/SuccessCheck'; +import MeetingDateSelect from '../components/MeetingDateSelect'; +import { datesReunion, dateLocale } from '../utils/datesReunion'; export default function AffecterPostes({ compact = false }) { + const { groupe } = useStore(); + const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 }); const [postes, setPostes] = useState([]); const [membres, setMembres] = useState([]); const [affectations, setAffectations] = useState([]); const [form, setForm] = useState({ membre_id: '', poste_id: '', - date_debut: new Date().toISOString().slice(0, 10), + date_debut: dateLocale(), proposeur_id: '', secondeur_id: '', }); @@ -31,6 +36,12 @@ export default function AffecterPostes({ compact = false }) { }).catch(() => setLoading(false)); }, []); + useEffect(() => { + if (choixDates.length > 0 && !choixDates.includes(form.date_debut)) { + setForm((f) => ({ ...f, date_debut: choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1] })); + } + }, [groupe?.jour_reunion]); + const handleSubmit = async () => { if (!form.membre_id || !form.poste_id || !form.proposeur_id || !form.secondeur_id) { setError('Sélectionnez un membre, un poste, un proposeur et un secondeur'); @@ -131,9 +142,7 @@ export default function AffecterPostes({ compact = false }) {
- setForm({ ...form, date_debut: e.target.value })} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> + setForm({ ...form, date_debut: v })} />
diff --git a/frontend/src/pages/AssembleeAffaires.jsx b/frontend/src/pages/AssembleeAffaires.jsx new file mode 100644 index 0000000..e2d89c3 --- /dev/null +++ b/frontend/src/pages/AssembleeAffaires.jsx @@ -0,0 +1,255 @@ +import React, { useEffect, useState } from 'react'; +import { api } from '../stores/api'; +import { useStore } from '../stores/store'; +import PageHeader from '../components/PageHeader'; +import { dateHeureReunionIso, datesAssembleesAffaires, dateLocale, labelDateReunion } from '../utils/datesReunion'; + +const INPUT = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue'; + +export default function AssembleeAffaires() { + const { postes, groupe } = useStore(); + const estExecutif = postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p)); + const choixDates = datesAssembleesAffaires(groupe); + const [membres, setMembres] = useState([]); + const [reunions, setReunions] = useState([]); + const [pvs, setPvs] = useState([]); + const [propositions, setPropositions] = useState([]); + const [resolutions, setResolutions] = useState([]); + const [postesVacants, setPostesVacants] = useState([]); + const [reunionId, setReunionId] = useState(''); + const [dateSelection, setDateSelection] = useState(dateLocale()); + const [dateReunion, setDateReunion] = useState(dateLocale()); + const [note, setNote] = useState(''); + const [presencesMap, setPresencesMap] = useState({}); + const [contenu, setContenu] = useState(''); + const [pvPrecedent, setPvPrecedent] = useState(null); + const [showPvPrecedent, setShowPvPrecedent] = useState(false); + const [adoption, setAdoption] = useState({ proposeur: '', secondeur: '' }); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const charger = async () => { + try { + const [m, r, pv, props, res, postesDispo] = await Promise.all([ + api.get('/membres/actifs'), + api.get('/presences/reunions'), + api.get('/pv/'), + api.get('/propositions/?statut=secondee&limite=100'), + api.get('/propositions/resolutions'), + api.get('/postes/disponibles'), + ]); + setMembres(m); + setReunions(r); + setPvs(pv); + setPropositions(props); + setResolutions(res); + setPostesVacants(postesDispo); + const map = {}; + m.forEach((mb) => { map[mb.id] = true; }); + setPresencesMap(map); + const duJour = r.find((x) => x.date_reunion?.slice(0, 10) === dateSelection); + if (duJour) setReunionId(duJour.id); + } catch (e) { setError(e.message); } + }; + + useEffect(() => { charger(); }, []); + useEffect(() => { + if (choixDates.length > 0) { + if (!choixDates.includes(dateSelection)) setDateSelection(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]); + if (!choixDates.includes(dateReunion)) setDateReunion(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]); + } + }, [groupe?.jour_reunion, groupe?.assemblee_affaires_ordre]); + useEffect(() => { + const duJour = reunions.find((x) => x.date_reunion?.slice(0, 10) === dateSelection); + setReunionId(duJour?.id || ''); + const precedents = pvs.filter((p) => p.date_reunion && p.date_reunion.slice(0, 10) < dateSelection); + setPvPrecedent(precedents[0] || null); + }, [dateSelection, reunions, pvs]); + + const reunion = reunions.find((r) => r.id === reunionId); + const pvJour = pvs.find((p) => p.date_reunion?.slice(0, 10) === dateSelection); + const resolutionsJour = resolutions.filter((r) => (r.date_vote || r.cree_le?.slice(0, 10)) === dateSelection); + const nbPresents = Object.values(presencesMap).filter(Boolean).length; + + const toggle = (membreId) => setPresencesMap({ ...presencesMap, [membreId]: !presencesMap[membreId] }); + const imprimer = () => window.print(); + + const creerRapport = async () => { + const res = await api.post('/presences/reunion', { + date_reunion: dateHeureReunionIso(dateReunion, groupe), + note: note.trim() || null, + }); + setReunionId(res.id); + setReunions([res, ...reunions]); + setDateSelection(res.date_reunion.slice(0, 10)); + return res.id; + }; + + const enregistrer = async () => { + setError(''); + setMsg(''); + setLoading(true); + try { + const id = reunionId || await creerRapport(); + const presences = Object.entries(presencesMap).map(([membre_id, present]) => ({ membre_id, present })); + await api.post('/presences/', { reunion_id: id, presences }); + if (contenu.trim()) { + await api.post('/pv/', { + reunion_id: id, + contenu: contenu.trim(), + decisions: null, + }); + } + setMsg(contenu.trim() ? 'Présences et PV enregistrés' : 'Présences enregistrées'); + await charger(); + } catch (e) { setError(e.message); } + setLoading(false); + }; + + const adopterPvPrecedent = async () => { + if (!pvPrecedent) return; + setError(''); + try { + await api.patch(`/pv/${pvPrecedent.id}/adopter`, adoption); + setMsg('PV précédent adopté'); + await charger(); + } catch (e) { setError(e.message); } + }; + + const textePvAuto = [ + 'Ouverture de l’assemblée.', + '', + pvPrecedent ? `Lecture du procès-verbal précédent (${new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}).` : 'Lecture du procès-verbal précédent.', + '', + 'Réflexions en cours:', + ...propositions.map((p) => `- ${p.sujet} (${p.proposeur_prenom} / ${p.secondeur_prenom || 'à confirmer'})`), + '', + 'Décisions prises pendant cette assemblée:', + ...resolutionsJour.map((r) => `- ${r.numero_resolution || ''} ${r.sujet}`.trim()), + '', + 'Postes disponibles:', + ...postesVacants.map((p) => `- ${p.nom}`), + ].join('\n'); + + return ( +
+ +
+ {msg &&
{msg}
} + {error &&
{error}
} + + + + + +
+ + {pvPrecedent && ( + + )} +
+
+ + {showPvPrecedent && pvPrecedent && ( + +

{new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}

+

{pvPrecedent.contenu}

+ {estExecutif && !pvPrecedent.approuve && ( +
+
+ setAdoption({ ...adoption, proposeur: e.target.value })} placeholder="Proposeur" className={INPUT} /> + setAdoption({ ...adoption, secondeur: e.target.value })} placeholder="Secondeur" className={INPUT} /> +
+ +
+ )} +
+ )} + +
+

Rapport d’assemblée d’affaires

+

{dateSelection}

+
+ {reunion?.presences?.length ? reunion.presences.filter((p) => p.present).map((p) =>
  • {p.membre_prenom}
  • ) :
  • Présences à cocher sur papier
  • } +
    +
    + {propositions.length ? propositions.map((p) =>
  • {p.sujet} ({p.proposeur_prenom} / {p.secondeur_prenom || 'à confirmer'})
  • ) :
  • Aucune proposition secondée non résolue
  • } +
    +
    + {resolutionsJour.length ? resolutionsJour.map((r) =>
  • {r.numero_resolution} — {r.sujet}
  • ) :
  • Aucune décision enregistrée pour cette date
  • } +
    +
    + {postesVacants.length ? postesVacants.map((p) =>
  • {p.nom}
  • ) :
  • Aucun poste vacant
  • } +
    +
    +

    Procès-verbal

    +

    {pvJour?.contenu || textePvAuto}

    +
    +
    + + {estExecutif && ( + <> + + {reunions.length > 0 && ( + + + + )} + {!reunionId && ( + <> + + setNote(e.target.value)} placeholder="Note optionnelle" className={INPUT} /> + + + )} + + + +
    {membres.map((m) => ( + + ))}
    +
    + + +