dernière mouture
This commit is contained in:
parent
5e5c38fb21
commit
a936e7ed81
97 changed files with 4245 additions and 2916 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
48
README.md
48
README.md
|
|
@ -11,29 +11,28 @@ Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche.
|
||||||
| Frontend | React 18 + Vite (PWA installable) |
|
| 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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
78
backend/app/core/audit.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
24
backend/app/core/decisions.py
Normal file
24
backend/app/core/decisions.py
Normal 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}"
|
||||||
38
backend/app/core/reunions.py
Normal file
38
backend/app/core/reunions.py
Normal 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
51
backend/app/core/roles.py
Normal 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
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
22
backend/app/models/journal_action.py
Normal file
22
backend/app/models/journal_action.py
Normal 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)
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
20
backend/app/models/mouvement_reserve.py
Normal file
20
backend/app/models/mouvement_reserve.py
Normal 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))
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
22
backend/app/models/push_subscription.py
Normal file
22
backend/app/models/push_subscription.py
Normal 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))
|
||||||
|
|
@ -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")
|
|
||||||
23
backend/app/models/transaction_comptable.py
Normal file
23
backend/app/models/transaction_comptable.py
Normal 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))
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
41
backend/app/routers/journal.py
Normal file
41
backend/app/routers/journal.py
Normal 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()
|
||||||
|
]
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
91
backend/app/routers/notifications.py
Normal file
91
backend/app/routers/notifications.py
Normal 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}
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}"'},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
111
backend/app/services/finance.py
Normal file
111
backend/app/services/finance.py
Normal 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,
|
||||||
|
)
|
||||||
76
backend/app/services/notifications.py
Normal file
76
backend/app/services/notifications.py
Normal 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,
|
||||||
|
})
|
||||||
BIN
data/import/Rapport de trésorerie.xlsx
Normal file
BIN
data/import/Rapport de trésorerie.xlsx
Normal file
Binary file not shown.
|
|
@ -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:")
|
||||||
|
|
|
||||||
|
|
@ -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
52
docs/ARCHITECTURE.md
Normal 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.
|
||||||
|
|
@ -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**
|
|
||||||
|
|
|
||||||
BIN
docs/Liste-de-prix-rev.-Fevrier-2026.pdf
Normal file
BIN
docs/Liste-de-prix-rev.-Fevrier-2026.pdf
Normal file
Binary file not shown.
|
|
@ -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. | | | | | | ✓ | | |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
24
frontend/public/push-sw.js
Normal file
24
frontend/public/push-sw.js
Normal 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));
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
41
frontend/src/components/BottomNav.jsx
Normal file
41
frontend/src/components/BottomNav.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/components/MeetingDateSelect.jsx
Normal file
19
frontend/src/components/MeetingDateSelect.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
255
frontend/src/pages/AssembleeAffaires.jsx
Normal file
255
frontend/src/pages/AssembleeAffaires.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
63
frontend/src/pages/JournalActions.jsx
Normal file
63
frontend/src/pages/JournalActions.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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..."
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
207
frontend/src/pages/ReunionHebdomadaire.jsx
Normal file
207
frontend/src/pages/ReunionHebdomadaire.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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) */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
105
frontend/src/utils/datesReunion.js
Normal file
105
frontend/src/utils/datesReunion.js
Normal 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;
|
||||||
|
}
|
||||||
56
frontend/src/utils/pushNotifications.js
Normal file
56
frontend/src/utils/pushNotifications.js
Normal 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', {});
|
||||||
|
}
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue