dernière mouture

This commit is contained in:
Daniel Allaire 2026-05-25 17:39:38 -04:00
parent 5e5c38fb21
commit a936e7ed81
97 changed files with 4245 additions and 2916 deletions

2
.gitignore vendored
View file

@ -1,4 +1,6 @@
node_modules/
dist/
__pycache__/
*.py[cod]
*.retry
ansible/group_vars/vault.yml

View file

@ -11,29 +11,28 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche.
| Frontend | React 18 + Vite (PWA installable) |
| UI | Tailwind CSS (mobile-first) |
| État | Zustand |
| Backend | FastAPI (Python) async, 82 routes |
| DB | PostgreSQL + SQLAlchemy async (23+ tables) |
| Backend | FastAPI (Python) async |
| DB | PostgreSQL + SQLAlchemy async |
| Auth | JWT 30 jours + bcrypt 12 rounds (prénom + PIN) |
| PDF | WeasyPrint |
| Serveur | NGINX (statiques + reverse proxy HTTPS) |
## Modules
### Consultation (C1-C7) — Tous les membres
### Consultation — Tous les membres
- **C1** Rapports PDF — finances, PV, résolutions, rapport complet
- **C2** Événements à venir — anniversaires de sobriété et événements ad hoc du district
- **C2** Événements — anniversaires de sobriété, événements et résolutions
- **C4** Littérature — inventaire avec prix, alerte stock bas
- **C5** Décisions en cours — suivi des résolutions
- **C6** Votes ouverts — sondages de conscience de groupe, vote anonyme
- **C5** Gouvernance — décisions du groupe, propositions, résolutions, nominations et contributions
- **C7** Mon profil — modifier, changer PIN, notifications
- **C8** Propositions — soumettre, seconder, voter (créables en tout temps)
- **C9** S'impliquer — postes vacants, candidatures
- **C9** Postes à pourvoir — postes vacants, candidatures
### Saisie hebdomadaire (S1-S5)
- **S1** Collecte 7e Tradition — saisie le jour de réunion, confirmation par un second membre, historique mensuel avec totaux
- **S1T** Trésorerie — soldes (banque + encaisse + disponible après réserves), réception des collectes et ventes de littérature, registre détaillé des transactions, opérations bancaires (dépôt/retrait/relevé)
- **S0** Réunion hebdomadaire — formulaire regroupé pour rapport, collecte, jeton et littérature
- **S1** Collecte 7e Tradition — réservé aux postes exécutifs
- **S1T** Trésorerie — soldes (banque + encaisse + réserves affectées), réception des collectes et ventes de littérature, registre détaillé des transactions, opérations bancaires (dépôt/retrait/relevé)
- **S2** Remise de jeton — 9 types, historique
- **S3** Vente littérature — stock auto-décrémenté, montant à remettre au trésorier
- **S4** Rapport de réunion — thème + annonces
@ -41,6 +40,7 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche.
### Saisie mensuelle (M1-M6)
- **M0** Assemblée d'affaires — formulaire regroupé pour réunion, présences et PV
- **M1** Rédiger PV — auto-renseigné depuis les propositions secondées, décisions structurées
- **M2** Présences réunion d'affaires
- **M3** Rapport RSG — template 3 sections
@ -50,27 +50,23 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche.
### Gestion — Exécutif
- **G1** Inviter un membre — code 48h
- **G2** Gérer les membres — fiche complète, droit à l'oubli
- **G6** Lancer un sondage — oui/non, choix multiple, unanimité
- **G2** Gestion des membres — membres, invitations, droit à l'oubli
- **G9** Paramètres du groupe — nom, horaire, adresse, format
- **G11** Gestion des mandats — affecter, terminer, planifier les rotations
- **G12** Gestion des postes — créer, abolir, candidatures, accès aux modules
## Trésorerie
Le calcul des soldes repose sur le relevé bancaire mensuel :
La source de vérité financière est le registre `transactions_comptables`.
- **Solde banque** = dernier relevé + dépôts − retraits depuis le relevé
- **Disponible** = solde banque − engagements non débités − réserves
- **Réserves** = réserve prudente (500 $) + réserve anniversaire (20 $/mois)
- **Encaisse** = collectes reçues + ventes remises − dépôts − dépenses cash
- **Banque** et **encaisse** sont calculées depuis les écritures comptables.
- **Réserves** est calculé depuis l'historique `mouvements_reserves`.
- Toute dépense ou contribution par chèque/virement reste un engagement jusqu'à confirmation du débit.
- Les rapports mensuels doivent s'appuyer sur ce registre.
Toute dépense par chèque ou virement est un engagement. Le trésorier confirme le débit lorsque l'argent quitte effectivement le compte.
## Gouvernance
## Propositions et résolutions
Les propositions peuvent être créées en tout temps par n'importe quel membre, sans nécessiter une réunion d'affaires. Le cycle est : proposée → secondée → votée (adoptée/rejetée). Lorsqu'adoptée, la proposition devient une résolution numérotée (RES-2026-001) et peut être associée à l'assemblée d'affaires lors du vote.
Les propositions peuvent être créées en tout temps par n'importe quel membre, sans type initial. Le cycle est : proposée → secondée → transformée en résolution par l'exécutif. Lorsqu'adoptée, la proposition devient une décision numérotée avec les préfixes `RES` (résolution), `NOM` (nomination) ou `DON` (contribution), et les sections « ÉTANT DONNÉ QUE » et « LE GROUPE A DÉCIDÉ DE ».
Le PV de la réunion d'affaires s'auto-renseigne depuis les propositions secondées en attente de vote.
@ -87,9 +83,10 @@ Le PV de la réunion d'affaires s'auto-renseigne depuis les propositions second
```
backend/ API FastAPI (Python)
app/
core/ Config, DB, sécurité
models/ Modèles SQLAlchemy (23+ tables)
routers/ Routes API (26 groupes)
core/ Config, DB, sécurité, rôles, règles communes
services/ Logique métier réutilisable
models/ Modèles SQLAlchemy
routers/ Routes API
schemas/ Schémas Pydantic
frontend/ React + Vite + Tailwind
src/
@ -103,6 +100,7 @@ docs/ DEPLOIEMENT.md, RUNBOOK.md
## Documentation
- [Guide utilisateur](docs/UTILISATEURS.md)
- [Architecture technique](docs/ARCHITECTURE.md)
- [Déploiement Ansible](docs/ANSIBLE.md)
- [Runbook d'exploitation](docs/RUNBOOK.md)

View file

@ -33,6 +33,9 @@ groupe_meditation_database_url: >-
groupe_meditation_cors_origins:
- "https://{{ groupe_meditation_domain }}"
groupe_meditation_invitation_expire_hours: 48
groupe_meditation_vapid_public_key: ""
groupe_meditation_vapid_private_key_b64: ""
groupe_meditation_vapid_subject: "mailto:admin@{{ groupe_meditation_domain }}"
# Initialisation applicative. Le seed existant est interactif; Ansible lui
# fournit ces valeurs seulement si la base ne contient encore aucun groupe.
@ -52,4 +55,3 @@ groupe_meditation_certbot_email: admin@example.com
# Sauvegardes.
groupe_meditation_backup_dir: /var/backups/groupe-meditation
groupe_meditation_backup_retention_days: 30

View file

@ -3,4 +3,6 @@ DATABASE_URL={{ groupe_meditation_database_url }}
JWT_SECRET={{ groupe_meditation_jwt_secret }}
CORS_ORIGINS={{ groupe_meditation_cors_origins | to_json }}
INVITATION_EXPIRE_HOURS={{ groupe_meditation_invitation_expire_hours }}
VAPID_PUBLIC_KEY={{ groupe_meditation_vapid_public_key }}
VAPID_PRIVATE_KEY_B64={{ groupe_meditation_vapid_private_key_b64 }}
VAPID_SUBJECT={{ groupe_meditation_vapid_subject }}

View file

@ -18,9 +18,24 @@ server {
root {{ groupe_meditation_web_root }};
index index.html;
location = /index.html {
expires off;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
location ~ ^/(sw\.js|push-sw\.js|registerSW\.js|manifest\.webmanifest)$ {
expires off;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
try_files $uri =404;
}
location ~* \.(js|css|png|jpg|svg|woff2|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cache-Control "public, immutable" always;
try_files $uri =404;
}
@ -28,10 +43,5 @@ server {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
client_max_body_size 5M;
}

78
backend/app/core/audit.py Normal file
View file

@ -0,0 +1,78 @@
"""Journalisation automatique des changements API."""
from jose import JWTError, jwt
from sqlalchemy import select
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
from app.core.database import async_session
from app.models.journal_action import JournalAction
from app.models.membre import Membre
from app.services.notifications import notify_group_change
MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
SKIPPED_PREFIXES = (
"/api/auth/connexion",
"/api/health",
)
def _token_payload(request):
header = request.headers.get("authorization") or ""
if not header.lower().startswith("bearer "):
return None
token = header.split(" ", 1)[1].strip()
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
except JWTError:
return None
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
path = request.url.path
if (
request.method not in MUTATING_METHODS
or not path.startswith("/api/")
or any(path.startswith(prefix) for prefix in SKIPPED_PREFIXES)
or response.status_code >= 400
):
return response
payload = _token_payload(request)
if not payload or not payload.get("sub"):
return response
async with async_session() as db:
membre = None
result = await db.execute(select(Membre).where(Membre.id == payload["sub"]))
membre = result.scalar_one_or_none()
if not membre:
return response
details = {
"query": dict(request.query_params),
"impersonification": bool(payload.get("adm")),
}
db.add(JournalAction(
groupe_id=membre.groupe_id,
membre_id=membre.id,
admin_id=payload.get("adm"),
methode=request.method,
chemin=path,
statut_http=response.status_code,
adresse_ip=request.client.host if request.client else None,
user_agent=(request.headers.get("user-agent") or "")[:255] or None,
details=details,
))
await notify_group_change(
db,
groupe_id=membre.groupe_id,
actor_id=membre.id,
title="Changement dans l'application",
body=f"{membre.prenom} a enregistré une modification.",
url="/",
)
await db.commit()
return response

View file

@ -17,6 +17,9 @@ class Settings(BaseSettings):
"https://groupe-meditation.87-16.org",
]
INVITATION_EXPIRE_HOURS: int = 48
VAPID_PUBLIC_KEY: str | None = None
VAPID_PRIVATE_KEY_B64: str | None = None
VAPID_SUBJECT: str = "mailto:admin@groupe-meditation.local"
class Config:
env_file = str(_ENV_FILE)

View file

@ -0,0 +1,24 @@
"""Utilitaires pour la numérotation des décisions du groupe."""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.proposition import Proposition
PREFIXES_DECISION = {
"resolution": "RES",
"nomination": "NOM",
"contribution": "DON",
}
async def prochain_numero_decision(groupe_id, type_decision: str, annee: int, db: AsyncSession) -> str:
prefixe = PREFIXES_DECISION.get(type_decision, "RES")
result = await db.execute(
select(func.count(Proposition.id)).where(
Proposition.groupe_id == groupe_id,
Proposition.numero_resolution.isnot(None),
Proposition.numero_resolution.like(f"{prefixe}-{annee}-%"),
)
)
count = result.scalar() or 0
return f"{prefixe}-{annee}-{count + 1:03d}"

View file

@ -0,0 +1,38 @@
"""Règles communes liées aux dates de réunion."""
from datetime import date as dt_date
from calendar import monthrange
JOURS_SEMAINE = {
"lundi": 0,
"mardi": 1,
"mercredi": 2,
"jeudi": 3,
"vendredi": 4,
"samedi": 5,
"dimanche": 6,
}
def jour_reunion_attendu(groupe) -> int | None:
if not getattr(groupe, "jour_reunion", None):
return None
return JOURS_SEMAINE.get(groupe.jour_reunion.lower())
def date_correspond_au_jour_reunion(groupe, date_reunion: dt_date) -> bool:
jour = jour_reunion_attendu(groupe)
return True if jour is None else date_reunion.weekday() == jour
def date_assemblee_affaires_du_mois(annee: int, mois: int, jour_cible: int, ordre: str) -> dt_date:
dates = []
for jour in range(1, monthrange(annee, mois)[1] + 1):
date = dt_date(annee, mois, jour)
if date.weekday() == jour_cible:
dates.append(date)
if not dates:
return dt_date(annee, mois, 1)
if ordre == "derniere":
return dates[-1]
index = {"premiere": 0, "deuxieme": 1, "troisieme": 2, "quatrieme": 3}.get(ordre, 0)
return dates[index] if index < len(dates) else dates[-1]

51
backend/app/core/roles.py Normal file
View file

@ -0,0 +1,51 @@
"""Vérifications de rôles applicatifs."""
from fastapi import Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_membre, is_sysadmin
from app.models.affectation import Affectation
from app.models.poste import Poste
async def membre_a_poste_ou_executif(membre_id, libelle_poste: str, db: AsyncSession) -> bool:
from app.models.membre import Membre
membre = await db.get(Membre, membre_id)
if membre and is_sysadmin(membre):
return True
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre_id,
Affectation.statut == "actif",
(Poste.nom.ilike(f"%{libelle_poste}%") | (Poste.categorie == "executif")),
)
)
return result.scalars().first() is not None
async def require_tresorier(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if not await membre_a_poste_ou_executif(membre.id, "trésorier", db):
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
return membre
async def require_secretaire(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if not await membre_a_poste_ou_executif(membre.id, "secrétaire", db):
raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif")
return membre
async def require_rsg(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if not await membre_a_poste_ou_executif(membre.id, "RSG", db):
raise HTTPException(status_code=403, detail="Réservé au RSG ou à l'exécutif")
return membre

View file

@ -21,10 +21,12 @@ def verify_pin(pin: str, hashed: str) -> bool:
return bcrypt.checkpw(pin.encode("utf-8"), hashed.encode("utf-8"))
def create_token(membre_id: str, groupe_id: str, expire_jours: int = None) -> str:
def create_token(membre_id: str, groupe_id: str, expire_jours: int = None, admin_id: str | None = None) -> str:
jours = expire_jours or settings.JWT_EXPIRE_DAYS
expire = datetime.now(timezone.utc) + timedelta(days=jours)
payload = {"sub": str(membre_id), "grp": str(groupe_id), "exp": expire}
if admin_id:
payload["adm"] = str(admin_id)
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
@ -35,6 +37,10 @@ def decode_token(token: str) -> dict:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token invalide ou expiré")
def is_sysadmin(membre) -> bool:
return bool(getattr(membre, "is_sysadmin", False))
async def get_current_membre(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
@ -51,6 +57,8 @@ async def get_current_membre(
raise HTTPException(status_code=401, detail="Membre introuvable ou inactif")
if groupe_id and str(membre.groupe_id) != str(groupe_id):
raise HTTPException(status_code=401, detail="Session invalide pour ce groupe")
if payload.get("adm"):
setattr(membre, "_sysadmin_session", True)
return membre
@ -58,6 +66,8 @@ async def require_executif(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if is_sysadmin(membre):
return membre
from app.models.affectation import Affectation
from app.models.poste import Poste
result = await db.execute(

View file

@ -6,10 +6,12 @@ District 87-16, Région 87
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from app.core.config import settings
from app.core.audit import AuditMiddleware
from app.core.database import engine, Base
from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, propositions, operations_bancaires, evenements
from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, propositions, operations_bancaires, evenements, notifications, journal
# Import models pour que create_all crée toutes les tables
from app.models.sync_log import SyncLog # noqa: F401
@ -19,12 +21,27 @@ from app.models.evenement import Evenement # noqa: F401
from app.models.candidature import Candidature # noqa: F401
from app.models.poste_module import PosteModule # noqa: F401
from app.models.reserve import Reserve # noqa: F401
from app.models.transaction_comptable import TransactionComptable # noqa: F401
from app.models.mouvement_reserve import MouvementReserve # noqa: F401
from app.models.journal_action import JournalAction # noqa: F401
from app.models.push_subscription import PushSubscription # noqa: F401
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.execute(text(
"ALTER TABLE groupes "
"ADD COLUMN IF NOT EXISTS assemblee_affaires_ordre VARCHAR(20) DEFAULT 'premiere'"
))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS date_adoption DATE"))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS etant_donne_que TEXT"))
await conn.execute(text("ALTER TABLE propositions ADD COLUMN IF NOT EXISTS groupe_a_decide_de TEXT"))
await conn.execute(text("DROP TABLE IF EXISTS votes"))
await conn.execute(text("DROP TABLE IF EXISTS options_sondage"))
await conn.execute(text("DROP TABLE IF EXISTS sondages"))
await conn.execute(text("DROP TABLE IF EXISTS decisions"))
yield
await engine.dispose()
@ -43,6 +60,7 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(AuditMiddleware)
# Montage des routes
app.include_router(auth.router, prefix="/api")
@ -53,7 +71,6 @@ app.include_router(invitations.router, prefix="/api")
app.include_router(collectes.router, prefix="/api")
app.include_router(reunions.router, prefix="/api")
app.include_router(depenses.router, prefix="/api")
app.include_router(decisions.router, prefix="/api")
app.include_router(calendrier.router, prefix="/api")
app.include_router(rapports.router, prefix="/api")
app.include_router(rotations.router, prefix="/api")
@ -68,10 +85,11 @@ app.include_router(litterature.router, prefix="/api")
app.include_router(ventes_litterature.router, prefix="/api")
app.include_router(contributions.router, prefix="/api")
app.include_router(config_repartition.router, prefix="/api")
app.include_router(sondages.router, prefix="/api")
app.include_router(propositions.router, prefix="/api")
app.include_router(operations_bancaires.router, prefix="/api")
app.include_router(evenements.router, prefix="/api")
app.include_router(notifications.router, prefix="/api")
app.include_router(journal.router, prefix="/api")
@app.get("/api/health")

View file

@ -1,27 +0,0 @@
"""Table decisions — suivi des décisions de réunion d'affaires."""
import uuid
from datetime import date, datetime, timezone
from sqlalchemy import String, Date, DateTime, Text, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Decision(Base):
__tablename__ = "decisions"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"))
sujet: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
statut: Mapped[str] = mapped_column(
SAEnum("en_attente", "en_cours", "complete", name="statut_decision"),
default="en_attente",
)
responsable_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
proposeur: Mapped[str | None] = mapped_column(String(100))
secondeur: Mapped[str | None] = mapped_column(String(100))
date_decision: Mapped[date] = mapped_column(Date, nullable=False)
date_completion: Mapped[date | None] = mapped_column(Date)
cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -16,6 +16,7 @@ class Groupe(Base):
adresse_local: Mapped[str | None] = mapped_column(Text)
jour_reunion: Mapped[str | None] = mapped_column(String(10))
heure_reunion: Mapped[time | None] = mapped_column(Time)
assemblee_affaires_ordre: Mapped[str] = mapped_column(String(20), default="premiere")
format_reunion: Mapped[str | None] = mapped_column(Text)
lien_aa87: Mapped[str] = mapped_column(String(255), default="https://aa87.org")
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -0,0 +1,22 @@
"""Journal d'audit des actions membres."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, JSON, Integer
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class JournalAction(Base):
__tablename__ = "journal_actions"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("groupes.id"), index=True)
membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"), index=True)
admin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"), index=True)
methode: Mapped[str] = mapped_column(String(10), nullable=False)
chemin: Mapped[str] = mapped_column(String(255), nullable=False)
statut_http: Mapped[int] = mapped_column(Integer, nullable=False)
adresse_ip: Mapped[str | None] = mapped_column(String(64))
user_agent: Mapped[str | None] = mapped_column(String(255))
details: Mapped[dict | None] = mapped_column(JSON)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)

View file

@ -27,3 +27,7 @@ class Membre(Base):
groupe = relationship("Groupe", back_populates="membres")
affectations = relationship("Affectation", back_populates="membre", foreign_keys="Affectation.membre_id")
@property
def is_sysadmin(self) -> bool:
return (self.courriel or "").strip().lower() == "sysadmin" or bool(getattr(self, "_sysadmin_session", False))

View file

@ -0,0 +1,20 @@
"""Historique des mouvements entre réserves."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class MouvementReserve(Base):
__tablename__ = "mouvements_reserves"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
source_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reserves.id"), nullable=True)
destination_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reserves.id"), nullable=True)
montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
type: Mapped[str] = mapped_column(String(40), nullable=False)
note: Mapped[str | None] = mapped_column(Text)
cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -1,16 +0,0 @@
"""Table options_sondage — Options de vote."""
import uuid
from sqlalchemy import String, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class OptionSondage(Base):
__tablename__ = "options_sondage"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False)
texte: Mapped[str] = mapped_column(String(200), nullable=False)
ordre: Mapped[int] = mapped_column(Integer, default=0)
sondage = relationship("Sondage", back_populates="options")

View file

@ -13,7 +13,7 @@ class Proposition(Base):
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"), nullable=True)
type_proposition: Mapped[str] = mapped_column(
String(20), default="generale", # generale, nomination, contribution
String(20), default="proposition", # proposition, resolution, nomination, contribution
)
sujet: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
@ -23,8 +23,11 @@ class Proposition(Base):
SAEnum("proposee", "secondee", "adoptee", "rejetee", name="statut_proposition"),
default="proposee",
)
numero_resolution: Mapped[str | None] = mapped_column(String(20)) # RES-2026-001
numero_resolution: Mapped[str | None] = mapped_column(String(20)) # RES/NOM/DON-2026-001
date_vote: Mapped[date | None] = mapped_column(Date)
date_adoption: Mapped[date | None] = mapped_column(Date)
etant_donne_que: Mapped[str | None] = mapped_column(Text)
groupe_a_decide_de: Mapped[str | None] = mapped_column(Text)
note: Mapped[str | None] = mapped_column(Text)
# Lien vers la candidature (si type=nomination)
candidature_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("candidatures.id"))

View file

@ -0,0 +1,22 @@
"""Abonnements Web Push des appareils membres."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, ForeignKey, JSON, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
__table_args__ = (
UniqueConstraint("endpoint", name="uq_push_subscription_endpoint"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False, index=True)
membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False, index=True)
endpoint: Mapped[str] = mapped_column(String(500), nullable=False)
abonnement: Mapped[dict] = mapped_column(JSON, nullable=False)
user_agent: Mapped[str | None] = mapped_column(String(255))
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

View file

@ -1,29 +0,0 @@
"""Table sondages — Votes de conscience de groupe."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import String, Text, DateTime, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Sondage(Base):
__tablename__ = "sondages"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
titre: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
type: Mapped[str] = mapped_column(
SAEnum("oui_non", "choix_multiple", "unanimite", name="type_sondage"),
nullable=False,
)
cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
date_ouverture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
date_fermeture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
statut: Mapped[str] = mapped_column(
SAEnum("brouillon", "ouvert", "ferme", name="statut_sondage"),
default="brouillon",
)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
options = relationship("OptionSondage", back_populates="sondage", cascade="all, delete-orphan")

View file

@ -0,0 +1,23 @@
"""Registre comptable central."""
import uuid
from datetime import date, datetime, timezone
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class TransactionComptable(Base):
__tablename__ = "transactions_comptables"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
date_transaction: Mapped[date] = mapped_column(Date, nullable=False)
type: Mapped[str] = mapped_column(String(40), nullable=False)
compte: Mapped[str] = mapped_column(String(40), nullable=False)
montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
sens: Mapped[str] = mapped_column(String(10), nullable=False) # debit ou credit
source_table: Mapped[str | None] = mapped_column(String(80))
source_id: Mapped[uuid.UUID | None] = mapped_column(nullable=True)
note: Mapped[str | None] = mapped_column(Text)
cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -1,16 +0,0 @@
"""Table votes — Votes anonymes."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Vote(Base):
__tablename__ = "votes"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False)
option_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("options_sondage.id", ondelete="CASCADE"), nullable=False)
membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
vote_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View file

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import get_current_membre
from app.core.security import get_current_membre, is_sysadmin
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.models.groupe import Groupe
@ -18,26 +18,24 @@ MODULES_CONSULTATION = [
ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, résolutions", icone="📄", couleur="blue"),
ModuleTuile(code="C2", nom="Événements", description="Anniversaires et événements", icone="📅", couleur="green"),
ModuleTuile(code="C4", nom="Littérature", description="Inventaire et prix", icone="📚", couleur="purple"),
ModuleTuile(code="C5", nom="Décisions en cours", description="Suivi réunion d'affaires", icone="📌", couleur="red"),
ModuleTuile(code="C6", nom="Votes ouverts", description="Conscience de groupe", icone="🗳️", couleur="indigo"),
ModuleTuile(code="C5", nom="Gouvernance", description="Décisions du groupe", icone="📌", couleur="red"),
ModuleTuile(code="C7", nom="Mon profil", description="Modifier, PIN, notifications", icone="👤", couleur="gray"),
ModuleTuile(code="C8", nom="Propositions", description="Soumettre, seconder, résolutions", icone="📋", couleur="orange"),
ModuleTuile(code="C9", nom="Postes à pourvoir", description="Postes disponibles, candidature", icone="🙋", couleur="yellow"),
ModuleTuile(code="C10", nom="État financier", description="Résultats et transactions", icone="📊", couleur="blue"),
ModuleTuile(code="S1T", nom="Trésorerie", description="État des résultats, soldes, registre", icone="🏦", couleur="green"),
]
MODULES_PAR_CODE = {m.code: m for m in MODULES_CONSULTATION}
MODULES_PAR_CODE["S0"] = ModuleTuile(code="S0", nom="Rencontre hebdomadaire", description="Saisies du contexte régulier", icone="🗓️", couleur="green")
MODULES_PAR_CODE["M0"] = ModuleTuile(code="M0", nom="Assemblée d'affaires", description="Présences et procès-verbal", icone="🏛️", couleur="indigo")
MODULES_PAR_CODE["S1"] = ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green")
# Modules de saisie visibles par TOUS les membres
MODULES_SAISIE_TOUS = [
ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"),
ModuleTuile(code="S5", nom="Dépense", description="Soumettre une dépense", icone="💸", couleur="red"),
]
# Modules de saisie par poste
MODULES_SAISIE = {
"Trésorier(ière)": [
ModuleTuile(code="S1T", nom="Trésorerie", description="Réceptions, dépôts, soldes", icone="🏦", couleur="green"),
ModuleTuile(code="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"),
],
"Secrétaire": [
@ -65,10 +63,10 @@ MODULES_SAISIE = {
MODULES_GESTION = [
ModuleTuile(code="G2", nom="Gestion des membres", description="Membres, invitations, désactivation", icone="👥", couleur="blue"),
ModuleTuile(code="G6", nom="Lancer un sondage", description="Conscience de groupe", icone="📊", couleur="indigo"),
ModuleTuile(code="G9", nom="Paramètres groupe", description="Nom, horaire, adresse", icone="🔧", couleur="gray"),
ModuleTuile(code="G11", nom="Gestion des mandats", description="Affecter, terminer, rotations", icone="🎯", couleur="purple"),
ModuleTuile(code="G12", nom="Gestion des postes", description="Postes, candidatures, accès", icone="📋", couleur="purple"),
ModuleTuile(code="G13", nom="Journal", description="Changements enregistrés", icone="🧾", couleur="gray"),
]
for _modules in [MODULES_SAISIE_TOUS, MODULES_GESTION, *MODULES_SAISIE.values()]:
@ -82,6 +80,34 @@ async def accueil(
db: AsyncSession = Depends(get_db),
):
"""Écran d'accueil dynamique — retourne les modules selon les postes du membre."""
if is_sysadmin(membre):
modules_saisie_sysadmin = []
codes_vus = set()
for module in [MODULES_PAR_CODE["S1"], MODULES_PAR_CODE["S0"], MODULES_PAR_CODE["M0"], *MODULES_SAISIE_TOUS, *[m for mods in MODULES_SAISIE.values() for m in mods]]:
if module.code not in codes_vus:
modules_saisie_sysadmin.append(module)
codes_vus.add(module.code)
groupe = await db.get(Groupe, membre.groupe_id)
groupe_info = None
if groupe:
groupe_info = {
"id": str(groupe.id),
"nom": groupe.nom,
"jour_reunion": groupe.jour_reunion,
"heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None,
"assemblee_affaires_ordre": groupe.assemblee_affaires_ordre,
"adresse_local": groupe.adresse_local,
}
return AccueilResponse(
membre=MembrePublic.model_validate(membre),
postes=["Sysadmin"],
est_executif=True,
modules_consultation=MODULES_CONSULTATION,
modules_saisie=modules_saisie_sysadmin,
modules_gestion=MODULES_GESTION,
groupe=groupe_info,
)
# Récupérer les affectations actives
result = await db.execute(
select(Affectation).join(Poste).where(
@ -106,13 +132,15 @@ async def accueil(
# Modules de saisie : collecte pour tous + modules par poste
modules_saisie = list(MODULES_SAISIE_TOUS)
codes_vus = {"S1", "S5"}
codes_vus = {"S5"}
result = await db.execute(select(PosteModule).where(PosteModule.poste_id.in_(poste_ids))) if poste_ids else None
modules_configures = result.scalars().all() if result else []
if modules_configures:
codes = sorted({pm.module_code for pm in modules_configures})
for code in codes:
if code == "S1" and "executif" not in postes_categories:
continue
m = MODULES_PAR_CODE.get(code)
if m and not code.startswith("G") and m.code not in codes_vus:
modules_saisie.append(m)
@ -123,6 +151,13 @@ async def accueil(
if m.code not in codes_vus:
modules_saisie.append(m)
codes_vus.add(m.code)
if "executif" in postes_categories and "S1" not in codes_vus:
modules_saisie.insert(0, MODULES_PAR_CODE["M0"])
modules_saisie.insert(0, MODULES_PAR_CODE["S0"])
modules_saisie.insert(0, ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"))
codes_vus.add("S1")
codes_vus.add("S0")
codes_vus.add("M0")
# Modules de gestion : seulement si exécutif
if modules_configures:
@ -143,12 +178,14 @@ async def accueil(
"nom": groupe.nom,
"jour_reunion": groupe.jour_reunion,
"heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None,
"assemblee_affaires_ordre": groupe.assemblee_affaires_ordre,
"adresse_local": groupe.adresse_local,
}
return AccueilResponse(
membre=MembrePublic.model_validate(membre),
postes=postes_noms,
est_executif="executif" in postes_categories,
modules_consultation=modules_consultation,
modules_saisie=modules_saisie,
modules_gestion=modules_gestion,

View file

@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.security import hash_pin, verify_pin, create_token
from app.core.security import get_current_membre, hash_pin, verify_pin, create_token, is_sysadmin
from app.models.membre import Membre
from app.models.groupe import Groupe
from app.models.invitation import Invitation
@ -36,6 +36,7 @@ async def liste_groupes(db: AsyncSession = Depends(get_db)):
"adresse_local": g.adresse_local,
"jour_reunion": g.jour_reunion,
"heure_reunion": g.heure_reunion.strftime("%H:%M") if g.heure_reunion else None,
"assemblee_affaires_ordre": g.assemblee_affaires_ordre,
"format_reunion": g.format_reunion,
} for g in groupes]
@ -63,6 +64,25 @@ async def connexion(req: ConnexionRequest, db: AsyncSession = Depends(get_db)):
)
@router.post("/impersoner/{membre_id}", response_model=TokenResponse)
async def impersoner_membre(
membre_id: str,
admin=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if not is_sysadmin(admin):
raise HTTPException(status_code=403, detail="Réservé au sysadmin")
cible = await db.get(Membre, membre_id)
if not cible or cible.groupe_id != admin.groupe_id or not cible.actif:
raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
setattr(cible, "_sysadmin_session", True)
token = create_token(str(cible.id), str(cible.groupe_id), admin_id=str(admin.id))
return TokenResponse(
access_token=token,
membre=MembrePublic.model_validate(cible),
)
@router.post("/inscription", response_model=TokenResponse)
async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db)):
# Valider le code d'invitation

View file

@ -1,11 +1,13 @@
"""Routes S1 — Collecte 7e Tradition."""
from datetime import date, datetime, time as dt_time, timezone
from datetime import date, datetime, time as dt_time, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract, func
from app.core.database import get_db
from app.core.security import get_current_membre, require_executif
from app.core.roles import membre_a_poste_ou_executif, require_tresorier
from app.core.reunions import JOURS_SEMAINE
from app.core.security import get_current_membre, require_executif, is_sysadmin
from app.models.collecte import Collecte
from app.models.groupe import Groupe
from app.models.membre import Membre
@ -13,34 +15,16 @@ from app.models.depense import Depense
from app.models.envoi_contribution import EnvoiContribution
from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.models.mouvement_reserve import MouvementReserve
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
from app.services.finance import encaisse, solde_banque, solde_reserve, transaction
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
JOURS_SEMAINE = {
"lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3,
"vendredi": 4, "samedi": 5, "dimanche": 6,
}
async def _est_tresorier(membre_id, groupe_id, db: AsyncSession) -> bool:
"""Vérifie si un membre est trésorier ou exécutif."""
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre_id,
Affectation.statut == "actif",
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
)
)
return result.scalars().first() is not None
async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
if not await _est_tresorier(membre.id, membre.groupe_id, db):
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
return membre
return await membre_a_poste_ou_executif(membre_id, "trésorier", db)
async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut:
@ -66,28 +50,41 @@ async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut:
@router.post("/", response_model=CollecteOut)
async def saisir_collecte(
req: CollecteCreate,
membre=Depends(get_current_membre),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""S1 — N'importe quel membre saisit la collecte. Rétroactif possible sur les dates de réunion."""
"""S1 — Un membre exécutif saisit la collecte."""
groupe = await db.get(Groupe, membre.groupe_id)
aujourdhui = date.today()
retro_sysadmin = is_sysadmin(membre)
# Validation : pas dans le futur
if req.date_collecte > aujourdhui:
if req.date_collecte > aujourdhui and not retro_sysadmin:
raise HTTPException(status_code=400, detail="La date ne peut pas être dans le futur")
# Validation : la date doit tomber un jour de réunion
if groupe.jour_reunion:
jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower())
if jour_attendu is not None and req.date_collecte.weekday() != jour_attendu:
if jour_attendu is not None and req.date_collecte.weekday() != jour_attendu and not retro_sysadmin:
raise HTTPException(
status_code=400,
detail=f"La collecte doit avoir lieu un {groupe.jour_reunion}",
)
if jour_attendu is not None and not retro_sysadmin:
dernieres_reunions = []
d = aujourdhui
while len(dernieres_reunions) < 52:
if d.weekday() == jour_attendu:
dernieres_reunions.append(d)
d -= timedelta(days=1)
if req.date_collecte not in dernieres_reunions:
raise HTTPException(
status_code=400,
detail="La date doit faire partie des 52 dernières rencontres du groupe",
)
# Validation : si c'est aujourd'hui, pas avant l'heure de la réunion
if req.date_collecte == aujourdhui and groupe.heure_reunion:
if req.date_collecte == aujourdhui and groupe.heure_reunion and not retro_sysadmin:
now = datetime.now(timezone.utc)
if isinstance(groupe.heure_reunion, dt_time) and now.time() < groupe.heure_reunion:
raise HTTPException(
@ -126,10 +123,10 @@ async def saisir_collecte(
async def ajuster_collecte(
collecte_id: str,
req: CollecteUpdate,
membre=Depends(get_current_membre),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Ajuster le montant (exécutif ou celui qui a saisi, avant confirmation)."""
"""Ajuster le montant avant confirmation."""
result = await db.execute(
select(Collecte).where(
Collecte.id == collecte_id,
@ -142,11 +139,6 @@ async def ajuster_collecte(
if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation")
# Vérifier : exécutif ou saisi_par
est_exec = await _est_tresorier(membre.id, membre.groupe_id, db)
if collecte.saisi_par != membre.id and not est_exec:
raise HTTPException(status_code=403, detail="Seul un exécutif ou la personne qui a saisi peut modifier")
if req.montant is not None:
collecte.montant = req.montant
await db.flush()
@ -156,7 +148,7 @@ async def ajuster_collecte(
@router.post("/{collecte_id}/confirmer", response_model=CollecteOut)
async def confirmer_collecte(
collecte_id: str,
membre=Depends(get_current_membre),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Un second membre confirme le montant. Si le trésorier est impliqué → réception auto."""
@ -186,6 +178,7 @@ async def confirmer_collecte(
collecte.statut = "recu"
collecte.recu_par = tresorier_id
collecte.date_reception = date.today()
db.add(transaction(collecte.groupe_id, tresorier_id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
else:
collecte.statut = "confirmee"
@ -196,7 +189,7 @@ async def confirmer_collecte(
@router.post("/{collecte_id}/reception", response_model=CollecteOut)
async def accuser_reception(
collecte_id: str,
membre=Depends(_require_tresorier),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier accuse réception (pour les cas sans trésorier impliqué)."""
@ -220,6 +213,7 @@ async def accuser_reception(
collecte.statut = "recu"
collecte.recu_par = membre.id
collecte.date_reception = date.today()
db.add(transaction(collecte.groupe_id, membre.id, collecte.date_collecte, "collecte", "encaisse", collecte.montant, "debit", "collectes", collecte.id, "Collecte reçue"))
await db.flush()
return await _to_out(collecte, db)
@ -230,7 +224,7 @@ async def liste_collectes(
mois: int | None = None,
annee: int | None = None,
limite: int = 20,
membre=Depends(get_current_membre),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
query = select(Collecte).where(Collecte.groupe_id == membre.groupe_id)
@ -257,8 +251,6 @@ async def soldes_tresorerie(
from app.models.vente_litterature import VenteLitterature
gid = membre.groupe_id
# ── Dernier relevé bancaire ──
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == gid,
@ -266,54 +258,10 @@ async def soldes_tresorerie(
).order_by(desc(OperationBancaire.date_operation)).limit(1)
)
dernier_releve = result.scalar_one_or_none()
solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0
date_releve = str(dernier_releve.date_operation) if dernier_releve else None
# ── Dépôts et retraits depuis le dernier relevé ──
filtre_depuis = dernier_releve.date_operation if dernier_releve else None
# Dépôts après relevé
q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid,
OperationBancaire.type == "depot",
)
if filtre_depuis:
q = q.where(OperationBancaire.date_operation > filtre_depuis)
result = await db.execute(q)
depots_depuis = float(result.scalar())
# Retraits après relevé
q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid,
OperationBancaire.type == "retrait",
)
if filtre_depuis:
q = q.where(OperationBancaire.date_operation > filtre_depuis)
result = await db.execute(q)
retraits_depuis = float(result.scalar())
# Contributions débitées après relevé
q = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
)
if filtre_depuis:
q = q.where(EnvoiContribution.date_envoi > filtre_depuis)
result = await db.execute(q)
contributions_debitees_depuis = float(result.scalar())
# Dépenses bancaires débitées après relevé
q = select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid,
Depense.paye == True,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
)
if filtre_depuis:
q = q.where(Depense.date_confirmation > filtre_depuis)
result = await db.execute(q)
depenses_bancaires_depuis = float(result.scalar())
solde_banque = solde_releve + depots_depuis - retraits_depuis - contributions_debitees_depuis - depenses_bancaires_depuis
solde_banque_actuel = await solde_banque(gid, db)
encaisse_actuelle = await encaisse(gid, db)
# ── Dépenses engagées non payées (chèque/virement votés mais pas encore débités) ──
result = await db.execute(
@ -336,7 +284,6 @@ async def soldes_tresorerie(
contributions_non_debitees = float(result.scalar())
engagements_non_debites += contributions_non_debitees
# ── Réserves ──
result = await db.execute(
select(Reserve).where(
Reserve.groupe_id == gid,
@ -344,49 +291,20 @@ async def soldes_tresorerie(
).order_by(Reserve.nom)
)
reserves = result.scalars().all()
reserves_details = [
{
reserves_details = []
for r in reserves:
montant = await solde_reserve(r.id, db)
r.montant = montant
reserves_details.append({
"id": str(r.id),
"nom": r.nom,
"montant": float(r.montant),
"montant": round(montant, 2),
"note": r.note,
}
for r in reserves
]
total_reserves = sum(r["montant"] for r in reserves_details)
})
total_reserves_reelles = sum(r["montant"] for r in reserves_details)
# ── Somme disponible ──
disponible = solde_banque - engagements_non_debites - total_reserves
# ── Encaisse (cash reçu par le trésorier, pas déposé) ──
result = await db.execute(
select(func.coalesce(func.sum(Collecte.montant), 0)).where(
Collecte.groupe_id == gid, Collecte.statut == "recu"))
total_collectes_recues = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot"))
total_depots = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait"))
total_retraits = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE"))
depenses_cash = float(result.scalar())
# Ventes de littérature remises au trésorier
result = await db.execute(
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True))
ventes_recues = float(result.scalar())
encaisse = total_collectes_recues + ventes_recues + total_retraits - total_depots - depenses_cash
disponible = solde_banque_actuel + encaisse_actuelle - engagements_non_debites - total_reserves_reelles
# ── Argent en circulation (collectes non remises au trésorier) ──
result = await db.execute(
select(Collecte).where(
@ -398,6 +316,7 @@ async def soldes_tresorerie(
det = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None
total_en_circulation += float(c.montant)
details_circulation.append({
"id": str(c.id),
"date": str(c.date_collecte),
"montant": float(c.montant),
"detenteur": det.prenom if det else "?",
@ -428,14 +347,15 @@ async def soldes_tresorerie(
})
return {
"solde_banque": round(solde_banque, 2),
"solde_banque": round(solde_banque_actuel, 2),
"date_releve": date_releve,
"encaisse": round(encaisse, 2),
"encaisse": round(encaisse_actuelle, 2),
"disponible": round(disponible, 2),
"engagements_non_debites": round(engagements_non_debites, 2),
"reserve_prudente": 0,
"reserve_anniversaire": 0,
"total_reserves": round(total_reserves, 2),
"total_reserves": round(total_reserves_reelles, 2),
"total_reserves_reelles": round(total_reserves_reelles, 2),
"reserves_details": reserves_details,
"en_possession": round(total_en_circulation, 2),
"en_possession_details": details_circulation,
@ -455,9 +375,11 @@ async def registre_transactions(
"""Registre détaillé de toutes les transactions financières du groupe."""
from app.models.vente_litterature import VenteLitterature
from app.models.litterature import Litterature
from app.models.transaction_comptable import TransactionComptable
gid = membre.groupe_id
transactions = []
dons_operation_ids = set()
# Filtres date
def filtre_mois(d):
@ -544,7 +466,30 @@ async def registre_transactions(
"par": "Trésorier",
})
# 5. Opérations bancaires (dépôts/retraits)
# 5. Dons déposés directement à la banque
result = await db.execute(
select(TransactionComptable).where(
TransactionComptable.groupe_id == gid,
TransactionComptable.type == "don",
TransactionComptable.compte == "banque",
TransactionComptable.sens == "debit",
).order_by(TransactionComptable.date_transaction)
)
for don in result.scalars().all():
if filtre_mois(don.date_transaction):
m = await db.get(Membre, don.cree_par) if don.cree_par else None
if don.source_id:
dons_operation_ids.add(don.source_id)
ajouter_transaction({
"date": str(don.date_transaction),
"type": "don",
"description": f"Don déposé directement{(' — ' + don.note) if don.note else ''}",
"entree": float(don.montant),
"sortie": 0,
"par": m.prenom if m else "?",
})
# 6. Opérations bancaires (dépôts/retraits)
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == gid,
@ -552,6 +497,8 @@ async def registre_transactions(
).order_by(OperationBancaire.date_operation)
)
for op in result.scalars().all():
if op.id in dons_operation_ids:
continue
if filtre_mois(op.date_operation):
m = await db.get(Membre, op.effectue_par)
is_depot = op.type == "depot"
@ -564,5 +511,27 @@ async def registre_transactions(
"par": m.prenom if m else "?",
})
transactions.sort(key=lambda t: t["date"])
# 7. Mouvements virtuels entre le disponible et les réserves
result = await db.execute(
select(MouvementReserve).where(MouvementReserve.groupe_id == gid)
.order_by(MouvementReserve.cree_le)
)
for mv in result.scalars().all():
date_mouvement = mv.cree_le.date()
if filtre_mois(date_mouvement):
source = await db.get(Reserve, mv.source_id) if mv.source_id else None
destination = await db.get(Reserve, mv.destination_id) if mv.destination_id else None
createur = await db.get(Membre, mv.cree_par) if mv.cree_par else None
source_nom = source.nom if source else "Disponible"
destination_nom = destination.nom if destination else "Disponible"
ajouter_transaction({
"date": str(date_mouvement),
"type": "reserve_virement",
"description": f"Virement de réserve : {source_nom} → {destination_nom} ({float(mv.montant):.2f} $)",
"entree": 0,
"sortie": 0,
"par": createur.prenom if createur else "?",
})
transactions.sort(key=lambda t: t["date"], reverse=True)
return transactions

View file

@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.security import get_current_membre
from app.core.security import get_current_membre, is_sysadmin
from app.models.jeton import Jeton
from app.models.affectation import Affectation
from app.models.poste import Poste
@ -17,6 +17,8 @@ router = APIRouter(prefix="/commandes-jetons", tags=["M6 — Commander jetons"])
async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable des jetons."""
if is_sysadmin(membre):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,

View file

@ -2,15 +2,17 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.roles import require_tresorier
from app.core.security import is_sysadmin
from app.core.security import get_current_membre
from app.models.envoi_contribution import EnvoiContribution
from app.models.config_repartition import ConfigRepartition
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.services.finance import solde_banque, total_reserves, transaction
from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut,
@ -28,33 +30,6 @@ DEFAUT_REPARTITION = {
}
async def _prochain_numero_resolution(groupe_id, annee: int, db: AsyncSession) -> str:
from app.models.proposition import Proposition
result = await db.execute(
select(func.count(Proposition.id)).where(
Proposition.groupe_id == groupe_id,
Proposition.numero_resolution.isnot(None),
Proposition.numero_resolution.like(f"RES-{annee}-%"),
)
)
count = result.scalar() or 0
return f"RES-{annee}-{count + 1:03d}"
async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
Poste.nom.ilike("%trésorier%"),
)
)
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé au trésorier")
return membre
async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]:
"""Récupère la répartition active ou utilise les défauts."""
result = await db.execute(
@ -123,7 +98,8 @@ async def enregistrer_envoi(
if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif")
numero_resolution = await _prochain_numero_resolution(membre.groupe_id, date.today().year, db)
date_decision = req.date_envoi if is_sysadmin(membre) else date.today()
numero_resolution = await prochain_numero_decision(membre.groupe_id, "contribution", date_decision.year, db)
resolution = Proposition(
groupe_id=membre.groupe_id,
type_proposition="contribution",
@ -136,7 +112,9 @@ async def enregistrer_envoi(
secondeur_id=req.secondeur_id,
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=date.today(),
date_vote=date_decision,
date_adoption=date_decision,
groupe_a_decide_de=f"Envoyer {req.montant:.2f} $ à {DESTINATIONS_LABELS[req.destination]}.",
)
db.add(resolution)
await db.flush()
@ -189,17 +167,16 @@ async def confirmer_debit_contribution(
if envoi.debitee:
raise HTTPException(status_code=400, detail="Contribution déjà débitée")
from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if float(envoi.montant) > solde_banque:
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
if float(envoi.montant) > solde_banque_actuel:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
total_reserves = await _total_reserves(membre.groupe_id, db)
if solde_banque - float(envoi.montant) < total_reserves:
total_reserves_actuel = await total_reserves(membre.groupe_id, db)
if solde_banque_actuel - float(envoi.montant) < total_reserves_actuel:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
envoi.debitee = True
envoi.engagee = True
db.add(transaction(membre.groupe_id, membre.id, date.today(), "contribution", "banque", envoi.montant, "credit", "envois_contributions", envoi.id, DESTINATIONS_LABELS.get(envoi.destination, envoi.destination)))
await db.flush()
saisi = await db.get(Membre, envoi.saisi_par) if envoi.saisi_par else None

View file

@ -1,110 +0,0 @@
"""Routes C5 — Décisions en cours."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.security import get_current_membre, require_executif
from app.models.decision import Decision
from app.models.membre import Membre
from app.schemas.phase2 import DecisionCreate, DecisionOut, DecisionUpdate
router = APIRouter(prefix="/decisions", tags=["C5 — Décisions en cours"])
async def _to_out(d: Decision, db: AsyncSession) -> DecisionOut:
resp = await db.get(Membre, d.responsable_id) if d.responsable_id else None
return DecisionOut(
id=d.id,
sujet=d.sujet,
description=d.description,
statut=d.statut,
responsable_prenom=resp.prenom if resp else None,
proposeur=d.proposeur,
secondeur=d.secondeur,
date_decision=d.date_decision,
date_completion=d.date_completion,
cree_le=d.cree_le,
)
@router.get("/", response_model=list[DecisionOut])
async def liste_decisions(
statut: str | None = None,
limite: int = 50,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""C5 — Lister les décisions du groupe avec filtre optionnel par statut."""
query = select(Decision).where(Decision.groupe_id == membre.groupe_id)
if statut:
query = query.where(Decision.statut == statut)
query = query.order_by(desc(Decision.date_decision)).limit(limite)
result = await db.execute(query)
return [await _to_out(d, db) for d in result.scalars().all()]
@router.get("/compteur")
async def compteur_decisions(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Nombre de décisions ouvertes (pour le badge sur la tuile)."""
result = await db.execute(
select(Decision).where(
Decision.groupe_id == membre.groupe_id,
Decision.statut.in_(["en_attente", "en_cours"]),
)
)
return {"ouvertes": len(result.scalars().all())}
@router.post("/", response_model=DecisionOut)
async def creer_decision(
req: DecisionCreate,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Créer une décision (exécutif — typiquement via le PV)."""
decision = Decision(
groupe_id=executif.groupe_id,
reunion_id=req.reunion_id,
sujet=req.sujet.strip(),
description=req.description,
statut=req.statut,
responsable_id=req.responsable_id,
proposeur=req.proposeur,
secondeur=req.secondeur,
date_decision=req.date_decision,
cree_par=executif.id,
)
db.add(decision)
await db.flush()
return await _to_out(decision, db)
@router.patch("/{decision_id}", response_model=DecisionOut)
async def modifier_decision(
decision_id: str,
req: DecisionUpdate,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Modifier le statut d'une décision (exécutif)."""
result = await db.execute(
select(Decision).where(Decision.id == decision_id, Decision.groupe_id == executif.groupe_id)
)
decision = result.scalar_one_or_none()
if not decision:
raise HTTPException(status_code=404, detail="Décision introuvable")
if req.statut is not None:
decision.statut = req.statut
if req.description is not None:
decision.description = req.description
if req.responsable_id is not None:
decision.responsable_id = req.responsable_id
if req.date_completion is not None:
decision.date_completion = req.date_completion
await db.flush()
return await _to_out(decision, db)

View file

@ -1,42 +1,24 @@
"""Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier)."""
import os
import uuid as uuid_mod
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.security import get_current_membre
from app.core.roles import membre_a_poste_ou_executif, require_tresorier
from app.core.security import get_current_membre, is_sysadmin
from app.models.depense import Depense
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
from app.services.finance import encaisse, engagements_non_debites, solde_banque, total_reserves, transaction
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
METHODES_VALIDES = {"DU", "VIREMENT", "CHEQUE", "COLLECTE"}
METHODES_VALIDES = {"VIREMENT", "CHEQUE", "COLLECTE"}
UPLOAD_DIR = "/opt/groupe-meditation/uploads/preuves"
async def _est_tresorier_ou_executif(membre_id, db: AsyncSession) -> bool:
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre_id,
Affectation.statut == "actif",
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
)
)
return result.scalars().first() is not None
async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
if not await _est_tresorier_ou_executif(membre.id, db):
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
return membre
async def _to_out(d: Depense, db: AsyncSession) -> DepenseOut:
qui = await db.get(Membre, d.qui)
saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None
@ -61,12 +43,21 @@ async def soumettre_depense(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""S5 — N'importe quel membre soumet une dépense courante."""
"""S5 — Un membre soumet une dépense pour lui-même; un exécutif peut le faire pour un autre."""
qui_id = membre.id
if req.membre_id and req.membre_id != membre.id:
if not await membre_a_poste_ou_executif(membre.id, "trésorier", db):
raise HTTPException(status_code=403, detail="Seul un exécutif peut soumettre une dépense pour un autre membre")
beneficiaire = await db.get(Membre, req.membre_id)
if not beneficiaire or beneficiaire.groupe_id != membre.groupe_id or not beneficiaire.actif:
raise HTTPException(status_code=404, detail="Membre introuvable ou inactif")
qui_id = beneficiaire.id
depense = Depense(
groupe_id=membre.groupe_id,
date_depense=req.date_depense,
date_depense=req.date_depense if req.date_depense and is_sysadmin(membre) else date.today(),
montant=req.montant,
qui=membre.id, # le membre qui soumet est celui qui a payé
qui=qui_id,
quoi=req.quoi.strip(),
paye=False,
methode=None, # sera renseigné par le trésorier
@ -110,7 +101,7 @@ async def telecharger_preuve(
async def confirmer_depense(
depense_id: str,
methode: str,
membre=Depends(_require_tresorier),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme la dépense et renseigne le mode de paiement."""
@ -124,13 +115,23 @@ async def confirmer_depense(
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
if depense.confirme_par is not None:
raise HTTPException(status_code=400, detail="Cette dépense a déjà été traitée")
if methode_norm == "COLLECTE":
from app.routers.operations_bancaires import _encaisse
encaisse = await _encaisse(membre.groupe_id, db)
if float(depense.montant) > encaisse:
solde_encaisse = await encaisse(membre.groupe_id, db)
if float(depense.montant) > solde_encaisse:
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense")
elif methode_norm in ("CHEQUE", "VIREMENT"):
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
total_reserves_actuel = await total_reserves(membre.groupe_id, db)
engagements = await engagements_non_debites(membre.groupe_id, db)
disponible = solde_banque_actuel - total_reserves_actuel - engagements
if float(depense.montant) > disponible:
raise HTTPException(
status_code=400,
detail="Le solde bancaire disponible est insuffisant pour engager cette dépense",
)
depense.methode = methode_norm
depense.confirme_par = membre.id
@ -139,8 +140,8 @@ async def confirmer_depense(
# Si cash (COLLECTE) → payé immédiatement
if methode_norm == "COLLECTE":
depense.paye = True
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "encaisse", depense.montant, "credit", "depenses", depense.id, depense.quoi))
# Si DU → le groupe doit rembourser, pas encore payé
# Si CHEQUE/VIREMENT → engagement, en attente de débit
await db.flush()
@ -150,7 +151,7 @@ async def confirmer_depense(
@router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut)
async def confirmer_debit(
depense_id: str,
membre=Depends(_require_tresorier),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Le trésorier confirme que le chèque/virement a été débité du compte."""
@ -165,20 +166,46 @@ async def confirmer_debit(
if depense.methode not in ("CHEQUE", "VIREMENT"):
raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit")
from app.routers.operations_bancaires import _solde_banque_brut, _total_reserves
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if float(depense.montant) > solde_banque:
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
if float(depense.montant) > solde_banque_actuel:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit")
total_reserves = await _total_reserves(membre.groupe_id, db)
if solde_banque - float(depense.montant) < total_reserves:
total_reserves_actuel = await total_reserves(membre.groupe_id, db)
if solde_banque_actuel - float(depense.montant) < total_reserves_actuel:
raise HTTPException(status_code=400, detail="Ce débit ferait dépasser les réserves par rapport au solde en banque")
depense.paye = True
depense.date_confirmation = datetime.now(timezone.utc)
db.add(transaction(membre.groupe_id, membre.id, date.today(), "depense", "banque", depense.montant, "credit", "depenses", depense.id, depense.quoi))
await db.flush()
return await _to_out(depense, db)
@router.delete("/{depense_id}")
async def rejeter_depense(
depense_id: str,
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Rejeter une dépense soumise : elle est supprimée du registre."""
result = await db.execute(
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
)
depense = result.scalar_one_or_none()
if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable")
if depense.confirme_par is not None:
raise HTTPException(status_code=400, detail="Seules les dépenses soumises non traitées peuvent être rejetées")
if depense.preuve_nom:
chemin = os.path.join(UPLOAD_DIR, depense.preuve_nom)
if os.path.exists(chemin):
os.remove(chemin)
await db.delete(depense)
await db.flush()
return {"message": "Dépense rejetée"}
@router.get("/", response_model=list[DepenseOut])
async def liste_depenses(
limite: int = 30,
@ -204,7 +231,7 @@ async def liste_depenses(
@router.get("/a-confirmer")
async def depenses_a_confirmer(
membre=Depends(_require_tresorier),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Dépenses soumises en attente de confirmation par le trésorier."""
@ -221,7 +248,7 @@ async def depenses_a_confirmer(
async def modifier_depense(
depense_id: str,
req: DepenseUpdate,
membre=Depends(_require_tresorier),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Modifier une dépense (trésorier)."""

View file

@ -1,8 +1,8 @@
"""Routes — Événements (anniversaires + ad hoc + résolutions)."""
"""Routes — Événements (anniversaires + ad hoc)."""
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import get_current_membre, require_executif
@ -11,7 +11,6 @@ from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.models.evenement import Evenement
from app.models.proposition import Proposition
router = APIRouter(prefix="/evenements", tags=["Événements"])
@ -31,7 +30,7 @@ async def evenements_a_venir(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Tous les événements : anniversaires, ad hoc et résolutions."""
"""Tous les événements : anniversaires et ad hoc."""
aujourdhui = date.today()
debut = aujourdhui - timedelta(days=jours_avant)
limite = aujourdhui + timedelta(days=jours)
@ -84,24 +83,6 @@ async def evenements_a_venir(
"groupe_nom": groupe_nom,
})
# ── 3. Résolutions adoptées du groupe ──
result = await db.execute(
select(Proposition).where(
Proposition.groupe_id == membre.groupe_id,
Proposition.statut == "adoptee",
).order_by(desc(Proposition.date_vote))
)
for p in result.scalars().all():
date_res = str(p.date_vote) if p.date_vote else str(p.cree_le.date())
evenements.append({
"type": "resolution",
"date": date_res,
"titre": f"📜 {p.numero_resolution or 'RES'} — {p.sujet}",
"detail": p.description or p.note,
"lieu": None,
"groupe_nom": groupe.nom if groupe else "?",
})
# Trier par date
evenements.sort(key=lambda e: e["date"])
return evenements

View file

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.security import get_current_membre
from app.core.security import get_current_membre, is_sysadmin
from app.models.jeton import Jeton
from app.models.membre import Membre
from app.models.affectation import Affectation
@ -16,11 +16,13 @@ router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"])
async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable des jetons."""
if is_sysadmin(membre):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
Poste.nom.ilike("%jeton%"),
(Poste.nom.ilike("%jeton%") | (Poste.categorie == "executif")),
)
)
if not result.scalars().first():

View file

@ -0,0 +1,41 @@
"""Consultation du journal d'audit."""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_executif
from app.models.journal_action import JournalAction
router = APIRouter(prefix="/journal", tags=["Journal"])
@router.get("/actions")
async def journal_actions(
limite: int = 100,
executif=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
limite = max(1, min(limite, 500))
result = await db.execute(
select(JournalAction)
.where(JournalAction.groupe_id == executif.groupe_id)
.order_by(JournalAction.cree_le.desc())
.limit(limite)
)
return [
{
"id": str(item.id),
"groupe_id": str(item.groupe_id) if item.groupe_id else None,
"membre_id": str(item.membre_id) if item.membre_id else None,
"admin_id": str(item.admin_id) if item.admin_id else None,
"methode": item.methode,
"chemin": item.chemin,
"statut_http": item.statut_http,
"adresse_ip": item.adresse_ip,
"user_agent": item.user_agent,
"details": item.details,
"cree_le": item.cree_le.isoformat(),
}
for item in result.scalars().all()
]

View file

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import get_current_membre
from app.core.security import get_current_membre, is_sysadmin
from app.models.litterature import Litterature
from app.models.affectation import Affectation
from app.models.poste import Poste
@ -15,6 +15,8 @@ router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"])
async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable de la littérature."""
if is_sysadmin(membre):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,

View file

@ -0,0 +1,91 @@
"""Routes de notifications Web Push."""
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_membre
from app.models.push_subscription import PushSubscription
from app.services.notifications import notifications_configured, send_to_subscriptions
router = APIRouter(prefix="/notifications", tags=["Notifications"])
class PushSubscriptionIn(BaseModel):
endpoint: str
expirationTime: int | None = None
keys: dict
@router.get("/configuration")
async def configuration_notifications():
return {
"enabled": notifications_configured(),
"public_key": settings.VAPID_PUBLIC_KEY,
}
@router.post("/abonnement")
async def enregistrer_abonnement(
abonnement: PushSubscriptionIn,
request: Request,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
if not notifications_configured():
raise HTTPException(status_code=400, detail="Les notifications ne sont pas configurées sur ce serveur")
result = await db.execute(select(PushSubscription).where(PushSubscription.endpoint == abonnement.endpoint))
sub = result.scalar_one_or_none()
if sub:
sub.groupe_id = membre.groupe_id
sub.membre_id = membre.id
sub.abonnement = abonnement.model_dump()
sub.user_agent = (request.headers.get("user-agent") or "")[:255] or None
else:
db.add(PushSubscription(
groupe_id=membre.groupe_id,
membre_id=membre.id,
endpoint=abonnement.endpoint,
abonnement=abonnement.model_dump(),
user_agent=(request.headers.get("user-agent") or "")[:255] or None,
))
membre.push_sub = abonnement.model_dump()
await db.flush()
return {"message": "Notifications activées"}
@router.delete("/abonnement")
async def retirer_abonnement(
endpoint: str,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PushSubscription).where(
PushSubscription.endpoint == endpoint,
PushSubscription.membre_id == membre.id,
)
)
sub = result.scalar_one_or_none()
if sub:
await db.delete(sub)
membre.push_sub = None
await db.flush()
return {"message": "Notifications désactivées"}
@router.post("/test")
async def envoyer_test(
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(PushSubscription).where(PushSubscription.membre_id == membre.id))
sent = await send_to_subscriptions(db, result.scalars().all(), {
"title": "Notifications activées",
"body": "Cet appareil recevra les avis du groupe.",
"url": "/",
})
await db.flush()
return {"message": "Notification de test envoyée", "sent": sent}

View file

@ -1,128 +1,87 @@
"""Routes — Opérations bancaires (dépôts, retraits)."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.roles import require_tresorier
from app.core.security import get_current_membre
from app.models.operation_bancaire import OperationBancaire
from app.models.collecte import Collecte
from app.models.depense import Depense
from app.models.transaction_comptable import TransactionComptable
from app.models.mouvement_reserve import MouvementReserve
from app.models.reserve import Reserve
from app.models.vente_litterature import VenteLitterature
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveUpdate
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut
from app.services.finance import encaisse, reserve_disponible, solde_banque, solde_reserve, total_reserves
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est trésorier ou exécutif."""
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
)
def _mouvement_reserve(groupe_id, membre_id, montant, type_mouvement, source_id=None, destination_id=None, note=None):
return MouvementReserve(
groupe_id=groupe_id,
source_id=source_id,
destination_id=destination_id,
montant=montant,
type=type_mouvement,
note=note,
cree_par=membre_id,
)
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
return membre
async def _encaisse(groupe_id, db: AsyncSession) -> float:
total_collectes = float((await db.execute(
select(func.coalesce(func.sum(Collecte.montant), 0)).where(
Collecte.groupe_id == groupe_id, Collecte.statut == "recu")
)).scalar())
ventes_recues = float((await db.execute(
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
VenteLitterature.groupe_id == groupe_id, VenteLitterature.remis_tresorier == True)
)).scalar())
retraits = float((await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait")
)).scalar())
depots = float((await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "depot")
)).scalar())
depenses_cash = float((await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == groupe_id, Depense.paye == True, Depense.methode == "COLLECTE")
)).scalar())
return total_collectes + ventes_recues + retraits - depots - depenses_cash
async def _solde_banque_brut(groupe_id, db: AsyncSession) -> float:
from app.models.envoi_contribution import EnvoiContribution
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == groupe_id,
OperationBancaire.type == "releve",
).order_by(desc(OperationBancaire.date_operation)).limit(1)
def _ligne_comptable(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str):
return TransactionComptable(
groupe_id=groupe_id,
date_transaction=op.date_operation,
type=op.type,
compte=compte,
montant=op.montant,
sens=sens,
source_table="operations_bancaires",
source_id=op.id,
note=op.note,
cree_par=membre_id,
)
dernier_releve = result.scalar_one_or_none()
solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0
filtre_depuis = dernier_releve.date_operation if dernier_releve else None
q_depots = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "depot")
q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait")
q_contribs = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == groupe_id, EnvoiContribution.debitee == True)
q_depenses = select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == groupe_id,
Depense.paye == True,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
def _ligne_comptable_montant(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str, montant: float):
return TransactionComptable(
groupe_id=groupe_id,
date_transaction=op.date_operation,
type=op.type,
compte=compte,
montant=montant,
sens=sens,
source_table="operations_bancaires",
source_id=op.id,
note=op.note,
cree_par=membre_id,
)
if filtre_depuis:
q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis)
q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis)
q_contribs = q_contribs.where(EnvoiContribution.date_envoi > filtre_depuis)
q_depenses = q_depenses.where(Depense.date_confirmation > filtre_depuis)
depots = float((await db.execute(q_depots)).scalar())
retraits = float((await db.execute(q_retraits)).scalar())
contribs = float((await db.execute(q_contribs)).scalar())
depenses = float((await db.execute(q_depenses)).scalar())
return solde_releve + depots - retraits - contribs - depenses
async def _total_reserves(groupe_id, db: AsyncSession, exclure_id: str | None = None) -> float:
query = select(func.coalesce(func.sum(Reserve.montant), 0)).where(
Reserve.groupe_id == groupe_id,
Reserve.active == True,
)
if exclure_id:
query = query.where(Reserve.id != exclure_id)
return float((await db.execute(query)).scalar())
@router.post("/", response_model=OperationBancaireOut)
async def enregistrer_operation(
req: OperationBancaireCreate,
membre=Depends(_require_tresorier),
membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db),
):
"""Enregistrer un dépôt ou retrait bancaire (trésorier)."""
if req.type not in ("depot", "retrait", "releve"):
raise HTTPException(status_code=400, detail="Type doit être 'depot', 'retrait' ou 'releve'")
provenance_depot = (req.provenance_depot or "encaisse").lower()
if req.type == "depot" and provenance_depot not in ("encaisse", "don"):
raise HTTPException(status_code=400, detail="Provenance du dépôt invalide")
if req.montant <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif")
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
total_reserves = await _total_reserves(membre.groupe_id, db)
if req.type == "depot" and req.montant > await _encaisse(membre.groupe_id, db):
solde_banque_actuel = await solde_banque(membre.groupe_id, db)
total_reserves_actuel = await total_reserves(membre.groupe_id, db)
if req.type == "depot" and provenance_depot == "encaisse" and req.montant > await encaisse(membre.groupe_id, db):
raise HTTPException(status_code=400, detail="Le dépôt ne peut pas dépasser l'encaisse disponible")
if req.type == "retrait" and req.montant > solde_banque:
if req.type == "retrait" and req.montant > solde_banque_actuel:
raise HTTPException(status_code=400, detail="Le retrait ne peut pas dépasser le solde en banque")
if req.type == "retrait" and solde_banque - req.montant < total_reserves:
if req.type == "retrait" and solde_banque_actuel - req.montant < total_reserves_actuel:
raise HTTPException(status_code=400, detail="Le retrait ferait dépasser les réserves par rapport au solde en banque")
if req.type == "releve" and req.montant < total_reserves:
if req.type == "releve" and req.montant < total_reserves_actuel:
raise HTTPException(status_code=400, detail="Le relevé ne peut pas être inférieur au total des réserves")
op = OperationBancaire(
@ -131,10 +90,39 @@ async def enregistrer_operation(
montant=req.montant,
date_operation=req.date_operation,
effectue_par=membre.id,
note=req.note,
note=req.note or ("Don déposé directement au compte bancaire" if provenance_depot == "don" else None),
)
db.add(op)
await db.flush()
if op.type == "depot":
if provenance_depot == "don":
db.add(TransactionComptable(
groupe_id=membre.groupe_id,
date_transaction=op.date_operation,
type="don",
compte="banque",
montant=op.montant,
sens="debit",
source_table="operations_bancaires",
source_id=op.id,
note=op.note,
cree_par=membre.id,
))
else:
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "debit"))
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "credit"))
elif op.type == "retrait":
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "banque", "credit"))
db.add(_ligne_comptable(membre.groupe_id, membre.id, op, "encaisse", "debit"))
else:
delta = req.montant - solde_banque_actuel
if delta != 0:
db.add(_ligne_comptable_montant(
membre.groupe_id, membre.id, op, "banque",
"debit" if delta > 0 else "credit",
abs(delta),
))
await db.flush()
return OperationBancaireOut(
id=op.id,
type=op.type,
@ -153,55 +141,154 @@ async def liste_reserves(membre=Depends(get_current_membre), db: AsyncSession =
.order_by(Reserve.nom)
)
return [
ReserveOut(id=r.id, nom=r.nom, montant=float(r.montant), note=r.note, active=r.active, cree_le=r.cree_le)
ReserveOut(id=r.id, nom=r.nom, montant=round(await solde_reserve(r.id, db), 2), note=r.note, active=r.active, cree_le=r.cree_le)
for r in result.scalars().all()
]
@router.get("/registre-comptable", response_model=list[TransactionComptableOut])
async def registre_comptable(
limite: int = 200,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(TransactionComptable)
.where(TransactionComptable.groupe_id == membre.groupe_id)
.order_by(desc(TransactionComptable.date_transaction), desc(TransactionComptable.cree_le))
.limit(limite)
)
transactions = result.scalars().all()
out = []
for tx in transactions:
createur = await db.get(Membre, tx.cree_par) if tx.cree_par else None
out.append(TransactionComptableOut(
id=tx.id,
date_transaction=tx.date_transaction,
type=tx.type,
compte=tx.compte,
montant=float(tx.montant),
sens=tx.sens,
source_table=tx.source_table,
source_id=tx.source_id,
note=tx.note,
cree_par_prenom=createur.prenom if createur else None,
cree_le=tx.cree_le,
))
return out
@router.post("/reserves", response_model=ReserveOut)
async def creer_reserve(req: ReserveCreate, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)):
async def creer_reserve(req: ReserveCreate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis")
if req.montant < 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul")
total = await _total_reserves(membre.groupe_id, db) + req.montant
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if total > solde_banque:
raise HTTPException(status_code=400, detail="Le total des réserves ne peut pas dépasser le solde en banque")
reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=req.montant, note=req.note)
if req.montant != 0:
raise HTTPException(status_code=400, detail="Créer la réserve à zéro, puis utiliser un virement pour l'approvisionner")
reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=0, note=req.note)
db.add(reserve)
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=0, note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
@router.patch("/reserves/{reserve_id}", response_model=ReserveOut)
async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)):
async def modifier_reserve(reserve_id: str, req: ReserveUpdate, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
montant = float(reserve.montant) if req.montant is None else req.montant
if montant < 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul")
total = await _total_reserves(membre.groupe_id, db, reserve_id) + montant
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if total > solde_banque:
raise HTTPException(status_code=400, detail="Le total des réserves ne peut pas dépasser le solde en banque")
ancien_montant = await solde_reserve(reserve.id, db)
if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2):
raise HTTPException(status_code=400, detail="Modifier le montant d'une réserve via l'action Virement")
if req.nom is not None:
if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis")
reserve.nom = req.nom.strip()
reserve.montant = montant
if req.note is not None:
reserve.note = req.note.strip() or None
reserve.montant = await solde_reserve(reserve.id, db)
await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
@router.get("/reserves/mouvements", response_model=list[ReserveMouvementOut])
async def liste_mouvements_reserves(
limite: int = 100,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(MouvementReserve)
.where(MouvementReserve.groupe_id == membre.groupe_id)
.order_by(desc(MouvementReserve.cree_le))
.limit(limite)
)
mouvements = result.scalars().all()
out = []
for mv in mouvements:
source = await db.get(Reserve, mv.source_id) if mv.source_id else None
destination = await db.get(Reserve, mv.destination_id) if mv.destination_id else None
createur = await db.get(Membre, mv.cree_par) if mv.cree_par else None
out.append(ReserveMouvementOut(
id=mv.id,
source_id=mv.source_id,
source_nom=source.nom if source else "Disponible",
destination_id=mv.destination_id,
destination_nom=destination.nom if destination else "Disponible",
montant=float(mv.montant),
type=mv.type,
note=mv.note,
cree_par_prenom=createur.prenom if createur else None,
cree_le=mv.cree_le,
))
return out
@router.post("/reserves/transfert")
async def transferer_reserve(req: ReserveTransfer, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
if req.montant <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif")
if req.source_id == req.destination_id:
raise HTTPException(status_code=400, detail="La source et la destination doivent être différentes")
source = None if req.source_id == "disponible" else await db.get(Reserve, req.source_id)
destination = None if req.destination_id == "disponible" else await db.get(Reserve, req.destination_id)
if req.source_id != "disponible" and (not source or source.groupe_id != membre.groupe_id or not source.active):
raise HTTPException(status_code=404, detail="Réserve source introuvable")
if req.destination_id != "disponible" and (not destination or destination.groupe_id != membre.groupe_id or not destination.active):
raise HTTPException(status_code=404, detail="Réserve destination introuvable")
if req.source_id == "disponible":
disponible = await reserve_disponible(membre.groupe_id, db)
if req.montant > disponible:
raise HTTPException(status_code=400, detail="La réserve disponible est insuffisante")
elif req.montant > await solde_reserve(source.id, db):
raise HTTPException(status_code=400, detail="La réserve source est insuffisante")
if source:
source.montant = await solde_reserve(source.id, db) - req.montant
if destination:
destination.montant = await solde_reserve(destination.id, db) + req.montant
db.add(_mouvement_reserve(
membre.groupe_id,
membre.id,
req.montant,
"transfert",
source_id=source.id if source else None,
destination_id=destination.id if destination else None,
))
await db.flush()
return {"message": "Transfert effectué"}
@router.delete("/reserves/{reserve_id}")
async def supprimer_reserve(reserve_id: str, membre=Depends(_require_tresorier), db: AsyncSession = Depends(get_db)):
async def supprimer_reserve(reserve_id: str, membre=Depends(require_tresorier), db: AsyncSession = Depends(get_db)):
reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable")
montant = await solde_reserve(reserve.id, db)
if round(montant, 2) != 0:
raise HTTPException(status_code=400, detail="Une réserve doit être vide avant d'être supprimée")
reserve.active = False
await db.flush()
return {"message": "Réserve supprimée"}

View file

@ -10,6 +10,8 @@ from app.schemas.phase3 import GroupeUpdate, GroupeDetail
router = APIRouter(prefix="/groupe", tags=["G9 — Paramètres"])
ORDRES_ASSEMBLEE = {"premiere", "deuxieme", "troisieme", "quatrieme", "derniere"}
@router.get("/", response_model=GroupeDetail)
async def detail_groupe(
@ -45,6 +47,10 @@ async def modifier_groupe(
groupe.heure_reunion = None
else:
groupe.heure_reunion = None
if req.assemblee_affaires_ordre is not None:
ordre = req.assemblee_affaires_ordre.strip()
if ordre in ORDRES_ASSEMBLEE:
groupe.assemblee_affaires_ordre = ordre
if req.format_reunion is not None:
groupe.format_reunion = req.format_reunion.strip()
if req.lien_aa87 is not None:

View file

@ -2,9 +2,10 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.security import get_current_membre, require_executif
from app.models.poste import Poste
from app.models.affectation import Affectation
@ -16,20 +17,6 @@ from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, Termin
router = APIRouter(prefix="/postes", tags=["Postes et affectations"])
async def _prochain_numero_resolution(groupe_id, annee: int, db: AsyncSession) -> str:
from app.models.proposition import Proposition
result = await db.execute(
select(func.count(Proposition.id)).where(
Proposition.groupe_id == groupe_id,
Proposition.numero_resolution.isnot(None),
Proposition.numero_resolution.like(f"RES-{annee}-%"),
)
)
count = result.scalar() or 0
return f"RES-{annee}-{count + 1:03d}"
# ── Postes ──
@router.get("/", response_model=list[PosteOut])
@ -501,7 +488,7 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce poste est déjà affecté")
numero_resolution = await _prochain_numero_resolution(executif.groupe_id, req.date_debut.year, db)
numero_resolution = await prochain_numero_decision(executif.groupe_id, "nomination", req.date_debut.year, db)
resolution = Proposition(
groupe_id=executif.groupe_id,
type_proposition="nomination",
@ -512,6 +499,8 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe
statut="adoptee",
numero_resolution=numero_resolution,
date_vote=req.date_debut,
date_adoption=req.date_debut,
groupe_a_decide_de=f"Nommer {cible.prenom} au poste de {poste.nom}.",
)
db.add(resolution)
await db.flush()

View file

@ -1,50 +1,22 @@
"""Routes M2 — Présences en réunion d'affaires."""
from datetime import date as dt_date
from calendar import monthrange
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
from app.core.database import get_db
from app.core.roles import require_secretaire
from app.core.reunions import date_assemblee_affaires_du_mois, jour_reunion_attendu
from app.core.security import get_current_membre
from app.models.reunion import Reunion
from app.models.groupe import Groupe
from app.models.presence import Presence
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase4 import (
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
)
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
JOURS_SEMAINE = {
"lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3,
"vendredi": 4, "samedi": 5, "dimanche": 6,
}
def _premier_jour_du_mois(annee: int, mois: int, jour_cible: int) -> dt_date:
"""Retourne la date du premier 'jour_cible' (0=lundi) du mois."""
premier = dt_date(annee, mois, 1)
diff = (jour_cible - premier.weekday()) % 7
return dt_date(annee, mois, 1 + diff)
async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)):
"""Vérifie que le membre est secrétaire ou exécutif."""
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
(Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")),
)
)
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif")
return membre
@router.post("/reunion", response_model=ReunionAffairesOut)
async def creer_reunion_affaires(
@ -55,16 +27,17 @@ async def creer_reunion_affaires(
"""Créer une réunion d'affaires (point de départ pour présences et PV)."""
groupe = await db.get(Groupe, membre.groupe_id)
# ── Validation : premier jour de réunion du mois, une seule par mois ──
# ── Validation : réunion d'affaires prévue dans les paramètres du groupe ──
dt = req.date_reunion
if groupe.jour_reunion:
jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower())
jour_attendu = jour_reunion_attendu(groupe)
if jour_attendu is not None:
premier = _premier_jour_du_mois(dt.year, dt.month, jour_attendu)
if dt.date() != premier:
ordre = groupe.assemblee_affaires_ordre or "premiere"
date_attendue = date_assemblee_affaires_du_mois(dt.year, dt.month, jour_attendu, ordre)
if dt.date() != date_attendue:
raise HTTPException(
status_code=400,
detail=f"La réunion d'affaires doit avoir lieu le premier {groupe.jour_reunion} du mois ({premier.isoformat()})",
detail=f"La réunion d'affaires doit avoir lieu à la date prévue ({date_attendue.isoformat()})",
)
# Une seule réunion d'affaires par mois

View file

@ -2,9 +2,10 @@
from datetime import date, timezone, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract, func
from sqlalchemy import select, desc, extract
from app.core.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.security import get_current_membre, require_executif
from app.models.proposition import Proposition
from app.models.reunion import Reunion
@ -24,7 +25,7 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
return PropositionOut(
id=p.id,
reunion_id=p.reunion_id,
type_proposition=p.type_proposition or "generale",
type_proposition=p.type_proposition or "proposition",
sujet=p.sujet,
description=p.description,
proposeur_prenom=proposeur.prenom if proposeur else "?",
@ -32,6 +33,9 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
statut=p.statut,
numero_resolution=p.numero_resolution,
date_vote=p.date_vote,
date_adoption=p.date_adoption,
etant_donne_que=p.etant_donne_que,
groupe_a_decide_de=p.groupe_a_decide_de,
note=p.note,
candidature_id=p.candidature_id,
cree_le=p.cree_le,
@ -39,19 +43,6 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
)
async def _prochain_numero(groupe_id, annee: int, db: AsyncSession) -> str:
"""Génère le prochain numéro de résolution : RES-2026-001."""
result = await db.execute(
select(func.count(Proposition.id)).where(
Proposition.groupe_id == groupe_id,
Proposition.numero_resolution.isnot(None),
Proposition.numero_resolution.like(f"RES-{annee}-%"),
)
)
count = result.scalar() or 0
return f"RES-{annee}-{count + 1:03d}"
@router.post("/", response_model=PropositionOut)
async def creer_proposition(
req: PropositionCreate,
@ -72,7 +63,7 @@ async def creer_proposition(
prop = Proposition(
groupe_id=membre.groupe_id,
reunion_id=req.reunion_id,
type_proposition=req.type_proposition or "generale",
type_proposition="proposition",
sujet=req.sujet.strip(),
description=req.description,
proposeur_id=membre.id,
@ -130,18 +121,28 @@ async def voter_proposition(
prop = result.scalar_one_or_none()
if not prop:
raise HTTPException(status_code=404, detail="Proposition introuvable")
if prop.statut != "secondee":
raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être mise au vote")
prop.statut = req.statut
prop.date_vote = date.today()
if req.note:
prop.note = req.note.strip()
if req.statut == "adoptee" and prop.statut != "secondee":
raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être transformée en résolution")
if req.statut == "rejetee" and prop.statut not in ("proposee", "secondee"):
raise HTTPException(status_code=400, detail="Seule une proposition en cours peut être rejetée")
# Attribuer un numéro de résolution si adoptée
if req.statut == "adoptee":
prop.numero_resolution = await _prochain_numero(
executif.groupe_id, date.today().year, db
etant_donne_que = (req.etant_donne_que or "").strip()
groupe_a_decide_de = (req.groupe_a_decide_de or "").strip()
if not etant_donne_que or not groupe_a_decide_de:
raise HTTPException(
status_code=400,
detail='Les sections "ÉTANT DONNÉ QUE" et "LE GROUPE A DÉCIDÉ DE" sont obligatoires',
)
prop.etant_donne_que = etant_donne_que
prop.groupe_a_decide_de = groupe_a_decide_de
prop.note = None
if prop.type_proposition not in ("nomination", "contribution"):
prop.type_proposition = "resolution"
prop.numero_resolution = await prochain_numero_decision(
executif.groupe_id, prop.type_proposition, date.today().year, db
)
if req.reunion_id and not prop.reunion_id:
prop.reunion_id = req.reunion_id
@ -162,12 +163,20 @@ async def voter_proposition(
db.add(affectation)
elif req.statut == "rejetee":
if req.note:
prop.note = req.note.strip()
# Si nomination rejetée, mettre la candidature en refusée
if prop.type_proposition == "nomination" and prop.candidature_id:
cand = await db.get(Candidature, prop.candidature_id)
if cand:
cand.statut = "refusee"
prop.statut = req.statut
prop.date_vote = date.today()
if req.statut == "adoptee":
prop.date_adoption = prop.date_vote
await db.flush()
return await _to_out(prop, db)
@ -211,7 +220,7 @@ async def registre_resolutions(
query = query.where(extract("year", Proposition.cree_le) == annee)
if type_proposition:
query = query.where(Proposition.type_proposition == type_proposition)
query = query.order_by(Proposition.numero_resolution)
query = query.order_by(Proposition.date_vote, Proposition.cree_le, Proposition.numero_resolution)
result = await db.execute(query)
return [await _to_out(p, db) for p in result.scalars().all()]

View file

@ -4,31 +4,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.roles import require_secretaire
from app.core.security import get_current_membre
from app.models.pv_reunion import PvReunion
from app.models.reunion import Reunion
from app.models.membre import Membre
from app.models.decision import Decision
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase4 import PvCreate, PvOut, PvUpdate
from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate
router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"])
async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)):
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
(Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")),
)
)
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif")
return membre
async def _to_out(pv: PvReunion, db: AsyncSession) -> PvOut:
redacteur = await db.get(Membre, pv.redacteur_id)
reunion = await db.get(Reunion, pv.reunion_id) if pv.reunion_id else None
@ -110,23 +95,6 @@ async def creer_pv(
db.add(pv)
await db.flush()
# Créer les décisions dans la table decisions (pour C5)
if req.decisions:
for d in req.decisions:
decision = Decision(
groupe_id=membre.groupe_id,
reunion_id=req.reunion_id,
sujet=d.sujet,
description=d.decision,
statut=d.statut,
proposeur=d.proposeur,
secondeur=d.secondeur,
date_decision=reunion.date_reunion.date() if reunion.date_reunion else None,
cree_par=membre.id,
)
db.add(decision)
await db.flush()
return await _to_out(pv, db)
@ -155,3 +123,35 @@ async def modifier_pv(
pv.approuve = req.approuve
await db.flush()
return await _to_out(pv, db)
@router.patch("/{pv_id}/adopter", response_model=PvOut)
async def adopter_pv(
pv_id: str,
req: PvAdoption,
membre=Depends(require_secretaire),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(PvReunion)
.join(Reunion, PvReunion.reunion_id == Reunion.id)
.where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id)
)
pv = result.scalar_one_or_none()
if not pv:
raise HTTPException(status_code=404, detail="PV introuvable")
if not req.proposeur.strip() or not req.secondeur.strip():
raise HTTPException(status_code=400, detail="Proposeur et secondeur requis")
decisions = list(pv.decisions or [])
decisions.append({
"sujet": "Adoption du procès-verbal précédent",
"decision": "Le procès-verbal précédent est adopté.",
"proposeur": req.proposeur.strip(),
"secondeur": req.secondeur.strip(),
"statut": "complete",
})
pv.decisions = decisions
pv.approuve = True
await db.flush()
return await _to_out(pv, db)

View file

@ -1,6 +1,7 @@
"""Routes C1 — Rapports PDF (finances, PV, rapport RSG)."""
import io
from datetime import date, datetime
from html import escape
from datetime import date, datetime, time, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
@ -24,6 +25,19 @@ MOIS_FR = [
]
def _texte_decision_resolution(p: Proposition) -> str:
"""Retourne le texte décisionnel si la résolution utilise le format structuré."""
if p.groupe_a_decide_de:
return p.groupe_a_decide_de
if not p.note:
return p.sujet
marqueur = "LE GROUPE A DÉCIDÉ DE"
if marqueur not in p.note:
return p.sujet
decision = p.note.split(marqueur, 1)[1].strip()
return decision or p.sujet
def _css():
return """
body { font-family: 'DejaVu Sans', sans-serif; font-size: 11pt; color: #333; margin: 2cm; }
@ -355,33 +369,34 @@ async def rapport_resolutions(
for p in propositions:
proposeur = await db.get(Membre, p.proposeur_id) if p.proposeur_id else None
secondeur = await db.get(Membre, p.secondeur_id) if p.secondeur_id else None
badge = '<span class="badge-paye">✓ Adoptée</span>' if p.statut == "adoptee" else '<span class="badge-impaye">✗ Rejetée</span>'
numero_date = f"{escape(p.numero_resolution or '—')}<br><span class=\"muted\">{escape(str(p.date_adoption or p.date_vote or '—'))}</span>"
resolution = escape(_texte_decision_resolution(p)).replace("\n", "<br>")
proposeurs = f"{escape(proposeur.prenom if proposeur else '—')} / {escape(secondeur.prenom if secondeur else '—')}"
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>
<td>{numero_date}</td>
<td>{resolution}</td>
<td>{proposeurs}</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>
<style>.muted {{ color: #777; font-size: 8pt; }}</style>
<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>
<p>Décisions prises en Groupe</p>
<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>'}
<colgroup>
<col style="width: 18%;">
<col style="width: 60%;">
<col style="width: 22%;">
</colgroup>
<tr><th>N° / date</th><th>Résolution</th><th>Proposeur / secondeur</th></tr>
{rows if rows else '<tr><td colspan="3">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>
@ -399,18 +414,27 @@ async def rapport_resolutions(
@router.get("/etat-financier")
async def etat_financier_temps_reel(
annee: int | None = None,
mois: int | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""État des résultats et transactions en temps réel (JSON)."""
"""État des résultats par période et soldes courants (JSON)."""
from sqlalchemy import func, desc
from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve
from app.models.transaction_comptable import TransactionComptable
from app.models.vente_litterature import VenteLitterature
from app.models.litterature import Litterature
from app.services.finance import encaisse as solde_encaisse
from app.services.finance import engagements_non_debites
from app.services.finance import solde_banque as solde_banque_actuel
from app.services.finance import solde_reserve
gid = membre.groupe_id
groupe = await db.get(Groupe, gid)
aujourd_hui = date.today()
annee = annee or aujourd_hui.year
mois = mois or aujourd_hui.month
# ── Dernier relevé bancaire ──
result = await db.execute(
@ -422,81 +446,71 @@ async def etat_financier_temps_reel(
dernier_releve = result.scalar_one_or_none()
solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0
# ── Revenus ──
# ── État des résultats : période demandée ──
result = await db.execute(
select(func.coalesce(func.sum(Collecte.montant), 0)).where(
Collecte.groupe_id == gid, Collecte.statut == "recu"))
Collecte.groupe_id == gid,
Collecte.statut == "recu",
extract("year", Collecte.date_reception) == annee,
extract("month", Collecte.date_reception) == mois,
)
)
total_collectes = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True))
VenteLitterature.groupe_id == gid,
VenteLitterature.remis_tresorier == True,
extract("year", VenteLitterature.date_remise) == annee,
extract("month", VenteLitterature.date_remise) == mois,
)
)
total_ventes = float(result.scalar())
revenus = total_collectes + total_ventes
result = await db.execute(
select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where(
TransactionComptable.groupe_id == gid,
TransactionComptable.type == "don",
TransactionComptable.compte == "banque",
TransactionComptable.sens == "debit",
extract("year", TransactionComptable.date_transaction) == annee,
extract("month", TransactionComptable.date_transaction) == mois,
)
)
total_dons = float(result.scalar())
revenus = total_collectes + total_ventes + total_dons
# ── Dépenses ──
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid, Depense.paye == True))
Depense.groupe_id == gid,
Depense.paye == True,
extract("year", Depense.date_confirmation) == annee,
extract("month", Depense.date_confirmation) == mois,
)
)
total_depenses_payees = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid, Depense.paye == False, Depense.confirme_par.isnot(None)))
Depense.groupe_id == gid,
Depense.paye == False,
Depense.confirme_par.isnot(None),
)
)
total_engagements = float(result.scalar())
# ── Contributions ──
result = await db.execute(
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
extract("year", EnvoiContribution.date_envoi) == annee,
extract("month", EnvoiContribution.date_envoi) == mois,
))
total_contributions = float(result.scalar())
# ── Encaisse ──
result = await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot"))
total_depots = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait"))
total_retraits = float(result.scalar())
result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE"))
depenses_cash = float(result.scalar())
encaisse = total_collectes + total_ventes + total_retraits - total_depots - depenses_cash
# ── Solde banque ──
filtre_depuis = dernier_releve.date_operation if dernier_releve else None
q_depots = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot")
q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait")
q_depenses_bancaires = select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid,
Depense.paye == True,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
)
q_contributions = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
)
if filtre_depuis:
q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis)
q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis)
q_depenses_bancaires = q_depenses_bancaires.where(Depense.date_confirmation > filtre_depuis)
q_contributions = q_contributions.where(EnvoiContribution.date_envoi > filtre_depuis)
depots_depuis = float((await db.execute(q_depots)).scalar())
retraits_depuis = float((await db.execute(q_retraits)).scalar())
depenses_bancaires_depuis = float((await db.execute(q_depenses_bancaires)).scalar())
contributions_depuis = float((await db.execute(q_contributions)).scalar())
solde_banque = solde_releve + depots_depuis - retraits_depuis - depenses_bancaires_depuis - contributions_depuis
encaisse = await solde_encaisse(gid, db)
solde_banque = await solde_banque_actuel(gid, db)
# ── Réserves ──
result = await db.execute(
@ -505,14 +519,18 @@ async def etat_financier_temps_reel(
Reserve.active == True,
).order_by(Reserve.nom)
)
reserves_details = [
{"id": str(r.id), "nom": r.nom, "montant": float(r.montant), "note": r.note}
for r in result.scalars().all()
]
reserves_details = []
for reserve in result.scalars().all():
reserves_details.append({
"id": str(reserve.id),
"nom": reserve.nom,
"montant": round(await solde_reserve(reserve.id, db), 2),
"note": reserve.note,
})
total_reserves = sum(r["montant"] for r in reserves_details)
disponible = solde_banque - total_engagements - total_reserves
engagements_courants = await engagements_non_debites(gid, db)
disponible = solde_banque + encaisse - engagements_courants - total_reserves
# ── Dernières transactions ──
result = await db.execute(
select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu")
@ -531,6 +549,11 @@ async def etat_financier_temps_reel(
return {
"groupe": groupe.nom if groupe else "?",
"periode": {
"annee": annee,
"mois": mois,
"libelle": f"{MOIS_FR[mois]} {annee}",
},
"date_releve": str(dernier_releve.date_operation) if dernier_releve else None,
"solde_releve": round(solde_releve, 2),
"solde_banque": round(solde_banque, 2),
@ -539,19 +562,246 @@ async def etat_financier_temps_reel(
"revenus": {
"collectes": round(total_collectes, 2),
"ventes_litterature": round(total_ventes, 2),
"dons": round(total_dons, 2),
"total": round(revenus, 2),
},
"sorties": {
"depenses_payees": round(total_depenses_payees, 2),
"engagements": round(total_engagements, 2),
"engagements": round(engagements_courants, 2),
"contributions": round(total_contributions, 2),
"total": round(total_depenses_payees + total_contributions, 2),
},
"reserves": {
"details": reserves_details,
"total": round(total_reserves, 2),
"total_reelles": round(total_reserves, 2),
},
"resultat_net": round(revenus - total_depenses_payees - total_contributions, 2),
"dernieres_collectes": dernieres_collectes,
"dernieres_depenses": dernieres_depenses,
}
@router.get("/etat-resultats/{annee}/{mois}")
async def rapport_etat_resultats(
annee: int,
mois: int,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""PDF mensuel: état des résultats et position de trésorerie."""
try:
from weasyprint import HTML
except ImportError:
raise HTTPException(status_code=500, detail="WeasyPrint non installé")
if mois < 1 or mois > 12:
raise HTTPException(status_code=400, detail="Mois invalide")
from sqlalchemy import func, desc
from app.models.operation_bancaire import OperationBancaire
from app.models.mouvement_reserve import MouvementReserve
from app.models.reserve import Reserve
from app.models.transaction_comptable import TransactionComptable
from app.models.vente_litterature import VenteLitterature
gid = membre.groupe_id
groupe = await db.get(Groupe, gid)
fin_mois = (date(annee + 1, 1, 1) if mois == 12 else date(annee, mois + 1, 1)) - timedelta(days=1)
fin_mois_dt = datetime.combine(fin_mois, time.max).replace(tzinfo=timezone.utc)
async def solde_compte_a_date(compte: str) -> float:
debits = float((await db.execute(
select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where(
TransactionComptable.groupe_id == gid,
TransactionComptable.compte == compte,
TransactionComptable.sens == "debit",
TransactionComptable.date_transaction <= fin_mois,
)
)).scalar())
credits = float((await db.execute(
select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where(
TransactionComptable.groupe_id == gid,
TransactionComptable.compte == compte,
TransactionComptable.sens == "credit",
TransactionComptable.date_transaction <= fin_mois,
)
)).scalar())
return debits - credits
async def solde_reserve_a_date(reserve_id) -> float:
entrees = float((await db.execute(
select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where(
MouvementReserve.destination_id == reserve_id,
MouvementReserve.cree_le <= fin_mois_dt,
)
)).scalar())
sorties = float((await db.execute(
select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where(
MouvementReserve.source_id == reserve_id,
MouvementReserve.cree_le <= fin_mois_dt,
)
)).scalar())
return entrees - sorties
async def engagements_a_date() -> float:
depenses = float((await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid,
Depense.paye == False,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
Depense.date_confirmation <= fin_mois_dt,
)
)).scalar())
contributions = float((await db.execute(
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.engagee == True,
EnvoiContribution.debitee == False,
EnvoiContribution.date_envoi <= fin_mois,
)
)).scalar())
return depenses + contributions
total_collectes = float((await db.execute(
select(func.coalesce(func.sum(Collecte.montant), 0)).where(
Collecte.groupe_id == gid,
Collecte.statut == "recu",
extract("year", Collecte.date_reception) == annee,
extract("month", Collecte.date_reception) == mois,
)
)).scalar())
total_ventes = float((await db.execute(
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
VenteLitterature.groupe_id == gid,
VenteLitterature.remis_tresorier == True,
extract("year", VenteLitterature.date_remise) == annee,
extract("month", VenteLitterature.date_remise) == mois,
)
)).scalar())
total_dons = float((await db.execute(
select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where(
TransactionComptable.groupe_id == gid,
TransactionComptable.type == "don",
TransactionComptable.compte == "banque",
TransactionComptable.sens == "debit",
extract("year", TransactionComptable.date_transaction) == annee,
extract("month", TransactionComptable.date_transaction) == mois,
)
)).scalar())
total_depenses = float((await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == gid,
Depense.paye == True,
extract("year", Depense.date_confirmation) == annee,
extract("month", Depense.date_confirmation) == mois,
)
)).scalar())
total_contributions = float((await db.execute(
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True,
extract("year", EnvoiContribution.date_envoi) == annee,
extract("month", EnvoiContribution.date_envoi) == mois,
)
)).scalar())
revenus = total_collectes + total_ventes + total_dons
charges = total_depenses + total_contributions
resultat = revenus - charges
banque = await solde_compte_a_date("banque")
caisse = await solde_compte_a_date("encaisse")
engagements = await engagements_a_date()
result = await db.execute(
select(Reserve).where(Reserve.groupe_id == gid).order_by(Reserve.nom)
)
reserves = []
total_reserves = 0.0
for reserve in result.scalars().all():
montant = await solde_reserve_a_date(reserve.id)
if not reserve.active and round(montant, 2) == 0:
continue
total_reserves += montant
reserves.append((reserve.nom, montant))
disponible = banque + caisse - engagements - total_reserves
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == gid,
OperationBancaire.type == "releve",
OperationBancaire.date_operation <= fin_mois,
).order_by(desc(OperationBancaire.date_operation)).limit(1)
)
dernier_releve = result.scalar_one_or_none()
rows_reserves = "".join(
f"<tr><td>{escape(nom)}</td><td class=\"montant\">{montant:.2f} $</td></tr>"
for nom, montant in reserves
) or "<tr><td colspan=\"2\">Aucune réserve enregistrée</td></tr>"
css = _css() + """
.montant { text-align: right; white-space: nowrap; }
.positif { color: #1e8449; }
.negatif { color: #c0392b; }
.note { color: #777; font-size: 9pt; }
"""
signe = "+" if resultat >= 0 else ""
classe_resultat = "positif" if resultat >= 0 else "negatif"
groupe_nom = groupe.nom if groupe and groupe.nom else "Groupe"
district = str(groupe.district if groupe and groupe.district else "87-16")
region = str(groupe.region if groupe and groupe.region else "87")
html_content = f"""<!DOCTYPE html><html><head><style>{css}</style></head><body>
<div class="header">
<div class="groupe">{escape(groupe_nom)}</div>
<div class="sub">District {escape(district)} · Région {escape(region)}</div>
</div>
<h1>État des résultats — {MOIS_FR[mois]} {annee}</h1>
<h2>Revenus</h2>
<table>
<tr><td>Collectes 7e Tradition</td><td class="montant">{total_collectes:.2f} $</td></tr>
<tr><td>Ventes de littérature</td><td class="montant">{total_ventes:.2f} $</td></tr>
<tr><td>Dons déposés directement</td><td class="montant">{total_dons:.2f} $</td></tr>
<tr><td><strong>Total des revenus</strong></td><td class="montant"><strong>{revenus:.2f} $</strong></td></tr>
</table>
<h2>Charges</h2>
<table>
<tr><td>Dépenses payées</td><td class="montant">{total_depenses:.2f} $</td></tr>
<tr><td>Contributions envoyées</td><td class="montant">{total_contributions:.2f} $</td></tr>
<tr><td><strong>Total des charges</strong></td><td class="montant"><strong>{charges:.2f} $</strong></td></tr>
</table>
<p class="total {classe_resultat}">{'Excédent' if resultat >= 0 else 'Déficit'} : {signe}{resultat:.2f} $</p>
<h2>Position de trésorerie</h2>
<table>
<tr><td>Solde bancaire au {fin_mois.isoformat()}</td><td class="montant">{banque:.2f} $</td></tr>
<tr><td>Encaisse au {fin_mois.isoformat()}</td><td class="montant">{caisse:.2f} $</td></tr>
<tr><td>Engagements non débités</td><td class="montant">{engagements:.2f} $</td></tr>
<tr><td>Réserves</td><td class="montant">{total_reserves:.2f} $</td></tr>
<tr><td>Disponible au {fin_mois.isoformat()}</td><td class="montant">{disponible:.2f} $</td></tr>
</table>
{f'<p class="note">Dernier relevé bancaire : {dernier_releve.date_operation}</p>' if dernier_releve else ''}
<h2>Réserves</h2>
<table>
{rows_reserves}
<tr><td><strong>Total des réserves</strong></td><td class="montant"><strong>{total_reserves:.2f} $</strong></td></tr>
</table>
<div class="footer">
Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}
</div>
</body></html>"""
pdf_bytes = HTML(string=html_content).write_pdf()
filename = f"etat_resultats_{groupe_nom.replace(' ', '_')}_{annee}_{mois:02d}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)

View file

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.security import get_current_membre
from app.core.security import get_current_membre, is_sysadmin
from app.models.rapport_rsg import RapportRsg
from app.models.membre import Membre
from app.models.affectation import Affectation
@ -16,6 +16,8 @@ router = APIRouter(prefix="/rapports-rsg", tags=["M3 — Rapport RSG"])
async def require_rsg(membre=Depends(get_current_membre), db=Depends(get_db)):
"""Vérifie que le membre est RSG, RSG substitut ou exécutif."""
if is_sysadmin(membre):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,

View file

@ -5,40 +5,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract
from app.core.database import get_db
from app.core.security import get_current_membre
from app.core.reunions import jour_reunion_attendu
from app.core.security import get_current_membre, require_executif
from app.models.reunion import Reunion
from app.models.groupe import Groupe
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase1 import ReunionCreate, ReunionOut
router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"])
JOURS_SEMAINE = {
"lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3,
"vendredi": 4, "samedi": 5, "dimanche": 6,
}
async def require_animateur(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est animateur ou exécutif."""
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
(Poste.nom.ilike("%animateur%") | (Poste.categorie == "executif")),
)
)
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé à l'animateur ou à l'exécutif")
return membre
@router.post("/", response_model=ReunionOut)
async def creer_rapport_reunion(
req: ReunionCreate,
membre=Depends(require_animateur),
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""S4 — L'animateur note le thème et les annonces."""
@ -46,7 +26,7 @@ async def creer_rapport_reunion(
# ── Validation : réunion régulière ──
if req.type == "reguliere" and groupe.jour_reunion:
jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower())
jour_attendu = jour_reunion_attendu(groupe)
jour_soumis = req.date_reunion.weekday()
if jour_attendu is not None and jour_soumis != jour_attendu:
raise HTTPException(

View file

@ -1,214 +0,0 @@
"""Routes G6/C6 — Sondages et votes de conscience de groupe."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from app.core.database import get_db
from app.core.security import get_current_membre, require_executif
from app.models.sondage import Sondage
from app.models.option_sondage import OptionSondage
from app.models.vote import Vote
from app.models.membre import Membre
from app.schemas.phase8 import SondageCreate, SondageOut, SondageUpdate, OptionOut, VoteCreate
router = APIRouter(prefix="/sondages", tags=["G6/C6 — Sondages et votes"])
TYPES_VALIDES = {"oui_non", "choix_multiple", "unanimite"}
async def _build_sondage_out(sondage: Sondage, membre_id, db: AsyncSession) -> SondageOut:
"""Construit la sortie complète d'un sondage avec comptage des votes."""
cree = await db.get(Membre, sondage.cree_par) if sondage.cree_par else None
# Options avec comptage
result = await db.execute(
select(OptionSondage)
.where(OptionSondage.sondage_id == sondage.id)
.order_by(OptionSondage.ordre)
)
options = result.scalars().all()
options_out = []
total = 0
for opt in options:
count_result = await db.execute(
select(func.count(Vote.id)).where(Vote.option_id == opt.id)
)
nb = count_result.scalar() or 0
total += nb
options_out.append(OptionOut(
id=opt.id, texte=opt.texte, ordre=opt.ordre, nb_votes=nb
))
# Le membre a-t-il voté?
vote_result = await db.execute(
select(Vote).where(Vote.sondage_id == sondage.id, Vote.membre_id == membre_id)
)
a_vote = vote_result.scalar_one_or_none() is not None
return SondageOut(
id=sondage.id,
titre=sondage.titre,
description=sondage.description,
type=sondage.type,
cree_par_prenom=cree.prenom if cree else None,
date_ouverture=sondage.date_ouverture,
date_fermeture=sondage.date_fermeture,
statut=sondage.statut,
options=options_out,
total_votes=total,
a_vote=a_vote,
)
@router.post("/", response_model=SondageOut)
async def creer_sondage(
req: SondageCreate,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""G6 — Créer un sondage de conscience de groupe."""
if req.type not in TYPES_VALIDES:
raise HTTPException(status_code=400, detail=f"Type invalide. Choix: {list(TYPES_VALIDES)}")
if req.date_fermeture <= req.date_ouverture:
raise HTTPException(status_code=400, detail="La date de fermeture doit être après l'ouverture")
# Pour oui_non, créer automatiquement les options
options_data = req.options
if req.type == "oui_non" and len(options_data) == 0:
from app.schemas.phase8 import OptionCreate
options_data = [OptionCreate(texte="Oui"), OptionCreate(texte="Non")]
if len(options_data) < 2:
raise HTTPException(status_code=400, detail="Au moins 2 options sont requises")
sondage = Sondage(
groupe_id=membre.groupe_id,
titre=req.titre.strip(),
description=req.description,
type=req.type,
cree_par=membre.id,
date_ouverture=req.date_ouverture,
date_fermeture=req.date_fermeture,
statut="brouillon",
)
db.add(sondage)
await db.flush()
for i, opt in enumerate(options_data):
option = OptionSondage(
sondage_id=sondage.id,
texte=opt.texte.strip(),
ordre=i,
)
db.add(option)
await db.flush()
return await _build_sondage_out(sondage, membre.id, db)
@router.get("/", response_model=list[SondageOut])
async def liste_sondages(
statut: str | None = None,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""Lister les sondages du groupe."""
query = select(Sondage).where(Sondage.groupe_id == membre.groupe_id)
if statut:
query = query.where(Sondage.statut == statut)
query = query.order_by(desc(Sondage.date_ouverture))
result = await db.execute(query)
sondages = result.scalars().all()
return [await _build_sondage_out(s, membre.id, db) for s in sondages]
@router.get("/{sondage_id}", response_model=SondageOut)
async def detail_sondage(
sondage_id: str,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id)
)
sondage = result.scalar_one_or_none()
if not sondage:
raise HTTPException(status_code=404, detail="Sondage introuvable")
return await _build_sondage_out(sondage, membre.id, db)
@router.patch("/{sondage_id}", response_model=SondageOut)
async def modifier_statut_sondage(
sondage_id: str,
req: SondageUpdate,
membre=Depends(require_executif),
db: AsyncSession = Depends(get_db),
):
"""Ouvrir ou fermer un sondage."""
result = await db.execute(
select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id)
)
sondage = result.scalar_one_or_none()
if not sondage:
raise HTTPException(status_code=404, detail="Sondage introuvable")
if req.statut and req.statut in ("brouillon", "ouvert", "ferme"):
sondage.statut = req.statut
await db.flush()
return await _build_sondage_out(sondage, membre.id, db)
@router.post("/{sondage_id}/voter")
async def voter(
sondage_id: str,
req: VoteCreate,
membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db),
):
"""C6 — Voter dans un sondage ouvert. Un membre = un vote."""
result = await db.execute(
select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id)
)
sondage = result.scalar_one_or_none()
if not sondage:
raise HTTPException(status_code=404, detail="Sondage introuvable")
if sondage.statut != "ouvert":
raise HTTPException(status_code=400, detail="Ce sondage n'est pas ouvert au vote")
now = datetime.now(timezone.utc)
if now < sondage.date_ouverture or now > sondage.date_fermeture:
raise HTTPException(status_code=400, detail="La période de vote est terminée ou pas encore ouverte")
# Vérifier qu'il n'a pas déjà voté
existing = await db.execute(
select(Vote).where(Vote.sondage_id == sondage.id, Vote.membre_id == membre.id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Vous avez déjà voté dans ce sondage")
# Vérifier que l'option appartient au sondage
opt_result = await db.execute(
select(OptionSondage).where(
OptionSondage.id == req.option_id,
OptionSondage.sondage_id == sondage.id,
)
)
if not opt_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Option invalide")
vote = Vote(
sondage_id=sondage.id,
option_id=req.option_id,
membre_id=membre.id,
)
db.add(vote)
await db.flush()
return {"message": "Vote enregistré", "option_id": str(req.option_id)}

View file

@ -4,12 +4,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.core.security import get_current_membre
from app.core.security import get_current_membre, is_sysadmin
from app.models.litterature import Litterature
from app.models.vente_litterature import VenteLitterature
from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.models.transaction_comptable import TransactionComptable
from app.schemas.phase6 import VenteCreate, VenteOut
router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littérature"])
@ -17,11 +18,13 @@ router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littératur
async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable de la littérature."""
if is_sysadmin(membre):
return membre
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
Poste.nom.ilike("%littérature%"),
(Poste.nom.ilike("%littérature%") | (Poste.categorie == "executif")),
)
)
if not result.scalars().first():
@ -151,15 +154,16 @@ async def confirmer_reception_vente(
):
"""Le trésorier confirme la réception du montant de la vente."""
# Vérifier que c'est le trésorier ou exécutif
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
if not is_sysadmin(membre):
result = await db.execute(
select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id,
Affectation.statut == "actif",
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
)
)
)
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
if not result.scalars().first():
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
result = await db.execute(
select(VenteLitterature).where(
@ -177,5 +181,17 @@ async def confirmer_reception_vente(
vente.remis_tresorier = True
vente.recu_par = membre.id
vente.date_remise = date_type.today()
db.add(TransactionComptable(
groupe_id=vente.groupe_id,
date_transaction=vente.date_remise,
type="vente_litterature",
compte="encaisse",
montant=vente.montant,
sens="debit",
source_table="ventes_litterature",
source_id=vente.id,
note="Vente de littérature reçue",
cree_par=membre.id,
))
await db.flush()
return {"message": "Réception confirmée"}

View file

@ -36,6 +36,7 @@ class CollecteUpdate(BaseModel):
# ── Opérations bancaires ──
class OperationBancaireCreate(BaseModel):
type: str = "depot"
provenance_depot: Optional[str] = "encaisse"
montant: float
date_operation: date
note: Optional[str] = None
@ -53,6 +54,20 @@ class OperationBancaireOut(BaseModel):
model_config = {"from_attributes": True}
class TransactionComptableOut(BaseModel):
id: UUID
date_transaction: date
type: str
compte: str
montant: float
sens: str
source_table: Optional[str] = None
source_id: Optional[UUID] = None
note: Optional[str] = None
cree_par_prenom: Optional[str] = None
cree_le: datetime
class ReserveCreate(BaseModel):
nom: str
montant: float
@ -65,6 +80,12 @@ class ReserveUpdate(BaseModel):
note: Optional[str] = None
class ReserveTransfer(BaseModel):
source_id: str
destination_id: str
montant: float
class ReserveOut(BaseModel):
id: UUID
nom: str
@ -76,6 +97,19 @@ class ReserveOut(BaseModel):
model_config = {"from_attributes": True}
class ReserveMouvementOut(BaseModel):
id: UUID
source_id: Optional[UUID] = None
source_nom: Optional[str] = None
destination_id: Optional[UUID] = None
destination_nom: Optional[str] = None
montant: float
type: str
note: Optional[str] = None
cree_par_prenom: Optional[str] = None
cree_le: datetime
class SoldesOut(BaseModel):
encaisse: float # cash reçu par trésorier, pas encore déposé
compte_bancaire: float # dépôts - dépenses payées - contributions
@ -107,10 +141,11 @@ class ReunionOut(BaseModel):
# ── S5 Dépense (ouvert à tous) ──
class DepenseCreate(BaseModel):
date_depense: date
montant: float
quoi: str
# qui = auto (membre courant)
membre_id: Optional[UUID] = None
date_depense: Optional[date] = None
# qui = auto, sauf si un exécutif soumet pour un autre membre
# methode renseignée par le trésorier à la confirmation

View file

@ -30,6 +30,7 @@ class GroupeUpdate(BaseModel):
adresse_local: Optional[str] = None
jour_reunion: Optional[str] = None
heure_reunion: Optional[str] = None # frontend envoie "HH:MM", parsé dans parametres.py
assemblee_affaires_ordre: Optional[str] = None
format_reunion: Optional[str] = None
lien_aa87: Optional[str] = None
@ -42,6 +43,7 @@ class GroupeDetail(BaseModel):
adresse_local: Optional[str] = None
jour_reunion: Optional[str] = None
heure_reunion: Optional[time] = None # sérialisé "HH:MM:SS" par pydantic
assemblee_affaires_ordre: Optional[str] = "premiere"
format_reunion: Optional[str] = None
lien_aa87: str
cree_le: datetime

View file

@ -71,6 +71,11 @@ class PvUpdate(BaseModel):
approuve: Optional[bool] = None
class PvAdoption(BaseModel):
proposeur: str
secondeur: str
# ── M3 Rapport RSG ──
class RapportRsgCreate(BaseModel):
reunion_dist: date

View file

@ -1,54 +0,0 @@
"""Schémas Phase 8 — Votes et sondages."""
from pydantic import BaseModel
from datetime import datetime
from uuid import UUID
from typing import Optional
# ── G6 Lancer un sondage ──
class OptionCreate(BaseModel):
texte: str
class SondageCreate(BaseModel):
titre: str
description: Optional[str] = None
type: str = "oui_non" # oui_non, choix_multiple, unanimite
date_ouverture: datetime
date_fermeture: datetime
options: list[OptionCreate] = []
class OptionOut(BaseModel):
id: UUID
texte: str
ordre: int
nb_votes: int = 0
model_config = {"from_attributes": True}
class SondageOut(BaseModel):
id: UUID
titre: str
description: Optional[str] = None
type: str
cree_par_prenom: Optional[str] = None
date_ouverture: datetime
date_fermeture: datetime
statut: str
options: list[OptionOut] = []
total_votes: int = 0
a_vote: bool = False # le membre courant a-t-il voté?
model_config = {"from_attributes": True}
class SondageUpdate(BaseModel):
statut: Optional[str] = None # ouvert, ferme
# ── C6 Voter ──
class VoteCreate(BaseModel):
option_id: UUID

View file

@ -9,13 +9,12 @@ class PropositionCreate(BaseModel):
reunion_id: Optional[UUID] = None
sujet: str
description: Optional[str] = None
type_proposition: str = "generale" # generale, nomination, contribution
class PropositionOut(BaseModel):
id: UUID
reunion_id: Optional[UUID] = None
type_proposition: str = "generale"
type_proposition: str = "proposition"
sujet: str
description: Optional[str] = None
proposeur_prenom: str
@ -23,6 +22,9 @@ class PropositionOut(BaseModel):
statut: str
numero_resolution: Optional[str] = None
date_vote: Optional[date] = None
date_adoption: Optional[date] = None
etant_donne_que: Optional[str] = None
groupe_a_decide_de: Optional[str] = None
note: Optional[str] = None
candidature_id: Optional[UUID] = None
cree_le: datetime
@ -34,4 +36,6 @@ class PropositionOut(BaseModel):
class PropositionVote(BaseModel):
statut: str # adoptee ou rejetee
note: Optional[str] = None
etant_donne_que: Optional[str] = None
groupe_a_decide_de: Optional[str] = None
reunion_id: Optional[UUID] = None

View file

@ -36,6 +36,7 @@ class MembrePublic(BaseModel):
courriel: str
groupe_id: UUID
date_abstinence: Optional[date] = None
is_sysadmin: bool = False
model_config = {"from_attributes": True}
@ -123,6 +124,7 @@ class ModuleTuile(BaseModel):
class AccueilResponse(BaseModel):
membre: MembrePublic
postes: list[str]
est_executif: bool = False
modules_consultation: list[ModuleTuile]
modules_saisie: list[ModuleTuile]
modules_gestion: list[ModuleTuile]
@ -148,5 +150,6 @@ class GroupeOut(BaseModel):
district: str
region: str
jour_reunion: Optional[str] = None
assemblee_affaires_ordre: Optional[str] = "premiere"
model_config = {"from_attributes": True}

View file

@ -0,0 +1,111 @@
"""Services financiers: registre comptable, soldes et réserves."""
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.depense import Depense
from app.models.envoi_contribution import EnvoiContribution
from app.models.mouvement_reserve import MouvementReserve
from app.models.reserve import Reserve
from app.models.transaction_comptable import TransactionComptable
async def solde_compte(groupe_id, compte: str, db: AsyncSession) -> float:
debits = float((await db.execute(
select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where(
TransactionComptable.groupe_id == groupe_id,
TransactionComptable.compte == compte,
TransactionComptable.sens == "debit",
)
)).scalar())
credits = float((await db.execute(
select(func.coalesce(func.sum(TransactionComptable.montant), 0)).where(
TransactionComptable.groupe_id == groupe_id,
TransactionComptable.compte == compte,
TransactionComptable.sens == "credit",
)
)).scalar())
return debits - credits
async def encaisse(groupe_id, db: AsyncSession) -> float:
return await solde_compte(groupe_id, "encaisse", db)
async def solde_banque(groupe_id, db: AsyncSession) -> float:
return await solde_compte(groupe_id, "banque", db)
async def engagements_non_debites(groupe_id, db: AsyncSession) -> float:
depenses = float((await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where(
Depense.groupe_id == groupe_id,
Depense.paye == False,
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
)
)).scalar())
contributions = float((await db.execute(
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == groupe_id,
EnvoiContribution.engagee == True,
EnvoiContribution.debitee == False,
)
)).scalar())
return depenses + contributions
async def solde_reserve(reserve_id, db: AsyncSession) -> float:
entrees = float((await db.execute(
select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where(
MouvementReserve.destination_id == reserve_id,
)
)).scalar())
sorties = float((await db.execute(
select(func.coalesce(func.sum(MouvementReserve.montant), 0)).where(
MouvementReserve.source_id == reserve_id,
)
)).scalar())
return entrees - sorties
async def total_reserves(groupe_id, db: AsyncSession, exclure_id: str | None = None) -> float:
result = await db.execute(select(Reserve).where(Reserve.groupe_id == groupe_id, Reserve.active == True))
total = 0.0
for reserve in result.scalars().all():
if exclure_id and str(reserve.id) == str(exclure_id):
continue
total += await solde_reserve(reserve.id, db)
return total
async def reserve_disponible(groupe_id, db: AsyncSession) -> float:
return (
await solde_banque(groupe_id, db)
- await total_reserves(groupe_id, db)
- await engagements_non_debites(groupe_id, db)
)
async def dernier_releve(groupe_id, db: AsyncSession):
from app.models.operation_bancaire import OperationBancaire
result = await db.execute(
select(OperationBancaire).where(
OperationBancaire.groupe_id == groupe_id,
OperationBancaire.type == "releve",
).order_by(desc(OperationBancaire.date_operation)).limit(1)
)
return result.scalar_one_or_none()
def transaction(groupe_id, membre_id, date_transaction, type_, compte, montant, sens, source_table=None, source_id=None, note=None):
return TransactionComptable(
groupe_id=groupe_id,
date_transaction=date_transaction,
type=type_,
compte=compte,
montant=montant,
sens=sens,
source_table=source_table,
source_id=source_id,
note=note,
cree_par=membre_id,
)

View file

@ -0,0 +1,76 @@
"""Envoi des notifications Web Push."""
import asyncio
import base64
import json
from tempfile import NamedTemporaryFile
from typing import Iterable
from pywebpush import WebPushException, webpush
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.push_subscription import PushSubscription
def notifications_configured() -> bool:
return bool(settings.VAPID_PUBLIC_KEY and settings.VAPID_PRIVATE_KEY_B64)
def _private_key_pem() -> str:
if not settings.VAPID_PRIVATE_KEY_B64:
return ""
return base64.b64decode(settings.VAPID_PRIVATE_KEY_B64).decode()
def _send_sync(abonnement: dict, payload: dict) -> None:
private_key = _private_key_pem()
with NamedTemporaryFile("w", suffix=".pem") as key_file:
key_file.write(private_key)
key_file.flush()
webpush(
subscription_info=abonnement,
data=json.dumps(payload, ensure_ascii=False),
vapid_private_key=key_file.name,
vapid_claims={"sub": settings.VAPID_SUBJECT},
)
async def send_to_subscriptions(db: AsyncSession, subscriptions: Iterable[PushSubscription], payload: dict) -> int:
if not notifications_configured():
return 0
sent = 0
for sub in subscriptions:
try:
await asyncio.to_thread(_send_sync, sub.abonnement, payload)
sent += 1
except WebPushException as exc:
if getattr(exc.response, "status_code", None) in {404, 410}:
await db.delete(sub)
except Exception:
continue
return sent
async def notify_group_change(
db: AsyncSession,
*,
groupe_id,
actor_id,
title: str,
body: str,
url: str = "/",
) -> int:
if not notifications_configured() or not groupe_id:
return 0
result = await db.execute(
select(PushSubscription).where(
PushSubscription.groupe_id == groupe_id,
PushSubscription.membre_id != actor_id,
)
)
return await send_to_subscriptions(db, result.scalars().all(), {
"title": title,
"body": body,
"url": url,
})

Binary file not shown.

View file

@ -26,7 +26,6 @@ from app.models.depense import Depense
from app.models.reunion import Reunion
from app.models.presence import Presence
from app.models.pv_reunion import PvReunion
from app.models.decision import Decision
from app.models.rotation import Rotation
from app.models.rapport_rsg import RapportRsg
from app.models.jeton import Jeton
@ -34,9 +33,6 @@ from app.models.litterature import Litterature
from app.models.vente_litterature import VenteLitterature
from app.models.envoi_contribution import EnvoiContribution
from app.models.config_repartition import ConfigRepartition
from app.models.sondage import Sondage
from app.models.option_sondage import OptionSondage
from app.models.vote import Vote
from app.models.sync_log import SyncLog
from app.models.proposition import Proposition
from app.models.operation_bancaire import OperationBancaire
@ -44,6 +40,8 @@ from app.models.evenement import Evenement
from app.models.candidature import Candidature
from app.models.poste_module import PosteModule
from app.models.reserve import Reserve
from app.models.transaction_comptable import TransactionComptable
from app.models.mouvement_reserve import MouvementReserve
POSTES = [
@ -103,7 +101,7 @@ async def seed():
nom_membre = input(" Nom (optionnel, Entrée pour passer): ").strip()
nom_membre = nom_membre.title() if nom_membre else None
courriel = input(" Courriel: ").strip().lower()
while not courriel or "@" not in courriel:
while not courriel or ("@" not in courriel and courriel != "sysadmin"):
courriel = input(" Courriel invalide. Réessayer: ").strip().lower()
telephone = input(" Téléphone: ").strip()
while not telephone:
@ -112,12 +110,14 @@ async def seed():
while not pin.isdigit() or len(pin) != 4:
pin = input(" PIN invalide. 4 chiffres: ").strip()
print("\n Postes disponibles:")
for i, (nom_poste, _, _, _, _, _) in enumerate(POSTES[:4], 1):
print(f" {i}. {nom_poste}")
choix = input(" Votre poste (1-4): ").strip()
poste_idx = int(choix) - 1 if choix.isdigit() and 1 <= int(choix) <= 4 else 0
poste_choisi = POSTES[poste_idx][0]
poste_choisi = None
if courriel != "sysadmin":
print("\n Postes disponibles:")
for i, (nom_poste, _, _, _, _, _) in enumerate(POSTES[:4], 1):
print(f" {i}. {nom_poste}")
choix = input(" Votre poste (1-4): ").strip()
poste_idx = int(choix) - 1 if choix.isdigit() and 1 <= int(choix) <= 4 else 0
poste_choisi = POSTES[poste_idx][0]
membre = Membre(
groupe_id=groupe.id,
@ -130,15 +130,15 @@ async def seed():
db.add(membre)
await db.flush()
# ── Affectation ──
affectation = Affectation(
groupe_id=groupe.id,
membre_id=membre.id,
poste_id=postes_map[poste_choisi].id,
date_debut=date.today(),
statut="actif",
)
db.add(affectation)
if poste_choisi:
affectation = Affectation(
groupe_id=groupe.id,
membre_id=membre.id,
poste_id=postes_map[poste_choisi].id,
date_debut=date.today(),
statut="actif",
)
db.add(affectation)
# ── Première invitation ──
code = secrets.token_hex(6).upper()
@ -174,7 +174,7 @@ async def seed():
print(f"\n═══ Initialisation terminée ═══")
print(f" Membre: {prenom} ({courriel})")
print(f" Groupe: Groupe Méditation")
print(f" Poste: {poste_choisi}")
print(f" Poste: {poste_choisi or 'Sysadmin'}")
print(f"\n Code d'invitation pour les prochains membres: {code}")
print(f" (Expire dans 7 jours)")
print(f"\n Connectez-vous sur l'app avec:")

View file

@ -114,6 +114,12 @@ ssh ansible@groupe-meditation.87-16.org 'sudo journalctl -u groupe-meditation -n
- Site NGINX reverse proxy + SPA.
- Sauvegarde quotidienne dans `/var/backups/groupe-meditation`.
## Documents liés
- [Architecture technique](ARCHITECTURE.md)
- [Runbook d'exploitation](RUNBOOK.md)
- [Guide utilisateur](UTILISATEURS.md)
## Notes importantes
- Le playbook ne modifie pas le code applicatif.

52
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,52 @@
# Architecture technique
## Sources de vérité
- `propositions` porte les propositions, résolutions, nominations et contributions.
- Une décision de groupe est une proposition adoptée avec `date_adoption`, `numero_resolution`, `etant_donne_que` et `groupe_a_decide_de`.
- `transactions_comptables` est la source des soldes financiers.
- `mouvements_reserves` est la source des soldes de réserves.
- `reserves.montant` est un cache d'affichage; il ne doit pas devenir la source de vérité.
## Backend
- `app/core`: configuration, sécurité, rôles, règles communes.
- `app/services`: logique métier réutilisable, notamment `finance.py`.
- `app/models`: tables SQLAlchemy.
- `app/routers`: endpoints FastAPI. Les routers doivent orchestrer, pas contenir les calculs centraux.
- `app/schemas`: contrats Pydantic d'entrée/sortie.
Règle de maintenance: un router ne doit pas importer un autre router pour obtenir une logique métier. Extraire dans `app/services` ou `app/core`.
## Frontend
- `src/pages`: écrans applicatifs.
- `src/components`: composants partagés.
- `src/utils`: calculs purs et helpers.
- `src/stores`: état global et client API.
Règle de maintenance: si un contrôle ou calcul apparaît dans plusieurs pages, l'extraire dans `components` ou `utils`.
## Comptabilité
Les écritures comptables utilisent:
- `compte`: `banque` ou `encaisse`;
- `sens`: `debit` augmente le compte, `credit` le diminue;
- `source_table` et `source_id`: lien vers l'objet métier.
Exemples:
- Collecte reçue: débit `encaisse`.
- Vente de littérature reçue: débit `encaisse`.
- Dépôt bancaire: débit `banque`, crédit `encaisse`.
- Retrait bancaire: crédit `banque`, débit `encaisse`.
- Dépense cash: crédit `encaisse`.
- Dépense par chèque/virement débitée: crédit `banque`.
- Contribution débitée: crédit `banque`.
## Déploiement
La voie normale est Ansible. Voir [ANSIBLE.md](ANSIBLE.md).
Le déploiement installe PostgreSQL, le backend FastAPI, le frontend compilé, NGINX, systemd et les sauvegardes.

View file

@ -1,389 +1,7 @@
# Guide de déploiement — Groupe Méditation
# Déploiement
## Debian Bookworm + NGINX + PostgreSQL
La méthode supportée est le déploiement Ansible depuis ce dépôt.
Testé sur Debian 12 (Bookworm) avec un utilisateur `ansible` ayant accès sudo.
Voir [ANSIBLE.md](ANSIBLE.md) pour la procédure complète.
Principe : on utilise ce que Debian fournit. Pas de dépôt externe.
---
## 0. Prérequis système
```bash
# Définir un mot de passe pour l'utilisateur ansible (indispensable pour récupération console)
sudo passwd ansible
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl ca-certificates gnupg
```
---
## 1. Installer PostgreSQL
On installe le PostgreSQL fourni par Debian Bookworm (version 15) :
```bash
sudo apt install -y postgresql postgresql-client
sudo systemctl enable postgresql
sudo systemctl start postgresql
```
Créer l'utilisateur et la base. **Choisissez votre mot de passe maintenant** — il sera réutilisé à l'étape 6 :
```bash
sudo -u postgres psql << 'SQL'
CREATE USER grpmed WITH PASSWORD 'votre_mot_de_passe_ici';
CREATE DATABASE groupe_meditation OWNER grpmed;
GRANT ALL PRIVILEGES ON DATABASE groupe_meditation TO grpmed;
\c groupe_meditation
GRANT ALL ON SCHEMA public TO grpmed;
SQL
```
Vérifier la connexion. **Important : utiliser `127.0.0.1`, pas `localhost`** (asyncpg résout `localhost` en IPv6 `::1` sur Debian Bookworm, tandis que PostgreSQL n'écoute que sur `127.0.0.1`) :
```bash
PGPASSWORD='votre_mot_de_passe_ici' psql -U grpmed -h 127.0.0.1 -d groupe_meditation -c "SELECT 1;"
```
---
## 2. Installer Node.js
On installe le Node.js fourni par Debian Bookworm (version 18) :
```bash
sudo apt install -y nodejs npm
```
Vérifier :
```bash
node --version
npm --version
```
---
## 3. Installer Python et les dépendances système
```bash
sudo apt install -y python3 python3-pip python3-venv
# Dépendances système pour WeasyPrint (rapports PDF)
sudo apt install -y libpango-1.0-0 libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 libffi-dev libcairo2
```
---
## 4. Installer NGINX
```bash
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
```
---
## 5. Déployer le code source
```bash
# Copier l'archive sur le serveur, puis :
cd ~
unzip groupe-meditation-v12.zip
# Backend
sudo mkdir -p /opt/groupe-meditation
sudo cp -r ~/groupe-meditation/backend/ /opt/groupe-meditation/backend/
sudo cp -r ~/groupe-meditation/deploy/ /opt/groupe-meditation/deploy/
# Environnement virtuel Python
cd /opt/groupe-meditation/backend
sudo python3 -m venv venv
sudo /opt/groupe-meditation/backend/venv/bin/pip install -r requirements.txt
```
---
## 6. Configurer le backend
Générer le secret JWT :
```bash
openssl rand -base64 48
```
Créer le `.env` :
```bash
cd /opt/groupe-meditation/backend
sudo cp .env.example .env
sudo nano .env
```
Modifier `.env` avec le **même mot de passe** que l'étape 1.
**⚠️ Utiliser `127.0.0.1`, jamais `localhost`** dans `DATABASE_URL` :
```
DATABASE_URL=postgresql+asyncpg://grpmed:votre_mot_de_passe_ici@127.0.0.1:5432/groupe_meditation
JWT_SECRET=valeur_generee_ci-dessus
CORS_ORIGINS=["https://groupe-meditation.87-16.org"]
INVITATION_EXPIRE_HOURS=48
```
**Permissions du `.env` — obligatoire** (le service tourne en `www-data`) :
```bash
sudo chown www-data:www-data /opt/groupe-meditation/backend/.env
sudo chmod 640 /opt/groupe-meditation/backend/.env
```
---
## 7. Initialiser la base de données
**Le seed doit être lancé depuis le dossier `backend/`** pour que le fichier `.env` soit trouvé :
```bash
cd /opt/groupe-meditation/backend
/opt/groupe-meditation/backend/venv/bin/python ../deploy/seed.py
```
Si le `.env` n'est pas trouvé, le script utilisera les valeurs par défaut de `config.py` (qui ont un autre mot de passe) et la connexion échouera.
Le script crée **interactivement** :
- Les tables (23+)
- Les 10 postes prédéfinis
- La littérature de base (5 titres)
- Le premier groupe et le premier membre
- Un code d'invitation valide 7 jours
---
## 7b. Mise à jour d'une base existante (v11 → v12)
Si la base existe déjà, exécuter ces commandes SQL **avant** de relancer le service :
```bash
PGPASSWORD='votre_mot_de_passe_ici' psql -U grpmed -h 127.0.0.1 -d groupe_meditation << 'SQL'
-- ══ Membre : courriel obligatoire, nom optionnel, téléphone obligatoire ══
-- Remplir les courriels manquants avant de rendre NOT NULL
UPDATE membres SET courriel = CONCAT(LOWER(prenom), '_', LEFT(id::text, 4), '@temp.local')
WHERE courriel IS NULL OR courriel = '';
UPDATE membres SET telephone = '000-000-0000'
WHERE telephone IS NULL OR telephone = '';
ALTER TABLE membres ALTER COLUMN courriel SET NOT NULL;
ALTER TABLE membres ALTER COLUMN telephone SET NOT NULL;
ALTER TABLE membres ALTER COLUMN nom DROP NOT NULL;
-- Contrainte d'unicité courriel+groupe
ALTER TABLE membres ADD CONSTRAINT uq_courriel_groupe UNIQUE (courriel, groupe_id);
-- ══ Tâches ══
CREATE TABLE IF NOT EXISTS taches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nom VARCHAR(200) NOT NULL,
informations TEXT,
ordre_affichage INTEGER DEFAULT 99,
cree_le TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS postes_taches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
poste_id UUID NOT NULL REFERENCES postes(id),
tache_id UUID NOT NULL REFERENCES taches(id),
cree_le TIMESTAMPTZ DEFAULT NOW()
);
-- ══ Poste : champ informations ══
ALTER TABLE postes ADD COLUMN IF NOT EXISTS informations TEXT;
-- ══ Dépenses : preuve, methode nullable ══
ALTER TABLE depenses ADD COLUMN IF NOT EXISTS preuve_nom VARCHAR(255);
ALTER TABLE depenses ALTER COLUMN methode DROP NOT NULL;
-- ══ Candidatures : type, nomination, retrait ══
-- Ajouter les nouvelles valeurs au type ENUM
DO $$ BEGIN
ALTER TYPE statut_candidature ADD VALUE IF NOT EXISTS 'retiree';
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE type_candidature AS ENUM ('candidature', 'nomination');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'candidature';
ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS nomme_par UUID REFERENCES membres(id);
ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS acceptee_par_membre BOOLEAN;
ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS proposition_id UUID REFERENCES propositions(id);
-- ══ Envoi contributions : résolution, engagement ══
ALTER TABLE envois_contributions ADD COLUMN IF NOT EXISTS resolution_id UUID REFERENCES propositions(id);
ALTER TABLE envois_contributions ADD COLUMN IF NOT EXISTS engagee BOOLEAN DEFAULT false;
ALTER TABLE envois_contributions ADD COLUMN IF NOT EXISTS debitee BOOLEAN DEFAULT false;
-- ══ Propositions : type et lien candidature ══
ALTER TABLE propositions ADD COLUMN IF NOT EXISTS type_proposition VARCHAR(20) DEFAULT 'generale';
ALTER TABLE propositions ADD COLUMN IF NOT EXISTS candidature_id UUID REFERENCES candidatures(id);
-- ══ Répertoire des preuves ══
-- Créer le répertoire manuellement :
-- sudo mkdir -p /opt/groupe-meditation/uploads/preuves
-- sudo chown www-data:www-data /opt/groupe-meditation/uploads/preuves
SQL
```
Ensuite créer le répertoire des preuves et relancer :
```bash
sudo mkdir -p /opt/groupe-meditation/uploads/preuves
sudo chown www-data:www-data /opt/groupe-meditation/uploads/preuves
sudo systemctl restart groupe-meditation
```
---
## 8. Créer le service systemd
```bash
sudo cp /opt/groupe-meditation/deploy/groupe-meditation.service /etc/systemd/system/
sudo chown -R www-data:www-data /opt/groupe-meditation
sudo systemctl daemon-reload
sudo systemctl enable groupe-meditation
sudo systemctl start groupe-meditation
```
Vérifier :
```bash
sudo systemctl status groupe-meditation
curl -s http://127.0.0.1:8000/api/health
```
Doit retourner `{"status":"ok","app":"Groupe Méditation","version":"0.1.0"}`
---
## 9. Construire et déployer le frontend
```bash
cd ~/groupe-meditation/frontend
npm install
npm run build
sudo mkdir -p /var/www/groupe-meditation
sudo cp -r dist/* /var/www/groupe-meditation/
sudo chown -R www-data:www-data /var/www/groupe-meditation
```
---
## 10. Configurer NGINX
NGINX fait tout : fichiers statiques du frontend + reverse proxy vers FastAPI.
```bash
sudo cp ~/groupe-meditation/deploy/nginx-groupe-meditation.conf \
/etc/nginx/sites-available/groupe-meditation
sudo ln -sf /etc/nginx/sites-available/groupe-meditation /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
```
Tester en HTTP :
```bash
curl -s http://groupe-meditation.87-16.org/api/health
```
---
## 11. Certificat TLS (Let's Encrypt)
```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d groupe-meditation.87-16.org
```
Certbot modifie automatiquement la config NGINX pour ajouter HTTPS et la redirection 80→443.
---
## 12. Sauvegarde automatique
```bash
sudo cp ~/groupe-meditation/deploy/backup.sh /etc/cron.daily/groupe-meditation-backup
sudo chmod +x /etc/cron.daily/groupe-meditation-backup
```
---
## 13. Vérification finale
```bash
# API
curl -s https://groupe-meditation.87-16.org/api/health | python3 -m json.tool
# Documentation OpenAPI
# https://groupe-meditation.87-16.org/api/docs
# Frontend
# https://groupe-meditation.87-16.org
```
---
## Mise à jour de l'application
```bash
# 1. Copier les nouveaux fichiers
cd ~
unzip groupe-meditation-vXX.zip
# 2. Backend
sudo cp -r ~/groupe-meditation/backend/ /opt/groupe-meditation/backend/
cd /opt/groupe-meditation/backend
sudo venv/bin/pip install -r requirements.txt
# 3. Migrations SQL si nécessaire (voir section 7b)
# 4. Permissions
sudo chown -R www-data:www-data /opt/groupe-meditation
sudo chown www-data:www-data /opt/groupe-meditation/backend/.env
sudo chmod 640 /opt/groupe-meditation/backend/.env
# 5. Relancer le service
sudo systemctl restart groupe-meditation
# 6. Frontend
cd ~/groupe-meditation/frontend
npm install && npm run build
sudo cp -r dist/* /var/www/groupe-meditation/
```
---
## Dépannage
| Problème | Commande / Solution |
|----------|---------------------|
| API ne démarre pas | `sudo journalctl -u groupe-meditation -f` |
| Workers bloqués au démarrage | `DATABASE_URL` doit utiliser `127.0.0.1` (pas `localhost`) |
| `InvalidPasswordError` au seed | Lancer depuis `backend/`, vérifier `.env` lisible |
| `InvalidPasswordError` à l'API | `sudo chown www-data:www-data .env && sudo chmod 640 .env` |
| NGINX 502 Bad Gateway | `sudo systemctl status groupe-meditation` — Uvicorn démarré ? |
| NGINX 504 Gateway Timeout | Worker bloqué DB — vérifier `127.0.0.1` dans `DATABASE_URL` |
| WeasyPrint échoue | `sudo apt install libpango-1.0-0 libpangocairo-1.0-0` |
| `seed.py ImportError` | Vérifier les noms de classes dans `models/` |
| Console inaccessible | `sudo passwd ansible` — à faire en tout début |
| `type "..." does not exist` après ALTER | Relancer le service pour que `create_all` crée les types manquants |
| Nouvelle colonne absente | Exécuter les ALTER TABLE de la section 7b |
---
**Hébergé au Québec · 100 % logiciel libre · Zéro traçage**
Ce fichier est conservé comme point d'entrée historique afin d'éviter deux procédures concurrentes.

Binary file not shown.

View file

@ -42,13 +42,13 @@
## Trésorerie (S1T)
Le trésorier dispose de quatre onglets.
Le module Trésorerie s'ouvre sur l'état des résultats. Les opérations de modification sont réservées aux postes exécutifs.
### Soldes
- **Solde banque** : basé sur le dernier relevé + dépôts/retraits enregistrés depuis
- **Disponible** : solde banque − engagements non débités − réserves
- **Solde banque** : calculé depuis le registre comptable
- **Disponible** : banque − engagements non débités − réserves affectées
- **Encaisse** : cash reçu mais pas encore déposé à la banque
- **Réserves** : prudente (500 $) + anniversaire (+20 $/mois depuis la création du groupe)
- **Réserves** : soldes calculés depuis l'historique des mouvements de réserves
- **En circulation** : collectes pas encore remises au trésorier
### À recevoir
@ -57,18 +57,18 @@ Deux catégories :
2. **Ventes de littérature** non remises — le responsable de la littérature vend, puis le trésorier confirme qu'il a reçu l'argent via « Confirmer la remise »
### Registre
Journal détaillé de toutes les transactions du mois courant : collectes, ventes, dépenses, contributions, opérations bancaires. Avec totaux entrées/sorties.
Journal détaillé des transactions : collectes, ventes, dépenses, contributions et opérations bancaires. Le registre comptable est la source de vérité des soldes.
### Opération bancaire
Trois types :
- **Dépôt** : enregistrer un dépôt bancaire
- **Retrait** : enregistrer un retrait
- **Relevé** : saisir le solde du relevé mensuel de la banque (sert de base au calcul des soldes)
### Opérations
Trois fonctions :
- **Dépôt** : enregistrer un dépôt bancaire provenant de l'encaisse ou d'un don direct
- **Retrait** : enregistrer un retrait de la banque vers l'encaisse
- **Virement** : transférer des montants entre les réserves
### Flux mensuel recommandé
1. Recevoir le relevé bancaire → enregistrer un **relevé** dans S1T
2. Vérifier que le solde correspond → les écarts signalent des opérations non enregistrées
3. Préparer le rapport financier pour la réunion d'affaires
1. Recevoir le relevé bancaire → vérifier le registre de trésorerie
2. Vérifier que le solde correspond → les écarts signalent des opérations non enregistrées ou une correction à saisir
3. Préparer l'état des résultats pour la réunion d'affaires
---
@ -88,17 +88,20 @@ Lorsque l'argent quitte effectivement le compte :
---
## Propositions et résolutions (C8)
## Gouvernance (C5)
### Soumettre une proposition
1. N'importe quel membre peut soumettre une proposition **en tout temps** (pas besoin d'une réunion d'affaires)
2. Le sujet est obligatoire ; la réunion d'affaires est optionnelle
3. Un autre membre doit **seconder** la proposition pour qu'elle soit recevable
### Voter une résolution
1. L'exécutif vote **Adopter** ou **Rejeter** lors de la réunion d'affaires
2. Si adoptée, un numéro de résolution est attribué (RES-2026-001)
3. La proposition est automatiquement associée à la réunion d'affaires lors du vote
### Transformer en résolution
1. L'exécutif transforme une proposition secondée en résolution
2. Les sections **ÉTANT DONNÉ QUE** et **LE GROUPE A DÉCIDÉ DE** sont obligatoires
3. Si adoptée, un numéro est attribué : `RES` pour une résolution, `NOM` pour une nomination, `DON` pour une contribution
### Rejeter une proposition
Un membre de l'exécutif peut rejeter une proposition en cours depuis C5.
### Lien avec le PV
Lors de la rédaction du PV (M1), le secrétaire peut **importer les propositions secondées** en un clic. Elles pré-remplissent la section décisions avec le sujet, le proposeur et le secondeur.
@ -108,14 +111,14 @@ Lors de la rédaction du PV (M1), le secrétaire peut **importer les proposition
## Réunion d'affaires mensuelle
### Avant la réunion
- Les membres soumettent leurs propositions via C8 (en tout temps)
- Les membres soumettent leurs propositions via C5 (en tout temps)
- D'autres membres secondent les propositions recevables
### Pendant la réunion
1. **Secrétaire** : M2 (présences) → M1 (PV avec import des propositions)
2. **RSG** : M3 (rapport du district)
3. **Trésorier** : présente le rapport financier (depuis le registre S1T)
4. **Exécutif** : vote les propositions secondées (C8)
3. **Trésorier** : présente l'état des résultats et la position de trésorerie
4. **Exécutif** : transforme les propositions secondées en résolutions (C5)
### Après la réunion
Le secrétaire finalise le PV avec les décisions votées.
@ -135,9 +138,8 @@ Le secrétaire finalise le PV avec les décisions votées.
| Rapport | URL API | Contenu |
|---------|---------|---------|
| Financier mensuel | `/api/rapports/finances/{annee}/{mois}` | Collectes + dépenses + solde |
| État des résultats | `/api/rapports/etat-resultats/{annee}/{mois}` | Revenus + charges + excédent/déficit + trésorerie |
| Réunions du mois | `/api/rapports/reunions/{annee}/{mois}` | Réunions + thèmes |
| Financier complet | `/api/rapports/financier-complet/{annee}/{mois}` | Collectes + dépenses + contributions + impayés |
> **Note :** les routes PDF nécessitent WeasyPrint installé sur le serveur.
@ -213,7 +215,7 @@ sudo systemctl start groupe-meditation
| Module | RSG | RSG sub | Trés. | Secr. | Anim. | Litt. | Jetons | Tous |
|--------|:---:|:-------:|:-----:|:-----:|:-----:|:-----:|:------:|:----:|
| C1-C9 Consultation | | | | | | | | ✓ |
| S1 Collecte | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| S1 Collecte | ✓ | ✓ | ✓ | ✓ | | | | |
| S1T Trésorerie | | | ✓ | | | | | |
| S2 Remise jeton | | | | | | | ✓ | |
| S3 Vente litt. | | | | | | ✓ | | |

View file

@ -77,13 +77,11 @@ Permet de consulter l'inventaire de littérature :
- quantités;
- alertes de stock bas.
### Décisions en cours
### Gouvernance
Affiche le suivi des décisions et propositions non terminées.
Permet de suivre les propositions non terminées, de soumettre une nouvelle proposition, de seconder une proposition non secondée, et de consulter l'historique des résolutions.
### Votes ouverts
Permet aux membres de voter dans les sondages ouverts.
Une proposition n'a pas de type lors de sa soumission. Les membres de l'exécutif peuvent transformer une proposition secondée en résolution, ou rejeter une proposition en cours. L'application exige alors de remplir les sections « ÉTANT DONNÉ QUE » et « LE GROUPE A DÉCIDÉ DE ».
### Mon profil
@ -98,21 +96,11 @@ Permet de modifier :
Un courriel déjà utilisé dans le même groupe ne peut pas être repris.
### Propositions
Permet de :
- soumettre une proposition;
- consulter les propositions;
- seconder une proposition;
- suivre le statut des résolutions.
Une proposition suit normalement ce cycle :
1. proposée;
2. secondée;
3. votée;
4. adoptée ou rejetée.
3. transformée en résolution par l'exécutif.
Une proposition adoptée devient une résolution numérotée.
@ -122,24 +110,32 @@ Permet de consulter les postes vacants et de proposer sa candidature.
Si un membre est nominé à un poste, il peut accepter ou refuser la nomination.
### État financier
### Trésorerie
Affiche une vue rapide :
La page d'accueil du module affiche l'état des résultats : revenus, charges et excédent ou déficit pour un mois donné.
Le bouton **Télécharger le PDF** génère le rapport mensuel à présenter au groupe.
L'écran affiche aussi la position courante de trésorerie :
- solde bancaire;
- encaisse;
- montant disponible;
- résultat net;
- revenus;
- sorties;
- montants engagés;
- réserves;
- dernières collectes;
- dernières dépenses.
- dernières collectes et dépenses.
## 4. Modules de saisie accessibles à tous
Les membres peuvent consulter l'état des résultats et le registre. Les boutons qui modifient les données de trésorerie sont réservés aux postes exécutifs.
## 4. Modules de saisie
### Rencontre hebdomadaire
Formulaire regroupé pour les données de la réunion régulière : thème, annonces, collecte, jeton et vente de littérature. Les sections non remplies sont ignorées.
### Collecte 7e Tradition
Ce module est réservé aux postes exécutifs.
Permet de saisir une collecte.
Règles importantes :
@ -153,15 +149,18 @@ Le module offre aussi une liste des collectes existantes.
### Dépense
Permet à un membre de soumettre une dépense.
Permet à un membre de soumettre une dépense en son nom. Un membre de l'exécutif peut aussi soumettre une dépense au nom d'un autre membre.
La date de la dépense est la date de soumission.
La dépense n'est pas considérée comme payée tant que le trésorier ne l'a pas confirmée.
Le trésorier choisit la méthode :
- `COLLECTE` : payé depuis l'encaisse;
- `CHEQUE` ou `VIREMENT` : engagement à confirmer lorsque le débit apparaît;
- `DU` : montant dû au membre.
- `CHEQUE` ou `VIREMENT` : engagement à confirmer lorsque le débit apparaît.
Une dépense soumise peut aussi être rejetée. Dans ce cas, elle est supprimée.
## 5. Trésorerie
@ -205,21 +204,20 @@ Types disponibles :
- dépôts bancaires;
- retraits bancaires.
### Réserves
### Réserves et opérations
Permet de créer, modifier et supprimer les réserves.
Dans l'onglet **Opérations**, il est possible de créer, modifier et supprimer les réserves.
Règle importante : le total des réserves ne peut jamais dépasser le solde en banque.
### Opérations bancaires
Permet d'enregistrer :
- un dépôt : transfert de l'encaisse vers la banque;
- un dépôt provenant de l'encaisse;
- un don déposé directement à la banque;
- un retrait : transfert de la banque vers l'encaisse;
- un relevé : solde réel du compte bancaire à une date donnée.
- un virement entre réserves.
Le dépôt ne peut pas dépasser l'encaisse disponible.
Un dépôt provenant de l'encaisse ne peut pas dépasser l'encaisse disponible. Un don augmente directement le solde bancaire et apparaît dans les revenus de l'état des résultats.
## 6. Envoi contributions
@ -246,6 +244,10 @@ La contribution ne modifie pas les soldes immédiatement. Elle doit être confir
## 7. Procès-verbal, présences et rapport RSG
### Assemblée d'affaires
Formulaire regroupé pour créer ou sélectionner une assemblée, noter les présences et rédiger le procès-verbal.
### Rédiger PV
Permet de préparer un procès-verbal de réunion d'affaires.
@ -354,17 +356,7 @@ Permet de modifier les informations générales :
- heure;
- format.
## 13. Sondages
### Lancer un sondage
Permet à l'exécutif de créer un sondage.
### Votes ouverts
Permet aux membres de voter.
## 14. Bonnes pratiques
## 13. Bonnes pratiques
- Utiliser son propre compte, jamais celui d'un autre membre.
- Ne pas partager son PIN.
@ -375,7 +367,7 @@ Permet aux membres de voter.
- Garder les réserves à jour.
- Utiliser les propositions et résolutions pour conserver une trace claire des décisions.
## 15. Problèmes fréquents
## 14. Problèmes fréquents
### Je ne peux pas me connecter
@ -397,7 +389,7 @@ Demander à l'exécutif de vérifier :
### Le dépôt est refusé
Le montant du dépôt dépasse probablement l'encaisse disponible.
Si le dépôt provient de l'encaisse, son montant dépasse probablement l'encaisse disponible.
### Une réserve est refusée
@ -415,7 +407,7 @@ Causes possibles :
- collecte déjà enregistrée pour cette date;
- date future.
## 16. Règles importantes de l'application
## 15. Règles importantes de l'application
- Les données sont séparées par groupe.
- Un membre se connecte avec son courriel et son PIN.
@ -423,4 +415,3 @@ Causes possibles :
- Les résolutions adoptées sont numérotées automatiquement.
- Les mandats ne se terminent pas seuls.
- Les accès aux modules sont contrôlés par les postes.

View file

@ -0,0 +1,24 @@
self.addEventListener('push', (event) => {
let data = {};
if (event.data) {
try {
data = event.data.json();
} catch {
data = { title: 'Groupe Méditation', body: event.data.text() };
}
}
const title = data.title || 'Groupe Méditation';
const options = {
body: data.body || 'Nouvelle notification',
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { url: data.url || '/' },
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(clients.openWindow(url));
});

View file

@ -23,13 +23,13 @@ import Inventaire from './pages/Inventaire';
import AjusterInventaire from './pages/AjusterInventaire';
import EnvoiContributions from './pages/EnvoiContributions';
import ConfigRepartition from './pages/ConfigRepartition';
import LancerSondage from './pages/LancerSondage';
import VotesOuverts from './pages/VotesOuverts';
import Propositions from './pages/Propositions';
import Tresorerie from './pages/Tresorerie';
import Impliquer from './pages/Impliquer';
import GererPostes from './pages/GererPostes';
import EtatFinancier from './pages/EtatFinancier';
import ReunionHebdomadaire from './pages/ReunionHebdomadaire';
import AssembleeAffaires from './pages/AssembleeAffaires';
import JournalActions from './pages/JournalActions';
import BottomNav from './components/BottomNav';
export default function App() {
const { token, page, loadAccueil, loadGroupes } = useStore();
@ -57,37 +57,45 @@ export default function App() {
// Pages protégées
if (token) {
let pageActive;
switch (page) {
case 'accueil': return <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 <Evenements />;
case 'C5': return <Decisions />;
case 'G2': return <GererMembres />;
case 'G9': return <Parametres />;
case 'G11': return <GestionMandats />;
case 'G12': return <GererPostes />;
case 'M1': return <RedigerPV />;
case 'M2': return <Presences />;
case 'M3': return <RapportRSG />;
case 'S2': return <RemiseJeton />;
case 'M6': return <CommanderJetons />;
case 'S3': return <VenteLitterature />;
case 'C4': return <Inventaire />;
case 'M5': return <AjusterInventaire />;
case 'M4': return <EnvoiContributions />;
case 'G8': return <ConfigRepartition />;
case 'G6': return <LancerSondage />;
case 'C6': return <VotesOuverts />;
case 'C8': return <Propositions />;
case 'C9': return <Impliquer />;
case 'C10': return <EtatFinancier />;
default: return <Accueil />;
case 'accueil': pageActive = <Accueil />; break;
case 'profil': pageActive = <Profil />; break;
case 'S0': pageActive = <ReunionHebdomadaire />; break;
case 'M0': pageActive = <AssembleeAffaires />; break;
case 'S1': pageActive = <Collecte />; break;
case 'S1T': pageActive = <Tresorerie />; break;
case 'S4': pageActive = <RapportReunion />; break;
case 'S5': pageActive = <Depense />; break;
case 'C1': pageActive = <Rapports />; break;
case 'C2': pageActive = <Evenements />; break;
case 'C5': pageActive = <Decisions />; break;
case 'G2': pageActive = <GererMembres />; break;
case 'G9': pageActive = <Parametres />; break;
case 'G11': pageActive = <GestionMandats />; break;
case 'G12': pageActive = <GererPostes />; break;
case 'G13': pageActive = <JournalActions />; break;
case 'M1': pageActive = <RedigerPV />; break;
case 'M2': pageActive = <Presences />; break;
case 'M3': pageActive = <RapportRSG />; break;
case 'S2': pageActive = <RemiseJeton />; break;
case 'M6': pageActive = <CommanderJetons />; break;
case 'S3': pageActive = <VenteLitterature />; break;
case 'C4': pageActive = <Inventaire />; break;
case 'M5': pageActive = <AjusterInventaire />; break;
case 'M4': pageActive = <EnvoiContributions />; break;
case 'G8': pageActive = <ConfigRepartition />; break;
case 'C9': pageActive = <Impliquer />; break;
case 'C10': pageActive = <Tresorerie />; break;
default: pageActive = <Accueil />;
}
const afficherNavigationContextes = page === 'accueil';
return (
<>
<main className={afficherNavigationContextes ? 'app-bottom-space' : ''}>{pageActive}</main>
{afficherNavigationContextes && <BottomNav />}
</>
);
}
// Pages publiques

View file

@ -0,0 +1,41 @@
import React from 'react';
import { useStore } from '../stores/store';
const NAV_CONTEXTS = [
{ id: 'accueil', label: 'Accueil', icon: '⌂' },
{ id: 'reunion', label: 'Réunion', icon: '□' },
{ id: 'affaires', label: 'Affaires', icon: '◇' },
{ id: 'gestion', label: 'Gestion', icon: '○' },
];
export default function BottomNav() {
const { navContext, setNavContext, setPage } = useStore();
const ouvrirContexte = (id) => {
setNavContext(id);
setPage('accueil');
};
return (
<nav className="fixed bottom-0 inset-x-0 z-40 bg-white/95 border-t border-gray-200 pb-safe shadow-[0_-8px_24px_rgba(15,23,42,0.08)]">
<div className="grid grid-cols-4 gap-1 px-2 pt-2">
{NAV_CONTEXTS.map((item) => {
const actif = navContext === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => ouvrirContexte(item.id)}
className={`min-h-[56px] rounded-xl text-xs font-bold flex flex-col items-center justify-center gap-0.5 transition ${
actif ? 'bg-aa-blue text-white' : 'text-gray-500 active:bg-gray-100'
}`}
>
<span className="text-xl leading-none">{item.icon}</span>
<span>{item.label}</span>
</button>
);
})}
</div>
</nav>
);
}

View file

@ -0,0 +1,19 @@
import React from 'react';
import { labelDateReunion } from '../utils/datesReunion';
const SELECT_CLASS = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue';
export default function MeetingDateSelect({ dates, value, onChange, className = SELECT_CLASS }) {
if (!dates.length) {
return (
<div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500">
Configurez le jour de réunion dans les paramètres du groupe.
</div>
);
}
return (
<select value={value} onChange={(e) => onChange(e.target.value)} className={className}>
{dates.map((d) => <option key={d} value={d}>{labelDateReunion(d)}</option>)}
</select>
);
}

View file

@ -4,11 +4,17 @@ import { useStore } from '../stores/store';
export default function PageHeader({ titre, sousTitre }) {
const { setPage } = useStore();
return (
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
<button onClick={() => setPage('accueil')} className="text-2xl active:opacity-60">←</button>
<div>
<h1 className="text-lg font-bold">{titre}</h1>
{sousTitre && <p className="text-blue-200 text-xs">{sousTitre}</p>}
<div className="sticky top-0 z-30 bg-aa-blue text-white px-3 py-3 flex items-center gap-3 shadow-sm">
<button
onClick={() => setPage('accueil')}
className="min-w-[48px] min-h-[48px] rounded-2xl bg-white/10 text-2xl active:bg-white/20"
aria-label="Retour à l'accueil"
>
←
</button>
<div className="min-w-0">
<h1 className="text-lg font-bold leading-tight truncate">{titre}</h1>
{sousTitre && <p className="text-blue-200 text-xs truncate">{sousTitre}</p>}
</div>
</div>
);

View file

@ -10,6 +10,7 @@
body {
font-family: 'DM Sans', sans-serif;
-webkit-font-smoothing: antialiased;
overscroll-behavior-y: contain;
}
button:active {
@ -20,3 +21,18 @@ button:active {
from { transform: scale(0.5); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@layer utilities {
.pb-safe {
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
}
.app-bottom-space {
padding-bottom: calc(5.5rem + env(safe-area-inset-bottom));
}
.tap-target {
min-height: 48px;
min-width: 48px;
}
}

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo, useState } from 'react';
import { useStore } from '../stores/store';
const COULEURS = {
@ -12,18 +12,65 @@ const COULEURS = {
gray: 'bg-gray-50 border-gray-200 text-gray-700',
};
const NAVIGABLE = ['C7', 'S0', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C9', 'G2', 'G9', 'G11', 'G12', 'G13', 'M0', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
const CONTEXTES = {
accueil: {
titre: 'Accueil',
description: 'Les objets les plus utiles au quotidien.',
sections: [
{ titre: 'À faire maintenant', codes: ['S5', 'C5', 'S1T', 'S0'] },
{ titre: 'Consulter', codes: ['C2', 'S1T', 'C1', 'C4'] },
{ titre: 'Mon espace', codes: ['C7', 'C9'] },
],
},
reunion: {
titre: 'Réunion',
description: 'Ce qui sert pendant une réunion régulière.',
sections: [
{ titre: 'Rapport de réunion', codes: ['S0', 'S4'] },
{ titre: 'Saisies', codes: ['S1', 'S2', 'S3'] },
{ titre: 'Références', codes: ['C2', 'C4'] },
],
},
affaires: {
titre: 'Affaires',
description: 'Décisions, assemblées et vie de groupe.',
sections: [
{ titre: "Assemblée d'affaires", codes: ['M0', 'M2', 'M1', 'M3'] },
{ titre: 'Gouvernance', codes: ['C5', 'C1'] },
{ titre: 'Postes à pourvoir', codes: ['C9'] },
],
},
gestion: {
titre: 'Gestion',
description: 'Administration, finances et structure du groupe.',
sections: [
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
{ titre: 'Membres et structure', codes: ['G2', 'G11', 'G12', 'G9', 'G13'] },
{ titre: 'Inventaire', codes: ['C4', 'M5', 'M6'] },
],
},
};
function ouvrirModule(module) {
if (module.code === 'C7') useStore.setState({ page: 'profil' });
else if (NAVIGABLE.includes(module.code)) useStore.setState({ page: module.code });
else alert(`Module ${module.code} - ${module.nom}\n\n(Sera disponible dans les prochaines phases)`);
}
function Tuile({ module, onClick }) {
const cls = COULEURS[module.couleur] || COULEURS.gray;
return (
<button
onClick={onClick}
className={`w-full p-4 rounded-2xl border-2 text-left active:scale-[0.97] transition ${cls}`}
className={`w-full min-h-[72px] p-4 rounded-2xl border-2 text-left active:scale-[0.97] transition ${cls}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{module.icone}</span>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm">{module.nom}</p>
<p className="text-xs opacity-70 truncate">{module.description}</p>
<p className="text-base font-bold leading-tight">{module.nom}</p>
<p className="text-xs opacity-70 line-clamp-2">{module.description}</p>
</div>
<span className="text-xs font-mono opacity-40">{module.code}</span>
</div>
@ -31,39 +78,59 @@ function Tuile({ module, onClick }) {
);
}
function Section({ titre, modules }) {
if (!modules || modules.length === 0) return null;
return (
<div className="space-y-2">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">{titre}</h2>
<div className="grid gap-2">
{modules.map((m) => (
<Tuile key={m.code} module={m} onClick={() => {
const navigable = ['C7', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C6', 'C8', 'C9', 'C10', 'G2', 'G6', 'G9', 'G11', 'G12', '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)`);
}} />
))}
</div>
</div>
);
}
export default function Accueil() {
const { membre, postes, modulesConsultation, modulesSaisie, modulesGestion, logout } = useStore();
const {
membre,
postes,
modulesConsultation,
modulesSaisie,
modulesGestion,
navContext,
setPage,
} = useStore();
const [recherche, setRecherche] = useState('');
const tousModules = useMemo(() => {
const map = new Map();
[
...(modulesSaisie || []),
...(modulesConsultation || []),
...(modulesGestion || []),
].forEach((m) => map.set(m.code, m));
return Array.from(map.values());
}, [modulesSaisie, modulesConsultation, modulesGestion]);
const moduleParCode = useMemo(() => {
const map = new Map();
tousModules.forEach((m) => map.set(m.code, m));
return map;
}, [tousModules]);
const contexte = CONTEXTES[navContext] || CONTEXTES.accueil;
const q = recherche.trim().toLowerCase();
const sections = useMemo(() => contexte.sections
.map((section) => ({
...section,
modules: section.codes.map((code) => moduleParCode.get(code)).filter(Boolean),
}))
.filter((section) => section.modules.length > 0), [contexte, moduleParCode]);
const resultatsRecherche = q
? tousModules.filter((m) => `${m.code} ${m.nom} ${m.description}`.toLowerCase().includes(q))
: [];
return (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<div className="bg-aa-blue text-white p-4 pb-6 rounded-b-3xl shadow-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-200 text-xs">Bonjour</p>
<h1 className="text-xl font-bold">{membre?.prenom}</h1>
<div className="bg-aa-blue text-white p-4 pb-5 rounded-b-3xl shadow-lg">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-blue-200 text-xs">Bonjour {membre?.prenom}</p>
<h1 className="text-xl font-bold leading-tight">{contexte.titre}</h1>
<p className="text-sm text-blue-100 mt-1">{contexte.description}</p>
</div>
<button onClick={logout} className="text-blue-200 text-sm active:text-white">
Déconnexion
<button onClick={() => setPage('profil')} className="tap-target px-3 rounded-xl bg-white/10 text-blue-100 text-sm font-bold active:bg-white/20">
Profil
</button>
</div>
{postes.length > 0 && (
@ -77,11 +144,49 @@ export default function Accueil() {
)}
</div>
{/* Modules */}
<div className="p-4 space-y-6 pb-20">
<Section titre="Mes tâches" modules={modulesSaisie} />
<Section titre="Consultation" modules={modulesConsultation} />
<Section titre="Gestion" modules={modulesGestion} />
<div className="p-4 space-y-5">
<input
type="search"
value={recherche}
onChange={(e) => setRecherche(e.target.value)}
placeholder="Chercher un module"
className="w-full min-h-[48px] px-4 bg-white rounded-2xl border-0 shadow-sm text-base focus:ring-2 focus:ring-aa-blue"
/>
{q ? (
<section className="space-y-2">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
Résultats
</h2>
{resultatsRecherche.length === 0 ? (
<div className="rounded-2xl bg-white p-5 text-center text-sm text-gray-500 shadow-sm">
Aucun module trouvé
</div>
) : (
<div className="grid gap-2">
{resultatsRecherche.map((m) => <Tuile key={m.code} module={m} onClick={() => ouvrirModule(m)} />)}
</div>
)}
</section>
) : (
<>
{sections.length === 0 && (
<div className="rounded-2xl bg-white p-5 text-center text-sm text-gray-500 shadow-sm">
Aucun module accessible dans ce contexte
</div>
)}
{sections.map((section) => (
<section key={section.titre} className="space-y-2">
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
{section.titre}
</h2>
<div className="grid gap-2">
{section.modules.map((m) => <Tuile key={m.code} module={m} onClick={() => ouvrirModule(m)} />)}
</div>
</section>
))}
</>
)}
</div>
</div>
);

View file

@ -1,16 +1,21 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { datesReunion, dateLocale } from '../utils/datesReunion';
export default function AffecterPostes({ compact = false }) {
const { groupe } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [postes, setPostes] = useState([]);
const [membres, setMembres] = useState([]);
const [affectations, setAffectations] = useState([]);
const [form, setForm] = useState({
membre_id: '',
poste_id: '',
date_debut: new Date().toISOString().slice(0, 10),
date_debut: dateLocale(),
proposeur_id: '',
secondeur_id: '',
});
@ -31,6 +36,12 @@ export default function AffecterPostes({ compact = false }) {
}).catch(() => setLoading(false));
}, []);
useEffect(() => {
if (choixDates.length > 0 && !choixDates.includes(form.date_debut)) {
setForm((f) => ({ ...f, date_debut: choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1] }));
}
}, [groupe?.jour_reunion]);
const handleSubmit = async () => {
if (!form.membre_id || !form.poste_id || !form.proposeur_id || !form.secondeur_id) {
setError('Sélectionnez un membre, un poste, un proposeur et un secondeur');
@ -131,9 +142,7 @@ export default function AffecterPostes({ compact = false }) {
<div>
<label className="text-xs text-gray-400 block mb-1">Date de début</label>
<input type="date" value={form.date_debut}
onChange={(e) => setForm({ ...form, date_debut: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<MeetingDateSelect dates={choixDates} value={form.date_debut} onChange={(v) => setForm({ ...form, date_debut: v })} />
</div>
</div>

View file

@ -0,0 +1,255 @@
import React, { useEffect, useState } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import { dateHeureReunionIso, datesAssembleesAffaires, dateLocale, labelDateReunion } from '../utils/datesReunion';
const INPUT = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue';
export default function AssembleeAffaires() {
const { postes, groupe } = useStore();
const estExecutif = postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p));
const choixDates = datesAssembleesAffaires(groupe);
const [membres, setMembres] = useState([]);
const [reunions, setReunions] = useState([]);
const [pvs, setPvs] = useState([]);
const [propositions, setPropositions] = useState([]);
const [resolutions, setResolutions] = useState([]);
const [postesVacants, setPostesVacants] = useState([]);
const [reunionId, setReunionId] = useState('');
const [dateSelection, setDateSelection] = useState(dateLocale());
const [dateReunion, setDateReunion] = useState(dateLocale());
const [note, setNote] = useState('');
const [presencesMap, setPresencesMap] = useState({});
const [contenu, setContenu] = useState('');
const [pvPrecedent, setPvPrecedent] = useState(null);
const [showPvPrecedent, setShowPvPrecedent] = useState(false);
const [adoption, setAdoption] = useState({ proposeur: '', secondeur: '' });
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const charger = async () => {
try {
const [m, r, pv, props, res, postesDispo] = await Promise.all([
api.get('/membres/actifs'),
api.get('/presences/reunions'),
api.get('/pv/'),
api.get('/propositions/?statut=secondee&limite=100'),
api.get('/propositions/resolutions'),
api.get('/postes/disponibles'),
]);
setMembres(m);
setReunions(r);
setPvs(pv);
setPropositions(props);
setResolutions(res);
setPostesVacants(postesDispo);
const map = {};
m.forEach((mb) => { map[mb.id] = true; });
setPresencesMap(map);
const duJour = r.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
if (duJour) setReunionId(duJour.id);
} catch (e) { setError(e.message); }
};
useEffect(() => { charger(); }, []);
useEffect(() => {
if (choixDates.length > 0) {
if (!choixDates.includes(dateSelection)) setDateSelection(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
if (!choixDates.includes(dateReunion)) setDateReunion(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
}
}, [groupe?.jour_reunion, groupe?.assemblee_affaires_ordre]);
useEffect(() => {
const duJour = reunions.find((x) => x.date_reunion?.slice(0, 10) === dateSelection);
setReunionId(duJour?.id || '');
const precedents = pvs.filter((p) => p.date_reunion && p.date_reunion.slice(0, 10) < dateSelection);
setPvPrecedent(precedents[0] || null);
}, [dateSelection, reunions, pvs]);
const reunion = reunions.find((r) => r.id === reunionId);
const pvJour = pvs.find((p) => p.date_reunion?.slice(0, 10) === dateSelection);
const resolutionsJour = resolutions.filter((r) => (r.date_vote || r.cree_le?.slice(0, 10)) === dateSelection);
const nbPresents = Object.values(presencesMap).filter(Boolean).length;
const toggle = (membreId) => setPresencesMap({ ...presencesMap, [membreId]: !presencesMap[membreId] });
const imprimer = () => window.print();
const creerRapport = async () => {
const res = await api.post('/presences/reunion', {
date_reunion: dateHeureReunionIso(dateReunion, groupe),
note: note.trim() || null,
});
setReunionId(res.id);
setReunions([res, ...reunions]);
setDateSelection(res.date_reunion.slice(0, 10));
return res.id;
};
const enregistrer = async () => {
setError('');
setMsg('');
setLoading(true);
try {
const id = reunionId || await creerRapport();
const presences = Object.entries(presencesMap).map(([membre_id, present]) => ({ membre_id, present }));
await api.post('/presences/', { reunion_id: id, presences });
if (contenu.trim()) {
await api.post('/pv/', {
reunion_id: id,
contenu: contenu.trim(),
decisions: null,
});
}
setMsg(contenu.trim() ? 'Présences et PV enregistrés' : 'Présences enregistrées');
await charger();
} catch (e) { setError(e.message); }
setLoading(false);
};
const adopterPvPrecedent = async () => {
if (!pvPrecedent) return;
setError('');
try {
await api.patch(`/pv/${pvPrecedent.id}/adopter`, adoption);
setMsg('PV précédent adopté');
await charger();
} catch (e) { setError(e.message); }
};
const textePvAuto = [
'Ouverture de l’assemblée.',
'',
pvPrecedent ? `Lecture du procès-verbal précédent (${new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}).` : 'Lecture du procès-verbal précédent.',
'',
'Réflexions en cours:',
...propositions.map((p) => `- ${p.sujet} (${p.proposeur_prenom} / ${p.secondeur_prenom || 'à confirmer'})`),
'',
'Décisions prises pendant cette assemblée:',
...resolutionsJour.map((r) => `- ${r.numero_resolution || ''} ${r.sujet}`.trim()),
'',
'Postes disponibles:',
...postesVacants.map((p) => `- ${p.nom}`),
].join('\n');
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Assemblée d'affaires" sousTitre="Assistant et rapport imprimable" />
<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>}
<Card titre="Consulter le rapport">
<Champ label="Date">
<SelectDate value={dateSelection} onChange={setDateSelection} dates={choixDates} />
</Champ>
<div className="grid grid-cols-2 gap-2">
<button onClick={imprimer} className="p-3 bg-gray-900 text-white rounded-xl text-sm font-bold">Imprimer</button>
{pvPrecedent && (
<button onClick={() => setShowPvPrecedent(!showPvPrecedent)} className="p-3 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold border border-blue-100">
PV précédent
</button>
)}
</div>
</Card>
{showPvPrecedent && pvPrecedent && (
<Card titre="Procès-verbal précédent">
<p className="text-xs text-gray-400">{new Date(pvPrecedent.date_reunion).toLocaleDateString('fr-CA')}</p>
<p className="text-sm whitespace-pre-wrap text-gray-700">{pvPrecedent.contenu}</p>
{estExecutif && !pvPrecedent.approuve && (
<div className="space-y-2 pt-2 border-t border-gray-100">
<div className="grid grid-cols-2 gap-2">
<input value={adoption.proposeur} onChange={(e) => setAdoption({ ...adoption, proposeur: e.target.value })} placeholder="Proposeur" className={INPUT} />
<input value={adoption.secondeur} onChange={(e) => setAdoption({ ...adoption, secondeur: e.target.value })} placeholder="Secondeur" className={INPUT} />
</div>
<button onClick={adopterPvPrecedent} className="w-full p-3 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-100">Adopter le PV précédent</button>
</div>
)}
</Card>
)}
<div className="bg-white rounded-2xl p-5 shadow-sm print:shadow-none print:rounded-none print:p-0">
<h2 className="text-xl font-bold text-gray-900">Rapport d’assemblée d’affaires</h2>
<p className="text-sm text-gray-500">{dateSelection}</p>
<Section titre="Présences">
{reunion?.presences?.length ? reunion.presences.filter((p) => p.present).map((p) => <li key={p.membre_id}>{p.membre_prenom}</li>) : <li>Présences à cocher sur papier</li>}
</Section>
<Section titre="Réflexions en cours">
{propositions.length ? propositions.map((p) => <li key={p.id}>{p.sujet} ({p.proposeur_prenom} / {p.secondeur_prenom || 'à confirmer'})</li>) : <li>Aucune proposition secondée non résolue</li>}
</Section>
<Section titre="Décisions prises">
{resolutionsJour.length ? resolutionsJour.map((r) => <li key={r.id}>{r.numero_resolution} — {r.sujet}</li>) : <li>Aucune décision enregistrée pour cette date</li>}
</Section>
<Section titre="Postes disponibles">
{postesVacants.length ? postesVacants.map((p) => <li key={p.id}>{p.nom}</li>) : <li>Aucun poste vacant</li>}
</Section>
<div className="mt-4">
<h3 className="font-bold text-gray-800">Procès-verbal</h3>
<p className="text-sm whitespace-pre-wrap mt-1">{pvJour?.contenu || textePvAuto}</p>
</div>
</div>
{estExecutif && (
<>
<Card titre="Créer ou renseigner l’assemblée">
{reunions.length > 0 && (
<Champ label="Assemblée existante">
<select value={reunionId} onChange={(e) => setReunionId(e.target.value)} className={INPUT}>
<option value="">Nouvelle assemblée</option>
{reunions.map((r) => <option key={r.id} value={r.id}>{new Date(r.date_reunion).toLocaleDateString('fr-CA')}</option>)}
</select>
</Champ>
)}
{!reunionId && (
<>
<Champ label="Date"><SelectDate value={dateReunion} onChange={setDateReunion} dates={choixDates} /></Champ>
<Champ label="Note"><input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note optionnelle" className={INPUT} /></Champ>
<button onClick={creerRapport} className="w-full p-3 bg-indigo-50 text-indigo-700 rounded-xl text-sm font-bold border border-indigo-100">Créer le rapport d’assemblée</button>
</>
)}
</Card>
<Card titre={`Présences (${nbPresents}/${membres.length})`}>
<div className="space-y-1">{membres.map((m) => (
<button key={m.id} onClick={() => toggle(m.id)} className={`w-full flex items-center gap-3 p-3 rounded-xl transition ${presencesMap[m.id] ? 'bg-green-50 border border-green-200' : 'bg-gray-50 border border-gray-100 opacity-50'}`}>
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-sm font-bold ${presencesMap[m.id] ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-400'}`}>{presencesMap[m.id] ? '✓' : ''}</span>
<span className="font-medium text-gray-800">{m.prenom}</span>
</button>
))}</div>
</Card>
<Card titre="Procès-verbal">
<textarea value={contenu} onChange={(e) => setContenu(e.target.value)} rows={10} placeholder={textePvAuto} className={`${INPUT} resize-none text-sm`} />
</Card>
<button onClick={enregistrer} disabled={loading} className="w-full p-5 bg-indigo-600 text-white rounded-2xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">{loading ? '...' : 'Enregistrer l’assemblée'}</button>
</>
)}
</div>
</div>
);
}
function Card({ titre, children }) {
return <div className="bg-white rounded-2xl p-4 shadow-sm space-y-3"><h2 className="font-bold text-gray-800">{titre}</h2>{children}</div>;
}
function Champ({ label, children }) {
return <label className="block"><span className="text-xs text-gray-400 block mb-1">{label}</span>{children}</label>;
}
function Section({ titre, children }) {
return <div className="mt-4"><h3 className="font-bold text-gray-800">{titre}</h3><ul className="list-disc ml-5 text-sm mt-1 space-y-1">{children}</ul></div>;
}
function SelectDate({ value, onChange, dates }) {
if (!dates.length) {
return <div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500">Configurez le jour de réunion dans les paramètres du groupe.</div>;
}
return (
<select value={value} onChange={(e) => onChange(e.target.value)} className={INPUT}>
{dates.map((d) => <option key={d} value={d}>{labelDateReunion(d)}</option>)}
</select>
);
}

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import { dateLocale, dernieresDatesReunion, indexJourReunion, labelDateReunion } from '../utils/datesReunion';
const STATUT = {
en_attente: { label: 'À confirmer', cls: 'bg-yellow-100 text-yellow-700' },
@ -10,7 +11,7 @@ const STATUT = {
};
export default function Collecte() {
const { membre, groupe } = useStore();
const { membre, groupe, estSysadmin } = useStore();
const [montant, setMontant] = useState('');
const [dateCollecte, setDateCollecte] = useState(new Date().toISOString().slice(0, 10));
const [detenteurId, setDetenteurId] = useState('');
@ -79,50 +80,19 @@ export default function Collecte() {
const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
const dateLocale = (d = new Date()) => {
const an = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const jo = String(d.getDate()).padStart(2, '0');
return `${an}-${mo}-${jo}`;
};
const labelDate = (d, avecAnnee = false) => {
const dt = new Date(d + 'T12:00:00');
return dt.toLocaleDateString('fr-CA', {
weekday: 'long',
day: 'numeric',
month: 'long',
...(avecAnnee ? { year: 'numeric' } : {}),
});
return labelDateReunion(d, avecAnnee);
};
// 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 datesAvecCollecte = new Set(collectesToutes.map((c) => c.date_collecte));
const dates = [];
const d = new Date();
for (let i = 0; i < 180; i++) {
const test = new Date(d);
test.setDate(d.getDate() - i);
const valeur = dateLocale(test);
if (test.getDay() === jourReunion) {
if (!datesAvecCollecte.has(valeur)) dates.push(valeur);
}
}
return dates;
};
const datesReunion = getDatesReunion();
const jourReunion = indexJourReunion(groupe);
const datesAvecCollecte = new Set(collectesToutes.map((c) => c.date_collecte));
const datesReunion = dernieresDatesReunion(groupe, { nombre: 52, exclure: datesAvecCollecte });
useEffect(() => {
if (datesReunion.length > 0 && !datesReunion.includes(dateCollecte)) {
if (!estSysadmin && datesReunion.length > 0 && !datesReunion.includes(dateCollecte)) {
setDateCollecte(datesReunion[0]);
}
}, [collectesToutes, groupe?.jour_reunion]);
}, [collectesToutes, groupe?.jour_reunion, estSysadmin]);
return (
<div className="min-h-screen bg-gray-100">
@ -236,7 +206,10 @@ export default function Collecte() {
<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 ? (
{estSysadmin ? (
<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" />
) : 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) => {
@ -245,17 +218,17 @@ export default function Collecte() {
return <option key={d} value={d}>{estAujourdhui ? '● Aujourd\'hui' : label}</option>;
})}
</select>
) : jourReunion === null ? (
<input type="date" value={dateCollecte} onChange={(e) => setDateCollecte(e.target.value)}
max={dateLocale()}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" />
) : (
<div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500">
Aucun {groupe?.jour_reunion || 'jour de réunion'} sans collecte enregistrée dans les derniers mois.
{jourReunion === null
? "Configurez d'abord le jour de réunion dans les paramètres du groupe."
: `Aucune des 52 dernières rencontres n'est sans collecte enregistrée.`}
</div>
)}
<p className="text-xs text-gray-400 mt-1">
Seuls les {groupe?.jour_reunion || 'jours de réunion'} sans collecte enregistrée sont proposés.
{estSysadmin
? 'Le sysadmin peut renseigner une date antérieure.'
: 'Seules les dates des 52 dernières rencontres sans collecte enregistrée sont proposées.'}
</p>
</div>
@ -281,7 +254,7 @@ export default function Collecte() {
</select>
</div>
<button onClick={handleSubmit} disabled={loading || !montant || !detenteurId || (datesReunion.length === 0 && jourReunion !== null)}
<button onClick={handleSubmit} disabled={loading || !montant || !detenteurId || (!estSysadmin && datesReunion.length === 0 && jourReunion !== null)}
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>

View file

@ -1,110 +1,315 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
const STATUTS = {
en_attente: { label: 'En attente', bg: 'bg-yellow-100', text: 'text-yellow-800', emoji: '⏳' },
en_cours: { label: 'En cours', bg: 'bg-blue-100', text: 'text-blue-800', emoji: '🔄' },
complete: { label: 'Complété', bg: 'bg-green-100', text: 'text-green-800', emoji: '✓' },
proposee: { label: 'À seconder', cls: 'bg-yellow-100 text-yellow-700' },
secondee: { label: 'Secondée', cls: 'bg-blue-100 text-blue-700' },
adoptee: { label: 'Résolution', cls: 'bg-green-100 text-green-700' },
rejetee: { label: 'Rejetée', cls: 'bg-red-100 text-red-700' },
};
function Badge({ statut }) {
const s = STATUTS[statut] || STATUTS.en_attente;
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-bold ${s.bg} ${s.text}`}>
{s.emoji} {s.label}
</span>
);
}
const TYPES = {
proposition: 'Proposition',
resolution: 'Résolution',
generale: 'Résolution',
nomination: 'Nomination',
contribution: 'Contribution',
};
export default function Decisions() {
const [decisions, setDecisions] = useState([]);
const [filtre, setFiltre] = useState('ouvertes'); // ouvertes | toutes
const { postes } = useStore();
const [propositions, setPropositions] = useState([]);
const [resolutions, setResolutions] = useState([]);
const [tab, setTab] = useState('cours');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [msg, setMsg] = useState('');
const [sujet, setSujet] = useState('');
const [description, setDescription] = useState('');
const [resolutionId, setResolutionId] = useState('');
const [resolutionForm, setResolutionForm] = useState({});
const estExecutif = postes.some((p) =>
['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p)
);
const charger = async () => {
setLoading(true);
try {
let url = '/decisions/';
if (filtre === 'ouvertes') {
// Charger en_attente et en_cours
const [att, enc] = await Promise.all([
api.get('/decisions/?statut=en_attente'),
api.get('/decisions/?statut=en_cours'),
]);
setDecisions([...enc, ...att]);
} else {
const data = await api.get(url);
setDecisions(data);
}
} catch (e) {
console.error(e);
}
const [props, res] = await Promise.all([
api.get('/propositions/?limite=100'),
api.get('/propositions/resolutions'),
]);
setPropositions(props);
setResolutions(res);
if (res.length > 0 && !resolutionId) setResolutionId(res[0].id);
} catch (e) { setError(e.message); }
setLoading(false);
};
useEffect(() => { charger(); }, [filtre]);
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/', {
sujet: sujet.trim(),
description: description.trim() || null,
});
setSujet('');
setDescription('');
setTab('cours');
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 mettreAJourResolution = (id, champ, valeur) => {
setResolutionForm({
...resolutionForm,
[id]: { ...(resolutionForm[id] || {}), [champ]: valeur },
});
};
const transformer = async (id) => {
setError('');
const form = resolutionForm[id] || {};
const etantDonneQue = (form.etant_donne_que || '').trim();
const groupeADecideDe = (form.groupe_a_decide_de || '').trim();
if (!etantDonneQue || !groupeADecideDe) {
setError('Les sections "ÉTANT DONNÉ QUE" et "LE GROUPE A DÉCIDÉ DE" sont obligatoires');
return;
}
try {
await api.patch(`/propositions/${id}/voter`, {
statut: 'adoptee',
etant_donne_que: etantDonneQue,
groupe_a_decide_de: groupeADecideDe,
});
setResolutionForm({ ...resolutionForm, [id]: { etant_donne_que: '', groupe_a_decide_de: '' } });
flash('Résolution créée');
await charger();
} catch (e) { setError(e.message); }
};
const rejeter = async (id) => {
setError('');
try {
await api.patch(`/propositions/${id}/voter`, { statut: 'rejetee' });
flash('Proposition rejetée');
await charger();
} catch (e) { setError(e.message); }
};
const enCours = propositions.filter((p) => ['proposee', 'secondee'].includes(p.statut));
const resolutionChoisie = resolutions.find((r) => r.id === resolutionId);
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="Décisions en cours" sousTitre="C5 — Suivi réunion d'affaires" />
<PageHeader titre="Gouvernance" sousTitre="Décisions du groupe" />
<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="p-4 pb-0">
<div className="flex gap-2">
{[
{ key: 'ouvertes', label: 'Ouvertes' },
{ key: 'toutes', label: 'Toutes' },
].map((f) => (
<button
key={f.key}
onClick={() => setFiltre(f.key)}
className={`px-4 py-2 rounded-full text-sm font-bold transition ${
filtre === f.key
? 'bg-aa-blue text-white'
: 'bg-white text-gray-600 border border-gray-200'
}`}
>
{f.label}
['cours', `En cours (${enCours.length})`],
['nouvelle', 'Soumettre'],
['historique', `Résolutions (${resolutions.length})`],
].map(([val, label]) => (
<button key={val} onClick={() => { setTab(val); setError(''); }}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition ${
tab === val ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
}`}>
{label}
</button>
))}
</div>
</div>
{/* Liste */}
<div className="p-4 space-y-3">
{loading ? (
<p className="text-center text-gray-400 py-8">Chargement...</p>
) : decisions.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📌</p>
<p className="text-gray-500">Aucune décision {filtre === 'ouvertes' ? 'ouverte' : ''}</p>
</div>
) : (
decisions.map((d) => (
<div key={d.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<p className="font-bold text-gray-800">{d.sujet}</p>
{d.description && (
<p className="text-sm text-gray-500 mt-1">{d.description}</p>
)}
<div className="flex flex-wrap items-center gap-2 mt-2">
<Badge statut={d.statut} />
{d.responsable_prenom && (
<span className="text-xs text-gray-400">→ {d.responsable_prenom}</span>
{tab === 'cours' && (
enCours.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📌</p>
<p className="text-gray-500">Aucune décision 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">
{enCours.map((p) => {
const statut = STATUTS[p.statut] || STATUTS.proposee;
const form = resolutionForm[p.id] || {};
const resolutionComplete = (form.etant_donne_que || '').trim() && (form.groupe_a_decide_de || '').trim();
return (
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<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 ${statut.cls}`}>
{statut.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>
{p.statut === 'proposee' && (
<div className="grid grid-cols-2 gap-2 mt-3">
{estExecutif && (
<button onClick={() => rejeter(p.id)}
className="py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97] transition">
Rejeter
</button>
)}
<button onClick={() => seconder(p.id)}
className={`${estExecutif ? '' : 'col-span-2'} py-2 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold border border-blue-200 active:scale-[0.97] transition`}>
Seconder
</button>
</div>
)}
{p.statut === 'secondee' && estExecutif && (
<div className="mt-3 space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">ÉTANT DONNÉ QUE</label>
<textarea value={form.etant_donne_que || ''}
onChange={(e) => mettreAJourResolution(p.id, 'etant_donne_que', e.target.value)}
placeholder="Contexte et considérants de la résolution"
rows={3}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">LE GROUPE A DÉCIDÉ DE</label>
<textarea value={form.groupe_a_decide_de || ''}
onChange={(e) => mettreAJourResolution(p.id, 'groupe_a_decide_de', e.target.value)}
placeholder="Décision adoptée par le groupe"
rows={3}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
</div>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => rejeter(p.id)}
className="py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97] transition">
Rejeter
</button>
<button onClick={() => transformer(p.id)} disabled={!resolutionComplete}
className="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">
Transformer
</button>
</div>
</div>
)}
<span className="text-xs text-gray-300">{d.date_decision}</span>
</div>
{d.proposeur && (
<p className="text-xs text-gray-400 mt-1">
Proposé par {d.proposeur}{d.secondeur ? `, secondé par ${d.secondeur}` : ''}
</p>
);
})}
</div>
)
)}
{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-1">Sujet</label>
<input type="text" value={sujet} onChange={(e) => setSujet(e.target.value)}
placeholder="Sujet de la proposition"
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, informations utiles"
rows={3}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
</div>
<button onClick={soumettre} disabled={!sujet.trim()}
className="w-full p-5 bg-aa-blue text-white rounded-2xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
Soumettre la proposition
</button>
</div>
)}
{tab === 'historique' && (
resolutions.length === 0 ? (
<div className="text-center py-12">
<p className="text-4xl mb-2">📜</p>
<p className="text-gray-500">Aucune résolution</p>
</div>
) : (
<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">Historique des résolutions</label>
<select value={resolutionId} onChange={(e) => setResolutionId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{resolutions.map((r) => (
<option key={r.id} value={r.id}>
{(r.date_adoption || r.date_vote || r.cree_le?.slice(0, 10) || 'Sans date')} · {r.numero_resolution || 'RES'} · {r.sujet}
</option>
))}
</select>
</div>
{resolutionChoisie && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-mono text-aa-blue font-bold">{resolutionChoisie.numero_resolution || 'Résolution'}</p>
<p className="font-bold text-gray-800">{resolutionChoisie.sujet}</p>
</div>
<span className="text-xs px-2 py-1 rounded-full font-medium bg-green-100 text-green-700">
Adoptée
</span>
</div>
{resolutionChoisie.description && <p className="text-sm text-gray-500 mt-3">{resolutionChoisie.description}</p>}
<div className="mt-3 text-xs text-gray-400 space-y-1">
<p>Type : {TYPES[resolutionChoisie.type_proposition] || resolutionChoisie.type_proposition}</p>
<p>Date : {resolutionChoisie.date_adoption || resolutionChoisie.date_vote || resolutionChoisie.cree_le?.slice(0, 10) || '—'}</p>
<p>Proposée par {resolutionChoisie.proposeur_prenom}</p>
{resolutionChoisie.secondeur_prenom && <p>Secondée par {resolutionChoisie.secondeur_prenom}</p>}
</div>
{(resolutionChoisie.etant_donne_que || resolutionChoisie.groupe_a_decide_de || resolutionChoisie.note) && (
<div className="mt-3 bg-gray-50 rounded-xl p-3">
<p className="text-xs text-gray-400 mb-1">Texte de la résolution</p>
{resolutionChoisie.etant_donne_que && (
<p className="text-sm text-gray-700 whitespace-pre-wrap"><strong>ÉTANT DONNÉ QUE</strong>{'\n'}{resolutionChoisie.etant_donne_que}</p>
)}
{resolutionChoisie.groupe_a_decide_de && (
<p className="text-sm text-gray-700 whitespace-pre-wrap mt-3"><strong>LE GROUPE A DÉCIDÉ DE</strong>{'\n'}{resolutionChoisie.groupe_a_decide_de}</p>
)}
{!resolutionChoisie.etant_donne_que && !resolutionChoisie.groupe_a_decide_de && (
<p className="text-sm text-gray-700 whitespace-pre-wrap">{resolutionChoisie.note}</p>
)}
</div>
)}
</div>
</div>
)}
</div>
))
)
)}
</div>
</div>

View file

@ -4,48 +4,33 @@ import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
const METHODES = [
{ value: 'COLLECTE', label: 'Cash', emoji: '💰' },
{ value: 'CHEQUE', label: 'Chèque', emoji: '📝' },
{ value: 'VIREMENT', label: 'Virement', emoji: '🏦' },
{ value: 'DU', label: 'DÛ', emoji: '📋' },
];
export default function Depense() {
const { postes } = useStore();
const { membre, postes, estSysadmin } = useStore();
const [form, setForm] = useState({
date_depense: new Date().toISOString().slice(0, 10),
montant: '', quoi: '',
montant: '', quoi: '', membre_id: '', date_depense: new Date().toISOString().slice(0, 10),
});
const [fichier, setFichier] = useState(null);
const [depenses, setDepenses] = useState([]);
const [aConfirmer, setAConfirmer] = useState([]);
const [tab, setTab] = useState('saisie');
const [membres, setMembres] = useState([]);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const estTresorier = postes.some((p) =>
['Trésorier(ière)', 'RSG', 'RSG Substitut(e)', 'Secrétaire'].includes(p)
const estExecutif = postes.some((p) =>
['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', '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);
if (estExecutif) {
const data = await api.get('/membres/actifs');
setMembres(data);
setForm((f) => ({ ...f, membre_id: f.membre_id || membre?.id || data[0]?.id || '' }));
}
} catch (e) { setError(e.message); }
};
useEffect(() => { charger(); }, []);
useEffect(() => { charger(); }, [estExecutif]);
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
const update = (k, v) => setForm({ ...form, [k]: v });
const handleSubmit = async () => {
@ -55,9 +40,10 @@ export default function Depense() {
setLoading(true); setError('');
try {
const result = await api.post('/depenses/', {
date_depense: form.date_depense,
montant: parseFloat(form.montant),
quoi: form.quoi.trim(),
membre_id: estExecutif ? form.membre_id || null : null,
date_depense: estSysadmin ? form.date_depense : null,
});
// Upload preuve si présente
if (fichier && result.id) {
@ -73,201 +59,73 @@ export default function Depense() {
} catch (e) { setError(e.message); setLoading(false); }
};
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épenses" sousTitre="S5 — Soumettre et confirmer" />
<PageHeader titre="Dépenses" sousTitre="S5 — Soumettre une dépense" />
<div className="p-4 space-y-3">
{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>
<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)}
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>
<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" />
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<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} />
{estExecutif && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Membre</label>
<select value={form.membre_id} onChange={(e) => update('membre_id', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{membres.map((m) => (
<option key={m.id} value={m.id}>{m.prenom}</option>
))}
</div>
)
)}
{/* ── 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>
))}
</select>
</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>
))}
</div>
)
{estSysadmin && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date de la dépense</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>
)}
</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 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)}
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>
<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" />
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<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">
{estSysadmin ? 'Le sysadmin peut renseigner une date antérieure.' : 'La date sera celle de la soumission.'} 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() || (estExecutif && !form.membre_id)}
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>
</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

@ -1,7 +1,10 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { datesReunion, dateLocale } from '../utils/datesReunion';
const METHODES = [
{ value: 'VIREMENT', label: 'Virement', emoji: '🏦' },
@ -16,6 +19,8 @@ const DESTINATIONS = [
];
export default function EnvoiContributions() {
const { groupe, estSysadmin } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [soldes, setSoldes] = useState(null);
const [membres, setMembres] = useState([]);
const [envois, setEnvois] = useState([]);
@ -24,7 +29,7 @@ export default function EnvoiContributions() {
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
date_envoi: new Date().toISOString().slice(0, 10),
date_envoi: dateLocale(),
destination: 'district',
montant: '',
methode: '',
@ -47,6 +52,12 @@ export default function EnvoiContributions() {
}).catch(() => {});
}, []);
useEffect(() => {
if (!estSysadmin && choixDates.length > 0 && !choixDates.includes(form.date_envoi)) {
setForm((f) => ({ ...f, date_envoi: choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1] }));
}
}, [groupe?.jour_reunion, estSysadmin]);
const update = (k, v) => setForm({ ...form, [k]: v });
const handleSubmit = async () => {
@ -87,7 +98,7 @@ export default function EnvoiContributions() {
</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>
<p>Réserves: -{(soldes.total_reserves_reelles || 0).toFixed(2)} $</p>
</div>
</div>
</div>
@ -121,8 +132,12 @@ export default function EnvoiContributions() {
<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)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
{estSysadmin ? (
<input type="date" value={form.date_envoi} onChange={(e) => update('date_envoi', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
) : (
<MeetingDateSelect dates={choixDates} value={form.date_envoi} onChange={(v) => update('date_envoi', v)} />
)}
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">

View file

@ -1,101 +1,157 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
export default function EtatFinancier() {
const MOIS = [
['1', 'Janvier'], ['2', 'Février'], ['3', 'Mars'], ['4', 'Avril'],
['5', 'Mai'], ['6', 'Juin'], ['7', 'Juillet'], ['8', 'Août'],
['9', 'Septembre'], ['10', 'Octobre'], ['11', 'Novembre'], ['12', 'Décembre'],
];
export default function EtatFinancier({ integre = false, afficherTresorerie = true, afficherPdf = true }) {
const maintenant = useMemo(() => new Date(), []);
const [annee, setAnnee] = useState(String(maintenant.getFullYear()));
const [mois, setMois] = useState(String(maintenant.getMonth() + 1));
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
useEffect(() => {
api.get('/rapports/etat-financier').then(setData).catch((e) => setError(e.message));
}, []);
setLoading(true);
setError('');
api.get(`/rapports/etat-financier?annee=${annee}&mois=${mois}`)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [annee, mois]);
if (!data && !error) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
const annees = useMemo(() => {
const courante = maintenant.getFullYear();
return Array.from({ length: 6 }, (_, i) => String(courante - i));
}, [maintenant]);
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="État financier" sousTitre="Résultats et transactions en temps réel" />
const telechargerPdf = async () => {
setGenerating(true);
try {
const token = api.getToken();
const res = await fetch(`/api/rapports/etat-resultats/${annee}/${mois}`, {
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 = `etat_resultats_${annee}_${String(mois).padStart(2, '0')}.pdf`;
a.click();
URL.revokeObjectURL(a.href);
} catch (e) {
alert(`Erreur: ${e.message}`);
} finally {
setGenerating(false);
}
};
if (loading && !data && !error) {
return <div className="flex items-center justify-center min-h-[240px]"><p className="text-gray-400">Chargement...</p></div>;
}
const contenu = (
<div className={integre ? 'space-y-4' : 'p-4 space-y-4'}>
<div className="bg-white rounded-2xl p-3 shadow-sm grid grid-cols-2 gap-2">
<select value={mois} onChange={(e) => setMois(e.target.value)}
className="min-h-[44px] px-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{MOIS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<select value={annee} onChange={(e) => setAnnee(e.target.value)}
className="min-h-[44px] px-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{annees.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
{afficherPdf && (
<button
type="button"
onClick={telechargerPdf}
disabled={generating || loading}
className="col-span-2 min-h-[48px] rounded-xl bg-aa-blue text-white font-bold disabled:opacity-40 active:scale-[0.98]"
>
{generating ? 'Génération...' : 'Rapport PDF'}
</button>
)}
</div>
<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">{formatMontant(data.revenus?.total)} $</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">{formatMontant(data.sorties?.total)} $</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>
{(data.reserves?.details || []).length > 0 ? (
data.reserves.details.map((reserve) => (
<Ligne key={reserve.id} label={reserve.nom} montant={reserve.montant} />
))
) : (
<p className="text-sm text-amber-700">Aucune réserve enregistrée.</p>
)}
<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">{formatMontant(data.reserves?.total)} $</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">+{formatMontant(c.montant)} $</span>
{afficherTresorerie && (
<>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-lg font-extrabold text-gray-900 uppercase tracking-wide mb-3">Position de trésorerie</h2>
<div className="grid grid-cols-2 gap-3">
<Bloc label="Banque" montant={data.solde_banque} couleur="blue" />
<Bloc label="Encaisse" montant={data.encaisse} couleur="green" />
<Bloc label="Réserves" montant={data.reserves?.total} couleur="amber" />
<Bloc label="Engagé" montant={data.sorties.engagements} couleur="orange" />
<div className="col-span-2">
<Bloc label="Disponible" montant={data.disponible} couleur={data.disponible >= 0 ? 'green' : 'red'} />
</div>
</div>
))}
</div>
{data.date_releve && (
<p className="text-xs text-gray-400 mt-2 text-center">Dernier relevé : {data.date_releve}</p>
)}
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-lg font-extrabold text-gray-900 uppercase tracking-wide mb-2">Réserves</h2>
{(data.reserves?.details || []).length > 0 ? (
data.reserves.details.map((reserve) => (
<Ligne key={reserve.id} label={reserve.nom} montant={reserve.montant} />
))
) : (
<p className="text-sm text-gray-500">Aucune réserve enregistrée.</p>
)}
</div>
</>
)}
{/* Dernières dépenses */}
{(data.dernieres_depenses || []).length > 0 && (
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3 mb-3">
<div>
<h2 className="text-lg font-extrabold text-gray-900 uppercase tracking-wide">État des résultats</h2>
<p className="text-sm text-gray-500">{data.periode?.libelle}</p>
</div>
{loading && <span className="text-xs text-gray-400">Mise à jour...</span>}
</div>
<SectionTitre label="Revenus" couleur="green" />
<Ligne label="Collectes 7e Tradition" montant={data.revenus.collectes} />
<Ligne label="Ventes de littérature" montant={data.revenus.ventes_litterature} />
<Ligne label="Dons déposés directement" montant={data.revenus.dons} />
<Total label="Total des revenus" montant={data.revenus.total} couleur="green" />
<SectionTitre label="Charges" couleur="red" marge />
<Ligne label="Dépenses payées" montant={data.sorties.depenses_payees} />
<Ligne label="Contributions envoyées" montant={data.sorties.contributions} />
<Total label="Total des charges" montant={data.sorties.total} couleur="red" />
<div className="mt-4 pt-3 border-t-2 border-gray-200 flex justify-between items-center">
<span className="font-bold text-gray-800">
{data.resultat_net >= 0 ? 'Excédent' : 'Déficit'}
</span>
<span className={`text-xl font-bold ${data.resultat_net >= 0 ? 'text-green-700' : 'text-red-700'}`}>
{formatMontant(data.resultat_net)} $
</span>
</div>
</div>
{afficherTresorerie && (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>
<p className="text-xs text-gray-400">{d.date} · {d.methode || '-'} · {d.paye ? 'Payée' : 'En attente'}</p>
</div>
<span className="text-sm font-bold text-red-600 whitespace-nowrap ml-2">-{formatMontant(d.montant)} $</span>
</div>
@ -105,6 +161,14 @@ export default function EtatFinancier() {
</>
)}
</div>
);
if (integre) return contenu;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="État des résultats" sousTitre="Revenus, charges et excédent de la période" />
{contenu}
</div>
);
}
@ -114,6 +178,8 @@ function Bloc({ label, montant, couleur }) {
blue: 'bg-blue-50 text-blue-700',
green: 'bg-green-50 text-green-700',
red: 'bg-red-50 text-red-700',
orange: 'bg-orange-50 text-orange-700',
amber: 'bg-amber-50 text-amber-700',
};
return (
<div className={`text-center p-3 rounded-xl ${cls[couleur] || cls.blue}`}>
@ -123,6 +189,11 @@ function Bloc({ label, montant, couleur }) {
);
}
function SectionTitre({ label, couleur, marge = false }) {
const cls = couleur === 'green' ? 'text-green-700' : 'text-red-700';
return <h3 className={`text-xs font-bold uppercase tracking-wider ${cls} ${marge ? 'mt-4' : ''}`}>{label}</h3>;
}
function Ligne({ label, montant }) {
return (
<div className="flex justify-between py-1">
@ -132,6 +203,20 @@ function Ligne({ label, montant }) {
);
}
function Total({ label, montant, couleur }) {
const cls = {
green: 'border-green-200 text-green-800',
red: 'border-red-200 text-red-800',
amber: 'border-amber-200 text-amber-800',
};
return (
<div className={`border-t mt-2 pt-2 flex justify-between ${cls[couleur] || cls.green}`}>
<span className="font-bold text-sm">{label}</span>
<span className="font-bold text-sm">{formatMontant(montant)} $</span>
</div>
);
}
function formatMontant(montant) {
const valeur = Number(montant);
return Number.isFinite(valeur) ? valeur.toFixed(2) : '0.00';

View file

@ -6,13 +6,12 @@ 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 [filtres, setFiltres] = useState(['anniversaire', 'adhoc', 'resolution']);
const [filtres, setFiltres] = useState(['anniversaire', 'adhoc']);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [error, setError] = useState('');
@ -58,7 +57,7 @@ export default function Evenements() {
));
};
const evenementsFiltres = evenements.filter((e) => filtres.includes(e.type));
const evenementsFiltres = evenements.filter((e) => e.type !== 'resolution' && filtres.includes(e.type));
// Grouper par date
const parDate = {};
@ -71,7 +70,7 @@ export default function Evenements() {
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Événements" sousTitre="Anniversaires, événements et résolutions" />
<PageHeader titre="Événements" sousTitre="Anniversaires et événements du groupe" />
<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>}
@ -79,7 +78,7 @@ export default function Evenements() {
{/* Filtres */}
<div className="flex gap-2 overflow-x-auto">
{[['anniversaire', '🎂 Anniv.'], ['adhoc', '⭐ Événements'], ['resolution', '📜 Résol.']].map(([k, label]) => (
{[['anniversaire', '🎂 Anniv.'], ['adhoc', '⭐ Événements']].map(([k, label]) => (
<button key={k} onClick={() => toggleFiltre(k)}
className={`px-3 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition ${
filtres.includes(k) ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'

View file

@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
export default function GererMembres() {
const { membre, estSysadmin, impersoner } = useStore();
const [membres, setMembres] = useState([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState(null);
@ -68,6 +70,12 @@ export default function GererMembres() {
} catch (e) { alert(e.message); }
};
const agirComme = async (id) => {
try {
await impersoner(id);
} catch (e) { alert(e.message); }
};
// Vue détail d'un membre
if (detail) {
return (
@ -109,6 +117,13 @@ export default function GererMembres() {
</div>
{/* Actions */}
{estSysadmin && detail.id !== membre?.id && !confirmAction && (
<button onClick={() => agirComme(detail.id)}
className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold text-sm">
Agir comme ce membre
</button>
)}
{detail.actif && !confirmAction && (
<button onClick={() => setConfirmAction('desactiver')}
className="w-full p-3 bg-orange-50 border border-orange-200 text-orange-700 rounded-xl font-bold text-sm">

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
@ -9,19 +9,30 @@ const CATEGORIES = [
];
const MODULES = [
['S0', 'Rencontre hebdomadaire'], ['M0', "Assemblée d'affaires"],
['S1', 'Collecte 7e'], ['C5', 'Gouvernance'],
['S1T', 'Trésorerie'], ['M4', 'Envoi contributions'],
['M1', 'Rédiger PV'], ['M2', 'Présences'], ['M3', 'Rapport RSG'],
['S2', 'Remise jeton'], ['M6', 'Commander jetons'],
['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'], ['S4', 'Rapport réunion'],
['G2', 'Gestion membres'], ['G6', 'Sondages'], ['G9', 'Paramètres'],
['G2', 'Gestion membres'], ['G9', 'Paramètres'],
['G11', 'Gestion mandats'], ['G12', 'Gestion postes'],
];
const GROUPES_MODULES = [
{ titre: 'Réunion', codes: ['S0', 'S1', 'S2', 'S3', 'S4'] },
{ titre: 'Affaires', codes: ['M0', 'M1', 'M2', 'M3', 'C5'] },
{ titre: 'Finances', codes: ['S1T', 'S5', 'M4'] },
{ titre: 'Inventaire', codes: ['C4', 'M5', 'M6'] },
{ titre: 'Administration', codes: ['G2', 'G9', 'G11', 'G12'] },
];
export default function GererPostes() {
const [postes, setPostes] = useState([]);
const [modulesPostes, setModulesPostes] = useState({});
const [candidatures, setCandidatures] = useState([]);
const [tab, setTab] = useState('postes');
const [posteOuvert, setPosteOuvert] = useState(null);
const [showForm, setShowForm] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
@ -112,6 +123,14 @@ export default function GererPostes() {
};
const CAT_ICON = { executif: '🔵', service: '🟣', physique: '🟢' };
const moduleParCode = useMemo(() => new Map(MODULES.map(([code, label]) => [code, label])), []);
const groupesPourPoste = (posteId) => GROUPES_MODULES.map((groupe) => ({
...groupe,
modules: groupe.codes
.filter((code) => moduleParCode.has(code))
.map((code) => [code, moduleParCode.get(code), (modulesPostes[posteId] || []).includes(code)]),
})).filter((groupe) => groupe.modules.length > 0);
return (
<div className="min-h-screen bg-gray-100">
@ -124,7 +143,6 @@ export default function GererPostes() {
<div className="flex gap-1 overflow-x-auto">
{[
['postes', `Postes (${postes.length})`],
['acces', 'Accès'],
['candidatures', `Candidatures (${candidatures.length})`],
].map(([t, label]) => (
<button key={t} onClick={() => { setTab(t); setError(''); }}
@ -164,6 +182,7 @@ export default function GererPostes() {
<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.prerequis && <p className="text-xs text-gray-500 mt-1"><span className="font-bold">Prérequis :</span> {p.prerequis}</p>}
{p.informations && <p className="text-xs text-gray-400 mt-1 italic">{p.informations}</p>}
</div>
{p.actif ? (
@ -175,28 +194,42 @@ export default function GererPostes() {
</div>
)}
</div>
</div>
))}
</div>
)}
{tab === 'acces' && (
<div className="space-y-3">
{postes.filter((p) => p.actif).map((p) => (
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
<p className="font-bold text-gray-800 mb-3">{p.nom}</p>
<div className="grid grid-cols-2 gap-2">
{MODULES.map(([code, label]) => {
const actif = (modulesPostes[p.id] || []).includes(code);
return (
<button key={code} onClick={() => toggleModule(p.id, code)}
className={`p-2 rounded-xl text-xs font-bold border ${
actif ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-500 border-gray-200'
}`}>
{code} · {label}
</button>
);
})}
<div className="mt-3 pt-3 border-t border-gray-100">
<button
type="button"
onClick={() => setPosteOuvert(posteOuvert === p.id ? null : p.id)}
className={`w-full min-h-[44px] rounded-xl text-sm font-bold border ${
posteOuvert === p.id ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-700 border-gray-200'
}`}
>
Rôles associés ({(modulesPostes[p.id] || []).length})
</button>
{posteOuvert === p.id && (
<div className="mt-3 space-y-4">
{groupesPourPoste(p.id).map((groupe) => (
<div key={groupe.titre} className="space-y-2">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">{groupe.titre}</p>
<div className="grid grid-cols-2 gap-2">
{groupe.modules.map(([code, label, actif]) => (
<button
key={code}
type="button"
onClick={() => toggleModule(p.id, code)}
disabled={!p.actif}
className={`min-h-[44px] p-2 rounded-xl text-xs font-bold border ${
actif ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-500 border-gray-200'
} disabled:opacity-40`}
>
{code} · {label}
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
))}

View file

@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { useStore } from '../stores/store';
import { api } from '../stores/api';
export default function JournalActions() {
const { setPage } = useStore();
const [actions, setActions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
api.get('/journal/actions?limite=100')
.then(setActions)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
const formatDate = (value) => new Date(value).toLocaleString('fr-CA', {
dateStyle: 'medium',
timeStyle: 'short',
});
return (
<div className="min-h-screen bg-gray-100">
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
<button onClick={() => setPage('accueil')} className="text-2xl">←</button>
<div>
<h1 className="text-lg font-bold">Journal</h1>
<p className="text-sm text-blue-100">Changements enregistrés</p>
</div>
</div>
<div className="p-4 space-y-3">
{loading && <p className="text-gray-500 text-center py-8">Chargement...</p>}
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-sm">{error}</div>}
{!loading && actions.length === 0 && (
<p className="text-gray-500 text-center py-8">Aucun changement journalisé.</p>
)}
{actions.map((action) => (
<div key={action.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-bold text-gray-800">{action.methode} {action.chemin}</p>
<p className="text-xs text-gray-400 mt-1">{formatDate(action.cree_le)}</p>
</div>
<span className="text-xs font-bold rounded-full bg-gray-100 text-gray-600 px-2 py-1">
{action.statut_http}
</span>
</div>
<div className="mt-3 text-xs text-gray-500 space-y-1">
<p>Membre: {action.membre_id}</p>
{action.admin_id && <p>Admin: {action.admin_id}</p>}
{action.adresse_ip && <p>IP: {action.adresse_ip}</p>}
{action.details?.impersonification && (
<p className="text-amber-700 font-medium">Action faite en impersonification</p>
)}
</div>
</div>
))}
</div>
</div>
);
}

View file

@ -1,163 +0,0 @@
import React, { useState } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
const TYPES = [
{ value: 'oui_non', label: 'Oui / Non', emoji: '👍' },
{ value: 'choix_multiple', label: 'Choix multiple', emoji: '📋' },
{ value: 'unanimite', label: 'Unanimité', emoji: '🤝' },
];
export default function LancerSondage() {
const [form, setForm] = useState({
titre: '',
description: '',
type: 'oui_non',
date_ouverture: new Date().toISOString().slice(0, 16),
date_fermeture: '',
options: ['', ''],
});
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const update = (k, v) => setForm({ ...form, [k]: v });
const updateOption = (idx, val) => {
const opts = [...form.options];
opts[idx] = val;
setForm({ ...form, options: opts });
};
const addOption = () => setForm({ ...form, options: [...form.options, ''] });
const removeOption = (idx) => {
if (form.options.length <= 2) return;
setForm({ ...form, options: form.options.filter((_, i) => i !== idx) });
};
const handleSubmit = async () => {
if (!form.titre.trim()) { setError('Le titre est obligatoire'); return; }
if (!form.date_fermeture) { setError('La date de fermeture est obligatoire'); return; }
const options = form.type === 'oui_non'
? [] // auto-générées par le backend
: form.options.filter((o) => o.trim()).map((o) => ({ texte: o.trim() }));
if (form.type !== 'oui_non' && options.length < 2) {
setError('Au moins 2 options sont requises');
return;
}
setLoading(true);
setError('');
try {
await api.post('/sondages/', {
titre: form.titre.trim(),
description: form.description.trim() || null,
type: form.type,
date_ouverture: new Date(form.date_ouverture).toISOString(),
date_fermeture: new Date(form.date_fermeture).toISOString(),
options,
});
setSuccess(true);
} catch (e) {
setError(e.message);
setLoading(false);
}
};
if (success) return <SuccessCheck message="Sondage créé ✓" />;
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Lancer un sondage" sousTitre="G6 — Conscience de groupe" />
<div className="p-4 space-y-3">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Question / Titre</label>
<input type="text" placeholder="Ex: Devons-nous changer l'heure de la réunion?"
value={form.titre} onChange={(e) => update('titre', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-indigo-400" autoFocus />
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Description (optionnel)</label>
<textarea placeholder="Contexte, détails..." value={form.description}
onChange={(e) => update('description', e.target.value)} rows={2}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-indigo-400 resize-none" />
</div>
{/* Type de vote */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-2">Type de vote</label>
<div className="grid grid-cols-3 gap-2">
{TYPES.map((t) => (
<button key={t.value} type="button" onClick={() => update('type', t.value)}
className={`p-3 rounded-xl text-xs font-bold border-2 text-center ${
form.type === t.value
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-gray-50 text-gray-600 border-gray-200'
}`}>
<span className="text-lg block">{t.emoji}</span>
{t.label}
</button>
))}
</div>
</div>
{/* Options (seulement si pas oui/non) */}
{form.type !== 'oui_non' && (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-2">
<label className="text-xs text-gray-400 block mb-1">Options</label>
{form.options.map((opt, idx) => (
<div key={idx} className="flex gap-2">
<input type="text" placeholder={`Option ${idx + 1}`}
value={opt} onChange={(e) => updateOption(idx, e.target.value)}
className="flex-1 p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-indigo-400" />
{form.options.length > 2 && (
<button onClick={() => removeOption(idx)}
className="w-10 bg-red-100 text-red-600 rounded-xl text-sm font-bold">✕</button>
)}
</div>
))}
<button onClick={addOption}
className="w-full p-2 text-indigo-600 text-sm font-bold bg-indigo-50 rounded-xl">
+ Ajouter une option
</button>
</div>
)}
{/* Dates */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Ouverture</label>
<input type="datetime-local" value={form.date_ouverture}
onChange={(e) => update('date_ouverture', e.target.value)}
className="w-full p-2 bg-gray-50 rounded-xl text-sm border-0 focus:ring-2 focus:ring-indigo-400" />
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Fermeture</label>
<input type="datetime-local" value={form.date_fermeture}
onChange={(e) => update('date_fermeture', e.target.value)}
className="w-full p-2 bg-gray-50 rounded-xl text-sm border-0 focus:ring-2 focus:ring-indigo-400" />
</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.titre.trim() || !form.date_fermeture}
className="w-full p-5 bg-indigo-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
{loading ? '...' : '📊 Créer le sondage'}
</button>
<div className="bg-blue-50 rounded-2xl p-4">
<p className="text-xs text-blue-600">
💡 Le sondage est créé en mode brouillon. Ouvrez-le quand vous êtes prêt depuis le module « Votes ouverts ».
</p>
</div>
</div>
</div>
);
}

View file

@ -13,8 +13,17 @@ export default function Login() {
}, []);
useEffect(() => {
if (groupes.length === 1 && !groupeId) setGroupeId(groupes[0].id);
}, [groupes]);
if (groupes.length === 0) return;
const groupeExiste = groupes.some((g) => g.id === groupeId);
if (groupeId && groupeExiste) return;
const prochainGroupeId = groupes.length === 1 ? groupes[0].id : '';
setGroupeId(prochainGroupeId);
if (prochainGroupeId) {
localStorage.setItem('gm_groupe_id', prochainGroupeId);
} else {
localStorage.removeItem('gm_groupe_id');
}
}, [groupes, groupeId]);
const handleSubmit = async (e) => {
e.preventDefault();
@ -76,12 +85,12 @@ export default function Login() {
)}
<input
type="email"
placeholder="Adresse courriel"
type="text"
placeholder="Adresse courriel ou identifiant"
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="email"
autoComplete="username"
inputMode="email"
/>

View file

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import PageHeader from '../components/PageHeader';
import { ORDRES_ASSEMBLEE } from '../utils/datesReunion';
const JOURS = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'];
@ -19,6 +20,7 @@ export default function Parametres() {
adresse_local: data.adresse_local || '',
jour_reunion: data.jour_reunion || '',
heure_reunion: data.heure_reunion || '',
assemblee_affaires_ordre: data.assemblee_affaires_ordre || 'premiere',
format_reunion: data.format_reunion || '',
lien_aa87: data.lien_aa87 || 'https://aa87.org',
});
@ -70,6 +72,7 @@ export default function Parametres() {
<Field label="Adresse du local" value={groupe.adresse_local} />
<Field label="Jour de réunion" value={groupe.jour_reunion} />
<Field label="Heure" value={groupe.heure_reunion} />
<Field label="Assemblée d'affaires mensuelle" value={ORDRES_ASSEMBLEE.find(([v]) => v === groupe.assemblee_affaires_ordre)?.[1]} />
<Field label="Format de réunion" value={groupe.format_reunion} />
<Field label="Lien aa87.org" value={groupe.lien_aa87} />
</>
@ -98,6 +101,13 @@ export default function Parametres() {
<input type="time" value={form.heure_reunion} onChange={(e) => setForm({ ...form, heure_reunion: 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">Assemblée d'affaires mensuelle</label>
<select value={form.assemblee_affaires_ordre} onChange={(e) => setForm({ ...form, assemblee_affaires_ordre: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
{ORDRES_ASSEMBLEE.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-400">Format de réunion</label>
<input type="text" placeholder="Discussion ouverte, conférencier, étapes..."

View file

@ -1,7 +1,10 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { datesReunion } from '../utils/datesReunion';
const JOURS_FR = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
@ -9,6 +12,8 @@ const JOURS_FR = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi',
const POSTES_ROTATION = ['Animateur(trice)', 'Responsable café', 'Responsable accueil', 'Responsable clés'];
export default function PlanifierRotations({ compact = false }) {
const { groupe } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 0, joursApres: 365 });
const [rotations, setRotations] = useState([]);
const [postes, setPostes] = useState([]);
const [membres, setMembres] = useState([]);
@ -79,9 +84,7 @@ export default function PlanifierRotations({ compact = false }) {
) : (
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<h2 className="font-bold text-gray-700">Nouvelle rotation</h2>
<input type="date" value={form.date_reunion}
onChange={(e) => setForm({ ...form, date_reunion: e.target.value })}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<MeetingDateSelect dates={choixDates} value={form.date_reunion} onChange={(v) => setForm({ ...form, date_reunion: v })} />
<select value={form.poste_id} onChange={(e) => setForm({ ...form, poste_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="">Poste</option>

View file

@ -1,6 +1,13 @@
import React, { useState, useEffect } from 'react';
import { useStore } from '../stores/store';
import { api } from '../stores/api';
import {
disablePushNotifications,
enablePushNotifications,
getPushSubscription,
pushSupported,
sendTestNotification,
} from '../utils/pushNotifications';
export default function Profil() {
const { setPage, logout } = useStore();
@ -11,6 +18,8 @@ export default function Profil() {
const [showPin, setShowPin] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [pushInfo, setPushInfo] = useState({ supported: pushSupported(), enabled: false, configured: false });
const [pushBusy, setPushBusy] = useState(false);
useEffect(() => {
api.get('/membres/moi').then((data) => {
@ -23,6 +32,16 @@ export default function Profil() {
date_abstinence: data.date_abstinence || '',
});
});
api.get('/notifications/configuration')
.then(async (config) => {
const subscription = await getPushSubscription();
setPushInfo({
supported: pushSupported(),
enabled: !!subscription,
configured: !!config.enabled,
});
})
.catch(() => {});
}, []);
const saveProfile = async () => {
@ -51,6 +70,50 @@ export default function Profil() {
} catch (e) { setError(e.message); setTimeout(() => setError(''), 3000); }
};
const activerNotifications = async () => {
setPushBusy(true);
setError('');
try {
await enablePushNotifications();
setPushInfo((current) => ({ ...current, enabled: true }));
setMsg('Notifications activées ✓');
setTimeout(() => setMsg(''), 2000);
} catch (e) {
setError(e.message);
} finally {
setPushBusy(false);
}
};
const desactiverNotifications = async () => {
setPushBusy(true);
setError('');
try {
await disablePushNotifications();
setPushInfo((current) => ({ ...current, enabled: false }));
setMsg('Notifications désactivées ✓');
setTimeout(() => setMsg(''), 2000);
} catch (e) {
setError(e.message);
} finally {
setPushBusy(false);
}
};
const testerNotifications = async () => {
setPushBusy(true);
setError('');
try {
await sendTestNotification();
setMsg('Notification de test envoyée ✓');
setTimeout(() => setMsg(''), 2000);
} catch (e) {
setError(e.message);
} finally {
setPushBusy(false);
}
};
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 }) => (
@ -131,6 +194,47 @@ export default function Profil() {
)}
</div>
{/* Notifications */}
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div>
<h2 className="font-bold text-gray-700">Notifications</h2>
<p className="text-sm text-gray-500 mt-1">
Recevoir un avis lorsqu'un membre enregistre un changement.
</p>
</div>
{!pushInfo.supported && (
<p className="text-sm text-amber-700 bg-amber-50 p-3 rounded-xl">
Ce navigateur ne supporte pas les notifications push.
</p>
)}
{pushInfo.supported && !pushInfo.configured && (
<p className="text-sm text-amber-700 bg-amber-50 p-3 rounded-xl">
Les notifications ne sont pas configurées sur ce serveur.
</p>
)}
{pushInfo.supported && pushInfo.configured && (
<div className="flex flex-col gap-2">
{!pushInfo.enabled ? (
<button onClick={activerNotifications} disabled={pushBusy}
className="w-full p-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">
Activer sur cet appareil
</button>
) : (
<>
<button onClick={testerNotifications} disabled={pushBusy}
className="w-full p-3 bg-gray-100 text-gray-700 rounded-xl font-bold disabled:opacity-40">
Envoyer un test
</button>
<button onClick={desactiverNotifications} disabled={pushBusy}
className="w-full p-3 bg-red-50 text-red-600 rounded-xl font-bold disabled:opacity-40">
Désactiver sur cet appareil
</button>
</>
)}
</div>
)}
</div>
{/* Changer PIN */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
{!showPin ? (

View file

@ -1,277 +0,0 @@
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

@ -1,17 +1,28 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { dateHeureReunionIso, datesReunion, dateLocale } from '../utils/datesReunion';
export default function RapportReunion() {
const { groupe } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [theme, setTheme] = useState('');
const [annonces, setAnnonces] = useState('');
const [note, setNote] = useState('');
const [dateReunion, setDateReunion] = useState(new Date().toISOString().slice(0, 16));
const [dateReunion, setDateReunion] = useState(dateLocale());
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (choixDates.length > 0 && !choixDates.includes(dateReunion)) {
setDateReunion(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
}
}, [groupe?.jour_reunion]);
const handleSubmit = async () => {
if (!theme.trim()) {
setError('Le thème est obligatoire');
@ -27,7 +38,7 @@ export default function RapportReunion() {
await api.post('/reunions/', {
type: 'reguliere',
date_reunion: new Date(dateReunion).toISOString(),
date_reunion: dateHeureReunionIso(dateReunion, groupe),
theme: theme.trim(),
annonces: annoncesList.length > 0 ? annoncesList : null,
note: note.trim() || null,
@ -49,12 +60,8 @@ export default function RapportReunion() {
{/* Date */}
<div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date et heure</label>
<input
type="datetime-local"
value={dateReunion}
onChange={(e) => setDateReunion(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue"
/>
<MeetingDateSelect dates={choixDates} value={dateReunion} onChange={setDateReunion}
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
{/* Thème */}

View file

@ -44,7 +44,8 @@ export default function Rapports() {
setGenerating(null);
};
const vide = disponibles.length === 0 && anneesResolutions.length === 0;
const rapportsMensuels = disponibles.filter((m) => m.reunions);
const vide = rapportsMensuels.length === 0 && anneesResolutions.length === 0;
return (
<div className="min-h-screen bg-gray-100">
@ -62,26 +63,12 @@ export default function Rapports() {
) : (
<>
{/* Rapports mensuels */}
{disponibles.map((m) => (
{rapportsMensuels.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}
</h3>
<div className="grid gap-2">
{m.finances && (
<button
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"
>
<span className="text-xl">💰</span>
<div className="flex-1">
<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>
</button>
)}
{m.reunions && (
<button
onClick={() => telecharger(`reunions/${m.annee}/${m.mois}`, `reunions-${m.annee}-${m.mois}`, `rapport_reunions_${m.annee}_${String(m.mois).padStart(2,'0')}.pdf`)}
@ -114,7 +101,7 @@ export default function Rapports() {
<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>
<p className="text-xs text-orange-600">Décisions prises en Groupe</p>
</div>
<span className="text-orange-400">{generating === `res-${a.annee}` ? '⏳' : '↓'}</span>
</button>

View file

@ -1,7 +1,10 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { datesReunion, dateLocale } from '../utils/datesReunion';
const TYPES_JETONS = [
{ value: 'desir', label: 'Désir', emoji: '💫' },
@ -16,10 +19,12 @@ const TYPES_JETONS = [
];
export default function RemiseJeton() {
const { groupe } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [form, setForm] = useState({
membre_id: '',
type_jeton: '',
date_remise: new Date().toISOString().slice(0, 10),
date_remise: dateLocale(),
annees: '',
});
const [membres, setMembres] = useState([]);
@ -31,6 +36,12 @@ export default function RemiseJeton() {
api.get('/membres/actifs').then(setMembres).catch(() => setMembres([]));
}, []);
useEffect(() => {
if (choixDates.length > 0 && !choixDates.includes(form.date_remise)) {
setForm((f) => ({ ...f, date_remise: choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1] }));
}
}, [groupe?.jour_reunion]);
const update = (k, v) => setForm({ ...form, [k]: v });
const handleSubmit = async () => {
@ -73,12 +84,7 @@ export default function RemiseJeton() {
{/* 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_remise}
onChange={(e) => update('date_remise', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
/>
<MeetingDateSelect dates={choixDates} value={form.date_remise} onChange={(v) => update('date_remise', v)} />
</div>
{/* Type de jeton */}

View file

@ -0,0 +1,207 @@
import React, { useEffect, useState } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import { dateHeureReunionIso, datesReunion, dateLocale, labelDateReunion } from '../utils/datesReunion';
const TYPES_JETONS = [['desir', 'Désir'], ['24h', '24 heures'], ['1mois', '1 mois'], ['2mois', '2 mois'], ['3mois', '3 mois'], ['6mois', '6 mois'], ['9mois', '9 mois'], ['1an', '1 an'], ['multiple', 'Multiple']];
const INPUT = 'w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue';
export default function ReunionHebdomadaire() {
const { postes, groupe } = useStore();
const estExecutif = postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p));
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [membres, setMembres] = useState([]);
const [catalogue, setCatalogue] = useState([]);
const [reunions, setReunions] = useState([]);
const [dateSelection, setDateSelection] = useState(dateLocale());
const [dateReunion, setDateReunion] = useState(dateLocale());
const [theme, setTheme] = useState('');
const [annonces, setAnnonces] = useState('');
const [note, setNote] = useState('');
const [collecteMontant, setCollecteMontant] = useState('');
const [detenteurId, setDetenteurId] = useState('');
const [jetonType, setJetonType] = useState('');
const [jetonMembreId, setJetonMembreId] = useState('');
const [jetonAnnees, setJetonAnnees] = useState('');
const [litteratureId, setLitteratureId] = useState('');
const [quantite, setQuantite] = useState('1');
const [venteMontant, setVenteMontant] = useState('');
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const charger = async () => {
try {
const [m, l, r] = await Promise.all([
api.get('/membres/actifs'),
api.get('/litterature/'),
api.get('/reunions/?limite=80'),
]);
setMembres(m);
setCatalogue(l);
setReunions(r.filter((x) => x.type === 'reguliere'));
if (m.length > 0 && !detenteurId) setDetenteurId(m[0].id);
} catch (e) { setError(e.message); }
};
useEffect(() => { charger(); }, []);
useEffect(() => {
if (choixDates.length > 0) {
if (!choixDates.includes(dateSelection)) setDateSelection(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
if (!choixDates.includes(dateReunion)) setDateReunion(choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1]);
}
}, [groupe?.jour_reunion]);
const rapport = reunions.find((r) => r.date_reunion?.slice(0, 10) === dateSelection);
const dateSimple = () => dateReunion;
const choisirLitterature = (id) => {
setLitteratureId(id);
const item = catalogue.find((c) => c.id === id);
if (item?.prix_coutant) setVenteMontant((item.prix_coutant * parseInt(quantite || '1')).toFixed(2));
};
const changerQuantite = (valeur) => {
setQuantite(valeur);
const item = catalogue.find((c) => c.id === litteratureId);
if (item?.prix_coutant && valeur) setVenteMontant((item.prix_coutant * parseInt(valeur)).toFixed(2));
};
const imprimer = () => window.print();
const enregistrer = async () => {
setError('');
setMsg('');
setLoading(true);
const faits = [];
try {
if (theme.trim() || annonces.trim() || note.trim()) {
const annoncesList = annonces.split('\n').map((a) => a.trim()).filter(Boolean);
await api.post('/reunions/', {
type: 'reguliere',
date_reunion: dateHeureReunionIso(dateReunion, groupe),
theme: theme.trim() || 'Rencontre hebdomadaire',
annonces: annoncesList.length ? annoncesList : null,
note: note.trim() || null,
});
faits.push('rapport');
}
if (collecteMontant) {
await api.post('/collectes/', { date_collecte: dateSimple(), montant: parseFloat(collecteMontant), detenteur_id: detenteurId });
faits.push('collecte');
}
if (jetonType) {
const body = { type_jeton: jetonType, date_remise: dateSimple() };
if (jetonMembreId) body.membre_id = jetonMembreId;
if (jetonType === 'multiple') body.annees = parseInt(jetonAnnees);
await api.post('/jetons/', body);
faits.push('jeton');
}
if (litteratureId) {
await api.post('/ventes-litterature/', { litterature_id: litteratureId, quantite: parseInt(quantite), montant: parseFloat(venteMontant), date_vente: dateSimple() });
faits.push('littérature');
}
if (faits.length === 0) setError('Remplissez au moins une section');
else {
setMsg(`Enregistré : ${faits.join(', ')}`);
setDateSelection(dateSimple());
await charger();
}
} catch (e) { setError(e.message); }
setLoading(false);
};
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Rencontre hebdomadaire" sousTitre="Assistant et rapport imprimable" />
<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>}
<Card titre="Consulter le rapport">
<Champ label="Date">
<SelectDate value={dateSelection} onChange={setDateSelection} dates={choixDates} />
</Champ>
<button onClick={imprimer} className="w-full p-3 bg-gray-900 text-white rounded-xl text-sm font-bold">Imprimer format lettre</button>
</Card>
<div className="bg-white rounded-2xl p-5 shadow-sm print:shadow-none print:rounded-none print:p-0">
<h2 className="text-xl font-bold text-gray-900">Rapport de réunion hebdomadaire</h2>
<p className="text-sm text-gray-500">{dateSelection}</p>
{rapport ? (
<div className="mt-4 space-y-3 text-sm">
<p><strong>Thème :</strong> {rapport.theme || '____________________________'}</p>
<div>
<strong>Annonces :</strong>
{(rapport.annonces || []).length > 0 ? (
<ul className="list-disc ml-5 mt-1">{rapport.annonces.map((a, i) => <li key={i}>{a}</li>)}</ul>
) : <p className="mt-1 whitespace-pre-wrap">{"\n\n\n"}</p>}
</div>
<p><strong>Notes :</strong> {rapport.note || '____________________________________________'}</p>
</div>
) : (
<div className="mt-4 space-y-4 text-sm">
<p><strong>Thème :</strong> ____________________________________________</p>
<p><strong>Annonces :</strong></p>
<div className="h-28 border border-gray-200 rounded-xl" />
<p><strong>Notes :</strong></p>
<div className="h-20 border border-gray-200 rounded-xl" />
</div>
)}
<div className="grid grid-cols-2 gap-4 mt-6 text-sm">
<p>Collecte : __________ $</p>
<p>Détenteur : _______________</p>
<p>Jeton : __________________</p>
<p>Littérature : __________ $</p>
</div>
</div>
{estExecutif && (
<>
<Card titre="Renseigner le rapport">
<Champ label="Date"><SelectDate value={dateReunion} onChange={setDateReunion} dates={choixDates} /></Champ>
<Champ label="Thème"><input value={theme} onChange={(e) => setTheme(e.target.value)} placeholder="Thème abordé" className={INPUT} /></Champ>
<Champ label="Annonces"><textarea value={annonces} onChange={(e) => setAnnonces(e.target.value)} rows={3} placeholder="Une annonce par ligne" className={`${INPUT} resize-none`} /></Champ>
<Champ label="Notes"><textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} placeholder="Notes optionnelles" className={`${INPUT} resize-none`} /></Champ>
</Card>
<Card titre="Saisies liées">
<div className="grid grid-cols-2 gap-3">
<Champ label="Collecte"><input type="number" inputMode="decimal" step="0.01" value={collecteMontant} onChange={(e) => setCollecteMontant(e.target.value)} placeholder="0.00" className={INPUT} /></Champ>
<Champ label="Détenteur"><select value={detenteurId} onChange={(e) => setDetenteurId(e.target.value)} className={INPUT}>{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}</select></Champ>
</div>
<Champ label="Jeton"><select value={jetonType} onChange={(e) => setJetonType(e.target.value)} className={INPUT}><option value="">Aucun jeton</option>{TYPES_JETONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Champ>
<Champ label="Membre jeton"><select value={jetonMembreId} onChange={(e) => setJetonMembreId(e.target.value)} className={INPUT}><option value="">Anonyme / non applicable</option>{membres.map((m) => <option key={m.id} value={m.id}>{m.prenom}</option>)}</select></Champ>
{jetonType === 'multiple' && <Champ label="Années"><input type="number" min="2" value={jetonAnnees} onChange={(e) => setJetonAnnees(e.target.value)} className={INPUT} /></Champ>}
<Champ label="Littérature"><select value={litteratureId} onChange={(e) => choisirLitterature(e.target.value)} className={INPUT}><option value="">Aucune vente</option>{catalogue.map((c) => <option key={c.id} value={c.id}>{c.titre} ({c.quantite_stock})</option>)}</select></Champ>
<div className="grid grid-cols-2 gap-3">
<Champ label="Quantité"><input type="number" min="1" value={quantite} onChange={(e) => changerQuantite(e.target.value)} className={INPUT} /></Champ>
<Champ label="Montant"><input type="number" inputMode="decimal" step="0.01" value={venteMontant} onChange={(e) => setVenteMontant(e.target.value)} placeholder="0.00" className={INPUT} /></Champ>
</div>
</Card>
<button onClick={enregistrer} disabled={loading} className="w-full p-5 bg-aa-blue text-white rounded-2xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">{loading ? '...' : 'Enregistrer les sections remplies'}</button>
</>
)}
</div>
</div>
);
}
function Card({ titre, children }) {
return <div className="bg-white rounded-2xl p-4 shadow-sm space-y-3"><h2 className="font-bold text-gray-800">{titre}</h2>{children}</div>;
}
function Champ({ label, children }) {
return <label className="block"><span className="text-xs text-gray-400 block mb-1">{label}</span>{children}</label>;
}
function SelectDate({ value, onChange, dates }) {
if (!dates.length) {
return <div className="rounded-xl bg-gray-50 p-3 text-sm text-gray-500">Configurez le jour de réunion dans les paramètres du groupe.</div>;
}
return (
<select value={value} onChange={(e) => onChange(e.target.value)} className={INPUT}>
{dates.map((d) => <option key={d} value={d}>{labelDateReunion(d)}</option>)}
</select>
);
}

View file

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import EtatFinancier from './EtatFinancier';
const STATUT_COL = {
en_attente: { label: 'À confirmer', cls: 'bg-yellow-100 text-yellow-700' },
@ -8,17 +10,27 @@ const STATUT_COL = {
recu: { label: '✓ Reçue', cls: 'bg-green-100 text-green-700' },
};
const METHODES_DEPENSE = [
{ value: 'COLLECTE', label: 'Cash', emoji: '💰' },
{ value: 'CHEQUE', label: 'Chèque', emoji: '📝' },
{ value: 'VIREMENT', label: 'Virement', emoji: '🏦' },
];
export default function Tresorerie() {
const { estExecutif } = useStore();
const [soldes, setSoldes] = useState(null);
const [collectes, setCollectes] = useState([]);
const [contributions, setContributions] = useState([]);
const [depenses, setDepenses] = useState([]);
const [depensesAConfirmer, setDepensesAConfirmer] = useState([]);
const [operations, setOperations] = useState([]);
const [reserves, setReserves] = useState([]);
const [registre, setRegistre] = useState([]);
const [tab, setTab] = useState('soldes');
const [tab, setTab] = useState('etat');
const [msg, setMsg] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [generatingRapport, setGeneratingRapport] = useState(false);
const [registreMois, setRegistreMois] = useState('');
const [registreAnnee, setRegistreAnnee] = useState(String(new Date().getFullYear()));
const [registreType, setRegistreType] = useState('');
@ -27,11 +39,15 @@ export default function Tresorerie() {
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 [depotType, setDepotType] = useState('depot'); // depot | retrait | virement | reserve
const [depotProvenance, setDepotProvenance] = useState('encaisse');
const [reserveId, setReserveId] = useState(null);
const [reserveNom, setReserveNom] = useState('');
const [reserveMontant, setReserveMontant] = useState('');
const [reserveNote, setReserveNote] = useState('');
const [transfertSourceId, setTransfertSourceId] = useState('');
const [transfertDestinationId, setTransfertDestinationId] = useState('');
const [transfertMontant, setTransfertMontant] = useState('');
const registreQuery = () => {
const params = new URLSearchParams();
@ -44,25 +60,38 @@ export default function Tresorerie() {
const charger = async () => {
try {
const [s, c, contribs, o, res, r] = await Promise.all([
const [s, res, r] = await Promise.all([
api.get('/collectes/soldes'),
api.get('/collectes/?limite=20'),
api.get('/contributions/?limite=50'),
api.get('/banque/'),
api.get('/banque/reserves'),
api.get(registreQuery()),
]);
setSoldes(s);
setCollectes(c);
setContributions(contribs);
setOperations(o);
setReserves(res);
setRegistre(r);
const [contribs, deps, depsConf] = await Promise.all([
api.get('/contributions/?limite=50'),
api.get('/depenses/?limite=50'),
api.get('/depenses/?limite=50&confirmees=false'),
]);
setContributions(contribs);
setDepenses(deps);
setDepensesAConfirmer(depsConf);
if (estExecutif) {
const [c, o] = await Promise.all([
api.get('/collectes/?limite=20'),
api.get('/banque/'),
]);
setCollectes(c);
setOperations(o);
} else {
setCollectes([]);
setOperations([]);
}
} catch (e) { setError(e.message); }
setLoading(false);
};
useEffect(() => { charger(); }, []);
useEffect(() => { charger(); }, [estExecutif]);
const chargerRegistre = async () => {
setError('');
@ -100,18 +129,54 @@ export default function Tresorerie() {
} catch (e) { setError(e.message); }
};
const confirmerDepense = async (id, methode) => {
setError('');
try {
await api.post(`/depenses/${id}/confirmer?methode=${methode}`);
flash('Dépense traitée ✓');
await charger();
} catch (e) { setError(e.message); }
};
const rejeterDepense = async (id) => {
setError('');
try {
await api.delete(`/depenses/${id}`);
flash('Dépense rejetée');
await charger();
} catch (e) { setError(e.message); }
};
const confirmerDebitDepense = async (id) => {
setError('');
try {
await api.post(`/depenses/${id}/confirmer-debit`);
flash('Débit confirmé ✓');
await charger();
} catch (e) { setError(e.message); }
};
const enregistrerOperation = async () => {
if (depotType === 'reserve') {
await enregistrerReserve();
return;
}
if (depotType === 'virement') {
await transfererReserve();
return;
}
if (!depotMontant || parseFloat(depotMontant) <= 0) { setError('Montant invalide'); return; }
setError('');
try {
await api.post('/banque/', {
type: depotType,
provenance_depot: depotType === 'depot' ? depotProvenance : null,
montant: parseFloat(depotMontant),
date_operation: depotDate,
note: depotNote.trim() || null,
});
setDepotMontant(''); setDepotNote(''); setTab('soldes');
const labels = { depot: 'Dépôt', retrait: 'Retrait', releve: 'Relevé' };
setDepotMontant(''); setDepotNote(''); setTab('etat');
const labels = { depot: 'Dépôt', retrait: 'Retrait' };
flash(`${labels[depotType]} enregistré ✓`);
await charger();
} catch (e) { setError(e.message); }
@ -127,27 +192,25 @@ export default function Tresorerie() {
const editerReserve = (reserve) => {
setReserveId(reserve.id);
setReserveNom(reserve.nom);
setReserveMontant(String(reserve.montant));
setReserveMontant(String(reserve.montant || 0));
setReserveNote(reserve.note || '');
setTab('reserves');
setTab('banque');
setDepotType('reserve');
};
const enregistrerReserve = async () => {
const montant = parseFloat(reserveMontant);
if (!reserveNom.trim()) { setError('Nom de réserve requis'); return; }
if (Number.isNaN(montant) || montant < 0) { setError('Montant de réserve invalide'); return; }
setError('');
try {
const payload = {
nom: reserveNom.trim(),
montant,
note: reserveNote.trim() || null,
};
if (reserveId) {
await api.patch(`/banque/reserves/${reserveId}`, payload);
flash('Réserve modifiée ✓');
} else {
await api.post('/banque/reserves', payload);
await api.post('/banque/reserves', { ...payload, montant: 0 });
flash('Réserve créée ✓');
}
resetReserveForm();
@ -166,31 +229,90 @@ export default function Tresorerie() {
} catch (e) { setError(e.message); }
};
const aRecevoir = collectes.filter((c) => c.statut !== 'recu');
const transfererReserve = async () => {
const montant = parseFloat(transfertMontant);
if (!transfertSourceId || !transfertDestinationId) { setError('Sélectionnez deux réserves'); return; }
if (transfertSourceId === transfertDestinationId) { setError('La source et la destination doivent être différentes'); return; }
if (Number.isNaN(montant) || montant <= 0) { setError('Montant de transfert invalide'); return; }
setError('');
try {
await api.post('/banque/reserves/transfert', {
source_id: transfertSourceId,
destination_id: transfertDestinationId,
montant,
});
setTransfertMontant('');
flash('Transfert effectué ✓');
await charger();
} catch (e) { setError(e.message); }
};
const periodeRapportPrecedent = () => {
const d = new Date();
d.setDate(1);
d.setMonth(d.getMonth() - 1);
return { annee: d.getFullYear(), mois: d.getMonth() + 1 };
};
const telechargerRapportPdf = async () => {
const { annee, mois } = periodeRapportPrecedent();
setGeneratingRapport(true);
setError('');
try {
const token = api.getToken();
const res = await fetch(`/api/rapports/etat-resultats/${annee}/${mois}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Erreur de génération du rapport');
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `rapport_tresorerie_${annee}_${String(mois).padStart(2, '0')}.pdf`;
a.click();
URL.revokeObjectURL(a.href);
} catch (e) {
setError(e.message);
} finally {
setGeneratingRapport(false);
}
};
const aRecevoir = soldes?.en_possession_details || [];
const contributionsADebiter = contributions.filter((c) => c.engagee && !c.debitee);
const totalReserveForm = parseFloat(reserveMontant) || 0;
const reservesSansEdition = reserves
.filter((r) => r.id !== reserveId)
.reduce((total, r) => total + parseFloat(r.montant || 0), 0);
const reserveDepasseSolde = soldes && reservesSansEdition + totalReserveForm > soldes.solde_banque;
const depensesADebiter = depenses.filter((d) => d.confirme && !d.paye && ['VIREMENT', 'CHEQUE'].includes(d.methode));
const nbATraiter = aRecevoir.length
+ (soldes?.ventes_non_remises_details?.length || 0)
+ contributionsADebiter.length
+ depensesAConfirmer.length
+ depensesADebiter.length;
const disponibleVirement = soldes
? soldes.solde_banque - soldes.engagements_non_debites - soldes.total_reserves
: 0;
const optionsTransfert = [
{ id: 'disponible', nom: `Disponible (${parseFloat(disponibleVirement || 0).toFixed(2)} $)` },
...reserves.map((r) => ({ id: r.id, nom: `${r.nom} (${parseFloat(r.montant).toFixed(2)} $)` })),
];
const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
const rapportPrecedent = periodeRapportPrecedent();
const TYPES_REGISTRE = [
['', 'Tous les types'],
['collecte', 'Collectes'],
['don', 'Dons'],
['vente_litterature', 'Ventes littérature'],
['depense', 'Dépenses'],
['contribution', 'Contributions'],
['banque_depot', 'Dépôts bancaires'],
['banque_retrait', 'Retraits bancaires'],
['reserve_virement', 'Virements de réserve'],
];
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" />
<PageHeader titre="Trésorerie" sousTitre="Positions, résultats et 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>}
@ -199,12 +321,11 @@ export default function Tresorerie() {
{/* Onglets */}
<div className="flex gap-1 overflow-x-auto">
{[
['soldes', '💰 Soldes'],
['receptions', `📥 À traiter (${aRecevoir.length + (soldes?.ventes_non_remises_details?.length || 0) + contributionsADebiter.length})`],
['etat', 'Accueil'],
['resultats', 'Résultats'],
['registre', '📒 Registre'],
['reserves', `🧾 Réserves (${reserves.length})`],
['banque', '🏦 Opération'],
].map(([t, label]) => (
['banque', '🏦 Opérations'],
].filter(([t]) => estExecutif || ['etat', 'resultats', 'registre'].includes(t)).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'
@ -212,6 +333,168 @@ export default function Tresorerie() {
))}
</div>
{tab === 'etat' && (
<div className="space-y-4">
{soldes && (
<>
<section className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-extrabold text-gray-900 uppercase tracking-wide">Positions de trésorerie</h2>
<p className="text-xs text-gray-500">
Rapport PDF : {MOIS_NOMS[rapportPrecedent.mois]} {rapportPrecedent.annee}
</p>
</div>
<button
type="button"
onClick={telechargerRapportPdf}
disabled={generatingRapport}
className="min-h-[44px] px-4 rounded-xl bg-aa-blue text-white text-sm font-bold disabled:opacity-40 active:scale-[0.98]"
>
{generatingRapport ? 'Génération...' : 'Rapport PDF'}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<TresorerieBloc label="Banque" montant={soldes.solde_banque} couleur="blue" />
<TresorerieBloc label="Encaisse" montant={soldes.encaisse} couleur="green" />
<TresorerieBloc label="Réserves" montant={soldes.total_reserves} couleur="amber" />
<TresorerieBloc label="Engagé" montant={soldes.engagements_non_debites} couleur="orange" />
<div className="col-span-2">
<TresorerieBloc
label="Disponible"
montant={soldes.disponible}
couleur={soldes.disponible >= 0 ? 'green' : 'red'}
/>
</div>
</div>
{soldes.date_releve && (
<p className="text-xs text-gray-400 text-center">Dernier relevé : {soldes.date_releve}</p>
)}
</section>
<section className="bg-white rounded-2xl p-4 shadow-sm">
<h2 className="text-xl font-extrabold text-gray-900 uppercase tracking-wide mb-3">Réserves</h2>
{reserves.length > 0 ? (
<div className="space-y-2">
{reserves.map((reserve) => (
<div key={reserve.id} className="flex justify-between items-center gap-3 rounded-xl bg-amber-50 px-3 py-2">
<div className="min-w-0">
<p className="font-bold text-amber-900 truncate">{reserve.nom}</p>
{reserve.note && <p className="text-xs text-amber-700 truncate">{reserve.note}</p>}
</div>
<p className="font-bold text-amber-800 whitespace-nowrap">{parseFloat(reserve.montant || 0).toFixed(2)} $</p>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">Aucune réserve enregistrée.</p>
)}
</section>
</>
)}
<section className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between gap-3 mb-3">
<h2 className="text-lg font-extrabold text-gray-900 uppercase tracking-wide">À traiter</h2>
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2 py-1 rounded-full">{nbATraiter}</span>
</div>
{nbATraiter === 0 ? (
<p className="text-sm text-gray-500">Rien à traiter.</p>
) : (
<div className="space-y-4">
{depensesAConfirmer.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Dépenses soumises</h3>
{depensesAConfirmer.map((d) => (
estExecutif ? (
<DepenseConfirmCard
key={d.id}
depense={d}
soldeDisponible={soldes?.disponible || 0}
onConfirm={confirmerDepense}
onReject={rejeterDepense}
/>
) : (
<SimplePendingCard key={d.id} titre={d.quoi} montant={d.montant} meta={`${d.date_depense} · Soumis par ${d.saisi_par_prenom || '—'}`} />
)
))}
</div>
)}
{depensesADebiter.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Dépenses à débiter</h3>
{depensesADebiter.map((d) => (
<SimplePendingCard
key={d.id}
titre={d.quoi}
montant={d.montant}
meta={`${d.date_depense} · ${d.qui_prenom} · ${d.methode}`}
action={estExecutif ? 'Confirmer le débit' : null}
onAction={() => confirmerDebitDepense(d.id)}
/>
))}
</div>
)}
{contributionsADebiter.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Contributions à débiter</h3>
{contributionsADebiter.map((c) => (
<SimplePendingCard
key={c.id}
titre={c.destination_label}
montant={c.montant}
meta={`${c.date_envoi} · ${c.methode} · Saisi par ${c.saisi_par_prenom || '—'}`}
action={estExecutif ? 'Confirmer le débit' : null}
onAction={() => confirmerContribution(c.id)}
/>
))}
</div>
)}
{aRecevoir.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Collectes à recevoir</h3>
{aRecevoir.map((c) => (
<SimplePendingCard
key={c.id}
titre={`Collecte du ${c.date}`}
montant={c.montant}
meta={`Chez ${c.detenteur} · ${STATUT_COL[c.statut]?.label || c.statut}`}
action={estExecutif ? 'Accuser réception' : null}
onAction={() => accuserReception(c.id)}
/>
))}
</div>
)}
{soldes?.ventes_non_remises_details?.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Ventes de littérature à recevoir</h3>
{soldes.ventes_non_remises_details.map((v) => (
<SimplePendingCard
key={v.id}
titre={v.titre}
montant={v.montant}
meta={`${v.date} · Vendu par ${v.vendu_par}`}
action={estExecutif ? 'Confirmer la remise' : null}
onAction={() => recevoirVente(v.id)}
/>
))}
</div>
)}
</div>
)}
</section>
</div>
)}
{tab === 'resultats' && (
<EtatFinancier integre afficherTresorerie={false} afficherPdf={false} />
)}
{/* ── Soldes ── */}
{tab === 'soldes' && soldes && (
<div className="space-y-3">
@ -247,7 +530,7 @@ export default function Tresorerie() {
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-200">
<div className="flex items-center justify-between mb-2">
<p className="font-bold text-amber-700 text-sm">Réserves ({soldes.total_reserves.toFixed(2)} $)</p>
<button onClick={() => setTab('reserves')}
<button onClick={() => setTab('banque')}
className="text-xs font-bold text-amber-700 bg-amber-100 px-3 py-1 rounded-lg">
Gérer
</button>
@ -292,50 +575,144 @@ export default function Tresorerie() {
</div>
)}
{/* ── Réserves ── */}
{tab === 'reserves' && soldes && (
{/* ── Opérations ── */}
{tab === 'banque' && soldes && (
<div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-3 bg-blue-50 rounded-xl">
<p className="text-xs text-blue-600">Solde banque</p>
<p className="text-xl font-bold text-blue-700">{soldes.solde_banque.toFixed(2)} $</p>
</div>
<div className="p-3 bg-amber-50 rounded-xl">
<p className="text-xs text-amber-600">Réserves</p>
<p className="text-xl font-bold text-amber-700">{soldes.total_reserves.toFixed(2)} $</p>
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-2">Action</label>
<div className="grid grid-cols-4 gap-1">
{[
['depot', 'Dépôt'],
['retrait', 'Retrait'],
['virement', 'Virement'],
['reserve', 'Réserve'],
].map(([val, label]) => (
<button key={val} onClick={() => setDepotType(val)}
className={`min-h-[44px] rounded-xl text-xs font-bold border ${
depotType === val ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}>
{label}
</button>
))}
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Nom</label>
<input type="text" value={reserveNom} onChange={(e) => setReserveNom(e.target.value)}
placeholder="Ex: Prudence, anniversaire, loyer"
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 block mb-1">Montant réservé</label>
<input type="number" inputMode="decimal" step="0.01" min="0" value={reserveMontant}
onChange={(e) => setReserveMontant(e.target.value)}
placeholder="0.00"
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 block mb-1">Note (optionnel)</label>
<input type="text" value={reserveNote} onChange={(e) => setReserveNote(e.target.value)}
placeholder="But de la réserve"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
{reserveDepasseSolde && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-lg">
Le total des réserves ne peut pas dépasser le solde en banque.
{depotType === 'depot' && (
<>
<div className="grid grid-cols-2 gap-2">
{[
['encaisse', 'Encaisse'],
['don', 'Don'],
].map(([val, label]) => (
<button key={val} onClick={() => setDepotProvenance(val)}
className={`min-h-[44px] rounded-xl text-sm font-bold border ${
depotProvenance === val ? 'bg-green-600 text-white border-green-600' : 'bg-gray-50 text-gray-600 border-gray-200'
}`}>
{label}
</button>
))}
</div>
<p className="text-xs text-green-700 bg-green-50 p-2 rounded-lg">
{depotProvenance === 'encaisse'
? `Dépôt de l'encaisse vers la banque. Encaisse disponible : ${soldes.encaisse.toFixed(2)} $.`
: "Don déposé directement au compte bancaire, sans diminuer l'encaisse."}
</p>
)}
</>
)}
{depotType === 'retrait' && (
<p className="text-xs text-orange-700 bg-orange-50 p-2 rounded-lg">
Retrait de la banque vers l'encaisse.
</p>
)}
{(depotType === 'depot' || depotType === 'retrait') && (
<>
<div>
<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-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" />
</div>
</div>
<div>
<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>
<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 === 'depot' && depotProvenance === 'don' ? 'Ex: Don anniversaire' : '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-4 bg-blue-600 text-white rounded-xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition">
{depotType === 'depot' ? 'Enregistrer le dépôt' : 'Enregistrer le retrait'}
</button>
</>
)}
{depotType === 'virement' && (
<>
<div>
<label className="text-xs text-gray-400 block mb-1">Source</label>
<select value={transfertSourceId} onChange={(e) => setTransfertSourceId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">Choisir une source</option>
{optionsTransfert.map((o) => <option key={o.id} value={o.id}>{o.nom}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Destination</label>
<select value={transfertDestinationId} onChange={(e) => setTransfertDestinationId(e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
<option value="">Choisir une destination</option>
{optionsTransfert.map((o) => <option key={o.id} value={o.id}>{o.nom}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">Montant</label>
<input type="number" inputMode="decimal" step="0.01" min="0" value={transfertMontant}
onChange={(e) => setTransfertMontant(e.target.value)}
placeholder="0.00"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<button onClick={transfererReserve}
className="w-full p-4 bg-aa-blue text-white rounded-xl text-lg font-bold active:scale-[0.97] transition">
Enregistrer le virement
</button>
</>
)}
{depotType === 'reserve' && (
<>
<div>
<label className="text-xs text-gray-400 block mb-1">Nom</label>
<input type="text" value={reserveNom} onChange={(e) => setReserveNom(e.target.value)}
placeholder="Ex: Prudence, anniversaire, loyer"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
{reserveId ? (
<p className="text-xs text-amber-700 bg-amber-50 p-2 rounded-lg">
Solde actuel : {parseFloat(reserveMontant || 0).toFixed(2)} $. Utiliser l'action Virement pour modifier le montant.
</p>
) : (
<p className="text-xs text-amber-700 bg-amber-50 p-2 rounded-lg">
La réserve sera créée à 0.00 $. Elle pourra ensuite être alimentée avec l'action Virement.
</p>
)}
<div>
<label className="text-xs text-gray-400 block mb-1">Note (optionnel)</label>
<input type="text" value={reserveNote} onChange={(e) => setReserveNote(e.target.value)}
placeholder="But de la réserve"
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
</div>
<div className="flex gap-2">
<button onClick={enregistrerReserve} disabled={reserveDepasseSolde}
<button onClick={enregistrerReserve}
className="flex-1 p-3 bg-aa-blue text-white rounded-xl text-sm font-bold disabled:opacity-40 active:scale-[0.97] transition">
{reserveId ? 'Modifier' : 'Créer'}
</button>
@ -346,17 +723,18 @@ export default function Tresorerie() {
</button>
)}
</div>
</div>
</>
)}
</div>
{reserves.length === 0 ? (
<div className="text-center py-10">
<p className="text-gray-500">Aucune réserve</p>
</div>
) : (
<div className="space-y-3">
<div className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="text-sm font-bold text-gray-700 mb-3">Réserves</h3>
{reserves.length === 0 ? (
<p className="text-sm text-gray-500">Aucune réserve</p>
) : (
<div className="space-y-2">
{reserves.map((r) => (
<div key={r.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div key={r.id} className="rounded-xl bg-gray-50 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-bold text-gray-800 truncate">{r.nom}</p>
@ -370,12 +748,39 @@ export default function Tresorerie() {
Modifier
</button>
<button onClick={() => supprimerReserve(r.id)}
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-100">
disabled={Math.abs(parseFloat(r.montant || 0)) > 0.009}
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-100 disabled:opacity-40">
Supprimer
</button>
{Math.abs(parseFloat(r.montant || 0)) > 0.009 && (
<p className="text-xs text-gray-400 self-center">Vider avant suppression</p>
)}
</div>
</div>
))}
</div>
)}
</div>
{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>
@ -384,6 +789,53 @@ export default function Tresorerie() {
{/* ── Réceptions et débits à confirmer ── */}
{tab === 'receptions' && (
<div className="space-y-4">
{/* Dépenses à qualifier */}
{depensesAConfirmer.length > 0 && (
<div>
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 px-1">
Dépenses à traiter
</h3>
<div className="space-y-3">
{depensesAConfirmer.map((d) => (
<DepenseConfirmCard
key={d.id}
depense={d}
soldeDisponible={soldes?.disponible || 0}
onConfirm={confirmerDepense}
onReject={rejeterDepense}
/>
))}
</div>
</div>
)}
{/* Dépenses bancaires à débiter */}
{depensesADebiter.length > 0 && (
<div>
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 px-1">
Dépenses à débiter
</h3>
<div className="space-y-3">
{depensesADebiter.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>
<button onClick={() => confirmerDebitDepense(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>
</div>
)}
{/* Contributions à débiter */}
{contributionsADebiter.length > 0 && (
<div>
@ -463,10 +915,10 @@ export default function Tresorerie() {
</div>
)}
{aRecevoir.length === 0 && contributionsADebiter.length === 0 && (!soldes?.ventes_non_remises_details || soldes.ventes_non_remises_details.length === 0) && (
{nbATraiter === 0 && (
<div className="text-center py-12">
<p className="text-4xl mb-2">✅</p>
<p className="text-gray-500">Tout est reçu</p>
<p className="text-gray-500">Rien à traiter</p>
</div>
)}
</div>
@ -527,97 +979,96 @@ export default function Tresorerie() {
</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>
)}
{depotType === 'depot' && (
<p className="text-xs text-green-600 mt-2 bg-green-50 p-2 rounded-lg">
Dépôt de l'encaisse vers la banque. Encaisse disponible : {soldes?.encaisse?.toFixed(2) || '0.00'} $.
</p>
)}
{depotType === 'retrait' && (
<p className="text-xs text-orange-600 mt-2 bg-orange-50 p-2 rounded-lg">
Retrait de la banque vers l'encaisse.
</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>
);
}
function SimplePendingCard({ titre, montant, meta, action, onAction }) {
return (
<div className="rounded-xl bg-gray-50 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-bold text-gray-800 truncate">{titre}</p>
<p className="text-xs text-gray-500 mt-1">{meta}</p>
</div>
<p className="font-bold text-orange-700 whitespace-nowrap">{parseFloat(montant).toFixed(2)} $</p>
</div>
{action && (
<button onClick={onAction}
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">
{action}
</button>
)}
</div>
);
}
function TresorerieBloc({ 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',
orange: 'bg-orange-50 text-orange-700',
amber: 'bg-amber-50 text-amber-700',
};
const valeur = Number(montant);
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">{Number.isFinite(valeur) ? valeur.toFixed(2) : '0.00'} $</p>
</div>
);
}
function DepenseConfirmCard({ depense, soldeDisponible, onConfirm, onReject }) {
const [methode, setMethode] = useState('');
const montant = parseFloat(depense.montant);
const engagementBancaire = ['CHEQUE', 'VIREMENT'].includes(methode);
const soldeInsuffisant = engagementBancaire && montant > soldeDisponible;
const peutTraiter = methode && !soldeInsuffisant;
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">{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-3 gap-1">
{METHODES_DEPENSE.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>
{soldeInsuffisant && (
<p className="mt-2 text-xs text-red-600">
Solde bancaire disponible insuffisant pour engager cette dépense.
</p>
)}
{engagementBancaire && !soldeInsuffisant && (
<p className="mt-2 text-xs text-gray-400">
Ce montant sera engagé jusqu'à la confirmation du débit bancaire.
</p>
)}
</div>
<div className="grid grid-cols-2 gap-2 mt-3">
<button onClick={() => onReject(depense.id)}
className="py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97] transition">
Rejeter
</button>
<button onClick={() => peutTraiter && onConfirm(depense.id, methode)}
disabled={!peutTraiter}
className="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">
Traiter ({methode || '...'})
</button>
</div>
</div>
);

View file

@ -1,14 +1,19 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { datesReunion, dateLocale } from '../utils/datesReunion';
export default function VenteLitterature() {
const { groupe } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [form, setForm] = useState({
litterature_id: '',
quantite: '1',
montant: '',
date_vente: new Date().toISOString().slice(0, 10),
date_vente: dateLocale(),
});
const [catalogue, setCatalogue] = useState([]);
const [loading, setLoading] = useState(false);
@ -19,6 +24,12 @@ export default function VenteLitterature() {
api.get('/litterature/').then(setCatalogue).catch(() => setCatalogue([]));
}, []);
useEffect(() => {
if (choixDates.length > 0 && !choixDates.includes(form.date_vente)) {
setForm((f) => ({ ...f, date_vente: choixDates.find((d) => d >= dateLocale()) || choixDates[choixDates.length - 1] }));
}
}, [groupe?.jour_reunion]);
const update = (k, v) => setForm({ ...form, [k]: v });
// Auto-remplir le montant quand on sélectionne un titre
@ -71,12 +82,7 @@ export default function VenteLitterature() {
{/* 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_vente}
onChange={(e) => update('date_vente', e.target.value)}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
/>
<MeetingDateSelect dates={choixDates} value={form.date_vente} onChange={(v) => update('date_vente', v)} />
</div>
{/* Titre (dropdown) */}

View file

@ -1,176 +0,0 @@
import React, { useState, useEffect } from 'react';
import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader';
export default function VotesOuverts() {
const [sondages, setSondages] = useState([]);
const [filtre, setFiltre] = useState('ouvert');
const [loading, setLoading] = useState(true);
const postes = useStore((s) => s.postes);
const isExecutif = postes.some((p) => ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p));
const charger = async () => {
setLoading(true);
try {
const url = filtre ? `/sondages/?statut=${filtre}` : '/sondages/';
const data = await api.get(url);
setSondages(data);
} catch { setSondages([]); }
setLoading(false);
};
useEffect(() => { charger(); }, [filtre]);
return (
<div className="min-h-screen bg-gray-100">
<PageHeader titre="Votes ouverts" sousTitre="C6 — Conscience de groupe" />
<div className="p-4 space-y-4">
{/* Filtres */}
<div className="flex gap-2">
{['ouvert', 'ferme', ''].map((f) => (
<button key={f} onClick={() => setFiltre(f)}
className={`flex-1 py-2 rounded-xl text-sm font-bold border-2 ${
filtre === f ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-500 border-gray-200'
}`}>
{f === 'ouvert' ? 'Ouverts' : f === 'ferme' ? 'Fermés' : 'Tous'}
</button>
))}
</div>
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
{sondages.map((s) => (
<SondageCard key={s.id} sondage={s} isExecutif={isExecutif} onUpdate={charger} />
))}
{!loading && sondages.length === 0 && (
<div className="text-center py-12">
<span className="text-4xl block mb-2">🗳️</span>
<p className="text-gray-400">Aucun sondage {filtre === 'ouvert' ? 'ouvert' : ''}</p>
</div>
)}
</div>
</div>
);
}
function SondageCard({ sondage: s, isExecutif, onUpdate }) {
const [voting, setVoting] = useState(false);
const [selected, setSelected] = useState(null);
const [error, setError] = useState('');
const maxVotes = Math.max(...s.options.map((o) => o.nb_votes), 1);
const handleVote = async () => {
if (!selected) return;
setVoting(true);
setError('');
try {
await api.post(`/sondages/${s.id}/voter`, { option_id: selected });
onUpdate();
} catch (e) {
setError(e.message);
}
setVoting(false);
};
const changerStatut = async (statut) => {
try {
await api.patch(`/sondages/${s.id}`, { statut });
onUpdate();
} catch (e) {
setError(e.message);
}
};
const statutColor = {
brouillon: 'bg-gray-100 text-gray-600',
ouvert: 'bg-green-100 text-green-700',
ferme: 'bg-red-100 text-red-600',
};
return (
<div className="bg-white rounded-2xl p-4 shadow-sm border-2 border-gray-100">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<p className="font-bold text-sm">{s.titre}</p>
{s.description && <p className="text-xs text-gray-400 mt-1">{s.description}</p>}
</div>
<span className={`text-xs px-2 py-1 rounded-full font-bold ${statutColor[s.statut] || ''}`}>
{s.statut}
</span>
</div>
<p className="text-xs text-gray-300 mb-3">
{s.type === 'oui_non' ? '👍 Oui/Non' : s.type === 'unanimite' ? '🤝 Unanimité' : '📋 Choix multiple'}
{' · '}{s.total_votes} vote{s.total_votes !== 1 ? 's' : ''}
{s.cree_par_prenom && ` · par ${s.cree_par_prenom}`}
</p>
{/* Options avec barres de résultat */}
<div className="space-y-2">
{s.options.map((opt) => {
const pct = s.total_votes > 0 ? Math.round((opt.nb_votes / s.total_votes) * 100) : 0;
const canSelect = s.statut === 'ouvert' && !s.a_vote;
return (
<button
key={opt.id}
onClick={() => canSelect && setSelected(opt.id)}
disabled={!canSelect}
className={`w-full text-left rounded-xl p-3 transition border-2 ${
selected === opt.id
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-100 bg-gray-50'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-bold">{opt.texte}</span>
<span className="text-xs text-gray-400">{opt.nb_votes} ({pct}%)</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all"
style={{ width: `${(opt.nb_votes / maxVotes) * 100}%` }}
/>
</div>
</button>
);
})}
</div>
{/* Bouton voter */}
{s.statut === 'ouvert' && !s.a_vote && (
<button onClick={handleVote} disabled={!selected || voting}
className="w-full mt-3 p-3 bg-indigo-600 text-white rounded-xl font-bold disabled:opacity-40 active:scale-[0.97] transition">
{voting ? '...' : '🗳️ Voter'}
</button>
)}
{s.a_vote && s.statut === 'ouvert' && (
<p className="text-center text-xs text-green-600 mt-3 font-bold">✓ Vous avez voté</p>
)}
{error && <p className="text-red-500 text-xs mt-2">{error}</p>}
{/* Actions exécutif */}
{isExecutif && (
<div className="flex gap-2 mt-3 pt-3 border-t border-gray-100">
{s.statut === 'brouillon' && (
<button onClick={() => changerStatut('ouvert')}
className="flex-1 p-2 text-xs font-bold bg-green-100 text-green-700 rounded-lg">
Ouvrir
</button>
)}
{s.statut === 'ouvert' && (
<button onClick={() => changerStatut('ferme')}
className="flex-1 p-2 text-xs font-bold bg-red-100 text-red-600 rounded-lg">
Fermer
</button>
)}
</div>
)}
</div>
);
}

View file

@ -10,6 +10,8 @@ export const useStore = create((set, get) => ({
// Accueil
postes: [],
estExecutif: false,
estSysadmin: false,
groupe: null,
modulesConsultation: [],
modulesSaisie: [],
@ -19,11 +21,16 @@ export const useStore = create((set, get) => ({
loading: false,
error: null,
page: localStorage.getItem('gm_page') || 'login',
navContext: localStorage.getItem('gm_nav_context') || 'accueil',
setPage: (page) => {
localStorage.setItem('gm_page', page);
set({ page, error: null });
},
setNavContext: (navContext) => {
localStorage.setItem('gm_nav_context', navContext);
set({ navContext });
},
setError: (error) => set({ error }),
loadGroupes: async () => {
@ -86,16 +93,36 @@ export const useStore = create((set, get) => ({
}
},
impersoner: async (membreId) => {
set({ loading: true, error: null });
try {
const data = await api.post(`/auth/impersoner/${membreId}`);
api.setToken(data.access_token, false);
set({
token: data.access_token,
membre: data.membre,
loading: false,
});
await get().loadAccueil();
get().setPage('accueil');
} catch (e) {
set({ loading: false, error: e.message });
throw e;
}
},
loadAccueil: async () => {
try {
const data = await api.get('/accueil/');
set({
postes: data.postes,
estExecutif: !!data.est_executif,
groupe: data.groupe,
modulesConsultation: data.modules_consultation,
modulesSaisie: data.modules_saisie,
modulesGestion: data.modules_gestion,
membre: data.membre,
estSysadmin: !!data.membre?.is_sysadmin,
});
} catch (e) {
set({ error: e.message });
@ -106,16 +133,20 @@ export const useStore = create((set, get) => ({
api.setToken(null);
localStorage.removeItem('gm_groupe_id');
localStorage.removeItem('gm_page');
localStorage.removeItem('gm_nav_context');
set({
token: null,
membre: null,
groupe: null,
groupeId: null,
postes: [],
estExecutif: false,
estSysadmin: false,
modulesConsultation: [],
modulesSaisie: [],
modulesGestion: [],
page: 'login',
navContext: 'accueil',
});
},
}));

View file

@ -0,0 +1,105 @@
const JOURS_SEMAINE = {
dimanche: 0,
lundi: 1,
mardi: 2,
mercredi: 3,
jeudi: 4,
vendredi: 5,
samedi: 6,
};
export const ORDRES_ASSEMBLEE = [
['premiere', 'Première réunion du mois'],
['deuxieme', 'Deuxième réunion du mois'],
['troisieme', 'Troisième réunion du mois'],
['quatrieme', 'Quatrième réunion du mois'],
['derniere', 'Dernière réunion du mois'],
];
export function dateLocale(date = new Date()) {
const annee = date.getFullYear();
const mois = String(date.getMonth() + 1).padStart(2, '0');
const jour = String(date.getDate()).padStart(2, '0');
return `${annee}-${mois}-${jour}`;
}
export function labelDateReunion(dateStr, avecAnnee = true) {
return new Date(`${dateStr}T12:00:00`).toLocaleDateString('fr-CA', {
weekday: 'long',
day: 'numeric',
month: 'long',
...(avecAnnee ? { year: 'numeric' } : {}),
});
}
export function heureReunion(groupe) {
return (groupe?.heure_reunion || '19:00').slice(0, 5);
}
export function dateHeureReunionIso(dateStr, groupe) {
return `${dateStr}T${heureReunion(groupe)}:00`;
}
export function indexJourReunion(groupe) {
if (!groupe?.jour_reunion) return null;
const jour = JOURS_SEMAINE[groupe.jour_reunion.toLowerCase()];
return typeof jour === 'number' ? jour : null;
}
export function datesReunion(groupe, { joursAvant = 365, joursApres = 180, exclure = new Set() } = {}) {
const jourReunion = indexJourReunion(groupe);
if (jourReunion === null) return [];
const depart = new Date();
depart.setDate(depart.getDate() - joursAvant);
const total = joursAvant + joursApres;
const dates = [];
for (let i = 0; i <= total; i += 1) {
const d = new Date(depart);
d.setDate(depart.getDate() + i);
const valeur = dateLocale(d);
if (d.getDay() === jourReunion && !exclure.has(valeur)) dates.push(valeur);
}
return dates;
}
export function dernieresDatesReunion(groupe, { nombre = 52, exclure = new Set() } = {}) {
const jourReunion = indexJourReunion(groupe);
if (jourReunion === null) return [];
const dates = [];
const d = new Date();
while (dates.length < nombre) {
const valeur = dateLocale(d);
if (d.getDay() === jourReunion && !exclure.has(valeur)) dates.push(valeur);
d.setDate(d.getDate() - 1);
}
return dates;
}
export function dateAssembleeAffaires(groupe, annee, moisIndexZero) {
const jourReunion = indexJourReunion(groupe);
if (jourReunion === null) return null;
const ordre = groupe?.assemblee_affaires_ordre || 'premiere';
const dates = [];
const d = new Date(annee, moisIndexZero, 1);
while (d.getMonth() === moisIndexZero) {
if (d.getDay() === jourReunion) dates.push(dateLocale(d));
d.setDate(d.getDate() + 1);
}
if (dates.length === 0) return null;
if (ordre === 'derniere') return dates[dates.length - 1];
const index = { premiere: 0, deuxieme: 1, troisieme: 2, quatrieme: 3 }[ordre] ?? 0;
return dates[index] || dates[dates.length - 1];
}
export function datesAssembleesAffaires(groupe, { moisAvant = 18, moisApres = 12 } = {}) {
const jourReunion = indexJourReunion(groupe);
if (jourReunion === null) return [];
const courant = new Date();
const dates = [];
for (let offset = -moisAvant; offset <= moisApres; offset += 1) {
const d = new Date(courant.getFullYear(), courant.getMonth() + offset, 1);
const date = dateAssembleeAffaires(groupe, d.getFullYear(), d.getMonth());
if (date) dates.push(date);
}
return dates;
}

View file

@ -0,0 +1,56 @@
import { api } from '../stores/api';
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export function pushSupported() {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}
export async function getPushSubscription() {
if (!pushSupported()) return null;
const registration = await navigator.serviceWorker.getRegistration('/push/');
if (!registration) return null;
return registration.pushManager.getSubscription();
}
export async function enablePushNotifications() {
if (!pushSupported()) {
throw new Error("Ce navigateur ne supporte pas les notifications push");
}
const config = await api.get('/notifications/configuration');
if (!config.enabled || !config.public_key) {
throw new Error("Les notifications ne sont pas configurées sur ce serveur");
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error("La permission de notification a été refusée");
}
const registration = await navigator.serviceWorker.register('/push-sw.js', { scope: '/push/' });
const existing = await registration.pushManager.getSubscription();
const subscription = existing || await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(config.public_key),
});
await api.post('/notifications/abonnement', subscription.toJSON());
return subscription;
}
export async function disablePushNotifications() {
const subscription = await getPushSubscription();
if (!subscription) return;
await api.request('DELETE', `/notifications/abonnement?endpoint=${encodeURIComponent(subscription.endpoint)}`);
await subscription.unsubscribe();
}
export async function sendTestNotification() {
return api.post('/notifications/test', {});
}

View file

@ -23,6 +23,9 @@ export default defineConfig({
],
},
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
runtimeCaching: [
{