From 2245f264929d8eaa3d73f125857eea761ad4ac2e Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Fri, 17 Apr 2026 04:04:04 -0400 Subject: [PATCH] =?UTF-8?q?derni=C3=A8re=20mouture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 115 ++-- backend/.env.example | 4 +- backend/app/core/config.py | 9 +- backend/app/core/security.py | 5 +- backend/app/main.py | 12 +- backend/app/models/candidature.py | 30 + backend/app/models/collecte.py | 11 +- backend/app/models/depense.py | 12 +- backend/app/models/envoi_contribution.py | 5 +- backend/app/models/evenement.py | 21 + backend/app/models/groupe.py | 4 +- backend/app/models/membre.py | 11 +- backend/app/models/operation_bancaire.py | 24 + backend/app/models/poste.py | 4 +- backend/app/models/proposition.py | 33 + backend/app/models/tache.py | 30 + backend/app/models/vente_litterature.py | 6 +- backend/app/routers/accueil.py | 39 +- backend/app/routers/auth.py | 32 +- backend/app/routers/collectes.py | 509 ++++++++++++++-- backend/app/routers/contributions.py | 29 +- backend/app/routers/depenses.py | 214 +++++-- backend/app/routers/evenements.py | 156 +++++ backend/app/routers/membres.py | 17 +- backend/app/routers/operations_bancaires.py | 90 +++ backend/app/routers/parametres.py | 11 +- backend/app/routers/postes.py | 640 +++++++++++++++++++- backend/app/routers/presences.py | 46 +- backend/app/routers/propositions.py | 217 +++++++ backend/app/routers/rapports.py | 226 ++++++- backend/app/routers/reunions.py | 36 +- backend/app/routers/sondages.py | 5 - backend/app/routers/ventes_litterature.py | 69 +++ backend/app/schemas/phase1.py | 49 +- backend/app/schemas/phase3.py | 6 +- backend/app/schemas/phase6.py | 1 + backend/app/schemas/phase7.py | 4 + backend/app/schemas/propositions.py | 37 ++ backend/app/schemas/schemas.py | 45 +- backend/requirements.txt | 2 + deploy/backup.sh | 2 +- deploy/nginx-groupe-meditation.conf | 4 +- deploy/seed.py | 40 +- docs/DEPLOIEMENT.md | 176 ++++-- docs/RUNBOOK.md | 221 ++++--- frontend/src/App.jsx | 25 +- frontend/src/pages/Accueil.jsx | 2 +- frontend/src/pages/AffecterPostes.jsx | 48 +- frontend/src/pages/Collecte.jsx | 253 ++++++-- frontend/src/pages/Depense.jsx | 357 +++++++---- frontend/src/pages/EnvoiContributions.jsx | 320 ++++------ frontend/src/pages/EtatFinancier.jsx | 128 ++++ frontend/src/pages/Evenements.jsx | 151 +++++ frontend/src/pages/GererPostes.jsx | 325 ++++++++++ frontend/src/pages/Impliquer.jsx | 176 ++++++ frontend/src/pages/Inscription.jsx | 23 +- frontend/src/pages/Login.jsx | 63 +- frontend/src/pages/Profil.jsx | 73 ++- frontend/src/pages/Propositions.jsx | 277 +++++++++ frontend/src/pages/Rapports.jsx | 125 ++-- frontend/src/pages/RedigerPV.jsx | 57 +- frontend/src/pages/Tresorerie.jsx | 372 ++++++++++++ frontend/src/stores/api.js | 10 +- frontend/src/stores/store.js | 31 +- 64 files changed, 5197 insertions(+), 878 deletions(-) create mode 100644 backend/app/models/candidature.py create mode 100644 backend/app/models/evenement.py create mode 100644 backend/app/models/operation_bancaire.py create mode 100644 backend/app/models/proposition.py create mode 100644 backend/app/models/tache.py create mode 100644 backend/app/routers/evenements.py create mode 100644 backend/app/routers/operations_bancaires.py create mode 100644 backend/app/routers/propositions.py create mode 100644 backend/app/schemas/propositions.py create mode 100644 frontend/src/pages/EtatFinancier.jsx create mode 100644 frontend/src/pages/Evenements.jsx create mode 100644 frontend/src/pages/GererPostes.jsx create mode 100644 frontend/src/pages/Impliquer.jsx create mode 100644 frontend/src/pages/Propositions.jsx create mode 100644 frontend/src/pages/Tresorerie.jsx diff --git a/README.md b/README.md index 140af9d..ae55f0a 100644 --- a/README.md +++ b/README.md @@ -2,65 +2,106 @@ **District 87-16 · Région 87 · Alcooliques anonymes** -Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. 27 modules. +Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. -## Phases 0 à 9 — Complétées +## Architecture + +| Couche | Technologie | +|--------|-------------| +| 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) | +| 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 -- **C1** Rapports PDF (WeasyPrint) — finances, PV, rapport RSG, financier complet -- **C2** Calendrier district — serviteurs de tous les groupes du 87-16 -- **C3** Anniversaires district — sobriété, prochains en évidence + +- **C1** Rapports PDF — finances, PV, résolutions, rapport complet +- **C2** Événements à venir — anniversaires de sobriété et événements ad hoc du district - **C4** Littérature — inventaire avec prix, alerte stock bas -- **C5** Décisions en cours — suivi réunion d'affaires -- **C6** Votes ouverts — sondages, barres de résultat, vote anonyme -- **C7** Mon profil — modifier, PIN, notifications +- **C5** Décisions en cours — suivi des résolutions +- **C6** Votes ouverts — sondages de conscience de groupe, vote anonyme +- **C7** Mon profil — modifier, changer PIN, notifications +- **C8** Propositions — soumettre, seconder, voter (créables en tout temps) +- **C9** S'impliquer — postes vacants, candidatures ### Saisie hebdomadaire (S1-S5) -- **S1** Collecte 7e Tradition (trésorier) -- **S2** Remise de jeton (resp. jetons) — 9 types, historique -- **S3** Vente littérature (resp. littérature) — stock auto-décrémenté -- **S4** Rapport de réunion (animateur) -- **S5** Dépense — 6 champs (trésorier) + +- **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é) +- **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 +- **S5** Dépenses — 6 champs, engagements non débités, confirmation de débit par le trésorier ### Saisie mensuelle (M1-M6) -- **M1** Rédiger PV (secrétaire) — décisions structurées, auto-création C5 -- **M2** Présences réunion d'affaires (secrétaire) + +- **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 -- **M4** Envoi contributions — répartition auto district/région/BSG/intergroupe +- **M4** Envoi contributions — somme disponible affichée par défaut, destinataire + montant + méthode - **M5** Ajuster inventaire — +/−, ajout catalogue - **M6** Commander jetons — état stock 30 jours ### Gestion (G1-G9) — Exécutif + - **G1** Inviter un membre — code 48h - **G2** Gérer les membres — fiche complète, droit à l'oubli -- **G3** Affecter les postes -- **G4** Terminer un mandat — transition manuelle -- **G5** Planifier les rotations +- **G3** Affecter les postes — n'affiche que les postes vacants +- **G4** Terminer un mandat — transition manuelle, note pour le successeur +- **G5** Planifier les rotations — calendrier serviteurs - **G6** Lancer un sondage — oui/non, choix multiple, unanimité - **G7** Jumeler parrain/filleul — confidentiel -- **G8** Config. répartition — % contributions (défaut 30/30/30/10) -- **G9** Paramètres du groupe +- **G9** Paramètres du groupe — nom, horaire, adresse, format +- **G10** Gérer les postes — créer, abolir, candidatures -## Stack technique +## Trésorerie -| Couche | Technologie | -|--------|-------------| -| Frontend | React 18 + Vite (PWA) | -| UI | Tailwind CSS | -| État | Zustand | -| Backend | FastAPI (Python) async | -| DB | PostgreSQL 16 + SQLAlchemy async | -| Auth | JWT 30 jours + bcrypt 12 rounds | -| PDF | WeasyPrint | -| Serveur | NGINX (statiques + reverse proxy HTTPS) | +Le calcul des soldes repose sur le relevé bancaire mensuel : + +- **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 + +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. + +## 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. + +Le PV de la réunion d'affaires s'auto-renseigne depuis les propositions secondées en attente de vote. ## Principes -- **Anonymat AA** — Prénom seul dans les écrans partagés -- **Pas d'administrateur** — Permissions = exécutif du groupe -- **Mandats manuels** — Aucune terminaison automatique -- **Zéro traçage** — Pas d'analytics, pas de cookies tiers -- **Hébergé au Québec** — Souveraineté numérique · 100% logiciel libre +- **Anonymat AA (12e Tradition)** — prénom seul dans les écrans partagés +- **Pas d'administrateur (2e Tradition)** — permissions = exécutif du groupe +- **Mandats manuels** — aucune terminaison automatique +- **Zéro traçage** — pas d'analytics, pas de cookies tiers +- **Hébergé au Québec** — souveraineté numérique · 100 % logiciel libre + +## Fichiers + +``` +backend/ API FastAPI (Python) + app/ + core/ Config, DB, sécurité + models/ Modèles SQLAlchemy (23+ tables) + routers/ Routes API (26 groupes) + schemas/ Schémas Pydantic +frontend/ React + Vite + Tailwind + src/ + pages/ 33 pages + stores/ Zustand + API helper + components/ Composants réutilisables +deploy/ Service systemd, NGINX, seed, backup +docs/ DEPLOIEMENT.md, RUNBOOK.md +``` --- diff --git a/backend/.env.example b/backend/.env.example index f0bfd7b..5b69803 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,7 +1,7 @@ # Groupe Méditation — Configuration # Copier vers .env et ajuster les valeurs -DATABASE_URL=postgresql+asyncpg://grpmed:CHANGEZ_MOI@localhost:5432/groupe_meditation +DATABASE_URL=postgresql+asyncpg://grpmed:CHANGEZ_MOI@127.0.0.1:5432/groupe_meditation JWT_SECRET=CHANGEZ_MOI_64_caracteres_aleatoires_generes_par_openssl_rand_base64_48 -CORS_ORIGINS=["https://groupe-meditation.chezlepro.ca"] +CORS_ORIGINS=["https://groupe-meditation.87-16.org"] INVITATION_EXPIRE_HOURS=48 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e5b2783..fd17bbd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,22 +1,25 @@ """Configuration de l'application.""" +from pathlib import Path from pydantic_settings import BaseSettings from typing import List +_ENV_FILE = Path(__file__).resolve().parent.parent.parent / ".env" + class Settings(BaseSettings): - DATABASE_URL: str = "postgresql+asyncpg://grpmed:grpmed2026@localhost:5432/groupe_meditation" + DATABASE_URL: str = "postgresql+asyncpg://grpmed:grpmed2026@127.0.0.1:5432/groupe_meditation" JWT_SECRET: str = "changez-moi-en-production-clef-secrete-64-caracteres-minimum" JWT_ALGORITHM: str = "HS256" JWT_EXPIRE_DAYS: int = 30 CORS_ORIGINS: List[str] = [ "http://localhost:5173", "http://localhost:3000", - "https://groupe-meditation.chezlepro.ca", + "https://groupe-meditation.87-16.org", ] INVITATION_EXPIRE_HOURS: int = 48 class Config: - env_file = ".env" + env_file = str(_ENV_FILE) env_file_encoding = "utf-8" diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 90bf935..90642e6 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -21,8 +21,9 @@ 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) -> str: - expire = datetime.now(timezone.utc) + timedelta(days=settings.JWT_EXPIRE_DAYS) +def create_token(membre_id: str, groupe_id: str, expire_jours: int = 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} return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) diff --git a/backend/app/main.py b/backend/app/main.py index ebba9ef..9d28751 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,10 +9,15 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings 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, jumelages +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, jumelages, propositions, operations_bancaires, evenements -# Import sync_log pour que create_all crée la table (aucun router ne l'importe) +# Import models pour que create_all crée toutes les tables from app.models.sync_log import SyncLog # noqa: F401 +from app.models.proposition import Proposition # noqa: F401 +from app.models.operation_bancaire import OperationBancaire # noqa: F401 +from app.models.evenement import Evenement # noqa: F401 +from app.models.candidature import Candidature # noqa: F401 +from app.models.tache import Tache, PosteTache # noqa: F401 @asynccontextmanager @@ -64,6 +69,9 @@ 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(jumelages.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.get("/api/health") diff --git a/backend/app/models/candidature.py b/backend/app/models/candidature.py new file mode 100644 index 0000000..34c0aa3 --- /dev/null +++ b/backend/app/models/candidature.py @@ -0,0 +1,30 @@ +"""Table candidatures — candidatures volontaires et nominations.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, Boolean, DateTime, Text, ForeignKey, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Candidature(Base): + __tablename__ = "candidatures" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False) + membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + type: Mapped[str] = mapped_column( + SAEnum("candidature", "nomination", name="type_candidature"), + default="candidature", + ) + statut: Mapped[str] = mapped_column( + SAEnum("proposee", "acceptee", "refusee", "retiree", name="statut_candidature"), + default="proposee", + ) + motivation: Mapped[str | None] = mapped_column(Text) + nomme_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + acceptee_par_membre: Mapped[bool | None] = mapped_column(Boolean) # le nominé accepte/refuse + proposition_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("propositions.id")) + cree_le: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/models/collecte.py b/backend/app/models/collecte.py index f209660..afb444f 100644 --- a/backend/app/models/collecte.py +++ b/backend/app/models/collecte.py @@ -1,7 +1,7 @@ """Table collectes — 7e Tradition.""" import uuid from datetime import date, datetime, timezone -from sqlalchemy import Date, DateTime, ForeignKey, Numeric +from sqlalchemy import String, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -14,5 +14,14 @@ class Collecte(Base): date_collecte: Mapped[date] = mapped_column(Date, nullable=False) montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id")) + detenteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + statut: Mapped[str] = mapped_column( + SAEnum("en_attente", "confirmee", "recu", name="statut_collecte"), + default="en_attente", + ) saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + confirme_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + date_confirmation: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + recu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + date_reception: Mapped[date | None] = mapped_column(Date) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/depense.py b/backend/app/models/depense.py index 1dd66b6..0c0ef0b 100644 --- a/backend/app/models/depense.py +++ b/backend/app/models/depense.py @@ -1,7 +1,7 @@ -"""Table depenses — 6 champs selon l'architecture v4.""" +"""Table depenses — ouvert à tous, confirmé par le trésorier.""" import uuid from datetime import date, datetime, timezone -from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum +from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, Numeric, Text, Enum as SAEnum from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -16,9 +16,13 @@ class Depense(Base): qui: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) quoi: Mapped[str] = mapped_column(String(255), nullable=False) paye: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - methode: Mapped[str] = mapped_column( + methode: Mapped[str | None] = mapped_column( SAEnum("DU", "VIREMENT", "CHEQUE", "COLLECTE", name="methode_paiement"), - nullable=False, + nullable=True, # renseigné par le trésorier lors de la confirmation ) + preuve_nom: Mapped[str | None] = mapped_column(String(255)) # nom du fichier preuve saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + resolution_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("propositions.id")) + confirme_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + date_confirmation: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/envoi_contribution.py b/backend/app/models/envoi_contribution.py index 792f135..805b4f9 100644 --- a/backend/app/models/envoi_contribution.py +++ b/backend/app/models/envoi_contribution.py @@ -1,7 +1,7 @@ """Table envois_contributions — Envois aux organismes AA.""" import uuid from datetime import date, datetime, timezone -from sqlalchemy import Date, DateTime, ForeignKey, Numeric, Enum as SAEnum +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -21,5 +21,8 @@ class EnvoiContribution(Base): SAEnum("DU", "VIREMENT", "CHEQUE", name="methode_contribution"), nullable=False, ) + resolution_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("propositions.id")) + engagee: Mapped[bool] = mapped_column(Boolean, default=False) # montant engagé + debitee: Mapped[bool] = mapped_column(Boolean, default=False) # montant débité du compte saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/models/evenement.py b/backend/app/models/evenement.py new file mode 100644 index 0000000..3ca05f7 --- /dev/null +++ b/backend/app/models/evenement.py @@ -0,0 +1,21 @@ +"""Table evenements — événements ad hoc du district.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import String, Date, DateTime, Text, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Evenement(Base): + __tablename__ = "evenements" + + 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) + date_evenement: Mapped[date] = mapped_column(Date, nullable=False) + lieu: Mapped[str | None] = mapped_column(String(200)) + 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 abebad4..ef36382 100644 --- a/backend/app/models/groupe.py +++ b/backend/app/models/groupe.py @@ -1,6 +1,6 @@ """Table groupes.""" import uuid -from datetime import datetime, timezone +from datetime import time, datetime, timezone from sqlalchemy import String, Text, Time, DateTime from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -15,7 +15,7 @@ class Groupe(Base): region: Mapped[str] = mapped_column(String(20), default="87") adresse_local: Mapped[str | None] = mapped_column(Text) jour_reunion: Mapped[str | None] = mapped_column(String(10)) - heure_reunion: Mapped[datetime | None] = mapped_column(Time) + heure_reunion: Mapped[time | None] = mapped_column(Time) 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/membre.py b/backend/app/models/membre.py index e26345d..73c608e 100644 --- a/backend/app/models/membre.py +++ b/backend/app/models/membre.py @@ -1,20 +1,23 @@ """Table membres.""" import uuid from datetime import datetime, date, timezone -from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, JSON +from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, JSON, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base class Membre(Base): __tablename__ = "membres" + __table_args__ = ( + UniqueConstraint("courriel", "groupe_id", name="uq_courriel_groupe"), + ) id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) prenom: Mapped[str] = mapped_column(String(50), nullable=False) - nom: Mapped[str] = mapped_column(String(50), nullable=False) - telephone: Mapped[str | None] = mapped_column(String(20)) - courriel: Mapped[str | None] = mapped_column(String(100)) + nom: Mapped[str | None] = mapped_column(String(50)) # optionnel + telephone: Mapped[str] = mapped_column(String(20), nullable=False) # obligatoire + courriel: Mapped[str] = mapped_column(String(100), nullable=False) # identifiant unique date_abstinence: Mapped[date | None] = mapped_column(Date) pin_hash: Mapped[str] = mapped_column(String(255), nullable=False) actif: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/app/models/operation_bancaire.py b/backend/app/models/operation_bancaire.py new file mode 100644 index 0000000..45b93e3 --- /dev/null +++ b/backend/app/models/operation_bancaire.py @@ -0,0 +1,24 @@ +"""Table operations_bancaires — suivi du compte bancaire.""" +import uuid +from datetime import date, datetime, timezone +from sqlalchemy import String, Date, DateTime, Text, ForeignKey, Numeric, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class OperationBancaire(Base): + __tablename__ = "operations_bancaires" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False) + type: Mapped[str] = mapped_column( + SAEnum("depot", "retrait", "releve", name="type_operation_bancaire"), + nullable=False, + ) + montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) + date_operation: Mapped[date] = mapped_column(Date, nullable=False) + effectue_par: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + note: Mapped[str | None] = mapped_column(Text) + cree_le: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/models/poste.py b/backend/app/models/poste.py index ad09b8b..97012f3 100644 --- a/backend/app/models/poste.py +++ b/backend/app/models/poste.py @@ -1,6 +1,6 @@ """Table postes.""" import uuid -from sqlalchemy import String, Text, Integer, Interval, Enum as SAEnum +from sqlalchemy import String, Text, Integer, Boolean, Interval, Enum as SAEnum from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -14,4 +14,6 @@ class Poste(Base): description: Mapped[str | None] = mapped_column(Text) duree_recommandee: Mapped[str | None] = mapped_column(Interval) prerequis: Mapped[str | None] = mapped_column(Text) + informations: Mapped[str | None] = mapped_column(Text) ordre_affichage: Mapped[int | None] = mapped_column(Integer) + actif: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/app/models/proposition.py b/backend/app/models/proposition.py new file mode 100644 index 0000000..41f03ce --- /dev/null +++ b/backend/app/models/proposition.py @@ -0,0 +1,33 @@ +"""Table propositions — cycle parlementaire des réunions 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 Proposition(Base): + __tablename__ = "propositions" + + 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"), nullable=True) + type_proposition: Mapped[str] = mapped_column( + String(20), default="generale", # generale, nomination, contribution + ) + sujet: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + proposeur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False) + secondeur_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + statut: Mapped[str] = mapped_column( + SAEnum("proposee", "secondee", "adoptee", "rejetee", name="statut_proposition"), + default="proposee", + ) + numero_resolution: Mapped[str | None] = mapped_column(String(20)) # RES-2026-001 + date_vote: Mapped[date | None] = mapped_column(Date) + 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")) + cree_le: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/models/tache.py b/backend/app/models/tache.py new file mode 100644 index 0000000..9707e1a --- /dev/null +++ b/backend/app/models/tache.py @@ -0,0 +1,30 @@ +"""Table taches — tâches assignables aux postes.""" +import uuid +from datetime import datetime, timezone +from sqlalchemy import String, Text, DateTime, ForeignKey, Integer +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class Tache(Base): + __tablename__ = "taches" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + nom: Mapped[str] = mapped_column(String(200), nullable=False) + informations: Mapped[str | None] = mapped_column(Text) + ordre_affichage: Mapped[int] = mapped_column(Integer, default=99) + cree_le: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + +class PosteTache(Base): + """Association poste ↔ tâche (N:N). Une tâche peut être affectée à plusieurs postes.""" + __tablename__ = "postes_taches" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False) + tache_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("taches.id"), nullable=False) + cree_le: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/models/vente_litterature.py b/backend/app/models/vente_litterature.py index 4829d68..e4c0b36 100644 --- a/backend/app/models/vente_litterature.py +++ b/backend/app/models/vente_litterature.py @@ -1,7 +1,7 @@ """Table ventes_litterature — Ventes de littérature AA.""" import uuid from datetime import date, datetime, timezone -from sqlalchemy import Integer, Date, DateTime, ForeignKey, Numeric +from sqlalchemy import Boolean, Integer, Date, DateTime, ForeignKey, Numeric from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base @@ -16,4 +16,8 @@ class VenteLitterature(Base): montant: Mapped[float] = mapped_column(Numeric(8, 2), nullable=False) date_vente: Mapped[date] = mapped_column(Date, nullable=False) vendu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + # Remise au trésorier + remis_tresorier: Mapped[bool] = mapped_column(Boolean, default=False) + recu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id")) + date_remise: Mapped[date | None] = mapped_column(Date) cree_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 23ca035..1e6769e 100644 --- a/backend/app/routers/accueil.py +++ b/backend/app/routers/accueil.py @@ -7,26 +7,34 @@ from app.core.database import get_db from app.core.security import get_current_membre from app.models.affectation import Affectation from app.models.poste import Poste +from app.models.groupe import Groupe from app.schemas.schemas import AccueilResponse, ModuleTuile, MembrePublic router = APIRouter(prefix="/accueil", tags=["Accueil"]) # ── Définition des modules ── MODULES_CONSULTATION = [ - ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, rapport RSG", icone="📄", couleur="blue"), - ModuleTuile(code="C2", nom="Calendrier district", description="Serviteurs de tous les groupes", icone="📅", couleur="green"), - ModuleTuile(code="C3", nom="Anniversaires", description="Sobriété du district 87-16", icone="🎂", couleur="yellow"), + ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, résolutions", icone="📄", couleur="blue"), + ModuleTuile(code="C2", nom="Événements à venir", 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="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="S'impliquer", description="Postes disponibles, candidature", icone="🙋", couleur="yellow"), + ModuleTuile(code="C10", nom="État financier", description="Résultats et transactions", icone="📊", couleur="blue"), +] + +# 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="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"), - ModuleTuile(code="S5", nom="Dépense", description="Enregistrer une dépense", icone="💸", couleur="red"), + 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": [ @@ -60,8 +68,8 @@ MODULES_GESTION = [ ModuleTuile(code="G5", nom="Planifier rotations", description="Calendrier serviteurs", icone="📅", couleur="green"), ModuleTuile(code="G6", nom="Lancer un sondage", description="Conscience de groupe", icone="📊", couleur="indigo"), ModuleTuile(code="G7", nom="Jumeler parrain", description="Parrain / filleul", icone="🤝", couleur="yellow"), - ModuleTuile(code="G8", nom="Config. répartition", description="% contributions", icone="⚙️", couleur="gray"), ModuleTuile(code="G9", nom="Paramètres groupe", description="Nom, horaire, adresse", icone="🔧", couleur="gray"), + ModuleTuile(code="G10", nom="Gérer les postes", description="Créer, abolir, candidatures", icone="📋", couleur="purple"), ] @@ -91,9 +99,9 @@ async def accueil( # Modules de consultation : toujours visibles modules_consultation = MODULES_CONSULTATION - # Modules de saisie : union des modules de tous les postes - modules_saisie = [] - codes_vus = set() + # Modules de saisie : collecte pour tous + modules par poste + modules_saisie = list(MODULES_SAISIE_TOUS) + codes_vus = {"S1", "S5"} for nom_poste in postes_noms: for m in MODULES_SAISIE.get(nom_poste, []): if m.code not in codes_vus: @@ -103,10 +111,23 @@ async def accueil( # Modules de gestion : seulement si exécutif modules_gestion = MODULES_GESTION if "executif" in postes_categories else [] + # Info groupe pour le frontend + 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, + "adresse_local": groupe.adresse_local, + } + return AccueilResponse( membre=MembrePublic.model_validate(membre), postes=postes_noms, modules_consultation=modules_consultation, modules_saisie=modules_saisie, modules_gestion=modules_gestion, + groupe=groupe_info, ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 67bdcce..fce6d16 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -29,24 +29,34 @@ async def health(db: AsyncSession = Depends(get_db)): async def liste_groupes(db: AsyncSession = Depends(get_db)): result = await db.execute(select(Groupe).order_by(Groupe.nom)) groupes = result.scalars().all() - return [{"id": str(g.id), "nom": g.nom, "district": g.district} for g in groupes] + return [{ + "id": str(g.id), + "nom": g.nom, + "district": g.district, + "adresse_local": g.adresse_local, + "jour_reunion": g.jour_reunion, + "heure_reunion": g.heure_reunion.strftime("%H:%M") if g.heure_reunion else None, + "format_reunion": g.format_reunion, + } for g in groupes] @router.post("/connexion", response_model=TokenResponse) async def connexion(req: ConnexionRequest, db: AsyncSession = Depends(get_db)): - # Chercher le membre par prénom + groupe (insensible à la casse) + # Chercher le membre par courriel + groupe (insensible à la casse) result = await db.execute( select(Membre).where( - func.lower(Membre.prenom) == func.lower(req.prenom), + func.lower(Membre.courriel) == func.lower(req.courriel), Membre.groupe_id == req.groupe_id, Membre.actif == True, ) ) membre = result.scalar_one_or_none() if not membre or not verify_pin(req.pin, membre.pin_hash): - raise HTTPException(status_code=401, detail="Prénom ou PIN incorrect") + raise HTTPException(status_code=401, detail="Courriel ou PIN incorrect") - token = create_token(str(membre.id), str(membre.groupe_id)) + # Token plus long si "se souvenir de moi" + expire_jours = 90 if req.se_souvenir else 30 + token = create_token(str(membre.id), str(membre.groupe_id), expire_jours=expire_jours) return TokenResponse( access_token=token, membre=MembrePublic.model_validate(membre), @@ -68,16 +78,16 @@ async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db if invitation.expire_le < datetime.now(timezone.utc): raise HTTPException(status_code=400, detail="Code d'invitation expiré") - # Vérifier unicité du prénom dans le groupe + # Vérifier unicité du courriel dans le groupe exists = await db.execute( select(Membre).where( - func.lower(Membre.prenom) == func.lower(req.prenom), + func.lower(Membre.courriel) == func.lower(req.courriel), Membre.groupe_id == invitation.groupe_id, Membre.actif == True, ) ) if exists.scalar_one_or_none(): - raise HTTPException(status_code=400, detail="Ce prénom est déjà utilisé dans ce groupe") + raise HTTPException(status_code=400, detail="Ce courriel est déjà utilisé dans ce groupe") # Valider le PIN (4 chiffres) if not req.pin.isdigit() or len(req.pin) != 4: @@ -87,9 +97,9 @@ async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db membre = Membre( groupe_id=invitation.groupe_id, prenom=req.prenom.strip().title(), - nom=req.nom.strip().title(), - telephone=req.telephone, - courriel=req.courriel, + nom=req.nom.strip().title() if req.nom else None, + telephone=req.telephone.strip(), + courriel=req.courriel.strip().lower(), date_abstinence=req.date_abstinence, pin_hash=hash_pin(req.pin), ) diff --git a/backend/app/routers/collectes.py b/backend/app/routers/collectes.py index 66c643d..e1cbde7 100644 --- a/backend/app/routers/collectes.py +++ b/backend/app/routers/collectes.py @@ -1,80 +1,521 @@ """Routes S1 — Collecte 7e Tradition.""" +from datetime import date, datetime, time as dt_time, timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc +from sqlalchemy import select, desc, extract, func from app.core.database import get_db -from app.core.security import get_current_membre +from app.core.security import get_current_membre, require_executif from app.models.collecte import Collecte +from app.models.groupe import Groupe from app.models.membre import Membre +from app.models.depense import Depense +from app.models.envoi_contribution import EnvoiContribution +from app.models.operation_bancaire import OperationBancaire from app.models.affectation import Affectation from app.models.poste import Poste -from app.schemas.phase1 import CollecteCreate, CollecteOut +from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut 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 require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est trésorier.""" + +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.membre_id == membre_id, Affectation.statut == "actif", - Poste.nom.ilike("%trésorier%"), + (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), ) ) - if not result.scalars().first(): - raise HTTPException(status_code=403, detail="Réservé au trésorier") + 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 +async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut: + detenteur = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None + saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None + confirme = await db.get(Membre, c.confirme_par) if c.confirme_par else None + recu = await db.get(Membre, c.recu_par) if c.recu_par else None + return CollecteOut( + id=c.id, + date_collecte=c.date_collecte, + montant=float(c.montant), + detenteur_prenom=detenteur.prenom if detenteur else None, + statut=c.statut, + saisi_par_prenom=saisi.prenom if saisi else None, + confirme_par_prenom=confirme.prenom if confirme else None, + date_confirmation=c.date_confirmation, + recu_par_prenom=recu.prenom if recu else None, + date_reception=c.date_reception, + cree_le=c.cree_le, + ) + + @router.post("/", response_model=CollecteOut) async def saisir_collecte( req: CollecteCreate, - membre=Depends(require_tresorier), + membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """S1 — Saisir le montant collecté au panier.""" + """S1 — N'importe quel membre saisit la collecte. Rétroactif possible sur les dates de réunion.""" + groupe = await db.get(Groupe, membre.groupe_id) + aujourdhui = date.today() + + # Validation : pas dans le futur + if req.date_collecte > aujourdhui: + 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: + raise HTTPException( + status_code=400, + detail=f"La collecte doit avoir lieu un {groupe.jour_reunion}", + ) + + # Validation : si c'est aujourd'hui, pas avant l'heure de la réunion + if req.date_collecte == aujourdhui and groupe.heure_reunion: + now = datetime.now(timezone.utc) + if isinstance(groupe.heure_reunion, dt_time) and now.time() < groupe.heure_reunion: + raise HTTPException( + status_code=400, + detail=f"La saisie n'est possible qu'à partir de {groupe.heure_reunion.strftime('%H:%M')}", + ) + + # Validation : une seule collecte par date de réunion + result = await db.execute( + select(Collecte).where( + Collecte.groupe_id == membre.groupe_id, + Collecte.date_collecte == req.date_collecte, + ) + ) + if result.scalars().first(): + raise HTTPException( + status_code=400, + detail=f"Il y a déjà une collecte pour le {req.date_collecte}", + ) + collecte = Collecte( groupe_id=membre.groupe_id, date_collecte=req.date_collecte, montant=req.montant, reunion_id=req.reunion_id, + detenteur_id=req.detenteur_id, + statut="en_attente", saisi_par=membre.id, ) db.add(collecte) await db.flush() - return CollecteOut( - id=collecte.id, - date_collecte=collecte.date_collecte, - montant=float(collecte.montant), - saisi_par_prenom=membre.prenom, - cree_le=collecte.cree_le, + return await _to_out(collecte, db) + + +@router.patch("/{collecte_id}", response_model=CollecteOut) +async def ajuster_collecte( + collecte_id: str, + req: CollecteUpdate, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Ajuster le montant (exécutif ou celui qui a saisi, avant confirmation).""" + result = await db.execute( + select(Collecte).where( + Collecte.id == collecte_id, + Collecte.groupe_id == membre.groupe_id, + ) ) + collecte = result.scalar_one_or_none() + if not collecte: + raise HTTPException(status_code=404, detail="Collecte introuvable") + 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() + return await _to_out(collecte, db) + + +@router.post("/{collecte_id}/confirmer", response_model=CollecteOut) +async def confirmer_collecte( + collecte_id: str, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Un second membre confirme le montant. Si le trésorier est impliqué → réception auto.""" + result = await db.execute( + select(Collecte).where( + Collecte.id == collecte_id, + Collecte.groupe_id == membre.groupe_id, + ) + ) + collecte = result.scalar_one_or_none() + if not collecte: + raise HTTPException(status_code=404, detail="Collecte introuvable") + if collecte.statut != "en_attente": + raise HTTPException(status_code=400, detail="Cette collecte a déjà été confirmée") + if collecte.saisi_par == membre.id: + raise HTTPException(status_code=400, detail="Le montant doit être confirmé par un autre membre") + + collecte.confirme_par = membre.id + collecte.date_confirmation = datetime.now(timezone.utc) + + # Si le trésorier est celui qui confirme OU celui qui a saisi → réception auto + confirmateur_est_tres = await _est_tresorier(membre.id, membre.groupe_id, db) + saisisseur_est_tres = await _est_tresorier(collecte.saisi_par, collecte.groupe_id, db) + + if confirmateur_est_tres or saisisseur_est_tres: + tresorier_id = membre.id if confirmateur_est_tres else collecte.saisi_par + collecte.statut = "recu" + collecte.recu_par = tresorier_id + collecte.date_reception = date.today() + else: + collecte.statut = "confirmee" + + await db.flush() + return await _to_out(collecte, db) + + +@router.post("/{collecte_id}/reception", response_model=CollecteOut) +async def accuser_reception( + collecte_id: str, + membre=Depends(_require_tresorier), + db: AsyncSession = Depends(get_db), +): + """Le trésorier accuse réception (pour les cas sans trésorier impliqué).""" + result = await db.execute( + select(Collecte).where( + Collecte.id == collecte_id, + Collecte.groupe_id == membre.groupe_id, + ) + ) + collecte = result.scalar_one_or_none() + if not collecte: + raise HTTPException(status_code=404, detail="Collecte introuvable") + if collecte.statut == "recu": + raise HTTPException(status_code=400, detail="Cette collecte a déjà été reçue") + + # Si en_attente, confirme aussi + if collecte.statut == "en_attente": + collecte.confirme_par = membre.id + collecte.date_confirmation = datetime.now(timezone.utc) + + collecte.statut = "recu" + collecte.recu_par = membre.id + collecte.date_reception = date.today() + await db.flush() + return await _to_out(collecte, db) @router.get("/", response_model=list[CollecteOut]) async def liste_collectes( + statut: str | None = None, + mois: int | None = None, + annee: int | None = None, limite: int = 20, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """Lister les collectes récentes du groupe.""" + query = select(Collecte).where(Collecte.groupe_id == membre.groupe_id) + if statut: + query = query.where(Collecte.statut == statut) + if mois and annee: + query = query.where( + extract("month", Collecte.date_collecte) == mois, + extract("year", Collecte.date_collecte) == annee, + ) + elif annee: + query = query.where(extract("year", Collecte.date_collecte) == annee) + query = query.order_by(desc(Collecte.date_collecte)).limit(limite) + result = await db.execute(query) + return [await _to_out(c, db) for c in result.scalars().all()] + + +@router.get("/soldes") +async def soldes_tresorerie( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Soldes détaillés : relevé bancaire, sommes engagées, réserves, disponible.""" + from app.models.vente_litterature import VenteLitterature + + gid = membre.groupe_id + + # ── Dernier relevé bancaire ── result = await db.execute( - select(Collecte) - .where(Collecte.groupe_id == membre.groupe_id) - .order_by(desc(Collecte.date_collecte)) - .limit(limite) + select(OperationBancaire).where( + OperationBancaire.groupe_id == gid, + OperationBancaire.type == "releve", + ).order_by(desc(OperationBancaire.date_operation)).limit(1) ) - collectes = result.scalars().all() - out = [] - for c in collectes: - saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None - out.append(CollecteOut( - id=c.id, - date_collecte=c.date_collecte, - montant=float(c.montant), - saisi_par_prenom=saisi.prenom if saisi else None, - cree_le=c.cree_le, - )) - return out + 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()) + + solde_banque = solde_releve + depots_depuis - retraits_depuis + + # ── Dépenses engagées non payées (chèque/virement votés mais pas encore débités) ── + result = await db.execute( + select(func.coalesce(func.sum(Depense.montant), 0)).where( + Depense.groupe_id == gid, + Depense.paye == False, + Depense.methode.in_(["VIREMENT", "CHEQUE"]), + ) + ) + engagements_non_debites = float(result.scalar()) + + # ── Contributions envoyées non débitées ── + # (on considère toutes les contributions comme engagées) + + # ── Réserves ── + reserve_prudente = 500.0 + + # Réserve anniversaire : +20$/mois depuis la création du groupe + from app.models.groupe import Groupe as GroupeModel + groupe = await db.get(GroupeModel, gid) + mois_depuis_creation = 0 + if groupe and groupe.cree_le: + from datetime import date as date_type + aujourdhui = date_type.today() + cree = groupe.cree_le.date() if hasattr(groupe.cree_le, 'date') else groupe.cree_le + mois_depuis_creation = (aujourdhui.year - cree.year) * 12 + (aujourdhui.month - cree.month) + mois_depuis_creation = max(0, mois_depuis_creation) + reserve_anniversaire = mois_depuis_creation * 20.0 + + total_reserves = reserve_prudente + reserve_anniversaire + + # ── 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(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_depots - depenses_cash + + # ── Argent en circulation (collectes non remises au trésorier) ── + result = await db.execute( + select(Collecte).where( + Collecte.groupe_id == gid, Collecte.statut.in_(["en_attente", "confirmee"]))) + en_circulation = result.scalars().all() + details_circulation = [] + total_en_circulation = 0.0 + for c in en_circulation: + det = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None + total_en_circulation += float(c.montant) + details_circulation.append({ + "date": str(c.date_collecte), + "montant": float(c.montant), + "detenteur": det.prenom if det else "?", + "statut": c.statut, + }) + + # ── Ventes de littérature non remises ── + result = await db.execute( + select(VenteLitterature).where( + VenteLitterature.groupe_id == gid, + VenteLitterature.remis_tresorier == False, + ) + ) + from app.models.litterature import Litterature + ventes_non_remises = result.scalars().all() + details_ventes = [] + total_ventes_non_remises = 0.0 + for v in ventes_non_remises: + vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None + item = await db.get(Litterature, v.litterature_id) if v.litterature_id else None + total_ventes_non_remises += float(v.montant) + details_ventes.append({ + "id": str(v.id), + "date": str(v.date_vente), + "montant": float(v.montant), + "titre": item.titre if item else "?", + "vendu_par": vendeur.prenom if vendeur else "?", + }) + + return { + "solde_banque": round(solde_banque, 2), + "date_releve": date_releve, + "encaisse": round(max(0, encaisse), 2), + "disponible": round(disponible, 2), + "engagements_non_debites": round(engagements_non_debites, 2), + "reserve_prudente": reserve_prudente, + "reserve_anniversaire": round(reserve_anniversaire, 2), + "total_reserves": round(total_reserves, 2), + "en_possession": round(total_en_circulation, 2), + "en_possession_details": details_circulation, + "ventes_non_remises": round(total_ventes_non_remises, 2), + "ventes_non_remises_details": details_ventes, + } + + +@router.get("/registre") +async def registre_transactions( + mois: int | None = None, + annee: int | None = None, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Registre détaillé de toutes les transactions financières du groupe.""" + from app.models.vente_litterature import VenteLitterature + from app.models.litterature import Litterature + + gid = membre.groupe_id + transactions = [] + + # Filtres date + def filtre_mois(d): + if mois and annee: + return d.month == mois and d.year == annee + if annee: + return d.year == annee + return True + + # 1. Collectes reçues + result = await db.execute( + select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu") + .order_by(Collecte.date_collecte) + ) + for c in result.scalars().all(): + if filtre_mois(c.date_collecte): + saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None + transactions.append({ + "date": str(c.date_collecte), + "type": "collecte", + "description": f"Collecte 7e Tradition", + "entree": float(c.montant), + "sortie": 0, + "par": saisi.prenom if saisi else "?", + }) + + # 2. Ventes de littérature remises + result = await db.execute( + select(VenteLitterature).where( + VenteLitterature.groupe_id == gid, + VenteLitterature.remis_tresorier == True, + ) + ) + for v in result.scalars().all(): + if filtre_mois(v.date_vente): + item = await db.get(Litterature, v.litterature_id) + transactions.append({ + "date": str(v.date_vente), + "type": "vente_litterature", + "description": f"Vente : {item.titre if item else '?'} (x{v.quantite})", + "entree": float(v.montant), + "sortie": 0, + "par": "Littérature", + }) + + # 3. Dépenses payées + result = await db.execute( + select(Depense).where(Depense.groupe_id == gid, Depense.paye == True) + .order_by(Depense.date_depense) + ) + for d in result.scalars().all(): + if filtre_mois(d.date_depense): + qui = await db.get(Membre, d.qui) + transactions.append({ + "date": str(d.date_depense), + "type": "depense", + "description": f"{d.quoi} ({d.methode})", + "entree": 0, + "sortie": float(d.montant), + "par": qui.prenom if qui else "?", + }) + + # 4. Envois de contributions + result = await db.execute( + select(EnvoiContribution).where(EnvoiContribution.groupe_id == gid) + .order_by(EnvoiContribution.date_envoi) + ) + for e in result.scalars().all(): + if filtre_mois(e.date_envoi): + transactions.append({ + "date": str(e.date_envoi), + "type": "contribution", + "description": f"Contribution {e.destination} ({e.methode})", + "entree": 0, + "sortie": float(e.montant), + "par": "Trésorier", + }) + + # 5. Opérations bancaires (dépôts/retraits) + result = await db.execute( + select(OperationBancaire).where( + OperationBancaire.groupe_id == gid, + OperationBancaire.type.in_(["depot", "retrait"]), + ).order_by(OperationBancaire.date_operation) + ) + for op in result.scalars().all(): + if filtre_mois(op.date_operation): + m = await db.get(Membre, op.effectue_par) + is_depot = op.type == "depot" + transactions.append({ + "date": str(op.date_operation), + "type": f"banque_{op.type}", + "description": f"{'Dépôt' if is_depot else 'Retrait'} bancaire{(' — ' + op.note) if op.note else ''}", + "entree": float(op.montant) if is_depot else 0, + "sortie": float(op.montant) if not is_depot else 0, + "par": m.prenom if m else "?", + }) + + transactions.sort(key=lambda t: t["date"]) + return transactions diff --git a/backend/app/routers/contributions.py b/backend/app/routers/contributions.py index 1d9a46d..062f4b7 100644 --- a/backend/app/routers/contributions.py +++ b/backend/app/routers/contributions.py @@ -92,18 +92,39 @@ async def enregistrer_envoi( membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db), ): - """M4 — Enregistrer un envoi de contribution à un organisme.""" + """M4 — Enregistrer un envoi de contribution. Exige une résolution adoptée.""" + from app.models.proposition import Proposition + if req.destination not in DESTINATIONS: raise HTTPException(status_code=400, detail=f"Destination invalide. Choix: {DESTINATIONS}") if req.methode.upper() not in METHODES_CONTRIBUTION: raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix: {METHODES_CONTRIBUTION}") + # Valider la résolution + if not req.resolution_id: + raise HTTPException( + status_code=400, + detail="Une contribution doit faire l'objet d'une résolution adoptée. Créez d'abord une proposition, faites-la seconder et adopter." + ) + result = await db.execute( + select(Proposition).where( + Proposition.id == req.resolution_id, + Proposition.groupe_id == membre.groupe_id, + Proposition.statut == "adoptee", + ) + ) + resolution = result.scalar_one_or_none() + if not resolution: + raise HTTPException(status_code=400, detail="Résolution introuvable ou non adoptée") + envoi = EnvoiContribution( groupe_id=membre.groupe_id, date_envoi=req.date_envoi, destination=req.destination, montant=req.montant, methode=req.methode.upper(), + resolution_id=req.resolution_id, + engagee=True, saisi_par=membre.id, ) db.add(envoi) @@ -116,6 +137,9 @@ async def enregistrer_envoi( destination_label=DESTINATIONS_LABELS[envoi.destination], montant=float(envoi.montant), methode=envoi.methode, + resolution_id=envoi.resolution_id, + engagee=envoi.engagee, + debitee=envoi.debitee, saisi_par_prenom=membre.prenom, cree_le=envoi.cree_le, ) @@ -145,6 +169,9 @@ async def liste_envois( destination_label=DESTINATIONS_LABELS.get(e.destination, e.destination), montant=float(e.montant), methode=e.methode, + resolution_id=e.resolution_id, + engagee=e.engagee, + debitee=e.debitee, saisi_par_prenom=saisi.prenom if saisi else None, cree_le=e.cree_le, )) diff --git a/backend/app/routers/depenses.py b/backend/app/routers/depenses.py index bc72861..0456f4f 100644 --- a/backend/app/routers/depenses.py +++ b/backend/app/routers/depenses.py @@ -1,5 +1,8 @@ -"""Routes S5 — Dépenses (6 champs).""" -from fastapi import APIRouter, Depends, HTTPException +"""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 sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc @@ -14,106 +17,197 @@ from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"]) METHODES_VALIDES = {"DU", "VIREMENT", "CHEQUE", "COLLECTE"} +UPLOAD_DIR = "/opt/groupe-meditation/uploads/preuves" -async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Vérifie que le membre est trésorier.""" +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.membre_id == membre_id, Affectation.statut == "actif", - Poste.nom.ilike("%trésorier%"), + (Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), ) ) - if not result.scalars().first(): - raise HTTPException(status_code=403, detail="Réservé au trésorier") + 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 + return DepenseOut( + id=d.id, + date_depense=d.date_depense, + montant=float(d.montant), + qui_prenom=qui.prenom if qui else "?", + quoi=d.quoi, + paye=d.paye, + methode=d.methode, + preuve_nom=d.preuve_nom, + saisi_par_prenom=saisi.prenom if saisi else None, + confirme=d.confirme_par is not None, + cree_le=d.cree_le, + ) + + @router.post("/", response_model=DepenseOut) -async def saisir_depense( +async def soumettre_depense( req: DepenseCreate, - membre=Depends(require_tresorier), + membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """S5 — Saisir une dépense avec les 6 champs.""" - # Valider la méthode - if req.methode.upper() not in METHODES_VALIDES: - raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}") - - # Valider que le membre 'qui' existe - qui_membre = await db.get(Membre, req.qui) - if not qui_membre or qui_membre.groupe_id != membre.groupe_id: - raise HTTPException(status_code=400, detail="Membre introuvable") - + """S5 — N'importe quel membre soumet une dépense courante.""" depense = Depense( groupe_id=membre.groupe_id, date_depense=req.date_depense, montant=req.montant, - qui=req.qui, + qui=membre.id, # le membre qui soumet est celui qui a payé quoi=req.quoi.strip(), - paye=req.paye, - methode=req.methode.upper(), + paye=False, + methode=None, # sera renseigné par le trésorier saisi_par=membre.id, ) db.add(depense) await db.flush() + return await _to_out(depense, db) - return DepenseOut( - id=depense.id, - date_depense=depense.date_depense, - montant=float(depense.montant), - qui_prenom=qui_membre.prenom, - quoi=depense.quoi, - paye=depense.paye, - methode=depense.methode, - saisi_par_prenom=membre.prenom, - cree_le=depense.cree_le, + +@router.post("/{depense_id}/preuve") +async def telecharger_preuve( + depense_id: str, + fichier: UploadFile = File(...), + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Attacher une preuve (reçu, photo) à une dépense.""" + result = await db.execute( + select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) ) + depense = result.scalar_one_or_none() + if not depense: + raise HTTPException(status_code=404, detail="Dépense introuvable") + + # Sauvegarder le fichier + os.makedirs(UPLOAD_DIR, exist_ok=True) + ext = os.path.splitext(fichier.filename)[1] if fichier.filename else ".jpg" + nom_fichier = f"{depense_id}{ext}" + chemin = os.path.join(UPLOAD_DIR, nom_fichier) + contenu = await fichier.read() + with open(chemin, "wb") as f: + f.write(contenu) + + depense.preuve_nom = nom_fichier + await db.flush() + return {"message": "Preuve enregistrée", "fichier": nom_fichier} + + +@router.post("/{depense_id}/confirmer", response_model=DepenseOut) +async def confirmer_depense( + depense_id: str, + methode: str, + membre=Depends(_require_tresorier), + db: AsyncSession = Depends(get_db), +): + """Le trésorier confirme la dépense et renseigne le mode de paiement.""" + if methode.upper() not in METHODES_VALIDES: + raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}") + + result = await db.execute( + select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) + ) + depense = result.scalar_one_or_none() + if not depense: + raise HTTPException(status_code=404, detail="Dépense introuvable") + + depense.methode = methode.upper() + depense.confirme_par = membre.id + depense.date_confirmation = datetime.now(timezone.utc) + + # Si cash (COLLECTE) → payé immédiatement + if methode.upper() == "COLLECTE": + depense.paye = True + + # Si DU → le groupe doit rembourser, pas encore payé + # Si CHEQUE/VIREMENT → engagement, en attente de débit + + await db.flush() + return await _to_out(depense, db) + + +@router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut) +async def confirmer_debit( + depense_id: str, + membre=Depends(_require_tresorier), + db: AsyncSession = Depends(get_db), +): + """Le trésorier confirme que le chèque/virement a été débité du compte.""" + result = await db.execute( + select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) + ) + depense = result.scalar_one_or_none() + if not depense: + raise HTTPException(status_code=404, detail="Dépense introuvable") + if depense.paye: + raise HTTPException(status_code=400, detail="Déjà confirmée comme payée") + if depense.methode not in ("CHEQUE", "VIREMENT"): + raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit") + + depense.paye = True + await db.flush() + return await _to_out(depense, db) @router.get("/", response_model=list[DepenseOut]) async def liste_depenses( limite: int = 30, paye: bool | None = None, + confirmees: bool | None = None, membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db), ): - """Lister les dépenses du groupe avec filtre optionnel sur payé.""" + """Lister les dépenses du groupe.""" query = select(Depense).where(Depense.groupe_id == membre.groupe_id) if paye is not None: query = query.where(Depense.paye == paye) + if confirmees is not None: + if confirmees: + query = query.where(Depense.confirme_par.isnot(None)) + else: + query = query.where(Depense.confirme_par.is_(None)) query = query.order_by(desc(Depense.date_depense)).limit(limite) result = await db.execute(query) - depenses = result.scalars().all() + return [await _to_out(d, db) for d in result.scalars().all()] - out = [] - for d in depenses: - qui = await db.get(Membre, d.qui) - saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None - out.append(DepenseOut( - id=d.id, - date_depense=d.date_depense, - montant=float(d.montant), - qui_prenom=qui.prenom if qui else "?", - quoi=d.quoi, - paye=d.paye, - methode=d.methode, - saisi_par_prenom=saisi.prenom if saisi else None, - cree_le=d.cree_le, - )) - return out + +@router.get("/a-confirmer") +async def depenses_a_confirmer( + membre=Depends(_require_tresorier), + db: AsyncSession = Depends(get_db), +): + """Dépenses soumises en attente de confirmation par le trésorier.""" + result = await db.execute( + select(Depense).where( + Depense.groupe_id == membre.groupe_id, + Depense.confirme_par.is_(None), + ).order_by(desc(Depense.cree_le)) + ) + return [await _to_out(d, db) for d in result.scalars().all()] @router.patch("/{depense_id}", response_model=DepenseOut) 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 (ex: marquer comme payé).""" + """Modifier une dépense (trésorier).""" result = await db.execute( select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id) ) @@ -131,16 +225,4 @@ async def modifier_depense( depense.quoi = req.quoi.strip() await db.flush() - - qui = await db.get(Membre, depense.qui) - return DepenseOut( - id=depense.id, - date_depense=depense.date_depense, - montant=float(depense.montant), - qui_prenom=qui.prenom if qui else "?", - quoi=depense.quoi, - paye=depense.paye, - methode=depense.methode, - saisi_par_prenom=membre.prenom, - cree_le=depense.cree_le, - ) + return await _to_out(depense, db) diff --git a/backend/app/routers/evenements.py b/backend/app/routers/evenements.py new file mode 100644 index 0000000..f42bafa --- /dev/null +++ b/backend/app/routers/evenements.py @@ -0,0 +1,156 @@ +"""Routes — Événements à venir (anniversaires + ad hoc + résolutions).""" +from datetime import date, timedelta +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.groupe import Groupe +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 à venir"]) + + +def _prochain_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, date, int]: + """Retourne (années, prochaine date, jours restants).""" + try: + anniv = date_abstinence.replace(year=aujourdhui.year) + except ValueError: + anniv = date(aujourdhui.year, 3, 1) + if anniv < aujourdhui: + try: + anniv = date_abstinence.replace(year=aujourdhui.year + 1) + except ValueError: + anniv = date(aujourdhui.year + 1, 3, 1) + annees = anniv.year - date_abstinence.year + jours = (anniv - aujourdhui).days + return max(0, annees), anniv, jours + + +@router.get("/") +async def evenements_a_venir( + jours: int = 90, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Tous les événements à venir : réunions, anniversaires, ad hoc.""" + aujourdhui = date.today() + limite = aujourdhui + timedelta(days=jours) + groupe = await db.get(Groupe, membre.groupe_id) + district = groupe.district if groupe else "87-16" + + evenements = [] + + # ── 1. Anniversaires de sobriété du district ── + result = await db.execute( + select(Membre, Groupe.nom) + .join(Groupe, Membre.groupe_id == Groupe.id) + .where( + Groupe.district == district, + Membre.actif == True, + Membre.date_abstinence.isnot(None), + ) + ) + for m, groupe_nom in result.all(): + annees, prochain, jours_restants = _prochain_anniversaire(m.date_abstinence, aujourdhui) + if 0 <= jours_restants <= jours: + evenements.append({ + "type": "anniversaire", + "date": str(prochain), + "titre": f"🎂 {m.prenom} — {annees} an{'s' if annees > 1 else ''}", + "detail": f"Anniversaire de sobriété · {groupe_nom}", + "lieu": None, + "groupe_nom": groupe_nom, + }) + + # ── 2. Événements ad hoc ── + result = await db.execute( + select(Evenement, Groupe.nom) + .join(Groupe, Evenement.groupe_id == Groupe.id) + .where( + Groupe.district == district, + Evenement.date_evenement >= aujourdhui, + Evenement.date_evenement <= limite, + ) + ) + for ev, groupe_nom in result.all(): + evenements.append({ + "type": "adhoc", + "date": str(ev.date_evenement), + "titre": ev.titre, + "detail": ev.description, + "lieu": ev.lieu, + "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 + + +# ── CRUD événements ad hoc ── + +@router.post("/") +async def creer_evenement( + titre: str, + date_evenement: date, + description: str = "", + lieu: str = "", + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Créer un événement ad hoc (exécutif).""" + ev = Evenement( + groupe_id=executif.groupe_id, + titre=titre.strip(), + description=description.strip() or None, + date_evenement=date_evenement, + lieu=lieu.strip() or None, + cree_par=executif.id, + ) + db.add(ev) + await db.flush() + return {"message": "Événement créé", "id": str(ev.id)} + + +@router.delete("/{evenement_id}") +async def supprimer_evenement( + evenement_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Evenement).where( + Evenement.id == evenement_id, + Evenement.groupe_id == executif.groupe_id, + ) + ) + ev = result.scalar_one_or_none() + if not ev: + raise HTTPException(status_code=404, detail="Événement introuvable") + await db.delete(ev) + await db.flush() + return {"message": "Événement supprimé"} diff --git a/backend/app/routers/membres.py b/backend/app/routers/membres.py index 62c0c18..af138c2 100644 --- a/backend/app/routers/membres.py +++ b/backend/app/routers/membres.py @@ -24,10 +24,14 @@ async def modifier_profil( db: AsyncSession = Depends(get_db), ): """C7 — Modifier mon profil.""" + if updates.prenom is not None: + membre.prenom = updates.prenom.strip().title() + if updates.nom is not None: + membre.nom = updates.nom.strip().title() if updates.nom.strip() else None if updates.telephone is not None: - membre.telephone = updates.telephone + membre.telephone = updates.telephone.strip() if updates.courriel is not None: - membre.courriel = updates.courriel + membre.courriel = updates.courriel.strip().lower() if updates.date_abstinence is not None: membre.date_abstinence = updates.date_abstinence await db.flush() @@ -116,12 +120,13 @@ async def droit_oubli( prenom_original = membre.prenom # Anonymiser - membre.prenom = f"Ancien membre" - membre.nom = "***" - membre.telephone = None - membre.courriel = None + membre.prenom = "Ancien membre" + membre.nom = None + membre.telephone = "000-000-0000" + membre.courriel = f"supprime_{membre.id}@anon.local" membre.pin_hash = "SUPPRIME" membre.actif = False membre.push_sub = None + membre.date_abstinence = None await db.flush() return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"} diff --git a/backend/app/routers/operations_bancaires.py b/backend/app/routers/operations_bancaires.py new file mode 100644 index 0000000..ea254ac --- /dev/null +++ b/backend/app/routers/operations_bancaires.py @@ -0,0 +1,90 @@ +"""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 + +from app.core.database import get_db +from app.core.security import get_current_membre +from app.models.operation_bancaire import OperationBancaire +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 + +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")), + ) + ) + if not result.scalars().first(): + raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif") + return membre + + +@router.post("/", response_model=OperationBancaireOut) +async def enregistrer_operation( + req: OperationBancaireCreate, + 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'") + if req.montant <= 0: + raise HTTPException(status_code=400, detail="Le montant doit être positif") + + op = OperationBancaire( + groupe_id=membre.groupe_id, + type=req.type, + montant=req.montant, + date_operation=req.date_operation, + effectue_par=membre.id, + note=req.note, + ) + db.add(op) + await db.flush() + return OperationBancaireOut( + id=op.id, + type=op.type, + montant=float(op.montant), + date_operation=op.date_operation, + effectue_par_prenom=membre.prenom, + note=op.note, + cree_le=op.cree_le, + ) + + +@router.get("/", response_model=list[OperationBancaireOut]) +async def liste_operations( + limite: int = 20, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Lister les opérations bancaires du groupe.""" + result = await db.execute( + select(OperationBancaire) + .where(OperationBancaire.groupe_id == membre.groupe_id) + .order_by(desc(OperationBancaire.date_operation)) + .limit(limite) + ) + ops = result.scalars().all() + out = [] + for op in ops: + m = await db.get(Membre, op.effectue_par) + out.append(OperationBancaireOut( + id=op.id, + type=op.type, + montant=float(op.montant), + date_operation=op.date_operation, + effectue_par_prenom=m.prenom if m else "?", + note=op.note, + cree_le=op.cree_le, + )) + return out diff --git a/backend/app/routers/parametres.py b/backend/app/routers/parametres.py index b1e0ef0..cef229f 100644 --- a/backend/app/routers/parametres.py +++ b/backend/app/routers/parametres.py @@ -1,4 +1,5 @@ """Routes G9 — Paramètres du groupe.""" +from datetime import time as dt_time from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession @@ -35,7 +36,15 @@ async def modifier_groupe( if req.jour_reunion is not None: groupe.jour_reunion = req.jour_reunion.strip() if req.heure_reunion is not None: - groupe.heure_reunion = req.heure_reunion + h = req.heure_reunion.strip() + if h: + try: + parts = h.split(":") + groupe.heure_reunion = dt_time(int(parts[0]), int(parts[1])) + except (ValueError, IndexError): + groupe.heure_reunion = None + else: + groupe.heure_reunion = None 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 403167a..1eaea02 100644 --- a/backend/app/routers/postes.py +++ b/backend/app/routers/postes.py @@ -1,4 +1,4 @@ -"""Routes postes et affectations.""" +"""Routes postes, affectations, candidatures, tâches.""" from datetime import date from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession @@ -8,29 +8,462 @@ from app.core.database import get_db from app.core.security import get_current_membre, require_executif from app.models.poste import Poste from app.models.affectation import Affectation -from app.schemas.schemas import PosteOut, AffectationOut, AffectationCreate, TerminerMandatRequest +from app.models.membre import Membre +from app.models.candidature import Candidature +from app.models.tache import Tache, PosteTache +from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, TerminerMandatRequest, TacheCreate, TacheOut router = APIRouter(prefix="/postes", tags=["Postes et affectations"]) -@router.get("/", response_model=list[PosteOut]) -async def liste_postes(db: AsyncSession = Depends(get_db), _=Depends(get_current_membre)): - result = await db.execute(select(Poste).order_by(Poste.ordre_affichage)) - return [PosteOut.model_validate(p) for p in result.scalars().all()] +# ── Postes ── +@router.get("/", response_model=list[PosteOut]) +async def liste_postes( + inclure_abolis: bool = False, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_membre), +): + query = select(Poste) + if not inclure_abolis: + query = query.where(Poste.actif == True) + result = await db.execute(query.order_by(Poste.ordre_affichage)) + postes = result.scalars().all() + + out = [] + for p in postes: + # Charger les tâches associées + result_t = await db.execute( + select(Tache).join(PosteTache).where(PosteTache.poste_id == p.id) + .order_by(Tache.ordre_affichage) + ) + taches_list = result_t.scalars().all() + taches = [{"id": str(t.id), "nom": t.nom, "informations": t.informations} + for t in taches_list] + + # Description composite : description du poste + tâches associées + desc_parts = [] + if p.description: + desc_parts.append(p.description) + if taches_list: + desc_parts.append("Tâches : " + ", ".join( + f"{t.nom}" + (f" ({t.informations})" if t.informations else "") + for t in taches_list + )) + + po = PosteOut.model_validate(p) + po.description = " — ".join(desc_parts) if desc_parts else p.description + po.taches = taches + out.append(po) + return out + + +@router.post("/", response_model=PosteOut) +async def creer_poste( + req: PosteCreate, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Créer un nouveau poste (exécutif).""" + if req.categorie not in ("executif", "service", "physique"): + raise HTTPException(status_code=400, detail="Catégorie invalide") + poste = Poste( + nom=req.nom.strip(), + categorie=req.categorie, + description=req.description, + prerequis=req.prerequis, + informations=req.informations, + ordre_affichage=req.ordre_affichage or 99, + ) + db.add(poste) + await db.flush() + return PosteOut.model_validate(poste) + + +@router.delete("/{poste_id}") +async def abolir_poste( + poste_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Abolir un poste — termine tous les mandats actifs associés.""" + poste = await db.get(Poste, poste_id) + if not poste: + raise HTTPException(status_code=404, detail="Poste introuvable") + + # Terminer les mandats actifs + result = await db.execute( + select(Affectation).where( + Affectation.poste_id == poste.id, + Affectation.statut == "actif", + ) + ) + mandats_termines = 0 + for a in result.scalars().all(): + a.statut = "termine" + a.date_fin = date.today() + a.note_transition = f"Poste aboli: {poste.nom}" + a.termine_par = executif.id + mandats_termines += 1 + + # Refuser les candidatures en attente + result = await db.execute( + select(Candidature).where( + Candidature.poste_id == poste.id, + Candidature.statut == "proposee", + ) + ) + for c in result.scalars().all(): + c.statut = "refusee" + + poste.actif = False + await db.flush() + return { + "message": f"Poste «{poste.nom}» aboli", + "mandats_termines": mandats_termines, + } + + +# ── Postes disponibles (vacants) ── + +@router.get("/disponibles") +async def postes_disponibles( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Postes actifs sans titulaire — opportunités d'implication.""" + result = await db.execute( + select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage) + ) + postes = result.scalars().all() + + disponibles = [] + for p in postes: + result = await db.execute( + select(Affectation).where( + Affectation.poste_id == p.id, + Affectation.statut == "actif", + Affectation.groupe_id == membre.groupe_id, + ) + ) + if not result.scalars().first(): + # Vérifier les candidatures existantes du membre + result_cand = await db.execute( + select(Candidature).where( + Candidature.poste_id == p.id, + Candidature.membre_id == membre.id, + Candidature.statut == "proposee", + ) + ) + deja_postule = result_cand.scalars().first() is not None + + # Description composite avec tâches + result_t = await db.execute( + select(Tache).join(PosteTache).where(PosteTache.poste_id == p.id) + .order_by(Tache.ordre_affichage) + ) + taches_list = result_t.scalars().all() + desc_parts = [] + if p.description: + desc_parts.append(p.description) + if taches_list: + desc_parts.append("Tâches : " + ", ".join( + f"{t.nom}" + (f" ({t.informations})" if t.informations else "") + for t in taches_list + )) + + disponibles.append({ + "id": str(p.id), + "nom": p.nom, + "categorie": p.categorie, + "description": " — ".join(desc_parts) if desc_parts else p.description, + "prerequis": p.prerequis, + "deja_postule": deja_postule, + }) + return disponibles + + +# ── Candidatures ── + +@router.post("/candidatures") +async def postuler( + poste_id: str, + motivation: str = "", + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Proposer sa candidature — crée automatiquement une proposition de nomination.""" + from app.models.proposition import Proposition + + poste = await db.get(Poste, poste_id) + if not poste or not poste.actif: + raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") + + # Vérifier qu'il n'a pas déjà postulé + result = await db.execute( + select(Candidature).where( + Candidature.poste_id == poste_id, + Candidature.membre_id == membre.id, + Candidature.statut == "proposee", + ) + ) + if result.scalars().first(): + raise HTTPException(status_code=400, detail="Vous avez déjà postulé pour ce poste") + + # Créer la candidature + cand = Candidature( + groupe_id=membre.groupe_id, + poste_id=poste_id, + membre_id=membre.id, + type="candidature", + motivation=motivation.strip() or None, + ) + db.add(cand) + await db.flush() + + # Créer automatiquement la proposition de nomination + prop = Proposition( + groupe_id=membre.groupe_id, + type_proposition="nomination", + sujet=f"Nomination de {membre.prenom} au poste de {poste.nom}", + description=motivation.strip() or None, + proposeur_id=membre.id, + candidature_id=cand.id, + statut="proposee", + ) + db.add(prop) + cand.proposition_id = prop.id + await db.flush() + + return { + "message": f"Candidature soumise pour {poste.nom} — proposition créée", + "id": str(cand.id), + "proposition_id": str(prop.id), + } + + +@router.post("/candidatures/nominer") +async def nominer( + poste_id: str, + membre_id: str, + motivation: str = "", + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Nominer quelqu'un (exécutif). Le nominé devra accepter avant que la proposition soit créée.""" + poste = await db.get(Poste, poste_id) + if not poste or not poste.actif: + raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") + nominee = await db.get(Membre, membre_id) + if not nominee or nominee.groupe_id != executif.groupe_id: + raise HTTPException(status_code=404, detail="Membre introuvable") + + cand = Candidature( + groupe_id=executif.groupe_id, + poste_id=poste_id, + membre_id=membre_id, + type="nomination", + nomme_par=executif.id, + motivation=motivation.strip() or None, + ) + db.add(cand) + await db.flush() + return { + "message": f"{nominee.prenom} nominé(e) pour {poste.nom} — en attente de son acceptation", + "id": str(cand.id), + } + + +@router.post("/candidatures/{candidature_id}/retirer") +async def retirer_candidature( + candidature_id: str, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Retirer sa propre candidature. Retire aussi la proposition associée.""" + from app.models.proposition import Proposition + + result = await db.execute( + select(Candidature).where( + Candidature.id == candidature_id, + Candidature.membre_id == membre.id, + Candidature.statut == "proposee", + ) + ) + cand = result.scalar_one_or_none() + if not cand: + raise HTTPException(status_code=404, detail="Candidature introuvable ou déjà traitée") + cand.statut = "retiree" + + # Retirer la proposition associée si elle n'est pas encore votée + if cand.proposition_id: + prop = await db.get(Proposition, cand.proposition_id) + if prop and prop.statut in ("proposee", "secondee"): + prop.statut = "rejetee" + prop.note = "Candidature retirée par le membre" + + await db.flush() + return {"message": "Candidature retirée"} + + +@router.post("/candidatures/{candidature_id}/accepter-nomination") +async def accepter_nomination( + candidature_id: str, + accepter: bool = True, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Le nominé accepte/refuse. Si accepté, la proposition est créée automatiquement.""" + from app.models.proposition import Proposition + + result = await db.execute( + select(Candidature).where( + Candidature.id == candidature_id, + Candidature.membre_id == membre.id, + Candidature.type == "nomination", + Candidature.statut == "proposee", + ) + ) + cand = result.scalar_one_or_none() + if not cand: + raise HTTPException(status_code=404, detail="Nomination introuvable") + + cand.acceptee_par_membre = accepter + + if not accepter: + cand.statut = "refusee" + await db.flush() + return {"message": "Nomination refusée"} + + # Acceptée → créer la proposition de nomination + poste = await db.get(Poste, cand.poste_id) + nomme_par = await db.get(Membre, cand.nomme_par) if cand.nomme_par else None + + prop = Proposition( + groupe_id=cand.groupe_id, + type_proposition="nomination", + sujet=f"Nomination de {membre.prenom} au poste de {poste.nom if poste else '?'}", + description=f"Nominé par {nomme_par.prenom if nomme_par else '?'}. {cand.motivation or ''}".strip(), + proposeur_id=cand.nomme_par or membre.id, + candidature_id=cand.id, + statut="proposee", + ) + db.add(prop) + cand.proposition_id = prop.id + await db.flush() + + return { + "message": "Nomination acceptée — proposition créée, en attente d'être secondée", + "proposition_id": str(prop.id), + } + + +@router.get("/candidatures") +async def liste_candidatures( + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Liste des candidatures du groupe (exécutif).""" + result = await db.execute( + select(Candidature).where( + Candidature.groupe_id == executif.groupe_id, + Candidature.statut == "proposee", + ) + ) + candidatures = result.scalars().all() + out = [] + for c in candidatures: + mbr = await db.get(Membre, c.membre_id) + poste = await db.get(Poste, c.poste_id) + nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None + out.append({ + "id": str(c.id), + "membre_prenom": mbr.prenom if mbr else "?", + "poste_nom": poste.nom if poste else "?", + "type": c.type, + "motivation": c.motivation, + "nomme_par_prenom": nomme.prenom if nomme else None, + "acceptee_par_membre": c.acceptee_par_membre, + "cree_le": str(c.cree_le), + }) + return out + + +@router.get("/candidatures/mes") +async def mes_candidatures( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Mes candidatures et nominations en attente.""" + result = await db.execute( + select(Candidature).where( + Candidature.membre_id == membre.id, + Candidature.statut == "proposee", + ) + ) + out = [] + for c in result.scalars().all(): + poste = await db.get(Poste, c.poste_id) + nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None + out.append({ + "id": str(c.id), + "poste_nom": poste.nom if poste else "?", + "type": c.type, + "nomme_par_prenom": nomme.prenom if nomme else None, + "acceptee_par_membre": c.acceptee_par_membre, + }) + return out + + +@router.patch("/candidatures/{candidature_id}") +async def traiter_candidature( + candidature_id: str, + statut: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Accepter ou refuser une candidature (exécutif).""" + if statut not in ("acceptee", "refusee"): + raise HTTPException(status_code=400, detail="Statut: acceptee ou refusee") + result = await db.execute( + select(Candidature).where( + Candidature.id == candidature_id, + Candidature.groupe_id == executif.groupe_id, + ) + ) + cand = result.scalar_one_or_none() + if not cand: + raise HTTPException(status_code=404, detail="Candidature introuvable") + + cand.statut = statut + + # Si acceptée, créer l'affectation automatiquement + if statut == "acceptee": + affectation = Affectation( + groupe_id=executif.groupe_id, + membre_id=cand.membre_id, + poste_id=cand.poste_id, + date_debut=date.today(), + statut="actif", + ) + db.add(affectation) + + await db.flush() + return {"message": "Candidature " + ("acceptée" if statut == "acceptee" else "refusée")} + + +# ── Affectations ── @router.get("/affectations") async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): - """Retourne les affectations actives du membre connecté.""" result = await db.execute( select(Affectation).join(Poste).where( Affectation.membre_id == membre.id, Affectation.statut == "actif", ) ) - affectations = result.scalars().all() out = [] - for a in affectations: + for a in result.scalars().all(): poste = await db.get(Poste, a.poste_id) out.append({ "id": str(a.id), @@ -43,21 +476,15 @@ async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession @router.get("/affectations/groupe") -async def affectations_groupe( - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G3 — Toutes les affectations actives du groupe.""" +async def affectations_groupe(executif=Depends(require_executif), db: AsyncSession = Depends(get_db)): result = await db.execute( select(Affectation).where( Affectation.groupe_id == executif.groupe_id, Affectation.statut == "actif", ) ) - affectations = result.scalars().all() out = [] - for a in affectations: - from app.models.membre import Membre + for a in result.scalars().all(): poste = await db.get(Poste, a.poste_id) mbr = await db.get(Membre, a.membre_id) out.append({ @@ -71,12 +498,7 @@ async def affectations_groupe( @router.post("/affectations") -async def creer_affectation( - req: AffectationCreate, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), -): - """G3 — Affecter un membre à un poste.""" +async def creer_affectation(req: AffectationCreate, executif=Depends(require_executif), db: AsyncSession = Depends(get_db)): affectation = Affectation( groupe_id=executif.groupe_id, membre_id=req.membre_id, @@ -91,12 +513,9 @@ async def creer_affectation( @router.post("/affectations/{affectation_id}/terminer") async def terminer_mandat( - affectation_id: str, - req: TerminerMandatRequest, - executif=Depends(require_executif), - db: AsyncSession = Depends(get_db), + affectation_id: str, req: TerminerMandatRequest, + executif=Depends(require_executif), db: AsyncSession = Depends(get_db), ): - """G4 — Terminer un mandat manuellement.""" result = await db.execute( select(Affectation).where( Affectation.id == affectation_id, @@ -107,10 +526,173 @@ async def terminer_mandat( affectation = result.scalar_one_or_none() if not affectation: raise HTTPException(status_code=404, detail="Affectation introuvable") - affectation.statut = "termine" affectation.date_fin = date.today() affectation.note_transition = req.note_transition affectation.termine_par = executif.id await db.flush() return {"message": "Mandat terminé"} + + +# ── Réactivation / suppression définitive de postes ── + +@router.post("/{poste_id}/reactiver") +async def reactiver_poste( + poste_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Réactiver un poste aboli.""" + poste = await db.get(Poste, poste_id) + if not poste: + raise HTTPException(status_code=404, detail="Poste introuvable") + if poste.actif: + raise HTTPException(status_code=400, detail="Ce poste est déjà actif") + poste.actif = True + await db.flush() + return {"message": f"Poste «{poste.nom}» réactivé"} + + +@router.delete("/{poste_id}/permanent") +async def supprimer_poste_permanent( + poste_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Supprimer définitivement un poste aboli (irréversible).""" + poste = await db.get(Poste, poste_id) + if not poste: + raise HTTPException(status_code=404, detail="Poste introuvable") + if poste.actif: + raise HTTPException(status_code=400, detail="Abolissez d'abord le poste avant de le supprimer") + + # Supprimer les liens tâches + result = await db.execute(select(PosteTache).where(PosteTache.poste_id == poste.id)) + for pt in result.scalars().all(): + await db.delete(pt) + + await db.delete(poste) + await db.flush() + return {"message": f"Poste «{poste.nom}» supprimé définitivement"} + + +# ── Tâches ── + +@router.get("/taches", response_model=list[TacheOut]) +async def liste_taches( + _=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Lister toutes les tâches avec les postes associés.""" + result = await db.execute(select(Tache).order_by(Tache.ordre_affichage)) + taches = result.scalars().all() + out = [] + for t in taches: + result_p = await db.execute( + select(Poste.nom).join(PosteTache).where(PosteTache.tache_id == t.id) + ) + postes_noms = [row[0] for row in result_p.all()] + out.append(TacheOut( + id=t.id, nom=t.nom, informations=t.informations, postes=postes_noms + )) + return out + + +@router.post("/taches", response_model=TacheOut) +async def creer_tache( + req: TacheCreate, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Créer une tâche (exécutif).""" + tache = Tache(nom=req.nom.strip(), informations=req.informations) + db.add(tache) + await db.flush() + return TacheOut(id=tache.id, nom=tache.nom, informations=tache.informations) + + +@router.patch("/taches/{tache_id}") +async def modifier_tache( + tache_id: str, + nom: str | None = None, + informations: str | None = None, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Modifier une tâche.""" + tache = await db.get(Tache, tache_id) + if not tache: + raise HTTPException(status_code=404, detail="Tâche introuvable") + if nom is not None: + tache.nom = nom.strip() + if informations is not None: + tache.informations = informations.strip() or None + await db.flush() + return {"message": "Tâche modifiée"} + + +@router.delete("/taches/{tache_id}") +async def supprimer_tache( + tache_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Supprimer une tâche et ses associations.""" + tache = await db.get(Tache, tache_id) + if not tache: + raise HTTPException(status_code=404, detail="Tâche introuvable") + result = await db.execute(select(PosteTache).where(PosteTache.tache_id == tache.id)) + for pt in result.scalars().all(): + await db.delete(pt) + await db.delete(tache) + await db.flush() + return {"message": f"Tâche «{tache.nom}» supprimée"} + + +# ── Affectation tâche ↔ poste ── + +@router.post("/{poste_id}/taches/{tache_id}") +async def affecter_tache( + poste_id: str, + tache_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Affecter une tâche à un poste. Une tâche peut être affectée à plusieurs postes.""" + poste = await db.get(Poste, poste_id) + tache = await db.get(Tache, tache_id) + if not poste or not poste.actif: + raise HTTPException(status_code=404, detail="Poste introuvable ou aboli") + if not tache: + raise HTTPException(status_code=404, detail="Tâche introuvable") + + # Vérifier doublon + result = await db.execute( + select(PosteTache).where(PosteTache.poste_id == poste_id, PosteTache.tache_id == tache_id) + ) + if result.scalars().first(): + raise HTTPException(status_code=400, detail="Cette tâche est déjà affectée à ce poste") + + pt = PosteTache(poste_id=poste_id, tache_id=tache_id) + db.add(pt) + await db.flush() + return {"message": f"Tâche «{tache.nom}» affectée au poste «{poste.nom}»"} + + +@router.delete("/{poste_id}/taches/{tache_id}") +async def desaffecter_tache( + poste_id: str, + tache_id: str, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Retirer une tâche d'un poste.""" + result = await db.execute( + select(PosteTache).where(PosteTache.poste_id == poste_id, PosteTache.tache_id == tache_id) + ) + pt = result.scalars().first() + if not pt: + raise HTTPException(status_code=404, detail="Association introuvable") + await db.delete(pt) + await db.flush() + return {"message": "Tâche retirée du poste"} diff --git a/backend/app/routers/presences.py b/backend/app/routers/presences.py index a02a330..5656751 100644 --- a/backend/app/routers/presences.py +++ b/backend/app/routers/presences.py @@ -1,11 +1,14 @@ """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 +from sqlalchemy import select, desc, extract from app.core.database import get_db 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 @@ -16,6 +19,18 @@ from app.schemas.phase4 import ( 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.""" @@ -38,6 +53,35 @@ async def creer_reunion_affaires( db: AsyncSession = Depends(get_db), ): """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 ── + dt = req.date_reunion + if groupe.jour_reunion: + jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower()) + if jour_attendu is not None: + premier = _premier_jour_du_mois(dt.year, dt.month, jour_attendu) + if dt.date() != premier: + raise HTTPException( + status_code=400, + detail=f"La réunion d'affaires doit avoir lieu le premier {groupe.jour_reunion} du mois ({premier.isoformat()})", + ) + + # Une seule réunion d'affaires par mois + result = await db.execute( + select(Reunion).where( + Reunion.groupe_id == membre.groupe_id, + Reunion.type == "affaires", + extract("year", Reunion.date_reunion) == dt.year, + extract("month", Reunion.date_reunion) == dt.month, + ) + ) + if result.scalars().first(): + raise HTTPException( + status_code=400, + detail=f"Il y a déjà une réunion d'affaires pour {dt.strftime('%B %Y')}", + ) + reunion = Reunion( groupe_id=membre.groupe_id, type="affaires", diff --git a/backend/app/routers/propositions.py b/backend/app/routers/propositions.py new file mode 100644 index 0000000..6e55934 --- /dev/null +++ b/backend/app/routers/propositions.py @@ -0,0 +1,217 @@ +"""Routes — Propositions et résolutions (réunions d'affaires).""" +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 app.core.database import get_db +from app.core.security import get_current_membre, require_executif +from app.models.proposition import Proposition +from app.models.reunion import Reunion +from app.models.membre import Membre +from app.models.candidature import Candidature +from app.models.affectation import Affectation +from app.models.poste import Poste +from app.schemas.propositions import PropositionCreate, PropositionOut, PropositionVote + +router = APIRouter(prefix="/propositions", tags=["Propositions et résolutions"]) + + +async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut: + proposeur = await db.get(Membre, p.proposeur_id) + secondeur = await db.get(Membre, p.secondeur_id) if p.secondeur_id else None + reunion = await db.get(Reunion, p.reunion_id) if p.reunion_id else None + return PropositionOut( + id=p.id, + reunion_id=p.reunion_id, + type_proposition=p.type_proposition or "generale", + sujet=p.sujet, + description=p.description, + proposeur_prenom=proposeur.prenom if proposeur else "?", + secondeur_prenom=secondeur.prenom if secondeur else None, + statut=p.statut, + numero_resolution=p.numero_resolution, + date_vote=p.date_vote, + note=p.note, + candidature_id=p.candidature_id, + cree_le=p.cree_le, + date_reunion=reunion.date_reunion if reunion else None, + ) + + +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, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Soumettre une proposition — peut être créée en tout temps.""" + if req.reunion_id: + result = await db.execute( + select(Reunion).where( + Reunion.id == req.reunion_id, + Reunion.groupe_id == membre.groupe_id, + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Réunion introuvable") + + prop = Proposition( + groupe_id=membre.groupe_id, + reunion_id=req.reunion_id, + type_proposition=req.type_proposition or "generale", + sujet=req.sujet.strip(), + description=req.description, + proposeur_id=membre.id, + statut="proposee", + ) + db.add(prop) + await db.flush() + return await _to_out(prop, db) + + +@router.post("/{proposition_id}/seconder", response_model=PropositionOut) +async def seconder_proposition( + proposition_id: str, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Seconder une proposition pour la rendre recevable.""" + result = await db.execute( + select(Proposition).where( + Proposition.id == proposition_id, + Proposition.groupe_id == membre.groupe_id, + ) + ) + prop = result.scalar_one_or_none() + if not prop: + raise HTTPException(status_code=404, detail="Proposition introuvable") + if prop.statut != "proposee": + raise HTTPException(status_code=400, detail="Seule une proposition en attente peut être secondée") + if prop.proposeur_id == membre.id: + raise HTTPException(status_code=400, detail="On ne peut pas seconder sa propre proposition") + + prop.secondeur_id = membre.id + prop.statut = "secondee" + await db.flush() + return await _to_out(prop, db) + + +@router.patch("/{proposition_id}/voter", response_model=PropositionOut) +async def voter_proposition( + proposition_id: str, + req: PropositionVote, + executif=Depends(require_executif), + db: AsyncSession = Depends(get_db), +): + """Enregistrer le résultat du vote sur une proposition secondée (exécutif).""" + if req.statut not in ("adoptee", "rejetee"): + raise HTTPException(status_code=400, detail="Le statut doit être 'adoptee' ou 'rejetee'") + + result = await db.execute( + select(Proposition).where( + Proposition.id == proposition_id, + Proposition.groupe_id == executif.groupe_id, + ) + ) + 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() + + # 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 + ) + if req.reunion_id and not prop.reunion_id: + prop.reunion_id = req.reunion_id + + # ── NOMINATION : créer l'affectation automatiquement ── + if prop.type_proposition == "nomination" and prop.candidature_id: + cand = await db.get(Candidature, prop.candidature_id) + if cand and cand.statut == "proposee": + cand.statut = "acceptee" + cand.proposition_id = prop.id + affectation = Affectation( + groupe_id=executif.groupe_id, + membre_id=cand.membre_id, + poste_id=cand.poste_id, + date_debut=date.today(), + statut="actif", + ) + db.add(affectation) + + elif req.statut == "rejetee": + # 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" + + await db.flush() + return await _to_out(prop, db) + + +@router.get("/", response_model=list[PropositionOut]) +async def liste_propositions( + statut: str | None = None, + reunion_id: str | None = None, + type_proposition: str | None = None, + limite: int = 50, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Lister les propositions du groupe avec filtres optionnels.""" + query = select(Proposition).where(Proposition.groupe_id == membre.groupe_id) + if statut: + query = query.where(Proposition.statut == statut) + if reunion_id: + query = query.where(Proposition.reunion_id == reunion_id) + if type_proposition: + query = query.where(Proposition.type_proposition == type_proposition) + query = query.order_by(desc(Proposition.cree_le)).limit(limite) + + result = await db.execute(query) + return [await _to_out(p, db) for p in result.scalars().all()] + + +@router.get("/resolutions", response_model=list[PropositionOut]) +async def registre_resolutions( + annee: int | None = None, + type_proposition: str | None = None, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Registre des résolutions adoptées.""" + query = select(Proposition).where( + Proposition.groupe_id == membre.groupe_id, + Proposition.statut == "adoptee", + ) + if annee: + 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) + + result = await db.execute(query) + return [await _to_out(p, db) for p in result.scalars().all()] diff --git a/backend/app/routers/rapports.py b/backend/app/routers/rapports.py index 60fe2be..7b6edc5 100644 --- a/backend/app/routers/rapports.py +++ b/backend/app/routers/rapports.py @@ -14,6 +14,7 @@ from app.models.depense import Depense from app.models.membre import Membre from app.models.reunion import Reunion from app.models.envoi_contribution import EnvoiContribution +from app.models.proposition import Proposition router = APIRouter(prefix="/rapports", tags=["C1 — Rapports PDF"]) @@ -34,7 +35,6 @@ def _css(): table { width: 100%; border-collapse: collapse; margin-top: 8pt; } th { background: #1a5276; color: white; padding: 6pt 8pt; text-align: left; font-size: 9pt; } td { padding: 5pt 8pt; border-bottom: 1px solid #ddd; font-size: 10pt; } - tr:nth-child(even) { background: #f8f9fa; } .total { font-weight: bold; font-size: 12pt; margin-top: 10pt; } .footer { text-align: center; font-size: 8pt; color: #aaa; margin-top: 30pt; } .badge-paye { color: #27ae60; } @@ -246,6 +246,24 @@ async def rapports_disponibles( return sorted(all_mois.values(), key=lambda x: (x["annee"], x["mois"]), reverse=True) +@router.get("/resolutions/annees") +async def annees_resolutions( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Années pour lesquelles des résolutions existent.""" + result = await db.execute( + select(extract("year", Proposition.cree_le).label("annee")) + .where( + Proposition.groupe_id == membre.groupe_id, + Proposition.statut.in_(["adoptee", "rejetee"]), + ) + .group_by("annee") + .order_by(extract("year", Proposition.cree_le).desc()) + ) + return [{"annee": int(r.annee)} for r in result.all()] + + @router.get("/financier-complet/{annee}/{mois}") async def rapport_financier_complet( annee: int, @@ -304,3 +322,209 @@ async def rapport_financier_complet( "nb_contributions": len(contributions), "depenses_impayees": round(depenses_impayees, 2), } + + +@router.get("/resolutions/{annee}") +async def rapport_resolutions( + annee: int, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """C1 — Registre des résolutions adoptées en PDF.""" + try: + from weasyprint import HTML + except ImportError: + raise HTTPException(status_code=500, detail="WeasyPrint non installé") + + groupe = await db.get(Groupe, membre.groupe_id) + + result = await db.execute( + select(Proposition).where( + Proposition.groupe_id == membre.groupe_id, + Proposition.statut.in_(["adoptee", "rejetee"]), + extract("year", Proposition.cree_le) == annee, + ).order_by(Proposition.numero_resolution, Proposition.cree_le) + ) + propositions = result.scalars().all() + + rows = "" + 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' + rows += f""" + {p.numero_resolution or '—'} + {p.date_vote or '—'} + {p.sujet} + {proposeur.prenom if proposeur else '—'} + {secondeur.prenom if secondeur else '—'} + {badge} + """ + + 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}

+ + + + {rows if rows else ''} +
N°DateSujetProposeurSecondeurRésultat
Aucune résolution pour cette année
+ +

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

+ + + """ + + pdf_bytes = HTML(string=html_content).write_pdf() + filename = f"registre_resolutions_{groupe.nom.replace(' ', '_')}_{annee}.pdf" + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) + + +@router.get("/etat-financier") +async def etat_financier_temps_reel( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """État des résultats et transactions en temps réel (JSON).""" + from sqlalchemy import func, desc + from app.models.operation_bancaire import OperationBancaire + from app.models.vente_litterature import VenteLitterature + from app.models.litterature import Litterature + + gid = membre.groupe_id + groupe = await db.get(Groupe, gid) + + # ── Dernier relevé bancaire ── + result = await db.execute( + select(OperationBancaire).where( + OperationBancaire.groupe_id == gid, + OperationBancaire.type == "releve", + ).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 + + # ── Revenus ── + result = await db.execute( + select(func.coalesce(func.sum(Collecte.montant), 0)).where( + Collecte.groupe_id == gid, Collecte.statut == "recu")) + 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)) + total_ventes = float(result.scalar()) + + revenus = total_collectes + total_ventes + + # ── Dépenses ── + result = await db.execute( + select(func.coalesce(func.sum(Depense.montant), 0)).where( + Depense.groupe_id == gid, Depense.paye == True)) + 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))) + total_engagements = float(result.scalar()) + + # ── Contributions ── + result = await db.execute( + select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( + EnvoiContribution.groupe_id == gid)) + 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(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_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") + if filtre_depuis: + q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis) + q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis) + depots_depuis = float((await db.execute(q_depots)).scalar()) + retraits_depuis = float((await db.execute(q_retraits)).scalar()) + solde_banque = solde_releve + depots_depuis - retraits_depuis + + # ── Réserves ── + reserve_prudente = 500.0 + mois_depuis = 0 + if groupe and groupe.cree_le: + from datetime import date as d + aujourdhui = d.today() + cree = groupe.cree_le.date() if hasattr(groupe.cree_le, 'date') else groupe.cree_le + mois_depuis = max(0, (aujourdhui.year - cree.year) * 12 + (aujourdhui.month - cree.month)) + reserve_anniversaire = mois_depuis * 20.0 + + disponible = solde_banque - total_engagements - reserve_prudente - reserve_anniversaire + + # ── Dernières transactions ── + result = await db.execute( + select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu") + .order_by(desc(Collecte.date_collecte)).limit(10)) + dernieres_collectes = [{ + "date": str(c.date_collecte), "montant": float(c.montant), + } for c in result.scalars().all()] + + result = await db.execute( + select(Depense).where(Depense.groupe_id == gid) + .order_by(desc(Depense.date_depense)).limit(10)) + dernieres_depenses = [{ + "date": str(d.date_depense), "montant": float(d.montant), + "quoi": d.quoi, "paye": d.paye, "methode": d.methode, + } for d in result.scalars().all()] + + return { + "groupe": groupe.nom if groupe else "?", + "date_releve": str(dernier_releve.date_operation) if dernier_releve else None, + "solde_releve": round(solde_releve, 2), + "solde_banque": round(solde_banque, 2), + "encaisse": round(max(0, encaisse), 2), + "disponible": round(disponible, 2), + "revenus": { + "collectes": round(total_collectes, 2), + "ventes_litterature": round(total_ventes, 2), + "total": round(revenus, 2), + }, + "sorties": { + "depenses_payees": round(total_depenses_payees, 2), + "engagements": round(total_engagements, 2), + "contributions": round(total_contributions, 2), + "total": round(total_depenses_payees + total_contributions, 2), + }, + "reserves": { + "prudente": reserve_prudente, + "anniversaire": round(reserve_anniversaire, 2), + "total": round(reserve_prudente + reserve_anniversaire, 2), + }, + "resultat_net": round(revenus - total_depenses_payees - total_contributions, 2), + "dernieres_collectes": dernieres_collectes, + "dernieres_depenses": dernieres_depenses, + } diff --git a/backend/app/routers/reunions.py b/backend/app/routers/reunions.py index 983e356..562a72e 100644 --- a/backend/app/routers/reunions.py +++ b/backend/app/routers/reunions.py @@ -1,11 +1,13 @@ """Routes S4 — Rapport de réunion.""" +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc +from sqlalchemy import select, desc, extract from app.core.database import get_db from app.core.security import get_current_membre 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 @@ -13,6 +15,11 @@ 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.""" @@ -35,6 +42,33 @@ async def creer_rapport_reunion( db: AsyncSession = Depends(get_db), ): """S4 — L'animateur note le thème et les annonces.""" + groupe = await db.get(Groupe, membre.groupe_id) + + # ── Validation : réunion régulière ── + if req.type == "reguliere" and groupe.jour_reunion: + jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower()) + jour_soumis = req.date_reunion.weekday() + if jour_attendu is not None and jour_soumis != jour_attendu: + raise HTTPException( + status_code=400, + detail=f"La réunion régulière doit avoir lieu le {groupe.jour_reunion}", + ) + # Une seule régulière par semaine ISO + iso_year, iso_week, _ = req.date_reunion.isocalendar() + result = await db.execute( + select(Reunion).where( + Reunion.groupe_id == membre.groupe_id, + Reunion.type == "reguliere", + extract("isoyear", Reunion.date_reunion) == iso_year, + extract("week", Reunion.date_reunion) == iso_week, + ) + ) + if result.scalars().first(): + raise HTTPException( + status_code=400, + detail=f"Il y a déjà une réunion régulière pour la semaine {iso_week}", + ) + reunion = Reunion( groupe_id=membre.groupe_id, type=req.type, diff --git a/backend/app/routers/sondages.py b/backend/app/routers/sondages.py index 2bd81c2..654c341 100644 --- a/backend/app/routers/sondages.py +++ b/backend/app/routers/sondages.py @@ -78,11 +78,6 @@ async def creer_sondage( # Pour oui_non, créer automatiquement les options options_data = req.options if req.type == "oui_non" and len(options_data) == 0: - options_data = [ - SondageCreate.model_fields["options"].default.__class__(texte="Oui"), - SondageCreate.model_fields["options"].default.__class__(texte="Non"), - ] - # Workaround: créer manuellement from app.schemas.phase8 import OptionCreate options_data = [OptionCreate(texte="Oui"), OptionCreate(texte="Non")] diff --git a/backend/app/routers/ventes_litterature.py b/backend/app/routers/ventes_litterature.py index 63b84d4..bd55dae 100644 --- a/backend/app/routers/ventes_litterature.py +++ b/backend/app/routers/ventes_litterature.py @@ -107,6 +107,75 @@ async def liste_ventes( montant=float(v.montant), date_vente=v.date_vente, vendu_par_prenom=vendeur.prenom if vendeur else None, + remis_tresorier=v.remis_tresorier, cree_le=v.cree_le, )) return out + + +@router.get("/a-recevoir") +async def ventes_a_recevoir( + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """Ventes dont le montant n'a pas encore été remis au trésorier.""" + result = await db.execute( + select(VenteLitterature) + .where( + VenteLitterature.groupe_id == membre.groupe_id, + VenteLitterature.remis_tresorier == False, + ) + .order_by(desc(VenteLitterature.date_vente)) + ) + ventes = result.scalars().all() + out = [] + for v in ventes: + item = await db.get(Litterature, v.litterature_id) + vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None + out.append({ + "id": str(v.id), + "litterature_titre": item.titre if item else "?", + "quantite": v.quantite, + "montant": float(v.montant), + "date_vente": str(v.date_vente), + "vendu_par_prenom": vendeur.prenom if vendeur else None, + }) + return out + + +@router.post("/{vente_id}/recevoir") +async def confirmer_reception_vente( + vente_id: str, + membre=Depends(get_current_membre), + db: AsyncSession = Depends(get_db), +): + """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 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( + VenteLitterature.id == vente_id, + VenteLitterature.groupe_id == membre.groupe_id, + ) + ) + vente = result.scalar_one_or_none() + if not vente: + raise HTTPException(status_code=404, detail="Vente introuvable") + if vente.remis_tresorier: + raise HTTPException(status_code=400, detail="Déjà reçue") + + from datetime import date as date_type + vente.remis_tresorier = True + vente.recu_par = membre.id + vente.date_remise = date_type.today() + await db.flush() + return {"message": "Réception confirmée"} diff --git a/backend/app/schemas/phase1.py b/backend/app/schemas/phase1.py index 508929f..b67d620 100644 --- a/backend/app/schemas/phase1.py +++ b/backend/app/schemas/phase1.py @@ -9,6 +9,7 @@ from typing import Optional class CollecteCreate(BaseModel): date_collecte: date montant: float + detenteur_id: UUID # qui a l'argent reunion_id: Optional[UUID] = None @@ -16,12 +17,49 @@ class CollecteOut(BaseModel): id: UUID date_collecte: date montant: float + detenteur_prenom: Optional[str] = None + statut: str saisi_par_prenom: Optional[str] = None + confirme_par_prenom: Optional[str] = None + date_confirmation: Optional[datetime] = None + recu_par_prenom: Optional[str] = None + date_reception: Optional[date] = None cree_le: datetime model_config = {"from_attributes": True} +class CollecteUpdate(BaseModel): + montant: Optional[float] = None + + +# ── Opérations bancaires ── +class OperationBancaireCreate(BaseModel): + type: str = "depot" + montant: float + date_operation: date + note: Optional[str] = None + + +class OperationBancaireOut(BaseModel): + id: UUID + type: str + montant: float + date_operation: date + effectue_par_prenom: str + note: Optional[str] = None + cree_le: datetime + + model_config = {"from_attributes": True} + + +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 + en_possession: float # collectes pas encore remises au trésorier + en_possession_details: list[dict] = [] + + # ── S4 Rapport de réunion ── class ReunionCreate(BaseModel): type: str = "reguliere" @@ -44,14 +82,13 @@ class ReunionOut(BaseModel): model_config = {"from_attributes": True} -# ── S5 Dépense (6 champs) ── +# ── S5 Dépense (ouvert à tous) ── class DepenseCreate(BaseModel): date_depense: date montant: float - qui: UUID # membre_id quoi: str - paye: bool = False - methode: str # DU, VIREMENT, CHEQUE, COLLECTE + # qui = auto (membre courant) + # methode renseignée par le trésorier à la confirmation class DepenseOut(BaseModel): @@ -61,8 +98,10 @@ class DepenseOut(BaseModel): qui_prenom: Optional[str] = None quoi: str paye: bool - methode: str + methode: Optional[str] = None + preuve_nom: Optional[str] = None saisi_par_prenom: Optional[str] = None + confirme: bool = False cree_le: datetime model_config = {"from_attributes": True} diff --git a/backend/app/schemas/phase3.py b/backend/app/schemas/phase3.py index 34be61b..bab1b86 100644 --- a/backend/app/schemas/phase3.py +++ b/backend/app/schemas/phase3.py @@ -1,6 +1,6 @@ """Schémas Phase 3 — Gestion.""" from pydantic import BaseModel -from datetime import date, datetime +from datetime import date, datetime, time from uuid import UUID from typing import Optional @@ -29,7 +29,7 @@ class GroupeUpdate(BaseModel): nom: Optional[str] = None adresse_local: Optional[str] = None jour_reunion: Optional[str] = None - heure_reunion: Optional[str] = None + heure_reunion: Optional[str] = None # frontend envoie "HH:MM", parsé dans parametres.py format_reunion: Optional[str] = None lien_aa87: Optional[str] = None @@ -41,7 +41,7 @@ class GroupeDetail(BaseModel): region: str adresse_local: Optional[str] = None jour_reunion: Optional[str] = None - heure_reunion: Optional[str] = None + heure_reunion: Optional[time] = None # sérialisé "HH:MM:SS" par pydantic format_reunion: Optional[str] = None lien_aa87: str cree_le: datetime diff --git a/backend/app/schemas/phase6.py b/backend/app/schemas/phase6.py index fcef551..e8514af 100644 --- a/backend/app/schemas/phase6.py +++ b/backend/app/schemas/phase6.py @@ -56,6 +56,7 @@ class VenteOut(BaseModel): montant: float date_vente: date vendu_par_prenom: Optional[str] = None + remis_tresorier: bool = False cree_le: datetime model_config = {"from_attributes": True} diff --git a/backend/app/schemas/phase7.py b/backend/app/schemas/phase7.py index ba6df1b..e9502f7 100644 --- a/backend/app/schemas/phase7.py +++ b/backend/app/schemas/phase7.py @@ -21,6 +21,7 @@ class EnvoiContributionCreate(BaseModel): destination: str # district, region, bsg, intergroupe montant: float methode: str # DU, VIREMENT, CHEQUE + resolution_id: UUID # résolution adoptée qui autorise l'envoi class EnvoiContributionOut(BaseModel): @@ -30,6 +31,9 @@ class EnvoiContributionOut(BaseModel): destination_label: str montant: float methode: str + resolution_id: Optional[UUID] = None + engagee: bool = False + debitee: bool = False saisi_par_prenom: Optional[str] = None cree_le: datetime diff --git a/backend/app/schemas/propositions.py b/backend/app/schemas/propositions.py new file mode 100644 index 0000000..c176206 --- /dev/null +++ b/backend/app/schemas/propositions.py @@ -0,0 +1,37 @@ +"""Schémas — Propositions et résolutions.""" +from pydantic import BaseModel +from datetime import date, datetime +from uuid import UUID +from typing import Optional + + +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" + sujet: str + description: Optional[str] = None + proposeur_prenom: str + secondeur_prenom: Optional[str] = None + statut: str + numero_resolution: Optional[str] = None + date_vote: Optional[date] = None + note: Optional[str] = None + candidature_id: Optional[UUID] = None + cree_le: datetime + date_reunion: Optional[datetime] = None + + model_config = {"from_attributes": True} + + +class PropositionVote(BaseModel): + statut: str # adoptee ou rejetee + note: Optional[str] = None + reunion_id: Optional[UUID] = None diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 32c7145..3dd6d33 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -7,18 +7,19 @@ from typing import Optional # ── Auth ── class ConnexionRequest(BaseModel): - prenom: str + courriel: str pin: str groupe_id: UUID + se_souvenir: bool = False class InscriptionRequest(BaseModel): prenom: str - nom: str + nom: Optional[str] = None pin: str code_invitation: str - telephone: Optional[str] = None - courriel: Optional[str] = None + telephone: str + courriel: str date_abstinence: Optional[date] = None @@ -32,6 +33,7 @@ class TokenResponse(BaseModel): class MembrePublic(BaseModel): id: UUID prenom: str + courriel: str groupe_id: UUID date_abstinence: Optional[date] = None @@ -39,9 +41,8 @@ class MembrePublic(BaseModel): class MembreComplet(MembrePublic): - nom: str - telephone: Optional[str] = None - courriel: Optional[str] = None + nom: Optional[str] = None + telephone: str actif: bool cree_le: datetime @@ -49,6 +50,8 @@ class MembreComplet(MembrePublic): class MembreUpdate(BaseModel): + prenom: Optional[str] = None + nom: Optional[str] = None telephone: Optional[str] = None courriel: Optional[str] = None date_abstinence: Optional[date] = None @@ -60,12 +63,39 @@ class ChangerPinRequest(BaseModel): # ── Poste / Affectation ── +class PosteCreate(BaseModel): + nom: str + categorie: str # executif, service, physique + description: Optional[str] = None + prerequis: Optional[str] = None + informations: Optional[str] = None + ordre_affichage: Optional[int] = None + + class PosteOut(BaseModel): id: UUID nom: str categorie: str description: Optional[str] = None + prerequis: Optional[str] = None + informations: Optional[str] = None ordre_affichage: Optional[int] = None + actif: bool = True + taches: list[dict] = [] + + model_config = {"from_attributes": True} + + +class TacheCreate(BaseModel): + nom: str + informations: Optional[str] = None + + +class TacheOut(BaseModel): + id: UUID + nom: str + informations: Optional[str] = None + postes: list[str] = [] # noms des postes associés model_config = {"from_attributes": True} @@ -109,6 +139,7 @@ class AccueilResponse(BaseModel): modules_consultation: list[ModuleTuile] modules_saisie: list[ModuleTuile] modules_gestion: list[ModuleTuile] + groupe: Optional[dict] = None # ── Invitation ── diff --git a/backend/requirements.txt b/backend/requirements.txt index 8479f9e..5f775a7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,5 +5,7 @@ asyncpg==0.30.0 pydantic-settings==2.7.1 python-jose[cryptography]==3.3.0 bcrypt==4.2.1 +python-multipart==0.0.18 weasyprint==62.3 +pydyf>=0.8.0,<0.11.0 pywebpush==2.0.1 diff --git a/deploy/backup.sh b/deploy/backup.sh index ff9a6c8..f242d34 100644 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -11,7 +11,7 @@ DATE=$(date +%Y-%m-%d_%H%M) mkdir -p "$BACKUP_DIR" # Dump -pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump" +pg_dump -U "$DB_USER" -h 127.0.0.1 -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump" # Nettoyage des anciennes sauvegardes find "$BACKUP_DIR" -name "grpmed_*.dump" -mtime +$RETENTION_DAYS -delete diff --git a/deploy/nginx-groupe-meditation.conf b/deploy/nginx-groupe-meditation.conf index 8740d2f..7d2a5b5 100644 --- a/deploy/nginx-groupe-meditation.conf +++ b/deploy/nginx-groupe-meditation.conf @@ -5,11 +5,11 @@ # sudo cp deploy/nginx-groupe-meditation.conf /etc/nginx/sites-available/groupe-meditation # sudo ln -s /etc/nginx/sites-available/groupe-meditation /etc/nginx/sites-enabled/ # sudo nginx -t && sudo systemctl reload nginx -# sudo certbot --nginx -d groupe-meditation.chezlepro.ca +# sudo certbot --nginx -d groupe-meditation.87-16.org server { listen 80; - server_name groupe-meditation.chezlepro.ca; + server_name groupe-meditation.87-16.org; # ── Headers de sécurité ── add_header X-Content-Type-Options nosniff always; diff --git a/deploy/seed.py b/deploy/seed.py index 73b128f..5150670 100644 --- a/deploy/seed.py +++ b/deploy/seed.py @@ -1,7 +1,7 @@ """ Script d'initialisation — Groupe Méditation Crée les postes, le groupe initial et le premier membre. -Usage: python deploy/seed.py +Usage: cd /opt/groupe-meditation/backend && venv/bin/python ../deploy/seed.py """ import asyncio import sys @@ -39,6 +39,11 @@ from app.models.option_sondage import OptionSondage from app.models.vote import Vote from app.models.jumelage import Jumelage from app.models.sync_log import SyncLog +from app.models.proposition import Proposition +from app.models.operation_bancaire import OperationBancaire +from app.models.evenement import Evenement +from app.models.candidature import Candidature +from app.models.tache import Tache, PosteTache POSTES = [ @@ -68,7 +73,7 @@ async def seed(): district="87-16", region="87", adresse_local="", - jour_reunion="lundi", + jour_reunion="mardi", heure_reunion=None, format_reunion="Discussion ouverte", lien_aa87="https://aa87.org", @@ -95,7 +100,14 @@ async def seed(): # ── Premier membre ── print("\n── Premier membre (vous) ──") prenom = input(" Prénom: ").strip().title() - nom = input(" Nom: ").strip().title() + 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: + courriel = input(" Courriel invalide. Réessayer: ").strip().lower() + telephone = input(" Téléphone: ").strip() + while not telephone: + telephone = input(" Téléphone obligatoire: ").strip() pin = input(" PIN (4 chiffres): ").strip() while not pin.isdigit() or len(pin) != 4: pin = input(" PIN invalide. 4 chiffres: ").strip() @@ -110,7 +122,9 @@ async def seed(): membre = Membre( groupe_id=groupe.id, prenom=prenom, - nom=nom, + nom=nom_membre, + courriel=courriel, + telephone=telephone, pin_hash=hash_pin(pin), ) db.add(membre) @@ -136,18 +150,8 @@ async def seed(): ) db.add(invitation) - # ── Répartition par défaut (30/30/30/10) ── - for dest, pct in [("district", 30), ("region", 30), ("bsg", 30), ("intergroupe", 10)]: - db.add(ConfigRepartition( - groupe_id=groupe.id, - destination=dest, - pourcentage=pct, - actif_depuis=date.today(), - )) - print(" ✓ Répartition contributions : 30/30/30/10") - # ── Littérature de base ── - for titre, cat, prix, code in [ + for titre, cat, prix, code_bsg in [ ("Les Alcooliques anonymes (Gros Livre)", "livre", 12.00, "B-1"), ("Les Douze Étapes et les Douze Traditions", "livre", 11.00, "B-2"), ("Vivre sans alcool", "livre", 9.00, "B-7"), @@ -161,20 +165,20 @@ async def seed(): prix_coutant=prix, quantite_stock=0, seuil_commande=2, - code_bsg=code, + code_bsg=code_bsg, )) print(" ✓ Littérature de base : 5 titres") await db.commit() print(f"\n═══ Initialisation terminée ═══") - print(f" Membre: {prenom} ({nom})") + print(f" Membre: {prenom} ({courriel})") print(f" Groupe: Groupe Méditation") print(f" Poste: {poste_choisi}") 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:") - print(f" Prénom: {prenom}") + print(f" Courriel: {courriel}") print(f" PIN: {pin}") diff --git a/docs/DEPLOIEMENT.md b/docs/DEPLOIEMENT.md index 043dc47..5692308 100644 --- a/docs/DEPLOIEMENT.md +++ b/docs/DEPLOIEMENT.md @@ -1,6 +1,6 @@ # Guide de déploiement — Groupe Méditation -## 27 modules · Debian Bookworm + NGINX + PostgreSQL +## Debian Bookworm + NGINX + PostgreSQL Testé sur Debian 12 (Bookworm) avec un utilisateur `ansible` ayant accès sudo. @@ -11,6 +11,9 @@ 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 ``` @@ -39,10 +42,10 @@ GRANT ALL ON SCHEMA public TO grpmed; SQL ``` -Vérifier que la connexion fonctionne avec le mot de passe choisi : +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 localhost -d groupe_meditation -c "SELECT 1;" +PGPASSWORD='votre_mot_de_passe_ici' psql -U grpmed -h 127.0.0.1 -d groupe_meditation -c "SELECT 1;" ``` --- @@ -91,7 +94,7 @@ sudo systemctl start nginx ```bash # Copier l'archive sur le serveur, puis : cd ~ -tar xzf groupe-meditation-complet.tar.gz +unzip groupe-meditation-v12.zip # Backend sudo mkdir -p /opt/groupe-meditation @@ -122,20 +125,27 @@ sudo cp .env.example .env sudo nano .env ``` -Modifier `.env` avec le **même mot de passe** que l'étape 1 : +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@localhost:5432/groupe_meditation +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.chezlepro.ca"] +CORS_ORIGINS=["https://groupe-meditation.87-16.org"] INVITATION_EXPIRE_HOURS=48 ``` -**Important :** le mot de passe dans `DATABASE_URL` doit être identique à celui de l'étape 1. Si les deux ne correspondent pas, le seed et l'API échoueront avec `InvalidPasswordError`. +**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 (23 tables) +## 7. Initialiser la base de données **Le seed doit être lancé depuis le dossier `backend/`** pour que le fichier `.env` soit trouvé : @@ -147,15 +157,95 @@ cd /opt/groupe-meditation/backend 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 23 tables +- Les tables (23+) - Les 10 postes prédéfinis -- La répartition par défaut des contributions (30/30/30/10) - 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 @@ -199,8 +289,6 @@ NGINX fait tout : fichiers statiques du frontend + reverse proxy vers FastAPI. 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/ - -# Retirer le site par défaut si présent sudo rm -f /etc/nginx/sites-enabled/default sudo nginx -t @@ -210,7 +298,7 @@ sudo systemctl reload nginx Tester en HTTP : ```bash -curl -s http://groupe-meditation.chezlepro.ca/api/health +curl -s http://groupe-meditation.87-16.org/api/health ``` --- @@ -219,7 +307,7 @@ curl -s http://groupe-meditation.chezlepro.ca/api/health ```bash sudo apt install -y certbot python3-certbot-nginx -sudo certbot --nginx -d groupe-meditation.chezlepro.ca +sudo certbot --nginx -d groupe-meditation.87-16.org ``` Certbot modifie automatiquement la config NGINX pour ajouter HTTPS et la redirection 80→443. @@ -238,39 +326,41 @@ sudo chmod +x /etc/cron.daily/groupe-meditation-backup ## 13. Vérification finale ```bash -# API en HTTPS -curl -s https://groupe-meditation.chezlepro.ca/api/health | python3 -m json.tool +# API +curl -s https://groupe-meditation.87-16.org/api/health | python3 -m json.tool # Documentation OpenAPI -# Ouvrir https://groupe-meditation.chezlepro.ca/api/docs +# https://groupe-meditation.87-16.org/api/docs # Frontend -# Ouvrir https://groupe-meditation.chezlepro.ca -# Se connecter avec le prénom et le PIN créés lors du seed +# https://groupe-meditation.87-16.org ``` --- -## Dépannage - -| Problème | Commande | -|----------|----------| -| seed.py InvalidPasswordError | Vérifier que le seed est lancé **depuis `backend/`** et que le `.env` contient le bon mot de passe | -| API ne démarre pas | `sudo journalctl -u groupe-meditation -f` | -| Erreur base de données | `sudo -u postgres psql -d groupe_meditation -c "SELECT count(*) FROM groupes;"` | -| NGINX 502 | `sudo systemctl status groupe-meditation` | -| WeasyPrint échoue | `sudo apt install libpango-1.0-0 libpangocairo-1.0-0` | -| seed.py ImportError | Vérifier les noms de classes dans `backend/app/models/` | - ## Mise à jour de l'application ```bash -# Backend +# 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 -# Frontend +# 6. Frontend cd ~/groupe-meditation/frontend npm install && npm run build sudo cp -r dist/* /var/www/groupe-meditation/ @@ -278,4 +368,22 @@ sudo cp -r dist/* /var/www/groupe-meditation/ --- -**Hébergé au Québec · 100% logiciel libre · Zéro traçage** +## 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** diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 75c1274..5712454 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -3,53 +3,144 @@ ## Opérations courantes ### Inviter un nouveau membre -1. Un membre de l'exécutif ouvre le module **G1 — Inviter** +1. Un membre de l'exécutif ouvre **G1 — Inviter** 2. Un code à 12 caractères est généré (expire 48h) 3. Le code est partagé en personne ou par SMS 4. Le nouveau membre ouvre l'app → Inscription → entre le code + prénom + nom + PIN +> **Note :** le seed crée le premier code d'invitation avec une expiration de 7 jours. + ### Affecter un poste 1. Exécutif ouvre **G3 — Affecter les postes** -2. Sélectionne le membre et le poste -3. Le membre voit immédiatement ses nouvelles tuiles sur l'accueil +2. Seuls les **postes vacants** apparaissent dans le menu déroulant +3. Si tous les postes sont pourvus, un message indique de terminer un mandat d'abord (G4) +4. Sélectionne le membre et le poste → le membre voit ses nouvelles tuiles immédiatement ### Terminer un mandat 1. Exécutif ouvre **G4 — Terminer un mandat** -2. Sélectionne l'affectation active -3. Saisit une note de transition -4. Les permissions sont retirées instantanément -5. **Rappel** : les mandats ne se terminent jamais automatiquement - -### Saisie hebdomadaire typique (pendant/après la réunion) -1. **Trésorier** : S1 (collecte) + S5 (dépenses si applicable) -2. **Animateur** : S4 (thème + annonces) -3. **Resp. jetons** : S2 (si anniversaire ou nouveau) -4. **Resp. littérature** : S3 (si vente) - -### Réunion d'affaires mensuelle -1. **Secrétaire** : M2 (présences) → M1 (PV avec décisions) -2. **RSG** : M3 (rapport du district) -3. **Trésorier** : présente le rapport financier (C1) -4. Si vote nécessaire : **G6** (créer sondage) → **C6** (voter) - -### Envoi trimestriel des contributions -1. **Trésorier** ouvre **M4 — Envoi contributions** -2. Mode « Calculer » : entre le surplus disponible → répartition auto -3. Mode « Enregistrer » : enregistre chaque envoi individuel -4. La config des % se fait dans **G8** (défaut 30/30/30/10) +2. Sélectionne l'affectation active, saisit une note de transition +3. Les permissions sont retirées instantanément et le poste redevient disponible +4. **Rappel** : les mandats ne se terminent jamais automatiquement --- -## Gestion des rapports PDF +## Saisie hebdomadaire (pendant/après la réunion) -Les rapports sont disponibles dans **C1 — Rapports PDF** : +### Collecte 7e Tradition (S1) +1. Le jour de la réunion, à partir de l'heure prévue, le trésorier ouvre **S1** +2. La date est renseignée automatiquement +3. Saisir le montant et indiquer qui a l'argent ce soir +4. Un second membre confirme le montant (le même membre ne peut pas saisir et confirmer) +5. L'onglet **mensuel** affiche toutes les collectes du mois avec date, montant, qui a saisi et qui détient l'argent + +### Autres saisies hebdomadaires +- **Animateur** : S4 (thème + annonces) +- **Resp. jetons** : S2 (si anniversaire ou nouveau) +- **Resp. littérature** : S3 (si vente — le montant sera visible dans la trésorerie pour confirmation) + +--- + +## Trésorerie (S1T) + +Le trésorier dispose de quatre onglets. + +### 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 +- **Encaisse** : cash reçu mais pas encore déposé à la banque +- **Réserves** : prudente (500 $) + anniversaire (+20 $/mois depuis la création du groupe) +- **En circulation** : collectes pas encore remises au trésorier + +### À recevoir +Deux catégories : +1. **Collectes** non encore reçues par le trésorier — bouton « Accuser réception » +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. + +### 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) + +### 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 + +--- + +## Dépenses (S5) + +### Saisir une dépense +Trois onglets : Saisir, Engagements, Historique. +- La dépense est créée avec `payé = Non` par défaut (engagement) +- Les méthodes chèque/virement créent un engagement visible dans l'onglet « Engagements » +- Pour les dépenses non courantes payées par chèque/virement, une résolution est attendue + +### Confirmer un débit +Lorsque l'argent quitte effectivement le compte : +1. Trésorier ouvre S5 → onglet **Engagements** +2. Clique **Confirmer le débit** sur chaque dépense débitée +3. L'engagement disparaît et le solde disponible est mis à jour + +--- + +## Propositions et résolutions (C8) + +### 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 + +### 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. + +--- + +## Réunion d'affaires mensuelle + +### Avant la réunion +- Les membres soumettent leurs propositions via C8 (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) + +### Après la réunion +Le secrétaire finalise le PV avec les décisions votées. + +--- + +## Envoi des contributions (M4) + +1. Le trésorier ouvre **M4 — Envoi contributions** +2. Le montant disponible (après réserves) est affiché et pré-rempli +3. Sélectionner le destinataire (district par défaut) et la méthode (virement/chèque) +4. Enregistrer l'envoi + +--- + +## Gestion des rapports PDF (C1) | Rapport | URL API | Contenu | |---------|---------|---------| | Financier mensuel | `/api/rapports/finances/{annee}/{mois}` | Collectes + dépenses + solde | -| Réunions du mois | `/api/rapports/reunions/{annee}/{mois}` | Liste des réunions + thèmes | +| 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. + --- ## Surveillance @@ -57,30 +148,16 @@ Les rapports sont disponibles dans **C1 — Rapports PDF** : ### Vérifier que l'application tourne ```bash -# Service FastAPI sudo systemctl status groupe-meditation - -# Test santé API -curl -s https://groupe-meditation.chezlepro.ca/api/health - -# Logs temps réel +curl -s https://groupe-meditation.87-16.org/api/health sudo journalctl -u groupe-meditation -f --no-pager - -# NGINX -sudo systemctl status nginx -sudo tail -f /var/log/nginx/error.log +sudo tail -f /var/log/nginx/groupe-meditation-error.log ``` ### Vérifier les sauvegardes ```bash -# Dernière sauvegarde ls -la /var/backups/groupe-meditation/ | tail -5 - -# Tester une restauration (sur une base test) -createdb -U grpmed groupe_meditation_test -pg_restore -U grpmed -d groupe_meditation_test /var/backups/groupe-meditation/grpmed_DERNIER.dump -dropdb -U grpmed groupe_meditation_test ``` ### Statistiques base de données @@ -106,50 +183,52 @@ sudo journalctl -u groupe-meditation --since "5 minutes ago" ### Un membre a oublié son PIN -Pas de récupération en libre-service (par design — sécurité). L'exécutif doit : -1. Utiliser **G2 — Gérer les membres** pour désactiver le membre -2. Recréer une invitation **G1** +Pas de récupération en libre-service (par design). L'exécutif doit : +1. Utiliser **G2** pour désactiver le membre +2. Créer une nouvelle invitation **G1** 3. Le membre se réinscrit avec un nouveau PIN -### Droit à l'oubli (RGPD/Loi 25) +### Droit à l'oubli 1. Exécutif ouvre **G2 — Gérer les membres** 2. Sélectionne le membre → **Droit à l'oubli** -3. Le système supprime les données personnelles -4. Les données historiques (collectes, PV) sont anonymisées (prénom remplacé) +3. Les données personnelles sont supprimées, les données historiques anonymisées ### Restaurer la base après incident ```bash sudo systemctl stop groupe-meditation - -# Restaurer la dernière sauvegarde sudo -u postgres dropdb groupe_meditation sudo -u postgres createdb -O grpmed groupe_meditation -pg_restore -U grpmed -d groupe_meditation /var/backups/groupe-meditation/grpmed_DERNIER.dump - +sudo -u postgres psql -d groupe_meditation -c "GRANT ALL ON SCHEMA public TO grpmed;" +pg_restore -U grpmed -h 127.0.0.1 -d groupe_meditation \ + /var/backups/groupe-meditation/grpmed_DERNIER.dump sudo systemctl start groupe-meditation ``` --- -## Matrice des 27 modules par rôle +## Matrice des modules par rôle -| Module | Trésorier | Secrétaire | RSG | Animateur | Litt. | Jetons | Tous | -|--------|:---------:|:----------:|:---:|:---------:|:-----:|:------:|:----:| -| C1-C7 Consultation | | | | | | | ✓ | -| S1 Collecte | ✓ | | | | | | | -| S2 Remise jeton | | | | | | ✓ | | -| S3 Vente litt. | | | | | ✓ | | | -| S4 Rapport réunion | | | | ✓ | | | | -| S5 Dépense | ✓ | | | | | | | -| M1 PV | | ✓ | | | | | | -| M2 Présences | | ✓ | | | | | | -| M3 Rapport RSG | | | ✓ | | | | | -| M4 Contributions | ✓ | | | | | | | -| M5 Inventaire | | | | | ✓ | | | -| M6 Commander jetons | | | | | | ✓ | | -| G1-G9 Gestion | ✓ | ✓ | ✓ | | | | | +| Module | RSG | RSG sub | Trés. | Secr. | Anim. | Litt. | Jetons | Tous | +|--------|:---:|:-------:|:-----:|:-----:|:-----:|:-----:|:------:|:----:| +| C1-C9 Consultation | | | | | | | | ✓ | +| S1 Collecte | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| S1T Trésorerie | | | ✓ | | | | | | +| S2 Remise jeton | | | | | | | ✓ | | +| S3 Vente litt. | | | | | | ✓ | | | +| S4 Rapport réunion | | | | | ✓ | | | | +| S5 Dépense | | | ✓ | | | | | | +| M1 PV | | | | ✓ | | | | | +| M2 Présences | | | | ✓ | | | | | +| M3 Rapport RSG | ✓ | ✓ | | | | | | | +| M4 Contributions | | | ✓ | | | | | | +| M5 Inventaire | | | | | | ✓ | | | +| M6 Commander jetons | | | | | | | ✓ | | +| G1-G9 Gestion | ✓ | ✓ | ✓ | ✓ | | | | | + +> Le RSG substitut a exactement les mêmes accès que le RSG — continuité assurée. +> Un membre peut cumuler plusieurs postes. L'écran affiche l'union de tous ses modules. --- @@ -157,7 +236,7 @@ sudo systemctl start groupe-meditation - **aa87.org** — Site de la Région 87 - **BSG** — Bureau des Services généraux (aa.org) -- Documentation API : `https://groupe-meditation.chezlepro.ca/api/docs` +- Documentation API : `https://groupe-meditation.87-16.org/api/docs` --- diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 43b5c4b..93ecae7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,7 +8,7 @@ import Collecte from './pages/Collecte'; import RapportReunion from './pages/RapportReunion'; import Depense from './pages/Depense'; import Decisions from './pages/Decisions'; -import Calendrier from './pages/Calendrier'; +import Evenements from './pages/Evenements'; import Rapports from './pages/Rapports'; import InviterMembre from './pages/InviterMembre'; import GererMembres from './pages/GererMembres'; @@ -20,7 +20,6 @@ import Presences from './pages/Presences'; import RedigerPV from './pages/RedigerPV'; import RapportRSG from './pages/RapportRSG'; import RemiseJeton from './pages/RemiseJeton'; -import Anniversaires from './pages/Anniversaires'; import CommanderJetons from './pages/CommanderJetons'; import VenteLitterature from './pages/VenteLitterature'; import Inventaire from './pages/Inventaire'; @@ -30,6 +29,11 @@ import ConfigRepartition from './pages/ConfigRepartition'; import LancerSondage from './pages/LancerSondage'; import VotesOuverts from './pages/VotesOuverts'; import JumelerParrain from './pages/JumelerParrain'; +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'; export default function App() { const { token, page, loadAccueil, loadGroupes } = useStore(); @@ -38,8 +42,15 @@ export default function App() { loadGroupes(); if (token) { loadAccueil().then(() => { - useStore.setState({ page: 'accueil' }); + // Restaurer la page sauvegardée ou aller à l'accueil + const savedPage = localStorage.getItem('gm_page'); + if (savedPage && savedPage !== 'login' && savedPage !== 'inscription') { + useStore.setState({ page: savedPage }); + } else { + useStore.setState({ page: 'accueil' }); + } }).catch(() => { + localStorage.removeItem('gm_page'); useStore.setState({ page: 'login' }); }); } @@ -51,10 +62,11 @@ export default function App() { 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 'C2': return ; case 'C5': return ; case 'G1': return ; case 'G2': return ; @@ -62,11 +74,11 @@ export default function App() { case 'G4': return ; case 'G5': return ; case 'G9': return ; + case 'G10': return ; case 'M1': return ; case 'M2': return ; case 'M3': return ; case 'S2': return ; - case 'C3': return ; case 'M6': return ; case 'S3': return ; case 'C4': return ; @@ -76,6 +88,9 @@ export default function App() { case 'G6': return ; case 'C6': return ; case 'G7': return ; + case 'C8': return ; + case 'C9': return ; + case 'C10': return ; default: return ; } } diff --git a/frontend/src/pages/Accueil.jsx b/frontend/src/pages/Accueil.jsx index 807cf7a..e8c2d7d 100644 --- a/frontend/src/pages/Accueil.jsx +++ b/frontend/src/pages/Accueil.jsx @@ -39,7 +39,7 @@ function Section({ titre, modules }) {
{modules.map((m) => ( { - const navigable = ['C7', 'S1', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7', 'G8', 'G9', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6']; + const navigable = ['C7', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C6', 'C8', 'C9', 'C10', 'G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7', 'G9', 'G10', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6']; if (m.code === 'C7') useStore.setState({ page: 'profil' }); else if (navigable.includes(m.code)) useStore.setState({ page: m.code }); else alert(`Module ${m.code} — ${m.nom}\n\n(Sera disponible dans les prochaines phases)`); diff --git a/frontend/src/pages/AffecterPostes.jsx b/frontend/src/pages/AffecterPostes.jsx index 6669e4e..3b2d3e4 100644 --- a/frontend/src/pages/AffecterPostes.jsx +++ b/frontend/src/pages/AffecterPostes.jsx @@ -43,6 +43,10 @@ export default function AffecterPostes() { const CATEGORIES = { executif: '🔵', service: '🟣', physique: '🟢' }; + // Postes vacants = postes actifs sans affectation active dans le groupe + const postesOccupes = new Set(affectations.map((a) => a.poste_nom)); + const postesVacants = postes.filter((p) => !postesOccupes.has(p.nom)); + return (
@@ -69,25 +73,33 @@ export default function AffecterPostes() {

Nouvelle affectation

-
- - -
+ {postesVacants.length === 0 ? ( +
+

Tous les postes sont pourvus. Terminez d'abord un mandat (G4) pour libérer un poste.

+
+ ) : ( + <> +
+ + +
-
- - -
+
+ + +
+ + )}
diff --git a/frontend/src/pages/Collecte.jsx b/frontend/src/pages/Collecte.jsx index 39878a2..39fa31c 100644 --- a/frontend/src/pages/Collecte.jsx +++ b/frontend/src/pages/Collecte.jsx @@ -1,81 +1,228 @@ -import React, { useState } from 'react'; +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'; + +const STATUT = { + en_attente: { label: 'À confirmer', cls: 'bg-yellow-100 text-yellow-700' }, + confirmee: { label: 'Confirmée', cls: 'bg-blue-100 text-blue-700' }, + recu: { label: '✓ Reçue', cls: 'bg-green-100 text-green-700' }, +}; export default function Collecte() { + const { membre, groupe } = useStore(); const [montant, setMontant] = useState(''); const [dateCollecte, setDateCollecte] = useState(new Date().toISOString().slice(0, 10)); + const [detenteurId, setDetenteurId] = useState(''); + const [membres, setMembres] = useState([]); + const [collectesMois, setCollectesMois] = useState([]); + const [tab, setTab] = useState('mois'); const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); + const [msg, setMsg] = useState(''); const [error, setError] = useState(''); + const aujourdhui = new Date(); + const mois = aujourdhui.getMonth() + 1; + const annee = aujourdhui.getFullYear(); + + const charger = async () => { + try { + const [mbrs, cols] = await Promise.all([ + api.get('/membres/actifs'), + api.get(`/collectes/?mois=${mois}&annee=${annee}&limite=50`), + ]); + setMembres(mbrs); + setCollectesMois(cols); + if (membre && !detenteurId) setDetenteurId(membre.id); + } catch (e) { setError(e.message); } + }; + + useEffect(() => { charger(); }, []); + + const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); }; + const handleSubmit = async () => { - if (!montant || parseFloat(montant) <= 0) { - setError('Entrez un montant valide'); - return; - } - setLoading(true); - setError(''); + if (!montant || parseFloat(montant) <= 0) { setError('Entrez un montant valide'); return; } + if (!detenteurId) { setError("Sélectionnez qui a l'argent"); return; } + if (!dateCollecte) { setError("Sélectionnez une date"); return; } + setLoading(true); setError(''); try { await api.post('/collectes/', { date_collecte: dateCollecte, montant: parseFloat(montant), + detenteur_id: detenteurId, }); - setSuccess(true); - } catch (e) { - setError(e.message); - setLoading(false); - } + setMontant(''); + flash('Collecte enregistrée ✓'); + setTab('mois'); + await charger(); + } catch (e) { setError(e.message); } + setLoading(false); }; - if (success) return ; + const confirmer = async (id) => { + setError(''); + try { + await api.post(`/collectes/${id}/confirmer`); + flash('Montant confirmé ✓'); + await charger(); + } catch (e) { setError(e.message); } + }; + + const totalMois = collectesMois.reduce((sum, c) => sum + parseFloat(c.montant), 0); + const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', + 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']; + + // Calculer les dernières dates de réunion pour le sélecteur + const joursSemaine = { 'lundi': 1, 'mardi': 2, 'mercredi': 3, 'jeudi': 4, 'vendredi': 5, 'samedi': 6, 'dimanche': 0 }; + const jourReunion = groupe?.jour_reunion ? joursSemaine[groupe.jour_reunion.toLowerCase()] : null; + + const getDatesReunion = () => { + if (jourReunion === null) return []; + const dates = []; + const d = new Date(); + for (let i = 0; i < 12; i++) { + const test = new Date(d); + test.setDate(d.getDate() - i); + if (test.getDay() === jourReunion) { + dates.push(test.toISOString().slice(0, 10)); + } + } + // Aussi vérifier les 30 jours suivants dans le passé + for (let i = 12; i < 60; i++) { + const test = new Date(d); + test.setDate(d.getDate() - i); + if (test.getDay() === jourReunion) { + dates.push(test.toISOString().slice(0, 10)); + } + } + return dates; + }; + + const datesReunion = getDatesReunion(); return (
- +
- {/* Date pré-remplie */} -
- - setDateCollecte(e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" - /> + {msg &&
{msg}
} + {error &&
{error}
} + +
+ +
- {/* Montant — gros clavier numérique */} -
- -
- $ - setMontant(e.target.value)} - className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-3xl font-bold text-center border-0 focus:ring-2 focus:ring-green-500" - autoFocus - /> + {tab === 'mois' && ( + <> +
+
+
+

Total {MOIS_NOMS[mois]} {annee}

+

{totalMois.toFixed(2)} $

+
+
+

{collectesMois.length} collecte{collectesMois.length > 1 ? 's' : ''}

+
+
+
+ + {collectesMois.length === 0 ? ( +
+

💰

+

Aucune collecte ce mois-ci

+
+ ) : ( +
+ {collectesMois.map((c) => { + const st = STATUT[c.statut] || STATUT.en_attente; + const peutConfirmer = c.statut === 'en_attente' && c.saisi_par_prenom !== membre?.prenom; + return ( +
+
+
+

{parseFloat(c.montant).toFixed(2)} $

+

📅 {c.date_collecte}

+

+ Saisi par {c.saisi_par_prenom} + {' · '}Chez {c.detenteur_prenom} +

+ {c.confirme_par_prenom &&

Confirmé par {c.confirme_par_prenom}

} + {c.recu_par_prenom &&

Reçu par {c.recu_par_prenom}

} +
+ {st.label} +
+ {peutConfirmer && ( + + )} +
+ ); + })} +
+ )} + + )} + + {tab === 'saisie' && ( +
+
+ + {datesReunion.length > 0 ? ( + + ) : ( + setDateCollecte(e.target.value)} + max={new Date().toISOString().slice(0, 10)} + className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" /> + )} +

Saisie rétroactive permise aux dates de réunion

+
+ +
+ +
+ $ + setMontant(e.target.value)} + className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-3xl font-bold text-center border-0 focus:ring-2 focus:ring-green-500" + autoFocus /> +
+
+ +
+ + +
+ +
-
- - {error &&

{error}

} - - {/* Bouton enregistrer */} - + )}
); diff --git a/frontend/src/pages/Depense.jsx b/frontend/src/pages/Depense.jsx index 84919b8..d6cc9de 100644 --- a/frontend/src/pages/Depense.jsx +++ b/frontend/src/pages/Depense.jsx @@ -1,178 +1,273 @@ 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'; const METHODES = [ - { value: 'DU', label: 'DÛ', emoji: '📋' }, - { value: 'VIREMENT', label: 'Virement', emoji: '🏦' }, + { value: 'COLLECTE', label: 'Cash', emoji: '💰' }, { value: 'CHEQUE', label: 'Chèque', emoji: '📝' }, - { value: 'COLLECTE', label: 'Collecte', emoji: '💰' }, + { value: 'VIREMENT', label: 'Virement', emoji: '🏦' }, + { value: 'DU', label: 'DÛ', emoji: '📋' }, ]; export default function Depense() { + const { postes } = useStore(); const [form, setForm] = useState({ date_depense: new Date().toISOString().slice(0, 10), - montant: '', - qui: '', - quoi: '', - paye: true, - methode: '', + montant: '', quoi: '', }); - const [membres, setMembres] = useState([]); + const [fichier, setFichier] = useState(null); + const [depenses, setDepenses] = useState([]); + const [aConfirmer, setAConfirmer] = useState([]); + const [tab, setTab] = useState('saisie'); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); + const [msg, setMsg] = useState(''); const [error, setError] = useState(''); - useEffect(() => { - // Charger la liste des membres pour le dropdown "Qui" - api.get('/membres/actifs').then(setMembres).catch(() => setMembres([])); - }, []); + const estTresorier = postes.some((p) => + ['Trésorier(ière)', 'RSG', 'RSG Substitut(e)', 'Secrétaire'].includes(p) + ); + const charger = async () => { + try { + const [d] = await Promise.all([ + api.get('/depenses/?limite=30'), + ]); + setDepenses(d); + if (estTresorier) { + const ac = await api.get('/depenses/a-confirmer'); + setAConfirmer(ac); + } + } catch (e) { setError(e.message); } + }; + + useEffect(() => { charger(); }, []); + + const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); }; const update = (k, v) => setForm({ ...form, [k]: v }); const handleSubmit = async () => { - if (!form.montant || !form.qui || !form.quoi.trim() || !form.methode) { - setError('Tous les champs sont obligatoires'); - return; + if (!form.montant || !form.quoi.trim()) { + setError('Montant et description obligatoires'); return; } - setLoading(true); - setError(''); + setLoading(true); setError(''); try { - await api.post('/depenses/', { + const result = await api.post('/depenses/', { date_depense: form.date_depense, montant: parseFloat(form.montant), - qui: form.qui, quoi: form.quoi.trim(), - paye: form.paye, - methode: form.methode, }); + // Upload preuve si présente + if (fichier && result.id) { + const formData = new FormData(); + formData.append('fichier', fichier); + await fetch(`/api/depenses/${result.id}/preuve`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${localStorage.getItem('gm_token')}` }, + body: formData, + }); + } setSuccess(true); - } catch (e) { - setError(e.message); - setLoading(false); - } + } catch (e) { setError(e.message); setLoading(false); } }; - if (success) return ; + const confirmerDepense = async (id, methode) => { + setError(''); + try { + await api.post(`/depenses/${id}/confirmer?methode=${methode}`); + flash('Dépense confirmée ✓'); + await charger(); + } catch (e) { setError(e.message); } + }; + + const confirmerDebit = async (id) => { + setError(''); + try { + await api.post(`/depenses/${id}/confirmer-debit`); + flash('Débit confirmé ✓'); + await charger(); + } catch (e) { setError(e.message); } + }; + + if (success) return ; + + const engagements = depenses.filter((d) => d.confirme && !d.paye && ['VIREMENT', 'CHEQUE'].includes(d.methode)); return (
- +
- {/* Date */} -
- - update('date_depense', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" - /> + {msg &&
{msg}
} + {error &&
{error}
} + +
+ {[ + ['saisie', '💸 Soumettre'], + ...(estTresorier ? [['confirmer', `📥 À confirmer (${aConfirmer.length})`]] : []), + ...(engagements.length > 0 ? [['engagements', `⏳ Engagements (${engagements.length})`]] : []), + ['historique', `📋 Tout (${depenses.length})`], + ].map(([t, label]) => ( + + ))}
- {/* Montant */} -
- -
- $ - update('montant', e.target.value)} - className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-red-400" - /> -
-
+ {/* ── Saisie (ouvert à tous) ── */} + {tab === 'saisie' && ( + <> +
+ + update('date_depense', e.target.value)} + className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> +
- {/* Qui (dropdown membres) */} -
- - -
+
+ +
+ $ + update('montant', e.target.value)} + className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-red-400" + autoFocus /> +
+
- {/* Quoi (texte libre) */} -
- - update('quoi', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" - /> -
+
+ + update('quoi', e.target.value)} + className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" /> +
- {/* Méthode (4 boutons radio) */} -
- -
- {METHODES.map((m) => ( - +
+ + setFichier(e.target.files?.[0] || null)} + className="w-full p-3 bg-gray-50 rounded-xl border-0 text-sm" /> + {fichier &&

📎 {fichier.name}

} +
+ +
+

La dépense sera soumise au trésorier pour confirmation et choix du mode de paiement.

+
+ + + + )} + + {/* ── À confirmer (trésorier) ── */} + {tab === 'confirmer' && estTresorier && ( + aConfirmer.length === 0 ? ( +
+

✅

+

Aucune dépense en attente

+
+ ) : ( +
+ {aConfirmer.map((d) => ( + + ))} +
+ ) + )} + + {/* ── Engagements ── */} + {tab === 'engagements' && ( +
+ {engagements.map((d) => ( +
+
+
+

{d.quoi}

+

{parseFloat(d.montant).toFixed(2)} $

+

{d.date_depense} · {d.qui_prenom} · {d.methode}

+
+ À débiter +
+ {estTresorier && ( + + )} +
))}
-
+ )} - {/* Payé (Oui/Non) */} -
- -
- - -
-
- - {error &&

{error}

} - - + {/* ── Historique ── */} + {tab === 'historique' && ( + depenses.length === 0 ? ( +
+

💸

+

Aucune dépense

+
+ ) : ( +
+ {depenses.map((d) => ( +
+
+

+ {d.quoi} + {d.preuve_nom && ' 📎'} +

+

+ {d.date_depense} · {d.qui_prenom} + {d.methode && ` · ${d.methode}`} +

+
+
+

{parseFloat(d.montant).toFixed(2)} $

+

+ {d.paye ? '✓ Payé' : d.confirme ? '⏳ Engagé' : '○ Soumise'} +

+
+
+ ))} +
+ ) + )}
); } + +function ConfirmCard({ depense, onConfirm }) { + const [methode, setMethode] = useState(''); + return ( +
+
+

{depense.quoi}

+

{parseFloat(depense.montant).toFixed(2)} $

+

{depense.date_depense} · Soumis par {depense.saisi_par_prenom}

+ {depense.preuve_nom &&

📎 Preuve jointe

} +
+
+

Mode de paiement

+
+ {METHODES.map((m) => ( + + ))} +
+
+ +
+ ); +} diff --git a/frontend/src/pages/EnvoiContributions.jsx b/frontend/src/pages/EnvoiContributions.jsx index 72c7749..15fc9ba 100644 --- a/frontend/src/pages/EnvoiContributions.jsx +++ b/frontend/src/pages/EnvoiContributions.jsx @@ -6,125 +6,170 @@ import SuccessCheck from '../components/SuccessCheck'; const METHODES = [ { value: 'VIREMENT', label: 'Virement', emoji: '🏦' }, { value: 'CHEQUE', label: 'Chèque', emoji: '📝' }, - { value: 'DU', label: 'Dû', emoji: '📋' }, +]; + +const DESTINATIONS = [ + { value: 'district', label: 'District 87-16' }, + { value: 'region', label: 'Région 87' }, + { value: 'bsg', label: 'BSG' }, + { value: 'intergroupe', label: 'Intergroupe' }, ]; export default function EnvoiContributions() { - const [mode, setMode] = useState('calculer'); // calculer | enregistrer - const [montantTotal, setMontantTotal] = useState(''); - const [repartition, setRepartition] = useState([]); + const [soldes, setSoldes] = useState(null); + const [resolutions, setResolutions] = useState([]); const [envois, setEnvois] = useState([]); - const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(false); const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const [form, setForm] = useState({ + date_envoi: new Date().toISOString().slice(0, 10), + destination: 'district', + montant: '', + methode: '', + resolution_id: '', + }); - // Charger les envois récents useEffect(() => { - api.get('/contributions/?limite=10').then(setEnvois).catch(() => {}); + Promise.all([ + api.get('/collectes/soldes'), + api.get('/propositions/resolutions?type_proposition=contribution'), + api.get('/contributions/?limite=10'), + ]).then(([s, r, e]) => { + setSoldes(s); + setResolutions(r); + setEnvois(e); + if (s && s.disponible > 0) { + setForm((f) => ({ ...f, montant: s.disponible.toFixed(2) })); + } + }).catch(() => {}); }, []); - const calculer = async () => { - if (!montantTotal || parseFloat(montantTotal) <= 0) { - setError('Entrez un montant valide'); + const update = (k, v) => setForm({ ...form, [k]: v }); + + const handleSubmit = async () => { + if (!form.montant || parseFloat(form.montant) <= 0) { setError('Montant invalide'); return; } + if (!form.methode) { setError('Sélectionnez une méthode'); return; } + if (!form.resolution_id) { + setError('Une résolution adoptée est requise. Créez d\'abord une proposition de type « contribution », faites-la seconder et adopter.'); return; } - setError(''); + setLoading(true); setError(''); try { - const data = await api.post('/contributions/calculer', { - montant_total: parseFloat(montantTotal), + await api.post('/contributions/', { + date_envoi: form.date_envoi, + destination: form.destination, + montant: parseFloat(form.montant), + methode: form.methode, + resolution_id: form.resolution_id, }); - setRepartition(data); - } catch (e) { - setError(e.message); - } + setSuccess(true); + } catch (e) { setError(e.message); setLoading(false); } }; if (success) return ; return (
- +
- {/* Tabs */} -
- - -
- - {mode === 'calculer' && ( - <> - {/* Montant à répartir */} -
- -
- $ - setMontantTotal(e.target.value)} - className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-aa-blue" - /> + {/* Somme disponible */} + {soldes && ( +
+
+
+

Somme disponible

+

= 0 ? 'text-blue-700' : 'text-red-600'}`}> + {soldes.disponible.toFixed(2)} $ +

+
+
+

Banque: {soldes.solde_banque.toFixed(2)} $

+

Réserves: -{soldes.total_reserves.toFixed(2)} $

- - - - {/* Résultat */} - {repartition.length > 0 && ( -
- {repartition.map((r) => ( -
-
-

{r.destination_label}

-

{r.pourcentage}%

-
-

{r.montant.toFixed(2)} $

-
- ))} -
- )} - +
)} - {mode === 'enregistrer' && ( - setSuccess(true)} - onError={setError} - /> - )} + {/* Résolution obligatoire */} +
+ + {resolutions.length === 0 ? ( +
+

+ Aucune résolution de type « contribution » disponible. +

+

+ Créez une proposition de type « contribution » dans la section Propositions, faites-la seconder puis adopter lors d'une assemblée d'affaires. +

+
+ ) : ( + + )} +
+ +
+ + update('date_envoi', e.target.value)} + className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> +
+ +
+ +
+ {DESTINATIONS.map((d) => ( + + ))} +
+
+ +
+ +
+ $ + update('montant', e.target.value)} + className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-blue-400" /> +
+
+ +
+ +
+ {METHODES.map((m) => ( + + ))} +
+
{error &&

{error}

} + + {/* Historique */} {envois.length > 0 && (
-

- Derniers envois -

+

Derniers envois

{envois.map((e) => (
@@ -140,104 +185,3 @@ export default function EnvoiContributions() {
); } - -function FormEnvoi({ onSuccess, onError }) { - const [form, setForm] = useState({ - date_envoi: new Date().toISOString().slice(0, 10), - destination: '', - montant: '', - methode: '', - }); - const [loading, setLoading] = useState(false); - - const DESTINATIONS = [ - { value: 'district', label: 'District 87-16' }, - { value: 'region', label: 'Région 87' }, - { value: 'bsg', label: 'BSG' }, - { value: 'intergroupe', label: 'Intergroupe' }, - ]; - - const update = (k, v) => setForm({ ...form, [k]: v }); - - const handleSubmit = async () => { - if (!form.destination || !form.montant || !form.methode) { - onError('Tous les champs sont obligatoires'); - return; - } - setLoading(true); - onError(''); - try { - await api.post('/contributions/', { - date_envoi: form.date_envoi, - destination: form.destination, - montant: parseFloat(form.montant), - methode: form.methode, - }); - onSuccess(); - } catch (e) { - onError(e.message); - setLoading(false); - } - }; - - return ( -
-
- - update('date_envoi', e.target.value)} - className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> -
- -
- -
- {DESTINATIONS.map((d) => ( - - ))} -
-
- -
- -
- $ - update('montant', e.target.value)} - className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-blue-400" /> -
-
- -
- -
- {METHODES.map((m) => ( - - ))} -
-
- - -
- ); -} diff --git a/frontend/src/pages/EtatFinancier.jsx b/frontend/src/pages/EtatFinancier.jsx new file mode 100644 index 0000000..fb6ef49 --- /dev/null +++ b/frontend/src/pages/EtatFinancier.jsx @@ -0,0 +1,128 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../stores/api'; +import PageHeader from '../components/PageHeader'; + +export default function EtatFinancier() { + const [data, setData] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + api.get('/rapports/etat-financier').then(setData).catch((e) => setError(e.message)); + }, []); + + if (!data && !error) return

Chargement...

; + + return ( +
+ + +
+ {error &&
{error}
} + + {data && ( + <> + {/* Soldes principaux */} +
+

Soldes

+
+ + + = 0 ? 'green' : 'red'} /> + = 0 ? 'green' : 'red'} /> +
+ {data.date_releve && ( +

Dernier relevé : {data.date_releve}

+ )} +
+ + {/* Revenus */} +
+

Revenus

+ + +
+ Total revenus + {data.revenus.total.toFixed(2)} $ +
+
+ + {/* Sorties */} +
+

Sorties

+ + + +
+ Total sorties + {data.sorties.total.toFixed(2)} $ +
+
+ + {/* Réserves */} +
+

Réserves

+ + +
+ Total réserves + {data.reserves.total.toFixed(2)} $ +
+
+ + {/* Dernières collectes */} + {data.dernieres_collectes.length > 0 && ( +
+

Dernières collectes

+ {data.dernieres_collectes.map((c, i) => ( +
+ {c.date} + +{c.montant.toFixed(2)} $ +
+ ))} +
+ )} + + {/* Dernières dépenses */} + {data.dernieres_depenses.length > 0 && ( +
+

Dernières dépenses

+ {data.dernieres_depenses.map((d, i) => ( +
+
+

{d.quoi}

+

{d.date} · {d.methode || '—'} · {d.paye ? '✓' : '⏳'}

+
+ -{d.montant.toFixed(2)} $ +
+ ))} +
+ )} + + )} +
+
+ ); +} + +function Bloc({ label, montant, couleur }) { + const cls = { + blue: 'bg-blue-50 text-blue-700', + green: 'bg-green-50 text-green-700', + red: 'bg-red-50 text-red-700', + }; + return ( +
+

{label}

+

{montant.toFixed(2)} $

+
+ ); +} + +function Ligne({ label, montant }) { + return ( +
+ {label} + {montant.toFixed(2)} $ +
+ ); +} diff --git a/frontend/src/pages/Evenements.jsx b/frontend/src/pages/Evenements.jsx new file mode 100644 index 0000000..d0eac48 --- /dev/null +++ b/frontend/src/pages/Evenements.jsx @@ -0,0 +1,151 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../stores/api'; +import { useStore } from '../stores/store'; +import PageHeader from '../components/PageHeader'; + +const TYPE_STYLE = { + anniversaire: { icon: '🎂', cls: 'bg-yellow-50 border-yellow-200', accent: 'text-yellow-700' }, + adhoc: { icon: '⭐', cls: 'bg-purple-50 border-purple-200', accent: 'text-purple-700' }, + resolution: { icon: '📜', cls: 'bg-green-50 border-green-200', accent: 'text-green-700' }, +}; + +export default function Evenements() { + const { postes } = useStore(); + const [evenements, setEvenements] = useState([]); + const [filtre, setFiltre] = useState('tous'); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [error, setError] = useState(''); + const [msg, setMsg] = useState(''); + + // Form adhoc + const [titre, setTitre] = useState(''); + const [dateEv, setDateEv] = useState(''); + const [description, setDescription] = useState(''); + const [lieu, setLieu] = useState(''); + + const estExecutif = postes.some((p) => + ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p) + ); + + const charger = async () => { + try { + const data = await api.get('/evenements/?jours=90'); + setEvenements(data); + } catch (e) { setError(e.message); } + setLoading(false); + }; + + useEffect(() => { charger(); }, []); + + const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); }; + + const creerEvenement = async () => { + if (!titre.trim() || !dateEv) { setError('Titre et date requis'); return; } + setError(''); + try { + await api.post(`/evenements/?titre=${encodeURIComponent(titre.trim())}&date_evenement=${dateEv}&description=${encodeURIComponent(description.trim())}&lieu=${encodeURIComponent(lieu.trim())}`); + setTitre(''); setDateEv(''); setDescription(''); setLieu(''); + setShowForm(false); + flash('Événement créé ✓'); + await charger(); + } catch (e) { setError(e.message); } + }; + + const filtres = evenements.filter((e) => filtre === 'tous' || e.type === filtre); + + // Grouper par date + const parDate = {}; + filtres.forEach((e) => { + if (!parDate[e.date]) parDate[e.date] = []; + parDate[e.date].push(e); + }); + + if (loading) return

Chargement...

; + + return ( +
+ + +
+ {msg &&
{msg}
} + {error &&
{error}
} + + {/* Filtres */} +
+ {[['tous', 'Tous'], ['anniversaire', '🎂 Anniv.'], ['adhoc', '⭐ Événements'], ['resolution', '📜 Résol.']].map(([k, label]) => ( + + ))} + {estExecutif && ( + + )} +
+ + {/* Form adhoc */} + {showForm && ( +
+

Nouvel événement

+ setTitre(e.target.value)} + placeholder="Titre" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> + setDateEv(e.target.value)} + className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> + setLieu(e.target.value)} + placeholder="Lieu (optionnel)" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> +