dernière mouture
This commit is contained in:
parent
d055b51132
commit
2245f26492
64 changed files with 5197 additions and 878 deletions
115
README.md
115
README.md
|
|
@ -2,65 +2,106 @@
|
|||
|
||||
**District 87-16 · Région 87 · Alcooliques anonymes**
|
||||
|
||||
Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. 27 modules.
|
||||
Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche.
|
||||
|
||||
## Phases 0 à 9 — Complétées
|
||||
## Architecture
|
||||
|
||||
| Couche | Technologie |
|
||||
|--------|-------------|
|
||||
| Frontend | React 18 + Vite (PWA installable) |
|
||||
| UI | Tailwind CSS (mobile-first) |
|
||||
| État | Zustand |
|
||||
| Backend | FastAPI (Python) async, 82 routes |
|
||||
| DB | PostgreSQL + SQLAlchemy async (23+ tables) |
|
||||
| Auth | JWT 30 jours + bcrypt 12 rounds (prénom + PIN) |
|
||||
| PDF | WeasyPrint |
|
||||
| Serveur | NGINX (statiques + reverse proxy HTTPS) |
|
||||
|
||||
## Modules
|
||||
|
||||
### Consultation (C1-C7) — Tous les membres
|
||||
- **C1** Rapports PDF (WeasyPrint) — finances, PV, rapport RSG, financier complet
|
||||
- **C2** Calendrier district — serviteurs de tous les groupes du 87-16
|
||||
- **C3** Anniversaires district — sobriété, prochains en évidence
|
||||
|
||||
- **C1** Rapports PDF — finances, PV, résolutions, rapport complet
|
||||
- **C2** Événements à venir — anniversaires de sobriété et événements ad hoc du district
|
||||
- **C4** Littérature — inventaire avec prix, alerte stock bas
|
||||
- **C5** Décisions en cours — suivi réunion d'affaires
|
||||
- **C6** Votes ouverts — sondages, barres de résultat, vote anonyme
|
||||
- **C7** Mon profil — modifier, PIN, notifications
|
||||
- **C5** Décisions en cours — suivi des résolutions
|
||||
- **C6** Votes ouverts — sondages de conscience de groupe, vote anonyme
|
||||
- **C7** Mon profil — modifier, changer PIN, notifications
|
||||
- **C8** Propositions — soumettre, seconder, voter (créables en tout temps)
|
||||
- **C9** S'impliquer — postes vacants, candidatures
|
||||
|
||||
### Saisie hebdomadaire (S1-S5)
|
||||
- **S1** Collecte 7e Tradition (trésorier)
|
||||
- **S2** Remise de jeton (resp. jetons) — 9 types, historique
|
||||
- **S3** Vente littérature (resp. littérature) — stock auto-décrémenté
|
||||
- **S4** Rapport de réunion (animateur)
|
||||
- **S5** Dépense — 6 champs (trésorier)
|
||||
|
||||
- **S1** Collecte 7e Tradition — saisie le jour de réunion, confirmation par un second membre, historique mensuel avec totaux
|
||||
- **S1T** Trésorerie — soldes (banque + encaisse + disponible après réserves), réception des collectes et ventes de littérature, registre détaillé des transactions, opérations bancaires (dépôt/retrait/relevé)
|
||||
- **S2** Remise de jeton — 9 types, historique
|
||||
- **S3** Vente littérature — stock auto-décrémenté, montant à remettre au trésorier
|
||||
- **S4** Rapport de réunion — thème + annonces
|
||||
- **S5** Dépenses — 6 champs, engagements non débités, confirmation de débit par le trésorier
|
||||
|
||||
### Saisie mensuelle (M1-M6)
|
||||
- **M1** Rédiger PV (secrétaire) — décisions structurées, auto-création C5
|
||||
- **M2** Présences réunion d'affaires (secrétaire)
|
||||
|
||||
- **M1** Rédiger PV — auto-renseigné depuis les propositions secondées, décisions structurées
|
||||
- **M2** Présences réunion d'affaires
|
||||
- **M3** Rapport RSG — template 3 sections
|
||||
- **M4** Envoi contributions — répartition auto district/région/BSG/intergroupe
|
||||
- **M4** Envoi contributions — somme disponible affichée par défaut, destinataire + montant + méthode
|
||||
- **M5** Ajuster inventaire — +/−, ajout catalogue
|
||||
- **M6** Commander jetons — état stock 30 jours
|
||||
|
||||
### Gestion (G1-G9) — Exécutif
|
||||
|
||||
- **G1** Inviter un membre — code 48h
|
||||
- **G2** Gérer les membres — fiche complète, droit à l'oubli
|
||||
- **G3** Affecter les postes
|
||||
- **G4** Terminer un mandat — transition manuelle
|
||||
- **G5** Planifier les rotations
|
||||
- **G3** Affecter les postes — n'affiche que les postes vacants
|
||||
- **G4** Terminer un mandat — transition manuelle, note pour le successeur
|
||||
- **G5** Planifier les rotations — calendrier serviteurs
|
||||
- **G6** Lancer un sondage — oui/non, choix multiple, unanimité
|
||||
- **G7** Jumeler parrain/filleul — confidentiel
|
||||
- **G8** Config. répartition — % contributions (défaut 30/30/30/10)
|
||||
- **G9** Paramètres du groupe
|
||||
- **G9** Paramètres du groupe — nom, horaire, adresse, format
|
||||
- **G10** Gérer les postes — créer, abolir, candidatures
|
||||
|
||||
## Stack technique
|
||||
## Trésorerie
|
||||
|
||||
| Couche | Technologie |
|
||||
|--------|-------------|
|
||||
| Frontend | React 18 + Vite (PWA) |
|
||||
| UI | Tailwind CSS |
|
||||
| État | Zustand |
|
||||
| Backend | FastAPI (Python) async |
|
||||
| DB | PostgreSQL 16 + SQLAlchemy async |
|
||||
| Auth | JWT 30 jours + bcrypt 12 rounds |
|
||||
| PDF | WeasyPrint |
|
||||
| Serveur | NGINX (statiques + reverse proxy HTTPS) |
|
||||
Le calcul des soldes repose sur le relevé bancaire mensuel :
|
||||
|
||||
- **Solde banque** = dernier relevé + dépôts − retraits depuis le relevé
|
||||
- **Disponible** = solde banque − engagements non débités − réserves
|
||||
- **Réserves** = réserve prudente (500 $) + réserve anniversaire (20 $/mois)
|
||||
- **Encaisse** = collectes reçues + ventes remises − dépôts − dépenses cash
|
||||
|
||||
Toute dépense par chèque ou virement est un engagement. Le trésorier confirme le débit lorsque l'argent quitte effectivement le compte.
|
||||
|
||||
## Propositions et résolutions
|
||||
|
||||
Les propositions peuvent être créées en tout temps par n'importe quel membre, sans nécessiter une réunion d'affaires. Le cycle est : proposée → secondée → votée (adoptée/rejetée). Lorsqu'adoptée, la proposition devient une résolution numérotée (RES-2026-001) et peut être associée à l'assemblée d'affaires lors du vote.
|
||||
|
||||
Le PV de la réunion d'affaires s'auto-renseigne depuis les propositions secondées en attente de vote.
|
||||
|
||||
## Principes
|
||||
|
||||
- **Anonymat AA** — Prénom seul dans les écrans partagés
|
||||
- **Pas d'administrateur** — Permissions = exécutif du groupe
|
||||
- **Mandats manuels** — Aucune terminaison automatique
|
||||
- **Zéro traçage** — Pas d'analytics, pas de cookies tiers
|
||||
- **Hébergé au Québec** — Souveraineté numérique · 100% logiciel libre
|
||||
- **Anonymat AA (12e Tradition)** — prénom seul dans les écrans partagés
|
||||
- **Pas d'administrateur (2e Tradition)** — permissions = exécutif du groupe
|
||||
- **Mandats manuels** — aucune terminaison automatique
|
||||
- **Zéro traçage** — pas d'analytics, pas de cookies tiers
|
||||
- **Hébergé au Québec** — souveraineté numérique · 100 % logiciel libre
|
||||
|
||||
## Fichiers
|
||||
|
||||
```
|
||||
backend/ API FastAPI (Python)
|
||||
app/
|
||||
core/ Config, DB, sécurité
|
||||
models/ Modèles SQLAlchemy (23+ tables)
|
||||
routers/ Routes API (26 groupes)
|
||||
schemas/ Schémas Pydantic
|
||||
frontend/ React + Vite + Tailwind
|
||||
src/
|
||||
pages/ 33 pages
|
||||
stores/ Zustand + API helper
|
||||
components/ Composants réutilisables
|
||||
deploy/ Service systemd, NGINX, seed, backup
|
||||
docs/ DEPLOIEMENT.md, RUNBOOK.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Groupe Méditation — Configuration
|
||||
# Copier vers .env et ajuster les valeurs
|
||||
|
||||
DATABASE_URL=postgresql+asyncpg://grpmed:CHANGEZ_MOI@localhost:5432/groupe_meditation
|
||||
DATABASE_URL=postgresql+asyncpg://grpmed:CHANGEZ_MOI@127.0.0.1:5432/groupe_meditation
|
||||
JWT_SECRET=CHANGEZ_MOI_64_caracteres_aleatoires_generes_par_openssl_rand_base64_48
|
||||
CORS_ORIGINS=["https://groupe-meditation.chezlepro.ca"]
|
||||
CORS_ORIGINS=["https://groupe-meditation.87-16.org"]
|
||||
INVITATION_EXPIRE_HOURS=48
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
"""Configuration de l'application."""
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
|
||||
_ENV_FILE = Path(__file__).resolve().parent.parent.parent / ".env"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql+asyncpg://grpmed:grpmed2026@localhost:5432/groupe_meditation"
|
||||
DATABASE_URL: str = "postgresql+asyncpg://grpmed:grpmed2026@127.0.0.1:5432/groupe_meditation"
|
||||
JWT_SECRET: str = "changez-moi-en-production-clef-secrete-64-caracteres-minimum"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRE_DAYS: int = 30
|
||||
CORS_ORIGINS: List[str] = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"https://groupe-meditation.chezlepro.ca",
|
||||
"https://groupe-meditation.87-16.org",
|
||||
]
|
||||
INVITATION_EXPIRE_HOURS: int = 48
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file = str(_ENV_FILE)
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ def verify_pin(pin: str, hashed: str) -> bool:
|
|||
return bcrypt.checkpw(pin.encode("utf-8"), hashed.encode("utf-8"))
|
||||
|
||||
|
||||
def create_token(membre_id: str, groupe_id: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.JWT_EXPIRE_DAYS)
|
||||
def create_token(membre_id: str, groupe_id: str, expire_jours: int = None) -> str:
|
||||
jours = expire_jours or settings.JWT_EXPIRE_DAYS
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=jours)
|
||||
payload = {"sub": str(membre_id), "grp": str(groupe_id), "exp": expire}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
|
||||
from app.core.config import settings
|
||||
from app.core.database import engine, Base
|
||||
from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, jumelages
|
||||
from app.routers import auth, membres, postes, accueil, invitations, collectes, reunions, depenses, decisions, calendrier, rapports, rotations, parametres, presences, pv, rapports_rsg, jetons, anniversaires, commandes_jetons, litterature, ventes_litterature, contributions, config_repartition, sondages, jumelages, propositions, operations_bancaires, evenements
|
||||
|
||||
# Import sync_log pour que create_all crée la table (aucun router ne l'importe)
|
||||
# Import models pour que create_all crée toutes les tables
|
||||
from app.models.sync_log import SyncLog # noqa: F401
|
||||
from app.models.proposition import Proposition # noqa: F401
|
||||
from app.models.operation_bancaire import OperationBancaire # noqa: F401
|
||||
from app.models.evenement import Evenement # noqa: F401
|
||||
from app.models.candidature import Candidature # noqa: F401
|
||||
from app.models.tache import Tache, PosteTache # noqa: F401
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -64,6 +69,9 @@ app.include_router(contributions.router, prefix="/api")
|
|||
app.include_router(config_repartition.router, prefix="/api")
|
||||
app.include_router(sondages.router, prefix="/api")
|
||||
app.include_router(jumelages.router, prefix="/api")
|
||||
app.include_router(propositions.router, prefix="/api")
|
||||
app.include_router(operations_bancaires.router, prefix="/api")
|
||||
app.include_router(evenements.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
30
backend/app/models/candidature.py
Normal file
30
backend/app/models/candidature.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Table candidatures — candidatures volontaires et nominations."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Boolean, DateTime, Text, ForeignKey, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Candidature(Base):
|
||||
__tablename__ = "candidatures"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||
poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False)
|
||||
membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||
type: Mapped[str] = mapped_column(
|
||||
SAEnum("candidature", "nomination", name="type_candidature"),
|
||||
default="candidature",
|
||||
)
|
||||
statut: Mapped[str] = mapped_column(
|
||||
SAEnum("proposee", "acceptee", "refusee", "retiree", name="statut_candidature"),
|
||||
default="proposee",
|
||||
)
|
||||
motivation: Mapped[str | None] = mapped_column(Text)
|
||||
nomme_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
acceptee_par_membre: Mapped[bool | None] = mapped_column(Boolean) # le nominé accepte/refuse
|
||||
proposition_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("propositions.id"))
|
||||
cree_le: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Table collectes — 7e Tradition."""
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Numeric
|
||||
from sqlalchemy import String, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
|
@ -14,5 +14,14 @@ class Collecte(Base):
|
|||
date_collecte: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"))
|
||||
detenteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||
statut: Mapped[str] = mapped_column(
|
||||
SAEnum("en_attente", "confirmee", "recu", name="statut_collecte"),
|
||||
default="en_attente",
|
||||
)
|
||||
saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
confirme_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
date_confirmation: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
recu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
date_reception: Mapped[date | None] = mapped_column(Date)
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Table depenses — 6 champs selon l'architecture v4."""
|
||||
"""Table depenses — ouvert à tous, confirmé par le trésorier."""
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum
|
||||
from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, Numeric, Text, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
|
@ -16,9 +16,13 @@ class Depense(Base):
|
|||
qui: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||
quoi: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
paye: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
methode: Mapped[str] = mapped_column(
|
||||
methode: Mapped[str | None] = mapped_column(
|
||||
SAEnum("DU", "VIREMENT", "CHEQUE", "COLLECTE", name="methode_paiement"),
|
||||
nullable=False,
|
||||
nullable=True, # renseigné par le trésorier lors de la confirmation
|
||||
)
|
||||
preuve_nom: Mapped[str | None] = mapped_column(String(255)) # nom du fichier preuve
|
||||
saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
resolution_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("propositions.id"))
|
||||
confirme_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
date_confirmation: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Table envois_contributions — Envois aux organismes AA."""
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, Enum as SAEnum
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
|
@ -21,5 +21,8 @@ class EnvoiContribution(Base):
|
|||
SAEnum("DU", "VIREMENT", "CHEQUE", name="methode_contribution"),
|
||||
nullable=False,
|
||||
)
|
||||
resolution_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("propositions.id"))
|
||||
engagee: Mapped[bool] = mapped_column(Boolean, default=False) # montant engagé
|
||||
debitee: Mapped[bool] = mapped_column(Boolean, default=False) # montant débité du compte
|
||||
saisi_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
|
|
|||
21
backend/app/models/evenement.py
Normal file
21
backend/app/models/evenement.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Table evenements — événements ad hoc du district."""
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy import String, Date, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Evenement(Base):
|
||||
__tablename__ = "evenements"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||
titre: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
date_evenement: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
lieu: Mapped[str | None] = mapped_column(String(200))
|
||||
cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
cree_le: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""Table groupes."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import time, datetime, timezone
|
||||
from sqlalchemy import String, Text, Time, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
|
|
@ -15,7 +15,7 @@ class Groupe(Base):
|
|||
region: Mapped[str] = mapped_column(String(20), default="87")
|
||||
adresse_local: Mapped[str | None] = mapped_column(Text)
|
||||
jour_reunion: Mapped[str | None] = mapped_column(String(10))
|
||||
heure_reunion: Mapped[datetime | None] = mapped_column(Time)
|
||||
heure_reunion: Mapped[time | None] = mapped_column(Time)
|
||||
format_reunion: Mapped[str | None] = mapped_column(Text)
|
||||
lien_aa87: Mapped[str] = mapped_column(String(255), default="https://aa87.org")
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
"""Table membres."""
|
||||
import uuid
|
||||
from datetime import datetime, date, timezone
|
||||
from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Membre(Base):
|
||||
__tablename__ = "membres"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("courriel", "groupe_id", name="uq_courriel_groupe"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||
prenom: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
nom: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
telephone: Mapped[str | None] = mapped_column(String(20))
|
||||
courriel: Mapped[str | None] = mapped_column(String(100))
|
||||
nom: Mapped[str | None] = mapped_column(String(50)) # optionnel
|
||||
telephone: Mapped[str] = mapped_column(String(20), nullable=False) # obligatoire
|
||||
courriel: Mapped[str] = mapped_column(String(100), nullable=False) # identifiant unique
|
||||
date_abstinence: Mapped[date | None] = mapped_column(Date)
|
||||
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
actif: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
|
|
|||
24
backend/app/models/operation_bancaire.py
Normal file
24
backend/app/models/operation_bancaire.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Table operations_bancaires — suivi du compte bancaire."""
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy import String, Date, DateTime, Text, ForeignKey, Numeric, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class OperationBancaire(Base):
|
||||
__tablename__ = "operations_bancaires"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||
type: Mapped[str] = mapped_column(
|
||||
SAEnum("depot", "retrait", "releve", name="type_operation_bancaire"),
|
||||
nullable=False,
|
||||
)
|
||||
montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
date_operation: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
effectue_par: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
cree_le: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""Table postes."""
|
||||
import uuid
|
||||
from sqlalchemy import String, Text, Integer, Interval, Enum as SAEnum
|
||||
from sqlalchemy import String, Text, Integer, Boolean, Interval, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
|
@ -14,4 +14,6 @@ class Poste(Base):
|
|||
description: Mapped[str | None] = mapped_column(Text)
|
||||
duree_recommandee: Mapped[str | None] = mapped_column(Interval)
|
||||
prerequis: Mapped[str | None] = mapped_column(Text)
|
||||
informations: Mapped[str | None] = mapped_column(Text)
|
||||
ordre_affichage: Mapped[int | None] = mapped_column(Integer)
|
||||
actif: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
|
|
|||
33
backend/app/models/proposition.py
Normal file
33
backend/app/models/proposition.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Table propositions — cycle parlementaire des réunions d'affaires."""
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy import String, Date, DateTime, Text, ForeignKey, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Proposition(Base):
|
||||
__tablename__ = "propositions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||
reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"), nullable=True)
|
||||
type_proposition: Mapped[str] = mapped_column(
|
||||
String(20), default="generale", # generale, nomination, contribution
|
||||
)
|
||||
sujet: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
proposeur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||
secondeur_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
statut: Mapped[str] = mapped_column(
|
||||
SAEnum("proposee", "secondee", "adoptee", "rejetee", name="statut_proposition"),
|
||||
default="proposee",
|
||||
)
|
||||
numero_resolution: Mapped[str | None] = mapped_column(String(20)) # RES-2026-001
|
||||
date_vote: Mapped[date | None] = mapped_column(Date)
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
# Lien vers la candidature (si type=nomination)
|
||||
candidature_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("candidatures.id"))
|
||||
cree_le: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
30
backend/app/models/tache.py
Normal file
30
backend/app/models/tache.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Table taches — tâches assignables aux postes."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Tache(Base):
|
||||
__tablename__ = "taches"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
nom: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
informations: Mapped[str | None] = mapped_column(Text)
|
||||
ordre_affichage: Mapped[int] = mapped_column(Integer, default=99)
|
||||
cree_le: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class PosteTache(Base):
|
||||
"""Association poste ↔ tâche (N:N). Une tâche peut être affectée à plusieurs postes."""
|
||||
__tablename__ = "postes_taches"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False)
|
||||
tache_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("taches.id"), nullable=False)
|
||||
cree_le: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Table ventes_litterature — Ventes de littérature AA."""
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy import Integer, Date, DateTime, ForeignKey, Numeric
|
||||
from sqlalchemy import Boolean, Integer, Date, DateTime, ForeignKey, Numeric
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
|
@ -16,4 +16,8 @@ class VenteLitterature(Base):
|
|||
montant: Mapped[float] = mapped_column(Numeric(8, 2), nullable=False)
|
||||
date_vente: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
vendu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
# Remise au trésorier
|
||||
remis_tresorier: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
recu_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||
date_remise: Mapped[date | None] = mapped_column(Date)
|
||||
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
|
|
|||
|
|
@ -7,26 +7,34 @@ from app.core.database import get_db
|
|||
from app.core.security import get_current_membre
|
||||
from app.models.affectation import Affectation
|
||||
from app.models.poste import Poste
|
||||
from app.models.groupe import Groupe
|
||||
from app.schemas.schemas import AccueilResponse, ModuleTuile, MembrePublic
|
||||
|
||||
router = APIRouter(prefix="/accueil", tags=["Accueil"])
|
||||
|
||||
# ── Définition des modules ──
|
||||
MODULES_CONSULTATION = [
|
||||
ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, rapport RSG", icone="📄", couleur="blue"),
|
||||
ModuleTuile(code="C2", nom="Calendrier district", description="Serviteurs de tous les groupes", icone="📅", couleur="green"),
|
||||
ModuleTuile(code="C3", nom="Anniversaires", description="Sobriété du district 87-16", icone="🎂", couleur="yellow"),
|
||||
ModuleTuile(code="C1", nom="Rapports PDF", description="Finances, PV, résolutions", icone="📄", couleur="blue"),
|
||||
ModuleTuile(code="C2", nom="Événements à venir", description="Anniversaires et événements", icone="📅", couleur="green"),
|
||||
ModuleTuile(code="C4", nom="Littérature", description="Inventaire et prix", icone="📚", couleur="purple"),
|
||||
ModuleTuile(code="C5", nom="Décisions en cours", description="Suivi réunion d'affaires", icone="📌", couleur="red"),
|
||||
ModuleTuile(code="C6", nom="Votes ouverts", description="Conscience de groupe", icone="🗳️", couleur="indigo"),
|
||||
ModuleTuile(code="C7", nom="Mon profil", description="Modifier, PIN, notifications", icone="👤", couleur="gray"),
|
||||
ModuleTuile(code="C8", nom="Propositions", description="Soumettre, seconder, résolutions", icone="📋", couleur="orange"),
|
||||
ModuleTuile(code="C9", nom="S'impliquer", description="Postes disponibles, candidature", icone="🙋", couleur="yellow"),
|
||||
ModuleTuile(code="C10", nom="État financier", description="Résultats et transactions", icone="📊", couleur="blue"),
|
||||
]
|
||||
|
||||
# Modules de saisie visibles par TOUS les membres
|
||||
MODULES_SAISIE_TOUS = [
|
||||
ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"),
|
||||
ModuleTuile(code="S5", nom="Dépense", description="Soumettre une dépense", icone="💸", couleur="red"),
|
||||
]
|
||||
|
||||
# Modules de saisie par poste
|
||||
MODULES_SAISIE = {
|
||||
"Trésorier(ière)": [
|
||||
ModuleTuile(code="S1", nom="Collecte 7e", description="Saisir le panier", icone="💰", couleur="green"),
|
||||
ModuleTuile(code="S5", nom="Dépense", description="Enregistrer une dépense", icone="💸", couleur="red"),
|
||||
ModuleTuile(code="S1T", nom="Trésorerie", description="Réceptions, dépôts, soldes", icone="🏦", couleur="green"),
|
||||
ModuleTuile(code="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"),
|
||||
],
|
||||
"Secrétaire": [
|
||||
|
|
@ -60,8 +68,8 @@ MODULES_GESTION = [
|
|||
ModuleTuile(code="G5", nom="Planifier rotations", description="Calendrier serviteurs", icone="📅", couleur="green"),
|
||||
ModuleTuile(code="G6", nom="Lancer un sondage", description="Conscience de groupe", icone="📊", couleur="indigo"),
|
||||
ModuleTuile(code="G7", nom="Jumeler parrain", description="Parrain / filleul", icone="🤝", couleur="yellow"),
|
||||
ModuleTuile(code="G8", nom="Config. répartition", description="% contributions", icone="⚙️", couleur="gray"),
|
||||
ModuleTuile(code="G9", nom="Paramètres groupe", description="Nom, horaire, adresse", icone="🔧", couleur="gray"),
|
||||
ModuleTuile(code="G10", nom="Gérer les postes", description="Créer, abolir, candidatures", icone="📋", couleur="purple"),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -91,9 +99,9 @@ async def accueil(
|
|||
# Modules de consultation : toujours visibles
|
||||
modules_consultation = MODULES_CONSULTATION
|
||||
|
||||
# Modules de saisie : union des modules de tous les postes
|
||||
modules_saisie = []
|
||||
codes_vus = set()
|
||||
# Modules de saisie : collecte pour tous + modules par poste
|
||||
modules_saisie = list(MODULES_SAISIE_TOUS)
|
||||
codes_vus = {"S1", "S5"}
|
||||
for nom_poste in postes_noms:
|
||||
for m in MODULES_SAISIE.get(nom_poste, []):
|
||||
if m.code not in codes_vus:
|
||||
|
|
@ -103,10 +111,23 @@ async def accueil(
|
|||
# Modules de gestion : seulement si exécutif
|
||||
modules_gestion = MODULES_GESTION if "executif" in postes_categories else []
|
||||
|
||||
# Info groupe pour le frontend
|
||||
groupe = await db.get(Groupe, membre.groupe_id)
|
||||
groupe_info = None
|
||||
if groupe:
|
||||
groupe_info = {
|
||||
"id": str(groupe.id),
|
||||
"nom": groupe.nom,
|
||||
"jour_reunion": groupe.jour_reunion,
|
||||
"heure_reunion": groupe.heure_reunion.strftime("%H:%M") if groupe.heure_reunion else None,
|
||||
"adresse_local": groupe.adresse_local,
|
||||
}
|
||||
|
||||
return AccueilResponse(
|
||||
membre=MembrePublic.model_validate(membre),
|
||||
postes=postes_noms,
|
||||
modules_consultation=modules_consultation,
|
||||
modules_saisie=modules_saisie,
|
||||
modules_gestion=modules_gestion,
|
||||
groupe=groupe_info,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,24 +29,34 @@ async def health(db: AsyncSession = Depends(get_db)):
|
|||
async def liste_groupes(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Groupe).order_by(Groupe.nom))
|
||||
groupes = result.scalars().all()
|
||||
return [{"id": str(g.id), "nom": g.nom, "district": g.district} for g in groupes]
|
||||
return [{
|
||||
"id": str(g.id),
|
||||
"nom": g.nom,
|
||||
"district": g.district,
|
||||
"adresse_local": g.adresse_local,
|
||||
"jour_reunion": g.jour_reunion,
|
||||
"heure_reunion": g.heure_reunion.strftime("%H:%M") if g.heure_reunion else None,
|
||||
"format_reunion": g.format_reunion,
|
||||
} for g in groupes]
|
||||
|
||||
|
||||
@router.post("/connexion", response_model=TokenResponse)
|
||||
async def connexion(req: ConnexionRequest, db: AsyncSession = Depends(get_db)):
|
||||
# Chercher le membre par prénom + groupe (insensible à la casse)
|
||||
# Chercher le membre par courriel + groupe (insensible à la casse)
|
||||
result = await db.execute(
|
||||
select(Membre).where(
|
||||
func.lower(Membre.prenom) == func.lower(req.prenom),
|
||||
func.lower(Membre.courriel) == func.lower(req.courriel),
|
||||
Membre.groupe_id == req.groupe_id,
|
||||
Membre.actif == True,
|
||||
)
|
||||
)
|
||||
membre = result.scalar_one_or_none()
|
||||
if not membre or not verify_pin(req.pin, membre.pin_hash):
|
||||
raise HTTPException(status_code=401, detail="Prénom ou PIN incorrect")
|
||||
raise HTTPException(status_code=401, detail="Courriel ou PIN incorrect")
|
||||
|
||||
token = create_token(str(membre.id), str(membre.groupe_id))
|
||||
# Token plus long si "se souvenir de moi"
|
||||
expire_jours = 90 if req.se_souvenir else 30
|
||||
token = create_token(str(membre.id), str(membre.groupe_id), expire_jours=expire_jours)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
membre=MembrePublic.model_validate(membre),
|
||||
|
|
@ -68,16 +78,16 @@ async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db
|
|||
if invitation.expire_le < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=400, detail="Code d'invitation expiré")
|
||||
|
||||
# Vérifier unicité du prénom dans le groupe
|
||||
# Vérifier unicité du courriel dans le groupe
|
||||
exists = await db.execute(
|
||||
select(Membre).where(
|
||||
func.lower(Membre.prenom) == func.lower(req.prenom),
|
||||
func.lower(Membre.courriel) == func.lower(req.courriel),
|
||||
Membre.groupe_id == invitation.groupe_id,
|
||||
Membre.actif == True,
|
||||
)
|
||||
)
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Ce prénom est déjà utilisé dans ce groupe")
|
||||
raise HTTPException(status_code=400, detail="Ce courriel est déjà utilisé dans ce groupe")
|
||||
|
||||
# Valider le PIN (4 chiffres)
|
||||
if not req.pin.isdigit() or len(req.pin) != 4:
|
||||
|
|
@ -87,9 +97,9 @@ async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db
|
|||
membre = Membre(
|
||||
groupe_id=invitation.groupe_id,
|
||||
prenom=req.prenom.strip().title(),
|
||||
nom=req.nom.strip().title(),
|
||||
telephone=req.telephone,
|
||||
courriel=req.courriel,
|
||||
nom=req.nom.strip().title() if req.nom else None,
|
||||
telephone=req.telephone.strip(),
|
||||
courriel=req.courriel.strip().lower(),
|
||||
date_abstinence=req.date_abstinence,
|
||||
pin_hash=hash_pin(req.pin),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,80 +1,521 @@
|
|||
"""Routes S1 — Collecte 7e Tradition."""
|
||||
from datetime import date, datetime, time as dt_time, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import select, desc, extract, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_membre
|
||||
from app.core.security import get_current_membre, require_executif
|
||||
from app.models.collecte import Collecte
|
||||
from app.models.groupe import Groupe
|
||||
from app.models.membre import Membre
|
||||
from app.models.depense import Depense
|
||||
from app.models.envoi_contribution import EnvoiContribution
|
||||
from app.models.operation_bancaire import OperationBancaire
|
||||
from app.models.affectation import Affectation
|
||||
from app.models.poste import Poste
|
||||
from app.schemas.phase1 import CollecteCreate, CollecteOut
|
||||
from app.schemas.phase1 import CollecteCreate, CollecteOut, CollecteUpdate, SoldesOut
|
||||
|
||||
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
|
||||
|
||||
JOURS_SEMAINE = {
|
||||
"lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3,
|
||||
"vendredi": 4, "samedi": 5, "dimanche": 6,
|
||||
}
|
||||
|
||||
async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||
"""Vérifie que le membre est trésorier."""
|
||||
|
||||
async def _est_tresorier(membre_id, groupe_id, db: AsyncSession) -> bool:
|
||||
"""Vérifie si un membre est trésorier ou exécutif."""
|
||||
result = await db.execute(
|
||||
select(Affectation).join(Poste).where(
|
||||
Affectation.membre_id == membre.id,
|
||||
Affectation.membre_id == membre_id,
|
||||
Affectation.statut == "actif",
|
||||
Poste.nom.ilike("%trésorier%"),
|
||||
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
|
||||
)
|
||||
)
|
||||
if not result.scalars().first():
|
||||
raise HTTPException(status_code=403, detail="Réservé au trésorier")
|
||||
return result.scalars().first() is not None
|
||||
|
||||
|
||||
async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||
if not await _est_tresorier(membre.id, membre.groupe_id, db):
|
||||
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
|
||||
return membre
|
||||
|
||||
|
||||
async def _to_out(c: Collecte, db: AsyncSession) -> CollecteOut:
|
||||
detenteur = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None
|
||||
saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None
|
||||
confirme = await db.get(Membre, c.confirme_par) if c.confirme_par else None
|
||||
recu = await db.get(Membre, c.recu_par) if c.recu_par else None
|
||||
return CollecteOut(
|
||||
id=c.id,
|
||||
date_collecte=c.date_collecte,
|
||||
montant=float(c.montant),
|
||||
detenteur_prenom=detenteur.prenom if detenteur else None,
|
||||
statut=c.statut,
|
||||
saisi_par_prenom=saisi.prenom if saisi else None,
|
||||
confirme_par_prenom=confirme.prenom if confirme else None,
|
||||
date_confirmation=c.date_confirmation,
|
||||
recu_par_prenom=recu.prenom if recu else None,
|
||||
date_reception=c.date_reception,
|
||||
cree_le=c.cree_le,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=CollecteOut)
|
||||
async def saisir_collecte(
|
||||
req: CollecteCreate,
|
||||
membre=Depends(require_tresorier),
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""S1 — Saisir le montant collecté au panier."""
|
||||
"""S1 — N'importe quel membre saisit la collecte. Rétroactif possible sur les dates de réunion."""
|
||||
groupe = await db.get(Groupe, membre.groupe_id)
|
||||
aujourdhui = date.today()
|
||||
|
||||
# Validation : pas dans le futur
|
||||
if req.date_collecte > aujourdhui:
|
||||
raise HTTPException(status_code=400, detail="La date ne peut pas être dans le futur")
|
||||
|
||||
# Validation : la date doit tomber un jour de réunion
|
||||
if groupe.jour_reunion:
|
||||
jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower())
|
||||
if jour_attendu is not None and req.date_collecte.weekday() != jour_attendu:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"La collecte doit avoir lieu un {groupe.jour_reunion}",
|
||||
)
|
||||
|
||||
# Validation : si c'est aujourd'hui, pas avant l'heure de la réunion
|
||||
if req.date_collecte == aujourdhui and groupe.heure_reunion:
|
||||
now = datetime.now(timezone.utc)
|
||||
if isinstance(groupe.heure_reunion, dt_time) and now.time() < groupe.heure_reunion:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"La saisie n'est possible qu'à partir de {groupe.heure_reunion.strftime('%H:%M')}",
|
||||
)
|
||||
|
||||
# Validation : une seule collecte par date de réunion
|
||||
result = await db.execute(
|
||||
select(Collecte).where(
|
||||
Collecte.groupe_id == membre.groupe_id,
|
||||
Collecte.date_collecte == req.date_collecte,
|
||||
)
|
||||
)
|
||||
if result.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Il y a déjà une collecte pour le {req.date_collecte}",
|
||||
)
|
||||
|
||||
collecte = Collecte(
|
||||
groupe_id=membre.groupe_id,
|
||||
date_collecte=req.date_collecte,
|
||||
montant=req.montant,
|
||||
reunion_id=req.reunion_id,
|
||||
detenteur_id=req.detenteur_id,
|
||||
statut="en_attente",
|
||||
saisi_par=membre.id,
|
||||
)
|
||||
db.add(collecte)
|
||||
await db.flush()
|
||||
return CollecteOut(
|
||||
id=collecte.id,
|
||||
date_collecte=collecte.date_collecte,
|
||||
montant=float(collecte.montant),
|
||||
saisi_par_prenom=membre.prenom,
|
||||
cree_le=collecte.cree_le,
|
||||
return await _to_out(collecte, db)
|
||||
|
||||
|
||||
@router.patch("/{collecte_id}", response_model=CollecteOut)
|
||||
async def ajuster_collecte(
|
||||
collecte_id: str,
|
||||
req: CollecteUpdate,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Ajuster le montant (exécutif ou celui qui a saisi, avant confirmation)."""
|
||||
result = await db.execute(
|
||||
select(Collecte).where(
|
||||
Collecte.id == collecte_id,
|
||||
Collecte.groupe_id == membre.groupe_id,
|
||||
)
|
||||
)
|
||||
collecte = result.scalar_one_or_none()
|
||||
if not collecte:
|
||||
raise HTTPException(status_code=404, detail="Collecte introuvable")
|
||||
if collecte.statut != "en_attente":
|
||||
raise HTTPException(status_code=400, detail="Le montant ne peut plus être modifié après confirmation")
|
||||
|
||||
# Vérifier : exécutif ou saisi_par
|
||||
est_exec = await _est_tresorier(membre.id, membre.groupe_id, db)
|
||||
if collecte.saisi_par != membre.id and not est_exec:
|
||||
raise HTTPException(status_code=403, detail="Seul un exécutif ou la personne qui a saisi peut modifier")
|
||||
|
||||
if req.montant is not None:
|
||||
collecte.montant = req.montant
|
||||
await db.flush()
|
||||
return await _to_out(collecte, db)
|
||||
|
||||
|
||||
@router.post("/{collecte_id}/confirmer", response_model=CollecteOut)
|
||||
async def confirmer_collecte(
|
||||
collecte_id: str,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Un second membre confirme le montant. Si le trésorier est impliqué → réception auto."""
|
||||
result = await db.execute(
|
||||
select(Collecte).where(
|
||||
Collecte.id == collecte_id,
|
||||
Collecte.groupe_id == membre.groupe_id,
|
||||
)
|
||||
)
|
||||
collecte = result.scalar_one_or_none()
|
||||
if not collecte:
|
||||
raise HTTPException(status_code=404, detail="Collecte introuvable")
|
||||
if collecte.statut != "en_attente":
|
||||
raise HTTPException(status_code=400, detail="Cette collecte a déjà été confirmée")
|
||||
if collecte.saisi_par == membre.id:
|
||||
raise HTTPException(status_code=400, detail="Le montant doit être confirmé par un autre membre")
|
||||
|
||||
collecte.confirme_par = membre.id
|
||||
collecte.date_confirmation = datetime.now(timezone.utc)
|
||||
|
||||
# Si le trésorier est celui qui confirme OU celui qui a saisi → réception auto
|
||||
confirmateur_est_tres = await _est_tresorier(membre.id, membre.groupe_id, db)
|
||||
saisisseur_est_tres = await _est_tresorier(collecte.saisi_par, collecte.groupe_id, db)
|
||||
|
||||
if confirmateur_est_tres or saisisseur_est_tres:
|
||||
tresorier_id = membre.id if confirmateur_est_tres else collecte.saisi_par
|
||||
collecte.statut = "recu"
|
||||
collecte.recu_par = tresorier_id
|
||||
collecte.date_reception = date.today()
|
||||
else:
|
||||
collecte.statut = "confirmee"
|
||||
|
||||
await db.flush()
|
||||
return await _to_out(collecte, db)
|
||||
|
||||
|
||||
@router.post("/{collecte_id}/reception", response_model=CollecteOut)
|
||||
async def accuser_reception(
|
||||
collecte_id: str,
|
||||
membre=Depends(_require_tresorier),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Le trésorier accuse réception (pour les cas sans trésorier impliqué)."""
|
||||
result = await db.execute(
|
||||
select(Collecte).where(
|
||||
Collecte.id == collecte_id,
|
||||
Collecte.groupe_id == membre.groupe_id,
|
||||
)
|
||||
)
|
||||
collecte = result.scalar_one_or_none()
|
||||
if not collecte:
|
||||
raise HTTPException(status_code=404, detail="Collecte introuvable")
|
||||
if collecte.statut == "recu":
|
||||
raise HTTPException(status_code=400, detail="Cette collecte a déjà été reçue")
|
||||
|
||||
# Si en_attente, confirme aussi
|
||||
if collecte.statut == "en_attente":
|
||||
collecte.confirme_par = membre.id
|
||||
collecte.date_confirmation = datetime.now(timezone.utc)
|
||||
|
||||
collecte.statut = "recu"
|
||||
collecte.recu_par = membre.id
|
||||
collecte.date_reception = date.today()
|
||||
await db.flush()
|
||||
return await _to_out(collecte, db)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CollecteOut])
|
||||
async def liste_collectes(
|
||||
statut: str | None = None,
|
||||
mois: int | None = None,
|
||||
annee: int | None = None,
|
||||
limite: int = 20,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Lister les collectes récentes du groupe."""
|
||||
result = await db.execute(
|
||||
select(Collecte)
|
||||
.where(Collecte.groupe_id == membre.groupe_id)
|
||||
.order_by(desc(Collecte.date_collecte))
|
||||
.limit(limite)
|
||||
query = select(Collecte).where(Collecte.groupe_id == membre.groupe_id)
|
||||
if statut:
|
||||
query = query.where(Collecte.statut == statut)
|
||||
if mois and annee:
|
||||
query = query.where(
|
||||
extract("month", Collecte.date_collecte) == mois,
|
||||
extract("year", Collecte.date_collecte) == annee,
|
||||
)
|
||||
collectes = result.scalars().all()
|
||||
out = []
|
||||
for c in collectes:
|
||||
elif annee:
|
||||
query = query.where(extract("year", Collecte.date_collecte) == annee)
|
||||
query = query.order_by(desc(Collecte.date_collecte)).limit(limite)
|
||||
result = await db.execute(query)
|
||||
return [await _to_out(c, db) for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/soldes")
|
||||
async def soldes_tresorerie(
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Soldes détaillés : relevé bancaire, sommes engagées, réserves, disponible."""
|
||||
from app.models.vente_litterature import VenteLitterature
|
||||
|
||||
gid = membre.groupe_id
|
||||
|
||||
# ── Dernier relevé bancaire ──
|
||||
result = await db.execute(
|
||||
select(OperationBancaire).where(
|
||||
OperationBancaire.groupe_id == gid,
|
||||
OperationBancaire.type == "releve",
|
||||
).order_by(desc(OperationBancaire.date_operation)).limit(1)
|
||||
)
|
||||
dernier_releve = result.scalar_one_or_none()
|
||||
solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0
|
||||
date_releve = str(dernier_releve.date_operation) if dernier_releve else None
|
||||
|
||||
# ── Dépôts et retraits depuis le dernier relevé ──
|
||||
filtre_depuis = dernier_releve.date_operation if dernier_releve else None
|
||||
|
||||
# Dépôts après relevé
|
||||
q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
|
||||
OperationBancaire.groupe_id == gid,
|
||||
OperationBancaire.type == "depot",
|
||||
)
|
||||
if filtre_depuis:
|
||||
q = q.where(OperationBancaire.date_operation > filtre_depuis)
|
||||
result = await db.execute(q)
|
||||
depots_depuis = float(result.scalar())
|
||||
|
||||
# Retraits après relevé
|
||||
q = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
|
||||
OperationBancaire.groupe_id == gid,
|
||||
OperationBancaire.type == "retrait",
|
||||
)
|
||||
if filtre_depuis:
|
||||
q = q.where(OperationBancaire.date_operation > filtre_depuis)
|
||||
result = await db.execute(q)
|
||||
retraits_depuis = float(result.scalar())
|
||||
|
||||
solde_banque = solde_releve + depots_depuis - retraits_depuis
|
||||
|
||||
# ── Dépenses engagées non payées (chèque/virement votés mais pas encore débités) ──
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Depense.montant), 0)).where(
|
||||
Depense.groupe_id == gid,
|
||||
Depense.paye == False,
|
||||
Depense.methode.in_(["VIREMENT", "CHEQUE"]),
|
||||
)
|
||||
)
|
||||
engagements_non_debites = float(result.scalar())
|
||||
|
||||
# ── Contributions envoyées non débitées ──
|
||||
# (on considère toutes les contributions comme engagées)
|
||||
|
||||
# ── Réserves ──
|
||||
reserve_prudente = 500.0
|
||||
|
||||
# Réserve anniversaire : +20$/mois depuis la création du groupe
|
||||
from app.models.groupe import Groupe as GroupeModel
|
||||
groupe = await db.get(GroupeModel, gid)
|
||||
mois_depuis_creation = 0
|
||||
if groupe and groupe.cree_le:
|
||||
from datetime import date as date_type
|
||||
aujourdhui = date_type.today()
|
||||
cree = groupe.cree_le.date() if hasattr(groupe.cree_le, 'date') else groupe.cree_le
|
||||
mois_depuis_creation = (aujourdhui.year - cree.year) * 12 + (aujourdhui.month - cree.month)
|
||||
mois_depuis_creation = max(0, mois_depuis_creation)
|
||||
reserve_anniversaire = mois_depuis_creation * 20.0
|
||||
|
||||
total_reserves = reserve_prudente + reserve_anniversaire
|
||||
|
||||
# ── Somme disponible ──
|
||||
disponible = solde_banque - engagements_non_debites - total_reserves
|
||||
|
||||
# ── Encaisse (cash reçu par le trésorier, pas déposé) ──
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Collecte.montant), 0)).where(
|
||||
Collecte.groupe_id == gid, Collecte.statut == "recu"))
|
||||
total_collectes_recues = float(result.scalar())
|
||||
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
|
||||
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot"))
|
||||
total_depots = float(result.scalar())
|
||||
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Depense.montant), 0)).where(
|
||||
Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE"))
|
||||
depenses_cash = float(result.scalar())
|
||||
|
||||
# Ventes de littérature remises au trésorier
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
|
||||
VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True))
|
||||
ventes_recues = float(result.scalar())
|
||||
|
||||
encaisse = total_collectes_recues + ventes_recues - total_depots - depenses_cash
|
||||
|
||||
# ── Argent en circulation (collectes non remises au trésorier) ──
|
||||
result = await db.execute(
|
||||
select(Collecte).where(
|
||||
Collecte.groupe_id == gid, Collecte.statut.in_(["en_attente", "confirmee"])))
|
||||
en_circulation = result.scalars().all()
|
||||
details_circulation = []
|
||||
total_en_circulation = 0.0
|
||||
for c in en_circulation:
|
||||
det = await db.get(Membre, c.detenteur_id) if c.detenteur_id else None
|
||||
total_en_circulation += float(c.montant)
|
||||
details_circulation.append({
|
||||
"date": str(c.date_collecte),
|
||||
"montant": float(c.montant),
|
||||
"detenteur": det.prenom if det else "?",
|
||||
"statut": c.statut,
|
||||
})
|
||||
|
||||
# ── Ventes de littérature non remises ──
|
||||
result = await db.execute(
|
||||
select(VenteLitterature).where(
|
||||
VenteLitterature.groupe_id == gid,
|
||||
VenteLitterature.remis_tresorier == False,
|
||||
)
|
||||
)
|
||||
from app.models.litterature import Litterature
|
||||
ventes_non_remises = result.scalars().all()
|
||||
details_ventes = []
|
||||
total_ventes_non_remises = 0.0
|
||||
for v in ventes_non_remises:
|
||||
vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None
|
||||
item = await db.get(Litterature, v.litterature_id) if v.litterature_id else None
|
||||
total_ventes_non_remises += float(v.montant)
|
||||
details_ventes.append({
|
||||
"id": str(v.id),
|
||||
"date": str(v.date_vente),
|
||||
"montant": float(v.montant),
|
||||
"titre": item.titre if item else "?",
|
||||
"vendu_par": vendeur.prenom if vendeur else "?",
|
||||
})
|
||||
|
||||
return {
|
||||
"solde_banque": round(solde_banque, 2),
|
||||
"date_releve": date_releve,
|
||||
"encaisse": round(max(0, encaisse), 2),
|
||||
"disponible": round(disponible, 2),
|
||||
"engagements_non_debites": round(engagements_non_debites, 2),
|
||||
"reserve_prudente": reserve_prudente,
|
||||
"reserve_anniversaire": round(reserve_anniversaire, 2),
|
||||
"total_reserves": round(total_reserves, 2),
|
||||
"en_possession": round(total_en_circulation, 2),
|
||||
"en_possession_details": details_circulation,
|
||||
"ventes_non_remises": round(total_ventes_non_remises, 2),
|
||||
"ventes_non_remises_details": details_ventes,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/registre")
|
||||
async def registre_transactions(
|
||||
mois: int | None = None,
|
||||
annee: int | None = None,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Registre détaillé de toutes les transactions financières du groupe."""
|
||||
from app.models.vente_litterature import VenteLitterature
|
||||
from app.models.litterature import Litterature
|
||||
|
||||
gid = membre.groupe_id
|
||||
transactions = []
|
||||
|
||||
# Filtres date
|
||||
def filtre_mois(d):
|
||||
if mois and annee:
|
||||
return d.month == mois and d.year == annee
|
||||
if annee:
|
||||
return d.year == annee
|
||||
return True
|
||||
|
||||
# 1. Collectes reçues
|
||||
result = await db.execute(
|
||||
select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu")
|
||||
.order_by(Collecte.date_collecte)
|
||||
)
|
||||
for c in result.scalars().all():
|
||||
if filtre_mois(c.date_collecte):
|
||||
saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None
|
||||
out.append(CollecteOut(
|
||||
id=c.id,
|
||||
date_collecte=c.date_collecte,
|
||||
montant=float(c.montant),
|
||||
saisi_par_prenom=saisi.prenom if saisi else None,
|
||||
cree_le=c.cree_le,
|
||||
))
|
||||
return out
|
||||
transactions.append({
|
||||
"date": str(c.date_collecte),
|
||||
"type": "collecte",
|
||||
"description": f"Collecte 7e Tradition",
|
||||
"entree": float(c.montant),
|
||||
"sortie": 0,
|
||||
"par": saisi.prenom if saisi else "?",
|
||||
})
|
||||
|
||||
# 2. Ventes de littérature remises
|
||||
result = await db.execute(
|
||||
select(VenteLitterature).where(
|
||||
VenteLitterature.groupe_id == gid,
|
||||
VenteLitterature.remis_tresorier == True,
|
||||
)
|
||||
)
|
||||
for v in result.scalars().all():
|
||||
if filtre_mois(v.date_vente):
|
||||
item = await db.get(Litterature, v.litterature_id)
|
||||
transactions.append({
|
||||
"date": str(v.date_vente),
|
||||
"type": "vente_litterature",
|
||||
"description": f"Vente : {item.titre if item else '?'} (x{v.quantite})",
|
||||
"entree": float(v.montant),
|
||||
"sortie": 0,
|
||||
"par": "Littérature",
|
||||
})
|
||||
|
||||
# 3. Dépenses payées
|
||||
result = await db.execute(
|
||||
select(Depense).where(Depense.groupe_id == gid, Depense.paye == True)
|
||||
.order_by(Depense.date_depense)
|
||||
)
|
||||
for d in result.scalars().all():
|
||||
if filtre_mois(d.date_depense):
|
||||
qui = await db.get(Membre, d.qui)
|
||||
transactions.append({
|
||||
"date": str(d.date_depense),
|
||||
"type": "depense",
|
||||
"description": f"{d.quoi} ({d.methode})",
|
||||
"entree": 0,
|
||||
"sortie": float(d.montant),
|
||||
"par": qui.prenom if qui else "?",
|
||||
})
|
||||
|
||||
# 4. Envois de contributions
|
||||
result = await db.execute(
|
||||
select(EnvoiContribution).where(EnvoiContribution.groupe_id == gid)
|
||||
.order_by(EnvoiContribution.date_envoi)
|
||||
)
|
||||
for e in result.scalars().all():
|
||||
if filtre_mois(e.date_envoi):
|
||||
transactions.append({
|
||||
"date": str(e.date_envoi),
|
||||
"type": "contribution",
|
||||
"description": f"Contribution {e.destination} ({e.methode})",
|
||||
"entree": 0,
|
||||
"sortie": float(e.montant),
|
||||
"par": "Trésorier",
|
||||
})
|
||||
|
||||
# 5. Opérations bancaires (dépôts/retraits)
|
||||
result = await db.execute(
|
||||
select(OperationBancaire).where(
|
||||
OperationBancaire.groupe_id == gid,
|
||||
OperationBancaire.type.in_(["depot", "retrait"]),
|
||||
).order_by(OperationBancaire.date_operation)
|
||||
)
|
||||
for op in result.scalars().all():
|
||||
if filtre_mois(op.date_operation):
|
||||
m = await db.get(Membre, op.effectue_par)
|
||||
is_depot = op.type == "depot"
|
||||
transactions.append({
|
||||
"date": str(op.date_operation),
|
||||
"type": f"banque_{op.type}",
|
||||
"description": f"{'Dépôt' if is_depot else 'Retrait'} bancaire{(' — ' + op.note) if op.note else ''}",
|
||||
"entree": float(op.montant) if is_depot else 0,
|
||||
"sortie": float(op.montant) if not is_depot else 0,
|
||||
"par": m.prenom if m else "?",
|
||||
})
|
||||
|
||||
transactions.sort(key=lambda t: t["date"])
|
||||
return transactions
|
||||
|
|
|
|||
|
|
@ -92,18 +92,39 @@ async def enregistrer_envoi(
|
|||
membre=Depends(require_tresorier),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""M4 — Enregistrer un envoi de contribution à un organisme."""
|
||||
"""M4 — Enregistrer un envoi de contribution. Exige une résolution adoptée."""
|
||||
from app.models.proposition import Proposition
|
||||
|
||||
if req.destination not in DESTINATIONS:
|
||||
raise HTTPException(status_code=400, detail=f"Destination invalide. Choix: {DESTINATIONS}")
|
||||
if req.methode.upper() not in METHODES_CONTRIBUTION:
|
||||
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix: {METHODES_CONTRIBUTION}")
|
||||
|
||||
# Valider la résolution
|
||||
if not req.resolution_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Une contribution doit faire l'objet d'une résolution adoptée. Créez d'abord une proposition, faites-la seconder et adopter."
|
||||
)
|
||||
result = await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.id == req.resolution_id,
|
||||
Proposition.groupe_id == membre.groupe_id,
|
||||
Proposition.statut == "adoptee",
|
||||
)
|
||||
)
|
||||
resolution = result.scalar_one_or_none()
|
||||
if not resolution:
|
||||
raise HTTPException(status_code=400, detail="Résolution introuvable ou non adoptée")
|
||||
|
||||
envoi = EnvoiContribution(
|
||||
groupe_id=membre.groupe_id,
|
||||
date_envoi=req.date_envoi,
|
||||
destination=req.destination,
|
||||
montant=req.montant,
|
||||
methode=req.methode.upper(),
|
||||
resolution_id=req.resolution_id,
|
||||
engagee=True,
|
||||
saisi_par=membre.id,
|
||||
)
|
||||
db.add(envoi)
|
||||
|
|
@ -116,6 +137,9 @@ async def enregistrer_envoi(
|
|||
destination_label=DESTINATIONS_LABELS[envoi.destination],
|
||||
montant=float(envoi.montant),
|
||||
methode=envoi.methode,
|
||||
resolution_id=envoi.resolution_id,
|
||||
engagee=envoi.engagee,
|
||||
debitee=envoi.debitee,
|
||||
saisi_par_prenom=membre.prenom,
|
||||
cree_le=envoi.cree_le,
|
||||
)
|
||||
|
|
@ -145,6 +169,9 @@ async def liste_envois(
|
|||
destination_label=DESTINATIONS_LABELS.get(e.destination, e.destination),
|
||||
montant=float(e.montant),
|
||||
methode=e.methode,
|
||||
resolution_id=e.resolution_id,
|
||||
engagee=e.engagee,
|
||||
debitee=e.debitee,
|
||||
saisi_par_prenom=saisi.prenom if saisi else None,
|
||||
cree_le=e.cree_le,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
"""Routes S5 — Dépenses (6 champs)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
"""Routes S5 — Dépenses (ouvert à tous, confirmé par le trésorier)."""
|
||||
import os
|
||||
import uuid as uuid_mod
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
|
|
@ -14,85 +17,30 @@ from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
|
|||
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
|
||||
|
||||
METHODES_VALIDES = {"DU", "VIREMENT", "CHEQUE", "COLLECTE"}
|
||||
UPLOAD_DIR = "/opt/groupe-meditation/uploads/preuves"
|
||||
|
||||
|
||||
async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||
"""Vérifie que le membre est trésorier."""
|
||||
async def _est_tresorier_ou_executif(membre_id, db: AsyncSession) -> bool:
|
||||
result = await db.execute(
|
||||
select(Affectation).join(Poste).where(
|
||||
Affectation.membre_id == membre.id,
|
||||
Affectation.membre_id == membre_id,
|
||||
Affectation.statut == "actif",
|
||||
Poste.nom.ilike("%trésorier%"),
|
||||
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
|
||||
)
|
||||
)
|
||||
if not result.scalars().first():
|
||||
raise HTTPException(status_code=403, detail="Réservé au trésorier")
|
||||
return result.scalars().first() is not None
|
||||
|
||||
|
||||
async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||
if not await _est_tresorier_ou_executif(membre.id, db):
|
||||
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
|
||||
return membre
|
||||
|
||||
|
||||
@router.post("/", response_model=DepenseOut)
|
||||
async def saisir_depense(
|
||||
req: DepenseCreate,
|
||||
membre=Depends(require_tresorier),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""S5 — Saisir une dépense avec les 6 champs."""
|
||||
# Valider la méthode
|
||||
if req.methode.upper() not in METHODES_VALIDES:
|
||||
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}")
|
||||
|
||||
# Valider que le membre 'qui' existe
|
||||
qui_membre = await db.get(Membre, req.qui)
|
||||
if not qui_membre or qui_membre.groupe_id != membre.groupe_id:
|
||||
raise HTTPException(status_code=400, detail="Membre introuvable")
|
||||
|
||||
depense = Depense(
|
||||
groupe_id=membre.groupe_id,
|
||||
date_depense=req.date_depense,
|
||||
montant=req.montant,
|
||||
qui=req.qui,
|
||||
quoi=req.quoi.strip(),
|
||||
paye=req.paye,
|
||||
methode=req.methode.upper(),
|
||||
saisi_par=membre.id,
|
||||
)
|
||||
db.add(depense)
|
||||
await db.flush()
|
||||
|
||||
return DepenseOut(
|
||||
id=depense.id,
|
||||
date_depense=depense.date_depense,
|
||||
montant=float(depense.montant),
|
||||
qui_prenom=qui_membre.prenom,
|
||||
quoi=depense.quoi,
|
||||
paye=depense.paye,
|
||||
methode=depense.methode,
|
||||
saisi_par_prenom=membre.prenom,
|
||||
cree_le=depense.cree_le,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DepenseOut])
|
||||
async def liste_depenses(
|
||||
limite: int = 30,
|
||||
paye: bool | None = None,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Lister les dépenses du groupe avec filtre optionnel sur payé."""
|
||||
query = select(Depense).where(Depense.groupe_id == membre.groupe_id)
|
||||
if paye is not None:
|
||||
query = query.where(Depense.paye == paye)
|
||||
query = query.order_by(desc(Depense.date_depense)).limit(limite)
|
||||
|
||||
result = await db.execute(query)
|
||||
depenses = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for d in depenses:
|
||||
async def _to_out(d: Depense, db: AsyncSession) -> DepenseOut:
|
||||
qui = await db.get(Membre, d.qui)
|
||||
saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None
|
||||
out.append(DepenseOut(
|
||||
return DepenseOut(
|
||||
id=d.id,
|
||||
date_depense=d.date_depense,
|
||||
montant=float(d.montant),
|
||||
|
|
@ -100,20 +48,166 @@ async def liste_depenses(
|
|||
quoi=d.quoi,
|
||||
paye=d.paye,
|
||||
methode=d.methode,
|
||||
preuve_nom=d.preuve_nom,
|
||||
saisi_par_prenom=saisi.prenom if saisi else None,
|
||||
confirme=d.confirme_par is not None,
|
||||
cree_le=d.cree_le,
|
||||
))
|
||||
return out
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=DepenseOut)
|
||||
async def soumettre_depense(
|
||||
req: DepenseCreate,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""S5 — N'importe quel membre soumet une dépense courante."""
|
||||
depense = Depense(
|
||||
groupe_id=membre.groupe_id,
|
||||
date_depense=req.date_depense,
|
||||
montant=req.montant,
|
||||
qui=membre.id, # le membre qui soumet est celui qui a payé
|
||||
quoi=req.quoi.strip(),
|
||||
paye=False,
|
||||
methode=None, # sera renseigné par le trésorier
|
||||
saisi_par=membre.id,
|
||||
)
|
||||
db.add(depense)
|
||||
await db.flush()
|
||||
return await _to_out(depense, db)
|
||||
|
||||
|
||||
@router.post("/{depense_id}/preuve")
|
||||
async def telecharger_preuve(
|
||||
depense_id: str,
|
||||
fichier: UploadFile = File(...),
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Attacher une preuve (reçu, photo) à une dépense."""
|
||||
result = await db.execute(
|
||||
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
|
||||
)
|
||||
depense = result.scalar_one_or_none()
|
||||
if not depense:
|
||||
raise HTTPException(status_code=404, detail="Dépense introuvable")
|
||||
|
||||
# Sauvegarder le fichier
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
ext = os.path.splitext(fichier.filename)[1] if fichier.filename else ".jpg"
|
||||
nom_fichier = f"{depense_id}{ext}"
|
||||
chemin = os.path.join(UPLOAD_DIR, nom_fichier)
|
||||
contenu = await fichier.read()
|
||||
with open(chemin, "wb") as f:
|
||||
f.write(contenu)
|
||||
|
||||
depense.preuve_nom = nom_fichier
|
||||
await db.flush()
|
||||
return {"message": "Preuve enregistrée", "fichier": nom_fichier}
|
||||
|
||||
|
||||
@router.post("/{depense_id}/confirmer", response_model=DepenseOut)
|
||||
async def confirmer_depense(
|
||||
depense_id: str,
|
||||
methode: str,
|
||||
membre=Depends(_require_tresorier),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Le trésorier confirme la dépense et renseigne le mode de paiement."""
|
||||
if methode.upper() not in METHODES_VALIDES:
|
||||
raise HTTPException(status_code=400, detail=f"Méthode invalide. Choix : {', '.join(METHODES_VALIDES)}")
|
||||
|
||||
result = await db.execute(
|
||||
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
|
||||
)
|
||||
depense = result.scalar_one_or_none()
|
||||
if not depense:
|
||||
raise HTTPException(status_code=404, detail="Dépense introuvable")
|
||||
|
||||
depense.methode = methode.upper()
|
||||
depense.confirme_par = membre.id
|
||||
depense.date_confirmation = datetime.now(timezone.utc)
|
||||
|
||||
# Si cash (COLLECTE) → payé immédiatement
|
||||
if methode.upper() == "COLLECTE":
|
||||
depense.paye = True
|
||||
|
||||
# Si DU → le groupe doit rembourser, pas encore payé
|
||||
# Si CHEQUE/VIREMENT → engagement, en attente de débit
|
||||
|
||||
await db.flush()
|
||||
return await _to_out(depense, db)
|
||||
|
||||
|
||||
@router.post("/{depense_id}/confirmer-debit", response_model=DepenseOut)
|
||||
async def confirmer_debit(
|
||||
depense_id: str,
|
||||
membre=Depends(_require_tresorier),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Le trésorier confirme que le chèque/virement a été débité du compte."""
|
||||
result = await db.execute(
|
||||
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
|
||||
)
|
||||
depense = result.scalar_one_or_none()
|
||||
if not depense:
|
||||
raise HTTPException(status_code=404, detail="Dépense introuvable")
|
||||
if depense.paye:
|
||||
raise HTTPException(status_code=400, detail="Déjà confirmée comme payée")
|
||||
if depense.methode not in ("CHEQUE", "VIREMENT"):
|
||||
raise HTTPException(status_code=400, detail="Seuls les chèques/virements nécessitent confirmation de débit")
|
||||
|
||||
depense.paye = True
|
||||
await db.flush()
|
||||
return await _to_out(depense, db)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DepenseOut])
|
||||
async def liste_depenses(
|
||||
limite: int = 30,
|
||||
paye: bool | None = None,
|
||||
confirmees: bool | None = None,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Lister les dépenses du groupe."""
|
||||
query = select(Depense).where(Depense.groupe_id == membre.groupe_id)
|
||||
if paye is not None:
|
||||
query = query.where(Depense.paye == paye)
|
||||
if confirmees is not None:
|
||||
if confirmees:
|
||||
query = query.where(Depense.confirme_par.isnot(None))
|
||||
else:
|
||||
query = query.where(Depense.confirme_par.is_(None))
|
||||
query = query.order_by(desc(Depense.date_depense)).limit(limite)
|
||||
|
||||
result = await db.execute(query)
|
||||
return [await _to_out(d, db) for d in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/a-confirmer")
|
||||
async def depenses_a_confirmer(
|
||||
membre=Depends(_require_tresorier),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Dépenses soumises en attente de confirmation par le trésorier."""
|
||||
result = await db.execute(
|
||||
select(Depense).where(
|
||||
Depense.groupe_id == membre.groupe_id,
|
||||
Depense.confirme_par.is_(None),
|
||||
).order_by(desc(Depense.cree_le))
|
||||
)
|
||||
return [await _to_out(d, db) for d in result.scalars().all()]
|
||||
|
||||
|
||||
@router.patch("/{depense_id}", response_model=DepenseOut)
|
||||
async def modifier_depense(
|
||||
depense_id: str,
|
||||
req: DepenseUpdate,
|
||||
membre=Depends(require_tresorier),
|
||||
membre=Depends(_require_tresorier),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Modifier une dépense (ex: marquer comme payé)."""
|
||||
"""Modifier une dépense (trésorier)."""
|
||||
result = await db.execute(
|
||||
select(Depense).where(Depense.id == depense_id, Depense.groupe_id == membre.groupe_id)
|
||||
)
|
||||
|
|
@ -131,16 +225,4 @@ async def modifier_depense(
|
|||
depense.quoi = req.quoi.strip()
|
||||
|
||||
await db.flush()
|
||||
|
||||
qui = await db.get(Membre, depense.qui)
|
||||
return DepenseOut(
|
||||
id=depense.id,
|
||||
date_depense=depense.date_depense,
|
||||
montant=float(depense.montant),
|
||||
qui_prenom=qui.prenom if qui else "?",
|
||||
quoi=depense.quoi,
|
||||
paye=depense.paye,
|
||||
methode=depense.methode,
|
||||
saisi_par_prenom=membre.prenom,
|
||||
cree_le=depense.cree_le,
|
||||
)
|
||||
return await _to_out(depense, db)
|
||||
|
|
|
|||
156
backend/app/routers/evenements.py
Normal file
156
backend/app/routers/evenements.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""Routes — Événements à venir (anniversaires + ad hoc + résolutions)."""
|
||||
from datetime import date, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_membre, require_executif
|
||||
from app.models.groupe import Groupe
|
||||
from app.models.membre import Membre
|
||||
from app.models.affectation import Affectation
|
||||
from app.models.poste import Poste
|
||||
from app.models.evenement import Evenement
|
||||
from app.models.proposition import Proposition
|
||||
|
||||
router = APIRouter(prefix="/evenements", tags=["Événements à venir"])
|
||||
|
||||
|
||||
def _prochain_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, date, int]:
|
||||
"""Retourne (années, prochaine date, jours restants)."""
|
||||
try:
|
||||
anniv = date_abstinence.replace(year=aujourdhui.year)
|
||||
except ValueError:
|
||||
anniv = date(aujourdhui.year, 3, 1)
|
||||
if anniv < aujourdhui:
|
||||
try:
|
||||
anniv = date_abstinence.replace(year=aujourdhui.year + 1)
|
||||
except ValueError:
|
||||
anniv = date(aujourdhui.year + 1, 3, 1)
|
||||
annees = anniv.year - date_abstinence.year
|
||||
jours = (anniv - aujourdhui).days
|
||||
return max(0, annees), anniv, jours
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def evenements_a_venir(
|
||||
jours: int = 90,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Tous les événements à venir : réunions, anniversaires, ad hoc."""
|
||||
aujourdhui = date.today()
|
||||
limite = aujourdhui + timedelta(days=jours)
|
||||
groupe = await db.get(Groupe, membre.groupe_id)
|
||||
district = groupe.district if groupe else "87-16"
|
||||
|
||||
evenements = []
|
||||
|
||||
# ── 1. Anniversaires de sobriété du district ──
|
||||
result = await db.execute(
|
||||
select(Membre, Groupe.nom)
|
||||
.join(Groupe, Membre.groupe_id == Groupe.id)
|
||||
.where(
|
||||
Groupe.district == district,
|
||||
Membre.actif == True,
|
||||
Membre.date_abstinence.isnot(None),
|
||||
)
|
||||
)
|
||||
for m, groupe_nom in result.all():
|
||||
annees, prochain, jours_restants = _prochain_anniversaire(m.date_abstinence, aujourdhui)
|
||||
if 0 <= jours_restants <= jours:
|
||||
evenements.append({
|
||||
"type": "anniversaire",
|
||||
"date": str(prochain),
|
||||
"titre": f"🎂 {m.prenom} — {annees} an{'s' if annees > 1 else ''}",
|
||||
"detail": f"Anniversaire de sobriété · {groupe_nom}",
|
||||
"lieu": None,
|
||||
"groupe_nom": groupe_nom,
|
||||
})
|
||||
|
||||
# ── 2. Événements ad hoc ──
|
||||
result = await db.execute(
|
||||
select(Evenement, Groupe.nom)
|
||||
.join(Groupe, Evenement.groupe_id == Groupe.id)
|
||||
.where(
|
||||
Groupe.district == district,
|
||||
Evenement.date_evenement >= aujourdhui,
|
||||
Evenement.date_evenement <= limite,
|
||||
)
|
||||
)
|
||||
for ev, groupe_nom in result.all():
|
||||
evenements.append({
|
||||
"type": "adhoc",
|
||||
"date": str(ev.date_evenement),
|
||||
"titre": ev.titre,
|
||||
"detail": ev.description,
|
||||
"lieu": ev.lieu,
|
||||
"groupe_nom": groupe_nom,
|
||||
})
|
||||
|
||||
# ── 3. Résolutions adoptées du groupe ──
|
||||
result = await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.groupe_id == membre.groupe_id,
|
||||
Proposition.statut == "adoptee",
|
||||
).order_by(desc(Proposition.date_vote))
|
||||
)
|
||||
for p in result.scalars().all():
|
||||
date_res = str(p.date_vote) if p.date_vote else str(p.cree_le.date())
|
||||
evenements.append({
|
||||
"type": "resolution",
|
||||
"date": date_res,
|
||||
"titre": f"📜 {p.numero_resolution or 'RES'} — {p.sujet}",
|
||||
"detail": p.description or p.note,
|
||||
"lieu": None,
|
||||
"groupe_nom": groupe.nom if groupe else "?",
|
||||
})
|
||||
|
||||
# Trier par date
|
||||
evenements.sort(key=lambda e: e["date"])
|
||||
return evenements
|
||||
|
||||
|
||||
# ── CRUD événements ad hoc ──
|
||||
|
||||
@router.post("/")
|
||||
async def creer_evenement(
|
||||
titre: str,
|
||||
date_evenement: date,
|
||||
description: str = "",
|
||||
lieu: str = "",
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Créer un événement ad hoc (exécutif)."""
|
||||
ev = Evenement(
|
||||
groupe_id=executif.groupe_id,
|
||||
titre=titre.strip(),
|
||||
description=description.strip() or None,
|
||||
date_evenement=date_evenement,
|
||||
lieu=lieu.strip() or None,
|
||||
cree_par=executif.id,
|
||||
)
|
||||
db.add(ev)
|
||||
await db.flush()
|
||||
return {"message": "Événement créé", "id": str(ev.id)}
|
||||
|
||||
|
||||
@router.delete("/{evenement_id}")
|
||||
async def supprimer_evenement(
|
||||
evenement_id: str,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Evenement).where(
|
||||
Evenement.id == evenement_id,
|
||||
Evenement.groupe_id == executif.groupe_id,
|
||||
)
|
||||
)
|
||||
ev = result.scalar_one_or_none()
|
||||
if not ev:
|
||||
raise HTTPException(status_code=404, detail="Événement introuvable")
|
||||
await db.delete(ev)
|
||||
await db.flush()
|
||||
return {"message": "Événement supprimé"}
|
||||
|
|
@ -24,10 +24,14 @@ async def modifier_profil(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""C7 — Modifier mon profil."""
|
||||
if updates.prenom is not None:
|
||||
membre.prenom = updates.prenom.strip().title()
|
||||
if updates.nom is not None:
|
||||
membre.nom = updates.nom.strip().title() if updates.nom.strip() else None
|
||||
if updates.telephone is not None:
|
||||
membre.telephone = updates.telephone
|
||||
membre.telephone = updates.telephone.strip()
|
||||
if updates.courriel is not None:
|
||||
membre.courriel = updates.courriel
|
||||
membre.courriel = updates.courriel.strip().lower()
|
||||
if updates.date_abstinence is not None:
|
||||
membre.date_abstinence = updates.date_abstinence
|
||||
await db.flush()
|
||||
|
|
@ -116,12 +120,13 @@ async def droit_oubli(
|
|||
|
||||
prenom_original = membre.prenom
|
||||
# Anonymiser
|
||||
membre.prenom = f"Ancien membre"
|
||||
membre.nom = "***"
|
||||
membre.telephone = None
|
||||
membre.courriel = None
|
||||
membre.prenom = "Ancien membre"
|
||||
membre.nom = None
|
||||
membre.telephone = "000-000-0000"
|
||||
membre.courriel = f"supprime_{membre.id}@anon.local"
|
||||
membre.pin_hash = "SUPPRIME"
|
||||
membre.actif = False
|
||||
membre.push_sub = None
|
||||
membre.date_abstinence = None
|
||||
await db.flush()
|
||||
return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"}
|
||||
|
|
|
|||
90
backend/app/routers/operations_bancaires.py
Normal file
90
backend/app/routers/operations_bancaires.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Routes — Opérations bancaires (dépôts, retraits)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_membre
|
||||
from app.models.operation_bancaire import OperationBancaire
|
||||
from app.models.membre import Membre
|
||||
from app.models.affectation import Affectation
|
||||
from app.models.poste import Poste
|
||||
from app.schemas.phase1 import OperationBancaireCreate, OperationBancaireOut
|
||||
|
||||
router = APIRouter(prefix="/banque", tags=["Opérations bancaires"])
|
||||
|
||||
|
||||
async def _require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||
"""Vérifie que le membre est trésorier ou exécutif."""
|
||||
result = await db.execute(
|
||||
select(Affectation).join(Poste).where(
|
||||
Affectation.membre_id == membre.id,
|
||||
Affectation.statut == "actif",
|
||||
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
|
||||
)
|
||||
)
|
||||
if not result.scalars().first():
|
||||
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
|
||||
return membre
|
||||
|
||||
|
||||
@router.post("/", response_model=OperationBancaireOut)
|
||||
async def enregistrer_operation(
|
||||
req: OperationBancaireCreate,
|
||||
membre=Depends(_require_tresorier),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Enregistrer un dépôt ou retrait bancaire (trésorier)."""
|
||||
if req.type not in ("depot", "retrait", "releve"):
|
||||
raise HTTPException(status_code=400, detail="Type doit être 'depot', 'retrait' ou 'releve'")
|
||||
if req.montant <= 0:
|
||||
raise HTTPException(status_code=400, detail="Le montant doit être positif")
|
||||
|
||||
op = OperationBancaire(
|
||||
groupe_id=membre.groupe_id,
|
||||
type=req.type,
|
||||
montant=req.montant,
|
||||
date_operation=req.date_operation,
|
||||
effectue_par=membre.id,
|
||||
note=req.note,
|
||||
)
|
||||
db.add(op)
|
||||
await db.flush()
|
||||
return OperationBancaireOut(
|
||||
id=op.id,
|
||||
type=op.type,
|
||||
montant=float(op.montant),
|
||||
date_operation=op.date_operation,
|
||||
effectue_par_prenom=membre.prenom,
|
||||
note=op.note,
|
||||
cree_le=op.cree_le,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[OperationBancaireOut])
|
||||
async def liste_operations(
|
||||
limite: int = 20,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Lister les opérations bancaires du groupe."""
|
||||
result = await db.execute(
|
||||
select(OperationBancaire)
|
||||
.where(OperationBancaire.groupe_id == membre.groupe_id)
|
||||
.order_by(desc(OperationBancaire.date_operation))
|
||||
.limit(limite)
|
||||
)
|
||||
ops = result.scalars().all()
|
||||
out = []
|
||||
for op in ops:
|
||||
m = await db.get(Membre, op.effectue_par)
|
||||
out.append(OperationBancaireOut(
|
||||
id=op.id,
|
||||
type=op.type,
|
||||
montant=float(op.montant),
|
||||
date_operation=op.date_operation,
|
||||
effectue_par_prenom=m.prenom if m else "?",
|
||||
note=op.note,
|
||||
cree_le=op.cree_le,
|
||||
))
|
||||
return out
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
"""Routes G9 — Paramètres du groupe."""
|
||||
from datetime import time as dt_time
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -35,7 +36,15 @@ async def modifier_groupe(
|
|||
if req.jour_reunion is not None:
|
||||
groupe.jour_reunion = req.jour_reunion.strip()
|
||||
if req.heure_reunion is not None:
|
||||
groupe.heure_reunion = req.heure_reunion
|
||||
h = req.heure_reunion.strip()
|
||||
if h:
|
||||
try:
|
||||
parts = h.split(":")
|
||||
groupe.heure_reunion = dt_time(int(parts[0]), int(parts[1]))
|
||||
except (ValueError, IndexError):
|
||||
groupe.heure_reunion = None
|
||||
else:
|
||||
groupe.heure_reunion = None
|
||||
if req.format_reunion is not None:
|
||||
groupe.format_reunion = req.format_reunion.strip()
|
||||
if req.lien_aa87 is not None:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Routes postes et affectations."""
|
||||
"""Routes postes, affectations, candidatures, tâches."""
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -8,29 +8,462 @@ from app.core.database import get_db
|
|||
from app.core.security import get_current_membre, require_executif
|
||||
from app.models.poste import Poste
|
||||
from app.models.affectation import Affectation
|
||||
from app.schemas.schemas import PosteOut, AffectationOut, AffectationCreate, TerminerMandatRequest
|
||||
from app.models.membre import Membre
|
||||
from app.models.candidature import Candidature
|
||||
from app.models.tache import Tache, PosteTache
|
||||
from app.schemas.schemas import PosteOut, PosteCreate, AffectationCreate, TerminerMandatRequest, TacheCreate, TacheOut
|
||||
|
||||
router = APIRouter(prefix="/postes", tags=["Postes et affectations"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PosteOut])
|
||||
async def liste_postes(db: AsyncSession = Depends(get_db), _=Depends(get_current_membre)):
|
||||
result = await db.execute(select(Poste).order_by(Poste.ordre_affichage))
|
||||
return [PosteOut.model_validate(p) for p in result.scalars().all()]
|
||||
# ── Postes ──
|
||||
|
||||
@router.get("/", response_model=list[PosteOut])
|
||||
async def liste_postes(
|
||||
inclure_abolis: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=Depends(get_current_membre),
|
||||
):
|
||||
query = select(Poste)
|
||||
if not inclure_abolis:
|
||||
query = query.where(Poste.actif == True)
|
||||
result = await db.execute(query.order_by(Poste.ordre_affichage))
|
||||
postes = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for p in postes:
|
||||
# Charger les tâches associées
|
||||
result_t = await db.execute(
|
||||
select(Tache).join(PosteTache).where(PosteTache.poste_id == p.id)
|
||||
.order_by(Tache.ordre_affichage)
|
||||
)
|
||||
taches_list = result_t.scalars().all()
|
||||
taches = [{"id": str(t.id), "nom": t.nom, "informations": t.informations}
|
||||
for t in taches_list]
|
||||
|
||||
# Description composite : description du poste + tâches associées
|
||||
desc_parts = []
|
||||
if p.description:
|
||||
desc_parts.append(p.description)
|
||||
if taches_list:
|
||||
desc_parts.append("Tâches : " + ", ".join(
|
||||
f"{t.nom}" + (f" ({t.informations})" if t.informations else "")
|
||||
for t in taches_list
|
||||
))
|
||||
|
||||
po = PosteOut.model_validate(p)
|
||||
po.description = " — ".join(desc_parts) if desc_parts else p.description
|
||||
po.taches = taches
|
||||
out.append(po)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/", response_model=PosteOut)
|
||||
async def creer_poste(
|
||||
req: PosteCreate,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Créer un nouveau poste (exécutif)."""
|
||||
if req.categorie not in ("executif", "service", "physique"):
|
||||
raise HTTPException(status_code=400, detail="Catégorie invalide")
|
||||
poste = Poste(
|
||||
nom=req.nom.strip(),
|
||||
categorie=req.categorie,
|
||||
description=req.description,
|
||||
prerequis=req.prerequis,
|
||||
informations=req.informations,
|
||||
ordre_affichage=req.ordre_affichage or 99,
|
||||
)
|
||||
db.add(poste)
|
||||
await db.flush()
|
||||
return PosteOut.model_validate(poste)
|
||||
|
||||
|
||||
@router.delete("/{poste_id}")
|
||||
async def abolir_poste(
|
||||
poste_id: str,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Abolir un poste — termine tous les mandats actifs associés."""
|
||||
poste = await db.get(Poste, poste_id)
|
||||
if not poste:
|
||||
raise HTTPException(status_code=404, detail="Poste introuvable")
|
||||
|
||||
# Terminer les mandats actifs
|
||||
result = await db.execute(
|
||||
select(Affectation).where(
|
||||
Affectation.poste_id == poste.id,
|
||||
Affectation.statut == "actif",
|
||||
)
|
||||
)
|
||||
mandats_termines = 0
|
||||
for a in result.scalars().all():
|
||||
a.statut = "termine"
|
||||
a.date_fin = date.today()
|
||||
a.note_transition = f"Poste aboli: {poste.nom}"
|
||||
a.termine_par = executif.id
|
||||
mandats_termines += 1
|
||||
|
||||
# Refuser les candidatures en attente
|
||||
result = await db.execute(
|
||||
select(Candidature).where(
|
||||
Candidature.poste_id == poste.id,
|
||||
Candidature.statut == "proposee",
|
||||
)
|
||||
)
|
||||
for c in result.scalars().all():
|
||||
c.statut = "refusee"
|
||||
|
||||
poste.actif = False
|
||||
await db.flush()
|
||||
return {
|
||||
"message": f"Poste «{poste.nom}» aboli",
|
||||
"mandats_termines": mandats_termines,
|
||||
}
|
||||
|
||||
|
||||
# ── Postes disponibles (vacants) ──
|
||||
|
||||
@router.get("/disponibles")
|
||||
async def postes_disponibles(
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Postes actifs sans titulaire — opportunités d'implication."""
|
||||
result = await db.execute(
|
||||
select(Poste).where(Poste.actif == True).order_by(Poste.ordre_affichage)
|
||||
)
|
||||
postes = result.scalars().all()
|
||||
|
||||
disponibles = []
|
||||
for p in postes:
|
||||
result = await db.execute(
|
||||
select(Affectation).where(
|
||||
Affectation.poste_id == p.id,
|
||||
Affectation.statut == "actif",
|
||||
Affectation.groupe_id == membre.groupe_id,
|
||||
)
|
||||
)
|
||||
if not result.scalars().first():
|
||||
# Vérifier les candidatures existantes du membre
|
||||
result_cand = await db.execute(
|
||||
select(Candidature).where(
|
||||
Candidature.poste_id == p.id,
|
||||
Candidature.membre_id == membre.id,
|
||||
Candidature.statut == "proposee",
|
||||
)
|
||||
)
|
||||
deja_postule = result_cand.scalars().first() is not None
|
||||
|
||||
# Description composite avec tâches
|
||||
result_t = await db.execute(
|
||||
select(Tache).join(PosteTache).where(PosteTache.poste_id == p.id)
|
||||
.order_by(Tache.ordre_affichage)
|
||||
)
|
||||
taches_list = result_t.scalars().all()
|
||||
desc_parts = []
|
||||
if p.description:
|
||||
desc_parts.append(p.description)
|
||||
if taches_list:
|
||||
desc_parts.append("Tâches : " + ", ".join(
|
||||
f"{t.nom}" + (f" ({t.informations})" if t.informations else "")
|
||||
for t in taches_list
|
||||
))
|
||||
|
||||
disponibles.append({
|
||||
"id": str(p.id),
|
||||
"nom": p.nom,
|
||||
"categorie": p.categorie,
|
||||
"description": " — ".join(desc_parts) if desc_parts else p.description,
|
||||
"prerequis": p.prerequis,
|
||||
"deja_postule": deja_postule,
|
||||
})
|
||||
return disponibles
|
||||
|
||||
|
||||
# ── Candidatures ──
|
||||
|
||||
@router.post("/candidatures")
|
||||
async def postuler(
|
||||
poste_id: str,
|
||||
motivation: str = "",
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Proposer sa candidature — crée automatiquement une proposition de nomination."""
|
||||
from app.models.proposition import Proposition
|
||||
|
||||
poste = await db.get(Poste, poste_id)
|
||||
if not poste or not poste.actif:
|
||||
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
|
||||
|
||||
# Vérifier qu'il n'a pas déjà postulé
|
||||
result = await db.execute(
|
||||
select(Candidature).where(
|
||||
Candidature.poste_id == poste_id,
|
||||
Candidature.membre_id == membre.id,
|
||||
Candidature.statut == "proposee",
|
||||
)
|
||||
)
|
||||
if result.scalars().first():
|
||||
raise HTTPException(status_code=400, detail="Vous avez déjà postulé pour ce poste")
|
||||
|
||||
# Créer la candidature
|
||||
cand = Candidature(
|
||||
groupe_id=membre.groupe_id,
|
||||
poste_id=poste_id,
|
||||
membre_id=membre.id,
|
||||
type="candidature",
|
||||
motivation=motivation.strip() or None,
|
||||
)
|
||||
db.add(cand)
|
||||
await db.flush()
|
||||
|
||||
# Créer automatiquement la proposition de nomination
|
||||
prop = Proposition(
|
||||
groupe_id=membre.groupe_id,
|
||||
type_proposition="nomination",
|
||||
sujet=f"Nomination de {membre.prenom} au poste de {poste.nom}",
|
||||
description=motivation.strip() or None,
|
||||
proposeur_id=membre.id,
|
||||
candidature_id=cand.id,
|
||||
statut="proposee",
|
||||
)
|
||||
db.add(prop)
|
||||
cand.proposition_id = prop.id
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"message": f"Candidature soumise pour {poste.nom} — proposition créée",
|
||||
"id": str(cand.id),
|
||||
"proposition_id": str(prop.id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/candidatures/nominer")
|
||||
async def nominer(
|
||||
poste_id: str,
|
||||
membre_id: str,
|
||||
motivation: str = "",
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Nominer quelqu'un (exécutif). Le nominé devra accepter avant que la proposition soit créée."""
|
||||
poste = await db.get(Poste, poste_id)
|
||||
if not poste or not poste.actif:
|
||||
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
|
||||
nominee = await db.get(Membre, membre_id)
|
||||
if not nominee or nominee.groupe_id != executif.groupe_id:
|
||||
raise HTTPException(status_code=404, detail="Membre introuvable")
|
||||
|
||||
cand = Candidature(
|
||||
groupe_id=executif.groupe_id,
|
||||
poste_id=poste_id,
|
||||
membre_id=membre_id,
|
||||
type="nomination",
|
||||
nomme_par=executif.id,
|
||||
motivation=motivation.strip() or None,
|
||||
)
|
||||
db.add(cand)
|
||||
await db.flush()
|
||||
return {
|
||||
"message": f"{nominee.prenom} nominé(e) pour {poste.nom} — en attente de son acceptation",
|
||||
"id": str(cand.id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/candidatures/{candidature_id}/retirer")
|
||||
async def retirer_candidature(
|
||||
candidature_id: str,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Retirer sa propre candidature. Retire aussi la proposition associée."""
|
||||
from app.models.proposition import Proposition
|
||||
|
||||
result = await db.execute(
|
||||
select(Candidature).where(
|
||||
Candidature.id == candidature_id,
|
||||
Candidature.membre_id == membre.id,
|
||||
Candidature.statut == "proposee",
|
||||
)
|
||||
)
|
||||
cand = result.scalar_one_or_none()
|
||||
if not cand:
|
||||
raise HTTPException(status_code=404, detail="Candidature introuvable ou déjà traitée")
|
||||
cand.statut = "retiree"
|
||||
|
||||
# Retirer la proposition associée si elle n'est pas encore votée
|
||||
if cand.proposition_id:
|
||||
prop = await db.get(Proposition, cand.proposition_id)
|
||||
if prop and prop.statut in ("proposee", "secondee"):
|
||||
prop.statut = "rejetee"
|
||||
prop.note = "Candidature retirée par le membre"
|
||||
|
||||
await db.flush()
|
||||
return {"message": "Candidature retirée"}
|
||||
|
||||
|
||||
@router.post("/candidatures/{candidature_id}/accepter-nomination")
|
||||
async def accepter_nomination(
|
||||
candidature_id: str,
|
||||
accepter: bool = True,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Le nominé accepte/refuse. Si accepté, la proposition est créée automatiquement."""
|
||||
from app.models.proposition import Proposition
|
||||
|
||||
result = await db.execute(
|
||||
select(Candidature).where(
|
||||
Candidature.id == candidature_id,
|
||||
Candidature.membre_id == membre.id,
|
||||
Candidature.type == "nomination",
|
||||
Candidature.statut == "proposee",
|
||||
)
|
||||
)
|
||||
cand = result.scalar_one_or_none()
|
||||
if not cand:
|
||||
raise HTTPException(status_code=404, detail="Nomination introuvable")
|
||||
|
||||
cand.acceptee_par_membre = accepter
|
||||
|
||||
if not accepter:
|
||||
cand.statut = "refusee"
|
||||
await db.flush()
|
||||
return {"message": "Nomination refusée"}
|
||||
|
||||
# Acceptée → créer la proposition de nomination
|
||||
poste = await db.get(Poste, cand.poste_id)
|
||||
nomme_par = await db.get(Membre, cand.nomme_par) if cand.nomme_par else None
|
||||
|
||||
prop = Proposition(
|
||||
groupe_id=cand.groupe_id,
|
||||
type_proposition="nomination",
|
||||
sujet=f"Nomination de {membre.prenom} au poste de {poste.nom if poste else '?'}",
|
||||
description=f"Nominé par {nomme_par.prenom if nomme_par else '?'}. {cand.motivation or ''}".strip(),
|
||||
proposeur_id=cand.nomme_par or membre.id,
|
||||
candidature_id=cand.id,
|
||||
statut="proposee",
|
||||
)
|
||||
db.add(prop)
|
||||
cand.proposition_id = prop.id
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"message": "Nomination acceptée — proposition créée, en attente d'être secondée",
|
||||
"proposition_id": str(prop.id),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/candidatures")
|
||||
async def liste_candidatures(
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Liste des candidatures du groupe (exécutif)."""
|
||||
result = await db.execute(
|
||||
select(Candidature).where(
|
||||
Candidature.groupe_id == executif.groupe_id,
|
||||
Candidature.statut == "proposee",
|
||||
)
|
||||
)
|
||||
candidatures = result.scalars().all()
|
||||
out = []
|
||||
for c in candidatures:
|
||||
mbr = await db.get(Membre, c.membre_id)
|
||||
poste = await db.get(Poste, c.poste_id)
|
||||
nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None
|
||||
out.append({
|
||||
"id": str(c.id),
|
||||
"membre_prenom": mbr.prenom if mbr else "?",
|
||||
"poste_nom": poste.nom if poste else "?",
|
||||
"type": c.type,
|
||||
"motivation": c.motivation,
|
||||
"nomme_par_prenom": nomme.prenom if nomme else None,
|
||||
"acceptee_par_membre": c.acceptee_par_membre,
|
||||
"cree_le": str(c.cree_le),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/candidatures/mes")
|
||||
async def mes_candidatures(
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mes candidatures et nominations en attente."""
|
||||
result = await db.execute(
|
||||
select(Candidature).where(
|
||||
Candidature.membre_id == membre.id,
|
||||
Candidature.statut == "proposee",
|
||||
)
|
||||
)
|
||||
out = []
|
||||
for c in result.scalars().all():
|
||||
poste = await db.get(Poste, c.poste_id)
|
||||
nomme = await db.get(Membre, c.nomme_par) if c.nomme_par else None
|
||||
out.append({
|
||||
"id": str(c.id),
|
||||
"poste_nom": poste.nom if poste else "?",
|
||||
"type": c.type,
|
||||
"nomme_par_prenom": nomme.prenom if nomme else None,
|
||||
"acceptee_par_membre": c.acceptee_par_membre,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/candidatures/{candidature_id}")
|
||||
async def traiter_candidature(
|
||||
candidature_id: str,
|
||||
statut: str,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Accepter ou refuser une candidature (exécutif)."""
|
||||
if statut not in ("acceptee", "refusee"):
|
||||
raise HTTPException(status_code=400, detail="Statut: acceptee ou refusee")
|
||||
result = await db.execute(
|
||||
select(Candidature).where(
|
||||
Candidature.id == candidature_id,
|
||||
Candidature.groupe_id == executif.groupe_id,
|
||||
)
|
||||
)
|
||||
cand = result.scalar_one_or_none()
|
||||
if not cand:
|
||||
raise HTTPException(status_code=404, detail="Candidature introuvable")
|
||||
|
||||
cand.statut = statut
|
||||
|
||||
# Si acceptée, créer l'affectation automatiquement
|
||||
if statut == "acceptee":
|
||||
affectation = Affectation(
|
||||
groupe_id=executif.groupe_id,
|
||||
membre_id=cand.membre_id,
|
||||
poste_id=cand.poste_id,
|
||||
date_debut=date.today(),
|
||||
statut="actif",
|
||||
)
|
||||
db.add(affectation)
|
||||
|
||||
await db.flush()
|
||||
return {"message": "Candidature " + ("acceptée" if statut == "acceptee" else "refusée")}
|
||||
|
||||
|
||||
# ── Affectations ──
|
||||
|
||||
@router.get("/affectations")
|
||||
async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||
"""Retourne les affectations actives du membre connecté."""
|
||||
result = await db.execute(
|
||||
select(Affectation).join(Poste).where(
|
||||
Affectation.membre_id == membre.id,
|
||||
Affectation.statut == "actif",
|
||||
)
|
||||
)
|
||||
affectations = result.scalars().all()
|
||||
out = []
|
||||
for a in affectations:
|
||||
for a in result.scalars().all():
|
||||
poste = await db.get(Poste, a.poste_id)
|
||||
out.append({
|
||||
"id": str(a.id),
|
||||
|
|
@ -43,21 +476,15 @@ async def mes_affectations(membre=Depends(get_current_membre), db: AsyncSession
|
|||
|
||||
|
||||
@router.get("/affectations/groupe")
|
||||
async def affectations_groupe(
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""G3 — Toutes les affectations actives du groupe."""
|
||||
async def affectations_groupe(executif=Depends(require_executif), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Affectation).where(
|
||||
Affectation.groupe_id == executif.groupe_id,
|
||||
Affectation.statut == "actif",
|
||||
)
|
||||
)
|
||||
affectations = result.scalars().all()
|
||||
out = []
|
||||
for a in affectations:
|
||||
from app.models.membre import Membre
|
||||
for a in result.scalars().all():
|
||||
poste = await db.get(Poste, a.poste_id)
|
||||
mbr = await db.get(Membre, a.membre_id)
|
||||
out.append({
|
||||
|
|
@ -71,12 +498,7 @@ async def affectations_groupe(
|
|||
|
||||
|
||||
@router.post("/affectations")
|
||||
async def creer_affectation(
|
||||
req: AffectationCreate,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""G3 — Affecter un membre à un poste."""
|
||||
async def creer_affectation(req: AffectationCreate, executif=Depends(require_executif), db: AsyncSession = Depends(get_db)):
|
||||
affectation = Affectation(
|
||||
groupe_id=executif.groupe_id,
|
||||
membre_id=req.membre_id,
|
||||
|
|
@ -91,12 +513,9 @@ async def creer_affectation(
|
|||
|
||||
@router.post("/affectations/{affectation_id}/terminer")
|
||||
async def terminer_mandat(
|
||||
affectation_id: str,
|
||||
req: TerminerMandatRequest,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
affectation_id: str, req: TerminerMandatRequest,
|
||||
executif=Depends(require_executif), db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""G4 — Terminer un mandat manuellement."""
|
||||
result = await db.execute(
|
||||
select(Affectation).where(
|
||||
Affectation.id == affectation_id,
|
||||
|
|
@ -107,10 +526,173 @@ async def terminer_mandat(
|
|||
affectation = result.scalar_one_or_none()
|
||||
if not affectation:
|
||||
raise HTTPException(status_code=404, detail="Affectation introuvable")
|
||||
|
||||
affectation.statut = "termine"
|
||||
affectation.date_fin = date.today()
|
||||
affectation.note_transition = req.note_transition
|
||||
affectation.termine_par = executif.id
|
||||
await db.flush()
|
||||
return {"message": "Mandat terminé"}
|
||||
|
||||
|
||||
# ── Réactivation / suppression définitive de postes ──
|
||||
|
||||
@router.post("/{poste_id}/reactiver")
|
||||
async def reactiver_poste(
|
||||
poste_id: str,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Réactiver un poste aboli."""
|
||||
poste = await db.get(Poste, poste_id)
|
||||
if not poste:
|
||||
raise HTTPException(status_code=404, detail="Poste introuvable")
|
||||
if poste.actif:
|
||||
raise HTTPException(status_code=400, detail="Ce poste est déjà actif")
|
||||
poste.actif = True
|
||||
await db.flush()
|
||||
return {"message": f"Poste «{poste.nom}» réactivé"}
|
||||
|
||||
|
||||
@router.delete("/{poste_id}/permanent")
|
||||
async def supprimer_poste_permanent(
|
||||
poste_id: str,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Supprimer définitivement un poste aboli (irréversible)."""
|
||||
poste = await db.get(Poste, poste_id)
|
||||
if not poste:
|
||||
raise HTTPException(status_code=404, detail="Poste introuvable")
|
||||
if poste.actif:
|
||||
raise HTTPException(status_code=400, detail="Abolissez d'abord le poste avant de le supprimer")
|
||||
|
||||
# Supprimer les liens tâches
|
||||
result = await db.execute(select(PosteTache).where(PosteTache.poste_id == poste.id))
|
||||
for pt in result.scalars().all():
|
||||
await db.delete(pt)
|
||||
|
||||
await db.delete(poste)
|
||||
await db.flush()
|
||||
return {"message": f"Poste «{poste.nom}» supprimé définitivement"}
|
||||
|
||||
|
||||
# ── Tâches ──
|
||||
|
||||
@router.get("/taches", response_model=list[TacheOut])
|
||||
async def liste_taches(
|
||||
_=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Lister toutes les tâches avec les postes associés."""
|
||||
result = await db.execute(select(Tache).order_by(Tache.ordre_affichage))
|
||||
taches = result.scalars().all()
|
||||
out = []
|
||||
for t in taches:
|
||||
result_p = await db.execute(
|
||||
select(Poste.nom).join(PosteTache).where(PosteTache.tache_id == t.id)
|
||||
)
|
||||
postes_noms = [row[0] for row in result_p.all()]
|
||||
out.append(TacheOut(
|
||||
id=t.id, nom=t.nom, informations=t.informations, postes=postes_noms
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/taches", response_model=TacheOut)
|
||||
async def creer_tache(
|
||||
req: TacheCreate,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Créer une tâche (exécutif)."""
|
||||
tache = Tache(nom=req.nom.strip(), informations=req.informations)
|
||||
db.add(tache)
|
||||
await db.flush()
|
||||
return TacheOut(id=tache.id, nom=tache.nom, informations=tache.informations)
|
||||
|
||||
|
||||
@router.patch("/taches/{tache_id}")
|
||||
async def modifier_tache(
|
||||
tache_id: str,
|
||||
nom: str | None = None,
|
||||
informations: str | None = None,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Modifier une tâche."""
|
||||
tache = await db.get(Tache, tache_id)
|
||||
if not tache:
|
||||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||||
if nom is not None:
|
||||
tache.nom = nom.strip()
|
||||
if informations is not None:
|
||||
tache.informations = informations.strip() or None
|
||||
await db.flush()
|
||||
return {"message": "Tâche modifiée"}
|
||||
|
||||
|
||||
@router.delete("/taches/{tache_id}")
|
||||
async def supprimer_tache(
|
||||
tache_id: str,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Supprimer une tâche et ses associations."""
|
||||
tache = await db.get(Tache, tache_id)
|
||||
if not tache:
|
||||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||||
result = await db.execute(select(PosteTache).where(PosteTache.tache_id == tache.id))
|
||||
for pt in result.scalars().all():
|
||||
await db.delete(pt)
|
||||
await db.delete(tache)
|
||||
await db.flush()
|
||||
return {"message": f"Tâche «{tache.nom}» supprimée"}
|
||||
|
||||
|
||||
# ── Affectation tâche ↔ poste ──
|
||||
|
||||
@router.post("/{poste_id}/taches/{tache_id}")
|
||||
async def affecter_tache(
|
||||
poste_id: str,
|
||||
tache_id: str,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Affecter une tâche à un poste. Une tâche peut être affectée à plusieurs postes."""
|
||||
poste = await db.get(Poste, poste_id)
|
||||
tache = await db.get(Tache, tache_id)
|
||||
if not poste or not poste.actif:
|
||||
raise HTTPException(status_code=404, detail="Poste introuvable ou aboli")
|
||||
if not tache:
|
||||
raise HTTPException(status_code=404, detail="Tâche introuvable")
|
||||
|
||||
# Vérifier doublon
|
||||
result = await db.execute(
|
||||
select(PosteTache).where(PosteTache.poste_id == poste_id, PosteTache.tache_id == tache_id)
|
||||
)
|
||||
if result.scalars().first():
|
||||
raise HTTPException(status_code=400, detail="Cette tâche est déjà affectée à ce poste")
|
||||
|
||||
pt = PosteTache(poste_id=poste_id, tache_id=tache_id)
|
||||
db.add(pt)
|
||||
await db.flush()
|
||||
return {"message": f"Tâche «{tache.nom}» affectée au poste «{poste.nom}»"}
|
||||
|
||||
|
||||
@router.delete("/{poste_id}/taches/{tache_id}")
|
||||
async def desaffecter_tache(
|
||||
poste_id: str,
|
||||
tache_id: str,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Retirer une tâche d'un poste."""
|
||||
result = await db.execute(
|
||||
select(PosteTache).where(PosteTache.poste_id == poste_id, PosteTache.tache_id == tache_id)
|
||||
)
|
||||
pt = result.scalars().first()
|
||||
if not pt:
|
||||
raise HTTPException(status_code=404, detail="Association introuvable")
|
||||
await db.delete(pt)
|
||||
await db.flush()
|
||||
return {"message": "Tâche retirée du poste"}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
"""Routes M2 — Présences en réunion d'affaires."""
|
||||
from datetime import date as dt_date
|
||||
from calendar import monthrange
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import select, desc, extract
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_membre
|
||||
from app.models.reunion import Reunion
|
||||
from app.models.groupe import Groupe
|
||||
from app.models.presence import Presence
|
||||
from app.models.membre import Membre
|
||||
from app.models.affectation import Affectation
|
||||
|
|
@ -16,6 +19,18 @@ from app.schemas.phase4 import (
|
|||
|
||||
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
|
||||
|
||||
JOURS_SEMAINE = {
|
||||
"lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3,
|
||||
"vendredi": 4, "samedi": 5, "dimanche": 6,
|
||||
}
|
||||
|
||||
|
||||
def _premier_jour_du_mois(annee: int, mois: int, jour_cible: int) -> dt_date:
|
||||
"""Retourne la date du premier 'jour_cible' (0=lundi) du mois."""
|
||||
premier = dt_date(annee, mois, 1)
|
||||
diff = (jour_cible - premier.weekday()) % 7
|
||||
return dt_date(annee, mois, 1 + diff)
|
||||
|
||||
|
||||
async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)):
|
||||
"""Vérifie que le membre est secrétaire ou exécutif."""
|
||||
|
|
@ -38,6 +53,35 @@ async def creer_reunion_affaires(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Créer une réunion d'affaires (point de départ pour présences et PV)."""
|
||||
groupe = await db.get(Groupe, membre.groupe_id)
|
||||
|
||||
# ── Validation : premier jour de réunion du mois, une seule par mois ──
|
||||
dt = req.date_reunion
|
||||
if groupe.jour_reunion:
|
||||
jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower())
|
||||
if jour_attendu is not None:
|
||||
premier = _premier_jour_du_mois(dt.year, dt.month, jour_attendu)
|
||||
if dt.date() != premier:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"La réunion d'affaires doit avoir lieu le premier {groupe.jour_reunion} du mois ({premier.isoformat()})",
|
||||
)
|
||||
|
||||
# Une seule réunion d'affaires par mois
|
||||
result = await db.execute(
|
||||
select(Reunion).where(
|
||||
Reunion.groupe_id == membre.groupe_id,
|
||||
Reunion.type == "affaires",
|
||||
extract("year", Reunion.date_reunion) == dt.year,
|
||||
extract("month", Reunion.date_reunion) == dt.month,
|
||||
)
|
||||
)
|
||||
if result.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Il y a déjà une réunion d'affaires pour {dt.strftime('%B %Y')}",
|
||||
)
|
||||
|
||||
reunion = Reunion(
|
||||
groupe_id=membre.groupe_id,
|
||||
type="affaires",
|
||||
|
|
|
|||
217
backend/app/routers/propositions.py
Normal file
217
backend/app/routers/propositions.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Routes — Propositions et résolutions (réunions d'affaires)."""
|
||||
from datetime import date, timezone, datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, extract, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_membre, require_executif
|
||||
from app.models.proposition import Proposition
|
||||
from app.models.reunion import Reunion
|
||||
from app.models.membre import Membre
|
||||
from app.models.candidature import Candidature
|
||||
from app.models.affectation import Affectation
|
||||
from app.models.poste import Poste
|
||||
from app.schemas.propositions import PropositionCreate, PropositionOut, PropositionVote
|
||||
|
||||
router = APIRouter(prefix="/propositions", tags=["Propositions et résolutions"])
|
||||
|
||||
|
||||
async def _to_out(p: Proposition, db: AsyncSession) -> PropositionOut:
|
||||
proposeur = await db.get(Membre, p.proposeur_id)
|
||||
secondeur = await db.get(Membre, p.secondeur_id) if p.secondeur_id else None
|
||||
reunion = await db.get(Reunion, p.reunion_id) if p.reunion_id else None
|
||||
return PropositionOut(
|
||||
id=p.id,
|
||||
reunion_id=p.reunion_id,
|
||||
type_proposition=p.type_proposition or "generale",
|
||||
sujet=p.sujet,
|
||||
description=p.description,
|
||||
proposeur_prenom=proposeur.prenom if proposeur else "?",
|
||||
secondeur_prenom=secondeur.prenom if secondeur else None,
|
||||
statut=p.statut,
|
||||
numero_resolution=p.numero_resolution,
|
||||
date_vote=p.date_vote,
|
||||
note=p.note,
|
||||
candidature_id=p.candidature_id,
|
||||
cree_le=p.cree_le,
|
||||
date_reunion=reunion.date_reunion if reunion else None,
|
||||
)
|
||||
|
||||
|
||||
async def _prochain_numero(groupe_id, annee: int, db: AsyncSession) -> str:
|
||||
"""Génère le prochain numéro de résolution : RES-2026-001."""
|
||||
result = await db.execute(
|
||||
select(func.count(Proposition.id)).where(
|
||||
Proposition.groupe_id == groupe_id,
|
||||
Proposition.numero_resolution.isnot(None),
|
||||
Proposition.numero_resolution.like(f"RES-{annee}-%"),
|
||||
)
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
return f"RES-{annee}-{count + 1:03d}"
|
||||
|
||||
|
||||
@router.post("/", response_model=PropositionOut)
|
||||
async def creer_proposition(
|
||||
req: PropositionCreate,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Soumettre une proposition — peut être créée en tout temps."""
|
||||
if req.reunion_id:
|
||||
result = await db.execute(
|
||||
select(Reunion).where(
|
||||
Reunion.id == req.reunion_id,
|
||||
Reunion.groupe_id == membre.groupe_id,
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Réunion introuvable")
|
||||
|
||||
prop = Proposition(
|
||||
groupe_id=membre.groupe_id,
|
||||
reunion_id=req.reunion_id,
|
||||
type_proposition=req.type_proposition or "generale",
|
||||
sujet=req.sujet.strip(),
|
||||
description=req.description,
|
||||
proposeur_id=membre.id,
|
||||
statut="proposee",
|
||||
)
|
||||
db.add(prop)
|
||||
await db.flush()
|
||||
return await _to_out(prop, db)
|
||||
|
||||
|
||||
@router.post("/{proposition_id}/seconder", response_model=PropositionOut)
|
||||
async def seconder_proposition(
|
||||
proposition_id: str,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Seconder une proposition pour la rendre recevable."""
|
||||
result = await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.id == proposition_id,
|
||||
Proposition.groupe_id == membre.groupe_id,
|
||||
)
|
||||
)
|
||||
prop = result.scalar_one_or_none()
|
||||
if not prop:
|
||||
raise HTTPException(status_code=404, detail="Proposition introuvable")
|
||||
if prop.statut != "proposee":
|
||||
raise HTTPException(status_code=400, detail="Seule une proposition en attente peut être secondée")
|
||||
if prop.proposeur_id == membre.id:
|
||||
raise HTTPException(status_code=400, detail="On ne peut pas seconder sa propre proposition")
|
||||
|
||||
prop.secondeur_id = membre.id
|
||||
prop.statut = "secondee"
|
||||
await db.flush()
|
||||
return await _to_out(prop, db)
|
||||
|
||||
|
||||
@router.patch("/{proposition_id}/voter", response_model=PropositionOut)
|
||||
async def voter_proposition(
|
||||
proposition_id: str,
|
||||
req: PropositionVote,
|
||||
executif=Depends(require_executif),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Enregistrer le résultat du vote sur une proposition secondée (exécutif)."""
|
||||
if req.statut not in ("adoptee", "rejetee"):
|
||||
raise HTTPException(status_code=400, detail="Le statut doit être 'adoptee' ou 'rejetee'")
|
||||
|
||||
result = await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.id == proposition_id,
|
||||
Proposition.groupe_id == executif.groupe_id,
|
||||
)
|
||||
)
|
||||
prop = result.scalar_one_or_none()
|
||||
if not prop:
|
||||
raise HTTPException(status_code=404, detail="Proposition introuvable")
|
||||
if prop.statut != "secondee":
|
||||
raise HTTPException(status_code=400, detail="Seule une proposition secondée peut être mise au vote")
|
||||
|
||||
prop.statut = req.statut
|
||||
prop.date_vote = date.today()
|
||||
if req.note:
|
||||
prop.note = req.note.strip()
|
||||
|
||||
# Attribuer un numéro de résolution si adoptée
|
||||
if req.statut == "adoptee":
|
||||
prop.numero_resolution = await _prochain_numero(
|
||||
executif.groupe_id, date.today().year, db
|
||||
)
|
||||
if req.reunion_id and not prop.reunion_id:
|
||||
prop.reunion_id = req.reunion_id
|
||||
|
||||
# ── NOMINATION : créer l'affectation automatiquement ──
|
||||
if prop.type_proposition == "nomination" and prop.candidature_id:
|
||||
cand = await db.get(Candidature, prop.candidature_id)
|
||||
if cand and cand.statut == "proposee":
|
||||
cand.statut = "acceptee"
|
||||
cand.proposition_id = prop.id
|
||||
affectation = Affectation(
|
||||
groupe_id=executif.groupe_id,
|
||||
membre_id=cand.membre_id,
|
||||
poste_id=cand.poste_id,
|
||||
date_debut=date.today(),
|
||||
statut="actif",
|
||||
)
|
||||
db.add(affectation)
|
||||
|
||||
elif req.statut == "rejetee":
|
||||
# Si nomination rejetée, mettre la candidature en refusée
|
||||
if prop.type_proposition == "nomination" and prop.candidature_id:
|
||||
cand = await db.get(Candidature, prop.candidature_id)
|
||||
if cand:
|
||||
cand.statut = "refusee"
|
||||
|
||||
await db.flush()
|
||||
return await _to_out(prop, db)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PropositionOut])
|
||||
async def liste_propositions(
|
||||
statut: str | None = None,
|
||||
reunion_id: str | None = None,
|
||||
type_proposition: str | None = None,
|
||||
limite: int = 50,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Lister les propositions du groupe avec filtres optionnels."""
|
||||
query = select(Proposition).where(Proposition.groupe_id == membre.groupe_id)
|
||||
if statut:
|
||||
query = query.where(Proposition.statut == statut)
|
||||
if reunion_id:
|
||||
query = query.where(Proposition.reunion_id == reunion_id)
|
||||
if type_proposition:
|
||||
query = query.where(Proposition.type_proposition == type_proposition)
|
||||
query = query.order_by(desc(Proposition.cree_le)).limit(limite)
|
||||
|
||||
result = await db.execute(query)
|
||||
return [await _to_out(p, db) for p in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/resolutions", response_model=list[PropositionOut])
|
||||
async def registre_resolutions(
|
||||
annee: int | None = None,
|
||||
type_proposition: str | None = None,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Registre des résolutions adoptées."""
|
||||
query = select(Proposition).where(
|
||||
Proposition.groupe_id == membre.groupe_id,
|
||||
Proposition.statut == "adoptee",
|
||||
)
|
||||
if annee:
|
||||
query = query.where(extract("year", Proposition.cree_le) == annee)
|
||||
if type_proposition:
|
||||
query = query.where(Proposition.type_proposition == type_proposition)
|
||||
query = query.order_by(Proposition.numero_resolution)
|
||||
|
||||
result = await db.execute(query)
|
||||
return [await _to_out(p, db) for p in result.scalars().all()]
|
||||
|
|
@ -14,6 +14,7 @@ from app.models.depense import Depense
|
|||
from app.models.membre import Membre
|
||||
from app.models.reunion import Reunion
|
||||
from app.models.envoi_contribution import EnvoiContribution
|
||||
from app.models.proposition import Proposition
|
||||
|
||||
router = APIRouter(prefix="/rapports", tags=["C1 — Rapports PDF"])
|
||||
|
||||
|
|
@ -34,7 +35,6 @@ def _css():
|
|||
table { width: 100%; border-collapse: collapse; margin-top: 8pt; }
|
||||
th { background: #1a5276; color: white; padding: 6pt 8pt; text-align: left; font-size: 9pt; }
|
||||
td { padding: 5pt 8pt; border-bottom: 1px solid #ddd; font-size: 10pt; }
|
||||
tr:nth-child(even) { background: #f8f9fa; }
|
||||
.total { font-weight: bold; font-size: 12pt; margin-top: 10pt; }
|
||||
.footer { text-align: center; font-size: 8pt; color: #aaa; margin-top: 30pt; }
|
||||
.badge-paye { color: #27ae60; }
|
||||
|
|
@ -246,6 +246,24 @@ async def rapports_disponibles(
|
|||
return sorted(all_mois.values(), key=lambda x: (x["annee"], x["mois"]), reverse=True)
|
||||
|
||||
|
||||
@router.get("/resolutions/annees")
|
||||
async def annees_resolutions(
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Années pour lesquelles des résolutions existent."""
|
||||
result = await db.execute(
|
||||
select(extract("year", Proposition.cree_le).label("annee"))
|
||||
.where(
|
||||
Proposition.groupe_id == membre.groupe_id,
|
||||
Proposition.statut.in_(["adoptee", "rejetee"]),
|
||||
)
|
||||
.group_by("annee")
|
||||
.order_by(extract("year", Proposition.cree_le).desc())
|
||||
)
|
||||
return [{"annee": int(r.annee)} for r in result.all()]
|
||||
|
||||
|
||||
@router.get("/financier-complet/{annee}/{mois}")
|
||||
async def rapport_financier_complet(
|
||||
annee: int,
|
||||
|
|
@ -304,3 +322,209 @@ async def rapport_financier_complet(
|
|||
"nb_contributions": len(contributions),
|
||||
"depenses_impayees": round(depenses_impayees, 2),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/resolutions/{annee}")
|
||||
async def rapport_resolutions(
|
||||
annee: int,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""C1 — Registre des résolutions adoptées en PDF."""
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=500, detail="WeasyPrint non installé")
|
||||
|
||||
groupe = await db.get(Groupe, membre.groupe_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Proposition).where(
|
||||
Proposition.groupe_id == membre.groupe_id,
|
||||
Proposition.statut.in_(["adoptee", "rejetee"]),
|
||||
extract("year", Proposition.cree_le) == annee,
|
||||
).order_by(Proposition.numero_resolution, Proposition.cree_le)
|
||||
)
|
||||
propositions = result.scalars().all()
|
||||
|
||||
rows = ""
|
||||
for p in propositions:
|
||||
proposeur = await db.get(Membre, p.proposeur_id) if p.proposeur_id else None
|
||||
secondeur = await db.get(Membre, p.secondeur_id) if p.secondeur_id else None
|
||||
badge = '<span class="badge-paye">✓ Adoptée</span>' if p.statut == "adoptee" else '<span class="badge-impaye">✗ Rejetée</span>'
|
||||
rows += f"""<tr>
|
||||
<td>{p.numero_resolution or '—'}</td>
|
||||
<td>{p.date_vote or '—'}</td>
|
||||
<td>{p.sujet}</td>
|
||||
<td>{proposeur.prenom if proposeur else '—'}</td>
|
||||
<td>{secondeur.prenom if secondeur else '—'}</td>
|
||||
<td>{badge}</td>
|
||||
</tr>"""
|
||||
|
||||
nb_adoptees = sum(1 for p in propositions if p.statut == "adoptee")
|
||||
nb_rejetees = sum(1 for p in propositions if p.statut == "rejetee")
|
||||
|
||||
html_content = f"""<!DOCTYPE html><html><head><style>{_css()}</style></head><body>
|
||||
<div class="header">
|
||||
<div class="groupe">{groupe.nom if groupe else 'Groupe'}</div>
|
||||
<div class="sub">District {groupe.district if groupe else '87-16'} · Région {groupe.region if groupe else '87'}</div>
|
||||
</div>
|
||||
<h1>Registre des résolutions — {annee}</h1>
|
||||
|
||||
<table>
|
||||
<tr><th>N°</th><th>Date</th><th>Sujet</th><th>Proposeur</th><th>Secondeur</th><th>Résultat</th></tr>
|
||||
{rows if rows else '<tr><td colspan="6">Aucune résolution pour cette année</td></tr>'}
|
||||
</table>
|
||||
|
||||
<p class="total">{nb_adoptees} résolution(s) adoptée(s), {nb_rejetees} rejetée(s)</p>
|
||||
|
||||
<div class="footer">
|
||||
Registre généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')} · 100% logiciel libre
|
||||
</div>
|
||||
</body></html>"""
|
||||
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
filename = f"registre_resolutions_{groupe.nom.replace(' ', '_')}_{annee}.pdf"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(pdf_bytes),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/etat-financier")
|
||||
async def etat_financier_temps_reel(
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""État des résultats et transactions en temps réel (JSON)."""
|
||||
from sqlalchemy import func, desc
|
||||
from app.models.operation_bancaire import OperationBancaire
|
||||
from app.models.vente_litterature import VenteLitterature
|
||||
from app.models.litterature import Litterature
|
||||
|
||||
gid = membre.groupe_id
|
||||
groupe = await db.get(Groupe, gid)
|
||||
|
||||
# ── Dernier relevé bancaire ──
|
||||
result = await db.execute(
|
||||
select(OperationBancaire).where(
|
||||
OperationBancaire.groupe_id == gid,
|
||||
OperationBancaire.type == "releve",
|
||||
).order_by(desc(OperationBancaire.date_operation)).limit(1)
|
||||
)
|
||||
dernier_releve = result.scalar_one_or_none()
|
||||
solde_releve = float(dernier_releve.montant) if dernier_releve else 0.0
|
||||
|
||||
# ── Revenus ──
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Collecte.montant), 0)).where(
|
||||
Collecte.groupe_id == gid, Collecte.statut == "recu"))
|
||||
total_collectes = float(result.scalar())
|
||||
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(VenteLitterature.montant), 0)).where(
|
||||
VenteLitterature.groupe_id == gid, VenteLitterature.remis_tresorier == True))
|
||||
total_ventes = float(result.scalar())
|
||||
|
||||
revenus = total_collectes + total_ventes
|
||||
|
||||
# ── Dépenses ──
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Depense.montant), 0)).where(
|
||||
Depense.groupe_id == gid, Depense.paye == True))
|
||||
total_depenses_payees = float(result.scalar())
|
||||
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Depense.montant), 0)).where(
|
||||
Depense.groupe_id == gid, Depense.paye == False, Depense.confirme_par.isnot(None)))
|
||||
total_engagements = float(result.scalar())
|
||||
|
||||
# ── Contributions ──
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(EnvoiContribution.montant), 0)).where(
|
||||
EnvoiContribution.groupe_id == gid))
|
||||
total_contributions = float(result.scalar())
|
||||
|
||||
# ── Encaisse ──
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
|
||||
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot"))
|
||||
total_depots = float(result.scalar())
|
||||
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Depense.montant), 0)).where(
|
||||
Depense.groupe_id == gid, Depense.paye == True, Depense.methode == "COLLECTE"))
|
||||
depenses_cash = float(result.scalar())
|
||||
|
||||
encaisse = total_collectes + total_ventes - total_depots - depenses_cash
|
||||
|
||||
# ── Solde banque ──
|
||||
filtre_depuis = dernier_releve.date_operation if dernier_releve else None
|
||||
q_depots = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
|
||||
OperationBancaire.groupe_id == gid, OperationBancaire.type == "depot")
|
||||
q_retraits = select(func.coalesce(func.sum(OperationBancaire.montant), 0)).where(
|
||||
OperationBancaire.groupe_id == gid, OperationBancaire.type == "retrait")
|
||||
if filtre_depuis:
|
||||
q_depots = q_depots.where(OperationBancaire.date_operation > filtre_depuis)
|
||||
q_retraits = q_retraits.where(OperationBancaire.date_operation > filtre_depuis)
|
||||
depots_depuis = float((await db.execute(q_depots)).scalar())
|
||||
retraits_depuis = float((await db.execute(q_retraits)).scalar())
|
||||
solde_banque = solde_releve + depots_depuis - retraits_depuis
|
||||
|
||||
# ── Réserves ──
|
||||
reserve_prudente = 500.0
|
||||
mois_depuis = 0
|
||||
if groupe and groupe.cree_le:
|
||||
from datetime import date as d
|
||||
aujourdhui = d.today()
|
||||
cree = groupe.cree_le.date() if hasattr(groupe.cree_le, 'date') else groupe.cree_le
|
||||
mois_depuis = max(0, (aujourdhui.year - cree.year) * 12 + (aujourdhui.month - cree.month))
|
||||
reserve_anniversaire = mois_depuis * 20.0
|
||||
|
||||
disponible = solde_banque - total_engagements - reserve_prudente - reserve_anniversaire
|
||||
|
||||
# ── Dernières transactions ──
|
||||
result = await db.execute(
|
||||
select(Collecte).where(Collecte.groupe_id == gid, Collecte.statut == "recu")
|
||||
.order_by(desc(Collecte.date_collecte)).limit(10))
|
||||
dernieres_collectes = [{
|
||||
"date": str(c.date_collecte), "montant": float(c.montant),
|
||||
} for c in result.scalars().all()]
|
||||
|
||||
result = await db.execute(
|
||||
select(Depense).where(Depense.groupe_id == gid)
|
||||
.order_by(desc(Depense.date_depense)).limit(10))
|
||||
dernieres_depenses = [{
|
||||
"date": str(d.date_depense), "montant": float(d.montant),
|
||||
"quoi": d.quoi, "paye": d.paye, "methode": d.methode,
|
||||
} for d in result.scalars().all()]
|
||||
|
||||
return {
|
||||
"groupe": groupe.nom if groupe else "?",
|
||||
"date_releve": str(dernier_releve.date_operation) if dernier_releve else None,
|
||||
"solde_releve": round(solde_releve, 2),
|
||||
"solde_banque": round(solde_banque, 2),
|
||||
"encaisse": round(max(0, encaisse), 2),
|
||||
"disponible": round(disponible, 2),
|
||||
"revenus": {
|
||||
"collectes": round(total_collectes, 2),
|
||||
"ventes_litterature": round(total_ventes, 2),
|
||||
"total": round(revenus, 2),
|
||||
},
|
||||
"sorties": {
|
||||
"depenses_payees": round(total_depenses_payees, 2),
|
||||
"engagements": round(total_engagements, 2),
|
||||
"contributions": round(total_contributions, 2),
|
||||
"total": round(total_depenses_payees + total_contributions, 2),
|
||||
},
|
||||
"reserves": {
|
||||
"prudente": reserve_prudente,
|
||||
"anniversaire": round(reserve_anniversaire, 2),
|
||||
"total": round(reserve_prudente + reserve_anniversaire, 2),
|
||||
},
|
||||
"resultat_net": round(revenus - total_depenses_payees - total_contributions, 2),
|
||||
"dernieres_collectes": dernieres_collectes,
|
||||
"dernieres_depenses": dernieres_depenses,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
"""Routes S4 — Rapport de réunion."""
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import select, desc, extract
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_membre
|
||||
from app.models.reunion import Reunion
|
||||
from app.models.groupe import Groupe
|
||||
from app.models.membre import Membre
|
||||
from app.models.affectation import Affectation
|
||||
from app.models.poste import Poste
|
||||
|
|
@ -13,6 +15,11 @@ from app.schemas.phase1 import ReunionCreate, ReunionOut
|
|||
|
||||
router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"])
|
||||
|
||||
JOURS_SEMAINE = {
|
||||
"lundi": 0, "mardi": 1, "mercredi": 2, "jeudi": 3,
|
||||
"vendredi": 4, "samedi": 5, "dimanche": 6,
|
||||
}
|
||||
|
||||
|
||||
async def require_animateur(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||
"""Vérifie que le membre est animateur ou exécutif."""
|
||||
|
|
@ -35,6 +42,33 @@ async def creer_rapport_reunion(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""S4 — L'animateur note le thème et les annonces."""
|
||||
groupe = await db.get(Groupe, membre.groupe_id)
|
||||
|
||||
# ── Validation : réunion régulière ──
|
||||
if req.type == "reguliere" and groupe.jour_reunion:
|
||||
jour_attendu = JOURS_SEMAINE.get(groupe.jour_reunion.lower())
|
||||
jour_soumis = req.date_reunion.weekday()
|
||||
if jour_attendu is not None and jour_soumis != jour_attendu:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"La réunion régulière doit avoir lieu le {groupe.jour_reunion}",
|
||||
)
|
||||
# Une seule régulière par semaine ISO
|
||||
iso_year, iso_week, _ = req.date_reunion.isocalendar()
|
||||
result = await db.execute(
|
||||
select(Reunion).where(
|
||||
Reunion.groupe_id == membre.groupe_id,
|
||||
Reunion.type == "reguliere",
|
||||
extract("isoyear", Reunion.date_reunion) == iso_year,
|
||||
extract("week", Reunion.date_reunion) == iso_week,
|
||||
)
|
||||
)
|
||||
if result.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Il y a déjà une réunion régulière pour la semaine {iso_week}",
|
||||
)
|
||||
|
||||
reunion = Reunion(
|
||||
groupe_id=membre.groupe_id,
|
||||
type=req.type,
|
||||
|
|
|
|||
|
|
@ -78,11 +78,6 @@ async def creer_sondage(
|
|||
# Pour oui_non, créer automatiquement les options
|
||||
options_data = req.options
|
||||
if req.type == "oui_non" and len(options_data) == 0:
|
||||
options_data = [
|
||||
SondageCreate.model_fields["options"].default.__class__(texte="Oui"),
|
||||
SondageCreate.model_fields["options"].default.__class__(texte="Non"),
|
||||
]
|
||||
# Workaround: créer manuellement
|
||||
from app.schemas.phase8 import OptionCreate
|
||||
options_data = [OptionCreate(texte="Oui"), OptionCreate(texte="Non")]
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,75 @@ async def liste_ventes(
|
|||
montant=float(v.montant),
|
||||
date_vente=v.date_vente,
|
||||
vendu_par_prenom=vendeur.prenom if vendeur else None,
|
||||
remis_tresorier=v.remis_tresorier,
|
||||
cree_le=v.cree_le,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/a-recevoir")
|
||||
async def ventes_a_recevoir(
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Ventes dont le montant n'a pas encore été remis au trésorier."""
|
||||
result = await db.execute(
|
||||
select(VenteLitterature)
|
||||
.where(
|
||||
VenteLitterature.groupe_id == membre.groupe_id,
|
||||
VenteLitterature.remis_tresorier == False,
|
||||
)
|
||||
.order_by(desc(VenteLitterature.date_vente))
|
||||
)
|
||||
ventes = result.scalars().all()
|
||||
out = []
|
||||
for v in ventes:
|
||||
item = await db.get(Litterature, v.litterature_id)
|
||||
vendeur = await db.get(Membre, v.vendu_par) if v.vendu_par else None
|
||||
out.append({
|
||||
"id": str(v.id),
|
||||
"litterature_titre": item.titre if item else "?",
|
||||
"quantite": v.quantite,
|
||||
"montant": float(v.montant),
|
||||
"date_vente": str(v.date_vente),
|
||||
"vendu_par_prenom": vendeur.prenom if vendeur else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{vente_id}/recevoir")
|
||||
async def confirmer_reception_vente(
|
||||
vente_id: str,
|
||||
membre=Depends(get_current_membre),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Le trésorier confirme la réception du montant de la vente."""
|
||||
# Vérifier que c'est le trésorier ou exécutif
|
||||
result = await db.execute(
|
||||
select(Affectation).join(Poste).where(
|
||||
Affectation.membre_id == membre.id,
|
||||
Affectation.statut == "actif",
|
||||
(Poste.nom.ilike("%trésorier%") | (Poste.categorie == "executif")),
|
||||
)
|
||||
)
|
||||
if not result.scalars().first():
|
||||
raise HTTPException(status_code=403, detail="Réservé au trésorier ou à l'exécutif")
|
||||
|
||||
result = await db.execute(
|
||||
select(VenteLitterature).where(
|
||||
VenteLitterature.id == vente_id,
|
||||
VenteLitterature.groupe_id == membre.groupe_id,
|
||||
)
|
||||
)
|
||||
vente = result.scalar_one_or_none()
|
||||
if not vente:
|
||||
raise HTTPException(status_code=404, detail="Vente introuvable")
|
||||
if vente.remis_tresorier:
|
||||
raise HTTPException(status_code=400, detail="Déjà reçue")
|
||||
|
||||
from datetime import date as date_type
|
||||
vente.remis_tresorier = True
|
||||
vente.recu_par = membre.id
|
||||
vente.date_remise = date_type.today()
|
||||
await db.flush()
|
||||
return {"message": "Réception confirmée"}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from typing import Optional
|
|||
class CollecteCreate(BaseModel):
|
||||
date_collecte: date
|
||||
montant: float
|
||||
detenteur_id: UUID # qui a l'argent
|
||||
reunion_id: Optional[UUID] = None
|
||||
|
||||
|
||||
|
|
@ -16,12 +17,49 @@ class CollecteOut(BaseModel):
|
|||
id: UUID
|
||||
date_collecte: date
|
||||
montant: float
|
||||
detenteur_prenom: Optional[str] = None
|
||||
statut: str
|
||||
saisi_par_prenom: Optional[str] = None
|
||||
confirme_par_prenom: Optional[str] = None
|
||||
date_confirmation: Optional[datetime] = None
|
||||
recu_par_prenom: Optional[str] = None
|
||||
date_reception: Optional[date] = None
|
||||
cree_le: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class CollecteUpdate(BaseModel):
|
||||
montant: Optional[float] = None
|
||||
|
||||
|
||||
# ── Opérations bancaires ──
|
||||
class OperationBancaireCreate(BaseModel):
|
||||
type: str = "depot"
|
||||
montant: float
|
||||
date_operation: date
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class OperationBancaireOut(BaseModel):
|
||||
id: UUID
|
||||
type: str
|
||||
montant: float
|
||||
date_operation: date
|
||||
effectue_par_prenom: str
|
||||
note: Optional[str] = None
|
||||
cree_le: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SoldesOut(BaseModel):
|
||||
encaisse: float # cash reçu par trésorier, pas encore déposé
|
||||
compte_bancaire: float # dépôts - dépenses payées - contributions
|
||||
en_possession: float # collectes pas encore remises au trésorier
|
||||
en_possession_details: list[dict] = []
|
||||
|
||||
|
||||
# ── S4 Rapport de réunion ──
|
||||
class ReunionCreate(BaseModel):
|
||||
type: str = "reguliere"
|
||||
|
|
@ -44,14 +82,13 @@ class ReunionOut(BaseModel):
|
|||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ── S5 Dépense (6 champs) ──
|
||||
# ── S5 Dépense (ouvert à tous) ──
|
||||
class DepenseCreate(BaseModel):
|
||||
date_depense: date
|
||||
montant: float
|
||||
qui: UUID # membre_id
|
||||
quoi: str
|
||||
paye: bool = False
|
||||
methode: str # DU, VIREMENT, CHEQUE, COLLECTE
|
||||
# qui = auto (membre courant)
|
||||
# methode renseignée par le trésorier à la confirmation
|
||||
|
||||
|
||||
class DepenseOut(BaseModel):
|
||||
|
|
@ -61,8 +98,10 @@ class DepenseOut(BaseModel):
|
|||
qui_prenom: Optional[str] = None
|
||||
quoi: str
|
||||
paye: bool
|
||||
methode: str
|
||||
methode: Optional[str] = None
|
||||
preuve_nom: Optional[str] = None
|
||||
saisi_par_prenom: Optional[str] = None
|
||||
confirme: bool = False
|
||||
cree_le: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Schémas Phase 3 — Gestion."""
|
||||
from pydantic import BaseModel
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, time
|
||||
from uuid import UUID
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ class GroupeUpdate(BaseModel):
|
|||
nom: Optional[str] = None
|
||||
adresse_local: Optional[str] = None
|
||||
jour_reunion: Optional[str] = None
|
||||
heure_reunion: Optional[str] = None
|
||||
heure_reunion: Optional[str] = None # frontend envoie "HH:MM", parsé dans parametres.py
|
||||
format_reunion: Optional[str] = None
|
||||
lien_aa87: Optional[str] = None
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ class GroupeDetail(BaseModel):
|
|||
region: str
|
||||
adresse_local: Optional[str] = None
|
||||
jour_reunion: Optional[str] = None
|
||||
heure_reunion: Optional[str] = None
|
||||
heure_reunion: Optional[time] = None # sérialisé "HH:MM:SS" par pydantic
|
||||
format_reunion: Optional[str] = None
|
||||
lien_aa87: str
|
||||
cree_le: datetime
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ class VenteOut(BaseModel):
|
|||
montant: float
|
||||
date_vente: date
|
||||
vendu_par_prenom: Optional[str] = None
|
||||
remis_tresorier: bool = False
|
||||
cree_le: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class EnvoiContributionCreate(BaseModel):
|
|||
destination: str # district, region, bsg, intergroupe
|
||||
montant: float
|
||||
methode: str # DU, VIREMENT, CHEQUE
|
||||
resolution_id: UUID # résolution adoptée qui autorise l'envoi
|
||||
|
||||
|
||||
class EnvoiContributionOut(BaseModel):
|
||||
|
|
@ -30,6 +31,9 @@ class EnvoiContributionOut(BaseModel):
|
|||
destination_label: str
|
||||
montant: float
|
||||
methode: str
|
||||
resolution_id: Optional[UUID] = None
|
||||
engagee: bool = False
|
||||
debitee: bool = False
|
||||
saisi_par_prenom: Optional[str] = None
|
||||
cree_le: datetime
|
||||
|
||||
|
|
|
|||
37
backend/app/schemas/propositions.py
Normal file
37
backend/app/schemas/propositions.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Schémas — Propositions et résolutions."""
|
||||
from pydantic import BaseModel
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PropositionCreate(BaseModel):
|
||||
reunion_id: Optional[UUID] = None
|
||||
sujet: str
|
||||
description: Optional[str] = None
|
||||
type_proposition: str = "generale" # generale, nomination, contribution
|
||||
|
||||
|
||||
class PropositionOut(BaseModel):
|
||||
id: UUID
|
||||
reunion_id: Optional[UUID] = None
|
||||
type_proposition: str = "generale"
|
||||
sujet: str
|
||||
description: Optional[str] = None
|
||||
proposeur_prenom: str
|
||||
secondeur_prenom: Optional[str] = None
|
||||
statut: str
|
||||
numero_resolution: Optional[str] = None
|
||||
date_vote: Optional[date] = None
|
||||
note: Optional[str] = None
|
||||
candidature_id: Optional[UUID] = None
|
||||
cree_le: datetime
|
||||
date_reunion: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PropositionVote(BaseModel):
|
||||
statut: str # adoptee ou rejetee
|
||||
note: Optional[str] = None
|
||||
reunion_id: Optional[UUID] = None
|
||||
|
|
@ -7,18 +7,19 @@ from typing import Optional
|
|||
|
||||
# ── Auth ──
|
||||
class ConnexionRequest(BaseModel):
|
||||
prenom: str
|
||||
courriel: str
|
||||
pin: str
|
||||
groupe_id: UUID
|
||||
se_souvenir: bool = False
|
||||
|
||||
|
||||
class InscriptionRequest(BaseModel):
|
||||
prenom: str
|
||||
nom: str
|
||||
nom: Optional[str] = None
|
||||
pin: str
|
||||
code_invitation: str
|
||||
telephone: Optional[str] = None
|
||||
courriel: Optional[str] = None
|
||||
telephone: str
|
||||
courriel: str
|
||||
date_abstinence: Optional[date] = None
|
||||
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ class TokenResponse(BaseModel):
|
|||
class MembrePublic(BaseModel):
|
||||
id: UUID
|
||||
prenom: str
|
||||
courriel: str
|
||||
groupe_id: UUID
|
||||
date_abstinence: Optional[date] = None
|
||||
|
||||
|
|
@ -39,9 +41,8 @@ class MembrePublic(BaseModel):
|
|||
|
||||
|
||||
class MembreComplet(MembrePublic):
|
||||
nom: str
|
||||
telephone: Optional[str] = None
|
||||
courriel: Optional[str] = None
|
||||
nom: Optional[str] = None
|
||||
telephone: str
|
||||
actif: bool
|
||||
cree_le: datetime
|
||||
|
||||
|
|
@ -49,6 +50,8 @@ class MembreComplet(MembrePublic):
|
|||
|
||||
|
||||
class MembreUpdate(BaseModel):
|
||||
prenom: Optional[str] = None
|
||||
nom: Optional[str] = None
|
||||
telephone: Optional[str] = None
|
||||
courriel: Optional[str] = None
|
||||
date_abstinence: Optional[date] = None
|
||||
|
|
@ -60,12 +63,39 @@ class ChangerPinRequest(BaseModel):
|
|||
|
||||
|
||||
# ── Poste / Affectation ──
|
||||
class PosteCreate(BaseModel):
|
||||
nom: str
|
||||
categorie: str # executif, service, physique
|
||||
description: Optional[str] = None
|
||||
prerequis: Optional[str] = None
|
||||
informations: Optional[str] = None
|
||||
ordre_affichage: Optional[int] = None
|
||||
|
||||
|
||||
class PosteOut(BaseModel):
|
||||
id: UUID
|
||||
nom: str
|
||||
categorie: str
|
||||
description: Optional[str] = None
|
||||
prerequis: Optional[str] = None
|
||||
informations: Optional[str] = None
|
||||
ordre_affichage: Optional[int] = None
|
||||
actif: bool = True
|
||||
taches: list[dict] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TacheCreate(BaseModel):
|
||||
nom: str
|
||||
informations: Optional[str] = None
|
||||
|
||||
|
||||
class TacheOut(BaseModel):
|
||||
id: UUID
|
||||
nom: str
|
||||
informations: Optional[str] = None
|
||||
postes: list[str] = [] # noms des postes associés
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
|
@ -109,6 +139,7 @@ class AccueilResponse(BaseModel):
|
|||
modules_consultation: list[ModuleTuile]
|
||||
modules_saisie: list[ModuleTuile]
|
||||
modules_gestion: list[ModuleTuile]
|
||||
groupe: Optional[dict] = None
|
||||
|
||||
|
||||
# ── Invitation ──
|
||||
|
|
|
|||
|
|
@ -5,5 +5,7 @@ asyncpg==0.30.0
|
|||
pydantic-settings==2.7.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.2.1
|
||||
python-multipart==0.0.18
|
||||
weasyprint==62.3
|
||||
pydyf>=0.8.0,<0.11.0
|
||||
pywebpush==2.0.1
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ DATE=$(date +%Y-%m-%d_%H%M)
|
|||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Dump
|
||||
pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump"
|
||||
pg_dump -U "$DB_USER" -h 127.0.0.1 -Fc "$DB_NAME" > "$BACKUP_DIR/grpmed_${DATE}.dump"
|
||||
|
||||
# Nettoyage des anciennes sauvegardes
|
||||
find "$BACKUP_DIR" -name "grpmed_*.dump" -mtime +$RETENTION_DAYS -delete
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
# sudo cp deploy/nginx-groupe-meditation.conf /etc/nginx/sites-available/groupe-meditation
|
||||
# sudo ln -s /etc/nginx/sites-available/groupe-meditation /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
# sudo certbot --nginx -d groupe-meditation.chezlepro.ca
|
||||
# sudo certbot --nginx -d groupe-meditation.87-16.org
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name groupe-meditation.chezlepro.ca;
|
||||
server_name groupe-meditation.87-16.org;
|
||||
|
||||
# ── Headers de sécurité ──
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
Script d'initialisation — Groupe Méditation
|
||||
Crée les postes, le groupe initial et le premier membre.
|
||||
Usage: python deploy/seed.py
|
||||
Usage: cd /opt/groupe-meditation/backend && venv/bin/python ../deploy/seed.py
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
|
|
@ -39,6 +39,11 @@ from app.models.option_sondage import OptionSondage
|
|||
from app.models.vote import Vote
|
||||
from app.models.jumelage import Jumelage
|
||||
from app.models.sync_log import SyncLog
|
||||
from app.models.proposition import Proposition
|
||||
from app.models.operation_bancaire import OperationBancaire
|
||||
from app.models.evenement import Evenement
|
||||
from app.models.candidature import Candidature
|
||||
from app.models.tache import Tache, PosteTache
|
||||
|
||||
|
||||
POSTES = [
|
||||
|
|
@ -68,7 +73,7 @@ async def seed():
|
|||
district="87-16",
|
||||
region="87",
|
||||
adresse_local="",
|
||||
jour_reunion="lundi",
|
||||
jour_reunion="mardi",
|
||||
heure_reunion=None,
|
||||
format_reunion="Discussion ouverte",
|
||||
lien_aa87="https://aa87.org",
|
||||
|
|
@ -95,7 +100,14 @@ async def seed():
|
|||
# ── Premier membre ──
|
||||
print("\n── Premier membre (vous) ──")
|
||||
prenom = input(" Prénom: ").strip().title()
|
||||
nom = input(" Nom: ").strip().title()
|
||||
nom_membre = input(" Nom (optionnel, Entrée pour passer): ").strip()
|
||||
nom_membre = nom_membre.title() if nom_membre else None
|
||||
courriel = input(" Courriel: ").strip().lower()
|
||||
while not courriel or "@" not in courriel:
|
||||
courriel = input(" Courriel invalide. Réessayer: ").strip().lower()
|
||||
telephone = input(" Téléphone: ").strip()
|
||||
while not telephone:
|
||||
telephone = input(" Téléphone obligatoire: ").strip()
|
||||
pin = input(" PIN (4 chiffres): ").strip()
|
||||
while not pin.isdigit() or len(pin) != 4:
|
||||
pin = input(" PIN invalide. 4 chiffres: ").strip()
|
||||
|
|
@ -110,7 +122,9 @@ async def seed():
|
|||
membre = Membre(
|
||||
groupe_id=groupe.id,
|
||||
prenom=prenom,
|
||||
nom=nom,
|
||||
nom=nom_membre,
|
||||
courriel=courriel,
|
||||
telephone=telephone,
|
||||
pin_hash=hash_pin(pin),
|
||||
)
|
||||
db.add(membre)
|
||||
|
|
@ -136,18 +150,8 @@ async def seed():
|
|||
)
|
||||
db.add(invitation)
|
||||
|
||||
# ── Répartition par défaut (30/30/30/10) ──
|
||||
for dest, pct in [("district", 30), ("region", 30), ("bsg", 30), ("intergroupe", 10)]:
|
||||
db.add(ConfigRepartition(
|
||||
groupe_id=groupe.id,
|
||||
destination=dest,
|
||||
pourcentage=pct,
|
||||
actif_depuis=date.today(),
|
||||
))
|
||||
print(" ✓ Répartition contributions : 30/30/30/10")
|
||||
|
||||
# ── Littérature de base ──
|
||||
for titre, cat, prix, code in [
|
||||
for titre, cat, prix, code_bsg in [
|
||||
("Les Alcooliques anonymes (Gros Livre)", "livre", 12.00, "B-1"),
|
||||
("Les Douze Étapes et les Douze Traditions", "livre", 11.00, "B-2"),
|
||||
("Vivre sans alcool", "livre", 9.00, "B-7"),
|
||||
|
|
@ -161,20 +165,20 @@ async def seed():
|
|||
prix_coutant=prix,
|
||||
quantite_stock=0,
|
||||
seuil_commande=2,
|
||||
code_bsg=code,
|
||||
code_bsg=code_bsg,
|
||||
))
|
||||
print(" ✓ Littérature de base : 5 titres")
|
||||
|
||||
await db.commit()
|
||||
|
||||
print(f"\n═══ Initialisation terminée ═══")
|
||||
print(f" Membre: {prenom} ({nom})")
|
||||
print(f" Membre: {prenom} ({courriel})")
|
||||
print(f" Groupe: Groupe Méditation")
|
||||
print(f" Poste: {poste_choisi}")
|
||||
print(f"\n Code d'invitation pour les prochains membres: {code}")
|
||||
print(f" (Expire dans 7 jours)")
|
||||
print(f"\n Connectez-vous sur l'app avec:")
|
||||
print(f" Prénom: {prenom}")
|
||||
print(f" Courriel: {courriel}")
|
||||
print(f" PIN: {pin}")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Guide de déploiement — Groupe Méditation
|
||||
|
||||
## 27 modules · Debian Bookworm + NGINX + PostgreSQL
|
||||
## Debian Bookworm + NGINX + PostgreSQL
|
||||
|
||||
Testé sur Debian 12 (Bookworm) avec un utilisateur `ansible` ayant accès sudo.
|
||||
|
||||
|
|
@ -11,6 +11,9 @@ Principe : on utilise ce que Debian fournit. Pas de dépôt externe.
|
|||
## 0. Prérequis système
|
||||
|
||||
```bash
|
||||
# Définir un mot de passe pour l'utilisateur ansible (indispensable pour récupération console)
|
||||
sudo passwd ansible
|
||||
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
sudo apt install -y curl ca-certificates gnupg
|
||||
```
|
||||
|
|
@ -39,10 +42,10 @@ GRANT ALL ON SCHEMA public TO grpmed;
|
|||
SQL
|
||||
```
|
||||
|
||||
Vérifier que la connexion fonctionne avec le mot de passe choisi :
|
||||
Vérifier la connexion. **Important : utiliser `127.0.0.1`, pas `localhost`** (asyncpg résout `localhost` en IPv6 `::1` sur Debian Bookworm, tandis que PostgreSQL n'écoute que sur `127.0.0.1`) :
|
||||
|
||||
```bash
|
||||
PGPASSWORD='votre_mot_de_passe_ici' psql -U grpmed -h localhost -d groupe_meditation -c "SELECT 1;"
|
||||
PGPASSWORD='votre_mot_de_passe_ici' psql -U grpmed -h 127.0.0.1 -d groupe_meditation -c "SELECT 1;"
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -91,7 +94,7 @@ sudo systemctl start nginx
|
|||
```bash
|
||||
# Copier l'archive sur le serveur, puis :
|
||||
cd ~
|
||||
tar xzf groupe-meditation-complet.tar.gz
|
||||
unzip groupe-meditation-v12.zip
|
||||
|
||||
# Backend
|
||||
sudo mkdir -p /opt/groupe-meditation
|
||||
|
|
@ -122,20 +125,27 @@ sudo cp .env.example .env
|
|||
sudo nano .env
|
||||
```
|
||||
|
||||
Modifier `.env` avec le **même mot de passe** que l'étape 1 :
|
||||
Modifier `.env` avec le **même mot de passe** que l'étape 1.
|
||||
|
||||
**⚠️ Utiliser `127.0.0.1`, jamais `localhost`** dans `DATABASE_URL` :
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql+asyncpg://grpmed:votre_mot_de_passe_ici@localhost:5432/groupe_meditation
|
||||
DATABASE_URL=postgresql+asyncpg://grpmed:votre_mot_de_passe_ici@127.0.0.1:5432/groupe_meditation
|
||||
JWT_SECRET=valeur_generee_ci-dessus
|
||||
CORS_ORIGINS=["https://groupe-meditation.chezlepro.ca"]
|
||||
CORS_ORIGINS=["https://groupe-meditation.87-16.org"]
|
||||
INVITATION_EXPIRE_HOURS=48
|
||||
```
|
||||
|
||||
**Important :** le mot de passe dans `DATABASE_URL` doit être identique à celui de l'étape 1. Si les deux ne correspondent pas, le seed et l'API échoueront avec `InvalidPasswordError`.
|
||||
**Permissions du `.env` — obligatoire** (le service tourne en `www-data`) :
|
||||
|
||||
```bash
|
||||
sudo chown www-data:www-data /opt/groupe-meditation/backend/.env
|
||||
sudo chmod 640 /opt/groupe-meditation/backend/.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Initialiser la base de données (23 tables)
|
||||
## 7. Initialiser la base de données
|
||||
|
||||
**Le seed doit être lancé depuis le dossier `backend/`** pour que le fichier `.env` soit trouvé :
|
||||
|
||||
|
|
@ -147,15 +157,95 @@ cd /opt/groupe-meditation/backend
|
|||
Si le `.env` n'est pas trouvé, le script utilisera les valeurs par défaut de `config.py` (qui ont un autre mot de passe) et la connexion échouera.
|
||||
|
||||
Le script crée **interactivement** :
|
||||
- Les 23 tables
|
||||
- Les tables (23+)
|
||||
- Les 10 postes prédéfinis
|
||||
- La répartition par défaut des contributions (30/30/30/10)
|
||||
- La littérature de base (5 titres)
|
||||
- Le premier groupe et le premier membre
|
||||
- Un code d'invitation valide 7 jours
|
||||
|
||||
---
|
||||
|
||||
## 7b. Mise à jour d'une base existante (v11 → v12)
|
||||
|
||||
Si la base existe déjà, exécuter ces commandes SQL **avant** de relancer le service :
|
||||
|
||||
```bash
|
||||
PGPASSWORD='votre_mot_de_passe_ici' psql -U grpmed -h 127.0.0.1 -d groupe_meditation << 'SQL'
|
||||
-- ══ Membre : courriel obligatoire, nom optionnel, téléphone obligatoire ══
|
||||
-- Remplir les courriels manquants avant de rendre NOT NULL
|
||||
UPDATE membres SET courriel = CONCAT(LOWER(prenom), '_', LEFT(id::text, 4), '@temp.local')
|
||||
WHERE courriel IS NULL OR courriel = '';
|
||||
UPDATE membres SET telephone = '000-000-0000'
|
||||
WHERE telephone IS NULL OR telephone = '';
|
||||
ALTER TABLE membres ALTER COLUMN courriel SET NOT NULL;
|
||||
ALTER TABLE membres ALTER COLUMN telephone SET NOT NULL;
|
||||
ALTER TABLE membres ALTER COLUMN nom DROP NOT NULL;
|
||||
-- Contrainte d'unicité courriel+groupe
|
||||
ALTER TABLE membres ADD CONSTRAINT uq_courriel_groupe UNIQUE (courriel, groupe_id);
|
||||
|
||||
-- ══ Tâches ══
|
||||
CREATE TABLE IF NOT EXISTS taches (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
nom VARCHAR(200) NOT NULL,
|
||||
informations TEXT,
|
||||
ordre_affichage INTEGER DEFAULT 99,
|
||||
cree_le TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS postes_taches (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
poste_id UUID NOT NULL REFERENCES postes(id),
|
||||
tache_id UUID NOT NULL REFERENCES taches(id),
|
||||
cree_le TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ══ Poste : champ informations ══
|
||||
ALTER TABLE postes ADD COLUMN IF NOT EXISTS informations TEXT;
|
||||
|
||||
-- ══ Dépenses : preuve, methode nullable ══
|
||||
ALTER TABLE depenses ADD COLUMN IF NOT EXISTS preuve_nom VARCHAR(255);
|
||||
ALTER TABLE depenses ALTER COLUMN methode DROP NOT NULL;
|
||||
|
||||
-- ══ Candidatures : type, nomination, retrait ══
|
||||
-- Ajouter les nouvelles valeurs au type ENUM
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE statut_candidature ADD VALUE IF NOT EXISTS 'retiree';
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE type_candidature AS ENUM ('candidature', 'nomination');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'candidature';
|
||||
ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS nomme_par UUID REFERENCES membres(id);
|
||||
ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS acceptee_par_membre BOOLEAN;
|
||||
ALTER TABLE candidatures ADD COLUMN IF NOT EXISTS proposition_id UUID REFERENCES propositions(id);
|
||||
|
||||
-- ══ Envoi contributions : résolution, engagement ══
|
||||
ALTER TABLE envois_contributions ADD COLUMN IF NOT EXISTS resolution_id UUID REFERENCES propositions(id);
|
||||
ALTER TABLE envois_contributions ADD COLUMN IF NOT EXISTS engagee BOOLEAN DEFAULT false;
|
||||
ALTER TABLE envois_contributions ADD COLUMN IF NOT EXISTS debitee BOOLEAN DEFAULT false;
|
||||
|
||||
-- ══ Propositions : type et lien candidature ══
|
||||
ALTER TABLE propositions ADD COLUMN IF NOT EXISTS type_proposition VARCHAR(20) DEFAULT 'generale';
|
||||
ALTER TABLE propositions ADD COLUMN IF NOT EXISTS candidature_id UUID REFERENCES candidatures(id);
|
||||
|
||||
-- ══ Répertoire des preuves ══
|
||||
-- Créer le répertoire manuellement :
|
||||
-- sudo mkdir -p /opt/groupe-meditation/uploads/preuves
|
||||
-- sudo chown www-data:www-data /opt/groupe-meditation/uploads/preuves
|
||||
SQL
|
||||
```
|
||||
|
||||
Ensuite créer le répertoire des preuves et relancer :
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/groupe-meditation/uploads/preuves
|
||||
sudo chown www-data:www-data /opt/groupe-meditation/uploads/preuves
|
||||
sudo systemctl restart groupe-meditation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Créer le service systemd
|
||||
|
||||
```bash
|
||||
|
|
@ -199,8 +289,6 @@ NGINX fait tout : fichiers statiques du frontend + reverse proxy vers FastAPI.
|
|||
sudo cp ~/groupe-meditation/deploy/nginx-groupe-meditation.conf \
|
||||
/etc/nginx/sites-available/groupe-meditation
|
||||
sudo ln -sf /etc/nginx/sites-available/groupe-meditation /etc/nginx/sites-enabled/
|
||||
|
||||
# Retirer le site par défaut si présent
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
sudo nginx -t
|
||||
|
|
@ -210,7 +298,7 @@ sudo systemctl reload nginx
|
|||
Tester en HTTP :
|
||||
|
||||
```bash
|
||||
curl -s http://groupe-meditation.chezlepro.ca/api/health
|
||||
curl -s http://groupe-meditation.87-16.org/api/health
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -219,7 +307,7 @@ curl -s http://groupe-meditation.chezlepro.ca/api/health
|
|||
|
||||
```bash
|
||||
sudo apt install -y certbot python3-certbot-nginx
|
||||
sudo certbot --nginx -d groupe-meditation.chezlepro.ca
|
||||
sudo certbot --nginx -d groupe-meditation.87-16.org
|
||||
```
|
||||
|
||||
Certbot modifie automatiquement la config NGINX pour ajouter HTTPS et la redirection 80→443.
|
||||
|
|
@ -238,39 +326,41 @@ sudo chmod +x /etc/cron.daily/groupe-meditation-backup
|
|||
## 13. Vérification finale
|
||||
|
||||
```bash
|
||||
# API en HTTPS
|
||||
curl -s https://groupe-meditation.chezlepro.ca/api/health | python3 -m json.tool
|
||||
# API
|
||||
curl -s https://groupe-meditation.87-16.org/api/health | python3 -m json.tool
|
||||
|
||||
# Documentation OpenAPI
|
||||
# Ouvrir https://groupe-meditation.chezlepro.ca/api/docs
|
||||
# https://groupe-meditation.87-16.org/api/docs
|
||||
|
||||
# Frontend
|
||||
# Ouvrir https://groupe-meditation.chezlepro.ca
|
||||
# Se connecter avec le prénom et le PIN créés lors du seed
|
||||
# https://groupe-meditation.87-16.org
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dépannage
|
||||
|
||||
| Problème | Commande |
|
||||
|----------|----------|
|
||||
| seed.py InvalidPasswordError | Vérifier que le seed est lancé **depuis `backend/`** et que le `.env` contient le bon mot de passe |
|
||||
| API ne démarre pas | `sudo journalctl -u groupe-meditation -f` |
|
||||
| Erreur base de données | `sudo -u postgres psql -d groupe_meditation -c "SELECT count(*) FROM groupes;"` |
|
||||
| NGINX 502 | `sudo systemctl status groupe-meditation` |
|
||||
| WeasyPrint échoue | `sudo apt install libpango-1.0-0 libpangocairo-1.0-0` |
|
||||
| seed.py ImportError | Vérifier les noms de classes dans `backend/app/models/` |
|
||||
|
||||
## Mise à jour de l'application
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
# 1. Copier les nouveaux fichiers
|
||||
cd ~
|
||||
unzip groupe-meditation-vXX.zip
|
||||
|
||||
# 2. Backend
|
||||
sudo cp -r ~/groupe-meditation/backend/ /opt/groupe-meditation/backend/
|
||||
cd /opt/groupe-meditation/backend
|
||||
sudo venv/bin/pip install -r requirements.txt
|
||||
|
||||
# 3. Migrations SQL si nécessaire (voir section 7b)
|
||||
|
||||
# 4. Permissions
|
||||
sudo chown -R www-data:www-data /opt/groupe-meditation
|
||||
sudo chown www-data:www-data /opt/groupe-meditation/backend/.env
|
||||
sudo chmod 640 /opt/groupe-meditation/backend/.env
|
||||
|
||||
# 5. Relancer le service
|
||||
sudo systemctl restart groupe-meditation
|
||||
|
||||
# Frontend
|
||||
# 6. Frontend
|
||||
cd ~/groupe-meditation/frontend
|
||||
npm install && npm run build
|
||||
sudo cp -r dist/* /var/www/groupe-meditation/
|
||||
|
|
@ -278,4 +368,22 @@ sudo cp -r dist/* /var/www/groupe-meditation/
|
|||
|
||||
---
|
||||
|
||||
## Dépannage
|
||||
|
||||
| Problème | Commande / Solution |
|
||||
|----------|---------------------|
|
||||
| API ne démarre pas | `sudo journalctl -u groupe-meditation -f` |
|
||||
| Workers bloqués au démarrage | `DATABASE_URL` doit utiliser `127.0.0.1` (pas `localhost`) |
|
||||
| `InvalidPasswordError` au seed | Lancer depuis `backend/`, vérifier `.env` lisible |
|
||||
| `InvalidPasswordError` à l'API | `sudo chown www-data:www-data .env && sudo chmod 640 .env` |
|
||||
| NGINX 502 Bad Gateway | `sudo systemctl status groupe-meditation` — Uvicorn démarré ? |
|
||||
| NGINX 504 Gateway Timeout | Worker bloqué DB — vérifier `127.0.0.1` dans `DATABASE_URL` |
|
||||
| WeasyPrint échoue | `sudo apt install libpango-1.0-0 libpangocairo-1.0-0` |
|
||||
| `seed.py ImportError` | Vérifier les noms de classes dans `models/` |
|
||||
| Console inaccessible | `sudo passwd ansible` — à faire en tout début |
|
||||
| `type "..." does not exist` après ALTER | Relancer le service pour que `create_all` crée les types manquants |
|
||||
| Nouvelle colonne absente | Exécuter les ALTER TABLE de la section 7b |
|
||||
|
||||
---
|
||||
|
||||
**Hébergé au Québec · 100 % logiciel libre · Zéro traçage**
|
||||
|
|
|
|||
221
docs/RUNBOOK.md
221
docs/RUNBOOK.md
|
|
@ -3,53 +3,144 @@
|
|||
## Opérations courantes
|
||||
|
||||
### Inviter un nouveau membre
|
||||
1. Un membre de l'exécutif ouvre le module **G1 — Inviter**
|
||||
1. Un membre de l'exécutif ouvre **G1 — Inviter**
|
||||
2. Un code à 12 caractères est généré (expire 48h)
|
||||
3. Le code est partagé en personne ou par SMS
|
||||
4. Le nouveau membre ouvre l'app → Inscription → entre le code + prénom + nom + PIN
|
||||
|
||||
> **Note :** le seed crée le premier code d'invitation avec une expiration de 7 jours.
|
||||
|
||||
### Affecter un poste
|
||||
1. Exécutif ouvre **G3 — Affecter les postes**
|
||||
2. Sélectionne le membre et le poste
|
||||
3. Le membre voit immédiatement ses nouvelles tuiles sur l'accueil
|
||||
2. Seuls les **postes vacants** apparaissent dans le menu déroulant
|
||||
3. Si tous les postes sont pourvus, un message indique de terminer un mandat d'abord (G4)
|
||||
4. Sélectionne le membre et le poste → le membre voit ses nouvelles tuiles immédiatement
|
||||
|
||||
### Terminer un mandat
|
||||
1. Exécutif ouvre **G4 — Terminer un mandat**
|
||||
2. Sélectionne l'affectation active
|
||||
3. Saisit une note de transition
|
||||
4. Les permissions sont retirées instantanément
|
||||
5. **Rappel** : les mandats ne se terminent jamais automatiquement
|
||||
|
||||
### Saisie hebdomadaire typique (pendant/après la réunion)
|
||||
1. **Trésorier** : S1 (collecte) + S5 (dépenses si applicable)
|
||||
2. **Animateur** : S4 (thème + annonces)
|
||||
3. **Resp. jetons** : S2 (si anniversaire ou nouveau)
|
||||
4. **Resp. littérature** : S3 (si vente)
|
||||
|
||||
### Réunion d'affaires mensuelle
|
||||
1. **Secrétaire** : M2 (présences) → M1 (PV avec décisions)
|
||||
2. **RSG** : M3 (rapport du district)
|
||||
3. **Trésorier** : présente le rapport financier (C1)
|
||||
4. Si vote nécessaire : **G6** (créer sondage) → **C6** (voter)
|
||||
|
||||
### Envoi trimestriel des contributions
|
||||
1. **Trésorier** ouvre **M4 — Envoi contributions**
|
||||
2. Mode « Calculer » : entre le surplus disponible → répartition auto
|
||||
3. Mode « Enregistrer » : enregistre chaque envoi individuel
|
||||
4. La config des % se fait dans **G8** (défaut 30/30/30/10)
|
||||
2. Sélectionne l'affectation active, saisit une note de transition
|
||||
3. Les permissions sont retirées instantanément et le poste redevient disponible
|
||||
4. **Rappel** : les mandats ne se terminent jamais automatiquement
|
||||
|
||||
---
|
||||
|
||||
## Gestion des rapports PDF
|
||||
## Saisie hebdomadaire (pendant/après la réunion)
|
||||
|
||||
Les rapports sont disponibles dans **C1 — Rapports PDF** :
|
||||
### Collecte 7e Tradition (S1)
|
||||
1. Le jour de la réunion, à partir de l'heure prévue, le trésorier ouvre **S1**
|
||||
2. La date est renseignée automatiquement
|
||||
3. Saisir le montant et indiquer qui a l'argent ce soir
|
||||
4. Un second membre confirme le montant (le même membre ne peut pas saisir et confirmer)
|
||||
5. L'onglet **mensuel** affiche toutes les collectes du mois avec date, montant, qui a saisi et qui détient l'argent
|
||||
|
||||
### Autres saisies hebdomadaires
|
||||
- **Animateur** : S4 (thème + annonces)
|
||||
- **Resp. jetons** : S2 (si anniversaire ou nouveau)
|
||||
- **Resp. littérature** : S3 (si vente — le montant sera visible dans la trésorerie pour confirmation)
|
||||
|
||||
---
|
||||
|
||||
## Trésorerie (S1T)
|
||||
|
||||
Le trésorier dispose de quatre onglets.
|
||||
|
||||
### Soldes
|
||||
- **Solde banque** : basé sur le dernier relevé + dépôts/retraits enregistrés depuis
|
||||
- **Disponible** : solde banque − engagements non débités − réserves
|
||||
- **Encaisse** : cash reçu mais pas encore déposé à la banque
|
||||
- **Réserves** : prudente (500 $) + anniversaire (+20 $/mois depuis la création du groupe)
|
||||
- **En circulation** : collectes pas encore remises au trésorier
|
||||
|
||||
### À recevoir
|
||||
Deux catégories :
|
||||
1. **Collectes** non encore reçues par le trésorier — bouton « Accuser réception »
|
||||
2. **Ventes de littérature** non remises — le responsable de la littérature vend, puis le trésorier confirme qu'il a reçu l'argent via « Confirmer la remise »
|
||||
|
||||
### Registre
|
||||
Journal détaillé de toutes les transactions du mois courant : collectes, ventes, dépenses, contributions, opérations bancaires. Avec totaux entrées/sorties.
|
||||
|
||||
### Opération bancaire
|
||||
Trois types :
|
||||
- **Dépôt** : enregistrer un dépôt bancaire
|
||||
- **Retrait** : enregistrer un retrait
|
||||
- **Relevé** : saisir le solde du relevé mensuel de la banque (sert de base au calcul des soldes)
|
||||
|
||||
### Flux mensuel recommandé
|
||||
1. Recevoir le relevé bancaire → enregistrer un **relevé** dans S1T
|
||||
2. Vérifier que le solde correspond → les écarts signalent des opérations non enregistrées
|
||||
3. Préparer le rapport financier pour la réunion d'affaires
|
||||
|
||||
---
|
||||
|
||||
## Dépenses (S5)
|
||||
|
||||
### Saisir une dépense
|
||||
Trois onglets : Saisir, Engagements, Historique.
|
||||
- La dépense est créée avec `payé = Non` par défaut (engagement)
|
||||
- Les méthodes chèque/virement créent un engagement visible dans l'onglet « Engagements »
|
||||
- Pour les dépenses non courantes payées par chèque/virement, une résolution est attendue
|
||||
|
||||
### Confirmer un débit
|
||||
Lorsque l'argent quitte effectivement le compte :
|
||||
1. Trésorier ouvre S5 → onglet **Engagements**
|
||||
2. Clique **Confirmer le débit** sur chaque dépense débitée
|
||||
3. L'engagement disparaît et le solde disponible est mis à jour
|
||||
|
||||
---
|
||||
|
||||
## Propositions et résolutions (C8)
|
||||
|
||||
### Soumettre une proposition
|
||||
1. N'importe quel membre peut soumettre une proposition **en tout temps** (pas besoin d'une réunion d'affaires)
|
||||
2. Le sujet est obligatoire ; la réunion d'affaires est optionnelle
|
||||
3. Un autre membre doit **seconder** la proposition pour qu'elle soit recevable
|
||||
|
||||
### Voter une résolution
|
||||
1. L'exécutif vote **Adopter** ou **Rejeter** lors de la réunion d'affaires
|
||||
2. Si adoptée, un numéro de résolution est attribué (RES-2026-001)
|
||||
3. La proposition est automatiquement associée à la réunion d'affaires lors du vote
|
||||
|
||||
### Lien avec le PV
|
||||
Lors de la rédaction du PV (M1), le secrétaire peut **importer les propositions secondées** en un clic. Elles pré-remplissent la section décisions avec le sujet, le proposeur et le secondeur.
|
||||
|
||||
---
|
||||
|
||||
## Réunion d'affaires mensuelle
|
||||
|
||||
### Avant la réunion
|
||||
- Les membres soumettent leurs propositions via C8 (en tout temps)
|
||||
- D'autres membres secondent les propositions recevables
|
||||
|
||||
### Pendant la réunion
|
||||
1. **Secrétaire** : M2 (présences) → M1 (PV avec import des propositions)
|
||||
2. **RSG** : M3 (rapport du district)
|
||||
3. **Trésorier** : présente le rapport financier (depuis le registre S1T)
|
||||
4. **Exécutif** : vote les propositions secondées (C8)
|
||||
|
||||
### Après la réunion
|
||||
Le secrétaire finalise le PV avec les décisions votées.
|
||||
|
||||
---
|
||||
|
||||
## Envoi des contributions (M4)
|
||||
|
||||
1. Le trésorier ouvre **M4 — Envoi contributions**
|
||||
2. Le montant disponible (après réserves) est affiché et pré-rempli
|
||||
3. Sélectionner le destinataire (district par défaut) et la méthode (virement/chèque)
|
||||
4. Enregistrer l'envoi
|
||||
|
||||
---
|
||||
|
||||
## Gestion des rapports PDF (C1)
|
||||
|
||||
| Rapport | URL API | Contenu |
|
||||
|---------|---------|---------|
|
||||
| Financier mensuel | `/api/rapports/finances/{annee}/{mois}` | Collectes + dépenses + solde |
|
||||
| Réunions du mois | `/api/rapports/reunions/{annee}/{mois}` | Liste des réunions + thèmes |
|
||||
| Réunions du mois | `/api/rapports/reunions/{annee}/{mois}` | Réunions + thèmes |
|
||||
| Financier complet | `/api/rapports/financier-complet/{annee}/{mois}` | Collectes + dépenses + contributions + impayés |
|
||||
|
||||
> **Note :** les routes PDF nécessitent WeasyPrint installé sur le serveur.
|
||||
|
||||
---
|
||||
|
||||
## Surveillance
|
||||
|
|
@ -57,30 +148,16 @@ Les rapports sont disponibles dans **C1 — Rapports PDF** :
|
|||
### Vérifier que l'application tourne
|
||||
|
||||
```bash
|
||||
# Service FastAPI
|
||||
sudo systemctl status groupe-meditation
|
||||
|
||||
# Test santé API
|
||||
curl -s https://groupe-meditation.chezlepro.ca/api/health
|
||||
|
||||
# Logs temps réel
|
||||
curl -s https://groupe-meditation.87-16.org/api/health
|
||||
sudo journalctl -u groupe-meditation -f --no-pager
|
||||
|
||||
# NGINX
|
||||
sudo systemctl status nginx
|
||||
sudo tail -f /var/log/nginx/error.log
|
||||
sudo tail -f /var/log/nginx/groupe-meditation-error.log
|
||||
```
|
||||
|
||||
### Vérifier les sauvegardes
|
||||
|
||||
```bash
|
||||
# Dernière sauvegarde
|
||||
ls -la /var/backups/groupe-meditation/ | tail -5
|
||||
|
||||
# Tester une restauration (sur une base test)
|
||||
createdb -U grpmed groupe_meditation_test
|
||||
pg_restore -U grpmed -d groupe_meditation_test /var/backups/groupe-meditation/grpmed_DERNIER.dump
|
||||
dropdb -U grpmed groupe_meditation_test
|
||||
```
|
||||
|
||||
### Statistiques base de données
|
||||
|
|
@ -106,50 +183,52 @@ sudo journalctl -u groupe-meditation --since "5 minutes ago"
|
|||
|
||||
### Un membre a oublié son PIN
|
||||
|
||||
Pas de récupération en libre-service (par design — sécurité). L'exécutif doit :
|
||||
1. Utiliser **G2 — Gérer les membres** pour désactiver le membre
|
||||
2. Recréer une invitation **G1**
|
||||
Pas de récupération en libre-service (par design). L'exécutif doit :
|
||||
1. Utiliser **G2** pour désactiver le membre
|
||||
2. Créer une nouvelle invitation **G1**
|
||||
3. Le membre se réinscrit avec un nouveau PIN
|
||||
|
||||
### Droit à l'oubli (RGPD/Loi 25)
|
||||
### Droit à l'oubli
|
||||
|
||||
1. Exécutif ouvre **G2 — Gérer les membres**
|
||||
2. Sélectionne le membre → **Droit à l'oubli**
|
||||
3. Le système supprime les données personnelles
|
||||
4. Les données historiques (collectes, PV) sont anonymisées (prénom remplacé)
|
||||
3. Les données personnelles sont supprimées, les données historiques anonymisées
|
||||
|
||||
### Restaurer la base après incident
|
||||
|
||||
```bash
|
||||
sudo systemctl stop groupe-meditation
|
||||
|
||||
# Restaurer la dernière sauvegarde
|
||||
sudo -u postgres dropdb groupe_meditation
|
||||
sudo -u postgres createdb -O grpmed groupe_meditation
|
||||
pg_restore -U grpmed -d groupe_meditation /var/backups/groupe-meditation/grpmed_DERNIER.dump
|
||||
|
||||
sudo -u postgres psql -d groupe_meditation -c "GRANT ALL ON SCHEMA public TO grpmed;"
|
||||
pg_restore -U grpmed -h 127.0.0.1 -d groupe_meditation \
|
||||
/var/backups/groupe-meditation/grpmed_DERNIER.dump
|
||||
sudo systemctl start groupe-meditation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Matrice des 27 modules par rôle
|
||||
## Matrice des modules par rôle
|
||||
|
||||
| Module | Trésorier | Secrétaire | RSG | Animateur | Litt. | Jetons | Tous |
|
||||
|--------|:---------:|:----------:|:---:|:---------:|:-----:|:------:|:----:|
|
||||
| C1-C7 Consultation | | | | | | | ✓ |
|
||||
| S1 Collecte | ✓ | | | | | | |
|
||||
| S2 Remise jeton | | | | | | ✓ | |
|
||||
| S3 Vente litt. | | | | | ✓ | | |
|
||||
| S4 Rapport réunion | | | | ✓ | | | |
|
||||
| S5 Dépense | ✓ | | | | | | |
|
||||
| M1 PV | | ✓ | | | | | |
|
||||
| M2 Présences | | ✓ | | | | | |
|
||||
| M3 Rapport RSG | | | ✓ | | | | |
|
||||
| M4 Contributions | ✓ | | | | | | |
|
||||
| M5 Inventaire | | | | | ✓ | | |
|
||||
| M6 Commander jetons | | | | | | ✓ | |
|
||||
| G1-G9 Gestion | ✓ | ✓ | ✓ | | | | |
|
||||
| Module | RSG | RSG sub | Trés. | Secr. | Anim. | Litt. | Jetons | Tous |
|
||||
|--------|:---:|:-------:|:-----:|:-----:|:-----:|:-----:|:------:|:----:|
|
||||
| C1-C9 Consultation | | | | | | | | ✓ |
|
||||
| S1 Collecte | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| S1T Trésorerie | | | ✓ | | | | | |
|
||||
| S2 Remise jeton | | | | | | | ✓ | |
|
||||
| S3 Vente litt. | | | | | | ✓ | | |
|
||||
| S4 Rapport réunion | | | | | ✓ | | | |
|
||||
| S5 Dépense | | | ✓ | | | | | |
|
||||
| M1 PV | | | | ✓ | | | | |
|
||||
| M2 Présences | | | | ✓ | | | | |
|
||||
| M3 Rapport RSG | ✓ | ✓ | | | | | | |
|
||||
| M4 Contributions | | | ✓ | | | | | |
|
||||
| M5 Inventaire | | | | | | ✓ | | |
|
||||
| M6 Commander jetons | | | | | | | ✓ | |
|
||||
| G1-G9 Gestion | ✓ | ✓ | ✓ | ✓ | | | | |
|
||||
|
||||
> Le RSG substitut a exactement les mêmes accès que le RSG — continuité assurée.
|
||||
> Un membre peut cumuler plusieurs postes. L'écran affiche l'union de tous ses modules.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -157,7 +236,7 @@ sudo systemctl start groupe-meditation
|
|||
|
||||
- **aa87.org** — Site de la Région 87
|
||||
- **BSG** — Bureau des Services généraux (aa.org)
|
||||
- Documentation API : `https://groupe-meditation.chezlepro.ca/api/docs`
|
||||
- Documentation API : `https://groupe-meditation.87-16.org/api/docs`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import Collecte from './pages/Collecte';
|
|||
import RapportReunion from './pages/RapportReunion';
|
||||
import Depense from './pages/Depense';
|
||||
import Decisions from './pages/Decisions';
|
||||
import Calendrier from './pages/Calendrier';
|
||||
import Evenements from './pages/Evenements';
|
||||
import Rapports from './pages/Rapports';
|
||||
import InviterMembre from './pages/InviterMembre';
|
||||
import GererMembres from './pages/GererMembres';
|
||||
|
|
@ -20,7 +20,6 @@ import Presences from './pages/Presences';
|
|||
import RedigerPV from './pages/RedigerPV';
|
||||
import RapportRSG from './pages/RapportRSG';
|
||||
import RemiseJeton from './pages/RemiseJeton';
|
||||
import Anniversaires from './pages/Anniversaires';
|
||||
import CommanderJetons from './pages/CommanderJetons';
|
||||
import VenteLitterature from './pages/VenteLitterature';
|
||||
import Inventaire from './pages/Inventaire';
|
||||
|
|
@ -30,6 +29,11 @@ import ConfigRepartition from './pages/ConfigRepartition';
|
|||
import LancerSondage from './pages/LancerSondage';
|
||||
import VotesOuverts from './pages/VotesOuverts';
|
||||
import JumelerParrain from './pages/JumelerParrain';
|
||||
import Propositions from './pages/Propositions';
|
||||
import Tresorerie from './pages/Tresorerie';
|
||||
import Impliquer from './pages/Impliquer';
|
||||
import GererPostes from './pages/GererPostes';
|
||||
import EtatFinancier from './pages/EtatFinancier';
|
||||
|
||||
export default function App() {
|
||||
const { token, page, loadAccueil, loadGroupes } = useStore();
|
||||
|
|
@ -38,8 +42,15 @@ export default function App() {
|
|||
loadGroupes();
|
||||
if (token) {
|
||||
loadAccueil().then(() => {
|
||||
// Restaurer la page sauvegardée ou aller à l'accueil
|
||||
const savedPage = localStorage.getItem('gm_page');
|
||||
if (savedPage && savedPage !== 'login' && savedPage !== 'inscription') {
|
||||
useStore.setState({ page: savedPage });
|
||||
} else {
|
||||
useStore.setState({ page: 'accueil' });
|
||||
}
|
||||
}).catch(() => {
|
||||
localStorage.removeItem('gm_page');
|
||||
useStore.setState({ page: 'login' });
|
||||
});
|
||||
}
|
||||
|
|
@ -51,10 +62,11 @@ export default function App() {
|
|||
case 'accueil': return <Accueil />;
|
||||
case 'profil': return <Profil />;
|
||||
case 'S1': return <Collecte />;
|
||||
case 'S1T': return <Tresorerie />;
|
||||
case 'S4': return <RapportReunion />;
|
||||
case 'S5': return <Depense />;
|
||||
case 'C1': return <Rapports />;
|
||||
case 'C2': return <Calendrier />;
|
||||
case 'C2': return <Evenements />;
|
||||
case 'C5': return <Decisions />;
|
||||
case 'G1': return <InviterMembre />;
|
||||
case 'G2': return <GererMembres />;
|
||||
|
|
@ -62,11 +74,11 @@ export default function App() {
|
|||
case 'G4': return <TerminerMandat />;
|
||||
case 'G5': return <PlanifierRotations />;
|
||||
case 'G9': return <Parametres />;
|
||||
case 'G10': return <GererPostes />;
|
||||
case 'M1': return <RedigerPV />;
|
||||
case 'M2': return <Presences />;
|
||||
case 'M3': return <RapportRSG />;
|
||||
case 'S2': return <RemiseJeton />;
|
||||
case 'C3': return <Anniversaires />;
|
||||
case 'M6': return <CommanderJetons />;
|
||||
case 'S3': return <VenteLitterature />;
|
||||
case 'C4': return <Inventaire />;
|
||||
|
|
@ -76,6 +88,9 @@ export default function App() {
|
|||
case 'G6': return <LancerSondage />;
|
||||
case 'C6': return <VotesOuverts />;
|
||||
case 'G7': return <JumelerParrain />;
|
||||
case 'C8': return <Propositions />;
|
||||
case 'C9': return <Impliquer />;
|
||||
case 'C10': return <EtatFinancier />;
|
||||
default: return <Accueil />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ function Section({ titre, modules }) {
|
|||
<div className="grid gap-2">
|
||||
{modules.map((m) => (
|
||||
<Tuile key={m.code} module={m} onClick={() => {
|
||||
const navigable = ['C7', 'S1', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7', 'G8', 'G9', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
|
||||
const navigable = ['C7', 'S1', 'S1T', 'S2', 'S3', 'S4', 'S5', 'C1', 'C2', 'C4', 'C5', 'C6', 'C8', 'C9', 'C10', 'G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7', 'G9', 'G10', 'M1', 'M2', 'M3', 'M4', 'M5', 'M6'];
|
||||
if (m.code === 'C7') useStore.setState({ page: 'profil' });
|
||||
else if (navigable.includes(m.code)) useStore.setState({ page: m.code });
|
||||
else alert(`Module ${m.code} — ${m.nom}\n\n(Sera disponible dans les prochaines phases)`);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ export default function AffecterPostes() {
|
|||
|
||||
const CATEGORIES = { executif: '🔵', service: '🟣', physique: '🟢' };
|
||||
|
||||
// Postes vacants = postes actifs sans affectation active dans le groupe
|
||||
const postesOccupes = new Set(affectations.map((a) => a.poste_nom));
|
||||
const postesVacants = postes.filter((p) => !postesOccupes.has(p.nom));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Affecter les postes" sousTitre="G3 — Assigner un serviteur" />
|
||||
|
|
@ -69,6 +73,12 @@ export default function AffecterPostes() {
|
|||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Nouvelle affectation</h2>
|
||||
|
||||
{postesVacants.length === 0 ? (
|
||||
<div className="bg-yellow-50 rounded-xl p-3 border border-yellow-200">
|
||||
<p className="text-sm text-yellow-700">Tous les postes sont pourvus. Terminez d'abord un mandat (G4) pour libérer un poste.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Membre</label>
|
||||
<select value={form.membre_id} onChange={(e) => setForm({ ...form, membre_id: e.target.value })}
|
||||
|
|
@ -79,15 +89,17 @@ export default function AffecterPostes() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Poste</label>
|
||||
<label className="text-xs text-gray-400 block mb-1">Poste vacant</label>
|
||||
<select value={form.poste_id} onChange={(e) => setForm({ ...form, poste_id: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
|
||||
<option value="">Sélectionner un poste</option>
|
||||
{postes.map((p) => (
|
||||
{postesVacants.map((p) => (
|
||||
<option key={p.id} value={p.id}>{CATEGORIES[p.categorie] || '⚪'} {p.nom}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Date de début</label>
|
||||
|
|
|
|||
|
|
@ -1,82 +1,229 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../stores/api';
|
||||
import { useStore } from '../stores/store';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import SuccessCheck from '../components/SuccessCheck';
|
||||
|
||||
const STATUT = {
|
||||
en_attente: { label: 'À confirmer', cls: 'bg-yellow-100 text-yellow-700' },
|
||||
confirmee: { label: 'Confirmée', cls: 'bg-blue-100 text-blue-700' },
|
||||
recu: { label: '✓ Reçue', cls: 'bg-green-100 text-green-700' },
|
||||
};
|
||||
|
||||
export default function Collecte() {
|
||||
const { membre, groupe } = useStore();
|
||||
const [montant, setMontant] = useState('');
|
||||
const [dateCollecte, setDateCollecte] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [detenteurId, setDetenteurId] = useState('');
|
||||
const [membres, setMembres] = useState([]);
|
||||
const [collectesMois, setCollectesMois] = useState([]);
|
||||
const [tab, setTab] = useState('mois');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const aujourdhui = new Date();
|
||||
const mois = aujourdhui.getMonth() + 1;
|
||||
const annee = aujourdhui.getFullYear();
|
||||
|
||||
const charger = async () => {
|
||||
try {
|
||||
const [mbrs, cols] = await Promise.all([
|
||||
api.get('/membres/actifs'),
|
||||
api.get(`/collectes/?mois=${mois}&annee=${annee}&limite=50`),
|
||||
]);
|
||||
setMembres(mbrs);
|
||||
setCollectesMois(cols);
|
||||
if (membre && !detenteurId) setDetenteurId(membre.id);
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
useEffect(() => { charger(); }, []);
|
||||
|
||||
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!montant || parseFloat(montant) <= 0) {
|
||||
setError('Entrez un montant valide');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
if (!montant || parseFloat(montant) <= 0) { setError('Entrez un montant valide'); return; }
|
||||
if (!detenteurId) { setError("Sélectionnez qui a l'argent"); return; }
|
||||
if (!dateCollecte) { setError("Sélectionnez une date"); return; }
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
await api.post('/collectes/', {
|
||||
date_collecte: dateCollecte,
|
||||
montant: parseFloat(montant),
|
||||
detenteur_id: detenteurId,
|
||||
});
|
||||
setSuccess(true);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setMontant('');
|
||||
flash('Collecte enregistrée ✓');
|
||||
setTab('mois');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) return <SuccessCheck message="Collecte enregistrée ✓" />;
|
||||
const confirmer = async (id) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/collectes/${id}/confirmer`);
|
||||
flash('Montant confirmé ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const totalMois = collectesMois.reduce((sum, c) => sum + parseFloat(c.montant), 0);
|
||||
const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
|
||||
|
||||
// Calculer les dernières dates de réunion pour le sélecteur
|
||||
const joursSemaine = { 'lundi': 1, 'mardi': 2, 'mercredi': 3, 'jeudi': 4, 'vendredi': 5, 'samedi': 6, 'dimanche': 0 };
|
||||
const jourReunion = groupe?.jour_reunion ? joursSemaine[groupe.jour_reunion.toLowerCase()] : null;
|
||||
|
||||
const getDatesReunion = () => {
|
||||
if (jourReunion === null) return [];
|
||||
const dates = [];
|
||||
const d = new Date();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const test = new Date(d);
|
||||
test.setDate(d.getDate() - i);
|
||||
if (test.getDay() === jourReunion) {
|
||||
dates.push(test.toISOString().slice(0, 10));
|
||||
}
|
||||
}
|
||||
// Aussi vérifier les 30 jours suivants dans le passé
|
||||
for (let i = 12; i < 60; i++) {
|
||||
const test = new Date(d);
|
||||
test.setDate(d.getDate() - i);
|
||||
if (test.getDay() === jourReunion) {
|
||||
dates.push(test.toISOString().slice(0, 10));
|
||||
}
|
||||
}
|
||||
return dates;
|
||||
};
|
||||
|
||||
const datesReunion = getDatesReunion();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Collecte 7e Tradition" sousTitre="S1 — Saisir le panier" />
|
||||
<PageHeader titre="Collecte 7e Tradition" sousTitre="S1 — Saisir et confirmer" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Date pré-remplie */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateCollecte}
|
||||
onChange={(e) => setDateCollecte(e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue"
|
||||
/>
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { setTab('mois'); setError(''); }}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-bold transition ${tab === 'mois' ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'}`}>
|
||||
📋 {MOIS_NOMS[mois]} ({collectesMois.length})
|
||||
</button>
|
||||
<button onClick={() => { setTab('saisie'); setError(''); }}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-bold transition ${tab === 'saisie' ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'}`}>
|
||||
💰 Saisir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'mois' && (
|
||||
<>
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Total {MOIS_NOMS[mois]} {annee}</p>
|
||||
<p className="text-2xl font-bold text-green-700">{totalMois.toFixed(2)} $</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-400">{collectesMois.length} collecte{collectesMois.length > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collectesMois.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">💰</p>
|
||||
<p className="text-gray-500">Aucune collecte ce mois-ci</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{collectesMois.map((c) => {
|
||||
const st = STATUT[c.statut] || STATUT.en_attente;
|
||||
const peutConfirmer = c.statut === 'en_attente' && c.saisi_par_prenom !== membre?.prenom;
|
||||
return (
|
||||
<div key={c.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-gray-800 text-lg">{parseFloat(c.montant).toFixed(2)} $</p>
|
||||
<p className="text-xs text-gray-500 mt-1">📅 {c.date_collecte}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Saisi par <span className="font-medium">{c.saisi_par_prenom}</span>
|
||||
{' · '}Chez <span className="font-medium">{c.detenteur_prenom}</span>
|
||||
</p>
|
||||
{c.confirme_par_prenom && <p className="text-xs text-blue-500">Confirmé par {c.confirme_par_prenom}</p>}
|
||||
{c.recu_par_prenom && <p className="text-xs text-green-500">Reçu par {c.recu_par_prenom}</p>}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
{peutConfirmer && (
|
||||
<button onClick={() => confirmer(c.id)}
|
||||
className="w-full mt-3 py-2 bg-yellow-50 text-yellow-700 rounded-xl text-sm font-bold border border-yellow-200 active:scale-[0.97] transition">
|
||||
✋ Confirmer le montant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'saisie' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Date de la réunion</label>
|
||||
{datesReunion.length > 0 ? (
|
||||
<select value={dateCollecte} onChange={(e) => setDateCollecte(e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
|
||||
{datesReunion.map((d) => {
|
||||
const dt = new Date(d + 'T12:00:00');
|
||||
const label = dt.toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
const estAujourdhui = d === new Date().toISOString().slice(0, 10);
|
||||
return <option key={d} value={d}>{estAujourdhui ? '● Aujourd\'hui' : label}</option>;
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<input type="date" value={dateCollecte} onChange={(e) => setDateCollecte(e.target.value)}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1">Saisie rétroactive permise aux dates de réunion</p>
|
||||
</div>
|
||||
|
||||
{/* Montant — gros clavier numérique */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Montant collecté</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-2xl text-gray-400">$</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={montant}
|
||||
onChange={(e) => setMontant(e.target.value)}
|
||||
<input type="number" inputMode="decimal" step="0.01" min="0" placeholder="0.00"
|
||||
value={montant} onChange={(e) => setMontant(e.target.value)}
|
||||
className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-3xl font-bold text-center border-0 focus:ring-2 focus:ring-green-500"
|
||||
autoFocus
|
||||
/>
|
||||
autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Qui a l'argent?</label>
|
||||
<select value={detenteurId} onChange={(e) => setDetenteurId(e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue">
|
||||
<option value="">— Sélectionner —</option>
|
||||
{membres.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.prenom}{m.id === membre?.id ? ' (moi)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bouton enregistrer */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !montant}
|
||||
className="w-full p-5 bg-green-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg"
|
||||
>
|
||||
<button onClick={handleSubmit} disabled={loading || !montant || !detenteurId}
|
||||
className="w-full p-5 bg-green-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
|
||||
{loading ? '...' : '💰 Enregistrer la collecte'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,178 +1,273 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../stores/api';
|
||||
import { useStore } from '../stores/store';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import SuccessCheck from '../components/SuccessCheck';
|
||||
|
||||
const METHODES = [
|
||||
{ value: 'DU', label: 'DÛ', emoji: '📋' },
|
||||
{ value: 'VIREMENT', label: 'Virement', emoji: '🏦' },
|
||||
{ value: 'COLLECTE', label: 'Cash', emoji: '💰' },
|
||||
{ value: 'CHEQUE', label: 'Chèque', emoji: '📝' },
|
||||
{ value: 'COLLECTE', label: 'Collecte', emoji: '💰' },
|
||||
{ value: 'VIREMENT', label: 'Virement', emoji: '🏦' },
|
||||
{ value: 'DU', label: 'DÛ', emoji: '📋' },
|
||||
];
|
||||
|
||||
export default function Depense() {
|
||||
const { postes } = useStore();
|
||||
const [form, setForm] = useState({
|
||||
date_depense: new Date().toISOString().slice(0, 10),
|
||||
montant: '',
|
||||
qui: '',
|
||||
quoi: '',
|
||||
paye: true,
|
||||
methode: '',
|
||||
montant: '', quoi: '',
|
||||
});
|
||||
const [membres, setMembres] = useState([]);
|
||||
const [fichier, setFichier] = useState(null);
|
||||
const [depenses, setDepenses] = useState([]);
|
||||
const [aConfirmer, setAConfirmer] = useState([]);
|
||||
const [tab, setTab] = useState('saisie');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Charger la liste des membres pour le dropdown "Qui"
|
||||
api.get('/membres/actifs').then(setMembres).catch(() => setMembres([]));
|
||||
}, []);
|
||||
const estTresorier = postes.some((p) =>
|
||||
['Trésorier(ière)', 'RSG', 'RSG Substitut(e)', 'Secrétaire'].includes(p)
|
||||
);
|
||||
|
||||
const charger = async () => {
|
||||
try {
|
||||
const [d] = await Promise.all([
|
||||
api.get('/depenses/?limite=30'),
|
||||
]);
|
||||
setDepenses(d);
|
||||
if (estTresorier) {
|
||||
const ac = await api.get('/depenses/a-confirmer');
|
||||
setAConfirmer(ac);
|
||||
}
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
useEffect(() => { charger(); }, []);
|
||||
|
||||
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
|
||||
const update = (k, v) => setForm({ ...form, [k]: v });
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.montant || !form.qui || !form.quoi.trim() || !form.methode) {
|
||||
setError('Tous les champs sont obligatoires');
|
||||
return;
|
||||
if (!form.montant || !form.quoi.trim()) {
|
||||
setError('Montant et description obligatoires'); return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
await api.post('/depenses/', {
|
||||
const result = await api.post('/depenses/', {
|
||||
date_depense: form.date_depense,
|
||||
montant: parseFloat(form.montant),
|
||||
qui: form.qui,
|
||||
quoi: form.quoi.trim(),
|
||||
paye: form.paye,
|
||||
methode: form.methode,
|
||||
});
|
||||
setSuccess(true);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setLoading(false);
|
||||
// Upload preuve si présente
|
||||
if (fichier && result.id) {
|
||||
const formData = new FormData();
|
||||
formData.append('fichier', fichier);
|
||||
await fetch(`/api/depenses/${result.id}/preuve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('gm_token')}` },
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
setSuccess(true);
|
||||
} catch (e) { setError(e.message); setLoading(false); }
|
||||
};
|
||||
|
||||
if (success) return <SuccessCheck message="Dépense enregistrée ✓" />;
|
||||
const confirmerDepense = async (id, methode) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/depenses/${id}/confirmer?methode=${methode}`);
|
||||
flash('Dépense confirmée ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const confirmerDebit = async (id) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/depenses/${id}/confirmer-debit`);
|
||||
flash('Débit confirmé ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
if (success) return <SuccessCheck message="Dépense soumise ✓" />;
|
||||
|
||||
const engagements = depenses.filter((d) => d.confirme && !d.paye && ['VIREMENT', 'CHEQUE'].includes(d.methode));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Dépense" sousTitre="S5 — Enregistrer une dépense" />
|
||||
<PageHeader titre="Dépenses" sousTitre="S5 — Soumettre et confirmer" />
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Date */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.date_depense}
|
||||
onChange={(e) => update('date_depense', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
|
||||
/>
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{[
|
||||
['saisie', '💸 Soumettre'],
|
||||
...(estTresorier ? [['confirmer', `📥 À confirmer (${aConfirmer.length})`]] : []),
|
||||
...(engagements.length > 0 ? [['engagements', `⏳ Engagements (${engagements.length})`]] : []),
|
||||
['historique', `📋 Tout (${depenses.length})`],
|
||||
].map(([t, label]) => (
|
||||
<button key={t} onClick={() => { setTab(t); setError(''); }}
|
||||
className={`flex-1 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
|
||||
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
|
||||
}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Saisie (ouvert à tous) ── */}
|
||||
{tab === 'saisie' && (
|
||||
<>
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Date</label>
|
||||
<input type="date" value={form.date_depense}
|
||||
onChange={(e) => update('date_depense', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
</div>
|
||||
|
||||
{/* Montant */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Montant</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-xl text-gray-400">$</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={form.montant}
|
||||
onChange={(e) => update('montant', e.target.value)}
|
||||
<input type="number" inputMode="decimal" step="0.01" min="0" placeholder="0.00"
|
||||
value={form.montant} onChange={(e) => update('montant', e.target.value)}
|
||||
className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-red-400"
|
||||
/>
|
||||
autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Qui (dropdown membres) */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Qui a fait la dépense</label>
|
||||
<select
|
||||
value={form.qui}
|
||||
onChange={(e) => update('qui', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue"
|
||||
>
|
||||
<option value="">Sélectionner un membre</option>
|
||||
{membres.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.prenom}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Quoi (texte libre) */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Loyer, café, littérature, jetons..."
|
||||
value={form.quoi}
|
||||
onChange={(e) => update('quoi', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue"
|
||||
/>
|
||||
<input type="text" placeholder="Loyer, café, littérature, jetons..."
|
||||
value={form.quoi} onChange={(e) => update('quoi', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
</div>
|
||||
|
||||
{/* Méthode (4 boutons radio) */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-2">Méthode de paiement</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{METHODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => update('methode', m.value)}
|
||||
className={`p-3 rounded-xl text-sm font-bold transition border-2 ${
|
||||
form.methode === m.value
|
||||
? 'bg-aa-blue text-white border-aa-blue'
|
||||
: 'bg-gray-50 text-gray-600 border-gray-200 active:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{m.emoji} {m.label}
|
||||
<label className="text-xs text-gray-400 block mb-1">Preuve (reçu, photo) — optionnel</label>
|
||||
<input type="file" accept="image/*,.pdf"
|
||||
onChange={(e) => setFichier(e.target.files?.[0] || null)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 text-sm" />
|
||||
{fichier && <p className="text-xs text-green-600 mt-1">📎 {fichier.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 rounded-xl p-3 border border-blue-200">
|
||||
<p className="text-xs text-blue-700">La dépense sera soumise au trésorier pour confirmation et choix du mode de paiement.</p>
|
||||
</div>
|
||||
|
||||
<button onClick={handleSubmit}
|
||||
disabled={loading || !form.montant || !form.quoi.trim()}
|
||||
className="w-full p-5 bg-red-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
|
||||
{loading ? '...' : '💸 Soumettre la dépense'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── À confirmer (trésorier) ── */}
|
||||
{tab === 'confirmer' && estTresorier && (
|
||||
aConfirmer.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">✅</p>
|
||||
<p className="text-gray-500">Aucune dépense en attente</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{aConfirmer.map((d) => (
|
||||
<ConfirmCard key={d.id} depense={d} onConfirm={confirmerDepense} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Payé (Oui/Non) */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-2">Payé ?</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update('paye', true)}
|
||||
className={`p-3 rounded-xl text-sm font-bold transition border-2 ${
|
||||
form.paye ? 'bg-green-600 text-white border-green-600' : 'bg-gray-50 text-gray-600 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
✓ Oui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update('paye', false)}
|
||||
className={`p-3 rounded-xl text-sm font-bold transition border-2 ${
|
||||
!form.paye ? 'bg-orange-500 text-white border-orange-500' : 'bg-gray-50 text-gray-600 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
✗ Non (à rembourser)
|
||||
{/* ── Engagements ── */}
|
||||
{tab === 'engagements' && (
|
||||
<div className="space-y-3">
|
||||
{engagements.map((d) => (
|
||||
<div key={d.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-gray-800">{d.quoi}</p>
|
||||
<p className="text-lg font-bold text-orange-600">{parseFloat(d.montant).toFixed(2)} $</p>
|
||||
<p className="text-xs text-gray-400">{d.date_depense} · {d.qui_prenom} · {d.methode}</p>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-1 rounded-full font-medium bg-orange-100 text-orange-700">À débiter</span>
|
||||
</div>
|
||||
{estTresorier && (
|
||||
<button onClick={() => confirmerDebit(d.id)}
|
||||
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97] transition">
|
||||
✓ Confirmer le débit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Historique ── */}
|
||||
{tab === 'historique' && (
|
||||
depenses.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">💸</p>
|
||||
<p className="text-gray-500">Aucune dépense</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{depenses.map((d) => (
|
||||
<div key={d.id} className="bg-white rounded-xl p-3 shadow-sm flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-gray-800 truncate">
|
||||
{d.quoi}
|
||||
{d.preuve_nom && ' 📎'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{d.date_depense} · {d.qui_prenom}
|
||||
{d.methode && ` · ${d.methode}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-sm text-red-600">{parseFloat(d.montant).toFixed(2)} $</p>
|
||||
<p className={`text-xs ${d.paye ? 'text-green-500' : d.confirme ? 'text-orange-500' : 'text-gray-400'}`}>
|
||||
{d.paye ? '✓ Payé' : d.confirme ? '⏳ Engagé' : '○ Soumise'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !form.montant || !form.qui || !form.quoi.trim() || !form.methode}
|
||||
className="w-full p-5 bg-red-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg"
|
||||
>
|
||||
{loading ? '...' : '💸 Enregistrer la dépense'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmCard({ depense, onConfirm }) {
|
||||
const [methode, setMethode] = useState('');
|
||||
return (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-gray-800">{depense.quoi}</p>
|
||||
<p className="text-lg font-bold text-red-600">{parseFloat(depense.montant).toFixed(2)} $</p>
|
||||
<p className="text-xs text-gray-400">{depense.date_depense} · Soumis par {depense.saisi_par_prenom}</p>
|
||||
{depense.preuve_nom && <p className="text-xs text-blue-500 mt-1">📎 Preuve jointe</p>}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-gray-400 mb-1">Mode de paiement</p>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{METHODES.map((m) => (
|
||||
<button key={m.value} onClick={() => setMethode(m.value)}
|
||||
className={`p-2 rounded-lg text-xs font-bold border ${
|
||||
methode === m.value ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-500 border-gray-200'
|
||||
}`}>{m.emoji}<br/>{m.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => methode && onConfirm(depense.id, methode)}
|
||||
disabled={!methode}
|
||||
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 disabled:opacity-40 active:scale-[0.97] transition">
|
||||
✓ Confirmer ({methode || '...'})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,150 +6,8 @@ import SuccessCheck from '../components/SuccessCheck';
|
|||
const METHODES = [
|
||||
{ value: 'VIREMENT', label: 'Virement', emoji: '🏦' },
|
||||
{ value: 'CHEQUE', label: 'Chèque', emoji: '📝' },
|
||||
{ value: 'DU', label: 'Dû', emoji: '📋' },
|
||||
];
|
||||
|
||||
export default function EnvoiContributions() {
|
||||
const [mode, setMode] = useState('calculer'); // calculer | enregistrer
|
||||
const [montantTotal, setMontantTotal] = useState('');
|
||||
const [repartition, setRepartition] = useState([]);
|
||||
const [envois, setEnvois] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Charger les envois récents
|
||||
useEffect(() => {
|
||||
api.get('/contributions/?limite=10').then(setEnvois).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const calculer = async () => {
|
||||
if (!montantTotal || parseFloat(montantTotal) <= 0) {
|
||||
setError('Entrez un montant valide');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.post('/contributions/calculer', {
|
||||
montant_total: parseFloat(montantTotal),
|
||||
});
|
||||
setRepartition(data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) return <SuccessCheck message="Contribution enregistrée ✓" />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Envoi contributions" sousTitre="M4 — District, région, BSG, intergroupe" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setMode('calculer')}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-bold border-2 ${
|
||||
mode === 'calculer' ? 'bg-aa-blue text-white border-aa-blue' : 'bg-white text-gray-500 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
🧮 Calculer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('enregistrer')}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-bold border-2 ${
|
||||
mode === 'enregistrer' ? 'bg-aa-blue text-white border-aa-blue' : 'bg-white text-gray-500 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
📮 Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'calculer' && (
|
||||
<>
|
||||
{/* Montant à répartir */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Montant à répartir</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-xl text-gray-400">$</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={montantTotal}
|
||||
onChange={(e) => setMontantTotal(e.target.value)}
|
||||
className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-2xl font-bold text-center border-0 focus:ring-2 focus:ring-aa-blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={calculer}
|
||||
disabled={!montantTotal}
|
||||
className="w-full p-4 bg-aa-blue text-white rounded-2xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition"
|
||||
>
|
||||
🧮 Calculer la répartition
|
||||
</button>
|
||||
|
||||
{/* Résultat */}
|
||||
{repartition.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{repartition.map((r) => (
|
||||
<div key={r.destination} className="bg-white rounded-2xl p-4 shadow-sm flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-sm">{r.destination_label}</p>
|
||||
<p className="text-xs text-gray-400">{r.pourcentage}%</p>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-aa-blue">{r.montant.toFixed(2)} $</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'enregistrer' && (
|
||||
<FormEnvoi
|
||||
onSuccess={() => setSuccess(true)}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
|
||||
|
||||
{/* Historique */}
|
||||
{envois.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
|
||||
Derniers envois
|
||||
</h2>
|
||||
{envois.map((e) => (
|
||||
<div key={e.id} className="bg-white rounded-xl p-3 shadow-sm flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-sm">{e.destination_label}</p>
|
||||
<p className="text-xs text-gray-400">{e.date_envoi} · {e.methode}</p>
|
||||
</div>
|
||||
<p className="font-bold text-sm">{e.montant.toFixed(2)} $</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormEnvoi({ onSuccess, onError }) {
|
||||
const [form, setForm] = useState({
|
||||
date_envoi: new Date().toISOString().slice(0, 10),
|
||||
destination: '',
|
||||
montant: '',
|
||||
methode: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const DESTINATIONS = [
|
||||
{ value: 'district', label: 'District 87-16' },
|
||||
{ value: 'region', label: 'Région 87' },
|
||||
|
|
@ -157,31 +15,109 @@ function FormEnvoi({ onSuccess, onError }) {
|
|||
{ value: 'intergroupe', label: 'Intergroupe' },
|
||||
];
|
||||
|
||||
export default function EnvoiContributions() {
|
||||
const [soldes, setSoldes] = useState(null);
|
||||
const [resolutions, setResolutions] = useState([]);
|
||||
const [envois, setEnvois] = useState([]);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
date_envoi: new Date().toISOString().slice(0, 10),
|
||||
destination: 'district',
|
||||
montant: '',
|
||||
methode: '',
|
||||
resolution_id: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.get('/collectes/soldes'),
|
||||
api.get('/propositions/resolutions?type_proposition=contribution'),
|
||||
api.get('/contributions/?limite=10'),
|
||||
]).then(([s, r, e]) => {
|
||||
setSoldes(s);
|
||||
setResolutions(r);
|
||||
setEnvois(e);
|
||||
if (s && s.disponible > 0) {
|
||||
setForm((f) => ({ ...f, montant: s.disponible.toFixed(2) }));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const update = (k, v) => setForm({ ...form, [k]: v });
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.destination || !form.montant || !form.methode) {
|
||||
onError('Tous les champs sont obligatoires');
|
||||
if (!form.montant || parseFloat(form.montant) <= 0) { setError('Montant invalide'); return; }
|
||||
if (!form.methode) { setError('Sélectionnez une méthode'); return; }
|
||||
if (!form.resolution_id) {
|
||||
setError('Une résolution adoptée est requise. Créez d\'abord une proposition de type « contribution », faites-la seconder et adopter.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
onError('');
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
await api.post('/contributions/', {
|
||||
date_envoi: form.date_envoi,
|
||||
destination: form.destination,
|
||||
montant: parseFloat(form.montant),
|
||||
methode: form.methode,
|
||||
resolution_id: form.resolution_id,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
onError(e.message);
|
||||
setLoading(false);
|
||||
}
|
||||
setSuccess(true);
|
||||
} catch (e) { setError(e.message); setLoading(false); }
|
||||
};
|
||||
|
||||
if (success) return <SuccessCheck message="Contribution enregistrée ✓" />;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Envoi contributions" sousTitre="M4 — Requiert une résolution" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Somme disponible */}
|
||||
{soldes && (
|
||||
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-blue-600">Somme disponible</p>
|
||||
<p className={`text-2xl font-bold ${soldes.disponible >= 0 ? 'text-blue-700' : 'text-red-600'}`}>
|
||||
{soldes.disponible.toFixed(2)} $
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-blue-500">
|
||||
<p>Banque: {soldes.solde_banque.toFixed(2)} $</p>
|
||||
<p>Réserves: -{soldes.total_reserves.toFixed(2)} $</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Résolution obligatoire */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-2">Résolution autorisant l'envoi *</label>
|
||||
{resolutions.length === 0 ? (
|
||||
<div className="bg-yellow-50 rounded-xl p-3 border border-yellow-200">
|
||||
<p className="text-sm text-yellow-700">
|
||||
Aucune résolution de type « contribution » disponible.
|
||||
</p>
|
||||
<p className="text-xs text-yellow-600 mt-1">
|
||||
Créez une proposition de type « contribution » dans la section Propositions, faites-la seconder puis adopter lors d'une assemblée d'affaires.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<select value={form.resolution_id} onChange={(e) => update('resolution_id', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
|
||||
<option value="">— Sélectionner une résolution —</option>
|
||||
{resolutions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.numero_resolution} — {r.sujet}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Date</label>
|
||||
<input type="date" value={form.date_envoi} onChange={(e) => update('date_envoi', e.target.value)}
|
||||
|
|
@ -189,18 +125,13 @@ function FormEnvoi({ onSuccess, onError }) {
|
|||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-2">Destination</label>
|
||||
<label className="text-xs text-gray-400 block mb-2">Destinataire</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{DESTINATIONS.map((d) => (
|
||||
<button key={d.value} type="button" onClick={() => update('destination', d.value)}
|
||||
className={`p-3 rounded-xl text-sm font-bold border-2 ${
|
||||
form.destination === d.value
|
||||
? 'bg-aa-blue text-white border-aa-blue'
|
||||
: 'bg-gray-50 text-gray-600 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
form.destination === d.value ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
|
||||
}`}>{d.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -217,27 +148,40 @@ function FormEnvoi({ onSuccess, onError }) {
|
|||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-2">Méthode</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{METHODES.map((m) => (
|
||||
<button key={m.value} type="button" onClick={() => update('methode', m.value)}
|
||||
className={`p-3 rounded-xl text-sm font-bold border-2 ${
|
||||
form.methode === m.value
|
||||
? 'bg-aa-blue text-white border-aa-blue'
|
||||
: 'bg-gray-50 text-gray-600 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{m.emoji} {m.label}
|
||||
</button>
|
||||
form.methode === m.value ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
|
||||
}`}>{m.emoji} {m.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
|
||||
|
||||
<button onClick={handleSubmit}
|
||||
disabled={loading || !form.destination || !form.montant || !form.methode}
|
||||
className="w-full p-5 bg-blue-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg"
|
||||
>
|
||||
disabled={loading || !form.montant || !form.methode || !form.resolution_id}
|
||||
className="w-full p-5 bg-blue-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
|
||||
{loading ? '...' : '📮 Enregistrer l\'envoi'}
|
||||
</button>
|
||||
|
||||
{/* Historique */}
|
||||
{envois.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">Derniers envois</h2>
|
||||
{envois.map((e) => (
|
||||
<div key={e.id} className="bg-white rounded-xl p-3 shadow-sm flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-sm">{e.destination_label}</p>
|
||||
<p className="text-xs text-gray-400">{e.date_envoi} · {e.methode}</p>
|
||||
</div>
|
||||
<p className="font-bold text-sm">{e.montant.toFixed(2)} $</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
128
frontend/src/pages/EtatFinancier.jsx
Normal file
128
frontend/src/pages/EtatFinancier.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../stores/api';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
|
||||
export default function EtatFinancier() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/rapports/etat-financier').then(setData).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
if (!data && !error) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="État financier" sousTitre="Résultats et transactions en temps réel" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Soldes principaux */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">Soldes</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Bloc label="Solde banque" montant={data.solde_banque} couleur="blue" />
|
||||
<Bloc label="Encaisse" montant={data.encaisse} couleur="green" />
|
||||
<Bloc label="Disponible" montant={data.disponible} couleur={data.disponible >= 0 ? 'green' : 'red'} />
|
||||
<Bloc label="Résultat net" montant={data.resultat_net} couleur={data.resultat_net >= 0 ? 'green' : 'red'} />
|
||||
</div>
|
||||
{data.date_releve && (
|
||||
<p className="text-xs text-gray-400 mt-2 text-center">Dernier relevé : {data.date_releve}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Revenus */}
|
||||
<div className="bg-green-50 rounded-2xl p-4 border border-green-200">
|
||||
<h2 className="text-xs font-bold text-green-700 uppercase tracking-wider mb-2">Revenus</h2>
|
||||
<Ligne label="Collectes 7e Tradition" montant={data.revenus.collectes} />
|
||||
<Ligne label="Ventes de littérature" montant={data.revenus.ventes_litterature} />
|
||||
<div className="border-t border-green-200 mt-2 pt-2 flex justify-between">
|
||||
<span className="font-bold text-green-800 text-sm">Total revenus</span>
|
||||
<span className="font-bold text-green-800 text-sm">{data.revenus.total.toFixed(2)} $</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sorties */}
|
||||
<div className="bg-red-50 rounded-2xl p-4 border border-red-200">
|
||||
<h2 className="text-xs font-bold text-red-700 uppercase tracking-wider mb-2">Sorties</h2>
|
||||
<Ligne label="Dépenses payées" montant={data.sorties.depenses_payees} />
|
||||
<Ligne label="Engagements en attente" montant={data.sorties.engagements} />
|
||||
<Ligne label="Contributions envoyées" montant={data.sorties.contributions} />
|
||||
<div className="border-t border-red-200 mt-2 pt-2 flex justify-between">
|
||||
<span className="font-bold text-red-800 text-sm">Total sorties</span>
|
||||
<span className="font-bold text-red-800 text-sm">{data.sorties.total.toFixed(2)} $</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Réserves */}
|
||||
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-200">
|
||||
<h2 className="text-xs font-bold text-amber-700 uppercase tracking-wider mb-2">Réserves</h2>
|
||||
<Ligne label="Réserve prudente" montant={data.reserves.prudente} />
|
||||
<Ligne label="Réserve anniversaire (+20$/mois)" montant={data.reserves.anniversaire} />
|
||||
<div className="border-t border-amber-200 mt-2 pt-2 flex justify-between">
|
||||
<span className="font-bold text-amber-800 text-sm">Total réserves</span>
|
||||
<span className="font-bold text-amber-800 text-sm">{data.reserves.total.toFixed(2)} $</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dernières collectes */}
|
||||
{data.dernieres_collectes.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Dernières collectes</h2>
|
||||
{data.dernieres_collectes.map((c, i) => (
|
||||
<div key={i} className="flex justify-between py-1.5 border-b border-gray-50 last:border-0">
|
||||
<span className="text-sm text-gray-600">{c.date}</span>
|
||||
<span className="text-sm font-bold text-green-600">+{c.montant.toFixed(2)} $</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dernières dépenses */}
|
||||
{data.dernieres_depenses.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">Dernières dépenses</h2>
|
||||
{data.dernieres_depenses.map((d, i) => (
|
||||
<div key={i} className="flex justify-between py-1.5 border-b border-gray-50 last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-600 truncate">{d.quoi}</p>
|
||||
<p className="text-xs text-gray-400">{d.date} · {d.methode || '—'} · {d.paye ? '✓' : '⏳'}</p>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-red-600 whitespace-nowrap ml-2">-{d.montant.toFixed(2)} $</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bloc({ label, montant, couleur }) {
|
||||
const cls = {
|
||||
blue: 'bg-blue-50 text-blue-700',
|
||||
green: 'bg-green-50 text-green-700',
|
||||
red: 'bg-red-50 text-red-700',
|
||||
};
|
||||
return (
|
||||
<div className={`text-center p-3 rounded-xl ${cls[couleur] || cls.blue}`}>
|
||||
<p className="text-xs opacity-70">{label}</p>
|
||||
<p className="text-xl font-bold">{montant.toFixed(2)} $</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ligne({ label, montant }) {
|
||||
return (
|
||||
<div className="flex justify-between py-1">
|
||||
<span className="text-sm text-gray-600">{label}</span>
|
||||
<span className="text-sm font-medium">{montant.toFixed(2)} $</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
frontend/src/pages/Evenements.jsx
Normal file
151
frontend/src/pages/Evenements.jsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../stores/api';
|
||||
import { useStore } from '../stores/store';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
|
||||
const TYPE_STYLE = {
|
||||
anniversaire: { icon: '🎂', cls: 'bg-yellow-50 border-yellow-200', accent: 'text-yellow-700' },
|
||||
adhoc: { icon: '⭐', cls: 'bg-purple-50 border-purple-200', accent: 'text-purple-700' },
|
||||
resolution: { icon: '📜', cls: 'bg-green-50 border-green-200', accent: 'text-green-700' },
|
||||
};
|
||||
|
||||
export default function Evenements() {
|
||||
const { postes } = useStore();
|
||||
const [evenements, setEvenements] = useState([]);
|
||||
const [filtre, setFiltre] = useState('tous');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
// Form adhoc
|
||||
const [titre, setTitre] = useState('');
|
||||
const [dateEv, setDateEv] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [lieu, setLieu] = useState('');
|
||||
|
||||
const estExecutif = postes.some((p) =>
|
||||
['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p)
|
||||
);
|
||||
|
||||
const charger = async () => {
|
||||
try {
|
||||
const data = await api.get('/evenements/?jours=90');
|
||||
setEvenements(data);
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { charger(); }, []);
|
||||
|
||||
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
|
||||
|
||||
const creerEvenement = async () => {
|
||||
if (!titre.trim() || !dateEv) { setError('Titre et date requis'); return; }
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/evenements/?titre=${encodeURIComponent(titre.trim())}&date_evenement=${dateEv}&description=${encodeURIComponent(description.trim())}&lieu=${encodeURIComponent(lieu.trim())}`);
|
||||
setTitre(''); setDateEv(''); setDescription(''); setLieu('');
|
||||
setShowForm(false);
|
||||
flash('Événement créé ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const filtres = evenements.filter((e) => filtre === 'tous' || e.type === filtre);
|
||||
|
||||
// Grouper par date
|
||||
const parDate = {};
|
||||
filtres.forEach((e) => {
|
||||
if (!parDate[e.date]) parDate[e.date] = [];
|
||||
parDate[e.date].push(e);
|
||||
});
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Événements à venir" sousTitre="Anniversaires et événements spéciaux" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{[['tous', 'Tous'], ['anniversaire', '🎂 Anniv.'], ['adhoc', '⭐ Événements'], ['resolution', '📜 Résol.']].map(([k, label]) => (
|
||||
<button key={k} onClick={() => setFiltre(k)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition ${
|
||||
filtre === k ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
|
||||
}`}>{label}</button>
|
||||
))}
|
||||
{estExecutif && (
|
||||
<button onClick={() => setShowForm(!showForm)}
|
||||
className="px-3 py-1.5 rounded-full text-xs font-bold bg-purple-100 text-purple-700 whitespace-nowrap">
|
||||
+ Événement
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form adhoc */}
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<p className="font-bold text-gray-700 text-sm">Nouvel événement</p>
|
||||
<input type="text" value={titre} onChange={(e) => setTitre(e.target.value)}
|
||||
placeholder="Titre" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
<input type="date" value={dateEv} onChange={(e) => setDateEv(e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
<input type="text" value={lieu} onChange={(e) => setLieu(e.target.value)}
|
||||
placeholder="Lieu (optionnel)" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description (optionnel)" rows={2}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
|
||||
<button onClick={creerEvenement} disabled={!titre.trim() || !dateEv}
|
||||
className="w-full py-3 bg-purple-600 text-white rounded-xl font-bold disabled:opacity-40">
|
||||
Créer l'événement
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste groupée par date */}
|
||||
{filtres.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">📅</p>
|
||||
<p className="text-gray-500">Aucun événement à venir</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(parDate).map(([dateStr, evs]) => {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const label = d.toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
const aujourdhui = new Date().toISOString().slice(0, 10) === dateStr;
|
||||
return (
|
||||
<div key={dateStr}>
|
||||
<p className={`text-xs font-bold uppercase tracking-wider px-1 mb-2 ${aujourdhui ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{aujourdhui ? '● Aujourd\'hui' : label}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{evs.map((ev, i) => {
|
||||
const style = TYPE_STYLE[ev.type] || TYPE_STYLE.adhoc;
|
||||
return (
|
||||
<div key={`${dateStr}-${i}`} className={`rounded-2xl p-3 border ${style.cls}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">{style.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-bold text-sm ${style.accent}`}>{ev.titre}</p>
|
||||
{ev.detail && <p className="text-xs text-gray-500 mt-0.5">{ev.detail}</p>}
|
||||
{ev.lieu && <p className="text-xs text-gray-400 mt-0.5">📍 {ev.lieu}</p>}
|
||||
<p className="text-xs text-gray-400 mt-0.5">{ev.groupe_nom}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
325
frontend/src/pages/GererPostes.jsx
Normal file
325
frontend/src/pages/GererPostes.jsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../stores/api';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'executif', label: 'Exécutif' },
|
||||
{ value: 'service', label: 'Service' },
|
||||
{ value: 'physique', label: 'Physique' },
|
||||
];
|
||||
|
||||
export default function GererPostes() {
|
||||
const [postes, setPostes] = useState([]);
|
||||
const [taches, setTaches] = useState([]);
|
||||
const [candidatures, setCandidatures] = useState([]);
|
||||
const [tab, setTab] = useState('postes');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showTacheForm, setShowTacheForm] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Form poste
|
||||
const [nom, setNom] = useState('');
|
||||
const [categorie, setCategorie] = useState('service');
|
||||
const [description, setDescription] = useState('');
|
||||
const [prerequis, setPrerequis] = useState('');
|
||||
const [informations, setInformations] = useState('');
|
||||
|
||||
// Form tache
|
||||
const [tacheNom, setTacheNom] = useState('');
|
||||
const [tacheInfo, setTacheInfo] = useState('');
|
||||
|
||||
// Affectation tache
|
||||
const [affectPosteId, setAffectPosteId] = useState('');
|
||||
const [affectTacheId, setAffectTacheId] = useState('');
|
||||
|
||||
const charger = async () => {
|
||||
try {
|
||||
const [p, t, c] = await Promise.all([
|
||||
api.get('/postes/?inclure_abolis=true'),
|
||||
api.get('/postes/taches'),
|
||||
api.get('/postes/candidatures'),
|
||||
]);
|
||||
setPostes(p);
|
||||
setTaches(t);
|
||||
setCandidatures(c);
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
useEffect(() => { charger(); }, []);
|
||||
|
||||
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
|
||||
|
||||
const creerPoste = async () => {
|
||||
if (!nom.trim()) { setError('Nom requis'); return; }
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/postes/', {
|
||||
nom: nom.trim(), categorie,
|
||||
description: description.trim() || null,
|
||||
prerequis: prerequis.trim() || null,
|
||||
informations: informations.trim() || null,
|
||||
});
|
||||
setNom(''); setDescription(''); setPrerequis(''); setInformations(''); setShowForm(false);
|
||||
flash('Poste créé ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const abolir = async (id, nomPoste) => {
|
||||
if (!confirm(`Abolir le poste «${nomPoste}» ?\nTous les mandats actifs seront terminés.`)) return;
|
||||
try {
|
||||
const res = await api.request('DELETE', `/postes/${id}`);
|
||||
flash(`Poste aboli — ${res.mandats_termines} mandat(s) terminé(s)`);
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const reactiver = async (id) => {
|
||||
try {
|
||||
await api.post(`/postes/${id}/reactiver`);
|
||||
flash('Poste réactivé ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const supprimerPermanent = async (id, nomPoste) => {
|
||||
if (!confirm(`Supprimer DÉFINITIVEMENT le poste «${nomPoste}» ?\nCette action est irréversible.`)) return;
|
||||
try {
|
||||
await api.request('DELETE', `/postes/${id}/permanent`);
|
||||
flash('Poste supprimé définitivement');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const creerTache = async () => {
|
||||
if (!tacheNom.trim()) { setError('Nom requis'); return; }
|
||||
try {
|
||||
await api.post('/postes/taches', { nom: tacheNom.trim(), informations: tacheInfo.trim() || null });
|
||||
setTacheNom(''); setTacheInfo(''); setShowTacheForm(false);
|
||||
flash('Tâche créée ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const supprimerTache = async (id, nomT) => {
|
||||
if (!confirm(`Supprimer la tâche «${nomT}» ?`)) return;
|
||||
try {
|
||||
await api.request('DELETE', `/postes/taches/${id}`);
|
||||
flash('Tâche supprimée');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const affecterTache = async () => {
|
||||
if (!affectPosteId || !affectTacheId) { setError('Sélectionnez un poste et une tâche'); return; }
|
||||
try {
|
||||
await api.post(`/postes/${affectPosteId}/taches/${affectTacheId}`);
|
||||
setAffectTacheId('');
|
||||
flash('Tâche affectée ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const desaffecterTache = async (posteId, tacheId) => {
|
||||
try {
|
||||
await api.request('DELETE', `/postes/${posteId}/taches/${tacheId}`);
|
||||
flash('Tâche retirée');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const traiterCandidature = async (id, statut) => {
|
||||
try {
|
||||
await api.patch(`/postes/candidatures/${id}?statut=${statut}`);
|
||||
flash(statut === 'acceptee' ? 'Candidature acceptée ✓' : 'Candidature refusée');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const postesActifs = postes.filter((p) => p.actif);
|
||||
const CAT_ICON = { executif: '🔵', service: '🟣', physique: '🟢' };
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Gestion des postes" sousTitre="Postes, tâches, candidatures" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{[
|
||||
['postes', `📋 Postes (${postes.length})`],
|
||||
['taches', `🔧 Tâches (${taches.length})`],
|
||||
['candidatures', `🙋 Candid. (${candidatures.length})`],
|
||||
].map(([t, label]) => (
|
||||
<button key={t} onClick={() => { setTab(t); setError(''); }}
|
||||
className={`flex-1 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
|
||||
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
|
||||
}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Postes ── */}
|
||||
{tab === 'postes' && (
|
||||
<div className="space-y-3">
|
||||
<button onClick={() => setShowForm(!showForm)}
|
||||
className="w-full py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200 active:scale-[0.97]">
|
||||
+ Créer un poste
|
||||
</button>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<input type="text" value={nom} onChange={(e) => setNom(e.target.value)}
|
||||
placeholder="Nom du poste" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" autoFocus />
|
||||
<select value={categorie} onChange={(e) => setCategorie(e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
|
||||
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
<input type="text" value={description} onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
<input type="text" value={prerequis} onChange={(e) => setPrerequis(e.target.value)}
|
||||
placeholder="Prérequis" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
<textarea value={informations} onChange={(e) => setInformations(e.target.value)}
|
||||
placeholder="Informations complémentaires" rows={2}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
|
||||
<button onClick={creerPoste} disabled={!nom.trim()}
|
||||
className="w-full py-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">Créer</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{postes.map((p) => (
|
||||
<div key={p.id} className={`bg-white rounded-2xl p-4 shadow-sm ${!p.actif ? 'border-2 border-dashed border-gray-300' : ''}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-gray-800">{CAT_ICON[p.categorie] || '⚪'} {p.nom}</p>
|
||||
{p.description && <p className="text-xs text-gray-500 mt-1">{p.description}</p>}
|
||||
{p.informations && <p className="text-xs text-gray-400 mt-1 italic">{p.informations}</p>}
|
||||
{/* Tâches associées */}
|
||||
{p.taches && p.taches.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{p.taches.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-2 py-1">
|
||||
<span className="text-xs text-gray-600">🔧 {t.nom}</span>
|
||||
{p.actif && (
|
||||
<button onClick={() => desaffecterTache(p.id, t.id)}
|
||||
className="text-xs text-red-400 active:text-red-600">✕</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{p.actif ? (
|
||||
<button onClick={() => abolir(p.id, p.nom)}
|
||||
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">Abolir</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => reactiver(p.id)}
|
||||
className="text-xs text-green-600 font-medium px-2 py-1 rounded-lg active:bg-green-50">Réactiver</button>
|
||||
<button onClick={() => supprimerPermanent(p.id, p.nom)}
|
||||
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">Supprimer</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tâches ── */}
|
||||
{tab === 'taches' && (
|
||||
<div className="space-y-3">
|
||||
<button onClick={() => setShowTacheForm(!showTacheForm)}
|
||||
className="w-full py-3 bg-green-50 text-green-700 rounded-xl font-bold border border-green-200 active:scale-[0.97]">
|
||||
+ Créer une tâche
|
||||
</button>
|
||||
|
||||
{showTacheForm && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<input type="text" value={tacheNom} onChange={(e) => setTacheNom(e.target.value)}
|
||||
placeholder="Nom de la tâche" className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" autoFocus />
|
||||
<textarea value={tacheInfo} onChange={(e) => setTacheInfo(e.target.value)}
|
||||
placeholder="Informations" rows={2}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
|
||||
<button onClick={creerTache} disabled={!tacheNom.trim()}
|
||||
className="w-full py-3 bg-aa-blue text-white rounded-xl font-bold disabled:opacity-40">Créer</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affecter tâche → poste */}
|
||||
{taches.length > 0 && postesActifs.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Affecter une tâche à un poste</p>
|
||||
<select value={affectTacheId} onChange={(e) => setAffectTacheId(e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm">
|
||||
<option value="">— Tâche —</option>
|
||||
{taches.map((t) => <option key={t.id} value={t.id}>{t.nom}</option>)}
|
||||
</select>
|
||||
<select value={affectPosteId} onChange={(e) => setAffectPosteId(e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue text-sm">
|
||||
<option value="">— Poste —</option>
|
||||
{postesActifs.map((p) => <option key={p.id} value={p.id}>{CAT_ICON[p.categorie]} {p.nom}</option>)}
|
||||
</select>
|
||||
<button onClick={affecterTache} disabled={!affectPosteId || !affectTacheId}
|
||||
className="w-full py-2 bg-purple-600 text-white rounded-xl font-bold disabled:opacity-40 text-sm">
|
||||
🔧 Affecter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des tâches */}
|
||||
{taches.map((t) => (
|
||||
<div key={t.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-gray-800">🔧 {t.nom}</p>
|
||||
{t.informations && <p className="text-xs text-gray-500 mt-1">{t.informations}</p>}
|
||||
{t.postes.length > 0 && (
|
||||
<p className="text-xs text-purple-500 mt-1">→ {t.postes.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => supprimerTache(t.id, t.nom)}
|
||||
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Candidatures ── */}
|
||||
{tab === 'candidatures' && (
|
||||
candidatures.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">🙋</p>
|
||||
<p className="text-gray-500">Aucune candidature en attente</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{candidatures.map((c) => (
|
||||
<div key={c.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<p className="font-bold text-gray-800">{c.membre_prenom} → {c.poste_nom}</p>
|
||||
{c.motivation && <p className="text-sm text-gray-500 mt-1 italic">«{c.motivation}»</p>}
|
||||
<p className="text-xs text-gray-400 mt-1">{new Date(c.cree_le).toLocaleDateString('fr-CA')}</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button onClick={() => traiterCandidature(c.id, 'acceptee')}
|
||||
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97]">
|
||||
✓ Accepter
|
||||
</button>
|
||||
<button onClick={() => traiterCandidature(c.id, 'refusee')}
|
||||
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97]">
|
||||
✗ Refuser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
frontend/src/pages/Impliquer.jsx
Normal file
176
frontend/src/pages/Impliquer.jsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../stores/api';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
|
||||
const CAT_STYLE = {
|
||||
executif: { label: 'Exécutif', cls: 'bg-blue-100 text-blue-700' },
|
||||
service: { label: 'Service', cls: 'bg-green-100 text-green-700' },
|
||||
physique: { label: 'Physique', cls: 'bg-gray-100 text-gray-600' },
|
||||
};
|
||||
|
||||
export default function Impliquer() {
|
||||
const [postes, setPostes] = useState([]);
|
||||
const [mesCandidatures, setMesCandidatures] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [motivation, setMotivation] = useState('');
|
||||
const [postulePour, setPostulePour] = useState(null);
|
||||
|
||||
const charger = async () => {
|
||||
try {
|
||||
const [p, mc] = await Promise.all([
|
||||
api.get('/postes/disponibles'),
|
||||
api.get('/postes/candidatures/mes'),
|
||||
]);
|
||||
setPostes(p);
|
||||
setMesCandidatures(mc);
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { charger(); }, []);
|
||||
|
||||
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
|
||||
|
||||
const postuler = async (posteId) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/postes/candidatures?poste_id=${posteId}&motivation=${encodeURIComponent(motivation.trim())}`);
|
||||
setMotivation(''); setPostulePour(null);
|
||||
flash('Candidature soumise ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const retirer = async (candId) => {
|
||||
try {
|
||||
await api.post(`/postes/candidatures/${candId}/retirer`);
|
||||
flash('Candidature retirée');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const repondreNomination = async (candId, accepter) => {
|
||||
try {
|
||||
await api.post(`/postes/candidatures/${candId}/accepter-nomination?accepter=${accepter}`);
|
||||
flash(accepter ? 'Nomination acceptée ✓' : 'Nomination refusée');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const nominations = mesCandidatures.filter((c) => c.type === 'nomination' && c.acceptee_par_membre === null);
|
||||
const candidaturesActives = mesCandidatures.filter((c) => c.type === 'candidature');
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="S'impliquer" sousTitre="Postes vacants, candidatures, nominations" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
{/* Nominations en attente de réponse */}
|
||||
{nominations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-bold text-purple-600 uppercase tracking-wider px-1">Nominations à répondre</p>
|
||||
{nominations.map((n) => (
|
||||
<div key={n.id} className="bg-purple-50 rounded-2xl p-4 border border-purple-200">
|
||||
<p className="font-bold text-purple-800">Vous êtes nominé(e) pour : {n.poste_nom}</p>
|
||||
{n.nomme_par_prenom && <p className="text-xs text-purple-600 mt-1">Par {n.nomme_par_prenom}</p>}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button onClick={() => repondreNomination(n.id, true)}
|
||||
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97]">
|
||||
✓ Accepter
|
||||
</button>
|
||||
<button onClick={() => repondreNomination(n.id, false)}
|
||||
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold border border-red-200 active:scale-[0.97]">
|
||||
✗ Refuser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mes candidatures actives */}
|
||||
{candidaturesActives.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">Mes candidatures</p>
|
||||
{candidaturesActives.map((c) => (
|
||||
<div key={c.id} className="bg-white rounded-xl p-3 shadow-sm flex items-center justify-between">
|
||||
<p className="font-medium text-sm text-gray-700">{c.poste_nom}</p>
|
||||
<button onClick={() => retirer(c.id)}
|
||||
className="text-xs text-red-500 font-medium px-2 py-1 rounded-lg active:bg-red-50">
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Postes disponibles */}
|
||||
<div className="bg-blue-50 rounded-xl p-3 border border-blue-200">
|
||||
<p className="text-xs text-blue-700">
|
||||
«Nos leaders sont des serviteurs de confiance; ils ne gouvernent pas.» — 2e Tradition
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{postes.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-4xl mb-2">✅</p>
|
||||
<p className="text-gray-500">Tous les postes sont comblés</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{postes.map((p) => {
|
||||
const cat = CAT_STYLE[p.categorie] || CAT_STYLE.physique;
|
||||
const ouvert = postulePour === p.id;
|
||||
return (
|
||||
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-gray-800">{p.nom}</p>
|
||||
{p.description && <p className="text-sm text-gray-500 mt-1">{p.description}</p>}
|
||||
{p.prerequis && <p className="text-xs text-gray-400 mt-1">Prérequis : {p.prerequis}</p>}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${cat.cls}`}>{cat.label}</span>
|
||||
</div>
|
||||
|
||||
{p.deja_postule ? (
|
||||
<div className="mt-3 py-2 bg-yellow-50 rounded-xl text-center">
|
||||
<p className="text-xs text-yellow-700 font-medium">Candidature déjà soumise</p>
|
||||
</div>
|
||||
) : ouvert ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<textarea value={motivation} onChange={(e) => setMotivation(e.target.value)}
|
||||
placeholder="Pourquoi ce poste vous intéresse? (optionnel)"
|
||||
rows={2} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => postuler(p.id)}
|
||||
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97]">
|
||||
✓ Soumettre
|
||||
</button>
|
||||
<button onClick={() => { setPostulePour(null); setMotivation(''); }}
|
||||
className="flex-1 py-2 bg-gray-100 text-gray-500 rounded-xl text-sm font-bold">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setPostulePour(p.id)}
|
||||
className="w-full mt-3 py-2 bg-yellow-50 text-yellow-700 rounded-xl text-sm font-bold border border-yellow-200 active:scale-[0.97] transition">
|
||||
🙋 Proposer ma candidature
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,27 +32,28 @@ export default function Inscription() {
|
|||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input type="text" placeholder="Prénom" value={form.prenom}
|
||||
<input type="text" placeholder="Prénom *" value={form.prenom}
|
||||
onChange={(e) => update('prenom', e.target.value)}
|
||||
className="p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
<input type="text" placeholder="Nom" value={form.nom}
|
||||
<input type="text" placeholder="Nom (optionnel)" value={form.nom}
|
||||
onChange={(e) => update('nom', e.target.value)}
|
||||
className="p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
</div>
|
||||
|
||||
<input type="email" placeholder="Adresse courriel *" value={form.courriel}
|
||||
onChange={(e) => update('courriel', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
|
||||
inputMode="email" />
|
||||
|
||||
<input type="tel" placeholder="Téléphone *" value={form.telephone}
|
||||
onChange={(e) => update('telephone', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
|
||||
<input type="password" inputMode="numeric" pattern="[0-9]*" maxLength={4}
|
||||
placeholder="PIN (4 chiffres)" value={form.pin}
|
||||
onChange={(e) => update('pin', e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
className="w-full p-4 bg-gray-50 rounded-xl text-lg text-center tracking-[1em] border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
|
||||
<input type="tel" placeholder="Téléphone (optionnel)" value={form.telephone}
|
||||
onChange={(e) => update('telephone', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
|
||||
<input type="email" placeholder="Courriel (optionnel)" value={form.courriel}
|
||||
onChange={(e) => update('courriel', e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 ml-1">Date d'abstinence (optionnel)</label>
|
||||
<input type="date" value={form.date_abstinence}
|
||||
|
|
@ -63,7 +64,7 @@ export default function Inscription() {
|
|||
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
|
||||
|
||||
<button type="submit"
|
||||
disabled={loading || !form.prenom || !form.nom || form.pin.length !== 4 || !form.code_invitation}
|
||||
disabled={loading || !form.prenom || !form.courriel || !form.telephone || form.pin.length !== 4 || !form.code_invitation}
|
||||
className="w-full p-4 bg-aa-blue text-white rounded-xl text-lg font-bold disabled:opacity-40">
|
||||
{loading ? '...' : "S'inscrire"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,34 +3,64 @@ import { useStore } from '../stores/store';
|
|||
|
||||
export default function Login() {
|
||||
const { login, loading, error, groupes, loadGroupes, setPage } = useStore();
|
||||
const [prenom, setPrenom] = useState('');
|
||||
const [courriel, setCourriel] = useState(localStorage.getItem('gm_courriel') || '');
|
||||
const [pin, setPin] = useState('');
|
||||
const [groupeId, setGroupeId] = useState('');
|
||||
const [groupeId, setGroupeId] = useState(localStorage.getItem('gm_groupe_id') || '');
|
||||
const [seSouvenir, setSeSouvenir] = useState(!!localStorage.getItem('gm_courriel'));
|
||||
|
||||
useEffect(() => {
|
||||
if (groupes.length === 0) loadGroupes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupes.length === 1) setGroupeId(groupes[0].id);
|
||||
if (groupes.length === 1 && !groupeId) setGroupeId(groupes[0].id);
|
||||
}, [groupes]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!prenom || !pin || !groupeId) return;
|
||||
if (!courriel || !pin || !groupeId) return;
|
||||
try {
|
||||
await login(prenom, pin, groupeId);
|
||||
await login(courriel, pin, groupeId, seSouvenir);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const groupeActif = groupes.find((g) => g.id === groupeId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-aa-blue to-blue-900 flex flex-col items-center justify-center p-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-5xl mb-3">☮️</div>
|
||||
<h1 className="text-2xl font-bold text-white">Groupe Méditation</h1>
|
||||
<p className="text-blue-200 text-sm mt-1">District 87-16 · Région 87</p>
|
||||
</div>
|
||||
|
||||
{/* Infos du groupe */}
|
||||
{groupeActif && (
|
||||
<div className="w-full max-w-sm bg-white/10 rounded-2xl p-4 mb-4 text-blue-100 text-sm space-y-1">
|
||||
{groupeActif.adresse_local && (
|
||||
<p className="flex items-start gap-2">
|
||||
<span>📍</span>
|
||||
<span>{groupeActif.adresse_local}</span>
|
||||
</p>
|
||||
)}
|
||||
{groupeActif.jour_reunion && (
|
||||
<p className="flex items-center gap-2">
|
||||
<span>📅</span>
|
||||
<span>
|
||||
{groupeActif.jour_reunion.charAt(0).toUpperCase() + groupeActif.jour_reunion.slice(1)}
|
||||
{groupeActif.heure_reunion && ` à ${groupeActif.heure_reunion}`}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{groupeActif.format_reunion && (
|
||||
<p className="flex items-center gap-2">
|
||||
<span>📖</span>
|
||||
<span>{groupeActif.format_reunion}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="w-full max-w-sm bg-white rounded-2xl shadow-xl p-6 space-y-4">
|
||||
{groupes.length > 1 && (
|
||||
<select
|
||||
|
|
@ -46,13 +76,13 @@ export default function Login() {
|
|||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Prénom"
|
||||
value={prenom}
|
||||
onChange={(e) => setPrenom(e.target.value)}
|
||||
type="email"
|
||||
placeholder="Adresse courriel"
|
||||
value={courriel}
|
||||
onChange={(e) => setCourriel(e.target.value)}
|
||||
className="w-full p-4 bg-gray-50 rounded-xl text-lg border-0 focus:ring-2 focus:ring-aa-blue"
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
/>
|
||||
|
||||
<input
|
||||
|
|
@ -66,13 +96,20 @@ export default function Login() {
|
|||
className="w-full p-4 bg-gray-50 rounded-xl text-lg text-center tracking-[1em] border-0 focus:ring-2 focus:ring-aa-blue"
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer">
|
||||
<input type="checkbox" checked={seSouvenir}
|
||||
onChange={(e) => setSeSouvenir(e.target.checked)}
|
||||
className="rounded border-gray-300 text-aa-blue focus:ring-aa-blue" />
|
||||
Se souvenir de moi
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !prenom || pin.length !== 4 || !groupeId}
|
||||
disabled={loading || !courriel || pin.length !== 4 || !groupeId}
|
||||
className="w-full p-4 bg-aa-blue text-white rounded-xl text-lg font-bold disabled:opacity-40 active:scale-[0.97] transition"
|
||||
>
|
||||
{loading ? '...' : 'Connexion'}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useStore } from '../stores/store';
|
|||
import { api } from '../stores/api';
|
||||
|
||||
export default function Profil() {
|
||||
const { setPage } = useStore();
|
||||
const { setPage, logout } = useStore();
|
||||
const [profil, setProfil] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({});
|
||||
|
|
@ -16,6 +16,8 @@ export default function Profil() {
|
|||
api.get('/membres/moi').then((data) => {
|
||||
setProfil(data);
|
||||
setForm({
|
||||
prenom: data.prenom || '',
|
||||
nom: data.nom || '',
|
||||
telephone: data.telephone || '',
|
||||
courriel: data.courriel || '',
|
||||
date_abstinence: data.date_abstinence || '',
|
||||
|
|
@ -24,15 +26,19 @@ export default function Profil() {
|
|||
}, []);
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!form.courriel.trim()) { setError('Le courriel est obligatoire'); return; }
|
||||
if (!form.telephone.trim()) { setError('Le téléphone est obligatoire'); return; }
|
||||
try {
|
||||
const updated = await api.patch('/membres/moi', form);
|
||||
const updated = await api.patch('/membres/moi', {
|
||||
...form,
|
||||
date_abstinence: form.date_abstinence || null,
|
||||
nom: form.nom.trim() || null,
|
||||
});
|
||||
setProfil(updated);
|
||||
setEditing(false);
|
||||
setMsg('Profil mis à jour ✓');
|
||||
setTimeout(() => setMsg(''), 2000);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const changePin = async () => {
|
||||
|
|
@ -42,19 +48,10 @@ export default function Profil() {
|
|||
setShowPin(false);
|
||||
setMsg('PIN modifié ✓');
|
||||
setTimeout(() => setMsg(''), 2000);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setTimeout(() => setError(''), 3000);
|
||||
}
|
||||
} catch (e) { setError(e.message); setTimeout(() => setError(''), 3000); }
|
||||
};
|
||||
|
||||
if (!profil) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-gray-500">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!profil) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-500">Chargement...</p></div>;
|
||||
|
||||
const Field = ({ label, value }) => (
|
||||
<div className="py-3 border-b border-gray-100">
|
||||
|
|
@ -74,7 +71,6 @@ export default function Profil() {
|
|||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
{/* Infos */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-bold text-gray-700">Informations</h2>
|
||||
|
|
@ -87,21 +83,40 @@ export default function Profil() {
|
|||
<>
|
||||
<Field label="Prénom" value={profil.prenom} />
|
||||
<Field label="Nom" value={profil.nom} />
|
||||
<Field label="Courriel (identifiant)" value={profil.courriel} />
|
||||
<Field label="Téléphone" value={profil.telephone} />
|
||||
<Field label="Courriel" value={profil.courriel} />
|
||||
<Field label="Date d'abstinence" value={profil.date_abstinence} />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-3 mt-3">
|
||||
<p className="text-sm text-gray-500">Prénom : <strong>{profil.prenom}</strong></p>
|
||||
<input type="tel" placeholder="Téléphone" value={form.telephone}
|
||||
onChange={(e) => setForm({ ...form, telephone: e.target.value })}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">Prénom *</label>
|
||||
<input type="text" value={form.prenom}
|
||||
onChange={(e) => setForm({ ...form, prenom: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
<input type="email" placeholder="Courriel" value={form.courriel}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">Nom</label>
|
||||
<input type="text" value={form.nom}
|
||||
onChange={(e) => setForm({ ...form, nom: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">Courriel (identifiant) *</label>
|
||||
<input type="email" value={form.courriel}
|
||||
onChange={(e) => setForm({ ...form, courriel: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">Date d'abstinence</label>
|
||||
<label className="text-xs text-gray-400">Téléphone *</label>
|
||||
<input type="tel" value={form.telephone}
|
||||
onChange={(e) => setForm({ ...form, telephone: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400">Date d'abstinence</label>
|
||||
<input type="date" value={form.date_abstinence}
|
||||
onChange={(e) => setForm({ ...form, date_abstinence: e.target.value })}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
|
|
@ -145,6 +160,12 @@ export default function Profil() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Déconnexion */}
|
||||
<button onClick={logout}
|
||||
className="w-full p-4 bg-red-50 text-red-600 rounded-2xl font-bold border border-red-200 active:scale-[0.97] transition">
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
277
frontend/src/pages/Propositions.jsx
Normal file
277
frontend/src/pages/Propositions.jsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../stores/api';
|
||||
import { useStore } from '../stores/store';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
|
||||
const STATUT_LABELS = {
|
||||
proposee: { label: 'En attente', cls: 'bg-yellow-100 text-yellow-700' },
|
||||
secondee: { label: 'Secondée', cls: 'bg-blue-100 text-blue-700' },
|
||||
adoptee: { label: '✓ Adoptée', cls: 'bg-green-100 text-green-700' },
|
||||
rejetee: { label: '✗ Rejetée', cls: 'bg-red-100 text-red-700' },
|
||||
};
|
||||
|
||||
export default function Propositions() {
|
||||
const { postes } = useStore();
|
||||
const [propositions, setPropositions] = useState([]);
|
||||
const [reunions, setReunions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState('actives'); // actives | resolutions | nouvelle
|
||||
const [error, setError] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
// Formulaire nouvelle proposition
|
||||
const [reunionId, setReunionId] = useState('');
|
||||
const [sujet, setSujet] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [typeProposition, setTypeProposition] = useState('generale');
|
||||
|
||||
const estExecutif = postes.some((p) =>
|
||||
['RSG', 'RSG Substitut(e)', 'Trésorier(ière)', 'Secrétaire'].includes(p)
|
||||
);
|
||||
|
||||
const charger = async () => {
|
||||
try {
|
||||
const [props, reuns] = await Promise.all([
|
||||
api.get('/propositions/'),
|
||||
api.get('/presences/reunions?limite=6'),
|
||||
]);
|
||||
setPropositions(props);
|
||||
setReunions(reuns);
|
||||
if (reuns.length > 0 && !reunionId) setReunionId(reuns[0].id);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { charger(); }, []);
|
||||
|
||||
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
|
||||
|
||||
const soumettre = async () => {
|
||||
if (!sujet.trim()) { setError('Le sujet est obligatoire'); return; }
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/propositions/', {
|
||||
reunion_id: reunionId || null,
|
||||
sujet: sujet.trim(),
|
||||
description: description.trim() || null,
|
||||
type_proposition: typeProposition,
|
||||
});
|
||||
setSujet(''); setDescription(''); setTypeProposition('generale'); setTab('actives');
|
||||
flash('Proposition soumise ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const seconder = async (id) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/propositions/${id}/seconder`);
|
||||
flash('Proposition secondée ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const voter = async (id, statut) => {
|
||||
setError('');
|
||||
try {
|
||||
const payload = { statut };
|
||||
// Si adoption et réunion dispo, associer la proposition à la réunion
|
||||
if (statut === 'adoptee' && reunions.length > 0) {
|
||||
payload.reunion_id = reunions[0].id;
|
||||
}
|
||||
await api.patch(`/propositions/${id}/voter`, payload);
|
||||
flash(statut === 'adoptee' ? 'Résolution adoptée ✓' : 'Proposition rejetée');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const actives = propositions.filter((p) => ['proposee', 'secondee'].includes(p.statut));
|
||||
const resolutions = propositions.filter((p) => ['adoptee', 'rejetee'].includes(p.statut));
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Propositions" sousTitre="Soumettre, seconder, résoudre" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
{/* Onglets */}
|
||||
<div className="flex gap-2">
|
||||
{['actives', 'resolutions', 'nouvelle'].map((t) => (
|
||||
<button key={t} onClick={() => { setTab(t); setError(''); }}
|
||||
className={`flex-1 py-2 rounded-xl text-sm font-bold transition ${
|
||||
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
|
||||
}`}>
|
||||
{t === 'actives' ? `En cours (${actives.length})` : t === 'resolutions' ? `Résolutions (${resolutions.length})` : '+ Proposer'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Onglet : Propositions actives ── */}
|
||||
{tab === 'actives' && (
|
||||
actives.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">📋</p>
|
||||
<p className="text-gray-500">Aucune proposition en cours</p>
|
||||
<button onClick={() => setTab('nouvelle')} className="mt-3 text-aa-blue font-bold text-sm">
|
||||
+ Soumettre une proposition
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{actives.map((p) => {
|
||||
const st = STATUT_LABELS[p.statut];
|
||||
return (
|
||||
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
{p.type_proposition && p.type_proposition !== 'generale' && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium mr-1 ${
|
||||
p.type_proposition === 'nomination' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}>{p.type_proposition === 'nomination' ? '🙋 Nomination' : '📮 Contribution'}</span>
|
||||
)}
|
||||
<p className="font-bold text-gray-800 mt-1">{p.sujet}</p>
|
||||
{p.description && <p className="text-sm text-gray-500 mt-1">{p.description}</p>}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-400 space-y-1">
|
||||
<p>Proposée par <span className="font-medium text-gray-600">{p.proposeur_prenom}</span></p>
|
||||
{p.secondeur_prenom && <p>Secondée par <span className="font-medium text-gray-600">{p.secondeur_prenom}</span></p>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
{p.statut === 'proposee' && (
|
||||
<button onClick={() => seconder(p.id)}
|
||||
className="flex-1 py-2 bg-blue-50 text-blue-700 rounded-xl text-sm font-bold active:scale-[0.97] transition border border-blue-200">
|
||||
✋ Seconder
|
||||
</button>
|
||||
)}
|
||||
{p.statut === 'secondee' && estExecutif && (
|
||||
<>
|
||||
<button onClick={() => voter(p.id, 'adoptee')}
|
||||
className="flex-1 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold active:scale-[0.97] transition border border-green-200">
|
||||
✓ Adopter
|
||||
</button>
|
||||
<button onClick={() => voter(p.id, 'rejetee')}
|
||||
className="flex-1 py-2 bg-red-50 text-red-700 rounded-xl text-sm font-bold active:scale-[0.97] transition border border-red-200">
|
||||
✗ Rejeter
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── Onglet : Registre des résolutions ── */}
|
||||
{tab === 'resolutions' && (
|
||||
resolutions.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">📜</p>
|
||||
<p className="text-gray-500">Aucune résolution encore</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{resolutions.map((p) => {
|
||||
const st = STATUT_LABELS[p.statut];
|
||||
return (
|
||||
<div key={p.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
{p.numero_resolution && (
|
||||
<p className="text-xs font-mono text-aa-blue font-bold">{p.numero_resolution}</p>
|
||||
)}
|
||||
{p.type_proposition && p.type_proposition !== 'generale' && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
p.type_proposition === 'nomination' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}>{p.type_proposition === 'nomination' ? '🙋 Nomination' : '📮 Contribution'}</span>
|
||||
)}
|
||||
<p className="font-bold text-gray-800">{p.sujet}</p>
|
||||
{p.description && <p className="text-sm text-gray-500 mt-1">{p.description}</p>}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
<p>{p.proposeur_prenom} → {p.secondeur_prenom || '?'} · {p.date_vote}</p>
|
||||
{p.note && <p className="italic mt-1">{p.note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── Onglet : Nouvelle proposition ── */}
|
||||
{tab === 'nouvelle' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-2">Type de proposition</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
['generale', '📋 Générale'],
|
||||
['contribution', '📮 Contribution'],
|
||||
['nomination', '🙋 Nomination'],
|
||||
].map(([val, label]) => (
|
||||
<button key={val} type="button" onClick={() => setTypeProposition(val)}
|
||||
className={`p-2 rounded-xl text-xs font-bold border-2 ${
|
||||
typeProposition === val ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
|
||||
}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Sujet de la proposition</label>
|
||||
<input type="text" value={sujet} onChange={(e) => setSujet(e.target.value)}
|
||||
placeholder={
|
||||
typeProposition === 'contribution' ? 'Ex: Envoi de 200$ au district 87-16' :
|
||||
typeProposition === 'nomination' ? 'Ex: Nomination de Marie au poste de secrétaire' :
|
||||
'Ex: Augmenter la contribution au district à 35%'
|
||||
}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue"
|
||||
autoFocus />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Détails (optionnel)</label>
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Contexte, justification..."
|
||||
rows={3} className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none" />
|
||||
</div>
|
||||
|
||||
{reunions.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Réunion d'affaires (optionnel)</label>
|
||||
<select value={reunionId} onChange={(e) => setReunionId(e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue">
|
||||
<option value="">— Aucune (à associer lors du vote) —</option>
|
||||
{reunions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{new Date(r.date_reunion).toLocaleDateString('fr-CA', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={soumettre} disabled={!sujet.trim()}
|
||||
className="w-full p-5 bg-aa-blue text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
|
||||
📋 Soumettre la proposition
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,30 +9,33 @@ const MOIS_FR = [
|
|||
|
||||
export default function Rapports() {
|
||||
const [disponibles, setDisponibles] = useState([]);
|
||||
const [anneesResolutions, setAnneesResolutions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/rapports/disponibles').then((data) => {
|
||||
setDisponibles(data);
|
||||
Promise.all([
|
||||
api.get('/rapports/disponibles'),
|
||||
api.get('/rapports/resolutions/annees'),
|
||||
]).then(([dispo, annees]) => {
|
||||
setDisponibles(dispo);
|
||||
setAnneesResolutions(annees);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const telecharger = async (type, annee, mois) => {
|
||||
const key = `${type}-${annee}-${mois}`;
|
||||
const telecharger = async (path, key, filename) => {
|
||||
setGenerating(key);
|
||||
try {
|
||||
const token = localStorage.getItem('gm_token');
|
||||
const url = `/api/rapports/${type}/${annee}/${mois}`;
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(`/api/rapports/${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Erreur de génération');
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `rapport_${type}_${annee}_${mois.toString().padStart(2, '0')}.pdf`;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) {
|
||||
|
|
@ -41,21 +44,25 @@ export default function Rapports() {
|
|||
setGenerating(null);
|
||||
};
|
||||
|
||||
const vide = disponibles.length === 0 && anneesResolutions.length === 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Rapports PDF" sousTitre="C1 — Finances, réunions" />
|
||||
<PageHeader titre="Rapports PDF" sousTitre="C1 — Finances, réunions, résolutions" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{loading ? (
|
||||
<p className="text-center text-gray-400 py-8">Chargement...</p>
|
||||
) : disponibles.length === 0 ? (
|
||||
) : vide ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">📄</p>
|
||||
<p className="text-gray-500">Aucun rapport disponible</p>
|
||||
<p className="text-gray-400 text-sm mt-1">Les rapports apparaîtront après les premières saisies</p>
|
||||
</div>
|
||||
) : (
|
||||
disponibles.map((m) => (
|
||||
<>
|
||||
{/* Rapports mensuels */}
|
||||
{disponibles.map((m) => (
|
||||
<div key={`${m.annee}-${m.mois}`} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h3 className="font-bold text-gray-800 mb-3">
|
||||
{MOIS_FR[m.mois]} {m.annee}
|
||||
|
|
@ -63,7 +70,7 @@ export default function Rapports() {
|
|||
<div className="grid gap-2">
|
||||
{m.finances && (
|
||||
<button
|
||||
onClick={() => telecharger('finances', m.annee, m.mois)}
|
||||
onClick={() => telecharger(`finances/${m.annee}/${m.mois}`, `finances-${m.annee}-${m.mois}`, `rapport_finances_${m.annee}_${String(m.mois).padStart(2,'0')}.pdf`)}
|
||||
disabled={generating === `finances-${m.annee}-${m.mois}`}
|
||||
className="flex items-center gap-3 p-3 bg-green-50 rounded-xl text-left active:scale-[0.98] transition border border-green-200"
|
||||
>
|
||||
|
|
@ -72,14 +79,12 @@ export default function Rapports() {
|
|||
<p className="font-bold text-green-800 text-sm">Rapport financier</p>
|
||||
<p className="text-xs text-green-600">Collectes, dépenses, solde</p>
|
||||
</div>
|
||||
<span className="text-green-400">
|
||||
{generating === `finances-${m.annee}-${m.mois}` ? '⏳' : '↓'}
|
||||
</span>
|
||||
<span className="text-green-400">{generating === `finances-${m.annee}-${m.mois}` ? '⏳' : '↓'}</span>
|
||||
</button>
|
||||
)}
|
||||
{m.reunions && (
|
||||
<button
|
||||
onClick={() => telecharger('reunions', m.annee, m.mois)}
|
||||
onClick={() => telecharger(`reunions/${m.annee}/${m.mois}`, `reunions-${m.annee}-${m.mois}`, `rapport_reunions_${m.annee}_${String(m.mois).padStart(2,'0')}.pdf`)}
|
||||
disabled={generating === `reunions-${m.annee}-${m.mois}`}
|
||||
className="flex items-center gap-3 p-3 bg-purple-50 rounded-xl text-left active:scale-[0.98] transition border border-purple-200"
|
||||
>
|
||||
|
|
@ -88,14 +93,36 @@ export default function Rapports() {
|
|||
<p className="font-bold text-purple-800 text-sm">Résumé des réunions</p>
|
||||
<p className="text-xs text-purple-600">Thèmes, annonces, animateurs</p>
|
||||
</div>
|
||||
<span className="text-purple-400">
|
||||
{generating === `reunions-${m.annee}-${m.mois}` ? '⏳' : '↓'}
|
||||
</span>
|
||||
<span className="text-purple-400">{generating === `reunions-${m.annee}-${m.mois}` ? '⏳' : '↓'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
|
||||
{/* Registre des résolutions */}
|
||||
{anneesResolutions.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h3 className="font-bold text-gray-800 mb-3">📜 Registre des résolutions</h3>
|
||||
<div className="grid gap-2">
|
||||
{anneesResolutions.map((a) => (
|
||||
<button key={a.annee}
|
||||
onClick={() => telecharger(`resolutions/${a.annee}`, `res-${a.annee}`, `registre_resolutions_${a.annee}.pdf`)}
|
||||
disabled={generating === `res-${a.annee}`}
|
||||
className="flex items-center gap-3 p-3 bg-orange-50 rounded-xl text-left active:scale-[0.98] transition border border-orange-200"
|
||||
>
|
||||
<span className="text-xl">📜</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-orange-800 text-sm">Résolutions {a.annee}</p>
|
||||
<p className="text-xs text-orange-600">Propositions adoptées et rejetées</p>
|
||||
</div>
|
||||
<span className="text-orange-400">{generating === `res-${a.annee}` ? '⏳' : '↓'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import SuccessCheck from '../components/SuccessCheck';
|
|||
export default function RedigerPV() {
|
||||
const [reunions, setReunions] = useState([]);
|
||||
const [pvList, setPvList] = useState([]);
|
||||
const [propositions, setPropositions] = useState([]);
|
||||
const [reunionId, setReunionId] = useState('');
|
||||
const [contenu, setContenu] = useState('');
|
||||
const [decisions, setDecisions] = useState([]);
|
||||
|
|
@ -18,13 +19,34 @@ export default function RedigerPV() {
|
|||
Promise.all([
|
||||
api.get('/presences/reunions'),
|
||||
api.get('/pv/'),
|
||||
]).then(([r, p]) => {
|
||||
api.get('/propositions/?statut=secondee'),
|
||||
]).then(([r, p, props]) => {
|
||||
setReunions(r);
|
||||
setPvList(p);
|
||||
setPropositions(props);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Auto-renseigner les décisions depuis les propositions secondées
|
||||
const chargerPropositions = () => {
|
||||
if (propositions.length === 0) return;
|
||||
const decisionsAuto = propositions.map((p) => ({
|
||||
sujet: p.sujet,
|
||||
decision: p.description || '',
|
||||
proposeur: p.proposeur_prenom,
|
||||
secondeur: p.secondeur_prenom || '',
|
||||
statut: 'en_attente',
|
||||
proposition_id: p.id,
|
||||
}));
|
||||
setDecisions((prev) => {
|
||||
// Ne pas dupliquer les propositions déjà ajoutées
|
||||
const existants = new Set(prev.map((d) => d.proposition_id).filter(Boolean));
|
||||
const nouvelles = decisionsAuto.filter((d) => !existants.has(d.proposition_id));
|
||||
return [...prev, ...nouvelles];
|
||||
});
|
||||
};
|
||||
|
||||
const ajouterDecision = () => {
|
||||
setDecisions([...decisions, { sujet: '', decision: '', proposeur: '', secondeur: '', statut: 'en_attente' }]);
|
||||
};
|
||||
|
|
@ -40,7 +62,7 @@ export default function RedigerPV() {
|
|||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reunionId) { setError('Sélectionnez une réunion d\'affaires'); return; }
|
||||
if (!reunionId) { setError("Sélectionnez une réunion d'affaires"); return; }
|
||||
if (!contenu.trim()) { setError('Le contenu du PV est obligatoire'); return; }
|
||||
setError('');
|
||||
try {
|
||||
|
|
@ -141,25 +163,42 @@ export default function RedigerPV() {
|
|||
|
||||
<label className="text-xs text-gray-400 block mb-1">Contenu du PV</label>
|
||||
<textarea value={contenu} onChange={(e) => setContenu(e.target.value)} rows={8}
|
||||
placeholder="Ouverture de la réunion à 19h00... Lecture du PV précédent... Rapport du trésorier... Rapport du RSG..."
|
||||
placeholder={"Ouverture de la réunion à 19h00...\n\nLecture du PV précédent...\n\nRapport du trésorier...\n\nRapport du RSG..."}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue resize-none text-sm" />
|
||||
</div>
|
||||
|
||||
{/* Décisions */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-bold text-gray-700">Décisions</h2>
|
||||
<h2 className="font-bold text-gray-700">Affaires nouvelles / Décisions</h2>
|
||||
<div className="flex gap-2">
|
||||
{propositions.length > 0 && (
|
||||
<button onClick={chargerPropositions}
|
||||
className="text-purple-600 text-xs font-medium bg-purple-50 px-2 py-1 rounded-lg active:opacity-60">
|
||||
📋 Importer propositions ({propositions.length})
|
||||
</button>
|
||||
)}
|
||||
<button onClick={ajouterDecision}
|
||||
className="text-aa-blue text-sm font-medium active:opacity-60">+ Ajouter</button>
|
||||
</div>
|
||||
</div>
|
||||
{decisions.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Aucune décision (cliquez + Ajouter)</p>
|
||||
<div className="text-gray-400 text-sm">
|
||||
<p>Aucune décision.</p>
|
||||
{propositions.length > 0 && (
|
||||
<p className="mt-1 text-purple-500 text-xs">
|
||||
{propositions.length} proposition{propositions.length > 1 ? 's' : ''} secondée{propositions.length > 1 ? 's' : ''} à traiter — cliquez « Importer propositions ».
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{decisions.map((d, i) => (
|
||||
<div key={i} className="bg-gray-50 rounded-xl p-3 space-y-2">
|
||||
<div key={i} className={`rounded-xl p-3 space-y-2 ${d.proposition_id ? 'bg-purple-50' : 'bg-gray-50'}`}>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs text-gray-400">Décision {i + 1}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{d.proposition_id ? '📋 Proposition importée' : `Décision ${i + 1}`}
|
||||
</span>
|
||||
<button onClick={() => supprimerDecision(i)} className="text-red-400 text-xs">Supprimer</button>
|
||||
</div>
|
||||
<input type="text" placeholder="Sujet" value={d.sujet}
|
||||
|
|
|
|||
372
frontend/src/pages/Tresorerie.jsx
Normal file
372
frontend/src/pages/Tresorerie.jsx
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { api } from '../stores/api';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
|
||||
const STATUT_COL = {
|
||||
en_attente: { label: 'À confirmer', cls: 'bg-yellow-100 text-yellow-700' },
|
||||
confirmee: { label: 'Confirmée', cls: 'bg-blue-100 text-blue-700' },
|
||||
recu: { label: '✓ Reçue', cls: 'bg-green-100 text-green-700' },
|
||||
};
|
||||
|
||||
export default function Tresorerie() {
|
||||
const [soldes, setSoldes] = useState(null);
|
||||
const [collectes, setCollectes] = useState([]);
|
||||
const [operations, setOperations] = useState([]);
|
||||
const [registre, setRegistre] = useState([]);
|
||||
const [tab, setTab] = useState('soldes');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Formulaires
|
||||
const [depotMontant, setDepotMontant] = useState('');
|
||||
const [depotDate, setDepotDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [depotNote, setDepotNote] = useState('');
|
||||
const [depotType, setDepotType] = useState('depot'); // depot | retrait | releve
|
||||
|
||||
const moisActuel = new Date().getMonth() + 1;
|
||||
const anneeActuelle = new Date().getFullYear();
|
||||
|
||||
const charger = async () => {
|
||||
try {
|
||||
const [s, c, o, r] = await Promise.all([
|
||||
api.get('/collectes/soldes'),
|
||||
api.get('/collectes/?limite=20'),
|
||||
api.get('/banque/'),
|
||||
api.get(`/collectes/registre?mois=${moisActuel}&annee=${anneeActuelle}`),
|
||||
]);
|
||||
setSoldes(s);
|
||||
setCollectes(c);
|
||||
setOperations(o);
|
||||
setRegistre(r);
|
||||
} catch (e) { setError(e.message); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { charger(); }, []);
|
||||
|
||||
const flash = (m) => { setMsg(m); setTimeout(() => setMsg(''), 2500); };
|
||||
|
||||
const accuserReception = async (id) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/collectes/${id}/reception`);
|
||||
flash('Réception confirmée ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const recevoirVente = async (id) => {
|
||||
setError('');
|
||||
try {
|
||||
await api.post(`/ventes-litterature/${id}/recevoir`);
|
||||
flash('Vente reçue ✓');
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const enregistrerOperation = async () => {
|
||||
if (!depotMontant || parseFloat(depotMontant) <= 0) { setError('Montant invalide'); return; }
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/banque/', {
|
||||
type: depotType,
|
||||
montant: parseFloat(depotMontant),
|
||||
date_operation: depotDate,
|
||||
note: depotNote.trim() || null,
|
||||
});
|
||||
setDepotMontant(''); setDepotNote(''); setTab('soldes');
|
||||
const labels = { depot: 'Dépôt', retrait: 'Retrait', releve: 'Relevé' };
|
||||
flash(`${labels[depotType]} enregistré ✓`);
|
||||
await charger();
|
||||
} catch (e) { setError(e.message); }
|
||||
};
|
||||
|
||||
const aRecevoir = collectes.filter((c) => c.statut !== 'recu');
|
||||
|
||||
const MOIS_NOMS = ['', 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">Chargement...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<PageHeader titre="Trésorerie" sousTitre="Soldes, réceptions, registre" />
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium">{msg}</div>}
|
||||
{error && <div className="bg-red-50 text-red-600 p-3 rounded-xl text-center text-sm">{error}</div>}
|
||||
|
||||
{/* Onglets */}
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{[
|
||||
['soldes', '💰 Soldes'],
|
||||
['receptions', `📥 À recevoir (${aRecevoir.length + (soldes?.ventes_non_remises_details?.length || 0)})`],
|
||||
['registre', '📒 Registre'],
|
||||
['banque', '🏦 Opération'],
|
||||
].map(([t, label]) => (
|
||||
<button key={t} onClick={() => { setTab(t); setError(''); }}
|
||||
className={`flex-1 py-2 rounded-xl text-xs font-bold transition whitespace-nowrap px-2 ${
|
||||
tab === t ? 'bg-aa-blue text-white' : 'bg-white text-gray-500'
|
||||
}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Soldes ── */}
|
||||
{tab === 'soldes' && soldes && (
|
||||
<div className="space-y-3">
|
||||
{/* Solde bancaire + disponible */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-3 bg-blue-50 rounded-xl">
|
||||
<p className="text-xs text-blue-600">Solde banque</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{soldes.solde_banque.toFixed(2)} $</p>
|
||||
{soldes.date_releve && <p className="text-xs text-blue-400 mt-1">Relevé : {soldes.date_releve}</p>}
|
||||
</div>
|
||||
<div className="text-center p-3 bg-green-50 rounded-xl">
|
||||
<p className="text-xs text-green-600">Disponible</p>
|
||||
<p className={`text-2xl font-bold ${soldes.disponible >= 0 ? 'text-green-700' : 'text-red-600'}`}>
|
||||
{soldes.disponible.toFixed(2)} $
|
||||
</p>
|
||||
<p className="text-xs text-green-500 mt-1">Après réserves</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Encaisse */}
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Encaisse (cash en main)</p>
|
||||
<p className="text-xl font-bold text-gray-700">{soldes.encaisse.toFixed(2)} $</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Réserves */}
|
||||
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-200">
|
||||
<p className="font-bold text-amber-700 text-sm mb-2">Réserves ({soldes.total_reserves.toFixed(2)} $)</p>
|
||||
<div className="space-y-1 text-xs text-amber-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Réserve prudente</span>
|
||||
<span className="font-bold">{soldes.reserve_prudente.toFixed(2)} $</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Réserve anniversaire (+20$/mois)</span>
|
||||
<span className="font-bold">{soldes.reserve_anniversaire.toFixed(2)} $</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Engagements */}
|
||||
{soldes.engagements_non_debites > 0 && (
|
||||
<div className="bg-orange-50 rounded-2xl p-4 border border-orange-200">
|
||||
<p className="font-bold text-orange-700 text-sm">
|
||||
Engagements non débités : {soldes.engagements_non_debites.toFixed(2)} $
|
||||
</p>
|
||||
<p className="text-xs text-orange-500 mt-1">Dépenses approuvées en attente de débit</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Argent en circulation */}
|
||||
{soldes.en_possession > 0 && (
|
||||
<div className="bg-yellow-50 rounded-2xl p-4 border border-yellow-200">
|
||||
<p className="font-bold text-yellow-700 text-sm">⚠️ En circulation : {soldes.en_possession.toFixed(2)} $</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{soldes.en_possession_details.map((d, i) => (
|
||||
<p key={i} className="text-xs text-yellow-600">
|
||||
{d.date} — {d.montant.toFixed(2)} $ chez <span className="font-bold">{d.detenteur}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Réceptions (collectes + ventes littérature) ── */}
|
||||
{tab === 'receptions' && (
|
||||
<div className="space-y-4">
|
||||
{/* Collectes à recevoir */}
|
||||
{aRecevoir.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 px-1">Collectes</h3>
|
||||
<div className="space-y-3">
|
||||
{aRecevoir.map((c) => {
|
||||
const st = STATUT_COL[c.statut] || STATUT_COL.en_attente;
|
||||
return (
|
||||
<div key={c.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-gray-800 text-lg">{parseFloat(c.montant).toFixed(2)} $</p>
|
||||
<p className="text-xs text-gray-400">{c.date_collecte} · Chez {c.detenteur_prenom}</p>
|
||||
<p className="text-xs text-gray-400">Saisi par {c.saisi_par_prenom}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
<button onClick={() => accuserReception(c.id)}
|
||||
className="w-full mt-3 py-2 bg-green-50 text-green-700 rounded-xl text-sm font-bold border border-green-200 active:scale-[0.97] transition">
|
||||
✓ Accuser réception
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ventes de littérature à recevoir */}
|
||||
{soldes?.ventes_non_remises_details?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 px-1">
|
||||
Ventes de littérature ({soldes.ventes_non_remises.toFixed(2)} $)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{soldes.ventes_non_remises_details.map((v) => (
|
||||
<div key={v.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-gray-800">{parseFloat(v.montant).toFixed(2)} $</p>
|
||||
<p className="text-xs text-gray-500">{v.titre}</p>
|
||||
<p className="text-xs text-gray-400">{v.date} · Vendu par {v.vendu_par}</p>
|
||||
</div>
|
||||
<button onClick={() => recevoirVente(v.id)}
|
||||
className="w-full mt-3 py-2 bg-purple-50 text-purple-700 rounded-xl text-sm font-bold border border-purple-200 active:scale-[0.97] transition">
|
||||
✓ Confirmer la remise
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aRecevoir.length === 0 && (!soldes?.ventes_non_remises_details || soldes.ventes_non_remises_details.length === 0) && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">✅</p>
|
||||
<p className="text-gray-500">Tout est reçu</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Registre des transactions ── */}
|
||||
{tab === 'registre' && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-white rounded-2xl p-3 shadow-sm">
|
||||
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">
|
||||
Registre — {MOIS_NOMS[moisActuel]} {anneeActuelle}
|
||||
</p>
|
||||
</div>
|
||||
{registre.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-4xl mb-2">📒</p>
|
||||
<p className="text-gray-500">Aucune transaction ce mois-ci</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{registre.map((t, i) => (
|
||||
<div key={i} className="bg-white rounded-xl p-3 shadow-sm flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-gray-800 truncate">{t.description}</p>
|
||||
<p className="text-xs text-gray-400">{t.date} · {t.par}</p>
|
||||
</div>
|
||||
{t.entree > 0 && <p className="font-bold text-sm text-green-600 whitespace-nowrap">+{t.entree.toFixed(2)} $</p>}
|
||||
{t.sortie > 0 && <p className="font-bold text-sm text-red-600 whitespace-nowrap">-{t.sortie.toFixed(2)} $</p>}
|
||||
</div>
|
||||
))}
|
||||
<div className="bg-gray-50 rounded-xl p-3 flex justify-between text-sm font-bold">
|
||||
<span className="text-gray-600">Total du mois</span>
|
||||
<div className="flex gap-4">
|
||||
<span className="text-green-600">+{registre.reduce((s, t) => s + t.entree, 0).toFixed(2)} $</span>
|
||||
<span className="text-red-600">-{registre.reduce((s, t) => s + t.sortie, 0).toFixed(2)} $</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Opération bancaire ── */}
|
||||
{tab === 'banque' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-2">Type d'opération</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
['depot', '🏦 Dépôt'],
|
||||
['retrait', '📤 Retrait'],
|
||||
['releve', '📋 Relevé'],
|
||||
].map(([val, label]) => (
|
||||
<button key={val} onClick={() => setDepotType(val)}
|
||||
className={`p-3 rounded-xl text-xs font-bold border-2 ${
|
||||
depotType === val ? 'bg-aa-blue text-white border-aa-blue' : 'bg-gray-50 text-gray-600 border-gray-200'
|
||||
}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{depotType === 'releve' && (
|
||||
<p className="text-xs text-blue-600 mt-2 bg-blue-50 p-2 rounded-lg">
|
||||
Le relevé enregistre le solde réel de votre compte bancaire tel qu'indiqué sur le relevé mensuel.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
{depotType === 'releve' ? 'Solde du relevé' : 'Montant'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-2xl text-gray-400">$</span>
|
||||
<input type="number" inputMode="decimal" step="0.01" min="0" placeholder="0.00"
|
||||
value={depotMontant} onChange={(e) => setDepotMontant(e.target.value)}
|
||||
className="w-full p-4 pl-10 bg-gray-50 rounded-xl text-3xl font-bold text-center border-0 focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Date</label>
|
||||
<input type="date" value={depotDate} onChange={(e) => setDepotDate(e.target.value)}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<label className="text-xs text-gray-400 block mb-1">Note (optionnel)</label>
|
||||
<input type="text" value={depotNote} onChange={(e) => setDepotNote(e.target.value)}
|
||||
placeholder={depotType === 'releve' ? 'Ex: Relevé avril 2026' : 'Ex: Dépôt Caisse Desjardins'}
|
||||
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||
</div>
|
||||
|
||||
<button onClick={enregistrerOperation} disabled={!depotMontant || parseFloat(depotMontant) < 0}
|
||||
className="w-full p-5 bg-blue-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
|
||||
{depotType === 'depot' ? '🏦 Enregistrer le dépôt' :
|
||||
depotType === 'retrait' ? '📤 Enregistrer le retrait' :
|
||||
'📋 Enregistrer le relevé'}
|
||||
</button>
|
||||
|
||||
{/* Historique opérations */}
|
||||
{operations.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<h3 className="font-bold text-gray-700 text-sm mb-2">Dernières opérations</h3>
|
||||
{operations.slice(0, 8).map((op) => (
|
||||
<div key={op.id} className="flex justify-between py-2 border-b border-gray-50 last:border-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{op.type === 'depot' ? '🏦 Dépôt' : op.type === 'retrait' ? '📤 Retrait' : '📋 Relevé'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{op.date_operation} · {op.effectue_par_prenom}</p>
|
||||
{op.note && <p className="text-xs text-gray-400 italic">{op.note}</p>}
|
||||
</div>
|
||||
<p className={`font-bold text-sm ${
|
||||
op.type === 'depot' ? 'text-green-600' : op.type === 'retrait' ? 'text-red-600' : 'text-blue-600'
|
||||
}`}>
|
||||
{op.type === 'releve' ? '=' : op.type === 'depot' ? '+' : '-'}{op.montant.toFixed(2)} $
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,7 +33,15 @@ class ApiClient {
|
|||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Erreur serveur' }));
|
||||
throw new Error(err.detail || 'Erreur');
|
||||
const detail = err.detail;
|
||||
let message;
|
||||
if (Array.isArray(detail)) {
|
||||
// Erreur de validation Pydantic (422) — extraire le premier message lisible
|
||||
message = detail.map(d => d.msg || String(d)).join(', ');
|
||||
} else {
|
||||
message = detail || 'Erreur';
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const useStore = create((set, get) => ({
|
|||
|
||||
// Accueil
|
||||
postes: [],
|
||||
groupe: null,
|
||||
modulesConsultation: [],
|
||||
modulesSaisie: [],
|
||||
modulesGestion: [],
|
||||
|
|
@ -17,9 +18,12 @@ export const useStore = create((set, get) => ({
|
|||
// UI
|
||||
loading: false,
|
||||
error: null,
|
||||
page: 'login', // login | inscription | accueil | profil
|
||||
page: localStorage.getItem('gm_page') || 'login',
|
||||
|
||||
setPage: (page) => set({ page, error: null }),
|
||||
setPage: (page) => {
|
||||
localStorage.setItem('gm_page', page);
|
||||
set({ page, error: null });
|
||||
},
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
loadGroupes: async () => {
|
||||
|
|
@ -31,22 +35,27 @@ export const useStore = create((set, get) => ({
|
|||
}
|
||||
},
|
||||
|
||||
login: async (prenom, pin, groupeId) => {
|
||||
login: async (courriel, pin, groupeId, seSouvenir = false) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await api.post('/auth/connexion', {
|
||||
prenom, pin, groupe_id: groupeId,
|
||||
courriel, pin, groupe_id: groupeId, se_souvenir: seSouvenir,
|
||||
});
|
||||
api.setToken(data.access_token);
|
||||
localStorage.setItem('gm_groupe_id', groupeId);
|
||||
if (seSouvenir) {
|
||||
localStorage.setItem('gm_courriel', courriel);
|
||||
} else {
|
||||
localStorage.removeItem('gm_courriel');
|
||||
}
|
||||
set({
|
||||
token: data.access_token,
|
||||
membre: data.membre,
|
||||
groupeId,
|
||||
loading: false,
|
||||
page: 'accueil',
|
||||
});
|
||||
await get().loadAccueil();
|
||||
get().setPage('accueil');
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message });
|
||||
throw e;
|
||||
|
|
@ -56,16 +65,21 @@ export const useStore = create((set, get) => ({
|
|||
inscription: async (data) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await api.post('/auth/inscription', data);
|
||||
const payload = {
|
||||
...data,
|
||||
nom: data.nom || null,
|
||||
date_abstinence: data.date_abstinence || null,
|
||||
};
|
||||
const res = await api.post('/auth/inscription', payload);
|
||||
api.setToken(res.access_token);
|
||||
localStorage.setItem('gm_groupe_id', data.groupe_id || '');
|
||||
set({
|
||||
token: res.access_token,
|
||||
membre: res.membre,
|
||||
loading: false,
|
||||
page: 'accueil',
|
||||
});
|
||||
await get().loadAccueil();
|
||||
get().setPage('accueil');
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e.message });
|
||||
throw e;
|
||||
|
|
@ -77,6 +91,7 @@ export const useStore = create((set, get) => ({
|
|||
const data = await api.get('/accueil/');
|
||||
set({
|
||||
postes: data.postes,
|
||||
groupe: data.groupe,
|
||||
modulesConsultation: data.modules_consultation,
|
||||
modulesSaisie: data.modules_saisie,
|
||||
modulesGestion: data.modules_gestion,
|
||||
|
|
@ -90,9 +105,11 @@ export const useStore = create((set, get) => ({
|
|||
logout: () => {
|
||||
api.setToken(null);
|
||||
localStorage.removeItem('gm_groupe_id');
|
||||
localStorage.removeItem('gm_page');
|
||||
set({
|
||||
token: null,
|
||||
membre: null,
|
||||
groupe: null,
|
||||
groupeId: null,
|
||||
postes: [],
|
||||
modulesConsultation: [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue