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/ node_modules/
dist/ dist/
__pycache__/
*.py[cod]
*.retry *.retry
ansible/group_vars/vault.yml 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) | | Frontend | React 18 + Vite (PWA installable) |
| UI | Tailwind CSS (mobile-first) | | UI | Tailwind CSS (mobile-first) |
| État | Zustand | | État | Zustand |
| Backend | FastAPI (Python) async, 82 routes | | Backend | FastAPI (Python) async |
| DB | PostgreSQL + SQLAlchemy async (23+ tables) | | DB | PostgreSQL + SQLAlchemy async |
| Auth | JWT 30 jours + bcrypt 12 rounds (prénom + PIN) | | Auth | JWT 30 jours + bcrypt 12 rounds (prénom + PIN) |
| PDF | WeasyPrint | | PDF | WeasyPrint |
| Serveur | NGINX (statiques + reverse proxy HTTPS) | | Serveur | NGINX (statiques + reverse proxy HTTPS) |
## Modules ## Modules
### Consultation (C1-C7) — Tous les membres ### Consultation — Tous les membres
- **C1** Rapports PDF — finances, PV, résolutions, rapport complet - **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 - **C4** Littérature — inventaire avec prix, alerte stock bas
- **C5** Décisions en cours — suivi des résolutions - **C5** Gouvernance — décisions du groupe, propositions, résolutions, nominations et contributions
- **C6** Votes ouverts — sondages de conscience de groupe, vote anonyme
- **C7** Mon profil — modifier, changer PIN, notifications - **C7** Mon profil — modifier, changer PIN, notifications
- **C8** Propositions — soumettre, seconder, voter (créables en tout temps) - **C9** Postes à pourvoir — postes vacants, candidatures
- **C9** S'impliquer — postes vacants, candidatures
### Saisie hebdomadaire (S1-S5) ### Saisie hebdomadaire (S1-S5)
- **S1** Collecte 7e Tradition — saisie le jour de réunion, confirmation par un second membre, historique mensuel avec totaux - **S0** Réunion hebdomadaire — formulaire regroupé pour rapport, collecte, jeton et littérature
- **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é) - **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 - **S2** Remise de jeton — 9 types, historique
- **S3** Vente littérature — stock auto-décrémenté, montant à remettre au trésorier - **S3** Vente littérature — stock auto-décrémenté, montant à remettre au trésorier
- **S4** Rapport de réunion — thème + annonces - **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) ### 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 - **M1** Rédiger PV — auto-renseigné depuis les propositions secondées, décisions structurées
- **M2** Présences réunion d'affaires - **M2** Présences réunion d'affaires
- **M3** Rapport RSG — template 3 sections - **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 ### Gestion — Exécutif
- **G1** Inviter un membre — code 48h - **G2** Gestion des membres — membres, invitations, droit à l'oubli
- **G2** Gérer les membres — fiche complète, droit à l'oubli
- **G6** Lancer un sondage — oui/non, choix multiple, unanimité
- **G9** Paramètres du groupe — nom, horaire, adresse, format - **G9** Paramètres du groupe — nom, horaire, adresse, format
- **G11** Gestion des mandats — affecter, terminer, planifier les rotations - **G11** Gestion des mandats — affecter, terminer, planifier les rotations
- **G12** Gestion des postes — créer, abolir, candidatures, accès aux modules - **G12** Gestion des postes — créer, abolir, candidatures, accès aux modules
## Trésorerie ## 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é - **Banque** et **encaisse** sont calculées depuis les écritures comptables.
- **Disponible** = solde banque − engagements non débités − réserves - **Réserves** est calculé depuis l'historique `mouvements_reserves`.
- **Réserves** = réserve prudente (500 $) + réserve anniversaire (20 $/mois) - Toute dépense ou contribution par chèque/virement reste un engagement jusqu'à confirmation du débit.
- **Encaisse** = collectes reçues + ventes remises − dépôts − dépenses cash - 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 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 ».
Les propositions peuvent être créées en tout temps par n'importe quel membre, sans nécessiter une réunion d'affaires. Le cycle est : proposée → secondée → votée (adoptée/rejetée). Lorsqu'adoptée, la proposition devient une résolution numérotée (RES-2026-001) et peut être associée à l'assemblée d'affaires lors du vote.
Le PV de la réunion d'affaires s'auto-renseigne depuis les propositions secondées en attente de vote. 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) backend/ API FastAPI (Python)
app/ app/
core/ Config, DB, sécurité core/ Config, DB, sécurité, rôles, règles communes
models/ Modèles SQLAlchemy (23+ tables) services/ Logique métier réutilisable
routers/ Routes API (26 groupes) models/ Modèles SQLAlchemy
routers/ Routes API
schemas/ Schémas Pydantic schemas/ Schémas Pydantic
frontend/ React + Vite + Tailwind frontend/ React + Vite + Tailwind
src/ src/
@ -103,6 +100,7 @@ docs/ DEPLOIEMENT.md, RUNBOOK.md
## Documentation ## Documentation
- [Guide utilisateur](docs/UTILISATEURS.md) - [Guide utilisateur](docs/UTILISATEURS.md)
- [Architecture technique](docs/ARCHITECTURE.md)
- [Déploiement Ansible](docs/ANSIBLE.md) - [Déploiement Ansible](docs/ANSIBLE.md)
- [Runbook d'exploitation](docs/RUNBOOK.md) - [Runbook d'exploitation](docs/RUNBOOK.md)

View file

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

View file

@ -3,4 +3,6 @@ DATABASE_URL={{ groupe_meditation_database_url }}
JWT_SECRET={{ groupe_meditation_jwt_secret }} JWT_SECRET={{ groupe_meditation_jwt_secret }}
CORS_ORIGINS={{ groupe_meditation_cors_origins | to_json }} CORS_ORIGINS={{ groupe_meditation_cors_origins | to_json }}
INVITATION_EXPIRE_HOURS={{ groupe_meditation_invitation_expire_hours }} 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 }}; root {{ groupe_meditation_web_root }};
index index.html; 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)$ { location ~* \.(js|css|png|jpg|svg|woff2|ico)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable" always;
try_files $uri =404; try_files $uri =404;
} }
@ -28,10 +43,5 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
client_max_body_size 5M; 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", "https://groupe-meditation.87-16.org",
] ]
INVITATION_EXPIRE_HOURS: int = 48 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: class Config:
env_file = str(_ENV_FILE) 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")) 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 jours = expire_jours or settings.JWT_EXPIRE_DAYS
expire = datetime.now(timezone.utc) + timedelta(days=jours) expire = datetime.now(timezone.utc) + timedelta(days=jours)
payload = {"sub": str(membre_id), "grp": str(groupe_id), "exp": expire} 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) 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é") 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( async def get_current_membre(
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -51,6 +57,8 @@ async def get_current_membre(
raise HTTPException(status_code=401, detail="Membre introuvable ou inactif") raise HTTPException(status_code=401, detail="Membre introuvable ou inactif")
if groupe_id and str(membre.groupe_id) != str(groupe_id): if groupe_id and str(membre.groupe_id) != str(groupe_id):
raise HTTPException(status_code=401, detail="Session invalide pour ce groupe") raise HTTPException(status_code=401, detail="Session invalide pour ce groupe")
if payload.get("adm"):
setattr(membre, "_sysadmin_session", True)
return membre return membre
@ -58,6 +66,8 @@ async def require_executif(
membre=Depends(get_current_membre), membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
if is_sysadmin(membre):
return membre
from app.models.affectation import Affectation from app.models.affectation import Affectation
from app.models.poste import Poste from app.models.poste import Poste
result = await db.execute( result = await db.execute(

View file

@ -6,10 +6,12 @@ District 87-16, Région 87
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from app.core.config import settings from app.core.config import settings
from app.core.audit import AuditMiddleware
from app.core.database import engine, Base 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 # Import models pour que create_all crée toutes les tables
from app.models.sync_log import SyncLog # noqa: F401 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.candidature import Candidature # noqa: F401
from app.models.poste_module import PosteModule # noqa: F401 from app.models.poste_module import PosteModule # noqa: F401
from app.models.reserve import Reserve # 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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 yield
await engine.dispose() await engine.dispose()
@ -43,6 +60,7 @@ app.add_middleware(
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
app.add_middleware(AuditMiddleware)
# Montage des routes # Montage des routes
app.include_router(auth.router, prefix="/api") 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(collectes.router, prefix="/api")
app.include_router(reunions.router, prefix="/api") app.include_router(reunions.router, prefix="/api")
app.include_router(depenses.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(calendrier.router, prefix="/api")
app.include_router(rapports.router, prefix="/api") app.include_router(rapports.router, prefix="/api")
app.include_router(rotations.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(ventes_litterature.router, prefix="/api")
app.include_router(contributions.router, prefix="/api") app.include_router(contributions.router, prefix="/api")
app.include_router(config_repartition.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(propositions.router, prefix="/api")
app.include_router(operations_bancaires.router, prefix="/api") app.include_router(operations_bancaires.router, prefix="/api")
app.include_router(evenements.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") @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) adresse_local: Mapped[str | None] = mapped_column(Text)
jour_reunion: Mapped[str | None] = mapped_column(String(10)) jour_reunion: Mapped[str | None] = mapped_column(String(10))
heure_reunion: Mapped[time | None] = mapped_column(Time) 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) format_reunion: Mapped[str | None] = mapped_column(Text)
lien_aa87: Mapped[str] = mapped_column(String(255), default="https://aa87.org") 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)) 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") groupe = relationship("Groupe", back_populates="membres")
affectations = relationship("Affectation", back_populates="membre", foreign_keys="Affectation.membre_id") 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) 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) reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"), nullable=True)
type_proposition: Mapped[str] = mapped_column( 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) sujet: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
@ -23,8 +23,11 @@ class Proposition(Base):
SAEnum("proposee", "secondee", "adoptee", "rejetee", name="statut_proposition"), SAEnum("proposee", "secondee", "adoptee", "rejetee", name="statut_proposition"),
default="proposee", 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_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) note: Mapped[str | None] = mapped_column(Text)
# Lien vers la candidature (si type=nomination) # Lien vers la candidature (si type=nomination)
candidature_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("candidatures.id")) 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 sqlalchemy import select
from app.core.database import get_db 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.affectation import Affectation
from app.models.poste import Poste from app.models.poste import Poste
from app.models.groupe import Groupe 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="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="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="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="C5", nom="Gouvernance", description="Décisions du groupe", icone="📌", couleur="red"),
ModuleTuile(code="C6", nom="Votes ouverts", description="Conscience de groupe", icone="🗳️", couleur="indigo"),
ModuleTuile(code="C7", nom="Mon profil", description="Modifier, PIN, notifications", icone="👤", couleur="gray"), ModuleTuile(code="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="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 = {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 = [ 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"), ModuleTuile(code="S5", nom="Dépense", description="Soumettre une dépense", icone="💸", couleur="red"),
] ]
# Modules de saisie par poste # Modules de saisie par poste
MODULES_SAISIE = { MODULES_SAISIE = {
"Trésorier(ière)": [ "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"), ModuleTuile(code="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"),
], ],
"Secrétaire": [ "Secrétaire": [
@ -65,10 +63,10 @@ MODULES_SAISIE = {
MODULES_GESTION = [ MODULES_GESTION = [
ModuleTuile(code="G2", nom="Gestion des membres", description="Membres, invitations, désactivation", icone="👥", couleur="blue"), 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="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="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="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()]: for _modules in [MODULES_SAISIE_TOUS, MODULES_GESTION, *MODULES_SAISIE.values()]:
@ -82,6 +80,34 @@ async def accueil(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Écran d'accueil dynamique — retourne les modules selon les postes du membre.""" """É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 # Récupérer les affectations actives
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
@ -106,13 +132,15 @@ async def accueil(
# Modules de saisie : collecte pour tous + modules par poste # Modules de saisie : collecte pour tous + modules par poste
modules_saisie = list(MODULES_SAISIE_TOUS) 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 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 [] modules_configures = result.scalars().all() if result else []
if modules_configures: if modules_configures:
codes = sorted({pm.module_code for pm in modules_configures}) codes = sorted({pm.module_code for pm in modules_configures})
for code in codes: for code in codes:
if code == "S1" and "executif" not in postes_categories:
continue
m = MODULES_PAR_CODE.get(code) m = MODULES_PAR_CODE.get(code)
if m and not code.startswith("G") and m.code not in codes_vus: if m and not code.startswith("G") and m.code not in codes_vus:
modules_saisie.append(m) modules_saisie.append(m)
@ -123,6 +151,13 @@ async def accueil(
if m.code not in codes_vus: if m.code not in codes_vus:
modules_saisie.append(m) modules_saisie.append(m)
codes_vus.add(m.code) 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 # Modules de gestion : seulement si exécutif
if modules_configures: if modules_configures:
@ -143,12 +178,14 @@ async def accueil(
"nom": groupe.nom, "nom": groupe.nom,
"jour_reunion": groupe.jour_reunion, "jour_reunion": groupe.jour_reunion,
"heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None, "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, "adresse_local": groupe.adresse_local,
} }
return AccueilResponse( return AccueilResponse(
membre=MembrePublic.model_validate(membre), membre=MembrePublic.model_validate(membre),
postes=postes_noms, postes=postes_noms,
est_executif="executif" in postes_categories,
modules_consultation=modules_consultation, modules_consultation=modules_consultation,
modules_saisie=modules_saisie, modules_saisie=modules_saisie,
modules_gestion=modules_gestion, modules_gestion=modules_gestion,

View file

@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from app.core.database import get_db 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.membre import Membre
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.invitation import Invitation from app.models.invitation import Invitation
@ -36,6 +36,7 @@ async def liste_groupes(db: AsyncSession = Depends(get_db)):
"adresse_local": g.adresse_local, "adresse_local": g.adresse_local,
"jour_reunion": g.jour_reunion, "jour_reunion": g.jour_reunion,
"heure_reunion": g.heure_reunion.strftime("%H:%M") if g.heure_reunion else None, "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, "format_reunion": g.format_reunion,
} for g in groupes] } 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) @router.post("/inscription", response_model=TokenResponse)
async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db)): async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db)):
# Valider le code d'invitation # Valider le code d'invitation

View file

@ -1,11 +1,13 @@
"""Routes S1 — Collecte 7e Tradition.""" """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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract, func from sqlalchemy import select, desc, extract, func
from app.core.database import get_db 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.collecte import Collecte
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.membre import Membre 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.envoi_contribution import EnvoiContribution
from app.models.operation_bancaire import OperationBancaire from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve from app.models.reserve import Reserve
from app.models.affectation import Affectation from app.models.mouvement_reserve import MouvementReserve
from app.models.poste import Poste
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut 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"]) 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: async def _est_tresorier(membre_id, groupe_id, db: AsyncSession) -> bool:
"""Vérifie si un membre est trésorier ou exécutif.""" """Vérifie si un membre est trésorier ou exécutif."""
result = await db.execute( return await membre_a_poste_ou_executif(membre_id, "trésorier", db)
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
async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut: 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) @router.post("/", response_model=CollecteOut)
async def saisir_collecte( async def saisir_collecte(
req: CollecteCreate, req: CollecteCreate,
membre=Depends(get_current_membre), membre=Depends(require_executif),
db: AsyncSession = Depends(get_db), 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) groupe = await db.get(Groupe, membre.groupe_id)
aujourdhui = date.today() aujourdhui = date.today()
retro_sysadmin = is_sysadmin(membre)
# Validation : pas dans le futur # 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") 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 # Validation : la date doit tomber un jour de réunion
if groupe.jour_reunion: if groupe.jour_reunion:
jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower()) 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( raise HTTPException(
status_code=400, status_code=400,
detail=f"La collecte doit avoir lieu un {groupe.jour_reunion}", 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 # 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) now = datetime.now(timezone.utc)
if isinstance(groupe.heure_reunion, dt_time) and now.time() < groupe.heure_reunion: if isinstance(groupe.heure_reunion, dt_time) and now.time() < groupe.heure_reunion:
raise HTTPException( raise HTTPException(
@ -126,10 +123,10 @@ async def saisir_collecte(
async def ajuster_collecte( async def ajuster_collecte(
collecte_id: str, collecte_id: str,
req: CollecteUpdate, req: CollecteUpdate,
membre=Depends(get_current_membre), membre=Depends(require_executif),
db: AsyncSession = Depends(get_db), 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( result = await db.execute(
select(Collecte).where( select(Collecte).where(
Collecte.id == collecte_id, Collecte.id == collecte_id,
@ -142,11 +139,6 @@ async def ajuster_collecte(
if collecte.statut != "en_attente": if collecte.statut != "en_attente":
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation") 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: if req.montant is not None:
collecte.montant = req.montant collecte.montant = req.montant
await db.flush() await db.flush()
@ -156,7 +148,7 @@ async def ajuster_collecte(
@router.post("/{collecte_id}/confirmer", response_model=CollecteOut) @router.post("/{collecte_id}/confirmer", response_model=CollecteOut)
async def confirmer_collecte( async def confirmer_collecte(
collecte_id: str, collecte_id: str,
membre=Depends(get_current_membre), membre=Depends(require_executif),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Un second membre confirme le montant. Si le trésorier est impliqué → réception auto.""" """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.statut = "recu"
collecte.recu_par = tresorier_id collecte.recu_par = tresorier_id
collecte.date_reception = date.today() 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: else:
collecte.statut = "confirmee" collecte.statut = "confirmee"
@ -196,7 +189,7 @@ async def confirmer_collecte(
@router.post("/{collecte_id}/reception", response_model=CollecteOut) @router.post("/{collecte_id}/reception", response_model=CollecteOut)
async def accuser_reception( async def accuser_reception(
collecte_id: str, collecte_id: str,
membre=Depends(_require_tresorier), membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Le trésorier accuse réception (pour les cas sans trésorier impliqué).""" """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.statut = "recu"
collecte.recu_par = membre.id collecte.recu_par = membre.id
collecte.date_reception = date.today() 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() await db.flush()
return await _to_out(collecte, db) return await _to_out(collecte, db)
@ -230,7 +224,7 @@ async def liste_collectes(
mois: int | None = None, mois: int | None = None,
annee: int | None = None, annee: int | None = None,
limite: int = 20, limite: int = 20,
membre=Depends(get_current_membre), membre=Depends(require_executif),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
query = select(Collecte).where(Collecte.groupe_id == membre.groupe_id) 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 from app.models.vente_litterature import VenteLitterature
gid = membre.groupe_id gid = membre.groupe_id
# ── Dernier relevé bancaire ──
result = await db.execute( result = await db.execute(
select(OperationBancaire).where( select(OperationBancaire).where(
OperationBancaire.groupe_id == gid, OperationBancaire.groupe_id == gid,
@ -266,54 +258,10 @@ async def soldes_tresorerie(
).order_by(desc(OperationBancaire.date_operation)).limit(1) ).order_by(desc(OperationBancaire.date_operation)).limit(1)
) )
dernier_releve = result.scalar_one_or_none() 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 date_releve = str(dernier_releve.date_operation) if dernier_releve else None
# ── Dépôts et retraits depuis le dernier relevé ── solde_banque_actuel = await solde_banque(gid, db)
filtre_depuis = dernier_releve.date_operation if dernier_releve else None encaisse_actuelle = await encaisse(gid, db)
# 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
# ── Dépenses engagées non payées (chèque/virement votés mais pas encore débités) ── # ── Dépenses engagées non payées (chèque/virement votés mais pas encore débités) ──
result = await db.execute( result = await db.execute(
@ -336,7 +284,6 @@ async def soldes_tresorerie(
contributions_non_debitees = float(result.scalar()) contributions_non_debitees = float(result.scalar())
engagements_non_debites += contributions_non_debitees engagements_non_debites += contributions_non_debitees
# ── Réserves ──
result = await db.execute( result = await db.execute(
select(Reserve).where( select(Reserve).where(
Reserve.groupe_id == gid, Reserve.groupe_id == gid,
@ -344,49 +291,20 @@ async def soldes_tresorerie(
).order_by(Reserve.nom) ).order_by(Reserve.nom)
) )
reserves = result.scalars().all() 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), "id": str(r.id),
"nom": r.nom, "nom": r.nom,
"montant": float(r.montant), "montant": round(montant, 2),
"note": r.note, "note": r.note,
} })
for r in reserves total_reserves_reelles = sum(r["montant"] for r in reserves_details)
]
total_reserves = sum(r["montant"] for r in reserves_details)
# ── Somme disponible ── # ── Somme disponible ──
disponible = solde_banque - engagements_non_debites - total_reserves disponible = solde_banque_actuel + encaisse_actuelle - engagements_non_debites - total_reserves_reelles
# ── 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
# ── Argent en circulation (collectes non remises au trésorier) ── # ── Argent en circulation (collectes non remises au trésorier) ──
result = await db.execute( result = await db.execute(
select(Collecte).where( 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 det = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None
total_en_circulation += float(c.montant) total_en_circulation += float(c.montant)
details_circulation.append({ details_circulation.append({
"id": str(c.id),
"date": str(c.date_collecte), "date": str(c.date_collecte),
"montant": float(c.montant), "montant": float(c.montant),
"detenteur": det.prenom if det else "?", "detenteur": det.prenom if det else "?",
@ -428,14 +347,15 @@ async def soldes_tresorerie(
}) })
return { return {
"solde_banque": round(solde_banque, 2), "solde_banque": round(solde_banque_actuel, 2),
"date_releve": date_releve, "date_releve": date_releve,
"encaisse": round(encaisse, 2), "encaisse": round(encaisse_actuelle, 2),
"disponible": round(disponible, 2), "disponible": round(disponible, 2),
"engagements_non_debites": round(engagements_non_debites, 2), "engagements_non_debites": round(engagements_non_debites, 2),
"reserve_prudente": 0, "reserve_prudente": 0,
"reserve_anniversaire": 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, "reserves_details": reserves_details,
"en_possession": round(total_en_circulation, 2), "en_possession": round(total_en_circulation, 2),
"en_possession_details": details_circulation, "en_possession_details": details_circulation,
@ -455,9 +375,11 @@ async def registre_transactions(
"""Registre détaillé de toutes les transactions financières du groupe.""" """Registre détaillé de toutes les transactions financières du groupe."""
from app.models.vente_litterature import VenteLitterature from app.models.vente_litterature import VenteLitterature
from app.models.litterature import Litterature from app.models.litterature import Litterature
from app.models.transaction_comptable import TransactionComptable
gid = membre.groupe_id gid = membre.groupe_id
transactions = [] transactions = []
dons_operation_ids = set()
# Filtres date # Filtres date
def filtre_mois(d): def filtre_mois(d):
@ -544,7 +466,30 @@ async def registre_transactions(
"par": "Trésorier", "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( result = await db.execute(
select(OperationBancaire).where( select(OperationBancaire).where(
OperationBancaire.groupe_id == gid, OperationBancaire.groupe_id == gid,
@ -552,6 +497,8 @@ async def registre_transactions(
).order_by(OperationBancaire.date_operation) ).order_by(OperationBancaire.date_operation)
) )
for op in result.scalars().all(): for op in result.scalars().all():
if op.id in dons_operation_ids:
continue
if filtre_mois(op.date_operation): if filtre_mois(op.date_operation):
m = await db.get(Membre, op.effectue_par) m = await db.get(Membre, op.effectue_par)
is_depot = op.type == "depot" is_depot = op.type == "depot"
@ -564,5 +511,27 @@ async def registre_transactions(
"par": m.prenom if m else "?", "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 return transactions

View file

@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from app.core.database import get_db 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.jeton import Jeton
from app.models.affectation import Affectation from app.models.affectation import Affectation
from app.models.poste import Poste 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)): async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable des jetons.""" """Vérifie que le membre est responsable des jetons."""
if is_sysadmin(membre):
return membre
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id, Affectation.membre_id == membre.id,

View file

@ -2,15 +2,17 @@
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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.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.core.security import get_current_membre
from app.models.envoi_contribution import EnvoiContribution from app.models.envoi_contribution import EnvoiContribution
from app.models.config_repartition import ConfigRepartition from app.models.config_repartition import ConfigRepartition
from app.models.membre import Membre from app.models.membre import Membre
from app.models.affectation import Affectation from app.services.finance import solde_banque, total_reserves, transaction
from app.models.poste import Poste
from app.schemas.phase7 import ( from app.schemas.phase7 import (
EnvoiContributionCreate, EnvoiContributionOut, EnvoiContributionCreate, EnvoiContributionOut,
CalculRepartitionIn, CalculRepartitionOut, 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]: async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]:
"""Récupère la répartition active ou utilise les défauts.""" """Récupère la répartition active ou utilise les défauts."""
result = await db.execute( 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: if not secondeur or secondeur.groupe_id != membre.groupe_id or not secondeur.actif:
raise HTTPException(status_code=404, detail="Secondeur introuvable ou inactif") 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( resolution = Proposition(
groupe_id=membre.groupe_id, groupe_id=membre.groupe_id,
type_proposition="contribution", type_proposition="contribution",
@ -136,7 +112,9 @@ async def enregistrer_envoi(
secondeur_id=req.secondeur_id, secondeur_id=req.secondeur_id,
statut="adoptee", statut="adoptee",
numero_resolution=numero_resolution, 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) db.add(resolution)
await db.flush() await db.flush()
@ -189,17 +167,16 @@ async def confirmer_debit_contribution(
if envoi.debitee: if envoi.debitee:
raise HTTPException(status_code=400, detail="Contribution déjà débitée") 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_actuel = await solde_banque(membre.groupe_id, db)
if float(envoi.montant) > solde_banque_actuel:
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if float(envoi.montant) > solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit") 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) total_reserves_actuel = await total_reserves(membre.groupe_id, db)
if solde_banque - float(envoi.montant) < total_reserves: 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") 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.debitee = True
envoi.engagee = 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() await db.flush()
saisi = await db.get(Membre, envoi.saisi_par) if envoi.saisi_par else None 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).""" """Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier)."""
import os import os
import uuid as uuid_mod from datetime import date, datetime, timezone
from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc
from app.core.database import get_db 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.depense import Depense
from app.models.membre import Membre 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.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"]) 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" 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: async def _to_out(d: Depense, db: AsyncSession) -> DepenseOut:
qui = await db.get(Membre, d.qui) qui = await db.get(Membre, d.qui)
saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None 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), membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db), 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( depense = Depense(
groupe_id=membre.groupe_id, 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, montant=req.montant,
qui=membre.id, # le membre qui soumet est celui qui a payé qui=qui_id,
quoi=req.quoi.strip(), quoi=req.quoi.strip(),
paye=False, paye=False,
methode=None, # sera renseigné par le trésorier methode=None, # sera renseigné par le trésorier
@ -110,7 +101,7 @@ async def telecharger_preuve(
async def confirmer_depense( async def confirmer_depense(
depense_id: str, depense_id: str,
methode: str, methode: str,
membre=Depends(_require_tresorier), membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Le trésorier confirme la dépense et renseigne le mode de paiement.""" """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() depense = result.scalar_one_or_none()
if not depense: if not depense:
raise HTTPException(status_code=404, detail="Dépense introuvable") 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": if methode_norm == "COLLECTE":
from app.routers.operations_bancaires import _encaisse solde_encaisse = await encaisse(membre.groupe_id, db)
if float(depense.montant) > solde_encaisse:
encaisse = await _encaisse(membre.groupe_id, db)
if float(depense.montant) > encaisse:
raise HTTPException(status_code=400, detail="L'encaisse disponible est insuffisante pour payer cette dépense") 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.methode = methode_norm
depense.confirme_par = membre.id depense.confirme_par = membre.id
@ -139,8 +140,8 @@ async def confirmer_depense(
# Si cash (COLLECTE) → payé immédiatement # Si cash (COLLECTE) → payé immédiatement
if methode_norm == "COLLECTE": if methode_norm == "COLLECTE":
depense.paye = True 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 # Si CHEQUE/VIREMENT → engagement, en attente de débit
await db.flush() await db.flush()
@ -150,7 +151,7 @@ async def confirmer_depense(
@router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut) @router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut)
async def confirmer_debit( async def confirmer_debit(
depense_id: str, depense_id: str,
membre=Depends(_require_tresorier), membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Le trésorier confirme que le chèque/virement a été débité du compte.""" """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"): if depense.methode not in ("CHEQUE", "VIREMENT"):
raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit") 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_actuel = await solde_banque(membre.groupe_id, db)
if float(depense.montant) > solde_banque_actuel:
solde_banque = await _solde_banque_brut(membre.groupe_id, db)
if float(depense.montant) > solde_banque:
raise HTTPException(status_code=400, detail="Le solde en banque est insuffisant pour confirmer ce débit") 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) total_reserves_actuel = await total_reserves(membre.groupe_id, db)
if solde_banque - float(depense.montant) < total_reserves: 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") 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.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() await db.flush()
return await _to_out(depense, db) 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]) @router.get("/", response_model=list[DepenseOut])
async def liste_depenses( async def liste_depenses(
limite: int = 30, limite: int = 30,
@ -204,7 +231,7 @@ async def liste_depenses(
@router.get("/a-confirmer") @router.get("/a-confirmer")
async def depenses_a_confirmer( async def depenses_a_confirmer(
membre=Depends(_require_tresorier), membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Dépenses soumises en attente de confirmation par le trésorier.""" """Dépenses soumises en attente de confirmation par le trésorier."""
@ -221,7 +248,7 @@ async def depenses_a_confirmer(
async def modifier_depense( async def modifier_depense(
depense_id: str, depense_id: str,
req: DepenseUpdate, req: DepenseUpdate,
membre=Depends(_require_tresorier), membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Modifier une dépense (trésorier).""" """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 datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.core.security import get_current_membre, require_executif 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.affectation import Affectation
from app.models.poste import Poste from app.models.poste import Poste
from app.models.evenement import Evenement from app.models.evenement import Evenement
from app.models.proposition import Proposition
router = APIRouter(prefix="/evenements", tags=["Événements"]) router = APIRouter(prefix="/evenements", tags=["Événements"])
@ -31,7 +30,7 @@ async def evenements_a_venir(
membre=Depends(get_current_membre), membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db), 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() aujourdhui = date.today()
debut = aujourdhui - timedelta(days=jours_avant) debut = aujourdhui - timedelta(days=jours_avant)
limite = aujourdhui + timedelta(days=jours) limite = aujourdhui + timedelta(days=jours)
@ -84,24 +83,6 @@ async def evenements_a_venir(
"groupe_nom": groupe_nom, "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 # Trier par date
evenements.sort(key=lambda e: e["date"]) evenements.sort(key=lambda e: e["date"])
return evenements return evenements

View file

@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc
from app.core.database import get_db 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.jeton import Jeton
from app.models.membre import Membre from app.models.membre import Membre
from app.models.affectation import Affectation 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)): async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
"""Vérifie que le membre est responsable des jetons.""" """Vérifie que le membre est responsable des jetons."""
if is_sysadmin(membre):
return membre
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id, Affectation.membre_id == membre.id,
Affectation.statut == "actif", Affectation.statut == "actif",
Poste.nom.ilike("%jeton%"), (Poste.nom.ilike("%jeton%") | (Poste.categorie == "executif")),
) )
) )
if not result.scalars().first(): 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 sqlalchemy import select
from app.core.database import get_db 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.litterature import Litterature
from app.models.affectation import Affectation from app.models.affectation import Affectation
from app.models.poste import Poste 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)): 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.""" """Vérifie que le membre est responsable de la littérature."""
if is_sysadmin(membre):
return membre
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id, 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).""" """Routes — Opérations bancaires (dépôts, retraits)."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.core.roles import require_tresorier
from app.core.security import get_current_membre from app.core.security import get_current_membre
from app.models.operation_bancaire import OperationBancaire from app.models.operation_bancaire import OperationBancaire
from app.models.collecte import Collecte from app.models.transaction_comptable import TransactionComptable
from app.models.depense import Depense from app.models.mouvement_reserve import MouvementReserve
from app.models.reserve import Reserve from app.models.reserve import Reserve
from app.models.vente_litterature import VenteLitterature
from app.models.membre import Membre from app.models.membre import Membre
from app.models.affectation import Affectation from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveMouvementOut, ReserveTransfer, ReserveUpdate, TransactionComptableOut
from app.models.poste import Poste from app.services.finance import encaisse, reserve_disponible, solde_banque, solde_reserve, total_reserves
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut, ReserveCreate, ReserveOut, ReserveUpdate
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"]) router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)): def _mouvement_reserve(groupe_id, membre_id, montant, type_mouvement, source_id=None, destination_id=None, note=None):
"""Vérifie que le membre est trésorier ou exécutif.""" return MouvementReserve(
result = await db.execute( groupe_id=groupe_id,
select(Affectation).join(Poste).where( source_id=source_id,
Affectation.membre_id == membre.id, destination_id=destination_id,
Affectation.statut == "actif", montant=montant,
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), 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: def _ligne_comptable(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str):
total_collectes = float((await db.execute( return TransactionComptable(
select(func.coalesce(func.sum(Collecte.montant), 0)).where( groupe_id=groupe_id,
Collecte.groupe_id == groupe_id, Collecte.statut == "recu") date_transaction=op.date_operation,
)).scalar()) type=op.type,
ventes_recues = float((await db.execute( compte=compte,
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where( montant=op.montant,
VenteLitterature.groupe_id == groupe_id, VenteLitterature.remis_tresorier == True) sens=sens,
)).scalar()) source_table="operations_bancaires",
retraits = float((await db.execute( source_id=op.id,
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( note=op.note,
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait") cree_par=membre_id,
)).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)
) )
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") def _ligne_comptable_montant(groupe_id, membre_id, op: OperationBancaire, compte: str, sens: str, montant: float):
q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where( return TransactionComptable(
OperationBancaire.groupe_id == groupe_id, OperationBancaire.type == "retrait") groupe_id=groupe_id,
q_contribs = select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( date_transaction=op.date_operation,
EnvoiContribution.groupe_id == groupe_id, EnvoiContribution.debitee == True) type=op.type,
q_depenses = select(func.coalesce(func.sum(Depense.montant), 0)).where( compte=compte,
Depense.groupe_id == groupe_id, montant=montant,
Depense.paye == True, sens=sens,
Depense.methode.in_(["VIREMENT", "CHEQUE"]), 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) @router.post("/", response_model=OperationBancaireOut)
async def enregistrer_operation( async def enregistrer_operation(
req: OperationBancaireCreate, req: OperationBancaireCreate,
membre=Depends(_require_tresorier), membre=Depends(require_tresorier),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Enregistrer un dépôt ou retrait bancaire (trésorier).""" """Enregistrer un dépôt ou retrait bancaire (trésorier)."""
if req.type not in ("depot", "retrait", "releve"): if req.type not in ("depot", "retrait", "releve"):
raise HTTPException(status_code=400, detail="Type doit être 'depot', 'retrait' ou '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: if req.montant <= 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif") raise HTTPException(status_code=400, detail="Le montant doit être positif")
solde_banque = await _solde_banque_brut(membre.groupe_id, db) solde_banque_actuel = await solde_banque(membre.groupe_id, db)
total_reserves = await _total_reserves(membre.groupe_id, db) total_reserves_actuel = await total_reserves(membre.groupe_id, db)
if req.type == "depot" and req.montant > await _encaisse(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") 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") 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") 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") raise HTTPException(status_code=400, detail="Le relevé ne peut pas être inférieur au total des réserves")
op = OperationBancaire( op = OperationBancaire(
@ -131,10 +90,39 @@ async def enregistrer_operation(
montant=req.montant, montant=req.montant,
date_operation=req.date_operation, date_operation=req.date_operation,
effectue_par=membre.id, 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) db.add(op)
await db.flush() 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( return OperationBancaireOut(
id=op.id, id=op.id,
type=op.type, type=op.type,
@ -153,55 +141,154 @@ async def liste_reserves(membre=Depends(get_current_membre), db: AsyncSession =
.order_by(Reserve.nom) .order_by(Reserve.nom)
) )
return [ 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() 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) @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(): if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis") raise HTTPException(status_code=400, detail="Nom requis")
if req.montant < 0: if req.montant != 0:
raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul") raise HTTPException(status_code=400, detail="Créer la réserve à zéro, puis utiliser un virement pour l'approvisionner")
total = await _total_reserves(membre.groupe_id, db) + req.montant reserve = Reserve(groupe_id=membre.groupe_id, nom=req.nom.strip(), montant=0, note=req.note)
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)
db.add(reserve) db.add(reserve)
await db.flush() 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) @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) reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active: if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable") raise HTTPException(status_code=404, detail="Réserve introuvable")
montant = float(reserve.montant) if req.montant is None else req.montant ancien_montant = await solde_reserve(reserve.id, db)
if montant < 0: if req.montant is not None and round(req.montant, 2) != round(ancien_montant, 2):
raise HTTPException(status_code=400, detail="Le montant doit être positif ou nul") raise HTTPException(status_code=400, detail="Modifier le montant d'une réserve via l'action Virement")
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")
if req.nom is not None: if req.nom is not None:
if not req.nom.strip(): if not req.nom.strip():
raise HTTPException(status_code=400, detail="Nom requis") raise HTTPException(status_code=400, detail="Nom requis")
reserve.nom = req.nom.strip() reserve.nom = req.nom.strip()
reserve.montant = montant
if req.note is not None: if req.note is not None:
reserve.note = req.note.strip() or None reserve.note = req.note.strip() or None
reserve.montant = await solde_reserve(reserve.id, db)
await db.flush() await db.flush()
return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le) return ReserveOut(id=reserve.id, nom=reserve.nom, montant=float(reserve.montant), note=reserve.note, active=reserve.active, cree_le=reserve.cree_le)
@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}") @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) reserve = await db.get(Reserve, reserve_id)
if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active: if not reserve or reserve.groupe_id != membre.groupe_id or not reserve.active:
raise HTTPException(status_code=404, detail="Réserve introuvable") 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 reserve.active = False
await db.flush() await db.flush()
return {"message": "Réserve supprimée"} 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"]) router = APIRouter(prefix="/groupe", tags=["G9 — Paramètres"])
ORDRES_ASSEMBLEE = {"premiere", "deuxieme", "troisieme", "quatrieme", "derniere"}
@router.get("/", response_model=GroupeDetail) @router.get("/", response_model=GroupeDetail)
async def detail_groupe( async def detail_groupe(
@ -45,6 +47,10 @@ async def modifier_groupe(
groupe.heure_reunion = None groupe.heure_reunion = None
else: else:
groupe.heure_reunion = None 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: if req.format_reunion is not None:
groupe.format_reunion = req.format_reunion.strip() groupe.format_reunion = req.format_reunion.strip()
if req.lien_aa87 is not None: if req.lien_aa87 is not None:

View file

@ -2,9 +2,10 @@
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.security import get_current_membre, require_executif from app.core.security import get_current_membre, require_executif
from app.models.poste import Poste from app.models.poste import Poste
from app.models.affectation import Affectation 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"]) 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 ── # ── Postes ──
@router.get("/", response_model=list[PosteOut]) @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(): if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Ce poste est déjà affecté") 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( resolution = Proposition(
groupe_id=executif.groupe_id, groupe_id=executif.groupe_id,
type_proposition="nomination", type_proposition="nomination",
@ -512,6 +499,8 @@ async def creer_affectation(req: AffectationCreate, executif=Depends(require_exe
statut="adoptee", statut="adoptee",
numero_resolution=numero_resolution, numero_resolution=numero_resolution,
date_vote=req.date_debut, 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) db.add(resolution)
await db.flush() await db.flush()

View file

@ -1,50 +1,22 @@
"""Routes M2 — Présences en réunion d'affaires.""" """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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract from sqlalchemy import select, desc, extract
from app.core.database import get_db 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.core.security import get_current_membre
from app.models.reunion import Reunion from app.models.reunion import Reunion
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.presence import Presence from app.models.presence import Presence
from app.models.membre import Membre from app.models.membre import Membre
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase4 import ( from app.schemas.phase4 import (
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut, PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
) )
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"]) 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) @router.post("/reunion", response_model=ReunionAffairesOut)
async def creer_reunion_affaires( 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).""" """Créer une réunion d'affaires (point de départ pour présences et PV)."""
groupe = await db.get(Groupe, membre.groupe_id) 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 dt = req.date_reunion
if groupe.jour_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: if jour_attendu is not None:
premier = _premier_jour_du_mois(dt.year, dt.month, jour_attendu) ordre = groupe.assemblee_affaires_ordre or "premiere"
if dt.date() != premier: date_attendue = date_assemblee_affaires_du_mois(dt.year, dt.month, jour_attendu, ordre)
if dt.date() != date_attendue:
raise HTTPException( raise HTTPException(
status_code=400, 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 # Une seule réunion d'affaires par mois

View file

@ -2,9 +2,10 @@
from datetime import date, timezone, datetime from datetime import date, timezone, datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession 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.database import get_db
from app.core.decisions import prochain_numero_decision
from app.core.security import get_current_membre, require_executif from app.core.security import get_current_membre, require_executif
from app.models.proposition import Proposition from app.models.proposition import Proposition
from app.models.reunion import Reunion from app.models.reunion import Reunion
@ -24,7 +25,7 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
return PropositionOut( return PropositionOut(
id=p.id, id=p.id,
reunion_id=p.reunion_id, reunion_id=p.reunion_id,
type_proposition=p.type_proposition or "generale", type_proposition=p.type_proposition or "proposition",
sujet=p.sujet, sujet=p.sujet,
description=p.description, description=p.description,
proposeur_prenom=proposeur.prenom if proposeur else "?", proposeur_prenom=proposeur.prenom if proposeur else "?",
@ -32,6 +33,9 @@ async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
statut=p.statut, statut=p.statut,
numero_resolution=p.numero_resolution, numero_resolution=p.numero_resolution,
date_vote=p.date_vote, 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, note=p.note,
candidature_id=p.candidature_id, candidature_id=p.candidature_id,
cree_le=p.cree_le, 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) @router.post("/", response_model=PropositionOut)
async def creer_proposition( async def creer_proposition(
req: PropositionCreate, req: PropositionCreate,
@ -72,7 +63,7 @@ async def creer_proposition(
prop = Proposition( prop = Proposition(
groupe_id=membre.groupe_id, groupe_id=membre.groupe_id,
reunion_id=req.reunion_id, reunion_id=req.reunion_id,
type_proposition=req.type_proposition or "generale", type_proposition="proposition",
sujet=req.sujet.strip(), sujet=req.sujet.strip(),
description=req.description, description=req.description,
proposeur_id=membre.id, proposeur_id=membre.id,
@ -130,18 +121,28 @@ async def voter_proposition(
prop = result.scalar_one_or_none() prop = result.scalar_one_or_none()
if not prop: if not prop:
raise HTTPException(status_code=404, detail="Proposition introuvable") raise HTTPException(status_code=404, detail="Proposition introuvable")
if prop.statut != "secondee": if req.statut == "adoptee" and prop.statut != "secondee":
raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être mise au vote") 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"):
prop.statut = req.statut raise HTTPException(status_code=400, detail="Seule une proposition en cours peut être rejetée")
prop.date_vote = date.today()
if req.note:
prop.note = req.note.strip()
# Attribuer un numéro de résolution si adoptée # Attribuer un numéro de résolution si adoptée
if req.statut == "adoptee": if req.statut == "adoptee":
prop.numero_resolution = await _prochain_numero( etant_donne_que = (req.etant_donne_que or "").strip()
executif.groupe_id, date.today().year, db 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: if req.reunion_id and not prop.reunion_id:
prop.reunion_id = req.reunion_id prop.reunion_id = req.reunion_id
@ -162,12 +163,20 @@ async def voter_proposition(
db.add(affectation) db.add(affectation)
elif req.statut == "rejetee": elif req.statut == "rejetee":
if req.note:
prop.note = req.note.strip()
# Si nomination rejetée, mettre la candidature en refusée # Si nomination rejetée, mettre la candidature en refusée
if prop.type_proposition == "nomination" and prop.candidature_id: if prop.type_proposition == "nomination" and prop.candidature_id:
cand = await db.get(Candidature, prop.candidature_id) cand = await db.get(Candidature, prop.candidature_id)
if cand: if cand:
cand.statut = "refusee" 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() await db.flush()
return await _to_out(prop, db) return await _to_out(prop, db)
@ -211,7 +220,7 @@ async def registre_resolutions(
query = query.where(extract("year", Proposition.cree_le) == annee) query = query.where(extract("year", Proposition.cree_le) == annee)
if type_proposition: if type_proposition:
query = query.where(Proposition.type_proposition == 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) result = await db.execute(query)
return [await _to_out(p, db) for p in result.scalars().all()] 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 sqlalchemy import select, desc
from app.core.database import get_db from app.core.database import get_db
from app.core.roles import require_secretaire
from app.core.security import get_current_membre from app.core.security import get_current_membre
from app.models.pv_reunion import PvReunion from app.models.pv_reunion import PvReunion
from app.models.reunion import Reunion from app.models.reunion import Reunion
from app.models.membre import Membre from app.models.membre import Membre
from app.models.decision import Decision from app.schemas.phase4 import PvAdoption, PvCreate, PvOut, PvUpdate
from app.models.affectation import Affectation
from app.models.poste import Poste
from app.schemas.phase4 import PvCreate, PvOut, PvUpdate
router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"]) 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: async def _to_out(pv: PvReunion, db: AsyncSession) -> PvOut:
redacteur = await db.get(Membre, pv.redacteur_id) redacteur = await db.get(Membre, pv.redacteur_id)
reunion = await db.get(Reunion, pv.reunion_id) if pv.reunion_id else None 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) db.add(pv)
await db.flush() 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) return await _to_out(pv, db)
@ -155,3 +123,35 @@ async def modifier_pv(
pv.approuve = req.approuve pv.approuve = req.approuve
await db.flush() await db.flush()
return await _to_out(pv, db) 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).""" """Routes C1 — Rapports PDF (finances, PV, rapport RSG)."""
import io 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 import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession 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(): def _css():
return """ return """
body { font-family: 'DejaVu Sans', sans-serif; font-size: 11pt; color: #333; margin: 2cm; } 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: for p in propositions:
proposeur = await db.get(Membre, p.proposeur_id) if p.proposeur_id else None 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 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> rows += f"""<tr>
<td>{p.numero_resolution or '—'}</td> <td>{numero_date}</td>
<td>{p.date_vote or '—'}</td> <td>{resolution}</td>
<td>{p.sujet}</td> <td>{proposeurs}</td>
<td>{proposeur.prenom if proposeur else '—'}</td>
<td>{secondeur.prenom if secondeur else '—'}</td>
<td>{badge}</td>
</tr>""" </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> 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="header">
<div class="groupe">{groupe.nom if groupe else 'Groupe'}</div> <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 class="sub">District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}</div>
</div> </div>
<h1>Registre des résolutions — {annee}</h1> <h1>Registre des résolutions — {annee}</h1>
<p>Décisions prises en Groupe</p>
<table> <table>
<tr><th>N°</th><th>Date</th><th>Sujet</th><th>Proposeur</th><th>Secondeur</th><th>Résultat</th></tr> <colgroup>
{rows if rows else '<tr><td colspan="6">Aucune résolution pour cette année</td></tr>'} <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> </table>
<p class="total">{nb_adoptees} résolution(s) adoptée(s), {nb_rejetees} rejetée(s)</p>
<div class="footer"> <div class="footer">
Registre généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')} · 100% logiciel libre Registre généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')} · 100% logiciel libre
</div> </div>
@ -399,18 +414,27 @@ async def rapport_resolutions(
@router.get("/etat-financier") @router.get("/etat-financier")
async def etat_financier_temps_reel( async def etat_financier_temps_reel(
annee: int | None = None,
mois: int | None = None,
membre=Depends(get_current_membre), membre=Depends(get_current_membre),
db: AsyncSession = Depends(get_db), 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 sqlalchemy import func, desc
from app.models.operation_bancaire import OperationBancaire from app.models.operation_bancaire import OperationBancaire
from app.models.reserve import Reserve from app.models.reserve import Reserve
from app.models.transaction_comptable import TransactionComptable
from app.models.vente_litterature import VenteLitterature 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 gid = membre.groupe_id
groupe = await db.get(Groupe, gid) 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 ── # ── Dernier relevé bancaire ──
result = await db.execute( result = await db.execute(
@ -422,81 +446,71 @@ async def etat_financier_temps_reel(
dernier_releve = result.scalar_one_or_none() dernier_releve = result.scalar_one_or_none()
solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0 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( result = await db.execute(
select(func.coalesce(func.sum(Collecte.montant), 0)).where( 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()) total_collectes = float(result.scalar())
result = await db.execute( result = await db.execute(
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where( 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()) 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( result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where( 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()) total_depenses_payees = float(result.scalar())
result = await db.execute( result = await db.execute(
select(func.coalesce(func.sum(Depense.montant), 0)).where( 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()) total_engagements = float(result.scalar())
# ── Contributions ──
result = await db.execute( result = await db.execute(
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where( select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
EnvoiContribution.groupe_id == gid, EnvoiContribution.groupe_id == gid,
EnvoiContribution.debitee == True, EnvoiContribution.debitee == True,
extract("year", EnvoiContribution.date_envoi) == annee,
extract("month", EnvoiContribution.date_envoi) == mois,
)) ))
total_contributions = float(result.scalar()) total_contributions = float(result.scalar())
# ── Encaisse ── encaisse = await solde_encaisse(gid, db)
result = await db.execute( solde_banque = await solde_banque_actuel(gid, db)
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
# ── Réserves ── # ── Réserves ──
result = await db.execute( result = await db.execute(
@ -505,14 +519,18 @@ async def etat_financier_temps_reel(
Reserve.active == True, Reserve.active == True,
).order_by(Reserve.nom) ).order_by(Reserve.nom)
) )
reserves_details = [ reserves_details = []
{"id": str(r.id), "nom": r.nom, "montant": float(r.montant), "note": r.note} for reserve in result.scalars().all():
for r 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) 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 ── # ── Dernières transactions ──
result = await db.execute( result = await db.execute(
select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu") select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu")
@ -531,6 +549,11 @@ async def etat_financier_temps_reel(
return { return {
"groupe": groupe.nom if groupe else "?", "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, "date_releve": str(dernier_releve.date_operation) if dernier_releve else None,
"solde_releve": round(solde_releve, 2), "solde_releve": round(solde_releve, 2),
"solde_banque": round(solde_banque, 2), "solde_banque": round(solde_banque, 2),
@ -539,19 +562,246 @@ async def etat_financier_temps_reel(
"revenus": { "revenus": {
"collectes": round(total_collectes, 2), "collectes": round(total_collectes, 2),
"ventes_litterature": round(total_ventes, 2), "ventes_litterature": round(total_ventes, 2),
"dons": round(total_dons, 2),
"total": round(revenus, 2), "total": round(revenus, 2),
}, },
"sorties": { "sorties": {
"depenses_payees": round(total_depenses_payees, 2), "depenses_payees": round(total_depenses_payees, 2),
"engagements": round(total_engagements, 2), "engagements": round(engagements_courants, 2),
"contributions": round(total_contributions, 2), "contributions": round(total_contributions, 2),
"total": round(total_depenses_payees + total_contributions, 2), "total": round(total_depenses_payees + total_contributions, 2),
}, },
"reserves": { "reserves": {
"details": reserves_details, "details": reserves_details,
"total": round(total_reserves, 2), "total": round(total_reserves, 2),
"total_reelles": round(total_reserves, 2),
}, },
"resultat_net": round(revenus - total_depenses_payees - total_contributions, 2), "resultat_net": round(revenus - total_depenses_payees - total_contributions, 2),
"dernieres_collectes": dernieres_collectes, "dernieres_collectes": dernieres_collectes,
"dernieres_depenses": dernieres_depenses, "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 sqlalchemy import select, desc
from app.core.database import get_db 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.rapport_rsg import RapportRsg
from app.models.membre import Membre from app.models.membre import Membre
from app.models.affectation import Affectation 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)): 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.""" """Vérifie que le membre est RSG, RSG substitut ou exécutif."""
if is_sysadmin(membre):
return membre
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id, Affectation.membre_id == membre.id,

View file

@ -5,40 +5,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, extract from sqlalchemy import select, desc, extract
from app.core.database import get_db 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.reunion import Reunion
from app.models.groupe import Groupe from app.models.groupe import Groupe
from app.models.membre import Membre 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 from app.schemas.phase1 import ReunionCreate, ReunionOut
router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"]) 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) @router.post("/", response_model=ReunionOut)
async def creer_rapport_reunion( async def creer_rapport_reunion(
req: ReunionCreate, req: ReunionCreate,
membre=Depends(require_animateur), membre=Depends(require_executif),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""S4 — L'animateur note le thème et les annonces.""" """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 ── # ── Validation : réunion régulière ──
if req.type == "reguliere" and groupe.jour_reunion: 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() jour_soumis = req.date_reunion.weekday()
if jour_attendu is not None and jour_soumis != jour_attendu: if jour_attendu is not None and jour_soumis != jour_attendu:
raise HTTPException( 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 sqlalchemy import select, desc
from app.core.database import get_db 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.litterature import Litterature
from app.models.vente_litterature import VenteLitterature from app.models.vente_litterature import VenteLitterature
from app.models.membre import Membre from app.models.membre import Membre
from app.models.affectation import Affectation from app.models.affectation import Affectation
from app.models.poste import Poste from app.models.poste import Poste
from app.models.transaction_comptable import TransactionComptable
from app.schemas.phase6 import VenteCreate, VenteOut from app.schemas.phase6 import VenteCreate, VenteOut
router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littérature"]) 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)): 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.""" """Vérifie que le membre est responsable de la littérature."""
if is_sysadmin(membre):
return membre
result = await db.execute( result = await db.execute(
select(Affectation).join(Poste).where( select(Affectation).join(Poste).where(
Affectation.membre_id == membre.id, Affectation.membre_id == membre.id,
Affectation.statut == "actif", Affectation.statut == "actif",
Poste.nom.ilike("%littérature%"), (Poste.nom.ilike("%littérature%") | (Poste.categorie == "executif")),
) )
) )
if not result.scalars().first(): 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.""" """Le trésorier confirme la réception du montant de la vente."""
# Vérifier que c'est le trésorier ou exécutif # Vérifier que c'est le trésorier ou exécutif
result = await db.execute( if not is_sysadmin(membre):
select(Affectation).join(Poste).where( result = await db.execute(
Affectation.membre_id == membre.id, select(Affectation).join(Poste).where(
Affectation.statut == "actif", Affectation.membre_id == membre.id,
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")), Affectation.statut == "actif",
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
)
) )
) if not result.scalars().first():
if not result.scalars().first(): raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
result = await db.execute( result = await db.execute(
select(VenteLitterature).where( select(VenteLitterature).where(
@ -177,5 +181,17 @@ async def confirmer_reception_vente(
vente.remis_tresorier = True vente.remis_tresorier = True
vente.recu_par = membre.id vente.recu_par = membre.id
vente.date_remise = date_type.today() 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() await db.flush()
return {"message": "Réception confirmée"} return {"message": "Réception confirmée"}

View file

@ -36,6 +36,7 @@ class CollecteUpdate(BaseModel):
# ── Opérations bancaires ── # ── Opérations bancaires ──
class OperationBancaireCreate(BaseModel): class OperationBancaireCreate(BaseModel):
type: str = "depot" type: str = "depot"
provenance_depot: Optional[str] = "encaisse"
montant: float montant: float
date_operation: date date_operation: date
note: Optional[str] = None note: Optional[str] = None
@ -53,6 +54,20 @@ class OperationBancaireOut(BaseModel):
model_config = {"from_attributes": True} 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): class ReserveCreate(BaseModel):
nom: str nom: str
montant: float montant: float
@ -65,6 +80,12 @@ class ReserveUpdate(BaseModel):
note: Optional[str] = None note: Optional[str] = None
class ReserveTransfer(BaseModel):
source_id: str
destination_id: str
montant: float
class ReserveOut(BaseModel): class ReserveOut(BaseModel):
id: UUID id: UUID
nom: str nom: str
@ -76,6 +97,19 @@ class ReserveOut(BaseModel):
model_config = {"from_attributes": True} 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): class SoldesOut(BaseModel):
encaisse: float # cash reçu par trésorier, pas encore déposé encaisse: float # cash reçu par trésorier, pas encore déposé
compte_bancaire: float # dépôts - dépenses payées - contributions compte_bancaire: float # dépôts - dépenses payées - contributions
@ -107,10 +141,11 @@ class ReunionOut(BaseModel):
# ── S5 Dépense (ouvert à tous) ── # ── S5 Dépense (ouvert à tous) ──
class DepenseCreate(BaseModel): class DepenseCreate(BaseModel):
date_depense: date
montant: float montant: float
quoi: str 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 # methode renseignée par le trésorier à la confirmation

View file

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

View file

@ -71,6 +71,11 @@ class PvUpdate(BaseModel):
approuve: Optional[bool] = None approuve: Optional[bool] = None
class PvAdoption(BaseModel):
proposeur: str
secondeur: str
# ── M3 Rapport RSG ── # ── M3 Rapport RSG ──
class RapportRsgCreate(BaseModel): class RapportRsgCreate(BaseModel):
reunion_dist: date 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 reunion_id: Optional[UUID] = None
sujet: str sujet: str
description: Optional[str] = None description: Optional[str] = None
type_proposition: str = "generale" # generale, nomination, contribution
class PropositionOut(BaseModel): class PropositionOut(BaseModel):
id: UUID id: UUID
reunion_id: Optional[UUID] = None reunion_id: Optional[UUID] = None
type_proposition: str = "generale" type_proposition: str = "proposition"
sujet: str sujet: str
description: Optional[str] = None description: Optional[str] = None
proposeur_prenom: str proposeur_prenom: str
@ -23,6 +22,9 @@ class PropositionOut(BaseModel):
statut: str statut: str
numero_resolution: Optional[str] = None numero_resolution: Optional[str] = None
date_vote: Optional[date] = 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 note: Optional[str] = None
candidature_id: Optional[UUID] = None candidature_id: Optional[UUID] = None
cree_le: datetime cree_le: datetime
@ -34,4 +36,6 @@ class PropositionOut(BaseModel):
class PropositionVote(BaseModel): class PropositionVote(BaseModel):
statut: str # adoptee ou rejetee statut: str # adoptee ou rejetee
note: Optional[str] = None note: Optional[str] = None
etant_donne_que: Optional[str] = None
groupe_a_decide_de: Optional[str] = None
reunion_id: Optional[UUID] = None reunion_id: Optional[UUID] = None

View file

@ -36,6 +36,7 @@ class MembrePublic(BaseModel):
courriel: str courriel: str
groupe_id: UUID groupe_id: UUID
date_abstinence: Optional[date] = None date_abstinence: Optional[date] = None
is_sysadmin: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@ -123,6 +124,7 @@ class ModuleTuile(BaseModel):
class AccueilResponse(BaseModel): class AccueilResponse(BaseModel):
membre: MembrePublic membre: MembrePublic
postes: list[str] postes: list[str]
est_executif: bool = False
modules_consultation: list[ModuleTuile] modules_consultation: list[ModuleTuile]
modules_saisie: list[ModuleTuile] modules_saisie: list[ModuleTuile]
modules_gestion: list[ModuleTuile] modules_gestion: list[ModuleTuile]
@ -148,5 +150,6 @@ class GroupeOut(BaseModel):
district: str district: str
region: str region: str
jour_reunion: Optional[str] = None jour_reunion: Optional[str] = None
assemblee_affaires_ordre: Optional[str] = "premiere"
model_config = {"from_attributes": True} 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.reunion import Reunion
from app.models.presence import Presence from app.models.presence import Presence
from app.models.pv_reunion import PvReunion from app.models.pv_reunion import PvReunion
from app.models.decision import Decision
from app.models.rotation import Rotation from app.models.rotation import Rotation
from app.models.rapport_rsg import RapportRsg from app.models.rapport_rsg import RapportRsg
from app.models.jeton import Jeton 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.vente_litterature import VenteLitterature
from app.models.envoi_contribution import EnvoiContribution from app.models.envoi_contribution import EnvoiContribution
from app.models.config_repartition import ConfigRepartition 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.sync_log import SyncLog
from app.models.proposition import Proposition from app.models.proposition import Proposition
from app.models.operation_bancaire import OperationBancaire 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.candidature import Candidature
from app.models.poste_module import PosteModule from app.models.poste_module import PosteModule
from app.models.reserve import Reserve from app.models.reserve import Reserve
from app.models.transaction_comptable import TransactionComptable
from app.models.mouvement_reserve import MouvementReserve
POSTES = [ POSTES = [
@ -103,7 +101,7 @@ async def seed():
nom_membre = input(" Nom (optionnel, Entrée pour passer): ").strip() nom_membre = input(" Nom (optionnel, Entrée pour passer): ").strip()
nom_membre = nom_membre.title() if nom_membre else None nom_membre = nom_membre.title() if nom_membre else None
courriel = input(" Courriel: ").strip().lower() 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() courriel = input(" Courriel invalide. Réessayer: ").strip().lower()
telephone = input(" Téléphone: ").strip() telephone = input(" Téléphone: ").strip()
while not telephone: while not telephone:
@ -112,12 +110,14 @@ async def seed():
while not pin.isdigit() or len(pin) != 4: while not pin.isdigit() or len(pin) != 4:
pin = input(" PIN invalide. 4 chiffres: ").strip() pin = input(" PIN invalide. 4 chiffres: ").strip()
print("\n Postes disponibles:") poste_choisi = None
for i, (nom_poste, _, _, _, _, _) in enumerate(POSTES[:4], 1): if courriel != "sysadmin":
print(f" {i}. {nom_poste}") print("\n Postes disponibles:")
choix = input(" Votre poste (1-4): ").strip() for i, (nom_poste, _, _, _, _, _) in enumerate(POSTES[:4], 1):
poste_idx = int(choix) - 1 if choix.isdigit() and 1 <= int(choix) <= 4 else 0 print(f" {i}. {nom_poste}")
poste_choisi = POSTES[poste_idx][0] 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( membre = Membre(
groupe_id=groupe.id, groupe_id=groupe.id,
@ -130,15 +130,15 @@ async def seed():
db.add(membre) db.add(membre)
await db.flush() await db.flush()
# ── Affectation ── if poste_choisi:
affectation = Affectation( affectation = Affectation(
groupe_id=groupe.id, groupe_id=groupe.id,
membre_id=membre.id, membre_id=membre.id,
poste_id=postes_map[poste_choisi].id, poste_id=postes_map[poste_choisi].id,
date_debut=date.today(), date_debut=date.today(),
statut="actif", statut="actif",
) )
db.add(affectation) db.add(affectation)
# ── Première invitation ── # ── Première invitation ──
code = secrets.token_hex(6).upper() code = secrets.token_hex(6).upper()
@ -174,7 +174,7 @@ async def seed():
print(f"\n═══ Initialisation terminée ═══") print(f"\n═══ Initialisation terminée ═══")
print(f" Membre: {prenom} ({courriel})") print(f" Membre: {prenom} ({courriel})")
print(f" Groupe: Groupe Méditation") 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"\n Code d'invitation pour les prochains membres: {code}")
print(f" (Expire dans 7 jours)") print(f" (Expire dans 7 jours)")
print(f"\n Connectez-vous sur l'app avec:") 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. - Site NGINX reverse proxy + SPA.
- Sauvegarde quotidienne dans `/var/backups/groupe-meditation`. - 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 ## Notes importantes
- Le playbook ne modifie pas le code applicatif. - 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. Ce fichier est conservé comme point d'entrée historique afin d'éviter deux procédures concurrentes.
---
## 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**

Binary file not shown.

View file

@ -42,13 +42,13 @@
## Trésorerie (S1T) ## 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 ### Soldes
- **Solde banque** : basé sur le dernier relevé + dépôts/retraits enregistrés depuis - **Solde banque** : calculé depuis le registre comptable
- **Disponible** : solde banque − engagements non débités − réserves - **Disponible** : banque − engagements non débités − réserves affectées
- **Encaisse** : cash reçu mais pas encore déposé à la banque - **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 - **En circulation** : collectes pas encore remises au trésorier
### À recevoir ### À 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 » 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 ### 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 ### Opérations
Trois types : Trois fonctions :
- **Dépôt** : enregistrer un dépôt bancaire - **Dépôt** : enregistrer un dépôt bancaire provenant de l'encaisse ou d'un don direct
- **Retrait** : enregistrer un retrait - **Retrait** : enregistrer un retrait de la banque vers l'encaisse
- **Relevé** : saisir le solde du relevé mensuel de la banque (sert de base au calcul des soldes) - **Virement** : transférer des montants entre les réserves
### Flux mensuel recommandé ### Flux mensuel recommandé
1. Recevoir le relevé bancaire → enregistrer un **relevé** dans S1T 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 2. Vérifier que le solde correspond → les écarts signalent des opérations non enregistrées ou une correction à saisir
3. Préparer le rapport financier pour la réunion d'affaires 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 ### Soumettre une proposition
1. N'importe quel membre peut soumettre une proposition **en tout temps** (pas besoin d'une réunion d'affaires) 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 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 3. Un autre membre doit **seconder** la proposition pour qu'elle soit recevable
### Voter une résolution ### Transformer en résolution
1. L'exécutif vote **Adopter** ou **Rejeter** lors de la réunion d'affaires 1. L'exécutif transforme une proposition secondée en résolution
2. Si adoptée, un numéro de résolution est attribué (RES-2026-001) 2. Les sections **ÉTANT DONNÉ QUE** et **LE GROUPE A DÉCIDÉ DE** sont obligatoires
3. La proposition est automatiquement associée à la réunion d'affaires lors du vote 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 ### 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. 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 ## Réunion d'affaires mensuelle
### Avant la réunion ### 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 - D'autres membres secondent les propositions recevables
### Pendant la réunion ### Pendant la réunion
1. **Secrétaire** : M2 (présences) → M1 (PV avec import des propositions) 1. **Secrétaire** : M2 (présences) → M1 (PV avec import des propositions)
2. **RSG** : M3 (rapport du district) 2. **RSG** : M3 (rapport du district)
3. **Trésorier** : présente le rapport financier (depuis le registre S1T) 3. **Trésorier** : présente l'état des résultats et la position de trésorerie
4. **Exécutif** : vote les propositions secondées (C8) 4. **Exécutif** : transforme les propositions secondées en résolutions (C5)
### Après la réunion ### Après la réunion
Le secrétaire finalise le PV avec les décisions votées. 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 | | 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 | | 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. > **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 | | Module | RSG | RSG sub | Trés. | Secr. | Anim. | Litt. | Jetons | Tous |
|--------|:---:|:-------:|:-----:|:-----:|:-----:|:-----:|:------:|:----:| |--------|:---:|:-------:|:-----:|:-----:|:-----:|:-----:|:------:|:----:|
| C1-C9 Consultation | | | | | | | | ✓ | | C1-C9 Consultation | | | | | | | | ✓ |
| S1 Collecte | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | S1 Collecte | ✓ | ✓ | ✓ | ✓ | | | | |
| S1T Trésorerie | | | ✓ | | | | | | | S1T Trésorerie | | | ✓ | | | | | |
| S2 Remise jeton | | | | | | | ✓ | | | S2 Remise jeton | | | | | | | ✓ | |
| S3 Vente litt. | | | | | | ✓ | | | | S3 Vente litt. | | | | | | ✓ | | |

View file

@ -77,13 +77,11 @@ Permet de consulter l'inventaire de littérature :
- quantités; - quantités;
- alertes de stock bas. - 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 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 ».
Permet aux membres de voter dans les sondages ouverts.
### Mon profil ### Mon profil
@ -98,21 +96,11 @@ Permet de modifier :
Un courriel déjà utilisé dans le même groupe ne peut pas être repris. 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 : Une proposition suit normalement ce cycle :
1. proposée; 1. proposée;
2. secondée; 2. secondée;
3. votée; 3. transformée en résolution par l'exécutif.
4. adoptée ou rejetée.
Une proposition adoptée devient une résolution numérotée. 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. 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; - solde bancaire;
- encaisse; - encaisse;
- montant disponible; - montant disponible;
- résultat net; - montants engagés;
- revenus;
- sorties;
- réserves; - réserves;
- dernières collectes; - dernières collectes et dépenses.
- dernières 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 ### Collecte 7e Tradition
Ce module est réservé aux postes exécutifs.
Permet de saisir une collecte. Permet de saisir une collecte.
Règles importantes : Règles importantes :
@ -153,15 +149,18 @@ Le module offre aussi une liste des collectes existantes.
### Dépense ### 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. 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 : Le trésorier choisit la méthode :
- `COLLECTE` : payé depuis l'encaisse; - `COLLECTE` : payé depuis l'encaisse;
- `CHEQUE` ou `VIREMENT` : engagement à confirmer lorsque le débit apparaît; - `CHEQUE` ou `VIREMENT` : engagement à confirmer lorsque le débit apparaît.
- `DU` : montant dû au membre.
Une dépense soumise peut aussi être rejetée. Dans ce cas, elle est supprimée.
## 5. Trésorerie ## 5. Trésorerie
@ -205,21 +204,20 @@ Types disponibles :
- dépôts bancaires; - dépôts bancaires;
- retraits 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. Règle importante : le total des réserves ne peut jamais dépasser le solde en banque.
### Opérations bancaires
Permet d'enregistrer : 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 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 ## 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 ## 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 ### Rédiger PV
Permet de préparer un procès-verbal de réunion d'affaires. 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; - heure;
- format. - format.
## 13. Sondages ## 13. Bonnes pratiques
### Lancer un sondage
Permet à l'exécutif de créer un sondage.
### Votes ouverts
Permet aux membres de voter.
## 14. Bonnes pratiques
- Utiliser son propre compte, jamais celui d'un autre membre. - Utiliser son propre compte, jamais celui d'un autre membre.
- Ne pas partager son PIN. - Ne pas partager son PIN.
@ -375,7 +367,7 @@ Permet aux membres de voter.
- Garder les réserves à jour. - Garder les réserves à jour.
- Utiliser les propositions et résolutions pour conserver une trace claire des décisions. - 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 ### Je ne peux pas me connecter
@ -397,7 +389,7 @@ Demander à l'exécutif de vérifier :
### Le dépôt est refusé ### 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 ### Une réserve est refusée
@ -415,7 +407,7 @@ Causes possibles :
- collecte déjà enregistrée pour cette date; - collecte déjà enregistrée pour cette date;
- date future. - 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. - Les données sont séparées par groupe.
- Un membre se connecte avec son courriel et son PIN. - 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 résolutions adoptées sont numérotées automatiquement.
- Les mandats ne se terminent pas seuls. - Les mandats ne se terminent pas seuls.
- Les accès aux modules sont contrôlés par les postes. - 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 AjusterInventaire from './pages/AjusterInventaire';
import EnvoiContributions from './pages/EnvoiContributions'; import EnvoiContributions from './pages/EnvoiContributions';
import ConfigRepartition from './pages/ConfigRepartition'; 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 Tresorerie from './pages/Tresorerie';
import Impliquer from './pages/Impliquer'; import Impliquer from './pages/Impliquer';
import GererPostes from './pages/GererPostes'; 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() { export default function App() {
const { token, page, loadAccueil, loadGroupes } = useStore(); const { token, page, loadAccueil, loadGroupes } = useStore();
@ -57,37 +57,45 @@ export default function App() {
// Pages protégées // Pages protégées
if (token) { if (token) {
let pageActive;
switch (page) { switch (page) {
case 'accueil': return <Accueil />; case 'accueil': pageActive = <Accueil />; break;
case 'profil': return <Profil />; case 'profil': pageActive = <Profil />; break;
case 'S1': return <Collecte />; case 'S0': pageActive = <ReunionHebdomadaire />; break;
case 'S1T': return <Tresorerie />; case 'M0': pageActive = <AssembleeAffaires />; break;
case 'S4': return <RapportReunion />; case 'S1': pageActive = <Collecte />; break;
case 'S5': return <Depense />; case 'S1T': pageActive = <Tresorerie />; break;
case 'C1': return <Rapports />; case 'S4': pageActive = <RapportReunion />; break;
case 'C2': return <Evenements />; case 'S5': pageActive = <Depense />; break;
case 'C5': return <Decisions />; case 'C1': pageActive = <Rapports />; break;
case 'G2': return <GererMembres />; case 'C2': pageActive = <Evenements />; break;
case 'G9': return <Parametres />; case 'C5': pageActive = <Decisions />; break;
case 'G11': return <GestionMandats />; case 'G2': pageActive = <GererMembres />; break;
case 'G12': return <GererPostes />; case 'G9': pageActive = <Parametres />; break;
case 'M1': return <RedigerPV />; case 'G11': pageActive = <GestionMandats />; break;
case 'M2': return <Presences />; case 'G12': pageActive = <GererPostes />; break;
case 'M3': return <RapportRSG />; case 'G13': pageActive = <JournalActions />; break;
case 'S2': return <RemiseJeton />; case 'M1': pageActive = <RedigerPV />; break;
case 'M6': return <CommanderJetons />; case 'M2': pageActive = <Presences />; break;
case 'S3': return <VenteLitterature />; case 'M3': pageActive = <RapportRSG />; break;
case 'C4': return <Inventaire />; case 'S2': pageActive = <RemiseJeton />; break;
case 'M5': return <AjusterInventaire />; case 'M6': pageActive = <CommanderJetons />; break;
case 'M4': return <EnvoiContributions />; case 'S3': pageActive = <VenteLitterature />; break;
case 'G8': return <ConfigRepartition />; case 'C4': pageActive = <Inventaire />; break;
case 'G6': return <LancerSondage />; case 'M5': pageActive = <AjusterInventaire />; break;
case 'C6': return <VotesOuverts />; case 'M4': pageActive = <EnvoiContributions />; break;
case 'C8': return <Propositions />; case 'G8': pageActive = <ConfigRepartition />; break;
case 'C9': return <Impliquer />; case 'C9': pageActive = <Impliquer />; break;
case 'C10': return <EtatFinancier />; case 'C10': pageActive = <Tresorerie />; break;
default: return <Accueil />; default: pageActive = <Accueil />;
} }
const afficherNavigationContextes = page === 'accueil';
return (
<>
<main className={afficherNavigationContextes ? 'app-bottom-space' : ''}>{pageActive}</main>
{afficherNavigationContextes && <BottomNav />}
</>
);
} }
// Pages publiques // 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 }) { export default function PageHeader({ titre, sousTitre }) {
const { setPage } = useStore(); const { setPage } = useStore();
return ( return (
<div className="bg-aa-blue text-white p-4 flex items-center gap-3"> <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="text-2xl active:opacity-60">←</button> <button
<div> onClick={() => setPage('accueil')}
<h1 className="text-lg font-bold">{titre}</h1> className="min-w-[48px] min-h-[48px] rounded-2xl bg-white/10 text-2xl active:bg-white/20"
{sousTitre && <p className="text-blue-200 text-xs">{sousTitre}</p>} 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>
</div> </div>
); );

View file

@ -10,6 +10,7 @@
body { body {
font-family: 'DM Sans', sans-serif; font-family: 'DM Sans', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overscroll-behavior-y: contain;
} }
button:active { button:active {
@ -20,3 +21,18 @@ button:active {
from { transform: scale(0.5); opacity: 0; } from { transform: scale(0.5); opacity: 0; }
to { transform: scale(1); opacity: 1; } 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'; import { useStore } from '../stores/store';
const COULEURS = { const COULEURS = {
@ -12,18 +12,65 @@ const COULEURS = {
gray: 'bg-gray-50 border-gray-200 text-gray-700', 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 }) { function Tuile({ module, onClick }) {
const cls = COULEURS[module.couleur] || COULEURS.gray; const cls = COULEURS[module.couleur] || COULEURS.gray;
return ( return (
<button <button
onClick={onClick} 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"> <div className="flex items-center gap-3">
<span className="text-2xl">{module.icone}</span> <span className="text-2xl">{module.icone}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-bold text-sm">{module.nom}</p> <p className="text-base font-bold leading-tight">{module.nom}</p>
<p className="text-xs opacity-70 truncate">{module.description}</p> <p className="text-xs opacity-70 line-clamp-2">{module.description}</p>
</div> </div>
<span className="text-xs font-mono opacity-40">{module.code}</span> <span className="text-xs font-mono opacity-40">{module.code}</span>
</div> </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() { 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 ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-gray-100">
{/* Header */} <div className="bg-aa-blue text-white p-4 pb-5 rounded-b-3xl shadow-lg">
<div className="bg-aa-blue text-white p-4 pb-6 rounded-b-3xl shadow-lg"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center justify-between"> <div className="min-w-0">
<div> <p className="text-blue-200 text-xs">Bonjour {membre?.prenom}</p>
<p className="text-blue-200 text-xs">Bonjour</p> <h1 className="text-xl font-bold leading-tight">{contexte.titre}</h1>
<h1 className="text-xl font-bold">{membre?.prenom}</h1> <p className="text-sm text-blue-100 mt-1">{contexte.description}</p>
</div> </div>
<button onClick={logout} className="text-blue-200 text-sm active:text-white"> <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">
Déconnexion Profil
</button> </button>
</div> </div>
{postes.length > 0 && ( {postes.length > 0 && (
@ -77,11 +144,49 @@ export default function Accueil() {
)} )}
</div> </div>
{/* Modules */} <div className="p-4 space-y-5">
<div className="p-4 space-y-6 pb-20"> <input
<Section titre="Mes tâches" modules={modulesSaisie} /> type="search"
<Section titre="Consultation" modules={modulesConsultation} /> value={recherche}
<Section titre="Gestion" modules={modulesGestion} /> 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>
</div> </div>
); );

View file

@ -1,16 +1,21 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { api } from '../stores/api'; import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck'; import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { datesReunion, dateLocale } from '../utils/datesReunion';
export default function AffecterPostes({ compact = false }) { export default function AffecterPostes({ compact = false }) {
const { groupe } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [postes, setPostes] = useState([]); const [postes, setPostes] = useState([]);
const [membres, setMembres] = useState([]); const [membres, setMembres] = useState([]);
const [affectations, setAffectations] = useState([]); const [affectations, setAffectations] = useState([]);
const [form, setForm] = useState({ const [form, setForm] = useState({
membre_id: '', membre_id: '',
poste_id: '', poste_id: '',
date_debut: new Date().toISOString().slice(0, 10), date_debut: dateLocale(),
proposeur_id: '', proposeur_id: '',
secondeur_id: '', secondeur_id: '',
}); });
@ -31,6 +36,12 @@ export default function AffecterPostes({ compact = false }) {
}).catch(() => setLoading(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 () => { const handleSubmit = async () => {
if (!form.membre_id || !form.poste_id || !form.proposeur_id || !form.secondeur_id) { 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'); setError('Sélectionnez un membre, un poste, un proposeur et un secondeur');
@ -131,9 +142,7 @@ export default function AffecterPostes({ compact = false }) {
<div> <div>
<label className="text-xs text-gray-400 block mb-1">Date de début</label> <label className="text-xs text-gray-400 block mb-1">Date de début</label>
<input type="date" value={form.date_debut} <MeetingDateSelect dates={choixDates} value={form.date_debut} onChange={(v) => setForm({ ...form, date_debut: v })} />
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" />
</div> </div>
</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 { api } from '../stores/api';
import { useStore } from '../stores/store'; import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import { dateLocale, dernieresDatesReunion, indexJourReunion, labelDateReunion } from '../utils/datesReunion';
const STATUT = { const STATUT = {
en_attente: { label: 'À confirmer', cls: 'bg-yellow-100 text-yellow-700' }, en_attente: { label: 'À confirmer', cls: 'bg-yellow-100 text-yellow-700' },
@ -10,7 +11,7 @@ const STATUT = {
}; };
export default function Collecte() { export default function Collecte() {
const { membre, groupe } = useStore(); const { membre, groupe, estSysadmin } = useStore();
const [montant, setMontant] = useState(''); const [montant, setMontant] = useState('');
const [dateCollecte, setDateCollecte] = useState(new Date().toISOString().slice(0, 10)); const [dateCollecte, setDateCollecte] = useState(new Date().toISOString().slice(0, 10));
const [detenteurId, setDetenteurId] = useState(''); const [detenteurId, setDetenteurId] = useState('');
@ -79,50 +80,19 @@ export default function Collecte() {
const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']; '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 labelDate = (d, avecAnnee = false) => {
const dt = new Date(d + 'T12:00:00'); return labelDateReunion(d, avecAnnee);
return dt.toLocaleDateString('fr-CA', {
weekday: 'long',
day: 'numeric',
month: 'long',
...(avecAnnee ? { year: 'numeric' } : {}),
});
}; };
// Calculer les dernières dates de réunion pour le sélecteur const jourReunion = indexJourReunion(groupe);
const joursSemaine = { 'lundi': 1, 'mardi': 2, 'mercredi': 3, 'jeudi': 4, 'vendredi': 5, 'samedi': 6, 'dimanche': 0 }; const datesAvecCollecte = new Set(collectesToutes.map((c) => c.date_collecte));
const jourReunion = groupe?.jour_reunion ? joursSemaine[groupe.jour_reunion.toLowerCase()] : null; const datesReunion = dernieresDatesReunion(groupe, { nombre: 52, exclure: datesAvecCollecte });
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();
useEffect(() => { useEffect(() => {
if (datesReunion.length > 0 && !datesReunion.includes(dateCollecte)) { if (!estSysadmin && datesReunion.length > 0 && !datesReunion.includes(dateCollecte)) {
setDateCollecte(datesReunion[0]); setDateCollecte(datesReunion[0]);
} }
}, [collectesToutes, groupe?.jour_reunion]); }, [collectesToutes, groupe?.jour_reunion, estSysadmin]);
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-gray-100">
@ -236,7 +206,10 @@ export default function Collecte() {
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm"> <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> <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)} <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"> className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
{datesReunion.map((d) => { {datesReunion.map((d) => {
@ -245,17 +218,17 @@ export default function Collecte() {
return <option key={d} value={d}>{estAujourdhui ? '● Aujourd\'hui' : label}</option>; return <option key={d} value={d}>{estAujourdhui ? '● Aujourd\'hui' : label}</option>;
})} })}
</select> </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"> <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> </div>
)} )}
<p className="text-xs text-gray-400 mt-1"> <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> </p>
</div> </div>
@ -281,7 +254,7 @@ export default function Collecte() {
</select> </select>
</div> </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"> 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'} {loading ? '...' : '💰 Enregistrer la collecte'}
</button> </button>

View file

@ -1,110 +1,315 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { api } from '../stores/api'; import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
const STATUTS = { const STATUTS = {
en_attente: { label: 'En attente', bg: 'bg-yellow-100', text: 'text-yellow-800', emoji: '⏳' }, proposee: { label: 'À seconder', cls: 'bg-yellow-100 text-yellow-700' },
en_cours: { label: 'En cours', bg: 'bg-blue-100', text: 'text-blue-800', emoji: '🔄' }, secondee: { label: 'Secondée', cls: 'bg-blue-100 text-blue-700' },
complete: { label: 'Complété', bg: 'bg-green-100', text: 'text-green-800', emoji: '✓' }, 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 TYPES = {
const s = STATUTS[statut] || STATUTS.en_attente; proposition: 'Proposition',
return ( resolution: 'Résolution',
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-bold ${s.bg} ${s.text}`}> generale: 'Résolution',
{s.emoji} {s.label} nomination: 'Nomination',
</span> contribution: 'Contribution',
); };
}
export default function Decisions() { export default function Decisions() {
const [decisions, setDecisions] = useState([]); const { postes } = useStore();
const [filtre, setFiltre] = useState('ouvertes'); // ouvertes | toutes const [propositions, setPropositions] = useState([]);
const [resolutions, setResolutions] = useState([]);
const [tab, setTab] = useState('cours');
const [loading, setLoading] = useState(true); 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 () => { const charger = async () => {
setLoading(true); setLoading(true);
try { try {
let url = '/decisions/'; const [props, res] = await Promise.all([
if (filtre === 'ouvertes') { api.get('/propositions/?limite=100'),
// Charger en_attente et en_cours api.get('/propositions/resolutions'),
const [att, enc] = await Promise.all([ ]);
api.get('/decisions/?statut=en_attente'), setPropositions(props);
api.get('/decisions/?statut=en_cours'), setResolutions(res);
]); if (res.length > 0 && !resolutionId) setResolutionId(res[0].id);
setDecisions([...enc, ...att]); } catch (e) { setError(e.message); }
} else {
const data = await api.get(url);
setDecisions(data);
}
} catch (e) {
console.error(e);
}
setLoading(false); 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 ( return (
<div className="min-h-screen bg-gray-100"> <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"> <div className="flex gap-2">
{[ {[
{ key: 'ouvertes', label: 'Ouvertes' }, ['cours', `En cours (${enCours.length})`],
{ key: 'toutes', label: 'Toutes' }, ['nouvelle', 'Soumettre'],
].map((f) => ( ['historique', `Résolutions (${resolutions.length})`],
<button ].map(([val, label]) => (
key={f.key} <button key={val} onClick={() => { setTab(val); setError(''); }}
onClick={() => setFiltre(f.key)} className={`flex-1 py-2 rounded-xl text-sm font-bold transition ${
className={`px-4 py-2 rounded-full text-sm font-bold transition ${ tab === val ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
filtre === f.key }`}>
? 'bg-aa-blue text-white' {label}
: 'bg-white text-gray-600 border border-gray-200'
}`}
>
{f.label}
</button> </button>
))} ))}
</div> </div>
</div>
{/* Liste */} {tab === 'cours' && (
<div className="p-4 space-y-3"> enCours.length === 0 ? (
{loading ? ( <div className="text-center py-12">
<p className="text-center text-gray-400 py-8">Chargement...</p> <p className="text-4xl mb-2">📌</p>
) : decisions.length === 0 ? ( <p className="text-gray-500">Aucune décision en cours</p>
<div className="text-center py-12"> <button onClick={() => setTab('nouvelle')} className="mt-3 text-aa-blue font-bold text-sm">
<p className="text-4xl mb-2">📌</p> Soumettre une proposition
<p className="text-gray-500">Aucune décision {filtre === 'ouvertes' ? 'ouverte' : ''}</p> </button>
</div> </div>
) : ( ) : (
decisions.map((d) => ( <div className="space-y-3">
<div key={d.id} className="bg-white rounded-2xl p-4 shadow-sm"> {enCours.map((p) => {
<div className="flex items-start justify-between gap-3"> const statut = STATUTS[p.statut] || STATUTS.proposee;
<div className="flex-1"> const form = resolutionForm[p.id] || {};
<p className="font-bold text-gray-800">{d.sujet}</p> const resolutionComplete = (form.etant_donne_que || '').trim() && (form.groupe_a_decide_de || '').trim();
{d.description && ( return (
<p className="text-sm text-gray-500 mt-1">{d.description}</p> <div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
)} <div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2 mt-2"> <div className="min-w-0">
<Badge statut={d.statut} /> <p className="font-bold text-gray-800">{p.sujet}</p>
{d.responsable_prenom && ( {p.description && <p className="text-sm text-gray-500 mt-1">{p.description}</p>}
<span className="text-xs text-gray-400">→ {d.responsable_prenom}</span> </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> </div>
{d.proposeur && ( );
<p className="text-xs text-gray-400 mt-1"> })}
Proposé par {d.proposeur}{d.secondeur ? `, secondé par ${d.secondeur}` : ''} </div>
</p> )
)}
{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>
)) )
)} )}
</div> </div>
</div> </div>

View file

@ -4,48 +4,33 @@ import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck'; 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() { export default function Depense() {
const { postes } = useStore(); const { membre, postes, estSysadmin } = useStore();
const [form, setForm] = useState({ const [form, setForm] = useState({
date_depense: new Date().toISOString().slice(0, 10), montant: '', quoi: '', membre_id: '', date_depense: new Date().toISOString().slice(0, 10),
montant: '', quoi: '',
}); });
const [fichier, setFichier] = useState(null); const [fichier, setFichier] = useState(null);
const [depenses, setDepenses] = useState([]); const [membres, setMembres] = useState([]);
const [aConfirmer, setAConfirmer] = useState([]);
const [tab, setTab] = useState('saisie');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [msg, setMsg] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const estTresorier = postes.some((p) => const estExecutif = postes.some((p) =>
['Trésorier(ière)', 'RSG', 'RSG Substitut(e)', 'Secrétaire'].includes(p) ['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p)
); );
const charger = async () => { const charger = async () => {
try { try {
const [d] = await Promise.all([ if (estExecutif) {
api.get('/depenses/?limite=30'), const data = await api.get('/membres/actifs');
]); setMembres(data);
setDepenses(d); setForm((f) => ({ ...f, membre_id: f.membre_id || membre?.id || data[0]?.id || '' }));
if (estTresorier) {
const ac = await api.get('/depenses/a-confirmer');
setAConfirmer(ac);
} }
} catch (e) { setError(e.message); } } 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 update = (k, v) => setForm({ ...form, [k]: v });
const handleSubmit = async () => { const handleSubmit = async () => {
@ -55,9 +40,10 @@ export default function Depense() {
setLoading(true); setError(''); setLoading(true); setError('');
try { try {
const result = await api.post('/depenses/', { const result = await api.post('/depenses/', {
date_depense: form.date_depense,
montant: parseFloat(form.montant), montant: parseFloat(form.montant),
quoi: form.quoi.trim(), quoi: form.quoi.trim(),
membre_id: estExecutif ? form.membre_id || null : null,
date_depense: estSysadmin ? form.date_depense : null,
}); });
// Upload preuve si présente // Upload preuve si présente
if (fichier && result.id) { if (fichier && result.id) {
@ -73,201 +59,73 @@ export default function Depense() {
} catch (e) { setError(e.message); setLoading(false); } } 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 ✓" />; if (success) return <SuccessCheck message="Dépense soumise ✓" />;
const engagements = depenses.filter((d) => d.confirme && !d.paye && ['VIREMENT', 'CHEQUE'].includes(d.methode));
return ( return (
<div className="min-h-screen bg-gray-100"> <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"> <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>} {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"> {estExecutif && (
{[ <div className="bg-white rounded-2xl p-4 shadow-sm">
['saisie', '💸 Soumettre'], <label className="text-xs text-gray-400 block mb-1">Membre</label>
...(estTresorier ? [['confirmer', `📥 À confirmer (${aConfirmer.length})`]] : []), <select value={form.membre_id} onChange={(e) => update('membre_id', e.target.value)}
...(engagements.length > 0 ? [['engagements', `⏳ Engagements (${engagements.length})`]] : []), className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
['historique', `📋 Tout (${depenses.length})`], {membres.map((m) => (
].map(([t, label]) => ( <option key={m.id} value={m.id}>{m.prenom}</option>
<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} />
))} ))}
</div> </select>
)
)}
{/* ── Engagements ── */}
{tab === 'engagements' && (
<div className="space-y-3">
{engagements.map((d) => (
<div key={d.id} className="bg-white rounded-2xl p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-bold text-gray-800">{d.quoi}</p>
<p className="text-lg font-bold text-orange-600">{parseFloat(d.montant).toFixed(2)} $</p>
<p className="text-xs text-gray-400">{d.date_depense} · {d.qui_prenom} · {d.methode}</p>
</div>
<span className="text-xs px-2 py-1 rounded-full font-medium bg-orange-100 text-orange-700">À débiter</span>
</div>
{estTresorier && (
<button onClick={() => confirmerDebit(d.id)}
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97] transition">
✓ Confirmer le débit
</button>
)}
</div>
))}
</div> </div>
)} )}
{/* ── Historique ── */} {estSysadmin && (
{tab === 'historique' && ( <div className="bg-white rounded-2xl p-4 shadow-sm">
depenses.length === 0 ? ( <label className="text-xs text-gray-400 block mb-1">Date de la dépense</label>
<div className="text-center py-12"> <input type="date" value={form.date_depense} onChange={(e) => update('date_depense', e.target.value)}
<p className="text-4xl mb-2">💸</p> className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
<p className="text-gray-500">Aucune dépense</p> </div>
</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>
)
)} )}
</div>
</div>
);
}
function ConfirmCard({ depense, onConfirm }) { <div className="bg-white rounded-2xl p-4 shadow-sm">
const [methode, setMethode] = useState(''); <label className="text-xs text-gray-400 block mb-1">Montant</label>
return ( <div className="relative">
<div className="bg-white rounded-2xl p-4 shadow-sm"> <span className="absolute left-4 top-1/2 -translate-y-1/2 text-xl text-gray-400">$</span>
<div className="flex-1"> <input type="number" inputMode="decimal" step="0.01" min="0" placeholder="0.00"
<p className="font-bold text-gray-800">{depense.quoi}</p> value={form.montant} onChange={(e) => update('montant', e.target.value)}
<p className="text-lg font-bold text-red-600">{parseFloat(depense.montant).toFixed(2)} $</p> 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"
<p className="text-xs text-gray-400">{depense.date_depense} · Soumis par {depense.saisi_par_prenom}</p> autoFocus />
{depense.preuve_nom && <p className="text-xs text-blue-500 mt-1">📎 Preuve jointe</p>} </div>
</div>
<div className="mt-3">
<p className="text-xs text-gray-400 mb-1">Mode de paiement</p>
<div className="grid grid-cols-4 gap-1">
{METHODES.map((m) => (
<button key={m.value} onClick={() => setMethode(m.value)}
className={`p-2 rounded-lg text-xs font-bold border ${
methode === m.value ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-500 border-gray-200'
}`}>{m.emoji}<br/>{m.label}</button>
))}
</div> </div>
<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> </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> </div>
); );
} }

View file

@ -1,7 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { api } from '../stores/api'; import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck'; import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { datesReunion, dateLocale } from '../utils/datesReunion';
const METHODES = [ const METHODES = [
{ value: 'VIREMENT', label: 'Virement', emoji: '🏦' }, { value: 'VIREMENT', label: 'Virement', emoji: '🏦' },
@ -16,6 +19,8 @@ const DESTINATIONS = [
]; ];
export default function EnvoiContributions() { export default function EnvoiContributions() {
const { groupe, estSysadmin } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [soldes, setSoldes] = useState(null); const [soldes, setSoldes] = useState(null);
const [membres, setMembres] = useState([]); const [membres, setMembres] = useState([]);
const [envois, setEnvois] = useState([]); const [envois, setEnvois] = useState([]);
@ -24,7 +29,7 @@ export default function EnvoiContributions() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
date_envoi: new Date().toISOString().slice(0, 10), date_envoi: dateLocale(),
destination: 'district', destination: 'district',
montant: '', montant: '',
methode: '', methode: '',
@ -47,6 +52,12 @@ export default function EnvoiContributions() {
}).catch(() => {}); }).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 update = (k, v) => setForm({ ...form, [k]: v });
const handleSubmit = async () => { const handleSubmit = async () => {
@ -87,7 +98,7 @@ export default function EnvoiContributions() {
</div> </div>
<div className="text-right text-xs text-blue-500"> <div className="text-right text-xs text-blue-500">
<p>Banque: {soldes.solde_banque.toFixed(2)} $</p> <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> </div>
</div> </div>
@ -121,8 +132,12 @@ export default function EnvoiContributions() {
<div className="bg-white rounded-2xl p-4 shadow-sm"> <div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date</label> <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)} {estSysadmin ? (
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> <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>
<div className="bg-white rounded-2xl p-4 shadow-sm"> <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 { api } from '../stores/api';
import PageHeader from '../components/PageHeader'; 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 [data, setData] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
useEffect(() => { 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 ( const telechargerPdf = async () => {
<div className="min-h-screen bg-gray-100"> setGenerating(true);
<PageHeader titre="État financier" sousTitre="Résultats et transactions en temps réel" /> 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>} {error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
{data && ( {data && (
<> <>
{/* Soldes principaux */} {afficherTresorerie && (
<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="bg-white rounded-2xl p-4 shadow-sm">
<div className="grid grid-cols-2 gap-3"> <h2 className="text-lg font-extrabold text-gray-900 uppercase tracking-wide mb-3">Position de trésorerie</h2>
<Bloc label="Solde banque" montant={data.solde_banque} couleur="blue" /> <div className="grid grid-cols-2 gap-3">
<Bloc label="Encaisse" montant={data.encaisse} couleur="green" /> <Bloc label="Banque" montant={data.solde_banque} couleur="blue" />
<Bloc label="Disponible" montant={data.disponible} couleur={data.disponible >= 0 ? 'green' : 'red'} /> <Bloc label="Encaisse" montant={data.encaisse} couleur="green" />
<Bloc label="Résultat net" montant={data.resultat_net} couleur={data.resultat_net >= 0 ? 'green' : 'red'} /> <Bloc label="Réserves" montant={data.reserves?.total} couleur="amber" />
</div> <Bloc label="Engagé" montant={data.sorties.engagements} couleur="orange" />
{data.date_releve && ( <div className="col-span-2">
<p className="text-xs text-gray-400 mt-2 text-center">Dernier relevé : {data.date_releve}</p> <Bloc label="Disponible" montant={data.disponible} couleur={data.disponible >= 0 ? 'green' : 'red'} />
)} </div>
</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>
</div> </div>
))} {data.date_releve && (
</div> <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 */} <div className="bg-white rounded-2xl p-4 shadow-sm">
{(data.dernieres_depenses || []).length > 0 && ( <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"> <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> <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) => ( {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 key={i} className="flex justify-between py-1.5 border-b border-gray-50 last:border-0">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-gray-600 truncate">{d.quoi}</p> <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> </div>
<span className="text-sm font-bold text-red-600 whitespace-nowrap ml-2">-{formatMontant(d.montant)} $</span> <span className="text-sm font-bold text-red-600 whitespace-nowrap ml-2">-{formatMontant(d.montant)} $</span>
</div> </div>
@ -105,6 +161,14 @@ export default function EtatFinancier() {
</> </>
)} )}
</div> </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> </div>
); );
} }
@ -114,6 +178,8 @@ function Bloc({ label, montant, couleur }) {
blue: 'bg-blue-50 text-blue-700', blue: 'bg-blue-50 text-blue-700',
green: 'bg-green-50 text-green-700', green: 'bg-green-50 text-green-700',
red: 'bg-red-50 text-red-700', red: 'bg-red-50 text-red-700',
orange: 'bg-orange-50 text-orange-700',
amber: 'bg-amber-50 text-amber-700',
}; };
return ( return (
<div className={`text-center p-3 rounded-xl ${cls[couleur] || cls.blue}`}> <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 }) { function Ligne({ label, montant }) {
return ( return (
<div className="flex justify-between py-1"> <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) { function formatMontant(montant) {
const valeur = Number(montant); const valeur = Number(montant);
return Number.isFinite(valeur) ? valeur.toFixed(2) : '0.00'; return Number.isFinite(valeur) ? valeur.toFixed(2) : '0.00';

View file

@ -6,13 +6,12 @@ import PageHeader from '../components/PageHeader';
const TYPE_STYLE = { const TYPE_STYLE = {
anniversaire: { icon: '🎂', cls: 'bg-yellow-50 border-yellow-200', accent: 'text-yellow-700' }, 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' }, 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() { export default function Evenements() {
const { postes } = useStore(); const { postes } = useStore();
const [evenements, setEvenements] = useState([]); const [evenements, setEvenements] = useState([]);
const [filtres, setFiltres] = useState(['anniversaire', 'adhoc', 'resolution']); const [filtres, setFiltres] = useState(['anniversaire', 'adhoc']);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [error, setError] = useState(''); 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 // Grouper par date
const parDate = {}; const parDate = {};
@ -71,7 +70,7 @@ export default function Evenements() {
return ( return (
<div className="min-h-screen bg-gray-100"> <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"> <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>} {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 */} {/* Filtres */}
<div className="flex gap-2 overflow-x-auto"> <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)} <button key={k} onClick={() => toggleFiltre(k)}
className={`px-3 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition ${ 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' 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 React, { useState, useEffect } from 'react';
import { api } from '../stores/api'; import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
export default function GererMembres() { export default function GererMembres() {
const { membre, estSysadmin, impersoner } = useStore();
const [membres, setMembres] = useState([]); const [membres, setMembres] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState(null); const [detail, setDetail] = useState(null);
@ -68,6 +70,12 @@ export default function GererMembres() {
} catch (e) { alert(e.message); } } catch (e) { alert(e.message); }
}; };
const agirComme = async (id) => {
try {
await impersoner(id);
} catch (e) { alert(e.message); }
};
// Vue détail d'un membre // Vue détail d'un membre
if (detail) { if (detail) {
return ( return (
@ -109,6 +117,13 @@ export default function GererMembres() {
</div> </div>
{/* Actions */} {/* 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 && ( {detail.actif && !confirmAction && (
<button onClick={() => setConfirmAction('desactiver')} <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"> 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 { api } from '../stores/api';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
@ -9,19 +9,30 @@ const CATEGORIES = [
]; ];
const MODULES = [ const MODULES = [
['S0', 'Rencontre hebdomadaire'], ['M0', "Assemblée d'affaires"],
['S1', 'Collecte 7e'], ['C5', 'Gouvernance'],
['S1T', 'Trésorerie'], ['M4', 'Envoi contributions'], ['S1T', 'Trésorerie'], ['M4', 'Envoi contributions'],
['M1', 'Rédiger PV'], ['M2', 'Présences'], ['M3', 'Rapport RSG'], ['M1', 'Rédiger PV'], ['M2', 'Présences'], ['M3', 'Rapport RSG'],
['S2', 'Remise jeton'], ['M6', 'Commander jetons'], ['S2', 'Remise jeton'], ['M6', 'Commander jetons'],
['S3', 'Vente littérature'], ['M5', 'Ajuster inventaire'], ['S4', 'Rapport réunion'], ['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'], ['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() { export default function GererPostes() {
const [postes, setPostes] = useState([]); const [postes, setPostes] = useState([]);
const [modulesPostes, setModulesPostes] = useState({}); const [modulesPostes, setModulesPostes] = useState({});
const [candidatures, setCandidatures] = useState([]); const [candidatures, setCandidatures] = useState([]);
const [tab, setTab] = useState('postes'); const [tab, setTab] = useState('postes');
const [posteOuvert, setPosteOuvert] = useState(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -112,6 +123,14 @@ export default function GererPostes() {
}; };
const CAT_ICON = { executif: '🔵', service: '🟣', physique: '🟢' }; 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 ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-gray-100">
@ -124,7 +143,6 @@ export default function GererPostes() {
<div className="flex gap-1 overflow-x-auto"> <div className="flex gap-1 overflow-x-auto">
{[ {[
['postes', `Postes (${postes.length})`], ['postes', `Postes (${postes.length})`],
['acces', 'Accès'],
['candidatures', `Candidatures (${candidatures.length})`], ['candidatures', `Candidatures (${candidatures.length})`],
].map(([t, label]) => ( ].map(([t, label]) => (
<button key={t} onClick={() => { setTab(t); setError(''); }} <button key={t} onClick={() => { setTab(t); setError(''); }}
@ -164,6 +182,7 @@ export default function GererPostes() {
<div className="flex-1"> <div className="flex-1">
<p className="font-bold text-gray-800">{CAT_ICON[p.categorie] || '⚪'} {p.nom}</p> <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.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>} {p.informations && <p className="text-xs text-gray-400 mt-1 italic">{p.informations}</p>}
</div> </div>
{p.actif ? ( {p.actif ? (
@ -175,28 +194,42 @@ export default function GererPostes() {
</div> </div>
)} )}
</div> </div>
</div>
))}
</div>
)}
{tab === 'acces' && ( <div className="mt-3 pt-3 border-t border-gray-100">
<div className="space-y-3"> <button
{postes.filter((p) => p.actif).map((p) => ( type="button"
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm"> onClick={() => setPosteOuvert(posteOuvert === p.id ? null : p.id)}
<p className="font-bold text-gray-800 mb-3">{p.nom}</p> className={`w-full min-h-[44px] rounded-xl text-sm font-bold border ${
<div className="grid grid-cols-2 gap-2"> posteOuvert === p.id ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-700 border-gray-200'
{MODULES.map(([code, label]) => { }`}
const actif = (modulesPostes[p.id] || []).includes(code); >
return ( Rôles associés ({(modulesPostes[p.id] || []).length})
<button key={code} onClick={() => toggleModule(p.id, code)} </button>
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' {posteOuvert === p.id && (
}`}> <div className="mt-3 space-y-4">
{code} · {label} {groupesPourPoste(p.id).map((groupe) => (
</button> <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>
</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(() => { useEffect(() => {
if (groupes.length === 1 && !groupeId) setGroupeId(groupes[0].id); if (groupes.length === 0) return;
}, [groupes]); 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) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
@ -76,12 +85,12 @@ export default function Login() {
)} )}
<input <input
type="email" type="text"
placeholder="Adresse courriel" placeholder="Adresse courriel ou identifiant"
value={courriel} value={courriel}
onChange={(e) => setCourriel(e.target.value)} 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" 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" inputMode="email"
/> />

View file

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

View file

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

View file

@ -1,6 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useStore } from '../stores/store'; import { useStore } from '../stores/store';
import { api } from '../stores/api'; import { api } from '../stores/api';
import {
disablePushNotifications,
enablePushNotifications,
getPushSubscription,
pushSupported,
sendTestNotification,
} from '../utils/pushNotifications';
export default function Profil() { export default function Profil() {
const { setPage, logout } = useStore(); const { setPage, logout } = useStore();
@ -11,6 +18,8 @@ export default function Profil() {
const [showPin, setShowPin] = useState(false); const [showPin, setShowPin] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [pushInfo, setPushInfo] = useState({ supported: pushSupported(), enabled: false, configured: false });
const [pushBusy, setPushBusy] = useState(false);
useEffect(() => { useEffect(() => {
api.get('/membres/moi').then((data) => { api.get('/membres/moi').then((data) => {
@ -23,6 +32,16 @@ export default function Profil() {
date_abstinence: data.date_abstinence || '', 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 () => { const saveProfile = async () => {
@ -51,6 +70,50 @@ export default function Profil() {
} catch (e) { setError(e.message); setTimeout(() => setError(''), 3000); } } 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>; 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 }) => ( const Field = ({ label, value }) => (
@ -131,6 +194,47 @@ export default function Profil() {
)} )}
</div> </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 */} {/* Changer PIN */}
<div className="bg-white rounded-2xl p-4 shadow-sm"> <div className="bg-white rounded-2xl p-4 shadow-sm">
{!showPin ? ( {!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 { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck'; import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { dateHeureReunionIso, datesReunion, dateLocale } from '../utils/datesReunion';
export default function RapportReunion() { export default function RapportReunion() {
const { groupe } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [theme, setTheme] = useState(''); const [theme, setTheme] = useState('');
const [annonces, setAnnonces] = useState(''); const [annonces, setAnnonces] = useState('');
const [note, setNote] = 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 [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [error, setError] = useState(''); 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 () => { const handleSubmit = async () => {
if (!theme.trim()) { if (!theme.trim()) {
setError('Le thème est obligatoire'); setError('Le thème est obligatoire');
@ -27,7 +38,7 @@ export default function RapportReunion() {
await api.post('/reunions/', { await api.post('/reunions/', {
type: 'reguliere', type: 'reguliere',
date_reunion: new Date(dateReunion).toISOString(), date_reunion: dateHeureReunionIso(dateReunion, groupe),
theme: theme.trim(), theme: theme.trim(),
annonces: annoncesList.length > 0 ? annoncesList : null, annonces: annoncesList.length > 0 ? annoncesList : null,
note: note.trim() || null, note: note.trim() || null,
@ -49,12 +60,8 @@ export default function RapportReunion() {
{/* Date */} {/* Date */}
<div className="bg-white rounded-2xl p-4 shadow-sm"> <div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date et heure</label> <label className="text-xs text-gray-400 block mb-1">Date et heure</label>
<input <MeetingDateSelect dates={choixDates} value={dateReunion} onChange={setDateReunion}
type="datetime-local" className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" />
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"
/>
</div> </div>
{/* Thème */} {/* Thème */}

View file

@ -44,7 +44,8 @@ export default function Rapports() {
setGenerating(null); 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 ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-gray-100">
@ -62,26 +63,12 @@ export default function Rapports() {
) : ( ) : (
<> <>
{/* Rapports mensuels */} {/* Rapports mensuels */}
{disponibles.map((m) => ( {rapportsMensuels.map((m) => (
<div key={`${m.annee}-${m.mois}`} className="bg-white rounded-2xl p-4 shadow-sm"> <div key={`${m.annee}-${m.mois}`} className="bg-white rounded-2xl p-4 shadow-sm">
<h3 className="font-bold text-gray-800 mb-3"> <h3 className="font-bold text-gray-800 mb-3">
{MOIS_FR[m.mois]} {m.annee} {MOIS_FR[m.mois]} {m.annee}
</h3> </h3>
<div className="grid gap-2"> <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 && ( {m.reunions && (
<button <button
onClick={() => telecharger(`reunions/${m.annee}/${m.mois}`, `reunions-${m.annee}-${m.mois}`, `rapport_reunions_${m.annee}_${String(m.mois).padStart(2,'0')}.pdf`)} 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> <span className="text-xl">📜</span>
<div className="flex-1"> <div className="flex-1">
<p className="font-bold text-orange-800 text-sm">Résolutions {a.annee}</p> <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> </div>
<span className="text-orange-400">{generating === `res-${a.annee}` ? '⏳' : '↓'}</span> <span className="text-orange-400">{generating === `res-${a.annee}` ? '⏳' : '↓'}</span>
</button> </button>

View file

@ -1,7 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { api } from '../stores/api'; import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck'; import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { datesReunion, dateLocale } from '../utils/datesReunion';
const TYPES_JETONS = [ const TYPES_JETONS = [
{ value: 'desir', label: 'Désir', emoji: '💫' }, { value: 'desir', label: 'Désir', emoji: '💫' },
@ -16,10 +19,12 @@ const TYPES_JETONS = [
]; ];
export default function RemiseJeton() { export default function RemiseJeton() {
const { groupe } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [form, setForm] = useState({ const [form, setForm] = useState({
membre_id: '', membre_id: '',
type_jeton: '', type_jeton: '',
date_remise: new Date().toISOString().slice(0, 10), date_remise: dateLocale(),
annees: '', annees: '',
}); });
const [membres, setMembres] = useState([]); const [membres, setMembres] = useState([]);
@ -31,6 +36,12 @@ export default function RemiseJeton() {
api.get('/membres/actifs').then(setMembres).catch(() => setMembres([])); 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 update = (k, v) => setForm({ ...form, [k]: v });
const handleSubmit = async () => { const handleSubmit = async () => {
@ -73,12 +84,7 @@ export default function RemiseJeton() {
{/* Date */} {/* Date */}
<div className="bg-white rounded-2xl p-4 shadow-sm"> <div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date</label> <label className="text-xs text-gray-400 block mb-1">Date</label>
<input <MeetingDateSelect dates={choixDates} value={form.date_remise} onChange={(v) => update('date_remise', v)} />
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"
/>
</div> </div>
{/* Type de jeton */} {/* 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 React, { useState, useEffect } from 'react';
import { api } from '../stores/api'; import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import EtatFinancier from './EtatFinancier';
const STATUT_COL = { const STATUT_COL = {
en_attente: { label: 'À confirmer', cls: 'bg-yellow-100 text-yellow-700' }, 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' }, 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() { export default function Tresorerie() {
const { estExecutif } = useStore();
const [soldes, setSoldes] = useState(null); const [soldes, setSoldes] = useState(null);
const [collectes, setCollectes] = useState([]); const [collectes, setCollectes] = useState([]);
const [contributions, setContributions] = useState([]); const [contributions, setContributions] = useState([]);
const [depenses, setDepenses] = useState([]);
const [depensesAConfirmer, setDepensesAConfirmer] = useState([]);
const [operations, setOperations] = useState([]); const [operations, setOperations] = useState([]);
const [reserves, setReserves] = useState([]); const [reserves, setReserves] = useState([]);
const [registre, setRegistre] = useState([]); const [registre, setRegistre] = useState([]);
const [tab, setTab] = useState('soldes'); const [tab, setTab] = useState('etat');
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [generatingRapport, setGeneratingRapport] = useState(false);
const [registreMois, setRegistreMois] = useState(''); const [registreMois, setRegistreMois] = useState('');
const [registreAnnee, setRegistreAnnee] = useState(String(new Date().getFullYear())); const [registreAnnee, setRegistreAnnee] = useState(String(new Date().getFullYear()));
const [registreType, setRegistreType] = useState(''); const [registreType, setRegistreType] = useState('');
@ -27,11 +39,15 @@ export default function Tresorerie() {
const [depotMontant, setDepotMontant] = useState(''); const [depotMontant, setDepotMontant] = useState('');
const [depotDate, setDepotDate] = useState(new Date().toISOString().slice(0, 10)); const [depotDate, setDepotDate] = useState(new Date().toISOString().slice(0, 10));
const [depotNote, setDepotNote] = useState(''); 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 [reserveId, setReserveId] = useState(null);
const [reserveNom, setReserveNom] = useState(''); const [reserveNom, setReserveNom] = useState('');
const [reserveMontant, setReserveMontant] = useState(''); const [reserveMontant, setReserveMontant] = useState('');
const [reserveNote, setReserveNote] = useState(''); const [reserveNote, setReserveNote] = useState('');
const [transfertSourceId, setTransfertSourceId] = useState('');
const [transfertDestinationId, setTransfertDestinationId] = useState('');
const [transfertMontant, setTransfertMontant] = useState('');
const registreQuery = () => { const registreQuery = () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -44,25 +60,38 @@ export default function Tresorerie() {
const charger = async () => { const charger = async () => {
try { 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/soldes'),
api.get('/collectes/?limite=20'),
api.get('/contributions/?limite=50'),
api.get('/banque/'),
api.get('/banque/reserves'), api.get('/banque/reserves'),
api.get(registreQuery()), api.get(registreQuery()),
]); ]);
setSoldes(s); setSoldes(s);
setCollectes(c);
setContributions(contribs);
setOperations(o);
setReserves(res); setReserves(res);
setRegistre(r); 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); } } catch (e) { setError(e.message); }
setLoading(false); setLoading(false);
}; };
useEffect(() => { charger(); }, []); useEffect(() => { charger(); }, [estExecutif]);
const chargerRegistre = async () => { const chargerRegistre = async () => {
setError(''); setError('');
@ -100,18 +129,54 @@ export default function Tresorerie() {
} catch (e) { setError(e.message); } } 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 () => { const enregistrerOperation = async () => {
if (depotType === 'reserve') {
await enregistrerReserve();
return;
}
if (depotType === 'virement') {
await transfererReserve();
return;
}
if (!depotMontant || parseFloat(depotMontant) <= 0) { setError('Montant invalide'); return; } if (!depotMontant || parseFloat(depotMontant) <= 0) { setError('Montant invalide'); return; }
setError(''); setError('');
try { try {
await api.post('/banque/', { await api.post('/banque/', {
type: depotType, type: depotType,
provenance_depot: depotType === 'depot' ? depotProvenance : null,
montant: parseFloat(depotMontant), montant: parseFloat(depotMontant),
date_operation: depotDate, date_operation: depotDate,
note: depotNote.trim() || null, note: depotNote.trim() || null,
}); });
setDepotMontant(''); setDepotNote(''); setTab('soldes'); setDepotMontant(''); setDepotNote(''); setTab('etat');
const labels = { depot: 'Dépôt', retrait: 'Retrait', releve: 'Relevé' }; const labels = { depot: 'Dépôt', retrait: 'Retrait' };
flash(`${labels[depotType]} enregistré ✓`); flash(`${labels[depotType]} enregistré ✓`);
await charger(); await charger();
} catch (e) { setError(e.message); } } catch (e) { setError(e.message); }
@ -127,27 +192,25 @@ export default function Tresorerie() {
const editerReserve = (reserve) => { const editerReserve = (reserve) => {
setReserveId(reserve.id); setReserveId(reserve.id);
setReserveNom(reserve.nom); setReserveNom(reserve.nom);
setReserveMontant(String(reserve.montant)); setReserveMontant(String(reserve.montant || 0));
setReserveNote(reserve.note || ''); setReserveNote(reserve.note || '');
setTab('reserves'); setTab('banque');
setDepotType('reserve');
}; };
const enregistrerReserve = async () => { const enregistrerReserve = async () => {
const montant = parseFloat(reserveMontant);
if (!reserveNom.trim()) { setError('Nom de réserve requis'); return; } if (!reserveNom.trim()) { setError('Nom de réserve requis'); return; }
if (Number.isNaN(montant) || montant < 0) { setError('Montant de réserve invalide'); return; }
setError(''); setError('');
try { try {
const payload = { const payload = {
nom: reserveNom.trim(), nom: reserveNom.trim(),
montant,
note: reserveNote.trim() || null, note: reserveNote.trim() || null,
}; };
if (reserveId) { if (reserveId) {
await api.patch(`/banque/reserves/${reserveId}`, payload); await api.patch(`/banque/reserves/${reserveId}`, payload);
flash('Réserve modifiée ✓'); flash('Réserve modifiée ✓');
} else { } else {
await api.post('/banque/reserves', payload); await api.post('/banque/reserves', { ...payload, montant: 0 });
flash('Réserve créée ✓'); flash('Réserve créée ✓');
} }
resetReserveForm(); resetReserveForm();
@ -166,31 +229,90 @@ export default function Tresorerie() {
} catch (e) { setError(e.message); } } 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 contributionsADebiter = contributions.filter((c) => c.engagee && !c.debitee);
const totalReserveForm = parseFloat(reserveMontant) || 0; const depensesADebiter = depenses.filter((d) => d.confirme && !d.paye && ['VIREMENT', 'CHEQUE'].includes(d.methode));
const reservesSansEdition = reserves const nbATraiter = aRecevoir.length
.filter((r) => r.id !== reserveId) + (soldes?.ventes_non_remises_details?.length || 0)
.reduce((total, r) => total + parseFloat(r.montant || 0), 0); + contributionsADebiter.length
const reserveDepasseSolde = soldes && reservesSansEdition + totalReserveForm > soldes.solde_banque; + 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', const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']; 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
const rapportPrecedent = periodeRapportPrecedent();
const TYPES_REGISTRE = [ const TYPES_REGISTRE = [
['', 'Tous les types'], ['', 'Tous les types'],
['collecte', 'Collectes'], ['collecte', 'Collectes'],
['don', 'Dons'],
['vente_litterature', 'Ventes littérature'], ['vente_litterature', 'Ventes littérature'],
['depense', 'Dépenses'], ['depense', 'Dépenses'],
['contribution', 'Contributions'], ['contribution', 'Contributions'],
['banque_depot', 'Dépôts bancaires'], ['banque_depot', 'Dépôts bancaires'],
['banque_retrait', 'Retraits 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>; if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
return ( return (
<div className="min-h-screen bg-gray-100"> <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"> <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>} {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 */} {/* Onglets */}
<div className="flex gap-1 overflow-x-auto"> <div className="flex gap-1 overflow-x-auto">
{[ {[
['soldes', '💰 Soldes'], ['etat', 'Accueil'],
['receptions', `📥 À traiter (${aRecevoir.length + (soldes?.ventes_non_remises_details?.length || 0) + contributionsADebiter.length})`], ['resultats', 'Résultats'],
['registre', '📒 Registre'], ['registre', '📒 Registre'],
['reserves', `🧾 Réserves (${reserves.length})`], ['banque', '🏦 Opérations'],
['banque', '🏦 Opération'], ].filter(([t]) => estExecutif || ['etat', 'resultats', 'registre'].includes(t)).map(([t, label]) => (
].map(([t, label]) => (
<button key={t} onClick={() => { setTab(t); setError(''); }} <button key={t} onClick={() => { setTab(t); setError(''); }}
className={`flex-1 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${ 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' tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
@ -212,6 +333,168 @@ export default function Tresorerie() {
))} ))}
</div> </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 ── */} {/* ── Soldes ── */}
{tab === 'soldes' && soldes && ( {tab === 'soldes' && soldes && (
<div className="space-y-3"> <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="bg-amber-50 rounded-2xl p-4 border border-amber-200">
<div className="flex items-center justify-between mb-2"> <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> <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"> className="text-xs font-bold text-amber-700 bg-amber-100 px-3 py-1 rounded-lg">
Gérer Gérer
</button> </button>
@ -292,50 +575,144 @@ export default function Tresorerie() {
</div> </div>
)} )}
{/* ── Réserves ── */} {/* ── Opérations ── */}
{tab === 'reserves' && soldes && ( {tab === 'banque' && soldes && (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-white rounded-2xl p-4 shadow-sm"> <div className="bg-white rounded-2xl p-4 shadow-sm space-y-4">
<div className="grid grid-cols-2 gap-3 mb-4"> <div>
<div className="p-3 bg-blue-50 rounded-xl"> <label className="text-xs text-gray-400 block mb-2">Action</label>
<p className="text-xs text-blue-600">Solde banque</p> <div className="grid grid-cols-4 gap-1">
<p className="text-xl font-bold text-blue-700">{soldes.solde_banque.toFixed(2)} $</p> {[
</div> ['depot', 'Dépôt'],
<div className="p-3 bg-amber-50 rounded-xl"> ['retrait', 'Retrait'],
<p className="text-xs text-amber-600">Réserves</p> ['virement', 'Virement'],
<p className="text-xl font-bold text-amber-700">{soldes.total_reserves.toFixed(2)} $</p> ['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> </div>
<div className="space-y-3"> {depotType === 'depot' && (
<div> <>
<label className="text-xs text-gray-400 block mb-1">Nom</label> <div className="grid grid-cols-2 gap-2">
<input type="text" value={reserveNom} onChange={(e) => setReserveNom(e.target.value)} {[
placeholder="Ex: Prudence, anniversaire, loyer" ['encaisse', 'Encaisse'],
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> ['don', 'Don'],
</div> ].map(([val, label]) => (
<div> <button key={val} onClick={() => setDepotProvenance(val)}
<label className="text-xs text-gray-400 block mb-1">Montant réservé</label> className={`min-h-[44px] rounded-xl text-sm font-bold border ${
<input type="number" inputMode="decimal" step="0.01" min="0" value={reserveMontant} depotProvenance === val ? 'bg-green-600 text-white border-green-600' : 'bg-gray-50 text-gray-600 border-gray-200'
onChange={(e) => setReserveMontant(e.target.value)} }`}>
placeholder="0.00" {label}
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> </button>
</div> ))}
<div> </div>
<label className="text-xs text-gray-400 block mb-1">Note (optionnel)</label> <p className="text-xs text-green-700 bg-green-50 p-2 rounded-lg">
<input type="text" value={reserveNote} onChange={(e) => setReserveNote(e.target.value)} {depotProvenance === 'encaisse'
placeholder="But de la réserve" ? `Dépôt de l'encaisse vers la banque. Encaisse disponible : ${soldes.encaisse.toFixed(2)} $.`
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> : "Don déposé directement au compte bancaire, sans diminuer l'encaisse."}
</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.
</p> </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"> <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"> 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'} {reserveId ? 'Modifier' : 'Créer'}
</button> </button>
@ -346,17 +723,18 @@ export default function Tresorerie() {
</button> </button>
)} )}
</div> </div>
</div> </>
)}
</div> </div>
{reserves.length === 0 ? ( <div className="bg-white rounded-2xl p-4 shadow-sm">
<div className="text-center py-10"> <h3 className="text-sm font-bold text-gray-700 mb-3">Réserves</h3>
<p className="text-gray-500">Aucune réserve</p> {reserves.length === 0 ? (
</div> <p className="text-sm text-gray-500">Aucune réserve</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-2">
{reserves.map((r) => ( {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="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="font-bold text-gray-800 truncate">{r.nom}</p> <p className="font-bold text-gray-800 truncate">{r.nom}</p>
@ -370,12 +748,39 @@ export default function Tresorerie() {
Modifier Modifier
</button> </button>
<button onClick={() => supprimerReserve(r.id)} <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 Supprimer
</button> </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>
))} ))}
</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>
)} )}
</div> </div>
@ -384,6 +789,53 @@ export default function Tresorerie() {
{/* ── Réceptions et débits à confirmer ── */} {/* ── Réceptions et débits à confirmer ── */}
{tab === 'receptions' && ( {tab === 'receptions' && (
<div className="space-y-4"> <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 */} {/* Contributions à débiter */}
{contributionsADebiter.length > 0 && ( {contributionsADebiter.length > 0 && (
<div> <div>
@ -463,10 +915,10 @@ export default function Tresorerie() {
</div> </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"> <div className="text-center py-12">
<p className="text-4xl mb-2">✅</p> <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>
)} )}
</div> </div>
@ -527,97 +979,96 @@ export default function Tresorerie() {
</div> </div>
)} )}
{/* ── Opération bancaire ── */} </div>
{tab === 'banque' && ( </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-2">Type d'opération</label>
<div className="grid grid-cols-3 gap-2"> function SimplePendingCard({ titre, montant, meta, action, onAction }) {
{[ return (
['depot', '🏦 Dépôt'], <div className="rounded-xl bg-gray-50 p-3">
['retrait', '📤 Retrait'], <div className="flex items-start justify-between gap-3">
['releve', '📋 Relevé'], <div className="min-w-0">
].map(([val, label]) => ( <p className="font-bold text-gray-800 truncate">{titre}</p>
<button key={val} onClick={() => setDepotType(val)} <p className="text-xs text-gray-500 mt-1">{meta}</p>
className={`p-3 rounded-xl text-xs font-bold border-2 ${ </div>
depotType === val ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200' <p className="font-bold text-orange-700 whitespace-nowrap">{parseFloat(montant).toFixed(2)} $</p>
}`}>{label}</button> </div>
))} {action && (
</div> <button onClick={onAction}
{depotType === 'releve' && ( 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">
<p className="text-xs text-blue-600 mt-2 bg-blue-50 p-2 rounded-lg"> {action}
Le relevé enregistre le solde réel de votre compte bancaire tel qu'indiqué sur le relevé mensuel. </button>
</p> )}
)} </div>
{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> function TresorerieBloc({ label, montant, couleur }) {
)} const cls = {
{depotType === 'retrait' && ( blue: 'bg-blue-50 text-blue-700',
<p className="text-xs text-orange-600 mt-2 bg-orange-50 p-2 rounded-lg"> green: 'bg-green-50 text-green-700',
Retrait de la banque vers l'encaisse. red: 'bg-red-50 text-red-700',
</p> orange: 'bg-orange-50 text-orange-700',
)} amber: 'bg-amber-50 text-amber-700',
</div> };
const valeur = Number(montant);
<div className="bg-white rounded-2xl p-4 shadow-sm"> return (
<label className="text-xs text-gray-400 block mb-1"> <div className={`text-center p-3 rounded-xl ${cls[couleur] || cls.blue}`}>
{depotType === 'releve' ? 'Solde du relevé' : 'Montant'} <p className="text-xs opacity-70">{label}</p>
</label> <p className="text-xl font-bold">{Number.isFinite(valeur) ? valeur.toFixed(2) : '0.00'} $</p>
<div className="relative"> </div>
<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" function DepenseConfirmCard({ depense, soldeDisponible, onConfirm, onReject }) {
autoFocus /> const [methode, setMethode] = useState('');
</div> const montant = parseFloat(depense.montant);
</div> const engagementBancaire = ['CHEQUE', 'VIREMENT'].includes(methode);
const soldeInsuffisant = engagementBancaire && montant > soldeDisponible;
<div className="bg-white rounded-2xl p-4 shadow-sm"> const peutTraiter = methode && !soldeInsuffisant;
<label className="text-xs text-gray-400 block mb-1">Date</label>
<input type="date" value={depotDate} onChange={(e) => setDepotDate(e.target.value)} return (
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> <div className="bg-white rounded-2xl p-4 shadow-sm">
</div> <div className="flex-1">
<p className="font-bold text-gray-800">{depense.quoi}</p>
<div className="bg-white rounded-2xl p-4 shadow-sm"> <p className="text-lg font-bold text-red-600">{montant.toFixed(2)} $</p>
<label className="text-xs text-gray-400 block mb-1">Note (optionnel)</label> <p className="text-xs text-gray-400">{depense.date_depense} · Soumis par {depense.saisi_par_prenom}</p>
<input type="text" value={depotNote} onChange={(e) => setDepotNote(e.target.value)} {depense.preuve_nom && <p className="text-xs text-blue-500 mt-1">📎 Preuve jointe</p>}
placeholder={depotType === 'releve' ? 'Ex: Relevé avril 2026' : 'Ex: Dépôt Caisse Desjardins'} </div>
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" /> <div className="mt-3">
</div> <p className="text-xs text-gray-400 mb-1">Mode de paiement</p>
<div className="grid grid-cols-3 gap-1">
<button onClick={enregistrerOperation} disabled={!depotMontant || parseFloat(depotMontant) < 0} {METHODES_DEPENSE.map((m) => (
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"> <button key={m.value} onClick={() => setMethode(m.value)}
{depotType === 'depot' ? '🏦 Enregistrer le dépôt' : className={`p-2 rounded-lg text-xs font-bold border ${
depotType === 'retrait' ? '📤 Enregistrer le retrait' : methode === m.value ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-500 border-gray-200'
'📋 Enregistrer le relevé'} }`}>
</button> {m.emoji}<br />{m.label}
</button>
{/* Historique opérations */} ))}
{operations.length > 0 && ( </div>
<div className="bg-white rounded-2xl p-4 shadow-sm"> {soldeInsuffisant && (
<h3 className="font-bold text-gray-700 text-sm mb-2">Dernières opérations</h3> <p className="mt-2 text-xs text-red-600">
{operations.slice(0, 8).map((op) => ( Solde bancaire disponible insuffisant pour engager cette dépense.
<div key={op.id} className="flex justify-between py-2 border-b border-gray-50 last:border-0"> </p>
<div> )}
<p className="text-sm font-medium text-gray-700"> {engagementBancaire && !soldeInsuffisant && (
{op.type === 'depot' ? '🏦 Dépôt' : op.type === 'retrait' ? '📤 Retrait' : '📋 Relevé'} <p className="mt-2 text-xs text-gray-400">
</p> Ce montant sera engagé jusqu'à la confirmation du débit bancaire.
<p className="text-xs text-gray-400">{op.date_operation} · {op.effectue_par_prenom}</p> </p>
{op.note && <p className="text-xs text-gray-400 italic">{op.note}</p>} )}
</div> </div>
<p className={`font-bold text-sm ${ <div className="grid grid-cols-2 gap-2 mt-3">
op.type === 'depot' ? 'text-green-600' : op.type === 'retrait' ? 'text-red-600' : 'text-blue-600' <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">
{op.type === 'releve' ? '=' : op.type === 'depot' ? '+' : '-'}{op.montant.toFixed(2)} $ Rejeter
</p> </button>
</div> <button onClick={() => peutTraiter && onConfirm(depense.id, methode)}
))} disabled={!peutTraiter}
</div> 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 || '...'})
</div> </button>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,14 +1,19 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { api } from '../stores/api'; import { api } from '../stores/api';
import { useStore } from '../stores/store';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import SuccessCheck from '../components/SuccessCheck'; import SuccessCheck from '../components/SuccessCheck';
import MeetingDateSelect from '../components/MeetingDateSelect';
import { datesReunion, dateLocale } from '../utils/datesReunion';
export default function VenteLitterature() { export default function VenteLitterature() {
const { groupe } = useStore();
const choixDates = datesReunion(groupe, { joursAvant: 365, joursApres: 180 });
const [form, setForm] = useState({ const [form, setForm] = useState({
litterature_id: '', litterature_id: '',
quantite: '1', quantite: '1',
montant: '', montant: '',
date_vente: new Date().toISOString().slice(0, 10), date_vente: dateLocale(),
}); });
const [catalogue, setCatalogue] = useState([]); const [catalogue, setCatalogue] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -19,6 +24,12 @@ export default function VenteLitterature() {
api.get('/litterature/').then(setCatalogue).catch(() => setCatalogue([])); 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 }); const update = (k, v) => setForm({ ...form, [k]: v });
// Auto-remplir le montant quand on sélectionne un titre // Auto-remplir le montant quand on sélectionne un titre
@ -71,12 +82,7 @@ export default function VenteLitterature() {
{/* Date */} {/* Date */}
<div className="bg-white rounded-2xl p-4 shadow-sm"> <div className="bg-white rounded-2xl p-4 shadow-sm">
<label className="text-xs text-gray-400 block mb-1">Date</label> <label className="text-xs text-gray-400 block mb-1">Date</label>
<input <MeetingDateSelect dates={choixDates} value={form.date_vente} onChange={(v) => update('date_vente', v)} />
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"
/>
</div> </div>
{/* Titre (dropdown) */} {/* 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 // Accueil
postes: [], postes: [],
estExecutif: false,
estSysadmin: false,
groupe: null, groupe: null,
modulesConsultation: [], modulesConsultation: [],
modulesSaisie: [], modulesSaisie: [],
@ -19,11 +21,16 @@ export const useStore = create((set, get) => ({
loading: false, loading: false,
error: null, error: null,
page: localStorage.getItem('gm_page') || 'login', page: localStorage.getItem('gm_page') || 'login',
navContext: localStorage.getItem('gm_nav_context') || 'accueil',
setPage: (page) => { setPage: (page) => {
localStorage.setItem('gm_page', page); localStorage.setItem('gm_page', page);
set({ page, error: null }); set({ page, error: null });
}, },
setNavContext: (navContext) => {
localStorage.setItem('gm_nav_context', navContext);
set({ navContext });
},
setError: (error) => set({ error }), setError: (error) => set({ error }),
loadGroupes: async () => { 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 () => { loadAccueil: async () => {
try { try {
const data = await api.get('/accueil/'); const data = await api.get('/accueil/');
set({ set({
postes: data.postes, postes: data.postes,
estExecutif: !!data.est_executif,
groupe: data.groupe, groupe: data.groupe,
modulesConsultation: data.modules_consultation, modulesConsultation: data.modules_consultation,
modulesSaisie: data.modules_saisie, modulesSaisie: data.modules_saisie,
modulesGestion: data.modules_gestion, modulesGestion: data.modules_gestion,
membre: data.membre, membre: data.membre,
estSysadmin: !!data.membre?.is_sysadmin,
}); });
} catch (e) { } catch (e) {
set({ error: e.message }); set({ error: e.message });
@ -106,16 +133,20 @@ export const useStore = create((set, get) => ({
api.setToken(null); api.setToken(null);
localStorage.removeItem('gm_groupe_id'); localStorage.removeItem('gm_groupe_id');
localStorage.removeItem('gm_page'); localStorage.removeItem('gm_page');
localStorage.removeItem('gm_nav_context');
set({ set({
token: null, token: null,
membre: null, membre: null,
groupe: null, groupe: null,
groupeId: null, groupeId: null,
postes: [], postes: [],
estExecutif: false,
estSysadmin: false,
modulesConsultation: [], modulesConsultation: [],
modulesSaisie: [], modulesSaisie: [],
modulesGestion: [], modulesGestion: [],
page: 'login', 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: { workbox: {
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'], globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
runtimeCaching: [ runtimeCaching: [
{ {