dernière mouture

This commit is contained in:
Daniel Allaire 2026-04-17 04:04:04 -04:00
parent d055b51132
commit 2245f26492
64 changed files with 5197 additions and 878 deletions

115
README.md
View file

@ -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
```
---

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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")

View file

@ -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)
)

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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)
)

View file

@ -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))

View file

@ -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)

View file

@ -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)
)

View file

@ -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)

View file

@ -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)
)

View file

@ -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)
)

View file

@ -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))

View file

@ -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,
)

View file

@ -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),
)

View file

@ -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."""
result = await db.execute(
select(Collecte)
.where(Collecte.groupe_id == membre.groupe_id)
.order_by(desc(Collecte.date_collecte))
.limit(limite)
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,
)
collectes = result.scalars().all()
out = []
for c in collectes:
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(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
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
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
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

View file

@ -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,
))

View file

@ -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,85 +17,30 @@ 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
@router.post("/", response_model=DepenseOut)
async def saisir_depense(
req: DepenseCreate,
membre=Depends(require_tresorier),
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")
depense = Depense(
groupe_id=membre.groupe_id,
date_depense=req.date_depense,
montant=req.montant,
qui=req.qui,
quoi=req.quoi.strip(),
paye=req.paye,
methode=req.methode.upper(),
saisi_par=membre.id,
)
db.add(depense)
await db.flush()
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.get("/", response_model=list[DepenseOut])
async def liste_depenses(
limite: int = 30,
paye: bool | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les dépenses du groupe avec filtre optionnel sur payé."""
query = select(Depense).where(Depense.groupe_id == membre.groupe_id)
if paye is not None:
query = query.where(Depense.paye == paye)
query = query.order_by(desc(Depense.date_depense)).limit(limite)
result = await db.execute(query)
depenses = result.scalars().all()
out = []
for d in depenses:
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
out.append(DepenseOut(
return DepenseOut(
id=d.id,
date_depense=d.date_depense,
montant=float(d.montant),
@ -100,20 +48,166 @@ async def liste_depenses(
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,
))
return out
)
@router.post("/", response_model=DepenseOut)
async def soumettre_depense(
req: DepenseCreate,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""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=membre.id, # le membre qui soumet est celui qui a payé
quoi=req.quoi.strip(),
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)
@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."""
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)
return [await _to_out(d, db) for d in result.scalars().all()]
@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)

View file

@ -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é"}

View file

@ -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)"}

View file

@ -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

View file

@ -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:

View file

@ -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"}

View file

@ -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",

View file

@ -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()]

View file

@ -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 = '<span class="badge-paye">✓ Adoptée</span>' if p.statut == "adoptee" else '<span class="badge-impaye">✗ Rejetée</span>'
rows += f"""<tr>
<td>{p.numero_resolution or '—'}</td>
<td>{p.date_vote or '—'}</td>
<td>{p.sujet}</td>
<td>{proposeur.prenom if proposeur else '—'}</td>
<td>{secondeur.prenom if secondeur else '—'}</td>
<td>{badge}</td>
</tr>"""
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"""<!DOCTYPE html><html><head><style>{_css()}</style></head><body>
<div class="header">
<div class="groupe">{groupe.nom if groupe else 'Groupe'}</div>
<div class="sub">District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}</div>
</div>
<h1>Registre des résolutions — {annee}</h1>
<table>
<tr><th>N°</th><th>Date</th><th>Sujet</th><th>Proposeur</th><th>Secondeur</th><th>Résultat</th></tr>
{rows if rows else '<tr><td colspan="6">Aucune résolution pour cette année</td></tr>'}
</table>
<p class="total">{nb_adoptees} résolution(s) adoptée(s), {nb_rejetees} rejetée(s)</p>
<div class="footer">
Registre généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')} · 100% logiciel libre
</div>
</body></html>"""
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,
}

View file

@ -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,

View file

@ -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")]

View file

@ -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"}

View file

@ -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}

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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 ──

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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}")

View file

@ -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/
---
## 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**

View file

@ -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`
---

View file

@ -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(() => {
// 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 <Accueil />;
case 'profil': return <Profil />;
case 'S1': return <Collecte />;
case 'S1T': return <Tresorerie />;
case 'S4': return <RapportReunion />;
case 'S5': return <Depense />;
case 'C1': return <Rapports />;
case 'C2': return <Calendrier />;
case 'C2': return <Evenements />;
case 'C5': return <Decisions />;
case 'G1': return <InviterMembre />;
case 'G2': return <GererMembres />;
@ -62,11 +74,11 @@ export default function App() {
case 'G4': return <TerminerMandat />;
case 'G5': return <PlanifierRotations />;
case 'G9': return <Parametres />;
case 'G10': return <GererPostes />;
case 'M1': return <RedigerPV />;
case 'M2': return <Presences />;
case 'M3': return <RapportRSG />;
case 'S2': return <RemiseJeton />;
case 'C3': return <Anniversaires />;
case 'M6': return <CommanderJetons />;
case 'S3': return <VenteLitterature />;
case 'C4': return <Inventaire />;
@ -76,6 +88,9 @@ export default function App() {
case 'G6': return <LancerSondage />;
case 'C6': return <VotesOuverts />;
case 'G7': return <JumelerParrain />;
case 'C8': return <Propositions />;
case 'C9': return <Impliquer />;
case 'C10': return <EtatFinancier />;
default: return <Accueil />;
}
}

View file

@ -39,7 +39,7 @@ function Section({ titre, modules }) {
<div className="grid gap-2">
{modules.map((m) => (
<Tuile key={m.code} module={m} onClick={() => {
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)`);

View file

@ -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 (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Affecter les postes" sousTitre="G3 — Assigner un serviteur" />
@ -69,6 +73,12 @@ export default function AffecterPostes() {
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Nouvelle affectation</h2>
{postesVacants.length === 0 ? (
<div className="bg-yellow-50 rounded-xl p-3 border border-yellow-200">
<p className="text-sm text-yellow-700">Tous les postes sont pourvus. Terminez d'abord un mandat (G4) pour libérer un poste.</p>
</div>
) : (
<>
<div>
<label className="text-xs text-gray-400 block mb-1">Membre</label>
<select value={form.membre_id} onChange={(e) => setForm({ ...form, membre_id: e.target.value })}
@ -79,15 +89,17 @@ export default function AffecterPostes() {
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Poste</label>
<label className="text-xs text-gray-400 block mb-1">Poste vacant</label>
<select value={form.poste_id} onChange={(e) => setForm({ ...form, poste_id: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">Sélectionner un poste</option>
{postes.map((p) => (
{postesVacants.map((p) => (
<option key={p.id} value={p.id}>{CATEGORIES[p.categorie] || '⚪'} {p.nom}</option>
))}
</select>
</div>
</>
)}
<div>
<label className="text-xs text-gray-400 block mb-1">Date de début</label>

View file

@ -1,82 +1,229 @@
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);
setMontant('');
flash('Collecte enregistrée ✓');
setTab('mois');
await charger();
} catch (e) { setError(e.message); }
setLoading(false);
}
};
if (success) return <SuccessCheck message="Collecte enregistrée ✓" />;
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 (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Collecte 7e Tradition" sousTitre="S1 — Saisir le panier" />
<PageHeader titre="Collecte 7e Tradition" sousTitre="S1 — Saisir et confirmer" />
<div className="p-4 space-y-4">
{/* Date pré-remplie */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date</label>
<input
type="date"
value={dateCollecte}
onChange={(e) => 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 && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
<div className="flex gap-2">
<button onClick={() => { setTab('mois'); setError(''); }}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition ${tab === 'mois' ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'}`}>
📋 {MOIS_NOMS[mois]} ({collectesMois.length})
</button>
<button onClick={() => { setTab('saisie'); setError(''); }}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition ${tab === 'saisie' ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'}`}>
💰 Saisir
</button>
</div>
{tab === 'mois' && (
<>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-400">Total {MOIS_NOMS[mois]} {annee}</p>
<p className="text-2xl font-bold text-green-700">{totalMois.toFixed(2)} $</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-400">{collectesMois.length} collecte{collectesMois.length > 1 ? 's' : ''}</p>
</div>
</div>
</div>
{collectesMois.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">💰</p>
<p className="text-gray-500">Aucune collecte ce mois-ci</p>
</div>
) : (
<div className="space-y-3">
{collectesMois.map((c) => {
const st = STATUT[c.statut] || STATUT.en_attente;
const peutConfirmer = c.statut === 'en_attente' && c.saisi_par_prenom !== membre?.prenom;
return (
<div key={c.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800 text-lg">{parseFloat(c.montant).toFixed(2)} $</p>
<p className="text-xs text-gray-500 mt-1">📅 {c.date_collecte}</p>
<p className="text-xs text-gray-400">
Saisi par <span className="font-medium">{c.saisi_par_prenom}</span>
{' · '}Chez <span className="font-medium">{c.detenteur_prenom}</span>
</p>
{c.confirme_par_prenom && <p className="text-xs text-blue-500">Confirmé par {c.confirme_par_prenom}</p>}
{c.recu_par_prenom && <p className="text-xs text-green-500">Reçu par {c.recu_par_prenom}</p>}
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${st.cls}`}>{st.label}</span>
</div>
{peutConfirmer && (
<button onClick={() => confirmer(c.id)}
className="w-full mt-3 py-2 bg-yellow-50 text-yellow-700 rounded-xl text-sm font-bold border border-yellow-200 active:scale-[0.97] transition">
✋ Confirmer le montant
</button>
)}
</div>
);
})}
</div>
)}
</>
)}
{tab === 'saisie' && (
<div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date de la réunion</label>
{datesReunion.length > 0 ? (
<select value={dateCollecte} onChange={(e) => 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">
{datesReunion.map((d) => {
const dt = new Date(d + 'T12:00:00');
const label = dt.toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' });
const estAujourdhui = d === new Date().toISOString().slice(0, 10);
return <option key={d} value={d}>{estAujourdhui ? '● Aujourd\'hui' : label}</option>;
})}
</select>
) : (
<input type="date" value={dateCollecte} onChange={(e) => 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" />
)}
<p className="text-xs text-gray-400 mt-1">Saisie rétroactive permise aux dates de réunion</p>
</div>
{/* Montant — gros clavier numérique */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Montant collecté</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-2xl text-gray-400">$</span>
<input
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="0.00"
value={montant}
onChange={(e) => setMontant(e.target.value)}
<input type="number" inputMode="decimal" step="0.01" min="0" placeholder="0.00"
value={montant} onChange={(e) => 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
/>
autoFocus />
</div>
</div>
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Qui a l'argent?</label>
<select value={detenteurId} onChange={(e) => setDetenteurId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">— Sélectionner —</option>
{membres.map((m) => (
<option key={m.id} value={m.id}>{m.prenom}{m.id === membre?.id ? ' (moi)' : ''}</option>
))}
</select>
</div>
{/* Bouton enregistrer */}
<button
onClick={handleSubmit}
disabled={loading || !montant}
className="w-full p-5 bg-green-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg"
>
<button onClick={handleSubmit} disabled={loading || !montant || !detenteurId}
className="w-full p-5 bg-green-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
{loading ? '...' : '💰 Enregistrer la collecte'}
</button>
</div>
)}
</div>
</div>
);
}

View file

@ -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,
});
setSuccess(true);
} catch (e) {
setError(e.message);
setLoading(false);
// 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); }
};
if (success) return <SuccessCheck message="Dépense enregistrée ✓" />;
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 <SuccessCheck message="Dépense soumise ✓" />;
const engagements = depenses.filter((d) => d.confirme && !d.paye && ['VIREMENT', 'CHEQUE'].includes(d.methode));
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Dépense" sousTitre="S5 — Enregistrer une dépense" />
<PageHeader titre="Dépenses" sousTitre="S5 — Soumettre et confirmer" />
<div className="p-4 space-y-3">
{/* Date */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date</label>
<input
type="date"
value={form.date_depense}
onChange={(e) => 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 && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
<div className="flex gap-1 overflow-x-auto">
{[
['saisie', '💸 Soumettre'],
...(estTresorier ? [['confirmer', `📥 À confirmer (${aConfirmer.length})`]] : []),
...(engagements.length > 0 ? [['engagements', `⏳ Engagements (${engagements.length})`]] : []),
['historique', `📋 Tout (${depenses.length})`],
].map(([t, label]) => (
<button key={t} onClick={() => { setTab(t); setError(''); }}
className={`flex-1 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>{label}</button>
))}
</div>
{/* ── Saisie (ouvert à tous) ── */}
{tab === 'saisie' && (
<>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date</label>
<input type="date" value={form.date_depense}
onChange={(e) => 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" />
</div>
{/* Montant */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Montant</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-xl text-gray-400">$</span>
<input
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="0.00"
value={form.montant}
onChange={(e) => update('montant', e.target.value)}
<input type="number" inputMode="decimal" step="0.01" min="0" placeholder="0.00"
value={form.montant} onChange={(e) => 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 />
</div>
</div>
{/* Qui (dropdown membres) */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Qui a fait la dépense</label>
<select
value={form.qui}
onChange={(e) => update('qui', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue"
>
<option value="">Sélectionner un membre</option>
{membres.map((m) => (
<option key={m.id} value={m.id}>{m.prenom}</option>
))}
</select>
</div>
{/* Quoi (texte libre) */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Description</label>
<input
type="text"
placeholder="Loyer, café, littérature, jetons..."
value={form.quoi}
onChange={(e) => 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"
/>
<input type="text" placeholder="Loyer, café, littérature, jetons..."
value={form.quoi} onChange={(e) => 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" />
</div>
{/* Méthode (4 boutons radio) */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Méthode de paiement</label>
<div className="grid grid-cols-2 gap-2">
{METHODES.map((m) => (
<button
key={m.value}
type="button"
onClick={() => update('methode', m.value)}
className={`p-3 rounded-xl text-sm font-bold transition border-2 ${
form.methode === m.value
? 'bg-aa-blue text-white border-aa-blue'
: 'bg-gray-50 text-gray-600 border-gray-200 active:bg-gray-100'
}`}
>
{m.emoji} {m.label}
<label className="text-xs text-gray-400 block mb-1">Preuve (reçu, photo) — optionnel</label>
<input type="file" accept="image/*,.pdf"
onChange={(e) => setFichier(e.target.files?.[0] || null)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 text-sm" />
{fichier && <p className="text-xs text-green-600 mt-1">📎 {fichier.name}</p>}
</div>
<div className="bg-blue-50 rounded-xl p-3 border border-blue-200">
<p className="text-xs text-blue-700">La dépense sera soumise au trésorier pour confirmation et choix du mode de paiement.</p>
</div>
<button onClick={handleSubmit}
disabled={loading || !form.montant || !form.quoi.trim()}
className="w-full p-5 bg-red-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
{loading ? '...' : '💸 Soumettre la dépense'}
</button>
</>
)}
{/* ── À confirmer (trésorier) ── */}
{tab === 'confirmer' && estTresorier && (
aConfirmer.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">✅</p>
<p className="text-gray-500">Aucune dépense en attente</p>
</div>
) : (
<div className="space-y-3">
{aConfirmer.map((d) => (
<ConfirmCard key={d.id} depense={d} onConfirm={confirmerDepense} />
))}
</div>
</div>
)
)}
{/* Payé (Oui/Non) */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Payé ?</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => update('paye', true)}
className={`p-3 rounded-xl text-sm font-bold transition border-2 ${
form.paye ? 'bg-green-600 text-white border-green-600' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}
>
✓ Oui
</button>
<button
type="button"
onClick={() => update('paye', false)}
className={`p-3 rounded-xl text-sm font-bold transition border-2 ${
!form.paye ? 'bg-orange-500 text-white border-orange-500' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}
>
✗ Non (à rembourser)
{/* ── Engagements ── */}
{tab === 'engagements' && (
<div className="space-y-3">
{engagements.map((d) => (
<div key={d.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800">{d.quoi}</p>
<p className="text-lg font-bold text-orange-600">{parseFloat(d.montant).toFixed(2)} $</p>
<p className="text-xs text-gray-400">{d.date_depense} · {d.qui_prenom} · {d.methode}</p>
</div>
<span className="text-xs px-2 py-1 rounded-full font-medium bg-orange-100 text-orange-700">À débiter</span>
</div>
{estTresorier && (
<button onClick={() => confirmerDebit(d.id)}
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97] transition">
✓ Confirmer le débit
</button>
)}
</div>
))}
</div>
)}
{/* ── Historique ── */}
{tab === 'historique' && (
depenses.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">💸</p>
<p className="text-gray-500">Aucune dépense</p>
</div>
) : (
<div className="space-y-2">
{depenses.map((d) => (
<div key={d.id} className="bg-white rounded-xl p-3 shadow-sm flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-gray-800 truncate">
{d.quoi}
{d.preuve_nom && ' 📎'}
</p>
<p className="text-xs text-gray-400">
{d.date_depense} · {d.qui_prenom}
{d.methode && ` · ${d.methode}`}
</p>
</div>
<div className="text-right">
<p className="font-bold text-sm text-red-600">{parseFloat(d.montant).toFixed(2)} $</p>
<p className={`text-xs ${d.paye ? 'text-green-500' : d.confirme ? 'text-orange-500' : 'text-gray-400'}`}>
{d.paye ? '✓ Payé' : d.confirme ? '⏳ Engagé' : '○ Soumise'}
</p>
</div>
</div>
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
<button
onClick={handleSubmit}
disabled={loading || !form.montant || !form.qui || !form.quoi.trim() || !form.methode}
className="w-full p-5 bg-red-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg"
>
{loading ? '...' : '💸 Enregistrer la dépense'}
</button>
))}
</div>
)
)}
</div>
</div>
);
}
function ConfirmCard({ depense, onConfirm }) {
const [methode, setMethode] = useState('');
return (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex-1">
<p className="font-bold text-gray-800">{depense.quoi}</p>
<p className="text-lg font-bold text-red-600">{parseFloat(depense.montant).toFixed(2)} $</p>
<p className="text-xs text-gray-400">{depense.date_depense} · Soumis par {depense.saisi_par_prenom}</p>
{depense.preuve_nom && <p className="text-xs text-blue-500 mt-1">📎 Preuve jointe</p>}
</div>
<div className="mt-3">
<p className="text-xs text-gray-400 mb-1">Mode de paiement</p>
<div className="grid grid-cols-4 gap-1">
{METHODES.map((m) => (
<button key={m.value} onClick={() => setMethode(m.value)}
className={`p-2 rounded-lg text-xs font-bold border ${
methode === m.value ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-500 border-gray-200'
}`}>{m.emoji}<br/>{m.label}</button>
))}
</div>
</div>
<button onClick={() => methode && onConfirm(depense.id, methode)}
disabled={!methode}
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 disabled:opacity-40 active:scale-[0.97] transition">
✓ Confirmer ({methode || '...'})
</button>
</div>
);
}

View file

@ -6,150 +6,8 @@ import SuccessCheck from '../components/SuccessCheck';
const METHODES = [
{ value: 'VIREMENT', label: 'Virement', emoji: '🏦' },
{ value: 'CHEQUE', label: 'Chèque', emoji: '📝' },
{ value: 'DU', label: 'Dû', emoji: '📋' },
];
export default function EnvoiContributions() {
const [mode, setMode] = useState('calculer'); // calculer | enregistrer
const [montantTotal, setMontantTotal] = useState('');
const [repartition, setRepartition] = useState([]);
const [envois, setEnvois] = useState([]);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
// Charger les envois récents
useEffect(() => {
api.get('/contributions/?limite=10').then(setEnvois).catch(() => {});
}, []);
const calculer = async () => {
if (!montantTotal || parseFloat(montantTotal) <= 0) {
setError('Entrez un montant valide');
return;
}
setError('');
try {
const data = await api.post('/contributions/calculer', {
montant_total: parseFloat(montantTotal),
});
setRepartition(data);
} catch (e) {
setError(e.message);
}
};
if (success) return <SuccessCheck message="Contribution enregistrée ✓" />;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Envoi contributions" sousTitre="M4 — District, région, BSG, intergroupe" />
<div className="p-4 space-y-4">
{/* Tabs */}
<div className="flex gap-2">
<button
onClick={() => setMode('calculer')}
className={`flex-1 py-2 rounded-xl text-sm font-bold border-2 ${
mode === 'calculer' ? 'bg-aa-blue text-white border-aa-blue' : 'bg-white text-gray-500 border-gray-200'
}`}
>
🧮 Calculer
</button>
<button
onClick={() => setMode('enregistrer')}
className={`flex-1 py-2 rounded-xl text-sm font-bold border-2 ${
mode === 'enregistrer' ? 'bg-aa-blue text-white border-aa-blue' : 'bg-white text-gray-500 border-gray-200'
}`}
>
📮 Enregistrer
</button>
</div>
{mode === 'calculer' && (
<>
{/* Montant à répartir */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Montant à répartir</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-xl text-gray-400">$</span>
<input
type="number"
inputMode="decimal"
step="0.01"
placeholder="0.00"
value={montantTotal}
onChange={(e) => 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"
/>
</div>
</div>
<button
onClick={calculer}
disabled={!montantTotal}
className="w-full p-4 bg-aa-blue text-white rounded-2xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition"
>
🧮 Calculer la répartition
</button>
{/* Résultat */}
{repartition.length > 0 && (
<div className="space-y-2">
{repartition.map((r) => (
<div key={r.destination} className="bg-white rounded-2xl p-4 shadow-sm flex items-center gap-3">
<div className="flex-1">
<p className="font-bold text-sm">{r.destination_label}</p>
<p className="text-xs text-gray-400">{r.pourcentage}%</p>
</div>
<p className="text-xl font-bold text-aa-blue">{r.montant.toFixed(2)} $</p>
</div>
))}
</div>
)}
</>
)}
{mode === 'enregistrer' && (
<FormEnvoi
onSuccess={() => setSuccess(true)}
onError={setError}
/>
)}
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
{/* Historique */}
{envois.length > 0 && (
<div className="space-y-2 mt-4">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
Derniers envois
</h2>
{envois.map((e) => (
<div key={e.id} className="bg-white rounded-xl p-3 shadow-sm flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="font-bold text-sm">{e.destination_label}</p>
<p className="text-xs text-gray-400">{e.date_envoi} · {e.methode}</p>
</div>
<p className="font-bold text-sm">{e.montant.toFixed(2)} $</p>
</div>
))}
</div>
)}
</div>
</div>
);
}
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' },
@ -157,31 +15,109 @@ function FormEnvoi({ onSuccess, onError }) {
{ value: 'intergroupe', label: 'Intergroupe' },
];
export default function EnvoiContributions() {
const [soldes, setSoldes] = useState(null);
const [resolutions, setResolutions] = useState([]);
const [envois, setEnvois] = useState([]);
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: '',
});
useEffect(() => {
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 update = (k, v) => setForm({ ...form, [k]: v });
const handleSubmit = async () => {
if (!form.destination || !form.montant || !form.methode) {
onError('Tous les champs sont obligatoires');
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;
}
setLoading(true);
onError('');
setLoading(true); setError('');
try {
await api.post('/contributions/', {
date_envoi: form.date_envoi,
destination: form.destination,
montant: parseFloat(form.montant),
methode: form.methode,
resolution_id: form.resolution_id,
});
onSuccess();
} catch (e) {
onError(e.message);
setLoading(false);
}
setSuccess(true);
} catch (e) { setError(e.message); setLoading(false); }
};
if (success) return <SuccessCheck message="Contribution enregistrée ✓" />;
return (
<div className="space-y-3">
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Envoi contributions" sousTitre="M4 — Requiert une résolution" />
<div className="p-4 space-y-4">
{/* Somme disponible */}
{soldes && (
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-200">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-blue-600">Somme disponible</p>
<p className={`text-2xl font-bold ${soldes.disponible >= 0 ? 'text-blue-700' : 'text-red-600'}`}>
{soldes.disponible.toFixed(2)} $
</p>
</div>
<div className="text-right text-xs text-blue-500">
<p>Banque: {soldes.solde_banque.toFixed(2)} $</p>
<p>Réserves: -{soldes.total_reserves.toFixed(2)} $</p>
</div>
</div>
</div>
)}
{/* Résolution obligatoire */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Résolution autorisant l'envoi *</label>
{resolutions.length === 0 ? (
<div className="bg-yellow-50 rounded-xl p-3 border border-yellow-200">
<p className="text-sm text-yellow-700">
Aucune résolution de type « contribution » disponible.
</p>
<p className="text-xs text-yellow-600 mt-1">
Créez une proposition de type « contribution » dans la section Propositions, faites-la seconder puis adopter lors d'une assemblée d'affaires.
</p>
</div>
) : (
<select value={form.resolution_id} onChange={(e) => update('resolution_id', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">— Sélectionner une résolution —</option>
{resolutions.map((r) => (
<option key={r.id} value={r.id}>
{r.numero_resolution} — {r.sujet}
</option>
))}
</select>
)}
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date</label>
<input type="date" value={form.date_envoi} onChange={(e) => update('date_envoi', e.target.value)}
@ -189,18 +125,13 @@ function FormEnvoi({ onSuccess, onError }) {
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Destination</label>
<label className="text-xs text-gray-400 block mb-2">Destinataire</label>
<div className="grid grid-cols-2 gap-2">
{DESTINATIONS.map((d) => (
<button key={d.value} type="button" onClick={() => update('destination', d.value)}
className={`p-3 rounded-xl text-sm font-bold border-2 ${
form.destination === d.value
? 'bg-aa-blue text-white border-aa-blue'
: 'bg-gray-50 text-gray-600 border-gray-200'
}`}
>
{d.label}
</button>
form.destination === d.value ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}>{d.label}</button>
))}
</div>
</div>
@ -217,27 +148,40 @@ function FormEnvoi({ onSuccess, onError }) {
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Méthode</label>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 gap-2">
{METHODES.map((m) => (
<button key={m.value} type="button" onClick={() => update('methode', m.value)}
className={`p-3 rounded-xl text-sm font-bold border-2 ${
form.methode === m.value
? 'bg-aa-blue text-white border-aa-blue'
: 'bg-gray-50 text-gray-600 border-gray-200'
}`}
>
{m.emoji} {m.label}
</button>
form.methode === m.value ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}>{m.emoji} {m.label}</button>
))}
</div>
</div>
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
<button onClick={handleSubmit}
disabled={loading || !form.destination || !form.montant || !form.methode}
className="w-full p-5 bg-blue-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg"
>
disabled={loading || !form.montant || !form.methode || !form.resolution_id}
className="w-full p-5 bg-blue-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
{loading ? '...' : '📮 Enregistrer l\'envoi'}
</button>
{/* Historique */}
{envois.length > 0 && (
<div className="space-y-2 mt-4">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">Derniers envois</h2>
{envois.map((e) => (
<div key={e.id} className="bg-white rounded-xl p-3 shadow-sm flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="font-bold text-sm">{e.destination_label}</p>
<p className="text-xs text-gray-400">{e.date_envoi} · {e.methode}</p>
</div>
<p className="font-bold text-sm">{e.montant.toFixed(2)} $</p>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -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 <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="État financier" sousTitre="Résultats et transactions en temps réel" />
<div className="p-4 space-y-4">
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
{data && (
<>
{/* Soldes principaux */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">Soldes</h2>
<div className="grid grid-cols-2 gap-3">
<Bloc label="Solde banque" montant={data.solde_banque} couleur="blue" />
<Bloc label="Encaisse" montant={data.encaisse} couleur="green" />
<Bloc label="Disponible" montant={data.disponible} couleur={data.disponible >= 0 ? 'green' : 'red'} />
<Bloc label="Résultat net" montant={data.resultat_net} couleur={data.resultat_net >= 0 ? 'green' : 'red'} />
</div>
{data.date_releve && (
<p className="text-xs text-gray-400 mt-2 text-center">Dernier relevé : {data.date_releve}</p>
)}
</div>
{/* Revenus */}
<div className="bg-green-50 rounded-2xl p-4 border border-green-200">
<h2 className="text-xs font-bold text-green-700 uppercase tracking-wider mb-2">Revenus</h2>
<Ligne label="Collectes 7e Tradition" montant={data.revenus.collectes} />
<Ligne label="Ventes de littérature" montant={data.revenus.ventes_litterature} />
<div className="border-t border-green-200 mt-2 pt-2 flex justify-between">
<span className="font-bold text-green-800 text-sm">Total revenus</span>
<span className="font-bold text-green-800 text-sm">{data.revenus.total.toFixed(2)} $</span>
</div>
</div>
{/* Sorties */}
<div className="bg-red-50 rounded-2xl p-4 border border-red-200">
<h2 className="text-xs font-bold text-red-700 uppercase tracking-wider mb-2">Sorties</h2>
<Ligne label="Dépenses payées" montant={data.sorties.depenses_payees} />
<Ligne label="Engagements en attente" montant={data.sorties.engagements} />
<Ligne label="Contributions envoyées" montant={data.sorties.contributions} />
<div className="border-t border-red-200 mt-2 pt-2 flex justify-between">
<span className="font-bold text-red-800 text-sm">Total sorties</span>
<span className="font-bold text-red-800 text-sm">{data.sorties.total.toFixed(2)} $</span>
</div>
</div>
{/* Réserves */}
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-200">
<h2 className="text-xs font-bold text-amber-700 uppercase tracking-wider mb-2">Réserves</h2>
<Ligne label="Réserve prudente" montant={data.reserves.prudente} />
<Ligne label="Réserve anniversaire (+20$/mois)" montant={data.reserves.anniversaire} />
<div className="border-t border-amber-200 mt-2 pt-2 flex justify-between">
<span className="font-bold text-amber-800 text-sm">Total réserves</span>
<span className="font-bold text-amber-800 text-sm">{data.reserves.total.toFixed(2)} $</span>
</div>
</div>
{/* Dernières collectes */}
{data.dernieres_collectes.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Dernières collectes</h2>
{data.dernieres_collectes.map((c, i) => (
<div key={i} className="flex justify-between py-1.5 border-b border-gray-50 last:border-0">
<span className="text-sm text-gray-600">{c.date}</span>
<span className="text-sm font-bold text-green-600">+{c.montant.toFixed(2)} $</span>
</div>
))}
</div>
)}
{/* Dernières dépenses */}
{data.dernieres_depenses.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Dernières dépenses</h2>
{data.dernieres_depenses.map((d, i) => (
<div key={i} className="flex justify-between py-1.5 border-b border-gray-50 last:border-0">
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-600 truncate">{d.quoi}</p>
<p className="text-xs text-gray-400">{d.date} · {d.methode || '—'} · {d.paye ? '✓' : '⏳'}</p>
</div>
<span className="text-sm font-bold text-red-600 whitespace-nowrap ml-2">-{d.montant.toFixed(2)} $</span>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
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 (
<div className={`text-center p-3 rounded-xl ${cls[couleur] || cls.blue}`}>
<p className="text-xs opacity-70">{label}</p>
<p className="text-xl font-bold">{montant.toFixed(2)} $</p>
</div>
);
}
function Ligne({ label, montant }) {
return (
<div className="flex justify-between py-1">
<span className="text-sm text-gray-600">{label}</span>
<span className="text-sm font-medium">{montant.toFixed(2)} $</span>
</div>
);
}

View file

@ -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 <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Événements à venir" sousTitre="Anniversaires et événements spéciaux" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
{/* Filtres */}
<div className="flex gap-2 overflow-x-auto">
{[['tous', 'Tous'], ['anniversaire', '🎂 Anniv.'], ['adhoc', '⭐ Événements'], ['resolution', '📜 Résol.']].map(([k, label]) => (
<button key={k} onClick={() => setFiltre(k)}
className={`px-3 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition ${
filtre === k ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>{label}</button>
))}
{estExecutif && (
<button onClick={() => setShowForm(!showForm)}
className="px-3 py-1.5 rounded-full text-xs font-bold bg-purple-100 text-purple-700 whitespace-nowrap">
+ Événement
</button>
)}
</div>
{/* Form adhoc */}
{showForm && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<p className="font-bold text-gray-700 text-sm">Nouvel événement</p>
<input type="text" value={titre} onChange={(e) => 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" />
<input type="date" value={dateEv} onChange={(e) => setDateEv(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input type="text" value={lieu} onChange={(e) => 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" />
<textarea value={description} onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optionnel)" rows={2}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
<button onClick={creerEvenement} disabled={!titre.trim() || !dateEv}
className="w-full py-3 bg-purple-600 text-white rounded-xl font-bold disabled:opacity-40">
Créer l'événement
</button>
</div>
)}
{/* Liste groupée par date */}
{filtres.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📅</p>
<p className="text-gray-500">Aucun événement à venir</p>
</div>
) : (
Object.entries(parDate).map(([dateStr, evs]) => {
const d = new Date(dateStr + 'T00:00:00');
const label = d.toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' });
const aujourdhui = new Date().toISOString().slice(0, 10) === dateStr;
return (
<div key={dateStr}>
<p className={`text-xs font-bold uppercase tracking-wider px-1 mb-2 ${aujourdhui ? 'text-green-600' : 'text-gray-400'}`}>
{aujourdhui ? '● Aujourd\'hui' : label}
</p>
<div className="space-y-2">
{evs.map((ev, i) => {
const style = TYPE_STYLE[ev.type] || TYPE_STYLE.adhoc;
return (
<div key={`${dateStr}-${i}`} className={`rounded-2xl p-3 border ${style.cls}`}>
<div className="flex items-start gap-3">
<span className="text-xl">{style.icon}</span>
<div className="flex-1 min-w-0">
<p className={`font-bold text-sm ${style.accent}`}>{ev.titre}</p>
{ev.detail && <p className="text-xs text-gray-500 mt-0.5">{ev.detail}</p>}
{ev.lieu && <p className="text-xs text-gray-400 mt-0.5">📍 {ev.lieu}</p>}
<p className="text-xs text-gray-400 mt-0.5">{ev.groupe_nom}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
})
)}
</div>
</div>
);
}

View file

@ -0,0 +1,325 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
const CATEGORIES = [
{ value: 'executif', label: 'Exécutif' },
{ value: 'service', label: 'Service' },
{ value: 'physique', label: 'Physique' },
];
export default function GererPostes() {
const [postes, setPostes] = useState([]);
const [taches, setTaches] = useState([]);
const [candidatures, setCandidatures] = useState([]);
const [tab, setTab] = useState('postes');
const [showForm, setShowForm] = useState(false);
const [showTacheForm, setShowTacheForm] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
// Form poste
const [nom, setNom] = useState('');
const [categorie, setCategorie] = useState('service');
const [description, setDescription] = useState('');
const [prerequis, setPrerequis] = useState('');
const [informations, setInformations] = useState('');
// Form tache
const [tacheNom, setTacheNom] = useState('');
const [tacheInfo, setTacheInfo] = useState('');
// Affectation tache
const [affectPosteId, setAffectPosteId] = useState('');
const [affectTacheId, setAffectTacheId] = useState('');
const charger = async () => {
try {
const [p, t, c] = await Promise.all([
api.get('/postes/?inclure_abolis=true'),
api.get('/postes/taches'),
api.get('/postes/candidatures'),
]);
setPostes(p);
setTaches(t);
setCandidatures(c);
} catch (e) { setError(e.message); }
};
useEffect(() => { charger(); }, []);
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const creerPoste = async () => {
if (!nom.trim()) { setError('Nom requis'); return; }
setError('');
try {
await api.post('/postes/', {
nom: nom.trim(), categorie,
description: description.trim() || null,
prerequis: prerequis.trim() || null,
informations: informations.trim() || null,
});
setNom(''); setDescription(''); setPrerequis(''); setInformations(''); setShowForm(false);
flash('Poste créé ✓');
await charger();
} catch (e) { setError(e.message); }
};
const abolir = async (id, nomPoste) => {
if (!confirm(`Abolir le poste «${nomPoste}» ?\nTous les mandats actifs seront terminés.`)) return;
try {
const res = await api.request('DELETE', `/postes/${id}`);
flash(`Poste aboli — ${res.mandats_termines} mandat(s) terminé(s)`);
await charger();
} catch (e) { setError(e.message); }
};
const reactiver = async (id) => {
try {
await api.post(`/postes/${id}/reactiver`);
flash('Poste réactivé ✓');
await charger();
} catch (e) { setError(e.message); }
};
const supprimerPermanent = async (id, nomPoste) => {
if (!confirm(`Supprimer DÉFINITIVEMENT le poste «${nomPoste}» ?\nCette action est irréversible.`)) return;
try {
await api.request('DELETE', `/postes/${id}/permanent`);
flash('Poste supprimé définitivement');
await charger();
} catch (e) { setError(e.message); }
};
const creerTache = async () => {
if (!tacheNom.trim()) { setError('Nom requis'); return; }
try {
await api.post('/postes/taches', { nom: tacheNom.trim(), informations: tacheInfo.trim() || null });
setTacheNom(''); setTacheInfo(''); setShowTacheForm(false);
flash('Tâche créée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const supprimerTache = async (id, nomT) => {
if (!confirm(`Supprimer la tâche «${nomT}» ?`)) return;
try {
await api.request('DELETE', `/postes/taches/${id}`);
flash('Tâche supprimée');
await charger();
} catch (e) { setError(e.message); }
};
const affecterTache = async () => {
if (!affectPosteId || !affectTacheId) { setError('Sélectionnez un poste et une tâche'); return; }
try {
await api.post(`/postes/${affectPosteId}/taches/${affectTacheId}`);
setAffectTacheId('');
flash('Tâche affectée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const desaffecterTache = async (posteId, tacheId) => {
try {
await api.request('DELETE', `/postes/${posteId}/taches/${tacheId}`);
flash('Tâche retirée');
await charger();
} catch (e) { setError(e.message); }
};
const traiterCandidature = async (id, statut) => {
try {
await api.patch(`/postes/candidatures/${id}?statut=${statut}`);
flash(statut === 'acceptee' ? 'Candidature acceptée ✓' : 'Candidature refusée');
await charger();
} catch (e) { setError(e.message); }
};
const postesActifs = postes.filter((p) => p.actif);
const CAT_ICON = { executif: '🔵', service: '🟣', physique: '🟢' };
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Gestion des postes" sousTitre="Postes, tâches, candidatures" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
<div className="flex gap-1 overflow-x-auto">
{[
['postes', `📋 Postes (${postes.length})`],
['taches', `🔧 Tâches (${taches.length})`],
['candidatures', `🙋 Candid. (${candidatures.length})`],
].map(([t, label]) => (
<button key={t} onClick={() => { setTab(t); setError(''); }}
className={`flex-1 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>{label}</button>
))}
</div>
{/* ── Postes ── */}
{tab === 'postes' && (
<div className="space-y-3">
<button onClick={() => setShowForm(!showForm)}
className="w-full py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200 active:scale-[0.97]">
+ Créer un poste
</button>
{showForm && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<input type="text" value={nom} onChange={(e) => setNom(e.target.value)}
placeholder="Nom du poste" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" autoFocus />
<select value={categorie} onChange={(e) => setCategorie(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
<input type="text" value={description} onChange={(e) => setDescription(e.target.value)}
placeholder="Description" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input type="text" value={prerequis} onChange={(e) => setPrerequis(e.target.value)}
placeholder="Prérequis" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<textarea value={informations} onChange={(e) => setInformations(e.target.value)}
placeholder="Informations complémentaires" rows={2}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
<button onClick={creerPoste} disabled={!nom.trim()}
className="w-full py-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">Créer</button>
</div>
)}
{postes.map((p) => (
<div key={p.id} className={`bg-white rounded-2xl p-4 shadow-sm ${!p.actif ? 'border-2 border-dashed border-gray-300' : ''}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800">{CAT_ICON[p.categorie] || '⚪'} {p.nom}</p>
{p.description && <p className="text-xs text-gray-500 mt-1">{p.description}</p>}
{p.informations && <p className="text-xs text-gray-400 mt-1 italic">{p.informations}</p>}
{/* Tâches associées */}
{p.taches && p.taches.length > 0 && (
<div className="mt-2 space-y-1">
{p.taches.map((t) => (
<div key={t.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-2 py-1">
<span className="text-xs text-gray-600">🔧 {t.nom}</span>
{p.actif && (
<button onClick={() => desaffecterTache(p.id, t.id)}
className="text-xs text-red-400 active:text-red-600">✕</button>
)}
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-1">
{p.actif ? (
<button onClick={() => abolir(p.id, p.nom)}
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">Abolir</button>
) : (
<>
<button onClick={() => reactiver(p.id)}
className="text-xs text-green-600 font-medium px-2 py-1 rounded-lg active:bg-green-50">Réactiver</button>
<button onClick={() => supprimerPermanent(p.id, p.nom)}
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">Supprimer</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* ── Tâches ── */}
{tab === 'taches' && (
<div className="space-y-3">
<button onClick={() => setShowTacheForm(!showTacheForm)}
className="w-full py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200 active:scale-[0.97]">
+ Créer une tâche
</button>
{showTacheForm && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<input type="text" value={tacheNom} onChange={(e) => setTacheNom(e.target.value)}
placeholder="Nom de la tâche" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" autoFocus />
<textarea value={tacheInfo} onChange={(e) => setTacheInfo(e.target.value)}
placeholder="Informations" rows={2}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
<button onClick={creerTache} disabled={!tacheNom.trim()}
className="w-full py-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">Créer</button>
</div>
)}
{/* Affecter tâche → poste */}
{taches.length > 0 && postesActifs.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Affecter une tâche à un poste</p>
<select value={affectTacheId} onChange={(e) => setAffectTacheId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm">
<option value="">— Tâche —</option>
{taches.map((t) => <option key={t.id} value={t.id}>{t.nom}</option>)}
</select>
<select value={affectPosteId} onChange={(e) => setAffectPosteId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm">
<option value="">— Poste —</option>
{postesActifs.map((p) => <option key={p.id} value={p.id}>{CAT_ICON[p.categorie]} {p.nom}</option>)}
</select>
<button onClick={affecterTache} disabled={!affectPosteId || !affectTacheId}
className="w-full py-2 bg-purple-600 text-white rounded-xl font-bold disabled:opacity-40 text-sm">
🔧 Affecter
</button>
</div>
)}
{/* Liste des tâches */}
{taches.map((t) => (
<div key={t.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800">🔧 {t.nom}</p>
{t.informations && <p className="text-xs text-gray-500 mt-1">{t.informations}</p>}
{t.postes.length > 0 && (
<p className="text-xs text-purple-500 mt-1">→ {t.postes.join(', ')}</p>
)}
</div>
<button onClick={() => supprimerTache(t.id, t.nom)}
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">Supprimer</button>
</div>
</div>
))}
</div>
)}
{/* ── Candidatures ── */}
{tab === 'candidatures' && (
candidatures.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">🙋</p>
<p className="text-gray-500">Aucune candidature en attente</p>
</div>
) : (
<div className="space-y-3">
{candidatures.map((c) => (
<div key={c.id} className="bg-white rounded-2xl p-4 shadow-sm">
<p className="font-bold text-gray-800">{c.membre_prenom} → {c.poste_nom}</p>
{c.motivation && <p className="text-sm text-gray-500 mt-1 italic">«{c.motivation}»</p>}
<p className="text-xs text-gray-400 mt-1">{new Date(c.cree_le).toLocaleDateString('fr-CA')}</p>
<div className="flex gap-2 mt-3">
<button onClick={() => traiterCandidature(c.id, 'acceptee')}
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97]">
✓ Accepter
</button>
<button onClick={() => traiterCandidature(c.id, 'refusee')}
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97]">
✗ Refuser
</button>
</div>
</div>
))}
</div>
)
)}
</div>
</div>
);
}

View file

@ -0,0 +1,176 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
const CAT_STYLE = {
executif: { label: 'Exécutif', cls: 'bg-blue-100 text-blue-700' },
service: { label: 'Service', cls: 'bg-green-100 text-green-700' },
physique: { label: 'Physique', cls: 'bg-gray-100 text-gray-600' },
};
export default function Impliquer() {
const [postes, setPostes] = useState([]);
const [mesCandidatures, setMesCandidatures] = useState([]);
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [motivation, setMotivation] = useState('');
const [postulePour, setPostulePour] = useState(null);
const charger = async () => {
try {
const [p, mc] = await Promise.all([
api.get('/postes/disponibles'),
api.get('/postes/candidatures/mes'),
]);
setPostes(p);
setMesCandidatures(mc);
} catch (e) { setError(e.message); }
setLoading(false);
};
useEffect(() => { charger(); }, []);
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const postuler = async (posteId) => {
setError('');
try {
await api.post(`/postes/candidatures?poste_id=${posteId}&motivation=${encodeURIComponent(motivation.trim())}`);
setMotivation(''); setPostulePour(null);
flash('Candidature soumise ✓');
await charger();
} catch (e) { setError(e.message); }
};
const retirer = async (candId) => {
try {
await api.post(`/postes/candidatures/${candId}/retirer`);
flash('Candidature retirée');
await charger();
} catch (e) { setError(e.message); }
};
const repondreNomination = async (candId, accepter) => {
try {
await api.post(`/postes/candidatures/${candId}/accepter-nomination?accepter=${accepter}`);
flash(accepter ? 'Nomination acceptée ✓' : 'Nomination refusée');
await charger();
} catch (e) { setError(e.message); }
};
const nominations = mesCandidatures.filter((c) => c.type === 'nomination' && c.acceptee_par_membre === null);
const candidaturesActives = mesCandidatures.filter((c) => c.type === 'candidature');
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="S'impliquer" sousTitre="Postes vacants, candidatures, nominations" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
{/* Nominations en attente de réponse */}
{nominations.length > 0 && (
<div className="space-y-3">
<p className="text-xs font-bold text-purple-600 uppercase tracking-wider px-1">Nominations à répondre</p>
{nominations.map((n) => (
<div key={n.id} className="bg-purple-50 rounded-2xl p-4 border border-purple-200">
<p className="font-bold text-purple-800">Vous êtes nominé(e) pour : {n.poste_nom}</p>
{n.nomme_par_prenom && <p className="text-xs text-purple-600 mt-1">Par {n.nomme_par_prenom}</p>}
<div className="flex gap-2 mt-3">
<button onClick={() => repondreNomination(n.id, true)}
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97]">
✓ Accepter
</button>
<button onClick={() => repondreNomination(n.id, false)}
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97]">
✗ Refuser
</button>
</div>
</div>
))}
</div>
)}
{/* Mes candidatures actives */}
{candidaturesActives.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">Mes candidatures</p>
{candidaturesActives.map((c) => (
<div key={c.id} className="bg-white rounded-xl p-3 shadow-sm flex items-center justify-between">
<p className="font-medium text-sm text-gray-700">{c.poste_nom}</p>
<button onClick={() => retirer(c.id)}
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">
Retirer
</button>
</div>
))}
</div>
)}
{/* Postes disponibles */}
<div className="bg-blue-50 rounded-xl p-3 border border-blue-200">
<p className="text-xs text-blue-700">
«Nos leaders sont des serviteurs de confiance; ils ne gouvernent pas.» — 2e Tradition
</p>
</div>
{postes.length === 0 ? (
<div className="text-center py-8">
<p className="text-4xl mb-2">✅</p>
<p className="text-gray-500">Tous les postes sont comblés</p>
</div>
) : (
<div className="space-y-3">
{postes.map((p) => {
const cat = CAT_STYLE[p.categorie] || CAT_STYLE.physique;
const ouvert = postulePour === p.id;
return (
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800">{p.nom}</p>
{p.description && <p className="text-sm text-gray-500 mt-1">{p.description}</p>}
{p.prerequis && <p className="text-xs text-gray-400 mt-1">Prérequis : {p.prerequis}</p>}
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${cat.cls}`}>{cat.label}</span>
</div>
{p.deja_postule ? (
<div className="mt-3 py-2 bg-yellow-50 rounded-xl text-center">
<p className="text-xs text-yellow-700 font-medium">Candidature déjà soumise</p>
</div>
) : ouvert ? (
<div className="mt-3 space-y-2">
<textarea value={motivation} onChange={(e) => setMotivation(e.target.value)}
placeholder="Pourquoi ce poste vous intéresse? (optionnel)"
rows={2} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
<div className="flex gap-2">
<button onClick={() => postuler(p.id)}
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97]">
✓ Soumettre
</button>
<button onClick={() => { setPostulePour(null); setMotivation(''); }}
className="flex-1 py-2 bg-gray-100 text-gray-500 rounded-xl text-sm font-bold">
Annuler
</button>
</div>
</div>
) : (
<button onClick={() => setPostulePour(p.id)}
className="w-full mt-3 py-2 bg-yellow-50 text-yellow-700 rounded-xl text-sm font-bold border border-yellow-200 active:scale-[0.97] transition">
🙋 Proposer ma candidature
</button>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View file

@ -32,27 +32,28 @@ export default function Inscription() {
/>
<div className="grid grid-cols-2 gap-3">
<input type="text" placeholder="Prénom" value={form.prenom}
<input type="text" placeholder="Prénom *" value={form.prenom}
onChange={(e) => update('prenom', e.target.value)}
className="p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input type="text" placeholder="Nom" value={form.nom}
<input type="text" placeholder="Nom (optionnel)" value={form.nom}
onChange={(e) => update('nom', e.target.value)}
className="p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<input type="email" placeholder="Adresse courriel *" value={form.courriel}
onChange={(e) => update('courriel', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
inputMode="email" />
<input type="tel" placeholder="Téléphone *" value={form.telephone}
onChange={(e) => update('telephone', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input type="password" inputMode="numeric" pattern="[0-9]*" maxLength={4}
placeholder="PIN (4 chiffres)" value={form.pin}
onChange={(e) => update('pin', e.target.value.replace(/\D/g, '').slice(0, 4))}
className="w-full p-4 bg-gray-50 rounded-xl text-lg text-center tracking-[1em] border-0 focus:ring-2 focus:ring-aa-blue" />
<input type="tel" placeholder="Téléphone (optionnel)" value={form.telephone}
onChange={(e) => update('telephone', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input type="email" placeholder="Courriel (optionnel)" value={form.courriel}
onChange={(e) => update('courriel', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<div>
<label className="text-xs text-gray-500 ml-1">Date d'abstinence (optionnel)</label>
<input type="date" value={form.date_abstinence}
@ -63,7 +64,7 @@ export default function Inscription() {
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
<button type="submit"
disabled={loading || !form.prenom || !form.nom || form.pin.length !== 4 || !form.code_invitation}
disabled={loading || !form.prenom || !form.courriel || !form.telephone || form.pin.length !== 4 || !form.code_invitation}
className="w-full p-4 bg-aa-blue text-white rounded-xl text-lg font-bold disabled:opacity-40">
{loading ? '...' : "S'inscrire"}
</button>

View file

@ -3,34 +3,64 @@ import { useStore } from '../stores/store';
export default function Login() {
const { login, loading, error, groupes, loadGroupes, setPage } = useStore();
const [prenom, setPrenom] = useState('');
const [courriel, setCourriel] = useState(localStorage.getItem('gm_courriel') || '');
const [pin, setPin] = useState('');
const [groupeId, setGroupeId] = useState('');
const [groupeId, setGroupeId] = useState(localStorage.getItem('gm_groupe_id') || '');
const [seSouvenir, setSeSouvenir] = useState(!!localStorage.getItem('gm_courriel'));
useEffect(() => {
if (groupes.length === 0) loadGroupes();
}, []);
useEffect(() => {
if (groupes.length === 1) setGroupeId(groupes[0].id);
if (groupes.length === 1 && !groupeId) setGroupeId(groupes[0].id);
}, [groupes]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!prenom || !pin || !groupeId) return;
if (!courriel || !pin || !groupeId) return;
try {
await login(prenom, pin, groupeId);
await login(courriel, pin, groupeId, seSouvenir);
} catch {}
};
const groupeActif = groupes.find((g) => g.id === groupeId);
return (
<div className="min-h-screen bg-gradient-to-b from-aa-blue to-blue-900 flex flex-col items-center justify-center p-6">
<div className="text-center mb-8">
<div className="text-center mb-6">
<div className="text-5xl mb-3">☮️</div>
<h1 className="text-2xl font-bold text-white">Groupe Méditation</h1>
<p className="text-blue-200 text-sm mt-1">District 87-16 · Région 87</p>
</div>
{/* Infos du groupe */}
{groupeActif && (
<div className="w-full max-w-sm bg-white/10 rounded-2xl p-4 mb-4 text-blue-100 text-sm space-y-1">
{groupeActif.adresse_local && (
<p className="flex items-start gap-2">
<span>📍</span>
<span>{groupeActif.adresse_local}</span>
</p>
)}
{groupeActif.jour_reunion && (
<p className="flex items-center gap-2">
<span>📅</span>
<span>
{groupeActif.jour_reunion.charAt(0).toUpperCase() + groupeActif.jour_reunion.slice(1)}
{groupeActif.heure_reunion && ` à ${groupeActif.heure_reunion}`}
</span>
</p>
)}
{groupeActif.format_reunion && (
<p className="flex items-center gap-2">
<span>📖</span>
<span>{groupeActif.format_reunion}</span>
</p>
)}
</div>
)}
<form onSubmit={handleSubmit} className="w-full max-w-sm bg-white rounded-2xl shadow-xl p-6 space-y-4">
{groupes.length > 1 && (
<select
@ -46,13 +76,13 @@ export default function Login() {
)}
<input
type="text"
placeholder="Prénom"
value={prenom}
onChange={(e) => setPrenom(e.target.value)}
type="email"
placeholder="Adresse courriel"
value={courriel}
onChange={(e) => setCourriel(e.target.value)}
className="w-full p-4 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue"
autoComplete="off"
autoCapitalize="words"
autoComplete="email"
inputMode="email"
/>
<input
@ -66,13 +96,20 @@ export default function Login() {
className="w-full p-4 bg-gray-50 rounded-xl text-lg text-center tracking-[1em] border-0 focus:ring-2 focus:ring-aa-blue"
/>
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer">
<input type="checkbox" checked={seSouvenir}
onChange={(e) => setSeSouvenir(e.target.checked)}
className="rounded border-gray-300 text-aa-blue focus:ring-aa-blue" />
Se souvenir de moi
</label>
{error && (
<p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>
)}
<button
type="submit"
disabled={loading || !prenom || pin.length !== 4 || !groupeId}
disabled={loading || !courriel || pin.length !== 4 || !groupeId}
className="w-full p-4 bg-aa-blue text-white rounded-xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition"
>
{loading ? '...' : 'Connexion'}

View file

@ -3,7 +3,7 @@ import { useStore } from '../stores/store';
import { api } from '../stores/api';
export default function Profil() {
const { setPage } = useStore();
const { setPage, logout } = useStore();
const [profil, setProfil] = useState(null);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({});
@ -16,6 +16,8 @@ export default function Profil() {
api.get('/membres/moi').then((data) => {
setProfil(data);
setForm({
prenom: data.prenom || '',
nom: data.nom || '',
telephone: data.telephone || '',
courriel: data.courriel || '',
date_abstinence: data.date_abstinence || '',
@ -24,15 +26,19 @@ export default function Profil() {
}, []);
const saveProfile = async () => {
if (!form.courriel.trim()) { setError('Le courriel est obligatoire'); return; }
if (!form.telephone.trim()) { setError('Le téléphone est obligatoire'); return; }
try {
const updated = await api.patch('/membres/moi', form);
const updated = await api.patch('/membres/moi', {
...form,
date_abstinence: form.date_abstinence || null,
nom: form.nom.trim() || null,
});
setProfil(updated);
setEditing(false);
setMsg('Profil mis à jour ✓');
setTimeout(() => setMsg(''), 2000);
} catch (e) {
setError(e.message);
}
} catch (e) { setError(e.message); }
};
const changePin = async () => {
@ -42,19 +48,10 @@ export default function Profil() {
setShowPin(false);
setMsg('PIN modifié ✓');
setTimeout(() => setMsg(''), 2000);
} catch (e) {
setError(e.message);
setTimeout(() => setError(''), 3000);
}
} catch (e) { setError(e.message); setTimeout(() => setError(''), 3000); }
};
if (!profil) {
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-gray-500">Chargement...</p>
</div>
);
}
if (!profil) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-500">Chargement...</p></div>;
const Field = ({ label, value }) => (
<div className="py-3 border-b border-gray-100">
@ -74,7 +71,6 @@ export default function Profil() {
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
{/* Infos */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<h2 className="font-bold text-gray-700">Informations</h2>
@ -87,21 +83,40 @@ export default function Profil() {
<>
<Field label="Prénom" value={profil.prenom} />
<Field label="Nom" value={profil.nom} />
<Field label="Courriel (identifiant)" value={profil.courriel} />
<Field label="Téléphone" value={profil.telephone} />
<Field label="Courriel" value={profil.courriel} />
<Field label="Date d'abstinence" value={profil.date_abstinence} />
</>
) : (
<div className="space-y-3 mt-3">
<p className="text-sm text-gray-500">Prénom : <strong>{profil.prenom}</strong></p>
<input type="tel" placeholder="Téléphone" value={form.telephone}
onChange={(e) => setForm({ ...form, telephone: e.target.value })}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400">Prénom *</label>
<input type="text" value={form.prenom}
onChange={(e) => setForm({ ...form, prenom: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<input type="email" placeholder="Courriel" value={form.courriel}
</div>
<div>
<label className="text-xs text-gray-400">Nom</label>
<input type="text" value={form.nom}
onChange={(e) => setForm({ ...form, nom: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
</div>
<div>
<label className="text-xs text-gray-400">Courriel (identifiant) *</label>
<input type="email" value={form.courriel}
onChange={(e) => setForm({ ...form, courriel: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<div>
<label className="text-xs text-gray-500">Date d'abstinence</label>
<label className="text-xs text-gray-400">Téléphone *</label>
<input type="tel" value={form.telephone}
onChange={(e) => setForm({ ...form, telephone: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<div>
<label className="text-xs text-gray-400">Date d'abstinence</label>
<input type="date" value={form.date_abstinence}
onChange={(e) => setForm({ ...form, date_abstinence: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
@ -145,6 +160,12 @@ export default function Profil() {
</div>
)}
</div>
{/* Déconnexion */}
<button onClick={logout}
className="w-full p-4 bg-red-50 text-red-600 rounded-2xl font-bold border border-red-200 active:scale-[0.97] transition">
Se déconnecter
</button>
</div>
</div>
);

View file

@ -0,0 +1,277 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
const STATUT_LABELS = {
proposee: { label: 'En attente', cls: 'bg-yellow-100 text-yellow-700' },
secondee: { label: 'Secondée', cls: 'bg-blue-100 text-blue-700' },
adoptee: { label: '✓ Adoptée', cls: 'bg-green-100 text-green-700' },
rejetee: { label: '✗ Rejetée', cls: 'bg-red-100 text-red-700' },
};
export default function Propositions() {
const { postes } = useStore();
const [propositions, setPropositions] = useState([]);
const [reunions, setReunions] = useState([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('actives'); // actives | resolutions | nouvelle
const [error, setError] = useState('');
const [msg, setMsg] = useState('');
// Formulaire nouvelle proposition
const [reunionId, setReunionId] = useState('');
const [sujet, setSujet] = useState('');
const [description, setDescription] = useState('');
const [typeProposition, setTypeProposition] = useState('generale');
const estExecutif = postes.some((p) =>
['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p)
);
const charger = async () => {
try {
const [props, reuns] = await Promise.all([
api.get('/propositions/'),
api.get('/presences/reunions?limite=6'),
]);
setPropositions(props);
setReunions(reuns);
if (reuns.length > 0 && !reunionId) setReunionId(reuns[0].id);
} catch (e) {
setError(e.message);
}
setLoading(false);
};
useEffect(() => { charger(); }, []);
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const soumettre = async () => {
if (!sujet.trim()) { setError('Le sujet est obligatoire'); return; }
setError('');
try {
await api.post('/propositions/', {
reunion_id: reunionId || null,
sujet: sujet.trim(),
description: description.trim() || null,
type_proposition: typeProposition,
});
setSujet(''); setDescription(''); setTypeProposition('generale'); setTab('actives');
flash('Proposition soumise ✓');
await charger();
} catch (e) { setError(e.message); }
};
const seconder = async (id) => {
setError('');
try {
await api.post(`/propositions/${id}/seconder`);
flash('Proposition secondée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const voter = async (id, statut) => {
setError('');
try {
const payload = { statut };
// Si adoption et réunion dispo, associer la proposition à la réunion
if (statut === 'adoptee' && reunions.length > 0) {
payload.reunion_id = reunions[0].id;
}
await api.patch(`/propositions/${id}/voter`, payload);
flash(statut === 'adoptee' ? 'Résolution adoptée ✓' : 'Proposition rejetée');
await charger();
} catch (e) { setError(e.message); }
};
const actives = propositions.filter((p) => ['proposee', 'secondee'].includes(p.statut));
const resolutions = propositions.filter((p) => ['adoptee', 'rejetee'].includes(p.statut));
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Propositions" sousTitre="Soumettre, seconder, résoudre" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
{/* Onglets */}
<div className="flex gap-2">
{['actives', 'resolutions', 'nouvelle'].map((t) => (
<button key={t} onClick={() => { setTab(t); setError(''); }}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition ${
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>
{t === 'actives' ? `En cours (${actives.length})` : t === 'resolutions' ? `Résolutions (${resolutions.length})` : '+ Proposer'}
</button>
))}
</div>
{/* ── Onglet : Propositions actives ── */}
{tab === 'actives' && (
actives.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📋</p>
<p className="text-gray-500">Aucune proposition en cours</p>
<button onClick={() => setTab('nouvelle')} className="mt-3 text-aa-blue font-bold text-sm">
+ Soumettre une proposition
</button>
</div>
) : (
<div className="space-y-3">
{actives.map((p) => {
const st = STATUT_LABELS[p.statut];
return (
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
{p.type_proposition && p.type_proposition !== 'generale' && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium mr-1 ${
p.type_proposition === 'nomination' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
}`}>{p.type_proposition === 'nomination' ? '🙋 Nomination' : '📮 Contribution'}</span>
)}
<p className="font-bold text-gray-800 mt-1">{p.sujet}</p>
{p.description && <p className="text-sm text-gray-500 mt-1">{p.description}</p>}
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${st.cls}`}>{st.label}</span>
</div>
<div className="mt-3 text-xs text-gray-400 space-y-1">
<p>Proposée par <span className="font-medium text-gray-600">{p.proposeur_prenom}</span></p>
{p.secondeur_prenom && <p>Secondée par <span className="font-medium text-gray-600">{p.secondeur_prenom}</span></p>}
</div>
{/* Actions */}
<div className="flex gap-2 mt-3">
{p.statut === 'proposee' && (
<button onClick={() => seconder(p.id)}
className="flex-1 py-2 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold active:scale-[0.97] transition border border-blue-200">
✋ Seconder
</button>
)}
{p.statut === 'secondee' && estExecutif && (
<>
<button onClick={() => voter(p.id, 'adoptee')}
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold active:scale-[0.97] transition border border-green-200">
✓ Adopter
</button>
<button onClick={() => voter(p.id, 'rejetee')}
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold active:scale-[0.97] transition border border-red-200">
✗ Rejeter
</button>
</>
)}
</div>
</div>
);
})}
</div>
)
)}
{/* ── Onglet : Registre des résolutions ── */}
{tab === 'resolutions' && (
resolutions.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📜</p>
<p className="text-gray-500">Aucune résolution encore</p>
</div>
) : (
<div className="space-y-3">
{resolutions.map((p) => {
const st = STATUT_LABELS[p.statut];
return (
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
{p.numero_resolution && (
<p className="text-xs font-mono text-aa-blue font-bold">{p.numero_resolution}</p>
)}
{p.type_proposition && p.type_proposition !== 'generale' && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
p.type_proposition === 'nomination' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
}`}>{p.type_proposition === 'nomination' ? '🙋 Nomination' : '📮 Contribution'}</span>
)}
<p className="font-bold text-gray-800">{p.sujet}</p>
{p.description && <p className="text-sm text-gray-500 mt-1">{p.description}</p>}
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${st.cls}`}>{st.label}</span>
</div>
<div className="mt-2 text-xs text-gray-400">
<p>{p.proposeur_prenom} → {p.secondeur_prenom || '?'} · {p.date_vote}</p>
{p.note && <p className="italic mt-1">{p.note}</p>}
</div>
</div>
);
})}
</div>
)
)}
{/* ── Onglet : Nouvelle proposition ── */}
{tab === 'nouvelle' && (
<div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Type de proposition</label>
<div className="grid grid-cols-3 gap-2">
{[
['generale', '📋 Générale'],
['contribution', '📮 Contribution'],
['nomination', '🙋 Nomination'],
].map(([val, label]) => (
<button key={val} type="button" onClick={() => setTypeProposition(val)}
className={`p-2 rounded-xl text-xs font-bold border-2 ${
typeProposition === val ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}>{label}</button>
))}
</div>
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Sujet de la proposition</label>
<input type="text" value={sujet} onChange={(e) => setSujet(e.target.value)}
placeholder={
typeProposition === 'contribution' ? 'Ex: Envoi de 200$ au district 87-16' :
typeProposition === 'nomination' ? 'Ex: Nomination de Marie au poste de secrétaire' :
'Ex: Augmenter la contribution au district à 35%'
}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
autoFocus />
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Détails (optionnel)</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)}
placeholder="Contexte, justification..."
rows={3} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
</div>
{reunions.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Réunion d'affaires (optionnel)</label>
<select value={reunionId} onChange={(e) => setReunionId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">— Aucune (à associer lors du vote) —</option>
{reunions.map((r) => (
<option key={r.id} value={r.id}>
{new Date(r.date_reunion).toLocaleDateString('fr-CA', { year: 'numeric', month: 'long', day: 'numeric' })}
</option>
))}
</select>
</div>
)}
<button onClick={soumettre} disabled={!sujet.trim()}
className="w-full p-5 bg-aa-blue text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
📋 Soumettre la proposition
</button>
</div>
)}
</div>
</div>
);
}

View file

@ -9,30 +9,33 @@ const MOIS_FR = [
export default function Rapports() {
const [disponibles, setDisponibles] = useState([]);
const [anneesResolutions, setAnneesResolutions] = useState([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(null);
useEffect(() => {
api.get('/rapports/disponibles').then((data) => {
setDisponibles(data);
Promise.all([
api.get('/rapports/disponibles'),
api.get('/rapports/resolutions/annees'),
]).then(([dispo, annees]) => {
setDisponibles(dispo);
setAnneesResolutions(annees);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const telecharger = async (type, annee, mois) => {
const key = `${type}-${annee}-${mois}`;
const telecharger = async (path, key, filename) => {
setGenerating(key);
try {
const token = localStorage.getItem('gm_token');
const url = `/api/rapports/${type}/${annee}/${mois}`;
const res = await fetch(url, {
const res = await fetch(`/api/rapports/${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Erreur de génération');
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `rapport_${type}_${annee}_${mois.toString().padStart(2, '0')}.pdf`;
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
} catch (e) {
@ -41,21 +44,25 @@ export default function Rapports() {
setGenerating(null);
};
const vide = disponibles.length === 0 && anneesResolutions.length === 0;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Rapports PDF" sousTitre="C1 — Finances, réunions" />
<PageHeader titre="Rapports PDF" sousTitre="C1 — Finances, réunions, résolutions" />
<div className="p-4 space-y-4">
{loading ? (
<p className="text-center text-gray-400 py-8">Chargement...</p>
) : disponibles.length === 0 ? (
) : vide ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📄</p>
<p className="text-gray-500">Aucun rapport disponible</p>
<p className="text-gray-400 text-sm mt-1">Les rapports apparaîtront après les premières saisies</p>
</div>
) : (
disponibles.map((m) => (
<>
{/* Rapports mensuels */}
{disponibles.map((m) => (
<div key={`${m.annee}-${m.mois}`} className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="font-bold text-gray-800 mb-3">
{MOIS_FR[m.mois]} {m.annee}
@ -63,7 +70,7 @@ export default function Rapports() {
<div className="grid gap-2">
{m.finances && (
<button
onClick={() => telecharger('finances', m.annee, m.mois)}
onClick={() => telecharger(`finances/${m.annee}/${m.mois}`, `finances-${m.annee}-${m.mois}`, `rapport_finances_${m.annee}_${String(m.mois).padStart(2,'0')}.pdf`)}
disabled={generating === `finances-${m.annee}-${m.mois}`}
className="flex items-center gap-3 p-3 bg-green-50 rounded-xl text-left active:scale-[0.98] transition border border-green-200"
>
@ -72,14 +79,12 @@ export default function Rapports() {
<p className="font-bold text-green-800 text-sm">Rapport financier</p>
<p className="text-xs text-green-600">Collectes, dépenses, solde</p>
</div>
<span className="text-green-400">
{generating === `finances-${m.annee}-${m.mois}` ? '⏳' : '↓'}
</span>
<span className="text-green-400">{generating === `finances-${m.annee}-${m.mois}` ? '⏳' : '↓'}</span>
</button>
)}
{m.reunions && (
<button
onClick={() => telecharger('reunions', m.annee, m.mois)}
onClick={() => telecharger(`reunions/${m.annee}/${m.mois}`, `reunions-${m.annee}-${m.mois}`, `rapport_reunions_${m.annee}_${String(m.mois).padStart(2,'0')}.pdf`)}
disabled={generating === `reunions-${m.annee}-${m.mois}`}
className="flex items-center gap-3 p-3 bg-purple-50 rounded-xl text-left active:scale-[0.98] transition border border-purple-200"
>
@ -88,14 +93,36 @@ export default function Rapports() {
<p className="font-bold text-purple-800 text-sm">Résumé des réunions</p>
<p className="text-xs text-purple-600">Thèmes, annonces, animateurs</p>
</div>
<span className="text-purple-400">
{generating === `reunions-${m.annee}-${m.mois}` ? '⏳' : '↓'}
</span>
<span className="text-purple-400">{generating === `reunions-${m.annee}-${m.mois}` ? '⏳' : '↓'}</span>
</button>
)}
</div>
</div>
))
))}
{/* Registre des résolutions */}
{anneesResolutions.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="font-bold text-gray-800 mb-3">📜 Registre des résolutions</h3>
<div className="grid gap-2">
{anneesResolutions.map((a) => (
<button key={a.annee}
onClick={() => telecharger(`resolutions/${a.annee}`, `res-${a.annee}`, `registre_resolutions_${a.annee}.pdf`)}
disabled={generating === `res-${a.annee}`}
className="flex items-center gap-3 p-3 bg-orange-50 rounded-xl text-left active:scale-[0.98] transition border border-orange-200"
>
<span className="text-xl">📜</span>
<div className="flex-1">
<p className="font-bold text-orange-800 text-sm">Résolutions {a.annee}</p>
<p className="text-xs text-orange-600">Propositions adoptées et rejetées</p>
</div>
<span className="text-orange-400">{generating === `res-${a.annee}` ? '⏳' : '↓'}</span>
</button>
))}
</div>
</div>
)}
</>
)}
</div>
</div>

View file

@ -6,6 +6,7 @@ import SuccessCheck from '../components/SuccessCheck';
export default function RedigerPV() {
const [reunions, setReunions] = useState([]);
const [pvList, setPvList] = useState([]);
const [propositions, setPropositions] = useState([]);
const [reunionId, setReunionId] = useState('');
const [contenu, setContenu] = useState('');
const [decisions, setDecisions] = useState([]);
@ -18,13 +19,34 @@ export default function RedigerPV() {
Promise.all([
api.get('/presences/reunions'),
api.get('/pv/'),
]).then(([r, p]) => {
api.get('/propositions/?statut=secondee'),
]).then(([r, p, props]) => {
setReunions(r);
setPvList(p);
setPropositions(props);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
// Auto-renseigner les décisions depuis les propositions secondées
const chargerPropositions = () => {
if (propositions.length === 0) return;
const decisionsAuto = propositions.map((p) => ({
sujet: p.sujet,
decision: p.description || '',
proposeur: p.proposeur_prenom,
secondeur: p.secondeur_prenom || '',
statut: 'en_attente',
proposition_id: p.id,
}));
setDecisions((prev) => {
// Ne pas dupliquer les propositions déjà ajoutées
const existants = new Set(prev.map((d) => d.proposition_id).filter(Boolean));
const nouvelles = decisionsAuto.filter((d) => !existants.has(d.proposition_id));
return [...prev, ...nouvelles];
});
};
const ajouterDecision = () => {
setDecisions([...decisions, { sujet: '', decision: '', proposeur: '', secondeur: '', statut: 'en_attente' }]);
};
@ -40,7 +62,7 @@ export default function RedigerPV() {
};
const handleSubmit = async () => {
if (!reunionId) { setError('Sélectionnez une réunion d\'affaires'); return; }
if (!reunionId) { setError("Sélectionnez une réunion d'affaires"); return; }
if (!contenu.trim()) { setError('Le contenu du PV est obligatoire'); return; }
setError('');
try {
@ -141,25 +163,42 @@ export default function RedigerPV() {
<label className="text-xs text-gray-400 block mb-1">Contenu du PV</label>
<textarea value={contenu} onChange={(e) => setContenu(e.target.value)} rows={8}
placeholder="Ouverture de la réunion à 19h00...&#10;&#10;Lecture du PV précédent...&#10;&#10;Rapport du trésorier...&#10;&#10;Rapport du RSG..."
placeholder={"Ouverture de la réunion à 19h00...\n\nLecture du PV précédent...\n\nRapport du trésorier...\n\nRapport du RSG..."}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
</div>
{/* Décisions */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="font-bold text-gray-700">Décisions</h2>
<h2 className="font-bold text-gray-700">Affaires nouvelles / Décisions</h2>
<div className="flex gap-2">
{propositions.length > 0 && (
<button onClick={chargerPropositions}
className="text-purple-600 text-xs font-medium bg-purple-50 px-2 py-1 rounded-lg active:opacity-60">
📋 Importer propositions ({propositions.length})
</button>
)}
<button onClick={ajouterDecision}
className="text-aa-blue text-sm font-medium active:opacity-60">+ Ajouter</button>
</div>
</div>
{decisions.length === 0 ? (
<p className="text-gray-400 text-sm">Aucune décision (cliquez + Ajouter)</p>
<div className="text-gray-400 text-sm">
<p>Aucune décision.</p>
{propositions.length > 0 && (
<p className="mt-1 text-purple-500 text-xs">
{propositions.length} proposition{propositions.length > 1 ? 's' : ''} secondée{propositions.length > 1 ? 's' : ''} à traiter — cliquez « Importer propositions ».
</p>
)}
</div>
) : (
<div className="space-y-3">
{decisions.map((d, i) => (
<div key={i} className="bg-gray-50 rounded-xl p-3 space-y-2">
<div key={i} className={`rounded-xl p-3 space-y-2 ${d.proposition_id ? 'bg-purple-50' : 'bg-gray-50'}`}>
<div className="flex justify-between">
<span className="text-xs text-gray-400">Décision {i + 1}</span>
<span className="text-xs text-gray-400">
{d.proposition_id ? '📋 Proposition importée' : `Décision ${i + 1}`}
</span>
<button onClick={() => supprimerDecision(i)} className="text-red-400 text-xs">Supprimer</button>
</div>
<input type="text" placeholder="Sujet" value={d.sujet}

View file

@ -0,0 +1,372 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
const STATUT_COL = {
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 Tresorerie() {
const [soldes, setSoldes] = useState(null);
const [collectes, setCollectes] = useState([]);
const [operations, setOperations] = useState([]);
const [registre, setRegistre] = useState([]);
const [tab, setTab] = useState('soldes');
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
// Formulaires
const [depotMontant, setDepotMontant] = useState('');
const [depotDate, setDepotDate] = useState(new Date().toISOString().slice(0, 10));
const [depotNote, setDepotNote] = useState('');
const [depotType, setDepotType] = useState('depot'); // depot | retrait | releve
const moisActuel = new Date().getMonth() + 1;
const anneeActuelle = new Date().getFullYear();
const charger = async () => {
try {
const [s, c, o, r] = await Promise.all([
api.get('/collectes/soldes'),
api.get('/collectes/?limite=20'),
api.get('/banque/'),
api.get(`/collectes/registre?mois=${moisActuel}&annee=${anneeActuelle}`),
]);
setSoldes(s);
setCollectes(c);
setOperations(o);
setRegistre(r);
} catch (e) { setError(e.message); }
setLoading(false);
};
useEffect(() => { charger(); }, []);
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const accuserReception = async (id) => {
setError('');
try {
await api.post(`/collectes/${id}/reception`);
flash('Réception confirmée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const recevoirVente = async (id) => {
setError('');
try {
await api.post(`/ventes-litterature/${id}/recevoir`);
flash('Vente reçue ✓');
await charger();
} catch (e) { setError(e.message); }
};
const enregistrerOperation = async () => {
if (!depotMontant || parseFloat(depotMontant) <= 0) { setError('Montant invalide'); return; }
setError('');
try {
await api.post('/banque/', {
type: depotType,
montant: parseFloat(depotMontant),
date_operation: depotDate,
note: depotNote.trim() || null,
});
setDepotMontant(''); setDepotNote(''); setTab('soldes');
const labels = { depot: 'Dépôt', retrait: 'Retrait', releve: 'Relevé' };
flash(`${labels[depotType]} enregistré ✓`);
await charger();
} catch (e) { setError(e.message); }
};
const aRecevoir = collectes.filter((c) => c.statut !== 'recu');
const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Trésorerie" sousTitre="Soldes, réceptions, registre" />
<div className="p-4 space-y-4">
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
{/* Onglets */}
<div className="flex gap-1 overflow-x-auto">
{[
['soldes', '💰 Soldes'],
['receptions', `📥 À recevoir (${aRecevoir.length + (soldes?.ventes_non_remises_details?.length || 0)})`],
['registre', '📒 Registre'],
['banque', '🏦 Opération'],
].map(([t, label]) => (
<button key={t} onClick={() => { setTab(t); setError(''); }}
className={`flex-1 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>{label}</button>
))}
</div>
{/* ── Soldes ── */}
{tab === 'soldes' && soldes && (
<div className="space-y-3">
{/* Solde bancaire + disponible */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-blue-50 rounded-xl">
<p className="text-xs text-blue-600">Solde banque</p>
<p className="text-2xl font-bold text-blue-700">{soldes.solde_banque.toFixed(2)} $</p>
{soldes.date_releve && <p className="text-xs text-blue-400 mt-1">Relevé : {soldes.date_releve}</p>}
</div>
<div className="text-center p-3 bg-green-50 rounded-xl">
<p className="text-xs text-green-600">Disponible</p>
<p className={`text-2xl font-bold ${soldes.disponible >= 0 ? 'text-green-700' : 'text-red-600'}`}>
{soldes.disponible.toFixed(2)} $
</p>
<p className="text-xs text-green-500 mt-1">Après réserves</p>
</div>
</div>
</div>
{/* Encaisse */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-400">Encaisse (cash en main)</p>
<p className="text-xl font-bold text-gray-700">{soldes.encaisse.toFixed(2)} $</p>
</div>
</div>
</div>
{/* Réserves */}
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-200">
<p className="font-bold text-amber-700 text-sm mb-2">Réserves ({soldes.total_reserves.toFixed(2)} $)</p>
<div className="space-y-1 text-xs text-amber-600">
<div className="flex justify-between">
<span>Réserve prudente</span>
<span className="font-bold">{soldes.reserve_prudente.toFixed(2)} $</span>
</div>
<div className="flex justify-between">
<span>Réserve anniversaire (+20$/mois)</span>
<span className="font-bold">{soldes.reserve_anniversaire.toFixed(2)} $</span>
</div>
</div>
</div>
{/* Engagements */}
{soldes.engagements_non_debites > 0 && (
<div className="bg-orange-50 rounded-2xl p-4 border border-orange-200">
<p className="font-bold text-orange-700 text-sm">
Engagements non débités : {soldes.engagements_non_debites.toFixed(2)} $
</p>
<p className="text-xs text-orange-500 mt-1">Dépenses approuvées en attente de débit</p>
</div>
)}
{/* Argent en circulation */}
{soldes.en_possession > 0 && (
<div className="bg-yellow-50 rounded-2xl p-4 border border-yellow-200">
<p className="font-bold text-yellow-700 text-sm">⚠️ En circulation : {soldes.en_possession.toFixed(2)} $</p>
<div className="mt-2 space-y-1">
{soldes.en_possession_details.map((d, i) => (
<p key={i} className="text-xs text-yellow-600">
{d.date} — {d.montant.toFixed(2)} $ chez <span className="font-bold">{d.detenteur}</span>
</p>
))}
</div>
</div>
)}
</div>
)}
{/* ── Réceptions (collectes + ventes littérature) ── */}
{tab === 'receptions' && (
<div className="space-y-4">
{/* Collectes à recevoir */}
{aRecevoir.length > 0 && (
<div>
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 px-1">Collectes</h3>
<div className="space-y-3">
{aRecevoir.map((c) => {
const st = STATUT_COL[c.statut] || STATUT_COL.en_attente;
return (
<div key={c.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800 text-lg">{parseFloat(c.montant).toFixed(2)} $</p>
<p className="text-xs text-gray-400">{c.date_collecte} · Chez {c.detenteur_prenom}</p>
<p className="text-xs text-gray-400">Saisi par {c.saisi_par_prenom}</p>
</div>
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${st.cls}`}>{st.label}</span>
</div>
<button onClick={() => accuserReception(c.id)}
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97] transition">
✓ Accuser réception
</button>
</div>
);
})}
</div>
</div>
)}
{/* Ventes de littérature à recevoir */}
{soldes?.ventes_non_remises_details?.length > 0 && (
<div>
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 px-1">
Ventes de littérature ({soldes.ventes_non_remises.toFixed(2)} $)
</h3>
<div className="space-y-3">
{soldes.ventes_non_remises_details.map((v) => (
<div key={v.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex-1">
<p className="font-bold text-gray-800">{parseFloat(v.montant).toFixed(2)} $</p>
<p className="text-xs text-gray-500">{v.titre}</p>
<p className="text-xs text-gray-400">{v.date} · Vendu par {v.vendu_par}</p>
</div>
<button onClick={() => recevoirVente(v.id)}
className="w-full mt-3 py-2 bg-purple-50 text-purple-700 rounded-xl text-sm font-bold border border-purple-200 active:scale-[0.97] transition">
✓ Confirmer la remise
</button>
</div>
))}
</div>
</div>
)}
{aRecevoir.length === 0 && (!soldes?.ventes_non_remises_details || soldes.ventes_non_remises_details.length === 0) && (
<div className="text-center py-12">
<p className="text-4xl mb-2">✅</p>
<p className="text-gray-500">Tout est reçu</p>
</div>
)}
</div>
)}
{/* ── Registre des transactions ── */}
{tab === 'registre' && (
<div className="space-y-3">
<div className="bg-white rounded-2xl p-3 shadow-sm">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">
Registre — {MOIS_NOMS[moisActuel]} {anneeActuelle}
</p>
</div>
{registre.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📒</p>
<p className="text-gray-500">Aucune transaction ce mois-ci</p>
</div>
) : (
<>
{registre.map((t, i) => (
<div key={i} className="bg-white rounded-xl p-3 shadow-sm flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-gray-800 truncate">{t.description}</p>
<p className="text-xs text-gray-400">{t.date} · {t.par}</p>
</div>
{t.entree > 0 && <p className="font-bold text-sm text-green-600 whitespace-nowrap">+{t.entree.toFixed(2)} $</p>}
{t.sortie > 0 && <p className="font-bold text-sm text-red-600 whitespace-nowrap">-{t.sortie.toFixed(2)} $</p>}
</div>
))}
<div className="bg-gray-50 rounded-xl p-3 flex justify-between text-sm font-bold">
<span className="text-gray-600">Total du mois</span>
<div className="flex gap-4">
<span className="text-green-600">+{registre.reduce((s, t) => s + t.entree, 0).toFixed(2)} $</span>
<span className="text-red-600">-{registre.reduce((s, t) => s + t.sortie, 0).toFixed(2)} $</span>
</div>
</div>
</>
)}
</div>
)}
{/* ── Opération bancaire ── */}
{tab === 'banque' && (
<div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Type d'opération</label>
<div className="grid grid-cols-3 gap-2">
{[
['depot', '🏦 Dépôt'],
['retrait', '📤 Retrait'],
['releve', '📋 Relevé'],
].map(([val, label]) => (
<button key={val} onClick={() => setDepotType(val)}
className={`p-3 rounded-xl text-xs font-bold border-2 ${
depotType === val ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}>{label}</button>
))}
</div>
{depotType === 'releve' && (
<p className="text-xs text-blue-600 mt-2 bg-blue-50 p-2 rounded-lg">
Le relevé enregistre le solde réel de votre compte bancaire tel qu'indiqué sur le relevé mensuel.
</p>
)}
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">
{depotType === 'releve' ? 'Solde du relevé' : 'Montant'}
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-2xl text-gray-400">$</span>
<input type="number" inputMode="decimal" step="0.01" min="0" placeholder="0.00"
value={depotMontant} onChange={(e) => setDepotMontant(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-blue-500"
autoFocus />
</div>
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date</label>
<input type="date" value={depotDate} onChange={(e) => setDepotDate(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Note (optionnel)</label>
<input type="text" value={depotNote} onChange={(e) => setDepotNote(e.target.value)}
placeholder={depotType === 'releve' ? 'Ex: Relevé avril 2026' : 'Ex: Dépôt Caisse Desjardins'}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<button onClick={enregistrerOperation} disabled={!depotMontant || parseFloat(depotMontant) < 0}
className="w-full p-5 bg-blue-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
{depotType === 'depot' ? '🏦 Enregistrer le dépôt' :
depotType === 'retrait' ? '📤 Enregistrer le retrait' :
'📋 Enregistrer le relevé'}
</button>
{/* Historique opérations */}
{operations.length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="font-bold text-gray-700 text-sm mb-2">Dernières opérations</h3>
{operations.slice(0, 8).map((op) => (
<div key={op.id} className="flex justify-between py-2 border-b border-gray-50 last:border-0">
<div>
<p className="text-sm font-medium text-gray-700">
{op.type === 'depot' ? '🏦 Dépôt' : op.type === 'retrait' ? '📤 Retrait' : '📋 Relevé'}
</p>
<p className="text-xs text-gray-400">{op.date_operation} · {op.effectue_par_prenom}</p>
{op.note && <p className="text-xs text-gray-400 italic">{op.note}</p>}
</div>
<p className={`font-bold text-sm ${
op.type === 'depot' ? 'text-green-600' : op.type === 'retrait' ? 'text-red-600' : 'text-blue-600'
}`}>
{op.type === 'releve' ? '=' : op.type === 'depot' ? '+' : '-'}{op.montant.toFixed(2)} $
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -33,7 +33,15 @@ class ApiClient {
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Erreur serveur' }));
throw new Error(err.detail || 'Erreur');
const detail = err.detail;
let message;
if (Array.isArray(detail)) {
// Erreur de validation Pydantic (422) — extraire le premier message lisible
message = detail.map(d => d.msg || String(d)).join(', ');
} else {
message = detail || 'Erreur';
}
throw new Error(message);
}
return res.json();
}

View file

@ -10,6 +10,7 @@ export const useStore = create((set, get) => ({
// Accueil
postes: [],
groupe: null,
modulesConsultation: [],
modulesSaisie: [],
modulesGestion: [],
@ -17,9 +18,12 @@ export const useStore = create((set, get) => ({
// UI
loading: false,
error: null,
page: 'login', // login | inscription | accueil | profil
page: localStorage.getItem('gm_page') || 'login',
setPage: (page) => set({ page, error: null }),
setPage: (page) => {
localStorage.setItem('gm_page', page);
set({ page, error: null });
},
setError: (error) => set({ error }),
loadGroupes: async () => {
@ -31,22 +35,27 @@ export const useStore = create((set, get) => ({
}
},
login: async (prenom, pin, groupeId) => {
login: async (courriel, pin, groupeId, seSouvenir = false) => {
set({ loading: true, error: null });
try {
const data = await api.post('/auth/connexion', {
prenom, pin, groupe_id: groupeId,
courriel, pin, groupe_id: groupeId, se_souvenir: seSouvenir,
});
api.setToken(data.access_token);
localStorage.setItem('gm_groupe_id', groupeId);
if (seSouvenir) {
localStorage.setItem('gm_courriel', courriel);
} else {
localStorage.removeItem('gm_courriel');
}
set({
token: data.access_token,
membre: data.membre,
groupeId,
loading: false,
page: 'accueil',
});
await get().loadAccueil();
get().setPage('accueil');
} catch (e) {
set({ loading: false, error: e.message });
throw e;
@ -56,16 +65,21 @@ export const useStore = create((set, get) => ({
inscription: async (data) => {
set({ loading: true, error: null });
try {
const res = await api.post('/auth/inscription', data);
const payload = {
...data,
nom: data.nom || null,
date_abstinence: data.date_abstinence || null,
};
const res = await api.post('/auth/inscription', payload);
api.setToken(res.access_token);
localStorage.setItem('gm_groupe_id', data.groupe_id || '');
set({
token: res.access_token,
membre: res.membre,
loading: false,
page: 'accueil',
});
await get().loadAccueil();
get().setPage('accueil');
} catch (e) {
set({ loading: false, error: e.message });
throw e;
@ -77,6 +91,7 @@ export const useStore = create((set, get) => ({
const data = await api.get('/accueil/');
set({
postes: data.postes,
groupe: data.groupe,
modulesConsultation: data.modules_consultation,
modulesSaisie: data.modules_saisie,
modulesGestion: data.modules_gestion,
@ -90,9 +105,11 @@ export const useStore = create((set, get) => ({
logout: () => {
api.setToken(null);
localStorage.removeItem('gm_groupe_id');
localStorage.removeItem('gm_page');
set({
token: null,
membre: null,
groupe: null,
groupeId: null,
postes: [],
modulesConsultation: [],