premier commit
This commit is contained in:
commit
ca38a5082c
119 changed files with 9622 additions and 0 deletions
67
README.md
Normal file
67
README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Groupe Méditation — Application PWA
|
||||||
|
|
||||||
|
**District 87-16 · Région 87 · Alcooliques anonymes**
|
||||||
|
|
||||||
|
Application mobile-first pour la gestion des groupes AA. 1 module = 1 tâche. 27 modules.
|
||||||
|
|
||||||
|
## Phases 0 à 9 — Complétées
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
- **M3** Rapport RSG — template 3 sections
|
||||||
|
- **M4** Envoi contributions — répartition auto district/région/BSG/intergroupe
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| 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 | Apache (statiques) + NGINX (reverse proxy HTTPS) |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*«Nos leaders sont des serviteurs de confiance ; ils ne gouvernent pas.» — 2e Tradition*
|
||||||
7
backend/.env.example
Normal file
7
backend/.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Groupe Méditation — Configuration
|
||||||
|
# Copier vers .env et ajuster les valeurs
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql+asyncpg://grpmed:CHANGEZ_MOI@localhost:5432/groupe_meditation
|
||||||
|
JWT_SECRET=CHANGEZ_MOI_64_caracteres_aleatoires_generes_par_openssl_rand_base64_48
|
||||||
|
CORS_ORIGINS=["https://groupe-meditation.chezlepro.ca"]
|
||||||
|
INVITATION_EXPIRE_HOURS=48
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
23
backend/app/core/config.py
Normal file
23
backend/app/core/config.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Configuration de l'application."""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://grpmed:grpmed2026@localhost: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",
|
||||||
|
]
|
||||||
|
INVITATION_EXPIRE_HOURS: int = 48
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
21
backend/app/core/database.py
Normal file
21
backend/app/core/database.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Connexion PostgreSQL async."""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=False, pool_size=5, max_overflow=10)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
68
backend/app/core/security.py
Normal file
68
backend/app/core/security.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Authentification JWT + hachage PIN."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_pin(pin: str) -> str:
|
||||||
|
return bcrypt.hashpw(pin.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
payload = {"sub": str(membre_id), "grp": str(groupe_id), "exp": expire}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token invalide ou expiré")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_membre(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.models.membre import Membre
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
membre_id = payload.get("sub")
|
||||||
|
if not membre_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Token invalide")
|
||||||
|
result = await db.execute(select(Membre).where(Membre.id == membre_id, Membre.actif == True))
|
||||||
|
membre = result.scalar_one_or_none()
|
||||||
|
if not membre:
|
||||||
|
raise HTTPException(status_code=401, detail="Membre introuvable ou inactif")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
async def require_executif(
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
Poste.categorie == "executif",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé à l'exécutif")
|
||||||
|
return membre
|
||||||
68
backend/app/main.py
Normal file
68
backend/app/main.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""
|
||||||
|
Groupe Méditation — API Backend
|
||||||
|
District 87-16, Région 87
|
||||||
|
100% logiciel libre · Hébergé au Québec
|
||||||
|
"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Groupe Méditation API",
|
||||||
|
description="API du Groupe Méditation — District 87-16, Région 87",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Montage des routes
|
||||||
|
app.include_router(auth.router, prefix="/api")
|
||||||
|
app.include_router(membres.router, prefix="/api")
|
||||||
|
app.include_router(postes.router, prefix="/api")
|
||||||
|
app.include_router(accueil.router, prefix="/api")
|
||||||
|
app.include_router(invitations.router, prefix="/api")
|
||||||
|
app.include_router(collectes.router, prefix="/api")
|
||||||
|
app.include_router(reunions.router, prefix="/api")
|
||||||
|
app.include_router(depenses.router, prefix="/api")
|
||||||
|
app.include_router(decisions.router, prefix="/api")
|
||||||
|
app.include_router(calendrier.router, prefix="/api")
|
||||||
|
app.include_router(rapports.router, prefix="/api")
|
||||||
|
app.include_router(rotations.router, prefix="/api")
|
||||||
|
app.include_router(parametres.router, prefix="/api")
|
||||||
|
app.include_router(presences.router, prefix="/api")
|
||||||
|
app.include_router(pv.router, prefix="/api")
|
||||||
|
app.include_router(rapports_rsg.router, prefix="/api")
|
||||||
|
app.include_router(jetons.router, prefix="/api")
|
||||||
|
app.include_router(anniversaires.router, prefix="/api")
|
||||||
|
app.include_router(commandes_jetons.router, prefix="/api")
|
||||||
|
app.include_router(litterature.router, prefix="/api")
|
||||||
|
app.include_router(ventes_litterature.router, prefix="/api")
|
||||||
|
app.include_router(contributions.router, prefix="/api")
|
||||||
|
app.include_router(config_repartition.router, prefix="/api")
|
||||||
|
app.include_router(sondages.router, prefix="/api")
|
||||||
|
app.include_router(jumelages.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def root_health():
|
||||||
|
return {"status": "ok", "app": "Groupe Méditation", "version": "0.1.0"}
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
23
backend/app/models/affectation.py
Normal file
23
backend/app/models/affectation.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Table affectations."""
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from sqlalchemy import String, Date, Text, ForeignKey, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Affectation(Base):
|
||||||
|
__tablename__ = "affectations"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||||
|
poste_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("postes.id"), nullable=False)
|
||||||
|
date_debut: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
date_fin: Mapped[date | None] = mapped_column(Date)
|
||||||
|
statut: Mapped[str] = mapped_column(SAEnum("actif", "termine", name="statut_affectation"), default="actif")
|
||||||
|
note_transition: Mapped[str | None] = mapped_column(Text)
|
||||||
|
termine_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||||
|
|
||||||
|
membre = relationship("Membre", back_populates="affectations", foreign_keys=[membre_id])
|
||||||
|
poste = relationship("Poste")
|
||||||
18
backend/app/models/collecte.py
Normal file
18
backend/app/models/collecte.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Table collectes — 7e Tradition."""
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Numeric
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Collecte(Base):
|
||||||
|
__tablename__ = "collectes"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
date_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"))
|
||||||
|
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))
|
||||||
19
backend/app/models/config_repartition.py
Normal file
19
backend/app/models/config_repartition.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Table config_repartition — % de répartition des contributions."""
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from sqlalchemy import Date, ForeignKey, Numeric, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRepartition(Base):
|
||||||
|
__tablename__ = "config_repartition"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
destination: Mapped[str] = mapped_column(
|
||||||
|
SAEnum("district", "region", "bsg", "intergroupe", name="destination_repartition", create_constraint=False),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
pourcentage: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
|
||||||
|
actif_depuis: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
27
backend/app/models/decision.py
Normal file
27
backend/app/models/decision.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Table decisions — suivi des décisions de réunion d'affaires."""
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from sqlalchemy import String, Date, DateTime, Text, ForeignKey, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Decision(Base):
|
||||||
|
__tablename__ = "decisions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
reunion_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("reunions.id"))
|
||||||
|
sujet: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
statut: Mapped[str] = mapped_column(
|
||||||
|
SAEnum("en_attente", "en_cours", "complete", name="statut_decision"),
|
||||||
|
default="en_attente",
|
||||||
|
)
|
||||||
|
responsable_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||||
|
proposeur: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
secondeur: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
date_decision: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
date_completion: Mapped[date | None] = mapped_column(Date)
|
||||||
|
cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||||
|
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
24
backend/app/models/depense.py
Normal file
24
backend/app/models/depense.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Table depenses — 6 champs selon l'architecture v4."""
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, Numeric, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Depense(Base):
|
||||||
|
__tablename__ = "depenses"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
date_depense: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||||
|
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(
|
||||||
|
SAEnum("DU", "VIREMENT", "CHEQUE", "COLLECTE", name="methode_paiement"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
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))
|
||||||
25
backend/app/models/envoi_contribution.py
Normal file
25
backend/app/models/envoi_contribution.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""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.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EnvoiContribution(Base):
|
||||||
|
__tablename__ = "envois_contributions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
date_envoi: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
destination: Mapped[str] = mapped_column(
|
||||||
|
SAEnum("district", "region", "bsg", "intergroupe", name="destination_contribution"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
montant: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
|
||||||
|
methode: Mapped[str] = mapped_column(
|
||||||
|
SAEnum("DU", "VIREMENT", "CHEQUE", name="methode_contribution"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
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))
|
||||||
23
backend/app/models/groupe.py
Normal file
23
backend/app/models/groupe.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Table groupes."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Text, Time, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Groupe(Base):
|
||||||
|
__tablename__ = "groupes"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
nom: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
district: Mapped[str] = mapped_column(String(20), default="87-16")
|
||||||
|
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)
|
||||||
|
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))
|
||||||
|
|
||||||
|
membres = relationship("Membre", back_populates="groupe")
|
||||||
18
backend/app/models/invitation.py
Normal file
18
backend/app/models/invitation.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Table invitations."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Invitation(Base):
|
||||||
|
__tablename__ = "invitations"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(12), unique=True, nullable=False)
|
||||||
|
cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||||
|
utilise_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||||
|
expire_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
utilise: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
26
backend/app/models/jeton.py
Normal file
26
backend/app/models/jeton.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Table jetons — Remise de jetons de sobriété."""
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from sqlalchemy import String, Integer, Date, DateTime, ForeignKey, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Jeton(Base):
|
||||||
|
__tablename__ = "jetons"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
membre_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||||
|
type_jeton: Mapped[str] = mapped_column(
|
||||||
|
SAEnum(
|
||||||
|
"desir", "24h", "1mois", "2mois", "3mois",
|
||||||
|
"6mois", "9mois", "1an", "multiple",
|
||||||
|
name="type_jeton",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
date_remise: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
annees: Mapped[int | None] = mapped_column(Integer) # pour type_jeton='multiple'
|
||||||
|
remis_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))
|
||||||
23
backend/app/models/jumelage.py
Normal file
23
backend/app/models/jumelage.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Table jumelages — Parrainage discret."""
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from sqlalchemy import String, Date, DateTime, ForeignKey, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Jumelage(Base):
|
||||||
|
__tablename__ = "jumelages"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
parrain_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||||
|
filleul_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||||
|
date_jumelage: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
statut: Mapped[str] = mapped_column(
|
||||||
|
SAEnum("actif", "termine", name="statut_jumelage"),
|
||||||
|
default="actif",
|
||||||
|
)
|
||||||
|
note: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
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))
|
||||||
21
backend/app/models/litterature.py
Normal file
21
backend/app/models/litterature.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Table litterature — Inventaire AA."""
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import String, Integer, Numeric, ForeignKey, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Litterature(Base):
|
||||||
|
__tablename__ = "litterature"
|
||||||
|
|
||||||
|
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)
|
||||||
|
categorie: Mapped[str] = mapped_column(
|
||||||
|
SAEnum("livre", "brochure", "depliant", "pamphlet", name="categorie_litterature"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
prix_coutant: Mapped[float | None] = mapped_column(Numeric(8, 2))
|
||||||
|
quantite_stock: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
seuil_commande: Mapped[int] = mapped_column(Integer, default=2)
|
||||||
|
code_bsg: Mapped[str | None] = mapped_column(String(20))
|
||||||
26
backend/app/models/membre.py
Normal file
26
backend/app/models/membre.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Table membres."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, date, timezone
|
||||||
|
from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Membre(Base):
|
||||||
|
__tablename__ = "membres"
|
||||||
|
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
push_sub: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
modifie_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
groupe = relationship("Groupe", back_populates="membres")
|
||||||
|
affectations = relationship("Affectation", back_populates="membre", foreign_keys="Affectation.membre_id")
|
||||||
16
backend/app/models/option_sondage.py
Normal file
16
backend/app/models/option_sondage.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""Table options_sondage — Options de vote."""
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import String, Integer, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class OptionSondage(Base):
|
||||||
|
__tablename__ = "options_sondage"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
texte: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
ordre: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
sondage = relationship("Sondage", back_populates="options")
|
||||||
17
backend/app/models/poste.py
Normal file
17
backend/app/models/poste.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Table postes."""
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import String, Text, Integer, Interval, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Poste(Base):
|
||||||
|
__tablename__ = "postes"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
nom: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
categorie: Mapped[str] = mapped_column(SAEnum("executif", "service", "physique", name="categorie_poste"), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
duree_recommandee: Mapped[str | None] = mapped_column(Interval)
|
||||||
|
prerequis: Mapped[str | None] = mapped_column(Text)
|
||||||
|
ordre_affichage: Mapped[int | None] = mapped_column(Integer)
|
||||||
14
backend/app/models/presence.py
Normal file
14
backend/app/models/presence.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"""Table presences — réunions d'affaires seulement."""
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import Boolean, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Presence(Base):
|
||||||
|
__tablename__ = "presences"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
reunion_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reunions.id"), nullable=False)
|
||||||
|
membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||||
|
present: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
18
backend/app/models/pv_reunion.py
Normal file
18
backend/app/models/pv_reunion.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Table pv_reunions — procès-verbaux des réunions d'affaires."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Text, Boolean, DateTime, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PvReunion(Base):
|
||||||
|
__tablename__ = "pv_reunions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
reunion_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reunions.id"), nullable=False)
|
||||||
|
redacteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||||
|
contenu: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
decisions: Mapped[list | None] = mapped_column(JSON) # [{sujet, decision, proposeur, secondeur, statut}]
|
||||||
|
approuve: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
19
backend/app/models/rapport_rsg.py
Normal file
19
backend/app/models/rapport_rsg.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Table rapports_rsg — compte-rendus de réunion de district."""
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from sqlalchemy import Date, DateTime, Text, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RapportRsg(Base):
|
||||||
|
__tablename__ = "rapports_rsg"
|
||||||
|
|
||||||
|
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_dist: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
redacteur_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||||
|
decisions: Mapped[list | None] = mapped_column(JSON)
|
||||||
|
infos_region: Mapped[str | None] = mapped_column(Text)
|
||||||
|
sujets_groupe: Mapped[str | None] = mapped_column(Text)
|
||||||
|
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
20
backend/app/models/reunion.py
Normal file
20
backend/app/models/reunion.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""Table reunions."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, DateTime, Text, ForeignKey, JSON, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Reunion(Base):
|
||||||
|
__tablename__ = "reunions"
|
||||||
|
|
||||||
|
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("reguliere", "affaires", name="type_reunion"), nullable=False)
|
||||||
|
date_reunion: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
animateur_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||||
|
theme: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
annonces: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
|
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
19
backend/app/models/rotation.py
Normal file
19
backend/app/models/rotation.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Table rotations — planification hebdomadaire des serviteurs."""
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from sqlalchemy import String, Date, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Rotation(Base):
|
||||||
|
__tablename__ = "rotations"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
date_reunion: Mapped[date] = mapped_column(Date, 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)
|
||||||
|
note: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
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))
|
||||||
29
backend/app/models/sondage.py
Normal file
29
backend/app/models/sondage.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Table sondages — Votes de conscience de groupe."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Sondage(Base):
|
||||||
|
__tablename__ = "sondages"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
titre: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
type: Mapped[str] = mapped_column(
|
||||||
|
SAEnum("oui_non", "choix_multiple", "unanimite", name="type_sondage"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
cree_par: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("membres.id"))
|
||||||
|
date_ouverture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
date_fermeture: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
statut: Mapped[str] = mapped_column(
|
||||||
|
SAEnum("brouillon", "ouvert", "ferme", name="statut_sondage"),
|
||||||
|
default="brouillon",
|
||||||
|
)
|
||||||
|
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
options = relationship("OptionSondage", back_populates="sondage", cascade="all, delete-orphan")
|
||||||
21
backend/app/models/sync_log.py
Normal file
21
backend/app/models/sync_log.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""Table sync_log — Journal de synchronisation."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, BigInteger, DateTime, ForeignKey, JSON, Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SyncLog(Base):
|
||||||
|
__tablename__ = "sync_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
groupe_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("groupes.id"))
|
||||||
|
table_name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
record_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(
|
||||||
|
SAEnum("insert", "update", "delete", name="sync_action"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
data: Mapped[dict | None] = mapped_column(JSON)
|
||||||
19
backend/app/models/vente_litterature.py
Normal file
19
backend/app/models/vente_litterature.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""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.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class VenteLitterature(Base):
|
||||||
|
__tablename__ = "ventes_litterature"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
litterature_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("litterature.id"), nullable=False)
|
||||||
|
groupe_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groupes.id"), nullable=False)
|
||||||
|
quantite: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
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"))
|
||||||
|
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
16
backend/app/models/vote.py
Normal file
16
backend/app/models/vote.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""Table votes — Votes anonymes."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Vote(Base):
|
||||||
|
__tablename__ = "votes"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
sondage_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("sondages.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
option_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("options_sondage.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
membre_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("membres.id"), nullable=False)
|
||||||
|
vote_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
112
backend/app/routers/accueil.py
Normal file
112
backend/app/routers/accueil.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Route accueil — écran dynamique par rôle."""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
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="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"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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="M4", nom="Envoi contributions", description="District, région, BSG", icone="📮", couleur="blue"),
|
||||||
|
],
|
||||||
|
"Secrétaire": [
|
||||||
|
ModuleTuile(code="M1", nom="Rédiger PV", description="Procès-verbal", icone="📝", couleur="blue"),
|
||||||
|
ModuleTuile(code="M2", nom="Présences", description="Réunion d'affaires", icone="✅", couleur="green"),
|
||||||
|
],
|
||||||
|
"RSG": [
|
||||||
|
ModuleTuile(code="M3", nom="Rapport RSG", description="Compte-rendu district", icone="📋", couleur="blue"),
|
||||||
|
],
|
||||||
|
"RSG Substitut(e)": [
|
||||||
|
ModuleTuile(code="M3", nom="Rapport RSG", description="Compte-rendu district", icone="📋", couleur="blue"),
|
||||||
|
],
|
||||||
|
"Animateur(trice)": [
|
||||||
|
ModuleTuile(code="S4", nom="Rapport réunion", description="Thème et annonces", icone="🎤", couleur="purple"),
|
||||||
|
],
|
||||||
|
"Responsable littérature": [
|
||||||
|
ModuleTuile(code="S3", nom="Vente littérature", description="Enregistrer une vente", icone="📖", couleur="purple"),
|
||||||
|
ModuleTuile(code="M5", nom="Ajuster inventaire", description="Mettre à jour le stock", icone="📦", couleur="orange"),
|
||||||
|
],
|
||||||
|
"Responsable jetons": [
|
||||||
|
ModuleTuile(code="S2", nom="Remise jeton", description="Enregistrer un jeton", icone="🏅", couleur="yellow"),
|
||||||
|
ModuleTuile(code="M6", nom="Commander jetons", description="Commander à l'Intergroupe", icone="📦", couleur="orange"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
MODULES_GESTION = [
|
||||||
|
ModuleTuile(code="G1", nom="Inviter un membre", description="Code d'invitation 48h", icone="➕", couleur="green"),
|
||||||
|
ModuleTuile(code="G2", nom="Gérer les membres", description="Fiche complète, désactiver", icone="👥", couleur="blue"),
|
||||||
|
ModuleTuile(code="G3", nom="Affecter les postes", description="Assigner un serviteur", icone="🎯", couleur="purple"),
|
||||||
|
ModuleTuile(code="G4", nom="Terminer un mandat", description="Transition manuelle", icone="🔄", couleur="orange"),
|
||||||
|
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"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=AccueilResponse)
|
||||||
|
async def accueil(
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Écran d'accueil dynamique — retourne les modules selon les postes du membre."""
|
||||||
|
# Récupérer les affectations actives
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
affectations = result.scalars().all()
|
||||||
|
|
||||||
|
postes_noms = []
|
||||||
|
postes_categories = []
|
||||||
|
for a in affectations:
|
||||||
|
poste = await db.get(Poste, a.poste_id)
|
||||||
|
if poste:
|
||||||
|
postes_noms.append(poste.nom)
|
||||||
|
postes_categories.append(poste.categorie)
|
||||||
|
|
||||||
|
# Modules de consultation : toujours visibles
|
||||||
|
modules_consultation = MODULES_CONSULTATION
|
||||||
|
|
||||||
|
# Modules de saisie : union des modules de tous les postes
|
||||||
|
modules_saisie = []
|
||||||
|
codes_vus = set()
|
||||||
|
for nom_poste in postes_noms:
|
||||||
|
for m in MODULES_SAISIE.get(nom_poste, []):
|
||||||
|
if m.code not in codes_vus:
|
||||||
|
modules_saisie.append(m)
|
||||||
|
codes_vus.add(m.code)
|
||||||
|
|
||||||
|
# Modules de gestion : seulement si exécutif
|
||||||
|
modules_gestion = MODULES_GESTION if "executif" in postes_categories else []
|
||||||
|
|
||||||
|
return AccueilResponse(
|
||||||
|
membre=MembrePublic.model_validate(membre),
|
||||||
|
postes=postes_noms,
|
||||||
|
modules_consultation=modules_consultation,
|
||||||
|
modules_saisie=modules_saisie,
|
||||||
|
modules_gestion=modules_gestion,
|
||||||
|
)
|
||||||
89
backend/app/routers/anniversaires.py
Normal file
89
backend/app/routers/anniversaires.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Routes C3 — Anniversaires district 87-16."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.groupe import Groupe
|
||||||
|
from app.schemas.phase5 import AnniversaireOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/anniversaires", tags=["C3 — Anniversaires district"])
|
||||||
|
|
||||||
|
|
||||||
|
def _calcul_anniversaire(date_abstinence: date, aujourdhui: date) -> tuple[int, date, int]:
|
||||||
|
"""Calcule les années de sobriété, le prochain anniversaire et les jours restants."""
|
||||||
|
annees = aujourdhui.year - date_abstinence.year
|
||||||
|
# Anniversaire cette année
|
||||||
|
try:
|
||||||
|
anniv_cette_annee = date_abstinence.replace(year=aujourdhui.year)
|
||||||
|
except ValueError:
|
||||||
|
# 29 février
|
||||||
|
anniv_cette_annee = date(aujourdhui.year, 3, 1)
|
||||||
|
|
||||||
|
if anniv_cette_annee < aujourdhui:
|
||||||
|
# L'anniversaire de cette année est passé
|
||||||
|
try:
|
||||||
|
prochain = date_abstinence.replace(year=aujourdhui.year + 1)
|
||||||
|
except ValueError:
|
||||||
|
prochain = date(aujourdhui.year + 1, 3, 1)
|
||||||
|
annees_sobriete = annees
|
||||||
|
elif anniv_cette_annee == aujourdhui:
|
||||||
|
prochain = anniv_cette_annee
|
||||||
|
annees_sobriete = annees
|
||||||
|
else:
|
||||||
|
prochain = anniv_cette_annee
|
||||||
|
annees_sobriete = annees - 1
|
||||||
|
|
||||||
|
# Minimum 0
|
||||||
|
annees_sobriete = max(0, annees_sobriete)
|
||||||
|
jours_restants = (prochain - aujourdhui).days
|
||||||
|
|
||||||
|
return annees_sobriete, prochain, jours_restants
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[AnniversaireOut])
|
||||||
|
async def anniversaires_district(
|
||||||
|
prochains_jours: int = 90,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""C3 — Anniversaires de sobriété de tous les groupes du district 87-16."""
|
||||||
|
aujourdhui = date.today()
|
||||||
|
|
||||||
|
# Récupérer le district du groupe du membre
|
||||||
|
groupe = await db.get(Groupe, membre.groupe_id)
|
||||||
|
district = groupe.district if groupe else "87-16"
|
||||||
|
|
||||||
|
# Tous les membres actifs du district ayant une date d'abstinence
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
anniversaires = []
|
||||||
|
for m, groupe_nom in rows:
|
||||||
|
annees_sobriete, prochain, jours_restants = _calcul_anniversaire(m.date_abstinence, aujourdhui)
|
||||||
|
|
||||||
|
# Filtrer : prochains X jours OU anniversaire aujourd'hui
|
||||||
|
if 0 <= jours_restants <= prochains_jours:
|
||||||
|
anniversaires.append(AnniversaireOut(
|
||||||
|
membre_prenom=m.prenom,
|
||||||
|
groupe_nom=groupe_nom,
|
||||||
|
date_abstinence=m.date_abstinence,
|
||||||
|
annees_sobriete=annees_sobriete + 1, # prochain anniversaire = +1
|
||||||
|
prochain_anniversaire=prochain,
|
||||||
|
jours_restants=jours_restants,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Trier par jours restants (prochains en premier)
|
||||||
|
anniversaires.sort(key=lambda a: a.jours_restants)
|
||||||
|
return anniversaires
|
||||||
107
backend/app/routers/auth.py
Normal file
107
backend/app/routers/auth.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""Routes d'authentification."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import hash_pin, verify_pin, create_token
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.groupe import Groupe
|
||||||
|
from app.models.invitation import Invitation
|
||||||
|
from app.schemas.schemas import ConnexionRequest, InscriptionRequest, TokenResponse, MembrePublic
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["Authentification"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(Groupe).limit(1))
|
||||||
|
groupe = result.scalar_one_or_none()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"groupe": groupe.nom if groupe else "Aucun",
|
||||||
|
"district": groupe.district if groupe else "-",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/groupes")
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Membre).where(
|
||||||
|
func.lower(Membre.prenom) == func.lower(req.prenom),
|
||||||
|
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")
|
||||||
|
|
||||||
|
token = create_token(str(membre.id), str(membre.groupe_id))
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
membre=MembrePublic.model_validate(membre),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/inscription", response_model=TokenResponse)
|
||||||
|
async def inscription(req: InscriptionRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
# Valider le code d'invitation
|
||||||
|
result = await db.execute(
|
||||||
|
select(Invitation).where(
|
||||||
|
Invitation.code == req.code_invitation,
|
||||||
|
Invitation.utilise == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
invitation = result.scalar_one_or_none()
|
||||||
|
if not invitation:
|
||||||
|
raise HTTPException(status_code=400, detail="Code d'invitation invalide")
|
||||||
|
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
|
||||||
|
exists = await db.execute(
|
||||||
|
select(Membre).where(
|
||||||
|
func.lower(Membre.prenom) == func.lower(req.prenom),
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Valider le PIN (4 chiffres)
|
||||||
|
if not req.pin.isdigit() or len(req.pin) != 4:
|
||||||
|
raise HTTPException(status_code=400, detail="Le PIN doit être composé de 4 chiffres")
|
||||||
|
|
||||||
|
# Créer le membre
|
||||||
|
membre = Membre(
|
||||||
|
groupe_id=invitation.groupe_id,
|
||||||
|
prenom=req.prenom.strip().title(),
|
||||||
|
nom=req.nom.strip().title(),
|
||||||
|
telephone=req.telephone,
|
||||||
|
courriel=req.courriel,
|
||||||
|
date_abstinence=req.date_abstinence,
|
||||||
|
pin_hash=hash_pin(req.pin),
|
||||||
|
)
|
||||||
|
db.add(membre)
|
||||||
|
|
||||||
|
# Marquer l'invitation utilisée
|
||||||
|
invitation.utilise = True
|
||||||
|
invitation.utilise_par = membre.id
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
token = create_token(str(membre.id), str(membre.groupe_id))
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
membre=MembrePublic.model_validate(membre),
|
||||||
|
)
|
||||||
102
backend/app/routers/calendrier.py
Normal file
102
backend/app/routers/calendrier.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Routes C2 — Calendrier district 87-16."""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre
|
||||||
|
from app.models.groupe import Groupe
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.schemas.phase2 import CalendrierEntry
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/calendrier", tags=["C2 — Calendrier district"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[CalendrierEntry])
|
||||||
|
async def calendrier_district(
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""C2 — Calendrier des serviteurs de tous les groupes du district 87-16."""
|
||||||
|
# Récupérer le district du membre
|
||||||
|
groupe = await db.get(Groupe, membre.groupe_id)
|
||||||
|
district = groupe.district if groupe else "87-16"
|
||||||
|
|
||||||
|
# Tous les groupes du même district
|
||||||
|
result = await db.execute(
|
||||||
|
select(Groupe).where(Groupe.district == district).order_by(Groupe.nom)
|
||||||
|
)
|
||||||
|
groupes = {g.id: g for g in result.scalars().all()}
|
||||||
|
|
||||||
|
# Toutes les affectations actives de ces groupes
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).where(
|
||||||
|
Affectation.groupe_id.in_(groupes.keys()),
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
affectations = result.scalars().all()
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for a in affectations:
|
||||||
|
poste = await db.get(Poste, a.poste_id)
|
||||||
|
mbr = await db.get(Membre, a.membre_id)
|
||||||
|
grp = groupes.get(a.groupe_id)
|
||||||
|
if poste and mbr and grp:
|
||||||
|
entries.append(CalendrierEntry(
|
||||||
|
groupe_nom=grp.nom,
|
||||||
|
groupe_id=grp.id,
|
||||||
|
jour_reunion=grp.jour_reunion,
|
||||||
|
poste_nom=poste.nom,
|
||||||
|
membre_prenom=mbr.prenom,
|
||||||
|
date_debut=a.date_debut,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Trier par groupe puis par ordre de poste
|
||||||
|
entries.sort(key=lambda e: (e.groupe_nom, e.poste_nom))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/groupe/{groupe_id}")
|
||||||
|
async def calendrier_groupe(
|
||||||
|
groupe_id: str,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Détail des serviteurs d'un groupe spécifique."""
|
||||||
|
groupe = await db.get(Groupe, groupe_id)
|
||||||
|
if not groupe:
|
||||||
|
return {"groupe": None, "serviteurs": []}
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).where(
|
||||||
|
Affectation.groupe_id == groupe_id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
affectations = result.scalars().all()
|
||||||
|
|
||||||
|
serviteurs = []
|
||||||
|
for a in affectations:
|
||||||
|
poste = await db.get(Poste, a.poste_id)
|
||||||
|
mbr = await db.get(Membre, a.membre_id)
|
||||||
|
if poste and mbr:
|
||||||
|
serviteurs.append({
|
||||||
|
"poste": poste.nom,
|
||||||
|
"categorie": poste.categorie,
|
||||||
|
"prenom": mbr.prenom,
|
||||||
|
"depuis": str(a.date_debut),
|
||||||
|
})
|
||||||
|
serviteurs.sort(key=lambda s: s.get("poste", ""))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"groupe": {
|
||||||
|
"id": str(groupe.id),
|
||||||
|
"nom": groupe.nom,
|
||||||
|
"jour_reunion": groupe.jour_reunion,
|
||||||
|
"adresse": groupe.adresse_local,
|
||||||
|
},
|
||||||
|
"serviteurs": serviteurs,
|
||||||
|
}
|
||||||
80
backend/app/routers/collectes.py
Normal file
80
backend/app/routers/collectes.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Routes S1 — Collecte 7e Tradition."""
|
||||||
|
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.collecte import Collecte
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase1 import CollecteCreate, CollecteOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/collectes", tags=["S1 — Collecte 7e Tradition"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Vérifie que le membre est trésorier."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
Poste.nom.ilike("%trésorier%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au trésorier")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=CollecteOut)
|
||||||
|
async def saisir_collecte(
|
||||||
|
req: CollecteCreate,
|
||||||
|
membre=Depends(require_tresorier),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""S1 — Saisir le montant collecté au panier."""
|
||||||
|
collecte = Collecte(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
date_collecte=req.date_collecte,
|
||||||
|
montant=req.montant,
|
||||||
|
reunion_id=req.reunion_id,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[CollecteOut])
|
||||||
|
async def liste_collectes(
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
collectes = result.scalars().all()
|
||||||
|
out = []
|
||||||
|
for c in collectes:
|
||||||
|
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
|
||||||
66
backend/app/routers/commandes_jetons.py
Normal file
66
backend/app/routers/commandes_jetons.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Routes M6 — Commander des jetons."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre
|
||||||
|
from app.models.jeton import Jeton
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase5 import StockJetonsOut, TYPES_JETONS
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/commandes-jetons", tags=["M6 — Commander jetons"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Vérifie que le membre est responsable des jetons."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
Poste.nom.ilike("%jeton%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au responsable des jetons")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stock", response_model=list[StockJetonsOut])
|
||||||
|
async def etat_stock_jetons(
|
||||||
|
membre=Depends(require_resp_jetons),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""M6 — État du stock de jetons (basé sur l'utilisation du dernier mois)."""
|
||||||
|
il_y_a_30_jours = date.today() - timedelta(days=30)
|
||||||
|
|
||||||
|
# Compter les jetons remis par type dans les 30 derniers jours
|
||||||
|
result = await db.execute(
|
||||||
|
select(Jeton.type_jeton, func.count(Jeton.id))
|
||||||
|
.where(
|
||||||
|
Jeton.groupe_id == membre.groupe_id,
|
||||||
|
Jeton.date_remise >= il_y_a_30_jours,
|
||||||
|
)
|
||||||
|
.group_by(Jeton.type_jeton)
|
||||||
|
)
|
||||||
|
usage = {row[0]: row[1] for row in result.all()}
|
||||||
|
|
||||||
|
stock = []
|
||||||
|
for t in TYPES_JETONS:
|
||||||
|
remis = usage.get(t["value"], 0)
|
||||||
|
# Suggestion simple : si plus de 2 remis dans le mois, suggérer commande
|
||||||
|
suggestion = "Bas — commander" if remis >= 2 else "OK"
|
||||||
|
# Le jeton de désir est toujours à surveiller (remis chaque semaine)
|
||||||
|
if t["value"] == "desir" and remis >= 4:
|
||||||
|
suggestion = "Bas — commander"
|
||||||
|
|
||||||
|
stock.append(StockJetonsOut(
|
||||||
|
type_jeton=t["value"],
|
||||||
|
label=f"{t['emoji']} {t['label']}",
|
||||||
|
remis_dernier_mois=remis,
|
||||||
|
stock_suggestion=suggestion,
|
||||||
|
))
|
||||||
|
return stock
|
||||||
103
backend/app/routers/config_repartition.py
Normal file
103
backend/app/routers/config_repartition.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""Routes G8 — Configurer la répartition des contributions."""
|
||||||
|
from datetime import date
|
||||||
|
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 require_executif
|
||||||
|
from app.models.config_repartition import ConfigRepartition
|
||||||
|
from app.schemas.phase7 import (
|
||||||
|
ConfigRepartitionOut, ConfigRepartitionBatch,
|
||||||
|
DESTINATIONS, DESTINATIONS_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/config-repartition", tags=["G8 — Config répartition"])
|
||||||
|
|
||||||
|
DEFAUT_REPARTITION = {
|
||||||
|
"district": 30.0,
|
||||||
|
"region": 30.0,
|
||||||
|
"bsg": 30.0,
|
||||||
|
"intergroupe": 10.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[ConfigRepartitionOut])
|
||||||
|
async def voir_repartition(
|
||||||
|
membre=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G8 — Voir la répartition actuelle des contributions."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ConfigRepartition)
|
||||||
|
.where(ConfigRepartition.groupe_id == membre.groupe_id)
|
||||||
|
.order_by(desc(ConfigRepartition.actif_depuis))
|
||||||
|
)
|
||||||
|
configs = result.scalars().all()
|
||||||
|
|
||||||
|
# Garder la plus récente par destination
|
||||||
|
vu = {}
|
||||||
|
out = []
|
||||||
|
for c in configs:
|
||||||
|
if c.destination not in vu:
|
||||||
|
vu[c.destination] = True
|
||||||
|
out.append(ConfigRepartitionOut(
|
||||||
|
destination=c.destination,
|
||||||
|
destination_label=DESTINATIONS_LABELS.get(c.destination, c.destination),
|
||||||
|
pourcentage=float(c.pourcentage),
|
||||||
|
actif_depuis=c.actif_depuis,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Compléter avec les défauts pour les destinations manquantes
|
||||||
|
for dest in DESTINATIONS:
|
||||||
|
if dest not in vu:
|
||||||
|
out.append(ConfigRepartitionOut(
|
||||||
|
destination=dest,
|
||||||
|
destination_label=DESTINATIONS_LABELS[dest],
|
||||||
|
pourcentage=DEFAUT_REPARTITION[dest],
|
||||||
|
actif_depuis=date.today(),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Trier par destination
|
||||||
|
ordre = {d: i for i, d in enumerate(DESTINATIONS)}
|
||||||
|
out.sort(key=lambda x: ordre.get(x.destination, 99))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/", response_model=list[ConfigRepartitionOut])
|
||||||
|
async def modifier_repartition(
|
||||||
|
req: ConfigRepartitionBatch,
|
||||||
|
membre=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G8 — Modifier la répartition (toutes les destinations d'un coup)."""
|
||||||
|
# Validation
|
||||||
|
total = sum(item.pourcentage for item in req.items)
|
||||||
|
if abs(total - 100.0) > 0.01:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Le total doit être 100%. Actuellement: {total:.2f}%")
|
||||||
|
|
||||||
|
for item in req.items:
|
||||||
|
if item.destination not in DESTINATIONS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Destination invalide: {item.destination}")
|
||||||
|
if item.pourcentage < 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Les pourcentages ne peuvent pas être négatifs")
|
||||||
|
|
||||||
|
aujourdhui = date.today()
|
||||||
|
out = []
|
||||||
|
for item in req.items:
|
||||||
|
config = ConfigRepartition(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
destination=item.destination,
|
||||||
|
pourcentage=item.pourcentage,
|
||||||
|
actif_depuis=aujourdhui,
|
||||||
|
)
|
||||||
|
db.add(config)
|
||||||
|
await db.flush()
|
||||||
|
out.append(ConfigRepartitionOut(
|
||||||
|
destination=config.destination,
|
||||||
|
destination_label=DESTINATIONS_LABELS.get(config.destination, config.destination),
|
||||||
|
pourcentage=float(config.pourcentage),
|
||||||
|
actif_depuis=config.actif_depuis,
|
||||||
|
))
|
||||||
|
|
||||||
|
return out
|
||||||
151
backend/app/routers/contributions.py
Normal file
151
backend/app/routers/contributions.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""Routes M4 — Envoi de contributions aux organismes AA."""
|
||||||
|
from datetime import date
|
||||||
|
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.envoi_contribution import EnvoiContribution
|
||||||
|
from app.models.config_repartition import ConfigRepartition
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase7 import (
|
||||||
|
EnvoiContributionCreate, EnvoiContributionOut,
|
||||||
|
CalculRepartitionIn, CalculRepartitionOut,
|
||||||
|
DESTINATIONS, DESTINATIONS_LABELS, METHODES_CONTRIBUTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/contributions", tags=["M4 — Envoi contributions"])
|
||||||
|
|
||||||
|
# Répartition par défaut selon le Guide du groupe
|
||||||
|
DEFAUT_REPARTITION = {
|
||||||
|
"district": 30.0,
|
||||||
|
"region": 30.0,
|
||||||
|
"bsg": 30.0,
|
||||||
|
"intergroupe": 10.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
Poste.nom.ilike("%trésorier%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au trésorier")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_repartition(groupe_id, db: AsyncSession) -> dict[str, float]:
|
||||||
|
"""Récupère la répartition active ou utilise les défauts."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ConfigRepartition)
|
||||||
|
.where(ConfigRepartition.groupe_id == groupe_id)
|
||||||
|
.order_by(desc(ConfigRepartition.actif_depuis))
|
||||||
|
)
|
||||||
|
configs = result.scalars().all()
|
||||||
|
|
||||||
|
# Garder la plus récente par destination
|
||||||
|
repartition = {}
|
||||||
|
for c in configs:
|
||||||
|
if c.destination not in repartition:
|
||||||
|
repartition[c.destination] = float(c.pourcentage)
|
||||||
|
|
||||||
|
# Compléter avec les défauts
|
||||||
|
for dest in DESTINATIONS:
|
||||||
|
if dest not in repartition:
|
||||||
|
repartition[dest] = DEFAUT_REPARTITION[dest]
|
||||||
|
|
||||||
|
return repartition
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calculer", response_model=list[CalculRepartitionOut])
|
||||||
|
async def calculer_repartition(
|
||||||
|
req: CalculRepartitionIn,
|
||||||
|
membre=Depends(require_tresorier),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Calculer la répartition d'un montant selon les % configurés."""
|
||||||
|
if req.montant_total <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Le montant doit être positif")
|
||||||
|
|
||||||
|
repartition = await _get_repartition(membre.groupe_id, db)
|
||||||
|
return [
|
||||||
|
CalculRepartitionOut(
|
||||||
|
destination=dest,
|
||||||
|
destination_label=DESTINATIONS_LABELS[dest],
|
||||||
|
pourcentage=pct,
|
||||||
|
montant=round(req.montant_total * pct / 100, 2),
|
||||||
|
)
|
||||||
|
for dest, pct in repartition.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=EnvoiContributionOut)
|
||||||
|
async def enregistrer_envoi(
|
||||||
|
req: EnvoiContributionCreate,
|
||||||
|
membre=Depends(require_tresorier),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""M4 — Enregistrer un envoi de contribution à un organisme."""
|
||||||
|
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}")
|
||||||
|
|
||||||
|
envoi = EnvoiContribution(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
date_envoi=req.date_envoi,
|
||||||
|
destination=req.destination,
|
||||||
|
montant=req.montant,
|
||||||
|
methode=req.methode.upper(),
|
||||||
|
saisi_par=membre.id,
|
||||||
|
)
|
||||||
|
db.add(envoi)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return EnvoiContributionOut(
|
||||||
|
id=envoi.id,
|
||||||
|
date_envoi=envoi.date_envoi,
|
||||||
|
destination=envoi.destination,
|
||||||
|
destination_label=DESTINATIONS_LABELS[envoi.destination],
|
||||||
|
montant=float(envoi.montant),
|
||||||
|
methode=envoi.methode,
|
||||||
|
saisi_par_prenom=membre.prenom,
|
||||||
|
cree_le=envoi.cree_le,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[EnvoiContributionOut])
|
||||||
|
async def liste_envois(
|
||||||
|
limite: int = 30,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Lister les envois de contributions récents."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(EnvoiContribution)
|
||||||
|
.where(EnvoiContribution.groupe_id == membre.groupe_id)
|
||||||
|
.order_by(desc(EnvoiContribution.date_envoi))
|
||||||
|
.limit(limite)
|
||||||
|
)
|
||||||
|
envois = result.scalars().all()
|
||||||
|
out = []
|
||||||
|
for e in envois:
|
||||||
|
saisi = await db.get(Membre, e.saisi_par) if e.saisi_par else None
|
||||||
|
out.append(EnvoiContributionOut(
|
||||||
|
id=e.id,
|
||||||
|
date_envoi=e.date_envoi,
|
||||||
|
destination=e.destination,
|
||||||
|
destination_label=DESTINATIONS_LABELS.get(e.destination, e.destination),
|
||||||
|
montant=float(e.montant),
|
||||||
|
methode=e.methode,
|
||||||
|
saisi_par_prenom=saisi.prenom if saisi else None,
|
||||||
|
cree_le=e.cree_le,
|
||||||
|
))
|
||||||
|
return out
|
||||||
110
backend/app/routers/decisions.py
Normal file
110
backend/app/routers/decisions.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Routes C5 — Décisions en cours."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre, require_executif
|
||||||
|
from app.models.decision import Decision
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.schemas.phase2 import DecisionCreate, DecisionOut, DecisionUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/decisions", tags=["C5 — Décisions en cours"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_out(d: Decision, db: AsyncSession) -> DecisionOut:
|
||||||
|
resp = await db.get(Membre, d.responsable_id) if d.responsable_id else None
|
||||||
|
return DecisionOut(
|
||||||
|
id=d.id,
|
||||||
|
sujet=d.sujet,
|
||||||
|
description=d.description,
|
||||||
|
statut=d.statut,
|
||||||
|
responsable_prenom=resp.prenom if resp else None,
|
||||||
|
proposeur=d.proposeur,
|
||||||
|
secondeur=d.secondeur,
|
||||||
|
date_decision=d.date_decision,
|
||||||
|
date_completion=d.date_completion,
|
||||||
|
cree_le=d.cree_le,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[DecisionOut])
|
||||||
|
async def liste_decisions(
|
||||||
|
statut: str | None = None,
|
||||||
|
limite: int = 50,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""C5 — Lister les décisions du groupe avec filtre optionnel par statut."""
|
||||||
|
query = select(Decision).where(Decision.groupe_id == membre.groupe_id)
|
||||||
|
if statut:
|
||||||
|
query = query.where(Decision.statut == statut)
|
||||||
|
query = query.order_by(desc(Decision.date_decision)).limit(limite)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return [await _to_out(d, db) for d in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compteur")
|
||||||
|
async def compteur_decisions(
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Nombre de décisions ouvertes (pour le badge sur la tuile)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Decision).where(
|
||||||
|
Decision.groupe_id == membre.groupe_id,
|
||||||
|
Decision.statut.in_(["en_attente", "en_cours"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {"ouvertes": len(result.scalars().all())}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=DecisionOut)
|
||||||
|
async def creer_decision(
|
||||||
|
req: DecisionCreate,
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Créer une décision (exécutif — typiquement via le PV)."""
|
||||||
|
decision = Decision(
|
||||||
|
groupe_id=executif.groupe_id,
|
||||||
|
reunion_id=req.reunion_id,
|
||||||
|
sujet=req.sujet.strip(),
|
||||||
|
description=req.description,
|
||||||
|
statut=req.statut,
|
||||||
|
responsable_id=req.responsable_id,
|
||||||
|
proposeur=req.proposeur,
|
||||||
|
secondeur=req.secondeur,
|
||||||
|
date_decision=req.date_decision,
|
||||||
|
cree_par=executif.id,
|
||||||
|
)
|
||||||
|
db.add(decision)
|
||||||
|
await db.flush()
|
||||||
|
return await _to_out(decision, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{decision_id}", response_model=DecisionOut)
|
||||||
|
async def modifier_decision(
|
||||||
|
decision_id: str,
|
||||||
|
req: DecisionUpdate,
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Modifier le statut d'une décision (exécutif)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Decision).where(Decision.id == decision_id, Decision.groupe_id == executif.groupe_id)
|
||||||
|
)
|
||||||
|
decision = result.scalar_one_or_none()
|
||||||
|
if not decision:
|
||||||
|
raise HTTPException(status_code=404, detail="Décision introuvable")
|
||||||
|
|
||||||
|
if req.statut is not None:
|
||||||
|
decision.statut = req.statut
|
||||||
|
if req.description is not None:
|
||||||
|
decision.description = req.description
|
||||||
|
if req.responsable_id is not None:
|
||||||
|
decision.responsable_id = req.responsable_id
|
||||||
|
if req.date_completion is not None:
|
||||||
|
decision.date_completion = req.date_completion
|
||||||
|
await db.flush()
|
||||||
|
return await _to_out(decision, db)
|
||||||
146
backend/app/routers/depenses.py
Normal file
146
backend/app/routers/depenses.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Routes S5 — Dépenses (6 champs)."""
|
||||||
|
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.depense import Depense
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase1 import DepenseCreate, DepenseOut, DepenseUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/depenses", tags=["S5 — Dépenses"])
|
||||||
|
|
||||||
|
METHODES_VALIDES = {"DU", "VIREMENT", "CHEQUE", "COLLECTE"}
|
||||||
|
|
||||||
|
|
||||||
|
async def require_tresorier(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Vérifie que le membre est trésorier."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
Poste.nom.ilike("%trésorier%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au trésorier")
|
||||||
|
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:
|
||||||
|
qui = await db.get(Membre, d.qui)
|
||||||
|
saisi = await db.get(Membre, d.saisi_par) if d.saisi_par else None
|
||||||
|
out.append(DepenseOut(
|
||||||
|
id=d.id,
|
||||||
|
date_depense=d.date_depense,
|
||||||
|
montant=float(d.montant),
|
||||||
|
qui_prenom=qui.prenom if qui else "?",
|
||||||
|
quoi=d.quoi,
|
||||||
|
paye=d.paye,
|
||||||
|
methode=d.methode,
|
||||||
|
saisi_par_prenom=saisi.prenom if saisi else None,
|
||||||
|
cree_le=d.cree_le,
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{depense_id}", response_model=DepenseOut)
|
||||||
|
async def modifier_depense(
|
||||||
|
depense_id: str,
|
||||||
|
req: DepenseUpdate,
|
||||||
|
membre=Depends(require_tresorier),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Modifier une dépense (ex: marquer comme payé)."""
|
||||||
|
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 req.paye is not None:
|
||||||
|
depense.paye = req.paye
|
||||||
|
if req.methode is not None:
|
||||||
|
if req.methode.upper() not in METHODES_VALIDES:
|
||||||
|
raise HTTPException(status_code=400, detail="Méthode invalide")
|
||||||
|
depense.methode = req.methode.upper()
|
||||||
|
if req.quoi is not None:
|
||||||
|
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,
|
||||||
|
)
|
||||||
31
backend/app/routers/invitations.py
Normal file
31
backend/app/routers/invitations.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Routes invitations."""
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import require_executif
|
||||||
|
from app.models.invitation import Invitation
|
||||||
|
from app.schemas.schemas import InvitationOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/invitations", tags=["Invitations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=InvitationOut)
|
||||||
|
async def creer_invitation(
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G1 — Générer un code d'invitation (expire après 48h)."""
|
||||||
|
code = secrets.token_hex(6).upper() # 12 caractères hex
|
||||||
|
invitation = Invitation(
|
||||||
|
groupe_id=executif.groupe_id,
|
||||||
|
code=code,
|
||||||
|
cree_par=executif.id,
|
||||||
|
expire_le=datetime.now(timezone.utc) + timedelta(hours=settings.INVITATION_EXPIRE_HOURS),
|
||||||
|
)
|
||||||
|
db.add(invitation)
|
||||||
|
await db.flush()
|
||||||
|
return InvitationOut.model_validate(invitation)
|
||||||
109
backend/app/routers/jetons.py
Normal file
109
backend/app/routers/jetons.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
"""Routes S2 — Remise de jeton."""
|
||||||
|
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.jeton import Jeton
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase5 import JetonCreate, JetonOut, TYPES_JETONS
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/jetons", tags=["S2 — Remise de jeton"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_resp_jetons(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Vérifie que le membre est responsable des jetons."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
Poste.nom.ilike("%jeton%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au responsable des jetons")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/types")
|
||||||
|
async def types_jetons():
|
||||||
|
"""Liste des types de jetons disponibles."""
|
||||||
|
return TYPES_JETONS
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=JetonOut)
|
||||||
|
async def remettre_jeton(
|
||||||
|
req: JetonCreate,
|
||||||
|
membre=Depends(require_resp_jetons),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""S2 — Enregistrer la remise d'un jeton."""
|
||||||
|
# Validation
|
||||||
|
types_valides = [t["value"] for t in TYPES_JETONS]
|
||||||
|
if req.type_jeton not in types_valides:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Type de jeton invalide. Valeurs: {types_valides}")
|
||||||
|
|
||||||
|
if req.type_jeton == "multiple" and (not req.annees or req.annees < 2):
|
||||||
|
raise HTTPException(status_code=400, detail="Le nombre d'années est requis (≥ 2) pour un jeton multiple")
|
||||||
|
|
||||||
|
# Vérifier que le membre existe si spécifié
|
||||||
|
membre_prenom = None
|
||||||
|
if req.membre_id:
|
||||||
|
cible = await db.get(Membre, req.membre_id)
|
||||||
|
if not cible or not cible.actif:
|
||||||
|
raise HTTPException(status_code=404, detail="Membre introuvable")
|
||||||
|
membre_prenom = cible.prenom
|
||||||
|
|
||||||
|
jeton = Jeton(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
membre_id=req.membre_id,
|
||||||
|
type_jeton=req.type_jeton,
|
||||||
|
date_remise=req.date_remise,
|
||||||
|
annees=req.annees if req.type_jeton == "multiple" else None,
|
||||||
|
remis_par=membre.id,
|
||||||
|
)
|
||||||
|
db.add(jeton)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return JetonOut(
|
||||||
|
id=jeton.id,
|
||||||
|
membre_prenom=membre_prenom,
|
||||||
|
type_jeton=jeton.type_jeton,
|
||||||
|
date_remise=jeton.date_remise,
|
||||||
|
annees=jeton.annees,
|
||||||
|
remis_par_prenom=membre.prenom,
|
||||||
|
cree_le=jeton.cree_le,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[JetonOut])
|
||||||
|
async def liste_jetons(
|
||||||
|
limite: int = 30,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Lister les jetons remis récemment (tout le groupe)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Jeton)
|
||||||
|
.where(Jeton.groupe_id == membre.groupe_id)
|
||||||
|
.order_by(desc(Jeton.date_remise))
|
||||||
|
.limit(limite)
|
||||||
|
)
|
||||||
|
jetons = result.scalars().all()
|
||||||
|
out = []
|
||||||
|
for j in jetons:
|
||||||
|
m = await db.get(Membre, j.membre_id) if j.membre_id else None
|
||||||
|
r = await db.get(Membre, j.remis_par) if j.remis_par else None
|
||||||
|
out.append(JetonOut(
|
||||||
|
id=j.id,
|
||||||
|
membre_prenom=m.prenom if m else None,
|
||||||
|
type_jeton=j.type_jeton,
|
||||||
|
date_remise=j.date_remise,
|
||||||
|
annees=j.annees,
|
||||||
|
remis_par_prenom=r.prenom if r else None,
|
||||||
|
cree_le=j.cree_le,
|
||||||
|
))
|
||||||
|
return out
|
||||||
99
backend/app/routers/jumelages.py
Normal file
99
backend/app/routers/jumelages.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Routes G7 — Jumelage parrain/filleul (confidentiel)."""
|
||||||
|
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 require_executif
|
||||||
|
from app.models.jumelage import Jumelage
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.schemas.phase8 import JumelageCreate, JumelageOut, JumelageUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/jumelages", tags=["G7 — Parrainage"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_out(j: Jumelage, db: AsyncSession) -> JumelageOut:
|
||||||
|
parrain = await db.get(Membre, j.parrain_id)
|
||||||
|
filleul = await db.get(Membre, j.filleul_id)
|
||||||
|
cree = await db.get(Membre, j.cree_par) if j.cree_par else None
|
||||||
|
return JumelageOut(
|
||||||
|
id=j.id,
|
||||||
|
parrain_prenom=parrain.prenom if parrain else "?",
|
||||||
|
filleul_prenom=filleul.prenom if filleul else "?",
|
||||||
|
date_jumelage=j.date_jumelage,
|
||||||
|
statut=j.statut,
|
||||||
|
note=j.note,
|
||||||
|
cree_par_prenom=cree.prenom if cree else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=JumelageOut)
|
||||||
|
async def creer_jumelage(
|
||||||
|
req: JumelageCreate,
|
||||||
|
membre=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G7 — Jumeler un parrain et un filleul."""
|
||||||
|
if req.parrain_id == req.filleul_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Le parrain et le filleul doivent être différents")
|
||||||
|
|
||||||
|
parrain = await db.get(Membre, req.parrain_id)
|
||||||
|
filleul = await db.get(Membre, req.filleul_id)
|
||||||
|
if not parrain or not parrain.actif:
|
||||||
|
raise HTTPException(status_code=404, detail="Parrain introuvable ou inactif")
|
||||||
|
if not filleul or not filleul.actif:
|
||||||
|
raise HTTPException(status_code=404, detail="Filleul introuvable ou inactif")
|
||||||
|
|
||||||
|
jumelage = Jumelage(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
parrain_id=req.parrain_id,
|
||||||
|
filleul_id=req.filleul_id,
|
||||||
|
date_jumelage=req.date_jumelage,
|
||||||
|
note=req.note,
|
||||||
|
cree_par=membre.id,
|
||||||
|
)
|
||||||
|
db.add(jumelage)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return await _to_out(jumelage, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[JumelageOut])
|
||||||
|
async def liste_jumelages(
|
||||||
|
statut: str | None = None,
|
||||||
|
membre=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Liste des jumelages (confidentiel — exécutif seulement)."""
|
||||||
|
query = select(Jumelage).where(Jumelage.groupe_id == membre.groupe_id)
|
||||||
|
if statut:
|
||||||
|
query = query.where(Jumelage.statut == statut)
|
||||||
|
query = query.order_by(desc(Jumelage.date_jumelage))
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
jumelages = result.scalars().all()
|
||||||
|
return [await _to_out(j, db) for j in jumelages]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{jumelage_id}", response_model=JumelageOut)
|
||||||
|
async def modifier_jumelage(
|
||||||
|
jumelage_id: str,
|
||||||
|
req: JumelageUpdate,
|
||||||
|
membre=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Modifier ou terminer un jumelage."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Jumelage).where(Jumelage.id == jumelage_id, Jumelage.groupe_id == membre.groupe_id)
|
||||||
|
)
|
||||||
|
jumelage = result.scalar_one_or_none()
|
||||||
|
if not jumelage:
|
||||||
|
raise HTTPException(status_code=404, detail="Jumelage introuvable")
|
||||||
|
|
||||||
|
if req.statut and req.statut in ("actif", "termine"):
|
||||||
|
jumelage.statut = req.statut
|
||||||
|
if req.note is not None:
|
||||||
|
jumelage.note = req.note
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return await _to_out(jumelage, db)
|
||||||
124
backend/app/routers/litterature.py
Normal file
124
backend/app/routers/litterature.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Routes C4 / M5 — Littérature : inventaire, ajustement, gestion catalogue."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre
|
||||||
|
from app.models.litterature import Litterature
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase6 import LitteratureCreate, LitteratureOut, LitteratureUpdate, AjustementStock
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/litterature", tags=["C4/M5 — Littérature"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Vérifie que le membre est responsable de la littérature."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
Poste.nom.ilike("%littérature%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au responsable de la littérature")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(item: Litterature) -> LitteratureOut:
|
||||||
|
return LitteratureOut(
|
||||||
|
id=item.id,
|
||||||
|
titre=item.titre,
|
||||||
|
categorie=item.categorie,
|
||||||
|
prix_coutant=float(item.prix_coutant) if item.prix_coutant else None,
|
||||||
|
quantite_stock=item.quantite_stock,
|
||||||
|
seuil_commande=item.seuil_commande,
|
||||||
|
code_bsg=item.code_bsg,
|
||||||
|
stock_bas=item.quantite_stock <= item.seuil_commande,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[LitteratureOut])
|
||||||
|
async def inventaire(
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""C4 — Inventaire de la littérature du groupe avec prix."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Litterature)
|
||||||
|
.where(Litterature.groupe_id == membre.groupe_id)
|
||||||
|
.order_by(Litterature.categorie, Litterature.titre)
|
||||||
|
)
|
||||||
|
return [_to_out(item) for item in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=LitteratureOut)
|
||||||
|
async def ajouter_titre(
|
||||||
|
req: LitteratureCreate,
|
||||||
|
membre=Depends(require_resp_litterature),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Ajouter un titre au catalogue du groupe."""
|
||||||
|
categories_valides = ["livre", "brochure", "depliant", "pamphlet"]
|
||||||
|
if req.categorie not in categories_valides:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Catégorie invalide. Valeurs: {categories_valides}")
|
||||||
|
|
||||||
|
item = Litterature(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
titre=req.titre,
|
||||||
|
categorie=req.categorie,
|
||||||
|
prix_coutant=req.prix_coutant,
|
||||||
|
quantite_stock=req.quantite_stock,
|
||||||
|
seuil_commande=req.seuil_commande,
|
||||||
|
code_bsg=req.code_bsg,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
await db.flush()
|
||||||
|
return _to_out(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{item_id}", response_model=LitteratureOut)
|
||||||
|
async def modifier_titre(
|
||||||
|
item_id: str,
|
||||||
|
req: LitteratureUpdate,
|
||||||
|
membre=Depends(require_resp_litterature),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Modifier les infos d'un titre."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Litterature).where(Litterature.id == item_id, Litterature.groupe_id == membre.groupe_id)
|
||||||
|
)
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Titre introuvable")
|
||||||
|
|
||||||
|
for field, value in req.model_dump(exclude_none=True).items():
|
||||||
|
setattr(item, field, value)
|
||||||
|
await db.flush()
|
||||||
|
return _to_out(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{item_id}/stock", response_model=LitteratureOut)
|
||||||
|
async def ajuster_stock(
|
||||||
|
item_id: str,
|
||||||
|
req: AjustementStock,
|
||||||
|
membre=Depends(require_resp_litterature),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""M5 — Ajuster le stock après réception d'une commande ou correction."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Litterature).where(Litterature.id == item_id, Litterature.groupe_id == membre.groupe_id)
|
||||||
|
)
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Titre introuvable")
|
||||||
|
|
||||||
|
nouveau_stock = item.quantite_stock + req.quantite
|
||||||
|
if nouveau_stock < 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Le stock ne peut pas être négatif")
|
||||||
|
|
||||||
|
item.quantite_stock = nouveau_stock
|
||||||
|
await db.flush()
|
||||||
|
return _to_out(item)
|
||||||
127
backend/app/routers/membres.py
Normal file
127
backend/app/routers/membres.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""Routes membres — profil et gestion."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre, require_executif, hash_pin, verify_pin
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.schemas.schemas import MembrePublic, MembreComplet, MembreUpdate, ChangerPinRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/membres", tags=["Membres"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/moi", response_model=MembreComplet)
|
||||||
|
async def mon_profil(membre=Depends(get_current_membre)):
|
||||||
|
"""C7 — Mon profil."""
|
||||||
|
return MembreComplet.model_validate(membre)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/moi", response_model=MembreComplet)
|
||||||
|
async def modifier_profil(
|
||||||
|
updates: MembreUpdate,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""C7 — Modifier mon profil."""
|
||||||
|
if updates.telephone is not None:
|
||||||
|
membre.telephone = updates.telephone
|
||||||
|
if updates.courriel is not None:
|
||||||
|
membre.courriel = updates.courriel
|
||||||
|
if updates.date_abstinence is not None:
|
||||||
|
membre.date_abstinence = updates.date_abstinence
|
||||||
|
await db.flush()
|
||||||
|
return MembreComplet.model_validate(membre)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/moi/pin")
|
||||||
|
async def changer_pin(
|
||||||
|
req: ChangerPinRequest,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""C7 — Changer mon PIN."""
|
||||||
|
if not verify_pin(req.ancien_pin, membre.pin_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="Ancien PIN incorrect")
|
||||||
|
if not req.nouveau_pin.isdigit() or len(req.nouveau_pin) != 4:
|
||||||
|
raise HTTPException(status_code=400, detail="Le PIN doit être composé de 4 chiffres")
|
||||||
|
membre.pin_hash = hash_pin(req.nouveau_pin)
|
||||||
|
await db.flush()
|
||||||
|
return {"message": "PIN modifié avec succès"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/actifs", response_model=list[MembrePublic])
|
||||||
|
async def liste_membres_actifs(
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Liste simplifiée des membres actifs (prénom + id) — pour les dropdowns."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Membre).where(
|
||||||
|
Membre.groupe_id == membre.groupe_id,
|
||||||
|
Membre.actif == True,
|
||||||
|
).order_by(Membre.prenom)
|
||||||
|
)
|
||||||
|
return [MembrePublic.model_validate(m) for m in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[MembreComplet])
|
||||||
|
async def liste_membres(
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G2 — Liste des membres (exécutif seulement)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Membre).where(
|
||||||
|
Membre.groupe_id == executif.groupe_id,
|
||||||
|
Membre.actif == True,
|
||||||
|
).order_by(Membre.prenom)
|
||||||
|
)
|
||||||
|
return [MembreComplet.model_validate(m) for m in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{membre_id}/desactiver")
|
||||||
|
async def desactiver_membre(
|
||||||
|
membre_id: str,
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G2 — Désactiver un membre."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id)
|
||||||
|
)
|
||||||
|
membre = result.scalar_one_or_none()
|
||||||
|
if not membre:
|
||||||
|
raise HTTPException(status_code=404, detail="Membre introuvable")
|
||||||
|
membre.actif = False
|
||||||
|
await db.flush()
|
||||||
|
return {"message": f"{membre.prenom} a été désactivé"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{membre_id}/oubli")
|
||||||
|
async def droit_oubli(
|
||||||
|
membre_id: str,
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G2 — Droit à l'oubli : anonymise les données personnelles."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Membre).where(Membre.id == membre_id, Membre.groupe_id == executif.groupe_id)
|
||||||
|
)
|
||||||
|
membre = result.scalar_one_or_none()
|
||||||
|
if not membre:
|
||||||
|
raise HTTPException(status_code=404, detail="Membre introuvable")
|
||||||
|
if str(membre.id) == str(executif.id):
|
||||||
|
raise HTTPException(status_code=400, detail="Impossible de supprimer votre propre compte")
|
||||||
|
|
||||||
|
prenom_original = membre.prenom
|
||||||
|
# Anonymiser
|
||||||
|
membre.prenom = f"Ancien membre"
|
||||||
|
membre.nom = "***"
|
||||||
|
membre.telephone = None
|
||||||
|
membre.courriel = None
|
||||||
|
membre.pin_hash = "SUPPRIME"
|
||||||
|
membre.actif = False
|
||||||
|
membre.push_sub = None
|
||||||
|
await db.flush()
|
||||||
|
return {"message": f"Données de {prenom_original} anonymisées (droit à l'oubli)"}
|
||||||
44
backend/app/routers/parametres.py
Normal file
44
backend/app/routers/parametres.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Routes G9 — Paramètres du groupe."""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import require_executif, get_current_membre
|
||||||
|
from app.models.groupe import Groupe
|
||||||
|
from app.schemas.phase3 import GroupeUpdate, GroupeDetail
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/groupe", tags=["G9 — Paramètres"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=GroupeDetail)
|
||||||
|
async def detail_groupe(
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Détail du groupe du membre connecté."""
|
||||||
|
groupe = await db.get(Groupe, membre.groupe_id)
|
||||||
|
return GroupeDetail.model_validate(groupe)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/", response_model=GroupeDetail)
|
||||||
|
async def modifier_groupe(
|
||||||
|
req: GroupeUpdate,
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G9 — Modifier les paramètres du groupe (exécutif)."""
|
||||||
|
groupe = await db.get(Groupe, executif.groupe_id)
|
||||||
|
if req.nom is not None:
|
||||||
|
groupe.nom = req.nom.strip()
|
||||||
|
if req.adresse_local is not None:
|
||||||
|
groupe.adresse_local = req.adresse_local.strip()
|
||||||
|
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
|
||||||
|
if req.format_reunion is not None:
|
||||||
|
groupe.format_reunion = req.format_reunion.strip()
|
||||||
|
if req.lien_aa87 is not None:
|
||||||
|
groupe.lien_aa87 = req.lien_aa87.strip()
|
||||||
|
await db.flush()
|
||||||
|
return GroupeDetail.model_validate(groupe)
|
||||||
116
backend/app/routers/postes.py
Normal file
116
backend/app/routers/postes.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Routes postes et affectations."""
|
||||||
|
from datetime import date
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre, require_executif
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.schemas.schemas import PosteOut, AffectationOut, AffectationCreate, TerminerMandatRequest
|
||||||
|
|
||||||
|
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()]
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
poste = await db.get(Poste, a.poste_id)
|
||||||
|
out.append({
|
||||||
|
"id": str(a.id),
|
||||||
|
"poste_nom": poste.nom if poste else "?",
|
||||||
|
"poste_categorie": poste.categorie if poste else "?",
|
||||||
|
"date_debut": str(a.date_debut),
|
||||||
|
"statut": a.statut,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/affectations/groupe")
|
||||||
|
async def affectations_groupe(
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G3 — Toutes les affectations actives du groupe."""
|
||||||
|
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
|
||||||
|
poste = await db.get(Poste, a.poste_id)
|
||||||
|
mbr = await db.get(Membre, a.membre_id)
|
||||||
|
out.append({
|
||||||
|
"id": str(a.id),
|
||||||
|
"membre_prenom": mbr.prenom if mbr else "?",
|
||||||
|
"poste_nom": poste.nom if poste else "?",
|
||||||
|
"poste_categorie": poste.categorie if poste else "?",
|
||||||
|
"date_debut": str(a.date_debut),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/affectations")
|
||||||
|
async def creer_affectation(
|
||||||
|
req: AffectationCreate,
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G3 — Affecter un membre à un poste."""
|
||||||
|
affectation = Affectation(
|
||||||
|
groupe_id=executif.groupe_id,
|
||||||
|
membre_id=req.membre_id,
|
||||||
|
poste_id=req.poste_id,
|
||||||
|
date_debut=req.date_debut,
|
||||||
|
statut="actif",
|
||||||
|
)
|
||||||
|
db.add(affectation)
|
||||||
|
await db.flush()
|
||||||
|
return {"message": "Affectation créée", "id": str(affectation.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/affectations/{affectation_id}/terminer")
|
||||||
|
async def terminer_mandat(
|
||||||
|
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,
|
||||||
|
Affectation.groupe_id == executif.groupe_id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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é"}
|
||||||
128
backend/app/routers/presences.py
Normal file
128
backend/app/routers/presences.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""Routes M2 — Présences en réunion d'affaires."""
|
||||||
|
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.reunion import Reunion
|
||||||
|
from app.models.presence import Presence
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase4 import (
|
||||||
|
PresencesBatch, PresenceOut, ReunionAffairesCreate, ReunionAffairesOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/presences", tags=["M2 — Présences affaires"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)):
|
||||||
|
"""Vérifie que le membre est secrétaire ou exécutif."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
(Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reunion", response_model=ReunionAffairesOut)
|
||||||
|
async def creer_reunion_affaires(
|
||||||
|
req: ReunionAffairesCreate,
|
||||||
|
membre=Depends(require_secretaire),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Créer une réunion d'affaires (point de départ pour présences et PV)."""
|
||||||
|
reunion = Reunion(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
type="affaires",
|
||||||
|
date_reunion=req.date_reunion,
|
||||||
|
note=req.note,
|
||||||
|
)
|
||||||
|
db.add(reunion)
|
||||||
|
await db.flush()
|
||||||
|
return ReunionAffairesOut(id=reunion.id, date_reunion=reunion.date_reunion, note=reunion.note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reunions", response_model=list[ReunionAffairesOut])
|
||||||
|
async def liste_reunions_affaires(
|
||||||
|
limite: int = 12,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Lister les réunions d'affaires récentes avec leurs présences."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reunion).where(
|
||||||
|
Reunion.groupe_id == membre.groupe_id,
|
||||||
|
Reunion.type == "affaires",
|
||||||
|
).order_by(desc(Reunion.date_reunion)).limit(limite)
|
||||||
|
)
|
||||||
|
reunions = result.scalars().all()
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for r in reunions:
|
||||||
|
pres_result = await db.execute(
|
||||||
|
select(Presence).where(Presence.reunion_id == r.id)
|
||||||
|
)
|
||||||
|
presences = pres_result.scalars().all()
|
||||||
|
pres_out = []
|
||||||
|
for p in presences:
|
||||||
|
mbr = await db.get(Membre, p.membre_id)
|
||||||
|
pres_out.append(PresenceOut(
|
||||||
|
membre_id=p.membre_id,
|
||||||
|
membre_prenom=mbr.prenom if mbr else "?",
|
||||||
|
present=p.present,
|
||||||
|
))
|
||||||
|
out.append(ReunionAffairesOut(
|
||||||
|
id=r.id, date_reunion=r.date_reunion, note=r.note, presences=pres_out,
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=list[PresenceOut])
|
||||||
|
async def enregistrer_presences(
|
||||||
|
req: PresencesBatch,
|
||||||
|
membre=Depends(require_secretaire),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""M2 — Enregistrer les présences pour une réunion d'affaires."""
|
||||||
|
# Vérifier que la réunion existe et est de type affaires
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reunion).where(
|
||||||
|
Reunion.id == req.reunion_id,
|
||||||
|
Reunion.groupe_id == membre.groupe_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reunion = result.scalar_one_or_none()
|
||||||
|
if not reunion:
|
||||||
|
raise HTTPException(status_code=404, detail="Réunion introuvable")
|
||||||
|
|
||||||
|
# Supprimer les présences existantes pour cette réunion (pour pouvoir re-saisir)
|
||||||
|
existing = await db.execute(
|
||||||
|
select(Presence).where(Presence.reunion_id == req.reunion_id)
|
||||||
|
)
|
||||||
|
for p in existing.scalars().all():
|
||||||
|
await db.delete(p)
|
||||||
|
|
||||||
|
# Insérer les nouvelles
|
||||||
|
out = []
|
||||||
|
for item in req.presences:
|
||||||
|
pres = Presence(
|
||||||
|
reunion_id=req.reunion_id,
|
||||||
|
membre_id=item.membre_id,
|
||||||
|
present=item.present,
|
||||||
|
)
|
||||||
|
db.add(pres)
|
||||||
|
mbr = await db.get(Membre, item.membre_id)
|
||||||
|
out.append(PresenceOut(
|
||||||
|
membre_id=item.membre_id,
|
||||||
|
membre_prenom=mbr.prenom if mbr else "?",
|
||||||
|
present=item.present,
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
return out
|
||||||
157
backend/app/routers/pv.py
Normal file
157
backend/app/routers/pv.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""Routes M1 — Rédiger un PV de réunion d'affaires."""
|
||||||
|
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.pv_reunion import PvReunion
|
||||||
|
from app.models.reunion import Reunion
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.decision import Decision
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase4 import PvCreate, PvOut, PvUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pv", tags=["M1 — Procès-verbal"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_secretaire(membre=Depends(get_current_membre), db=Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
(Poste.nom.ilike("%secrétaire%") | (Poste.categorie == "executif")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au secrétaire ou à l'exécutif")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_out(pv: PvReunion, db: AsyncSession) -> PvOut:
|
||||||
|
redacteur = await db.get(Membre, pv.redacteur_id)
|
||||||
|
reunion = await db.get(Reunion, pv.reunion_id) if pv.reunion_id else None
|
||||||
|
return PvOut(
|
||||||
|
id=pv.id,
|
||||||
|
reunion_id=pv.reunion_id,
|
||||||
|
redacteur_prenom=redacteur.prenom if redacteur else "?",
|
||||||
|
contenu=pv.contenu,
|
||||||
|
decisions=pv.decisions,
|
||||||
|
approuve=pv.approuve,
|
||||||
|
cree_le=pv.cree_le,
|
||||||
|
date_reunion=reunion.date_reunion if reunion else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[PvOut])
|
||||||
|
async def liste_pv(
|
||||||
|
limite: int = 12,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Lister les PV des réunions d'affaires."""
|
||||||
|
# Récupérer les PV via les réunions du groupe
|
||||||
|
result = await db.execute(
|
||||||
|
select(PvReunion)
|
||||||
|
.join(Reunion, PvReunion.reunion_id == Reunion.id)
|
||||||
|
.where(Reunion.groupe_id == membre.groupe_id)
|
||||||
|
.order_by(desc(PvReunion.cree_le))
|
||||||
|
.limit(limite)
|
||||||
|
)
|
||||||
|
return [await _to_out(pv, db) for pv in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{pv_id}", response_model=PvOut)
|
||||||
|
async def detail_pv(
|
||||||
|
pv_id: str,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(PvReunion)
|
||||||
|
.join(Reunion, PvReunion.reunion_id == Reunion.id)
|
||||||
|
.where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id)
|
||||||
|
)
|
||||||
|
pv = result.scalar_one_or_none()
|
||||||
|
if not pv:
|
||||||
|
raise HTTPException(status_code=404, detail="PV introuvable")
|
||||||
|
return await _to_out(pv, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=PvOut)
|
||||||
|
async def creer_pv(
|
||||||
|
req: PvCreate,
|
||||||
|
membre=Depends(require_secretaire),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""M1 — Rédiger un procès-verbal."""
|
||||||
|
# Vérifier la réunion
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reunion).where(
|
||||||
|
Reunion.id == req.reunion_id,
|
||||||
|
Reunion.groupe_id == membre.groupe_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reunion = result.scalar_one_or_none()
|
||||||
|
if not reunion:
|
||||||
|
raise HTTPException(status_code=404, detail="Réunion introuvable")
|
||||||
|
|
||||||
|
decisions_data = None
|
||||||
|
if req.decisions:
|
||||||
|
decisions_data = [d.model_dump() for d in req.decisions]
|
||||||
|
|
||||||
|
pv = PvReunion(
|
||||||
|
reunion_id=req.reunion_id,
|
||||||
|
redacteur_id=membre.id,
|
||||||
|
contenu=req.contenu.strip(),
|
||||||
|
decisions=decisions_data,
|
||||||
|
)
|
||||||
|
db.add(pv)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Créer les décisions dans la table decisions (pour C5)
|
||||||
|
if req.decisions:
|
||||||
|
for d in req.decisions:
|
||||||
|
decision = Decision(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
reunion_id=req.reunion_id,
|
||||||
|
sujet=d.sujet,
|
||||||
|
description=d.decision,
|
||||||
|
statut=d.statut,
|
||||||
|
proposeur=d.proposeur,
|
||||||
|
secondeur=d.secondeur,
|
||||||
|
date_decision=reunion.date_reunion.date() if reunion.date_reunion else None,
|
||||||
|
cree_par=membre.id,
|
||||||
|
)
|
||||||
|
db.add(decision)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return await _to_out(pv, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{pv_id}", response_model=PvOut)
|
||||||
|
async def modifier_pv(
|
||||||
|
pv_id: str,
|
||||||
|
req: PvUpdate,
|
||||||
|
membre=Depends(require_secretaire),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Modifier un PV existant."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(PvReunion)
|
||||||
|
.join(Reunion, PvReunion.reunion_id == Reunion.id)
|
||||||
|
.where(PvReunion.id == pv_id, Reunion.groupe_id == membre.groupe_id)
|
||||||
|
)
|
||||||
|
pv = result.scalar_one_or_none()
|
||||||
|
if not pv:
|
||||||
|
raise HTTPException(status_code=404, detail="PV introuvable")
|
||||||
|
|
||||||
|
if req.contenu is not None:
|
||||||
|
pv.contenu = req.contenu.strip()
|
||||||
|
if req.decisions is not None:
|
||||||
|
pv.decisions = [d.model_dump() for d in req.decisions]
|
||||||
|
if req.approuve is not None:
|
||||||
|
pv.approuve = req.approuve
|
||||||
|
await db.flush()
|
||||||
|
return await _to_out(pv, db)
|
||||||
306
backend/app/routers/rapports.py
Normal file
306
backend/app/routers/rapports.py
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
"""Routes C1 — Rapports PDF (finances, PV, rapport RSG)."""
|
||||||
|
import io
|
||||||
|
from datetime import date, datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, extract
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre
|
||||||
|
from app.models.groupe import Groupe
|
||||||
|
from app.models.collecte import Collecte
|
||||||
|
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
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rapports", tags=["C1 — Rapports PDF"])
|
||||||
|
|
||||||
|
MOIS_FR = [
|
||||||
|
"", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
||||||
|
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _css():
|
||||||
|
return """
|
||||||
|
body { font-family: 'DejaVu Sans', sans-serif; font-size: 11pt; color: #333; margin: 2cm; }
|
||||||
|
h1 { color: #1a5276; font-size: 16pt; margin-bottom: 4pt; }
|
||||||
|
h2 { color: #1a5276; font-size: 13pt; border-bottom: 2px solid #1a5276; padding-bottom: 4pt; margin-top: 20pt; }
|
||||||
|
.header { text-align: center; margin-bottom: 20pt; }
|
||||||
|
.header .groupe { font-size: 18pt; font-weight: bold; color: #1a5276; }
|
||||||
|
.header .sub { font-size: 9pt; color: #888; }
|
||||||
|
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; }
|
||||||
|
.badge-impaye { color: #e74c3c; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/finances/{annee}/{mois}")
|
||||||
|
async def rapport_finances(
|
||||||
|
annee: int,
|
||||||
|
mois: int,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""C1 — Rapport financier mensuel en PDF."""
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=500, detail="WeasyPrint non installé. Installer avec: pip install weasyprint")
|
||||||
|
|
||||||
|
groupe = await db.get(Groupe, membre.groupe_id)
|
||||||
|
|
||||||
|
# Collectes du mois
|
||||||
|
result = await db.execute(
|
||||||
|
select(Collecte).where(
|
||||||
|
Collecte.groupe_id == membre.groupe_id,
|
||||||
|
extract("year", Collecte.date_collecte) == annee,
|
||||||
|
extract("month", Collecte.date_collecte) == mois,
|
||||||
|
).order_by(Collecte.date_collecte)
|
||||||
|
)
|
||||||
|
collectes = result.scalars().all()
|
||||||
|
|
||||||
|
# Dépenses du mois
|
||||||
|
result = await db.execute(
|
||||||
|
select(Depense).where(
|
||||||
|
Depense.groupe_id == membre.groupe_id,
|
||||||
|
extract("year", Depense.date_depense) == annee,
|
||||||
|
extract("month", Depense.date_depense) == mois,
|
||||||
|
).order_by(Depense.date_depense)
|
||||||
|
)
|
||||||
|
depenses = result.scalars().all()
|
||||||
|
|
||||||
|
total_collectes = sum(float(c.montant) for c in collectes)
|
||||||
|
total_depenses = sum(float(d.montant) for d in depenses)
|
||||||
|
solde = total_collectes - total_depenses
|
||||||
|
|
||||||
|
# Construire le HTML
|
||||||
|
rows_collectes = ""
|
||||||
|
for c in collectes:
|
||||||
|
saisi = await db.get(Membre, c.saisi_par) if c.saisi_par else None
|
||||||
|
rows_collectes += f"<tr><td>{c.date_collecte}</td><td>{float(c.montant):.2f} $</td><td>{saisi.prenom if saisi else '-'}</td></tr>"
|
||||||
|
|
||||||
|
rows_depenses = ""
|
||||||
|
for d in depenses:
|
||||||
|
qui = await db.get(Membre, d.qui) if d.qui else None
|
||||||
|
badge = '<span class="badge-paye">✓</span>' if d.paye else '<span class="badge-impaye">✗ DÛ</span>'
|
||||||
|
rows_depenses += f"<tr><td>{d.date_depense}</td><td>{float(d.montant):.2f} $</td><td>{qui.prenom if qui else '-'}</td><td>{d.quoi}</td><td>{d.methode}</td><td>{badge}</td></tr>"
|
||||||
|
|
||||||
|
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>Rapport financier — {MOIS_FR[mois]} {annee}</h1>
|
||||||
|
|
||||||
|
<h2>Collectes (7e Tradition)</h2>
|
||||||
|
<table><tr><th>Date</th><th>Montant</th><th>Saisi par</th></tr>
|
||||||
|
{rows_collectes if rows_collectes else '<tr><td colspan="3">Aucune collecte ce mois</td></tr>'}
|
||||||
|
</table>
|
||||||
|
<p class="total">Total collectes : {total_collectes:.2f} $</p>
|
||||||
|
|
||||||
|
<h2>Dépenses</h2>
|
||||||
|
<table><tr><th>Date</th><th>Montant</th><th>Qui</th><th>Quoi</th><th>Méthode</th><th>Payé</th></tr>
|
||||||
|
{rows_depenses if rows_depenses else '<tr><td colspan="6">Aucune dépense ce mois</td></tr>'}
|
||||||
|
</table>
|
||||||
|
<p class="total">Total dépenses : {total_depenses:.2f} $</p>
|
||||||
|
|
||||||
|
<h2>Sommaire</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td><strong>Collectes</strong></td><td><strong>{total_collectes:.2f} $</strong></td></tr>
|
||||||
|
<tr><td><strong>Dépenses</strong></td><td><strong>−{total_depenses:.2f} $</strong></td></tr>
|
||||||
|
<tr><td><strong>Solde du mois</strong></td><td><strong>{'+'if solde>=0 else ''}{solde:.2f} $</strong></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
Rapport 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"rapport_finances_{groupe.nom.replace(' ','_')}_{annee}_{mois:02d}.pdf"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(pdf_bytes),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reunions/{annee}/{mois}")
|
||||||
|
async def rapport_reunions(
|
||||||
|
annee: int,
|
||||||
|
mois: int,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""C1 — Résumé des réunions du mois 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(Reunion).where(
|
||||||
|
Reunion.groupe_id == membre.groupe_id,
|
||||||
|
extract("year", Reunion.date_reunion) == annee,
|
||||||
|
extract("month", Reunion.date_reunion) == mois,
|
||||||
|
).order_by(Reunion.date_reunion)
|
||||||
|
)
|
||||||
|
reunions = result.scalars().all()
|
||||||
|
|
||||||
|
rows = ""
|
||||||
|
for r in reunions:
|
||||||
|
anim = await db.get(Membre, r.animateur_id) if r.animateur_id else None
|
||||||
|
annonces_str = ", ".join(r.annonces) if r.annonces else "—"
|
||||||
|
rows += f"<tr><td>{r.date_reunion.strftime('%Y-%m-%d')}</td><td>{r.type}</td><td>{anim.prenom if anim else '-'}</td><td>{r.theme or '-'}</td><td>{annonces_str}</td></tr>"
|
||||||
|
|
||||||
|
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>Réunions — {MOIS_FR[mois]} {annee}</h1>
|
||||||
|
<table><tr><th>Date</th><th>Type</th><th>Animateur</th><th>Thème</th><th>Annonces</th></tr>
|
||||||
|
{rows if rows else '<tr><td colspan="5">Aucune réunion enregistrée ce mois</td></tr>'}
|
||||||
|
</table>
|
||||||
|
<p class="total">{len(reunions)} réunion(s) enregistrée(s)</p>
|
||||||
|
<div class="footer">Rapport généré automatiquement · {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||||
|
filename = f"rapport_reunions_{annee}_{mois:02d}.pdf"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(pdf_bytes),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/disponibles")
|
||||||
|
async def rapports_disponibles(
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Liste les mois pour lesquels des rapports sont disponibles."""
|
||||||
|
# Mois avec collectes
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
extract("year", Collecte.date_collecte).label("annee"),
|
||||||
|
extract("month", Collecte.date_collecte).label("mois"),
|
||||||
|
).where(Collecte.groupe_id == membre.groupe_id)
|
||||||
|
.group_by("annee", "mois")
|
||||||
|
.order_by(extract("year", Collecte.date_collecte).desc(), extract("month", Collecte.date_collecte).desc())
|
||||||
|
)
|
||||||
|
mois_finances = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()]
|
||||||
|
|
||||||
|
# Mois avec réunions
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
extract("year", Reunion.date_reunion).label("annee"),
|
||||||
|
extract("month", Reunion.date_reunion).label("mois"),
|
||||||
|
).where(Reunion.groupe_id == membre.groupe_id)
|
||||||
|
.group_by("annee", "mois")
|
||||||
|
.order_by(extract("year", Reunion.date_reunion).desc(), extract("month", Reunion.date_reunion).desc())
|
||||||
|
)
|
||||||
|
mois_reunions = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()]
|
||||||
|
|
||||||
|
# Mois avec contributions
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
extract("year", EnvoiContribution.date_envoi).label("annee"),
|
||||||
|
extract("month", EnvoiContribution.date_envoi).label("mois"),
|
||||||
|
).where(EnvoiContribution.groupe_id == membre.groupe_id)
|
||||||
|
.group_by("annee", "mois")
|
||||||
|
)
|
||||||
|
mois_contributions = [{"annee": int(r.annee), "mois": int(r.mois)} for r in result.all()]
|
||||||
|
|
||||||
|
# Combiner et dédupliquer
|
||||||
|
all_mois = {}
|
||||||
|
for m in mois_finances:
|
||||||
|
key = f"{m['annee']}-{m['mois']:02d}"
|
||||||
|
all_mois[key] = {"annee": m["annee"], "mois": m["mois"], "finances": True, "reunions": False, "contributions": False}
|
||||||
|
for m in mois_reunions:
|
||||||
|
key = f"{m['annee']}-{m['mois']:02d}"
|
||||||
|
if key in all_mois:
|
||||||
|
all_mois[key]["reunions"] = True
|
||||||
|
else:
|
||||||
|
all_mois[key] = {"annee": m["annee"], "mois": m["mois"], "finances": False, "reunions": True, "contributions": False}
|
||||||
|
for m in mois_contributions:
|
||||||
|
key = f"{m['annee']}-{m['mois']:02d}"
|
||||||
|
if key in all_mois:
|
||||||
|
all_mois[key]["contributions"] = True
|
||||||
|
else:
|
||||||
|
all_mois[key] = {"annee": m["annee"], "mois": m["mois"], "finances": False, "reunions": False, "contributions": True}
|
||||||
|
|
||||||
|
return sorted(all_mois.values(), key=lambda x: (x["annee"], x["mois"]), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/financier-complet/{annee}/{mois}")
|
||||||
|
async def rapport_financier_complet(
|
||||||
|
annee: int,
|
||||||
|
mois: int,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Rapport financier complet incluant collectes, dépenses et contributions."""
|
||||||
|
gid = membre.groupe_id
|
||||||
|
|
||||||
|
# Collectes
|
||||||
|
result = await db.execute(
|
||||||
|
select(Collecte).where(
|
||||||
|
Collecte.groupe_id == gid,
|
||||||
|
extract("year", Collecte.date_collecte) == annee,
|
||||||
|
extract("month", Collecte.date_collecte) == mois,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
collectes = result.scalars().all()
|
||||||
|
total_collectes = sum(float(c.montant) for c in collectes)
|
||||||
|
|
||||||
|
# Dépenses
|
||||||
|
result = await db.execute(
|
||||||
|
select(Depense).where(
|
||||||
|
Depense.groupe_id == gid,
|
||||||
|
extract("year", Depense.date_depense) == annee,
|
||||||
|
extract("month", Depense.date_depense) == mois,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
depenses = result.scalars().all()
|
||||||
|
total_depenses = sum(float(d.montant) for d in depenses)
|
||||||
|
depenses_impayees = sum(float(d.montant) for d in depenses if not d.paye)
|
||||||
|
|
||||||
|
# Contributions
|
||||||
|
result = await db.execute(
|
||||||
|
select(EnvoiContribution).where(
|
||||||
|
EnvoiContribution.groupe_id == gid,
|
||||||
|
extract("year", EnvoiContribution.date_envoi) == annee,
|
||||||
|
extract("month", EnvoiContribution.date_envoi) == mois,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
contributions = result.scalars().all()
|
||||||
|
total_contributions = sum(float(c.montant) for c in contributions)
|
||||||
|
|
||||||
|
solde = total_collectes - total_depenses - total_contributions
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mois": mois,
|
||||||
|
"annee": annee,
|
||||||
|
"total_collectes": round(total_collectes, 2),
|
||||||
|
"total_depenses": round(total_depenses, 2),
|
||||||
|
"total_contributions": round(total_contributions, 2),
|
||||||
|
"solde_mois": round(solde, 2),
|
||||||
|
"nb_collectes": len(collectes),
|
||||||
|
"nb_depenses": len(depenses),
|
||||||
|
"nb_contributions": len(contributions),
|
||||||
|
"depenses_impayees": round(depenses_impayees, 2),
|
||||||
|
}
|
||||||
95
backend/app/routers/rapports_rsg.py
Normal file
95
backend/app/routers/rapports_rsg.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""Routes M3 — Rapport du RSG."""
|
||||||
|
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.rapport_rsg import RapportRsg
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase4 import RapportRsgCreate, RapportRsgOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rapports-rsg", tags=["M3 — Rapport RSG"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_rsg(membre=Depends(get_current_membre), db=Depends(get_db)):
|
||||||
|
"""Vérifie que le membre est RSG, RSG substitut ou exécutif."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
(Poste.nom.ilike("%RSG%") | (Poste.categorie == "executif")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au RSG ou à l'exécutif")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_out(r: RapportRsg, db: AsyncSession) -> RapportRsgOut:
|
||||||
|
redacteur = await db.get(Membre, r.redacteur_id)
|
||||||
|
return RapportRsgOut(
|
||||||
|
id=r.id,
|
||||||
|
reunion_dist=r.reunion_dist,
|
||||||
|
redacteur_prenom=redacteur.prenom if redacteur else "?",
|
||||||
|
decisions=r.decisions,
|
||||||
|
infos_region=r.infos_region,
|
||||||
|
sujets_groupe=r.sujets_groupe,
|
||||||
|
cree_le=r.cree_le,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[RapportRsgOut])
|
||||||
|
async def liste_rapports_rsg(
|
||||||
|
limite: int = 12,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Lister les rapports RSG du groupe."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RapportRsg)
|
||||||
|
.where(RapportRsg.groupe_id == membre.groupe_id)
|
||||||
|
.order_by(desc(RapportRsg.reunion_dist))
|
||||||
|
.limit(limite)
|
||||||
|
)
|
||||||
|
return [await _to_out(r, db) for r in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=RapportRsgOut)
|
||||||
|
async def creer_rapport_rsg(
|
||||||
|
req: RapportRsgCreate,
|
||||||
|
membre=Depends(require_rsg),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""M3 — Rédiger un rapport de réunion de district."""
|
||||||
|
rapport = RapportRsg(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
reunion_dist=req.reunion_dist,
|
||||||
|
redacteur_id=membre.id,
|
||||||
|
decisions=req.decisions,
|
||||||
|
infos_region=req.infos_region,
|
||||||
|
sujets_groupe=req.sujets_groupe,
|
||||||
|
)
|
||||||
|
db.add(rapport)
|
||||||
|
await db.flush()
|
||||||
|
return await _to_out(rapport, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{rapport_id}", response_model=RapportRsgOut)
|
||||||
|
async def detail_rapport_rsg(
|
||||||
|
rapport_id: str,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RapportRsg).where(
|
||||||
|
RapportRsg.id == rapport_id,
|
||||||
|
RapportRsg.groupe_id == membre.groupe_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rapport = result.scalar_one_or_none()
|
||||||
|
if not rapport:
|
||||||
|
raise HTTPException(status_code=404, detail="Rapport introuvable")
|
||||||
|
return await _to_out(rapport, db)
|
||||||
113
backend/app/routers/reunions.py
Normal file
113
backend/app/routers/reunions.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Routes S4 — Rapport de réunion."""
|
||||||
|
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.reunion import Reunion
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase1 import ReunionCreate, ReunionOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/reunions", tags=["S4 — Rapport de réunion"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_animateur(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Vérifie que le membre est animateur ou exécutif."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
(Poste.nom.ilike("%animateur%") | (Poste.categorie == "executif")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé à l'animateur ou à l'exécutif")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ReunionOut)
|
||||||
|
async def creer_rapport_reunion(
|
||||||
|
req: ReunionCreate,
|
||||||
|
membre=Depends(require_animateur),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""S4 — L'animateur note le thème et les annonces."""
|
||||||
|
reunion = Reunion(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
type=req.type,
|
||||||
|
date_reunion=req.date_reunion,
|
||||||
|
animateur_id=membre.id,
|
||||||
|
theme=req.theme,
|
||||||
|
annonces=req.annonces,
|
||||||
|
note=req.note,
|
||||||
|
)
|
||||||
|
db.add(reunion)
|
||||||
|
await db.flush()
|
||||||
|
return ReunionOut(
|
||||||
|
id=reunion.id,
|
||||||
|
type=reunion.type,
|
||||||
|
date_reunion=reunion.date_reunion,
|
||||||
|
animateur_prenom=membre.prenom,
|
||||||
|
theme=reunion.theme,
|
||||||
|
annonces=reunion.annonces,
|
||||||
|
note=reunion.note,
|
||||||
|
cree_le=reunion.cree_le,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[ReunionOut])
|
||||||
|
async def liste_reunions(
|
||||||
|
limite: int = 20,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Lister les réunions récentes du groupe."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reunion)
|
||||||
|
.where(Reunion.groupe_id == membre.groupe_id)
|
||||||
|
.order_by(desc(Reunion.date_reunion))
|
||||||
|
.limit(limite)
|
||||||
|
)
|
||||||
|
reunions = result.scalars().all()
|
||||||
|
out = []
|
||||||
|
for r in reunions:
|
||||||
|
anim = await db.get(Membre, r.animateur_id) if r.animateur_id else None
|
||||||
|
out.append(ReunionOut(
|
||||||
|
id=r.id,
|
||||||
|
type=r.type,
|
||||||
|
date_reunion=r.date_reunion,
|
||||||
|
animateur_prenom=anim.prenom if anim else None,
|
||||||
|
theme=r.theme,
|
||||||
|
annonces=r.annonces,
|
||||||
|
note=r.note,
|
||||||
|
cree_le=r.cree_le,
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{reunion_id}", response_model=ReunionOut)
|
||||||
|
async def detail_reunion(
|
||||||
|
reunion_id: str,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Reunion).where(Reunion.id == reunion_id, Reunion.groupe_id == membre.groupe_id)
|
||||||
|
)
|
||||||
|
reunion = result.scalar_one_or_none()
|
||||||
|
if not reunion:
|
||||||
|
raise HTTPException(status_code=404, detail="Réunion introuvable")
|
||||||
|
anim = await db.get(Membre, reunion.animateur_id) if reunion.animateur_id else None
|
||||||
|
return ReunionOut(
|
||||||
|
id=reunion.id,
|
||||||
|
type=reunion.type,
|
||||||
|
date_reunion=reunion.date_reunion,
|
||||||
|
animateur_prenom=anim.prenom if anim else None,
|
||||||
|
theme=reunion.theme,
|
||||||
|
annonces=reunion.annonces,
|
||||||
|
note=reunion.note,
|
||||||
|
cree_le=reunion.cree_le,
|
||||||
|
)
|
||||||
125
backend/app/routers/rotations.py
Normal file
125
backend/app/routers/rotations.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""Routes G5 — Planifier les rotations hebdomadaires."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import require_executif, get_current_membre
|
||||||
|
from app.models.rotation import Rotation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.schemas.phase3 import RotationCreate, RotationOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rotations", tags=["G5 — Rotations"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_out(r: Rotation, db: AsyncSession) -> RotationOut:
|
||||||
|
poste = await db.get(Poste, r.poste_id)
|
||||||
|
mbr = await db.get(Membre, r.membre_id)
|
||||||
|
return RotationOut(
|
||||||
|
id=r.id,
|
||||||
|
date_reunion=r.date_reunion,
|
||||||
|
poste_nom=poste.nom if poste else "?",
|
||||||
|
membre_prenom=mbr.prenom if mbr else "?",
|
||||||
|
note=r.note,
|
||||||
|
cree_le=r.cree_le,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[RotationOut])
|
||||||
|
async def liste_rotations(
|
||||||
|
semaines: int = 4,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Rotations des prochaines semaines."""
|
||||||
|
aujourdhui = date.today()
|
||||||
|
fin = aujourdhui + timedelta(weeks=semaines)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Rotation).where(
|
||||||
|
Rotation.groupe_id == membre.groupe_id,
|
||||||
|
Rotation.date_reunion >= aujourdhui,
|
||||||
|
Rotation.date_reunion <= fin,
|
||||||
|
).order_by(Rotation.date_reunion, Rotation.poste_id)
|
||||||
|
)
|
||||||
|
return [await _to_out(r, db) for r in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/historique", response_model=list[RotationOut])
|
||||||
|
async def historique_rotations(
|
||||||
|
semaines: int = 4,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Rotations passées."""
|
||||||
|
aujourdhui = date.today()
|
||||||
|
debut = aujourdhui - timedelta(weeks=semaines)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Rotation).where(
|
||||||
|
Rotation.groupe_id == membre.groupe_id,
|
||||||
|
Rotation.date_reunion >= debut,
|
||||||
|
Rotation.date_reunion < aujourdhui,
|
||||||
|
).order_by(Rotation.date_reunion.desc())
|
||||||
|
)
|
||||||
|
return [await _to_out(r, db) for r in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=RotationOut)
|
||||||
|
async def creer_rotation(
|
||||||
|
req: RotationCreate,
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G5 — Assigner un serviteur pour une date de réunion."""
|
||||||
|
rotation = Rotation(
|
||||||
|
groupe_id=executif.groupe_id,
|
||||||
|
date_reunion=req.date_reunion,
|
||||||
|
poste_id=req.poste_id,
|
||||||
|
membre_id=req.membre_id,
|
||||||
|
note=req.note,
|
||||||
|
cree_par=executif.id,
|
||||||
|
)
|
||||||
|
db.add(rotation)
|
||||||
|
await db.flush()
|
||||||
|
return await _to_out(rotation, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=list[RotationOut])
|
||||||
|
async def creer_rotations_batch(
|
||||||
|
rotations: list[RotationCreate],
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G5 — Assigner plusieurs serviteurs d'un coup pour une semaine."""
|
||||||
|
created = []
|
||||||
|
for req in rotations:
|
||||||
|
rotation = Rotation(
|
||||||
|
groupe_id=executif.groupe_id,
|
||||||
|
date_reunion=req.date_reunion,
|
||||||
|
poste_id=req.poste_id,
|
||||||
|
membre_id=req.membre_id,
|
||||||
|
note=req.note,
|
||||||
|
cree_par=executif.id,
|
||||||
|
)
|
||||||
|
db.add(rotation)
|
||||||
|
await db.flush()
|
||||||
|
created.append(await _to_out(rotation, db))
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{rotation_id}")
|
||||||
|
async def supprimer_rotation(
|
||||||
|
rotation_id: str,
|
||||||
|
executif=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Supprimer une rotation planifiée."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Rotation).where(Rotation.id == rotation_id, Rotation.groupe_id == executif.groupe_id)
|
||||||
|
)
|
||||||
|
rotation = result.scalar_one_or_none()
|
||||||
|
if not rotation:
|
||||||
|
raise HTTPException(status_code=404, detail="Rotation introuvable")
|
||||||
|
await db.delete(rotation)
|
||||||
|
return {"message": "Rotation supprimée"}
|
||||||
219
backend/app/routers/sondages.py
Normal file
219
backend/app/routers/sondages.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
"""Routes G6/C6 — Sondages et votes de conscience de groupe."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, desc, func
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_membre, require_executif
|
||||||
|
from app.models.sondage import Sondage
|
||||||
|
from app.models.option_sondage import OptionSondage
|
||||||
|
from app.models.vote import Vote
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.schemas.phase8 import SondageCreate, SondageOut, SondageUpdate, OptionOut, VoteCreate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sondages", tags=["G6/C6 — Sondages et votes"])
|
||||||
|
|
||||||
|
TYPES_VALIDES = {"oui_non", "choix_multiple", "unanimite"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_sondage_out(sondage: Sondage, membre_id, db: AsyncSession) -> SondageOut:
|
||||||
|
"""Construit la sortie complète d'un sondage avec comptage des votes."""
|
||||||
|
cree = await db.get(Membre, sondage.cree_par) if sondage.cree_par else None
|
||||||
|
|
||||||
|
# Options avec comptage
|
||||||
|
result = await db.execute(
|
||||||
|
select(OptionSondage)
|
||||||
|
.where(OptionSondage.sondage_id == sondage.id)
|
||||||
|
.order_by(OptionSondage.ordre)
|
||||||
|
)
|
||||||
|
options = result.scalars().all()
|
||||||
|
|
||||||
|
options_out = []
|
||||||
|
total = 0
|
||||||
|
for opt in options:
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count(Vote.id)).where(Vote.option_id == opt.id)
|
||||||
|
)
|
||||||
|
nb = count_result.scalar() or 0
|
||||||
|
total += nb
|
||||||
|
options_out.append(OptionOut(
|
||||||
|
id=opt.id, texte=opt.texte, ordre=opt.ordre, nb_votes=nb
|
||||||
|
))
|
||||||
|
|
||||||
|
# Le membre a-t-il voté?
|
||||||
|
vote_result = await db.execute(
|
||||||
|
select(Vote).where(Vote.sondage_id == sondage.id, Vote.membre_id == membre_id)
|
||||||
|
)
|
||||||
|
a_vote = vote_result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
return SondageOut(
|
||||||
|
id=sondage.id,
|
||||||
|
titre=sondage.titre,
|
||||||
|
description=sondage.description,
|
||||||
|
type=sondage.type,
|
||||||
|
cree_par_prenom=cree.prenom if cree else None,
|
||||||
|
date_ouverture=sondage.date_ouverture,
|
||||||
|
date_fermeture=sondage.date_fermeture,
|
||||||
|
statut=sondage.statut,
|
||||||
|
options=options_out,
|
||||||
|
total_votes=total,
|
||||||
|
a_vote=a_vote,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=SondageOut)
|
||||||
|
async def creer_sondage(
|
||||||
|
req: SondageCreate,
|
||||||
|
membre=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""G6 — Créer un sondage de conscience de groupe."""
|
||||||
|
if req.type not in TYPES_VALIDES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Type invalide. Choix: {list(TYPES_VALIDES)}")
|
||||||
|
|
||||||
|
if req.date_fermeture <= req.date_ouverture:
|
||||||
|
raise HTTPException(status_code=400, detail="La date de fermeture doit être après l'ouverture")
|
||||||
|
|
||||||
|
# Pour oui_non, créer automatiquement les options
|
||||||
|
options_data = req.options
|
||||||
|
if req.type == "oui_non" and len(options_data) == 0:
|
||||||
|
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")]
|
||||||
|
|
||||||
|
if len(options_data) < 2:
|
||||||
|
raise HTTPException(status_code=400, detail="Au moins 2 options sont requises")
|
||||||
|
|
||||||
|
sondage = Sondage(
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
titre=req.titre.strip(),
|
||||||
|
description=req.description,
|
||||||
|
type=req.type,
|
||||||
|
cree_par=membre.id,
|
||||||
|
date_ouverture=req.date_ouverture,
|
||||||
|
date_fermeture=req.date_fermeture,
|
||||||
|
statut="brouillon",
|
||||||
|
)
|
||||||
|
db.add(sondage)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
for i, opt in enumerate(options_data):
|
||||||
|
option = OptionSondage(
|
||||||
|
sondage_id=sondage.id,
|
||||||
|
texte=opt.texte.strip(),
|
||||||
|
ordre=i,
|
||||||
|
)
|
||||||
|
db.add(option)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return await _build_sondage_out(sondage, membre.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[SondageOut])
|
||||||
|
async def liste_sondages(
|
||||||
|
statut: str | None = None,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Lister les sondages du groupe."""
|
||||||
|
query = select(Sondage).where(Sondage.groupe_id == membre.groupe_id)
|
||||||
|
if statut:
|
||||||
|
query = query.where(Sondage.statut == statut)
|
||||||
|
query = query.order_by(desc(Sondage.date_ouverture))
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
sondages = result.scalars().all()
|
||||||
|
|
||||||
|
return [await _build_sondage_out(s, membre.id, db) for s in sondages]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sondage_id}", response_model=SondageOut)
|
||||||
|
async def detail_sondage(
|
||||||
|
sondage_id: str,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id)
|
||||||
|
)
|
||||||
|
sondage = result.scalar_one_or_none()
|
||||||
|
if not sondage:
|
||||||
|
raise HTTPException(status_code=404, detail="Sondage introuvable")
|
||||||
|
return await _build_sondage_out(sondage, membre.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sondage_id}", response_model=SondageOut)
|
||||||
|
async def modifier_statut_sondage(
|
||||||
|
sondage_id: str,
|
||||||
|
req: SondageUpdate,
|
||||||
|
membre=Depends(require_executif),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Ouvrir ou fermer un sondage."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id)
|
||||||
|
)
|
||||||
|
sondage = result.scalar_one_or_none()
|
||||||
|
if not sondage:
|
||||||
|
raise HTTPException(status_code=404, detail="Sondage introuvable")
|
||||||
|
|
||||||
|
if req.statut and req.statut in ("brouillon", "ouvert", "ferme"):
|
||||||
|
sondage.statut = req.statut
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return await _build_sondage_out(sondage, membre.id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sondage_id}/voter")
|
||||||
|
async def voter(
|
||||||
|
sondage_id: str,
|
||||||
|
req: VoteCreate,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""C6 — Voter dans un sondage ouvert. Un membre = un vote."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Sondage).where(Sondage.id == sondage_id, Sondage.groupe_id == membre.groupe_id)
|
||||||
|
)
|
||||||
|
sondage = result.scalar_one_or_none()
|
||||||
|
if not sondage:
|
||||||
|
raise HTTPException(status_code=404, detail="Sondage introuvable")
|
||||||
|
|
||||||
|
if sondage.statut != "ouvert":
|
||||||
|
raise HTTPException(status_code=400, detail="Ce sondage n'est pas ouvert au vote")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if now < sondage.date_ouverture or now > sondage.date_fermeture:
|
||||||
|
raise HTTPException(status_code=400, detail="La période de vote est terminée ou pas encore ouverte")
|
||||||
|
|
||||||
|
# Vérifier qu'il n'a pas déjà voté
|
||||||
|
existing = await db.execute(
|
||||||
|
select(Vote).where(Vote.sondage_id == sondage.id, Vote.membre_id == membre.id)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Vous avez déjà voté dans ce sondage")
|
||||||
|
|
||||||
|
# Vérifier que l'option appartient au sondage
|
||||||
|
opt_result = await db.execute(
|
||||||
|
select(OptionSondage).where(
|
||||||
|
OptionSondage.id == req.option_id,
|
||||||
|
OptionSondage.sondage_id == sondage.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not opt_result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Option invalide")
|
||||||
|
|
||||||
|
vote = Vote(
|
||||||
|
sondage_id=sondage.id,
|
||||||
|
option_id=req.option_id,
|
||||||
|
membre_id=membre.id,
|
||||||
|
)
|
||||||
|
db.add(vote)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {"message": "Vote enregistré", "option_id": str(req.option_id)}
|
||||||
112
backend/app/routers/ventes_litterature.py
Normal file
112
backend/app/routers/ventes_litterature.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Routes S3 — Vente de littérature."""
|
||||||
|
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.litterature import Litterature
|
||||||
|
from app.models.vente_litterature import VenteLitterature
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.schemas.phase6 import VenteCreate, VenteOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ventes-litterature", tags=["S3 — Vente littérature"])
|
||||||
|
|
||||||
|
|
||||||
|
async def require_resp_litterature(membre=Depends(get_current_membre), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Vérifie que le membre est responsable de la littérature."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Affectation).join(Poste).where(
|
||||||
|
Affectation.membre_id == membre.id,
|
||||||
|
Affectation.statut == "actif",
|
||||||
|
Poste.nom.ilike("%littérature%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=403, detail="Réservé au responsable de la littérature")
|
||||||
|
return membre
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=VenteOut)
|
||||||
|
async def enregistrer_vente(
|
||||||
|
req: VenteCreate,
|
||||||
|
membre=Depends(require_resp_litterature),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""S3 — Enregistrer une vente de littérature. Le stock est décrémenté automatiquement."""
|
||||||
|
# Vérifier le titre
|
||||||
|
result = await db.execute(
|
||||||
|
select(Litterature).where(
|
||||||
|
Litterature.id == req.litterature_id,
|
||||||
|
Litterature.groupe_id == membre.groupe_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
item = result.scalar_one_or_none()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Titre introuvable")
|
||||||
|
|
||||||
|
if req.quantite <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="La quantité doit être positive")
|
||||||
|
|
||||||
|
if item.quantite_stock < req.quantite:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Stock insuffisant ({item.quantite_stock} disponible, {req.quantite} demandé)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Décrémenter le stock
|
||||||
|
item.quantite_stock -= req.quantite
|
||||||
|
|
||||||
|
# Créer la vente
|
||||||
|
vente = VenteLitterature(
|
||||||
|
litterature_id=req.litterature_id,
|
||||||
|
groupe_id=membre.groupe_id,
|
||||||
|
quantite=req.quantite,
|
||||||
|
montant=req.montant,
|
||||||
|
date_vente=req.date_vente,
|
||||||
|
vendu_par=membre.id,
|
||||||
|
)
|
||||||
|
db.add(vente)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return VenteOut(
|
||||||
|
id=vente.id,
|
||||||
|
litterature_titre=item.titre,
|
||||||
|
quantite=vente.quantite,
|
||||||
|
montant=float(vente.montant),
|
||||||
|
date_vente=vente.date_vente,
|
||||||
|
vendu_par_prenom=membre.prenom,
|
||||||
|
cree_le=vente.cree_le,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[VenteOut])
|
||||||
|
async def liste_ventes(
|
||||||
|
limite: int = 20,
|
||||||
|
membre=Depends(get_current_membre),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Lister les ventes récentes."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(VenteLitterature)
|
||||||
|
.where(VenteLitterature.groupe_id == membre.groupe_id)
|
||||||
|
.order_by(desc(VenteLitterature.date_vente))
|
||||||
|
.limit(limite)
|
||||||
|
)
|
||||||
|
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(VenteOut(
|
||||||
|
id=v.id,
|
||||||
|
litterature_titre=item.titre if item else "?",
|
||||||
|
quantite=v.quantite,
|
||||||
|
montant=float(v.montant),
|
||||||
|
date_vente=v.date_vente,
|
||||||
|
vendu_par_prenom=vendeur.prenom if vendeur else None,
|
||||||
|
cree_le=v.cree_le,
|
||||||
|
))
|
||||||
|
return out
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
74
backend/app/schemas/phase1.py
Normal file
74
backend/app/schemas/phase1.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Schémas Phase 1 — Saisies hebdomadaires."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── S1 Collecte ──
|
||||||
|
class CollecteCreate(BaseModel):
|
||||||
|
date_collecte: date
|
||||||
|
montant: float
|
||||||
|
reunion_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CollecteOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
date_collecte: date
|
||||||
|
montant: float
|
||||||
|
saisi_par_prenom: Optional[str] = None
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── S4 Rapport de réunion ──
|
||||||
|
class ReunionCreate(BaseModel):
|
||||||
|
type: str = "reguliere"
|
||||||
|
date_reunion: datetime
|
||||||
|
theme: Optional[str] = None
|
||||||
|
annonces: Optional[list[str]] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReunionOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
type: str
|
||||||
|
date_reunion: datetime
|
||||||
|
animateur_prenom: Optional[str] = None
|
||||||
|
theme: Optional[str] = None
|
||||||
|
annonces: Optional[list[str]] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── S5 Dépense (6 champs) ──
|
||||||
|
class DepenseCreate(BaseModel):
|
||||||
|
date_depense: date
|
||||||
|
montant: float
|
||||||
|
qui: UUID # membre_id
|
||||||
|
quoi: str
|
||||||
|
paye: bool = False
|
||||||
|
methode: str # DU, VIREMENT, CHEQUE, COLLECTE
|
||||||
|
|
||||||
|
|
||||||
|
class DepenseOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
date_depense: date
|
||||||
|
montant: float
|
||||||
|
qui_prenom: Optional[str] = None
|
||||||
|
quoi: str
|
||||||
|
paye: bool
|
||||||
|
methode: str
|
||||||
|
saisi_par_prenom: Optional[str] = None
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DepenseUpdate(BaseModel):
|
||||||
|
paye: Optional[bool] = None
|
||||||
|
methode: Optional[str] = None
|
||||||
|
quoi: Optional[str] = None
|
||||||
63
backend/app/schemas/phase2.py
Normal file
63
backend/app/schemas/phase2.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Schémas Phase 2 — Consultation."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── C5 Décisions ──
|
||||||
|
class DecisionCreate(BaseModel):
|
||||||
|
sujet: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
statut: str = "en_attente"
|
||||||
|
responsable_id: Optional[UUID] = None
|
||||||
|
proposeur: Optional[str] = None
|
||||||
|
secondeur: Optional[str] = None
|
||||||
|
date_decision: date
|
||||||
|
reunion_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
sujet: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
statut: str
|
||||||
|
responsable_prenom: Optional[str] = None
|
||||||
|
proposeur: Optional[str] = None
|
||||||
|
secondeur: Optional[str] = None
|
||||||
|
date_decision: date
|
||||||
|
date_completion: Optional[date] = None
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionUpdate(BaseModel):
|
||||||
|
statut: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
responsable_id: Optional[UUID] = None
|
||||||
|
date_completion: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── C2 Calendrier district ──
|
||||||
|
class CalendrierEntry(BaseModel):
|
||||||
|
groupe_nom: str
|
||||||
|
groupe_id: UUID
|
||||||
|
jour_reunion: Optional[str] = None
|
||||||
|
poste_nom: str
|
||||||
|
membre_prenom: str
|
||||||
|
date_debut: date
|
||||||
|
|
||||||
|
|
||||||
|
# ── C1 Rapports PDF ──
|
||||||
|
class RapportRequest(BaseModel):
|
||||||
|
type: str # finances, pv, rsg
|
||||||
|
mois: int
|
||||||
|
annee: int
|
||||||
|
|
||||||
|
|
||||||
|
class RapportOut(BaseModel):
|
||||||
|
type: str
|
||||||
|
mois: int
|
||||||
|
annee: int
|
||||||
|
url: str
|
||||||
49
backend/app/schemas/phase3.py
Normal file
49
backend/app/schemas/phase3.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Schémas Phase 3 — Gestion."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── G5 Rotations ──
|
||||||
|
class RotationCreate(BaseModel):
|
||||||
|
date_reunion: date
|
||||||
|
poste_id: UUID
|
||||||
|
membre_id: UUID
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RotationOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
date_reunion: date
|
||||||
|
poste_nom: str
|
||||||
|
membre_prenom: str
|
||||||
|
note: Optional[str] = None
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── G9 Paramètres du groupe ──
|
||||||
|
class GroupeUpdate(BaseModel):
|
||||||
|
nom: Optional[str] = None
|
||||||
|
adresse_local: Optional[str] = None
|
||||||
|
jour_reunion: Optional[str] = None
|
||||||
|
heure_reunion: Optional[str] = None
|
||||||
|
format_reunion: Optional[str] = None
|
||||||
|
lien_aa87: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GroupeDetail(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
nom: str
|
||||||
|
district: str
|
||||||
|
region: str
|
||||||
|
adresse_local: Optional[str] = None
|
||||||
|
jour_reunion: Optional[str] = None
|
||||||
|
heure_reunion: Optional[str] = None
|
||||||
|
format_reunion: Optional[str] = None
|
||||||
|
lien_aa87: str
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
91
backend/app/schemas/phase4.py
Normal file
91
backend/app/schemas/phase4.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Schémas Phase 4 — PV, Présences, Rapport RSG."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── M2 Présences (réunion d'affaires) ──
|
||||||
|
class PresenceItem(BaseModel):
|
||||||
|
membre_id: UUID
|
||||||
|
present: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PresencesBatch(BaseModel):
|
||||||
|
reunion_id: UUID
|
||||||
|
presences: list[PresenceItem]
|
||||||
|
|
||||||
|
|
||||||
|
class PresenceOut(BaseModel):
|
||||||
|
membre_id: UUID
|
||||||
|
membre_prenom: str
|
||||||
|
present: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ReunionAffairesCreate(BaseModel):
|
||||||
|
"""Créer une réunion d'affaires (pour les présences et le PV)."""
|
||||||
|
date_reunion: datetime
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReunionAffairesOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
date_reunion: datetime
|
||||||
|
note: Optional[str] = None
|
||||||
|
presences: list[PresenceOut] = []
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── M1 Procès-verbal ──
|
||||||
|
class DecisionPV(BaseModel):
|
||||||
|
sujet: str
|
||||||
|
decision: str
|
||||||
|
proposeur: Optional[str] = None
|
||||||
|
secondeur: Optional[str] = None
|
||||||
|
statut: str = "en_attente"
|
||||||
|
|
||||||
|
|
||||||
|
class PvCreate(BaseModel):
|
||||||
|
reunion_id: UUID
|
||||||
|
contenu: str
|
||||||
|
decisions: Optional[list[DecisionPV]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PvOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
reunion_id: UUID
|
||||||
|
redacteur_prenom: str
|
||||||
|
contenu: str
|
||||||
|
decisions: Optional[list[dict]] = None
|
||||||
|
approuve: bool
|
||||||
|
cree_le: datetime
|
||||||
|
date_reunion: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class PvUpdate(BaseModel):
|
||||||
|
contenu: Optional[str] = None
|
||||||
|
decisions: Optional[list[DecisionPV]] = None
|
||||||
|
approuve: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── M3 Rapport RSG ──
|
||||||
|
class RapportRsgCreate(BaseModel):
|
||||||
|
reunion_dist: date
|
||||||
|
decisions: Optional[list[str]] = None
|
||||||
|
infos_region: Optional[str] = None
|
||||||
|
sujets_groupe: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RapportRsgOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
reunion_dist: date
|
||||||
|
redacteur_prenom: str
|
||||||
|
decisions: Optional[list[str]] = None
|
||||||
|
infos_region: Optional[str] = None
|
||||||
|
sujets_groupe: Optional[str] = None
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
56
backend/app/schemas/phase5.py
Normal file
56
backend/app/schemas/phase5.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Schémas Phase 5 — Jetons, Anniversaires, Commande jetons."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── S2 Remise de jeton ──
|
||||||
|
class JetonCreate(BaseModel):
|
||||||
|
membre_id: Optional[UUID] = None # None pour jeton de désir anonyme
|
||||||
|
type_jeton: str # desir, 24h, 1mois, 2mois, 3mois, 6mois, 9mois, 1an, multiple
|
||||||
|
date_remise: date
|
||||||
|
annees: Optional[int] = None # requis si type_jeton='multiple'
|
||||||
|
|
||||||
|
|
||||||
|
class JetonOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
membre_prenom: Optional[str] = None
|
||||||
|
type_jeton: str
|
||||||
|
date_remise: date
|
||||||
|
annees: Optional[int] = None
|
||||||
|
remis_par_prenom: Optional[str] = None
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── C3 Anniversaires district ──
|
||||||
|
class AnniversaireOut(BaseModel):
|
||||||
|
membre_prenom: str
|
||||||
|
groupe_nom: str
|
||||||
|
date_abstinence: date
|
||||||
|
annees_sobriete: int
|
||||||
|
prochain_anniversaire: date
|
||||||
|
jours_restants: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── M6 Commande de jetons ──
|
||||||
|
class StockJetonsOut(BaseModel):
|
||||||
|
type_jeton: str
|
||||||
|
label: str
|
||||||
|
remis_dernier_mois: int
|
||||||
|
stock_suggestion: str # "OK" ou "Bas — commander"
|
||||||
|
|
||||||
|
|
||||||
|
TYPES_JETONS = [
|
||||||
|
{"value": "desir", "label": "Désir", "emoji": "💫", "ordre": 1},
|
||||||
|
{"value": "24h", "label": "24 heures", "emoji": "🌅", "ordre": 2},
|
||||||
|
{"value": "1mois", "label": "1 mois", "emoji": "🥉", "ordre": 3},
|
||||||
|
{"value": "2mois", "label": "2 mois", "emoji": "🥉", "ordre": 4},
|
||||||
|
{"value": "3mois", "label": "3 mois", "emoji": "🥈", "ordre": 5},
|
||||||
|
{"value": "6mois", "label": "6 mois", "emoji": "🥈", "ordre": 6},
|
||||||
|
{"value": "9mois", "label": "9 mois", "emoji": "🥇", "ordre": 7},
|
||||||
|
{"value": "1an", "label": "1 an", "emoji": "⭐", "ordre": 8},
|
||||||
|
{"value": "multiple", "label": "Multiple", "emoji": "🏆", "ordre": 9},
|
||||||
|
]
|
||||||
61
backend/app/schemas/phase6.py
Normal file
61
backend/app/schemas/phase6.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Schémas Phase 6 — Littérature : inventaire, vente, ajustement."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── Littérature (inventaire) ──
|
||||||
|
class LitteratureCreate(BaseModel):
|
||||||
|
titre: str
|
||||||
|
categorie: str # livre, brochure, depliant, pamphlet
|
||||||
|
prix_coutant: Optional[float] = None
|
||||||
|
quantite_stock: int = 0
|
||||||
|
seuil_commande: int = 2
|
||||||
|
code_bsg: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LitteratureOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
titre: str
|
||||||
|
categorie: str
|
||||||
|
prix_coutant: Optional[float] = None
|
||||||
|
quantite_stock: int
|
||||||
|
seuil_commande: int
|
||||||
|
code_bsg: Optional[str] = None
|
||||||
|
stock_bas: bool = False
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class LitteratureUpdate(BaseModel):
|
||||||
|
titre: Optional[str] = None
|
||||||
|
categorie: Optional[str] = None
|
||||||
|
prix_coutant: Optional[float] = None
|
||||||
|
seuil_commande: Optional[int] = None
|
||||||
|
code_bsg: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── M5 Ajuster inventaire ──
|
||||||
|
class AjustementStock(BaseModel):
|
||||||
|
quantite: int # positif = ajout, négatif = retrait
|
||||||
|
|
||||||
|
|
||||||
|
# ── S3 Vente littérature ──
|
||||||
|
class VenteCreate(BaseModel):
|
||||||
|
litterature_id: UUID
|
||||||
|
quantite: int
|
||||||
|
montant: float
|
||||||
|
date_vente: date
|
||||||
|
|
||||||
|
|
||||||
|
class VenteOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
litterature_titre: str
|
||||||
|
quantite: int
|
||||||
|
montant: float
|
||||||
|
date_vente: date
|
||||||
|
vendu_par_prenom: Optional[str] = None
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
82
backend/app/schemas/phase7.py
Normal file
82
backend/app/schemas/phase7.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Schémas Phase 7 — Finances avancé : contributions, répartition, rapport complet."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
DESTINATIONS = ["district", "region", "bsg", "intergroupe"]
|
||||||
|
DESTINATIONS_LABELS = {
|
||||||
|
"district": "District 87-16",
|
||||||
|
"region": "Région 87",
|
||||||
|
"bsg": "BSG",
|
||||||
|
"intergroupe": "Intergroupe",
|
||||||
|
}
|
||||||
|
METHODES_CONTRIBUTION = ["DU", "VIREMENT", "CHEQUE"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── M4 Envoi de contributions ──
|
||||||
|
class EnvoiContributionCreate(BaseModel):
|
||||||
|
date_envoi: date
|
||||||
|
destination: str # district, region, bsg, intergroupe
|
||||||
|
montant: float
|
||||||
|
methode: str # DU, VIREMENT, CHEQUE
|
||||||
|
|
||||||
|
|
||||||
|
class EnvoiContributionOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
date_envoi: date
|
||||||
|
destination: str
|
||||||
|
destination_label: str
|
||||||
|
montant: float
|
||||||
|
methode: str
|
||||||
|
saisi_par_prenom: Optional[str] = None
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CalculRepartitionIn(BaseModel):
|
||||||
|
"""Montant à répartir — le backend calcule les montants par destination."""
|
||||||
|
montant_total: float
|
||||||
|
|
||||||
|
|
||||||
|
class CalculRepartitionOut(BaseModel):
|
||||||
|
destination: str
|
||||||
|
destination_label: str
|
||||||
|
pourcentage: float
|
||||||
|
montant: float
|
||||||
|
|
||||||
|
|
||||||
|
# ── G8 Config répartition ──
|
||||||
|
class ConfigRepartitionItem(BaseModel):
|
||||||
|
destination: str
|
||||||
|
pourcentage: float
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRepartitionOut(BaseModel):
|
||||||
|
destination: str
|
||||||
|
destination_label: str
|
||||||
|
pourcentage: float
|
||||||
|
actif_depuis: date
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRepartitionBatch(BaseModel):
|
||||||
|
"""Mettre à jour toutes les répartitions d'un coup."""
|
||||||
|
items: list[ConfigRepartitionItem]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rapport financier complet ──
|
||||||
|
class RapportFinancierComplet(BaseModel):
|
||||||
|
mois: int
|
||||||
|
annee: int
|
||||||
|
total_collectes: float
|
||||||
|
total_depenses: float
|
||||||
|
total_contributions: float
|
||||||
|
solde_mois: float
|
||||||
|
nb_collectes: int
|
||||||
|
nb_depenses: int
|
||||||
|
nb_contributions: int
|
||||||
|
depenses_impayees: float
|
||||||
78
backend/app/schemas/phase8.py
Normal file
78
backend/app/schemas/phase8.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""Schémas Phase 8 — Votes (sondages) et parrainage (jumelages)."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── G6 Lancer un sondage ──
|
||||||
|
class OptionCreate(BaseModel):
|
||||||
|
texte: str
|
||||||
|
|
||||||
|
|
||||||
|
class SondageCreate(BaseModel):
|
||||||
|
titre: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
type: str = "oui_non" # oui_non, choix_multiple, unanimite
|
||||||
|
date_ouverture: datetime
|
||||||
|
date_fermeture: datetime
|
||||||
|
options: list[OptionCreate] = []
|
||||||
|
|
||||||
|
|
||||||
|
class OptionOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
texte: str
|
||||||
|
ordre: int
|
||||||
|
nb_votes: int = 0
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SondageOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
titre: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
type: str
|
||||||
|
cree_par_prenom: Optional[str] = None
|
||||||
|
date_ouverture: datetime
|
||||||
|
date_fermeture: datetime
|
||||||
|
statut: str
|
||||||
|
options: list[OptionOut] = []
|
||||||
|
total_votes: int = 0
|
||||||
|
a_vote: bool = False # le membre courant a-t-il voté?
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SondageUpdate(BaseModel):
|
||||||
|
statut: Optional[str] = None # ouvert, ferme
|
||||||
|
|
||||||
|
|
||||||
|
# ── C6 Voter ──
|
||||||
|
class VoteCreate(BaseModel):
|
||||||
|
option_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
# ── G7 Jumelage parrain/filleul ──
|
||||||
|
class JumelageCreate(BaseModel):
|
||||||
|
parrain_id: UUID
|
||||||
|
filleul_id: UUID
|
||||||
|
date_jumelage: date
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class JumelageOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
parrain_prenom: str
|
||||||
|
filleul_prenom: str
|
||||||
|
date_jumelage: date
|
||||||
|
statut: str
|
||||||
|
note: Optional[str] = None
|
||||||
|
cree_par_prenom: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class JumelageUpdate(BaseModel):
|
||||||
|
statut: Optional[str] = None # actif, termine
|
||||||
|
note: Optional[str] = None
|
||||||
134
backend/app/schemas/schemas.py
Normal file
134
backend/app/schemas/schemas.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""Schémas Pydantic pour validation et sérialisation."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth ──
|
||||||
|
class ConnexionRequest(BaseModel):
|
||||||
|
prenom: str
|
||||||
|
pin: str
|
||||||
|
groupe_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class InscriptionRequest(BaseModel):
|
||||||
|
prenom: str
|
||||||
|
nom: str
|
||||||
|
pin: str
|
||||||
|
code_invitation: str
|
||||||
|
telephone: Optional[str] = None
|
||||||
|
courriel: Optional[str] = None
|
||||||
|
date_abstinence: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
membre: "MembrePublic"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Membre ──
|
||||||
|
class MembrePublic(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
prenom: str
|
||||||
|
groupe_id: UUID
|
||||||
|
date_abstinence: Optional[date] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MembreComplet(MembrePublic):
|
||||||
|
nom: str
|
||||||
|
telephone: Optional[str] = None
|
||||||
|
courriel: Optional[str] = None
|
||||||
|
actif: bool
|
||||||
|
cree_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MembreUpdate(BaseModel):
|
||||||
|
telephone: Optional[str] = None
|
||||||
|
courriel: Optional[str] = None
|
||||||
|
date_abstinence: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChangerPinRequest(BaseModel):
|
||||||
|
ancien_pin: str
|
||||||
|
nouveau_pin: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Poste / Affectation ──
|
||||||
|
class PosteOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
nom: str
|
||||||
|
categorie: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
ordre_affichage: Optional[int] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AffectationOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
membre_id: UUID
|
||||||
|
poste_id: UUID
|
||||||
|
poste_nom: Optional[str] = None
|
||||||
|
poste_categorie: Optional[str] = None
|
||||||
|
date_debut: date
|
||||||
|
date_fin: Optional[date] = None
|
||||||
|
statut: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AffectationCreate(BaseModel):
|
||||||
|
membre_id: UUID
|
||||||
|
poste_id: UUID
|
||||||
|
date_debut: date
|
||||||
|
|
||||||
|
|
||||||
|
class TerminerMandatRequest(BaseModel):
|
||||||
|
note_transition: Optional[str] = None
|
||||||
|
successeur_id: Optional[UUID] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Accueil ──
|
||||||
|
class ModuleTuile(BaseModel):
|
||||||
|
code: str
|
||||||
|
nom: str
|
||||||
|
description: str
|
||||||
|
icone: str
|
||||||
|
couleur: str
|
||||||
|
|
||||||
|
|
||||||
|
class AccueilResponse(BaseModel):
|
||||||
|
membre: MembrePublic
|
||||||
|
postes: list[str]
|
||||||
|
modules_consultation: list[ModuleTuile]
|
||||||
|
modules_saisie: list[ModuleTuile]
|
||||||
|
modules_gestion: list[ModuleTuile]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Invitation ──
|
||||||
|
class InvitationCreate(BaseModel):
|
||||||
|
pass # groupe_id vient du token
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationOut(BaseModel):
|
||||||
|
code: str
|
||||||
|
expire_le: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Groupe ──
|
||||||
|
class GroupeOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
nom: str
|
||||||
|
district: str
|
||||||
|
region: str
|
||||||
|
jour_reunion: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
asyncpg==0.30.0
|
||||||
|
pydantic-settings==2.7.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
bcrypt==4.2.1
|
||||||
|
weasyprint==62.3
|
||||||
|
pywebpush==2.0.1
|
||||||
28
deploy/apache-groupe-meditation.conf
Normal file
28
deploy/apache-groupe-meditation.conf
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<VirtualHost 127.0.0.1:8080>
|
||||||
|
ServerName groupe-meditation.chezlepro.ca
|
||||||
|
DocumentRoot /var/www/groupe-meditation
|
||||||
|
|
||||||
|
<Directory /var/www/groupe-meditation>
|
||||||
|
Options -Indexes
|
||||||
|
AllowOverride None
|
||||||
|
Require all granted
|
||||||
|
|
||||||
|
# SPA: rediriger toutes les routes vers index.html
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
RewriteRule ^index\.html$ - [L]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule . /index.html [L]
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
# Cache assets statiques
|
||||||
|
<LocationMatch "\.(js|css|png|jpg|svg|woff2)$">
|
||||||
|
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
# Pas de cache pour index.html (PWA)
|
||||||
|
<LocationMatch "^/index\.html$">
|
||||||
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
|
</LocationMatch>
|
||||||
|
</VirtualHost>
|
||||||
19
deploy/backup.sh
Normal file
19
deploy/backup.sh
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Sauvegarde quotidienne PostgreSQL — Groupe Méditation
|
||||||
|
# À placer dans /etc/cron.daily/ ou via crontab
|
||||||
|
|
||||||
|
BACKUP_DIR="/var/backups/groupe-meditation"
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
DB_NAME="groupe_meditation"
|
||||||
|
DB_USER="grpmed"
|
||||||
|
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"
|
||||||
|
|
||||||
|
# Nettoyage des anciennes sauvegardes
|
||||||
|
find "$BACKUP_DIR" -name "grpmed_*.dump" -mtime +$RETENTION_DAYS -delete
|
||||||
|
|
||||||
|
echo "[$(date)] Sauvegarde terminée: grpmed_${DATE}.dump"
|
||||||
17
deploy/groupe-meditation.service
Normal file
17
deploy/groupe-meditation.service
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Groupe Méditation API
|
||||||
|
After=network.target postgresql.service
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/opt/groupe-meditation/backend
|
||||||
|
Environment=PATH=/opt/groupe-meditation/backend/venv/bin
|
||||||
|
ExecStart=/opt/groupe-meditation/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
50
deploy/nginx-groupe-meditation.conf
Normal file
50
deploy/nginx-groupe-meditation.conf
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Groupe Méditation — Configuration NGINX
|
||||||
|
# Reverse proxy vers Apache (frontend) et FastAPI (backend)
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name groupe-meditation.chezlepro.ca;
|
||||||
|
|
||||||
|
# Certificat Let's Encrypt
|
||||||
|
ssl_certificate /etc/letsencrypt/live/groupe-meditation.chezlepro.ca/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/groupe-meditation.chezlepro.ca/privkey.pem;
|
||||||
|
|
||||||
|
# Headers de sécurité
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options DENY always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' fonts.googleapis.com; font-src fonts.gstatic.com; img-src 'self' data:; connect-src 'self'" always;
|
||||||
|
|
||||||
|
# Frontend — Apache sert les fichiers statiques
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API — FastAPI via Uvicorn
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Rate limiting anti-brute force (PIN)
|
||||||
|
limit_req zone=api_limit burst=10 nodelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Taille max des uploads
|
||||||
|
client_max_body_size 5M;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirection HTTP → HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name groupe-meditation.chezlepro.ca;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
182
deploy/seed.py
Normal file
182
deploy/seed.py
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
"""
|
||||||
|
Script d'initialisation — Groupe Méditation
|
||||||
|
Crée les postes, le groupe initial et le premier membre.
|
||||||
|
Usage: python deploy/seed.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import secrets
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
# Ajouter le backend au path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
|
||||||
|
|
||||||
|
from app.core.database import engine, Base, async_session
|
||||||
|
from app.core.security import hash_pin
|
||||||
|
# Importer TOUS les modèles pour que create_all crée toutes les tables
|
||||||
|
from app.models.groupe import Groupe
|
||||||
|
from app.models.membre import Membre
|
||||||
|
from app.models.poste import Poste
|
||||||
|
from app.models.affectation import Affectation
|
||||||
|
from app.models.invitation import Invitation
|
||||||
|
from app.models.collecte import Collecte
|
||||||
|
from app.models.depense import Depense
|
||||||
|
from app.models.reunion import Reunion
|
||||||
|
from app.models.presence import Presence
|
||||||
|
from app.models.pv_reunion import PVReunion
|
||||||
|
from app.models.decision import Decision
|
||||||
|
from app.models.rotation import Rotation
|
||||||
|
from app.models.rapport_rsg import RapportRSG
|
||||||
|
from app.models.jeton import Jeton
|
||||||
|
from app.models.litterature import Litterature
|
||||||
|
from app.models.vente_litterature import VenteLitterature
|
||||||
|
from app.models.envoi_contribution import EnvoiContribution
|
||||||
|
from app.models.config_repartition import ConfigRepartition
|
||||||
|
from app.models.sondage import Sondage
|
||||||
|
from app.models.option_sondage import OptionSondage
|
||||||
|
from app.models.vote import Vote
|
||||||
|
from app.models.jumelage import Jumelage
|
||||||
|
from app.models.sync_log import SyncLog
|
||||||
|
|
||||||
|
|
||||||
|
POSTES = [
|
||||||
|
("RSG", "executif", "Représentant aux Services du Groupe", "730 days", "2 ans sobriété", 1),
|
||||||
|
("RSG Substitut(e)", "executif", "Remplace le RSG, se prépare à assumer le rôle", "730 days", "1 an sobriété", 2),
|
||||||
|
("Trésorier(ière)", "executif", "Gestion financière, 7e Tradition", "548 days", "1 an sobriété", 3),
|
||||||
|
("Secrétaire", "executif", "PV, correspondance, mémoire du groupe", "548 days", "6 mois sobriété", 4),
|
||||||
|
("Animateur(trice)", "service", "Anime les réunions régulières", None, "6 mois sobriété", 5),
|
||||||
|
("Responsable accueil", "physique", "Accueil des membres et nouveaux", "365 days", "Sobriété stable", 6),
|
||||||
|
("Responsable café", "physique", "Café, entretien de la salle", "365 days", "Aucun", 7),
|
||||||
|
("Responsable littérature", "service", "Inventaire et vente de littérature AA", "365 days", "Connaissance littérature AA", 8),
|
||||||
|
("Responsable jetons", "service", "Jetons et anniversaires de sobriété", "365 days", "Aucun", 9),
|
||||||
|
("Responsable clés", "physique", "Ouverture et fermeture du local", None, "Fiabilité", 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
# Créer les tables
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
# ── Créer le groupe ──
|
||||||
|
print("\n═══ Initialisation du Groupe Méditation ═══\n")
|
||||||
|
groupe = Groupe(
|
||||||
|
nom="Groupe Méditation",
|
||||||
|
district="87-16",
|
||||||
|
region="87",
|
||||||
|
adresse_local="",
|
||||||
|
jour_reunion="lundi",
|
||||||
|
heure_reunion=None,
|
||||||
|
format_reunion="Discussion ouverte",
|
||||||
|
lien_aa87="https://aa87.org",
|
||||||
|
)
|
||||||
|
db.add(groupe)
|
||||||
|
await db.flush()
|
||||||
|
print(f"✓ Groupe créé: {groupe.nom} (district {groupe.district})")
|
||||||
|
|
||||||
|
# ── Créer les postes ──
|
||||||
|
postes_map = {}
|
||||||
|
for nom, cat, desc, duree, prereq, ordre in POSTES:
|
||||||
|
poste = Poste(
|
||||||
|
nom=nom,
|
||||||
|
categorie=cat,
|
||||||
|
description=desc,
|
||||||
|
prerequis=prereq,
|
||||||
|
ordre_affichage=ordre,
|
||||||
|
)
|
||||||
|
db.add(poste)
|
||||||
|
await db.flush()
|
||||||
|
postes_map[nom] = poste
|
||||||
|
print(f" ✓ Poste: {nom} ({cat})")
|
||||||
|
|
||||||
|
# ── Premier membre ──
|
||||||
|
print("\n── Premier membre (vous) ──")
|
||||||
|
prenom = input(" Prénom: ").strip().title()
|
||||||
|
nom = input(" Nom: ").strip().title()
|
||||||
|
pin = input(" PIN (4 chiffres): ").strip()
|
||||||
|
while not pin.isdigit() or len(pin) != 4:
|
||||||
|
pin = input(" PIN invalide. 4 chiffres: ").strip()
|
||||||
|
|
||||||
|
print("\n Postes disponibles:")
|
||||||
|
for i, (nom_poste, _, _, _, _, _) in enumerate(POSTES[:4], 1):
|
||||||
|
print(f" {i}. {nom_poste}")
|
||||||
|
choix = input(" Votre poste (1-4): ").strip()
|
||||||
|
poste_idx = int(choix) - 1 if choix.isdigit() and 1 <= int(choix) <= 4 else 0
|
||||||
|
poste_choisi = POSTES[poste_idx][0]
|
||||||
|
|
||||||
|
membre = Membre(
|
||||||
|
groupe_id=groupe.id,
|
||||||
|
prenom=prenom,
|
||||||
|
nom=nom,
|
||||||
|
pin_hash=hash_pin(pin),
|
||||||
|
)
|
||||||
|
db.add(membre)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# ── Affectation ──
|
||||||
|
affectation = Affectation(
|
||||||
|
groupe_id=groupe.id,
|
||||||
|
membre_id=membre.id,
|
||||||
|
poste_id=postes_map[poste_choisi].id,
|
||||||
|
date_debut=date.today(),
|
||||||
|
statut="actif",
|
||||||
|
)
|
||||||
|
db.add(affectation)
|
||||||
|
|
||||||
|
# ── Première invitation ──
|
||||||
|
code = secrets.token_hex(6).upper()
|
||||||
|
invitation = Invitation(
|
||||||
|
groupe_id=groupe.id,
|
||||||
|
code=code,
|
||||||
|
cree_par=membre.id,
|
||||||
|
expire_le=datetime.now(timezone.utc) + timedelta(days=7),
|
||||||
|
)
|
||||||
|
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 [
|
||||||
|
("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"),
|
||||||
|
("Les AA sont-ils pour vous?", "brochure", 0.00, "P-3"),
|
||||||
|
("Un nouveau arrive", "depliant", 0.00, "P-16"),
|
||||||
|
]:
|
||||||
|
db.add(Litterature(
|
||||||
|
groupe_id=groupe.id,
|
||||||
|
titre=titre,
|
||||||
|
categorie=cat,
|
||||||
|
prix_coutant=prix,
|
||||||
|
quantite_stock=0,
|
||||||
|
seuil_commande=2,
|
||||||
|
code_bsg=code,
|
||||||
|
))
|
||||||
|
print(" ✓ Littérature de base : 5 titres")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print(f"\n═══ Initialisation terminée ═══")
|
||||||
|
print(f" Membre: {prenom} ({nom})")
|
||||||
|
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" PIN: {pin}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
243
docs/DEPLOIEMENT.md
Normal file
243
docs/DEPLOIEMENT.md
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Guide de déploiement — Groupe Méditation
|
||||||
|
|
||||||
|
## 27 modules · Phases 0 à 9 · Infrastructure Debian + Apache + NGINX + PostgreSQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Debian 12+ ou Ubuntu 24.04+
|
||||||
|
- PostgreSQL 16+
|
||||||
|
- Python 3.11+
|
||||||
|
- Node.js 20+ (pour le build frontend)
|
||||||
|
- Apache 2.4+ avec mod_rewrite et mod_headers
|
||||||
|
- NGINX 1.24+ (reverse proxy)
|
||||||
|
- Certbot (Let's Encrypt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Installer PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y postgresql-16 postgresql-client-16
|
||||||
|
|
||||||
|
sudo systemctl enable postgresql
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
|
||||||
|
sudo -u postgres psql << 'SQL'
|
||||||
|
CREATE USER grpmed WITH PASSWORD 'CHANGEZ_CE_MOT_DE_PASSE';
|
||||||
|
CREATE DATABASE groupe_meditation OWNER grpmed;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE groupe_meditation TO grpmed;
|
||||||
|
\c groupe_meditation
|
||||||
|
GRANT ALL ON SCHEMA public TO grpmed;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Installer Python et les dépendances
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3 python3-pip python3-venv
|
||||||
|
|
||||||
|
# Dépendances système pour WeasyPrint (rapports PDF)
|
||||||
|
sudo apt install -y libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev libcairo2
|
||||||
|
|
||||||
|
sudo mkdir -p /opt/groupe-meditation
|
||||||
|
sudo chown $USER:$USER /opt/groupe-meditation
|
||||||
|
|
||||||
|
cp -r backend/ /opt/groupe-meditation/backend/
|
||||||
|
cp -r deploy/ /opt/groupe-meditation/deploy/
|
||||||
|
|
||||||
|
cd /opt/groupe-meditation/backend
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dépendances Python (requirements.txt)
|
||||||
|
|
||||||
|
| Paquet | Rôle |
|
||||||
|
|--------|------|
|
||||||
|
| fastapi | API REST async |
|
||||||
|
| uvicorn | Serveur ASGI |
|
||||||
|
| sqlalchemy[asyncio] + asyncpg | ORM + driver PostgreSQL |
|
||||||
|
| pydantic-settings | Configuration .env |
|
||||||
|
| python-jose + bcrypt | JWT + hachage PIN |
|
||||||
|
| weasyprint | Génération PDF (rapports C1) |
|
||||||
|
|
||||||
|
## 3. Configurer le backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/groupe-meditation/backend
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Modifier `.env` :
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgresql+asyncpg://grpmed:VOTRE_MOT_DE_PASSE@localhost:5432/groupe_meditation
|
||||||
|
JWT_SECRET=GENEREZ_AVEC_openssl_rand_base64_48
|
||||||
|
CORS_ORIGINS=["https://groupe-meditation.chezlepro.ca"]
|
||||||
|
INVITATION_EXPIRE_HOURS=48
|
||||||
|
```
|
||||||
|
|
||||||
|
Générer le secret JWT :
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 48
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Initialiser la base de données (23 tables)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/groupe-meditation
|
||||||
|
source backend/venv/bin/activate
|
||||||
|
python deploy/seed.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script crée **interactivement** :
|
||||||
|
- Les 23 tables (groupes, membres, postes, affectations, collectes, dépenses, réunions, présences, PV, décisions, rotations, rapports RSG, jetons, littérature, ventes, contributions, config_repartition, sondages, options_sondage, votes, jumelages, sync_log)
|
||||||
|
- Les 10 postes prédéfinis (RSG, RSG Substitut, Trésorier, Secrétaire, Animateur, Accueil, Café, Littérature, Jetons, Clés)
|
||||||
|
- La répartition par défaut des contributions (30/30/30/10)
|
||||||
|
- La littérature de base (Gros Livre, 12×12, Vivre sans alcool, brochures)
|
||||||
|
- Le premier groupe et le premier membre avec son poste
|
||||||
|
- Un code d'invitation valide 7 jours
|
||||||
|
|
||||||
|
## 5. Créer le service systemd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/groupe-meditation.service /etc/systemd/system/
|
||||||
|
sudo chown -R www-data:www-data /opt/groupe-meditation
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable groupe-meditation
|
||||||
|
sudo systemctl start groupe-meditation
|
||||||
|
|
||||||
|
# Vérifier
|
||||||
|
curl http://127.0.0.1:8000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Le service expose 25 routers API sur le port 8000 :
|
||||||
|
- Auth (login, register)
|
||||||
|
- Membres, postes, accueil, invitations
|
||||||
|
- Collectes, réunions, dépenses, décisions
|
||||||
|
- Calendrier, rapports PDF, rotations, paramètres
|
||||||
|
- Présences, PV, rapports RSG
|
||||||
|
- Jetons, anniversaires, commandes jetons
|
||||||
|
- Littérature, ventes littérature
|
||||||
|
- Contributions, config répartition
|
||||||
|
- Sondages (votes), jumelages (parrainage)
|
||||||
|
|
||||||
|
## 6. Construire le frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
sudo mkdir -p /var/www/groupe-meditation
|
||||||
|
sudo cp -r dist/* /var/www/groupe-meditation/
|
||||||
|
sudo chown -R www-data:www-data /var/www/groupe-meditation
|
||||||
|
```
|
||||||
|
|
||||||
|
Le build produit une PWA installable avec :
|
||||||
|
- 30 pages React (27 modules + Login + Inscription + Accueil)
|
||||||
|
- Tailwind CSS mobile-first
|
||||||
|
- Service Worker pour le mode hors ligne
|
||||||
|
|
||||||
|
## 7. Configurer Apache
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/apache-groupe-meditation.conf /etc/apache2/sites-available/
|
||||||
|
sudo a2enmod rewrite headers
|
||||||
|
sudo a2ensite groupe-meditation
|
||||||
|
sudo systemctl reload apache2
|
||||||
|
```
|
||||||
|
|
||||||
|
Apache écoute sur 127.0.0.1:8080 et sert :
|
||||||
|
- Les fichiers statiques du build React
|
||||||
|
- Le SPA routing (toutes les routes → index.html)
|
||||||
|
- Le cache immutable sur les assets (JS, CSS, images)
|
||||||
|
|
||||||
|
## 8. Configurer NGINX
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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/
|
||||||
|
|
||||||
|
# Ajouter dans /etc/nginx/nginx.conf (section http) :
|
||||||
|
# limit_req_zone $binary_remote_addr zone=api_limit:10m rate=5r/s;
|
||||||
|
|
||||||
|
# Obtenir le certificat Let's Encrypt
|
||||||
|
sudo certbot certonly --nginx -d groupe-meditation.chezlepro.ca
|
||||||
|
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
NGINX est le point d'entrée unique HTTPS :
|
||||||
|
- `/` → Apache (fichiers statiques frontend)
|
||||||
|
- `/api/*` → FastAPI (Uvicorn port 8000)
|
||||||
|
- HSTS, CSP, X-Frame-Options, rate limiting
|
||||||
|
|
||||||
|
## 9. Sauvegarde automatique
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/backup.sh /etc/cron.daily/groupe-meditation-backup
|
||||||
|
sudo chmod +x /etc/cron.daily/groupe-meditation-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
- pg_dump quotidien en format custom (-Fc)
|
||||||
|
- Rétention 30 jours
|
||||||
|
- Stockage Ceph (réplication triple copie)
|
||||||
|
- **Test de restauration mensuel recommandé** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pg_restore -U grpmed -d groupe_meditation_test /var/backups/groupe-meditation/grpmed_DERNIER.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Vérification finale
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API
|
||||||
|
curl -s https://groupe-meditation.chezlepro.ca/api/health | python3 -m json.tool
|
||||||
|
|
||||||
|
# Documentation OpenAPI
|
||||||
|
# Ouvrir https://groupe-meditation.chezlepro.ca/api/docs
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
# Ouvrir https://groupe-meditation.chezlepro.ca
|
||||||
|
# Se connecter avec le prénom et le PIN créés lors du seed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dépannage
|
||||||
|
|
||||||
|
| Problème | Commande |
|
||||||
|
|----------|----------|
|
||||||
|
| 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` (vérifier que FastAPI tourne) |
|
||||||
|
| WeasyPrint échoue | `sudo apt install libpango-1.0-0 libpangocairo-1.0-0` |
|
||||||
|
| Mise à jour frontend | `cd frontend && npm run build && sudo cp -r dist/* /var/www/groupe-meditation/` |
|
||||||
|
| Mise à jour backend | `sudo systemctl restart groupe-meditation` |
|
||||||
|
| Ajouter un groupe | Utiliser l'API : `POST /api/groupes/` ou ajouter via le seed |
|
||||||
|
|
||||||
|
## Mise à jour de l'application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd /opt/groupe-meditation/backend
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
sudo systemctl restart groupe-meditation
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
sudo cp -r dist/* /var/www/groupe-meditation/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Hébergé au Québec · 100% logiciel libre · Zéro traçage · 23 tables · 25 routers · 27 modules**
|
||||||
164
docs/RUNBOOK.md
Normal file
164
docs/RUNBOOK.md
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
# Runbook opérationnel — Groupe Méditation
|
||||||
|
|
||||||
|
## Opérations courantes
|
||||||
|
|
||||||
|
### Inviter un nouveau membre
|
||||||
|
1. Un membre de l'exécutif ouvre le module **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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gestion des rapports PDF
|
||||||
|
|
||||||
|
Les rapports sont disponibles dans **C1 — Rapports PDF** :
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| Financier complet | `/api/rapports/financier-complet/{annee}/{mois}` | Collectes + dépenses + contributions + impayés |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surveillance
|
||||||
|
|
||||||
|
### 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
|
||||||
|
sudo journalctl -u groupe-meditation -f --no-pager
|
||||||
|
|
||||||
|
# NGINX
|
||||||
|
sudo systemctl status nginx
|
||||||
|
sudo tail -f /var/log/nginx/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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql -d groupe_meditation -c "
|
||||||
|
SELECT schemaname, tablename, n_live_tup AS lignes
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
ORDER BY n_live_tup DESC;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opérations d'urgence
|
||||||
|
|
||||||
|
### L'API ne répond plus
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart groupe-meditation
|
||||||
|
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**
|
||||||
|
3. Le membre se réinscrit avec un nouveau PIN
|
||||||
|
|
||||||
|
### Droit à l'oubli (RGPD/Loi 25)
|
||||||
|
|
||||||
|
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é)
|
||||||
|
|
||||||
|
### 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 systemctl start groupe-meditation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Matrice des 27 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 | ✓ | ✓ | ✓ | | | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contacts et références
|
||||||
|
|
||||||
|
- **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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*«Nos leaders sont des serviteurs de confiance ; ils ne gouvernent pas.» — 2e Tradition*
|
||||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#1a5276" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>Groupe Méditation</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "groupe-meditation",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Application du Groupe Méditation — District 87-16",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vite-plugin-pwa": "^0.20.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="48" fill="#1a5276"/>
|
||||||
|
<text x="50" y="65" font-size="50" text-anchor="middle" fill="white">☮</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 202 B |
88
frontend/src/App.jsx
Normal file
88
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useStore } from './stores/store';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Inscription from './pages/Inscription';
|
||||||
|
import Accueil from './pages/Accueil';
|
||||||
|
import Profil from './pages/Profil';
|
||||||
|
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 Rapports from './pages/Rapports';
|
||||||
|
import InviterMembre from './pages/InviterMembre';
|
||||||
|
import GererMembres from './pages/GererMembres';
|
||||||
|
import AffecterPostes from './pages/AffecterPostes';
|
||||||
|
import TerminerMandat from './pages/TerminerMandat';
|
||||||
|
import PlanifierRotations from './pages/PlanifierRotations';
|
||||||
|
import Parametres from './pages/Parametres';
|
||||||
|
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';
|
||||||
|
import AjusterInventaire from './pages/AjusterInventaire';
|
||||||
|
import EnvoiContributions from './pages/EnvoiContributions';
|
||||||
|
import ConfigRepartition from './pages/ConfigRepartition';
|
||||||
|
import LancerSondage from './pages/LancerSondage';
|
||||||
|
import VotesOuverts from './pages/VotesOuverts';
|
||||||
|
import JumelerParrain from './pages/JumelerParrain';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { token, page, loadAccueil, loadGroupes } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroupes();
|
||||||
|
if (token) {
|
||||||
|
loadAccueil().then(() => {
|
||||||
|
useStore.setState({ page: 'accueil' });
|
||||||
|
}).catch(() => {
|
||||||
|
useStore.setState({ page: 'login' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Pages protégées
|
||||||
|
if (token) {
|
||||||
|
switch (page) {
|
||||||
|
case 'accueil': return <Accueil />;
|
||||||
|
case 'profil': return <Profil />;
|
||||||
|
case 'S1': return <Collecte />;
|
||||||
|
case 'S4': return <RapportReunion />;
|
||||||
|
case 'S5': return <Depense />;
|
||||||
|
case 'C1': return <Rapports />;
|
||||||
|
case 'C2': return <Calendrier />;
|
||||||
|
case 'C5': return <Decisions />;
|
||||||
|
case 'G1': return <InviterMembre />;
|
||||||
|
case 'G2': return <GererMembres />;
|
||||||
|
case 'G3': return <AffecterPostes />;
|
||||||
|
case 'G4': return <TerminerMandat />;
|
||||||
|
case 'G5': return <PlanifierRotations />;
|
||||||
|
case 'G9': return <Parametres />;
|
||||||
|
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 />;
|
||||||
|
case 'M5': return <AjusterInventaire />;
|
||||||
|
case 'M4': return <EnvoiContributions />;
|
||||||
|
case 'G8': return <ConfigRepartition />;
|
||||||
|
case 'G6': return <LancerSondage />;
|
||||||
|
case 'C6': return <VotesOuverts />;
|
||||||
|
case 'G7': return <JumelerParrain />;
|
||||||
|
default: return <Accueil />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages publiques
|
||||||
|
switch (page) {
|
||||||
|
case 'inscription': return <Inscription />;
|
||||||
|
default: return <Login />;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/components/PageHeader.jsx
Normal file
15
frontend/src/components/PageHeader.jsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useStore } from '../stores/store';
|
||||||
|
|
||||||
|
export default function PageHeader({ titre, sousTitre }) {
|
||||||
|
const { setPage } = useStore();
|
||||||
|
return (
|
||||||
|
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
|
||||||
|
<button onClick={() => setPage('accueil')} className="text-2xl active:opacity-60">←</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold">{titre}</h1>
|
||||||
|
{sousTitre && <p className="text-blue-200 text-xs">{sousTitre}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/src/components/SuccessCheck.jsx
Normal file
21
frontend/src/components/SuccessCheck.jsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useStore } from '../stores/store';
|
||||||
|
|
||||||
|
export default function SuccessCheck({ message, delai = 1500 }) {
|
||||||
|
const { setPage } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setPage('accueil'), delai);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-white/95 flex flex-col items-center justify-center z-50">
|
||||||
|
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mb-4"
|
||||||
|
style={{ animation: 'scaleIn 0.3s ease-out' }}>
|
||||||
|
<span className="text-4xl">✓</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-green-700 font-bold text-lg">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/index.css
Normal file
22
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { transform: scale(0.5); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
93
frontend/src/pages/Accueil.jsx
Normal file
93
frontend/src/pages/Accueil.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useStore } from '../stores/store';
|
||||||
|
|
||||||
|
const COULEURS = {
|
||||||
|
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||||
|
green: 'bg-green-50 border-green-200 text-green-700',
|
||||||
|
yellow: 'bg-yellow-50 border-yellow-200 text-yellow-700',
|
||||||
|
red: 'bg-red-50 border-red-200 text-red-700',
|
||||||
|
purple: 'bg-purple-50 border-purple-200 text-purple-700',
|
||||||
|
indigo: 'bg-indigo-50 border-indigo-200 text-indigo-700',
|
||||||
|
orange: 'bg-orange-50 border-orange-200 text-orange-700',
|
||||||
|
gray: 'bg-gray-50 border-gray-200 text-gray-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Tuile({ module, onClick }) {
|
||||||
|
const cls = COULEURS[module.couleur] || COULEURS.gray;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full p-4 rounded-2xl border-2 text-left active:scale-[0.97] transition ${cls}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{module.icone}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-sm">{module.nom}</p>
|
||||||
|
<p className="text-xs opacity-70 truncate">{module.description}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono opacity-40">{module.code}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ titre, modules }) {
|
||||||
|
if (!modules || modules.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">{titre}</h2>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{modules.map((m) => (
|
||||||
|
<Tuile key={m.code} module={m} onClick={() => {
|
||||||
|
const navigable = ['C7', 'S1', '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'];
|
||||||
|
if (m.code === 'C7') useStore.setState({ page: 'profil' });
|
||||||
|
else if (navigable.includes(m.code)) useStore.setState({ page: m.code });
|
||||||
|
else alert(`Module ${m.code} — ${m.nom}\n\n(Sera disponible dans les prochaines phases)`);
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Accueil() {
|
||||||
|
const { membre, postes, modulesConsultation, modulesSaisie, modulesGestion, logout } = useStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-aa-blue text-white p-4 pb-6 rounded-b-3xl shadow-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-blue-200 text-xs">Bonjour</p>
|
||||||
|
<h1 className="text-xl font-bold">{membre?.prenom}</h1>
|
||||||
|
</div>
|
||||||
|
<button onClick={logout} className="text-blue-200 text-sm active:text-white">
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{postes.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{postes.map((p) => (
|
||||||
|
<span key={p} className="bg-white/20 text-white text-xs px-3 py-1 rounded-full">
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Lien aa87.org */}
|
||||||
|
<a href="https://aa87.org" target="_blank" rel="noopener noreferrer"
|
||||||
|
className="inline-block mt-3 bg-white/10 text-blue-100 text-xs px-3 py-2 rounded-lg active:bg-white/20">
|
||||||
|
📅 Événements du district →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modules */}
|
||||||
|
<div className="p-4 space-y-6 pb-20">
|
||||||
|
<Section titre="Mes tâches" modules={modulesSaisie} />
|
||||||
|
<Section titre="Consultation" modules={modulesConsultation} />
|
||||||
|
<Section titre="Gestion" modules={modulesGestion} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/src/pages/AffecterPostes.jsx
Normal file
110
frontend/src/pages/AffecterPostes.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import SuccessCheck from '../components/SuccessCheck';
|
||||||
|
|
||||||
|
export default function AffecterPostes() {
|
||||||
|
const [postes, setPostes] = useState([]);
|
||||||
|
const [membres, setMembres] = useState([]);
|
||||||
|
const [affectations, setAffectations] = useState([]);
|
||||||
|
const [form, setForm] = useState({ membre_id: '', poste_id: '', date_debut: new Date().toISOString().slice(0, 10) });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.get('/postes/'),
|
||||||
|
api.get('/membres/actifs'),
|
||||||
|
api.get('/postes/affectations/groupe'),
|
||||||
|
]).then(([p, m, a]) => {
|
||||||
|
setPostes(p);
|
||||||
|
setMembres(m);
|
||||||
|
setAffectations(a);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.membre_id || !form.poste_id) {
|
||||||
|
setError('Sélectionnez un membre et un poste');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post('/postes/affectations', form);
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) return <SuccessCheck message="Poste affecté ✓" />;
|
||||||
|
|
||||||
|
const CATEGORIES = { executif: '🔵', service: '🟣', physique: '🟢' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Affecter les postes" sousTitre="G3 — Assigner un serviteur" />
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Affectations actuelles */}
|
||||||
|
{affectations.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-3">Affectations actives</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{affectations.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-center justify-between py-2 border-b border-gray-50 last:border-0">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-sm text-gray-800">{CATEGORIES[a.poste_categorie] || '⚪'} {a.poste_nom}</p>
|
||||||
|
<p className="text-xs text-gray-400">{a.membre_prenom} · depuis {a.date_debut}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nouvelle affectation */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 })}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Poste</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) => (
|
||||||
|
<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>
|
||||||
|
<input type="date" value={form.date_debut}
|
||||||
|
onChange={(e) => setForm({ ...form, date_debut: e.target.value })}
|
||||||
|
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||||
|
</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={!form.membre_id || !form.poste_id}
|
||||||
|
className="w-full p-5 bg-purple-600 text-white rounded-2xl text-xl font-bold disabled:opacity-40 active:scale-[0.97] transition shadow-lg">
|
||||||
|
🎯 Affecter le poste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
frontend/src/pages/AjusterInventaire.jsx
Normal file
226
frontend/src/pages/AjusterInventaire.jsx
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import SuccessCheck from '../components/SuccessCheck';
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: 'livre', label: 'Livre', emoji: '📕' },
|
||||||
|
{ value: 'brochure', label: 'Brochure', emoji: '📄' },
|
||||||
|
{ value: 'depliant', label: 'Dépliant', emoji: '📃' },
|
||||||
|
{ value: 'pamphlet', label: 'Pamphlet', emoji: '📑' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AjusterInventaire() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [showAjout, setShowAjout] = useState(false);
|
||||||
|
|
||||||
|
const charger = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/litterature/');
|
||||||
|
setItems(data);
|
||||||
|
} catch { setItems([]); }
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { charger(); }, []);
|
||||||
|
|
||||||
|
const ajusterStock = async (itemId, quantite) => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.patch(`/litterature/${itemId}/stock`, { quantite });
|
||||||
|
setSuccess('Stock ajusté ✓');
|
||||||
|
setTimeout(() => { setSuccess(''); charger(); }, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) return <SuccessCheck message={success} delai={1000} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Ajuster inventaire" sousTitre="M5 — Stock littérature" />
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Bouton ajouter un titre */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAjout(!showAjout)}
|
||||||
|
className="w-full p-3 bg-purple-100 text-purple-700 rounded-2xl text-sm font-bold active:bg-purple-200 transition"
|
||||||
|
>
|
||||||
|
{showAjout ? '✕ Annuler' : '+ Ajouter un titre au catalogue'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAjout && <FormAjoutTitre onSuccess={() => { setShowAjout(false); charger(); }} />}
|
||||||
|
|
||||||
|
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
|
||||||
|
|
||||||
|
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
|
||||||
|
|
||||||
|
{/* Liste des titres avec ajustement */}
|
||||||
|
{items.map((item) => (
|
||||||
|
<ItemStock key={item.id} item={item} onAjuster={ajusterStock} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<span className="text-4xl block mb-2">📦</span>
|
||||||
|
<p className="text-gray-400">Aucun titre dans le catalogue</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemStock({ item, onAjuster }) {
|
||||||
|
const [ajustement, setAjustement] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-2xl p-4 shadow-sm border-2 ${
|
||||||
|
item.stock_bas ? 'border-orange-300' : 'border-gray-100'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-sm truncate">{item.titre}</p>
|
||||||
|
<p className="text-xs text-gray-400">{item.categorie}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className={`text-2xl font-bold ${item.stock_bas ? 'text-orange-600' : 'text-gray-700'}`}>
|
||||||
|
{item.quantite_stock}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">en stock</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ajustement rapide */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onAjuster(item.id, -1)}
|
||||||
|
className="w-12 h-12 bg-red-100 text-red-700 rounded-xl text-xl font-bold active:bg-red-200"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 flex gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="+5"
|
||||||
|
value={ajustement}
|
||||||
|
onChange={(e) => setAjustement(e.target.value)}
|
||||||
|
className="flex-1 p-2 bg-gray-50 rounded-xl text-center border-0 focus:ring-2 focus:ring-purple-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (ajustement && parseInt(ajustement) !== 0) {
|
||||||
|
onAjuster(item.id, parseInt(ajustement));
|
||||||
|
setAjustement('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!ajustement}
|
||||||
|
className="px-4 bg-purple-600 text-white rounded-xl text-sm font-bold disabled:opacity-40 active:bg-purple-700"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onAjuster(item.id, 1)}
|
||||||
|
className="w-12 h-12 bg-green-100 text-green-700 rounded-xl text-xl font-bold active:bg-green-200"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormAjoutTitre({ onSuccess }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
titre: '', categorie: 'livre', prix_coutant: '', quantite_stock: '0', code_bsg: '',
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const update = (k, v) => setForm({ ...form, [k]: v });
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.titre.trim()) { setError('Le titre est obligatoire'); return; }
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post('/litterature/', {
|
||||||
|
titre: form.titre.trim(),
|
||||||
|
categorie: form.categorie,
|
||||||
|
prix_coutant: form.prix_coutant ? parseFloat(form.prix_coutant) : null,
|
||||||
|
quantite_stock: parseInt(form.quantite_stock) || 0,
|
||||||
|
code_bsg: form.code_bsg.trim() || null,
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-purple-50 rounded-2xl p-4 space-y-3 border-2 border-purple-200">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Titre (ex: Gros Livre, 12x12...)"
|
||||||
|
value={form.titre}
|
||||||
|
onChange={(e) => update('titre', e.target.value)}
|
||||||
|
className="w-full p-3 bg-white rounded-xl border-0 focus:ring-2 focus:ring-purple-400"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => update('categorie', c.value)}
|
||||||
|
className={`p-2 rounded-xl text-xs font-bold border-2 ${
|
||||||
|
form.categorie === c.value
|
||||||
|
? 'bg-purple-600 text-white border-purple-600'
|
||||||
|
: 'bg-white text-gray-600 border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.emoji} {c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Prix coûtant ($)</label>
|
||||||
|
<input
|
||||||
|
type="number" inputMode="decimal" step="0.01" placeholder="0.00"
|
||||||
|
value={form.prix_coutant} onChange={(e) => update('prix_coutant', e.target.value)}
|
||||||
|
className="w-full p-2 bg-white rounded-xl border-0 focus:ring-2 focus:ring-purple-400 text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Stock initial</label>
|
||||||
|
<input
|
||||||
|
type="number" inputMode="numeric" min="0" placeholder="0"
|
||||||
|
value={form.quantite_stock} onChange={(e) => update('quantite_stock', e.target.value)}
|
||||||
|
className="w-full p-2 bg-white rounded-xl border-0 focus:ring-2 focus:ring-purple-400 text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text" placeholder="Code BSG (optionnel)"
|
||||||
|
value={form.code_bsg} onChange={(e) => update('code_bsg', e.target.value)}
|
||||||
|
className="w-full p-2 bg-white rounded-xl border-0 focus:ring-2 focus:ring-purple-400"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-500 text-xs">{error}</p>}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || !form.titre.trim()}
|
||||||
|
className="w-full p-3 bg-purple-600 text-white rounded-xl font-bold disabled:opacity-40 active:scale-[0.97] transition"
|
||||||
|
>
|
||||||
|
{loading ? '...' : '📚 Ajouter au catalogue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/pages/Anniversaires.jsx
Normal file
128
frontend/src/pages/Anniversaires.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 Anniversaires() {
|
||||||
|
const [anniversaires, setAnniversaires] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filtre, setFiltre] = useState(90); // jours
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
charger();
|
||||||
|
}, [filtre]);
|
||||||
|
|
||||||
|
const charger = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/anniversaires/?prochains_jours=${filtre}`);
|
||||||
|
setAnniversaires(data);
|
||||||
|
} catch {
|
||||||
|
setAnniversaires([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const emojiAnnees = (annees) => {
|
||||||
|
if (annees >= 20) return '💎';
|
||||||
|
if (annees >= 10) return '🏆';
|
||||||
|
if (annees >= 5) return '⭐';
|
||||||
|
if (annees >= 1) return '🥇';
|
||||||
|
return '🌟';
|
||||||
|
};
|
||||||
|
|
||||||
|
const aujourdhui = anniversaires.filter((a) => a.jours_restants === 0);
|
||||||
|
const prochains = anniversaires.filter((a) => a.jours_restants > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Anniversaires" sousTitre="C3 — Sobriété du district 87-16" />
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Filtres */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[30, 90, 365].map((j) => (
|
||||||
|
<button
|
||||||
|
key={j}
|
||||||
|
onClick={() => setFiltre(j)}
|
||||||
|
className={`flex-1 py-2 rounded-xl text-sm font-bold transition border-2 ${
|
||||||
|
filtre === j
|
||||||
|
? 'bg-aa-blue text-white border-aa-blue'
|
||||||
|
: 'bg-white text-gray-500 border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{j === 30 ? '30 jours' : j === 90 ? '3 mois' : '1 an'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
|
||||||
|
|
||||||
|
{/* Aujourd'hui */}
|
||||||
|
{aujourdhui.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-xs font-bold text-aa-gold uppercase tracking-wider px-1">
|
||||||
|
🎉 Aujourd'hui
|
||||||
|
</h2>
|
||||||
|
{aujourdhui.map((a, i) => (
|
||||||
|
<AnniversaireCard key={i} anniversaire={a} emoji={emojiAnnees(a.annees_sobriete)} highlight />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prochains */}
|
||||||
|
{prochains.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
|
||||||
|
Prochains anniversaires
|
||||||
|
</h2>
|
||||||
|
{prochains.map((a, i) => (
|
||||||
|
<AnniversaireCard key={i} anniversaire={a} emoji={emojiAnnees(a.annees_sobriete)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && anniversaires.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<span className="text-4xl block mb-2">🎂</span>
|
||||||
|
<p className="text-gray-400">Aucun anniversaire dans les prochains {filtre} jours</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnniversaireCard({ anniversaire: a, emoji, highlight }) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl p-4 shadow-sm border-2 ${
|
||||||
|
highlight
|
||||||
|
? 'bg-yellow-50 border-aa-gold'
|
||||||
|
: 'bg-white border-gray-100'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-3xl">{emoji}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-sm">
|
||||||
|
{a.membre_prenom}
|
||||||
|
<span className="text-aa-gold ml-1">— {a.annees_sobriete} an{a.annees_sobriete > 1 ? 's' : ''}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">{a.groupe_nom}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{a.jours_restants === 0 ? (
|
||||||
|
<span className="bg-aa-gold text-white text-xs px-2 py-1 rounded-full font-bold">
|
||||||
|
Aujourd'hui!
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
dans {a.jours_restants} j
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-300 mt-1 ml-12">
|
||||||
|
{a.prochain_anniversaire}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
frontend/src/pages/Calendrier.jsx
Normal file
133
frontend/src/pages/Calendrier.jsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import { useStore } from '../stores/store';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
|
||||||
|
const CATEGORIES = {
|
||||||
|
executif: { label: 'Exécutif', bg: 'bg-blue-100', text: 'text-blue-700' },
|
||||||
|
service: { label: 'Service', bg: 'bg-purple-100', text: 'text-purple-700' },
|
||||||
|
physique: { label: 'Physique', bg: 'bg-green-100', text: 'text-green-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Calendrier() {
|
||||||
|
const { groupeId } = useStore();
|
||||||
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [detailGroupe, setDetailGroupe] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/calendrier/').then((data) => {
|
||||||
|
setEntries(data);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Grouper par groupe
|
||||||
|
const parGroupe = {};
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (!parGroupe[e.groupe_nom]) {
|
||||||
|
parGroupe[e.groupe_nom] = { nom: e.groupe_nom, id: e.groupe_id, jour: e.jour_reunion, serviteurs: [] };
|
||||||
|
}
|
||||||
|
parGroupe[e.groupe_nom].serviteurs.push(e);
|
||||||
|
});
|
||||||
|
const groupes = Object.values(parGroupe);
|
||||||
|
|
||||||
|
const voirDetail = async (gId) => {
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/calendrier/groupe/${gId}`);
|
||||||
|
setDetailGroupe(data);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (detailGroupe) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
|
||||||
|
<button onClick={() => setDetailGroupe(null)} className="text-2xl active:opacity-60">←</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold">{detailGroupe.groupe?.nom}</h1>
|
||||||
|
<p className="text-blue-200 text-xs">
|
||||||
|
{detailGroupe.groupe?.jour_reunion ? `Réunion le ${detailGroupe.groupe.jour_reunion}` : 'Calendrier'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
{detailGroupe.groupe?.adresse && (
|
||||||
|
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||||
|
<p className="text-xs text-gray-400">Adresse</p>
|
||||||
|
<p className="text-gray-700">{detailGroupe.groupe.adresse}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detailGroupe.serviteurs?.map((s, i) => {
|
||||||
|
const cat = CATEGORIES[s.categorie] || CATEGORIES.physique;
|
||||||
|
return (
|
||||||
|
<div key={i} className="bg-white rounded-2xl p-4 shadow-sm flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-800">{s.poste}</p>
|
||||||
|
<p className="text-sm text-gray-500">{s.prenom}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-bold ${cat.bg} ${cat.text}`}>
|
||||||
|
{cat.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(!detailGroupe.serviteurs || detailGroupe.serviteurs.length === 0) && (
|
||||||
|
<p className="text-center text-gray-400 py-8">Aucun serviteur assigné</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Calendrier district" sousTitre="C2 — Serviteurs du district 87-16" />
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-center text-gray-400 py-8">Chargement...</p>
|
||||||
|
) : groupes.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-4xl mb-2">📅</p>
|
||||||
|
<p className="text-gray-500">Aucune donnée de calendrier</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groupes.map((g) => {
|
||||||
|
const isMon = g.id === groupeId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
onClick={() => voirDetail(g.id)}
|
||||||
|
className={`w-full text-left rounded-2xl p-4 shadow-sm active:scale-[0.98] transition ${
|
||||||
|
isMon ? 'bg-blue-50 border-2 border-blue-300' : 'bg-white border border-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isMon && <span className="text-yellow-500">⭐</span>}
|
||||||
|
<h3 className="font-bold text-gray-800">{g.nom}</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-300 text-lg">→</span>
|
||||||
|
</div>
|
||||||
|
{g.jour && (
|
||||||
|
<p className="text-xs text-gray-400 mb-2">Réunion le {g.jour}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{g.serviteurs.slice(0, 4).map((s, i) => (
|
||||||
|
<span key={i} className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full">
|
||||||
|
{s.poste_nom}: {s.membre_prenom}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{g.serviteurs.length > 4 && (
|
||||||
|
<span className="text-xs text-gray-400 px-2 py-1">+{g.serviteurs.length - 4}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/pages/Collecte.jsx
Normal file
82
frontend/src/pages/Collecte.jsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import SuccessCheck from '../components/SuccessCheck';
|
||||||
|
|
||||||
|
export default function Collecte() {
|
||||||
|
const [montant, setMontant] = useState('');
|
||||||
|
const [dateCollecte, setDateCollecte] = useState(new Date().toISOString().slice(0, 10));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!montant || parseFloat(montant) <= 0) {
|
||||||
|
setError('Entrez un montant valide');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post('/collectes/', {
|
||||||
|
date_collecte: dateCollecte,
|
||||||
|
montant: parseFloat(montant),
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) return <SuccessCheck message="Collecte enregistrée ✓" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Collecte 7e Tradition" sousTitre="S1 — Saisir le panier" />
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</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)}
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
>
|
||||||
|
{loading ? '...' : '💰 Enregistrer la collecte'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
frontend/src/pages/CommanderJetons.jsx
Normal file
88
frontend/src/pages/CommanderJetons.jsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
|
||||||
|
export default function CommanderJetons() {
|
||||||
|
const [stock, setStock] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/commandes-jetons/stock')
|
||||||
|
.then(setStock)
|
||||||
|
.catch(() => setStock([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const aCommander = stock.filter((s) => s.stock_suggestion !== 'OK');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Commander jetons" sousTitre="M6 — Stock et commande Intergroupe" />
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
|
||||||
|
|
||||||
|
{/* Alerte stock bas */}
|
||||||
|
{aCommander.length > 0 && (
|
||||||
|
<div className="bg-orange-50 border-2 border-orange-300 rounded-2xl p-4">
|
||||||
|
<p className="font-bold text-orange-700 text-sm mb-2">
|
||||||
|
⚠️ Jetons à commander ({aCommander.length})
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{aCommander.map((s) => (
|
||||||
|
<p key={s.type_jeton} className="text-sm text-orange-600">
|
||||||
|
{s.label} — {s.remis_dernier_mois} remis ce mois
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vue complète du stock */}
|
||||||
|
{!loading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
|
||||||
|
Utilisation (30 derniers jours)
|
||||||
|
</h2>
|
||||||
|
{stock.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.type_jeton}
|
||||||
|
className={`bg-white rounded-2xl p-4 shadow-sm flex items-center gap-3 border-2 ${
|
||||||
|
s.stock_suggestion !== 'OK' ? 'border-orange-300' : 'border-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{s.label.split(' ')[0]}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-sm">{s.label.split(' ').slice(1).join(' ')}</p>
|
||||||
|
<p className="text-xs text-gray-400">{s.remis_dernier_mois} remis</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-bold px-3 py-1 rounded-full ${
|
||||||
|
s.stock_suggestion === 'OK'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-orange-100 text-orange-700'
|
||||||
|
}`}>
|
||||||
|
{s.stock_suggestion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && stock.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<span className="text-4xl block mb-2">🏅</span>
|
||||||
|
<p className="text-gray-400">Aucune donnée de jetons pour le moment</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="bg-blue-50 rounded-2xl p-4 mt-4">
|
||||||
|
<p className="text-xs text-blue-600">
|
||||||
|
💡 Les suggestions sont basées sur le nombre de jetons remis dans les 30 derniers jours.
|
||||||
|
Commandez auprès de l'Intergroupe lorsque le stock est bas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
frontend/src/pages/ConfigRepartition.jsx
Normal file
124
frontend/src/pages/ConfigRepartition.jsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import SuccessCheck from '../components/SuccessCheck';
|
||||||
|
|
||||||
|
export default function ConfigRepartition() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/config-repartition/')
|
||||||
|
.then(setItems)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const total = items.reduce((sum, i) => sum + (parseFloat(i.pourcentage) || 0), 0);
|
||||||
|
|
||||||
|
const updatePct = (idx, val) => {
|
||||||
|
const updated = [...items];
|
||||||
|
updated[idx] = { ...updated[idx], pourcentage: val };
|
||||||
|
setItems(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (Math.abs(total - 100) > 0.01) {
|
||||||
|
setError(`Le total doit être 100%. Actuellement: ${total.toFixed(2)}%`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.request('PUT', '/config-repartition/', {
|
||||||
|
items: items.map((i) => ({
|
||||||
|
destination: i.destination,
|
||||||
|
pourcentage: parseFloat(i.pourcentage),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) return <SuccessCheck message="Répartition enregistrée ✓" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Config. répartition" sousTitre="G8 — % des contributions" />
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
|
||||||
|
|
||||||
|
<div className="bg-blue-50 rounded-2xl p-4">
|
||||||
|
<p className="text-xs text-blue-600">
|
||||||
|
💡 Répartition recommandée : District 30%, Région 30%, BSG 30%, Intergroupe 10%.
|
||||||
|
Le total doit toujours être 100%.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<div key={item.destination} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="font-bold text-sm">{item.destination_label}</p>
|
||||||
|
<p className="text-xs text-gray-400">depuis {item.actif_depuis}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
value={parseFloat(item.pourcentage) || 0}
|
||||||
|
onChange={(e) => updatePct(idx, parseFloat(e.target.value))}
|
||||||
|
className="flex-1 accent-aa-blue"
|
||||||
|
/>
|
||||||
|
<div className="relative w-20">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={item.pourcentage}
|
||||||
|
onChange={(e) => updatePct(idx, e.target.value)}
|
||||||
|
className="w-full p-2 bg-gray-50 rounded-xl text-center font-bold border-0 focus:ring-2 focus:ring-aa-blue"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 text-sm">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className={`rounded-2xl p-4 text-center font-bold text-lg ${
|
||||||
|
Math.abs(total - 100) < 0.01
|
||||||
|
? 'bg-green-50 text-green-700'
|
||||||
|
: 'bg-red-50 text-red-700'
|
||||||
|
}`}>
|
||||||
|
Total : {total.toFixed(1)}%
|
||||||
|
{Math.abs(total - 100) >= 0.01 && (
|
||||||
|
<p className="text-xs font-normal mt-1">Le total doit être exactement 100%</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-red-500 text-sm text-center bg-red-50 p-3 rounded-lg">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || Math.abs(total - 100) >= 0.01}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{saving ? '...' : '⚙️ Enregistrer la répartition'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/pages/Decisions.jsx
Normal file
112
frontend/src/pages/Decisions.jsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
|
||||||
|
const STATUTS = {
|
||||||
|
en_attente: { label: 'En attente', bg: 'bg-yellow-100', text: 'text-yellow-800', emoji: '⏳' },
|
||||||
|
en_cours: { label: 'En cours', bg: 'bg-blue-100', text: 'text-blue-800', emoji: '🔄' },
|
||||||
|
complete: { label: 'Complété', bg: 'bg-green-100', text: 'text-green-800', emoji: '✓' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function Badge({ statut }) {
|
||||||
|
const s = STATUTS[statut] || STATUTS.en_attente;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-bold ${s.bg} ${s.text}`}>
|
||||||
|
{s.emoji} {s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Decisions() {
|
||||||
|
const [decisions, setDecisions] = useState([]);
|
||||||
|
const [filtre, setFiltre] = useState('ouvertes'); // ouvertes | toutes
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const charger = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let url = '/decisions/';
|
||||||
|
if (filtre === 'ouvertes') {
|
||||||
|
// Charger en_attente et en_cours
|
||||||
|
const [att, enc] = await Promise.all([
|
||||||
|
api.get('/decisions/?statut=en_attente'),
|
||||||
|
api.get('/decisions/?statut=en_cours'),
|
||||||
|
]);
|
||||||
|
setDecisions([...enc, ...att]);
|
||||||
|
} else {
|
||||||
|
const data = await api.get(url);
|
||||||
|
setDecisions(data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { charger(); }, [filtre]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Décisions en cours" sousTitre="C5 — Suivi réunion d'affaires" />
|
||||||
|
|
||||||
|
{/* Filtres */}
|
||||||
|
<div className="p-4 pb-0">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ key: 'ouvertes', label: 'Ouvertes' },
|
||||||
|
{ key: 'toutes', label: 'Toutes' },
|
||||||
|
].map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
onClick={() => setFiltre(f.key)}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-bold transition ${
|
||||||
|
filtre === f.key
|
||||||
|
? 'bg-aa-blue text-white'
|
||||||
|
: 'bg-white text-gray-600 border border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-center text-gray-400 py-8">Chargement...</p>
|
||||||
|
) : decisions.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-4xl mb-2">📌</p>
|
||||||
|
<p className="text-gray-500">Aucune décision {filtre === 'ouvertes' ? 'ouverte' : ''}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
decisions.map((d) => (
|
||||||
|
<div key={d.id} className="bg-white rounded-2xl p-4 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold text-gray-800">{d.sujet}</p>
|
||||||
|
{d.description && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{d.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||||
|
<Badge statut={d.statut} />
|
||||||
|
{d.responsable_prenom && (
|
||||||
|
<span className="text-xs text-gray-400">→ {d.responsable_prenom}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-300">{d.date_decision}</span>
|
||||||
|
</div>
|
||||||
|
{d.proposeur && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Proposé par {d.proposeur}{d.secondeur ? `, secondé par ${d.secondeur}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
frontend/src/pages/Depense.jsx
Normal file
178
frontend/src/pages/Depense.jsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import SuccessCheck from '../components/SuccessCheck';
|
||||||
|
|
||||||
|
const METHODES = [
|
||||||
|
{ value: 'DU', label: 'DÛ', emoji: '📋' },
|
||||||
|
{ value: 'VIREMENT', label: 'Virement', emoji: '🏦' },
|
||||||
|
{ value: 'CHEQUE', label: 'Chèque', emoji: '📝' },
|
||||||
|
{ value: 'COLLECTE', label: 'Collecte', emoji: '💰' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Depense() {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
date_depense: new Date().toISOString().slice(0, 10),
|
||||||
|
montant: '',
|
||||||
|
qui: '',
|
||||||
|
quoi: '',
|
||||||
|
paye: true,
|
||||||
|
methode: '',
|
||||||
|
});
|
||||||
|
const [membres, setMembres] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Charger la liste des membres pour le dropdown "Qui"
|
||||||
|
api.get('/membres/actifs').then(setMembres).catch(() => setMembres([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) return <SuccessCheck message="Dépense enregistrée ✓" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Dépense" sousTitre="S5 — Enregistrer une dépense" />
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</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)}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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)
|
||||||
|
</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.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
frontend/src/pages/EnvoiContributions.jsx
Normal file
243
frontend/src/pages/EnvoiContributions.jsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
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' },
|
||||||
|
{ value: 'bsg', label: 'BSG' },
|
||||||
|
{ value: 'intergroupe', label: 'Intergroupe' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const update = (k, v) => setForm({ ...form, [k]: v });
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.destination || !form.montant || !form.methode) {
|
||||||
|
onError('Tous les champs sont obligatoires');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
onError('');
|
||||||
|
try {
|
||||||
|
await api.post('/contributions/', {
|
||||||
|
date_envoi: form.date_envoi,
|
||||||
|
destination: form.destination,
|
||||||
|
montant: parseFloat(form.montant),
|
||||||
|
methode: form.methode,
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
onError(e.message);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Date</label>
|
||||||
|
<input type="date" value={form.date_envoi} onChange={(e) => update('date_envoi', e.target.value)}
|
||||||
|
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||||
|
<label className="text-xs text-gray-400 block mb-2">Destination</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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Montant</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-xl text-gray-400">$</span>
|
||||||
|
<input type="number" inputMode="decimal" step="0.01" 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-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{loading ? '...' : '📮 Enregistrer l\'envoi'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
frontend/src/pages/GererMembres.jsx
Normal file
145
frontend/src/pages/GererMembres.jsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
|
||||||
|
export default function GererMembres() {
|
||||||
|
const [membres, setMembres] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [confirmAction, setConfirmAction] = useState(null);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
|
const charger = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.get('/membres/');
|
||||||
|
setMembres(data);
|
||||||
|
} catch {}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { charger(); }, []);
|
||||||
|
|
||||||
|
const desactiver = async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await api.patch(`/membres/${id}/desactiver`);
|
||||||
|
setMsg(res.message);
|
||||||
|
setConfirmAction(null);
|
||||||
|
setDetail(null);
|
||||||
|
charger();
|
||||||
|
setTimeout(() => setMsg(''), 3000);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const droitOubli = async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await api.request('DELETE', `/membres/${id}/oubli`);
|
||||||
|
setMsg(res.message);
|
||||||
|
setConfirmAction(null);
|
||||||
|
setDetail(null);
|
||||||
|
charger();
|
||||||
|
setTimeout(() => setMsg(''), 3000);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vue détail d'un membre
|
||||||
|
if (detail) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<div className="bg-aa-blue text-white p-4 flex items-center gap-3">
|
||||||
|
<button onClick={() => { setDetail(null); setConfirmAction(null); }} className="text-2xl">←</button>
|
||||||
|
<h1 className="text-lg font-bold">{detail.prenom} {detail.nom}</h1>
|
||||||
|
</div>
|
||||||
|
<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>}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl p-4 shadow-sm space-y-3">
|
||||||
|
<div><p className="text-xs text-gray-400">Prénom</p><p className="font-medium">{detail.prenom}</p></div>
|
||||||
|
<div><p className="text-xs text-gray-400">Nom</p><p className="font-medium">{detail.nom}</p></div>
|
||||||
|
<div><p className="text-xs text-gray-400">Téléphone</p><p className="font-medium">{detail.telephone || '—'}</p></div>
|
||||||
|
<div><p className="text-xs text-gray-400">Courriel</p><p className="font-medium">{detail.courriel || '—'}</p></div>
|
||||||
|
<div><p className="text-xs text-gray-400">Date d'abstinence</p><p className="font-medium">{detail.date_abstinence || '—'}</p></div>
|
||||||
|
<div><p className="text-xs text-gray-400">Statut</p>
|
||||||
|
<span className={`inline-block px-2 py-1 rounded-full text-xs font-bold ${detail.actif ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
{detail.actif ? 'Actif' : 'Inactif'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact rapide */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{detail.telephone && (
|
||||||
|
<a href={`tel:${detail.telephone}`}
|
||||||
|
className="p-3 bg-green-50 border border-green-200 rounded-xl text-center text-green-700 font-bold text-sm">
|
||||||
|
📞 Appeler
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{detail.courriel && (
|
||||||
|
<a href={`mailto:${detail.courriel}`}
|
||||||
|
className="p-3 bg-blue-50 border border-blue-200 rounded-xl text-center text-blue-700 font-bold text-sm">
|
||||||
|
✉️ Courriel
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{detail.actif && !confirmAction && (
|
||||||
|
<button onClick={() => setConfirmAction('desactiver')}
|
||||||
|
className="w-full p-3 bg-orange-50 border border-orange-200 text-orange-700 rounded-xl font-bold text-sm">
|
||||||
|
Désactiver ce membre
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!confirmAction && (
|
||||||
|
<button onClick={() => setConfirmAction('oubli')}
|
||||||
|
className="w-full p-3 bg-red-50 border border-red-200 text-red-700 rounded-xl font-bold text-sm">
|
||||||
|
Droit à l'oubli (irréversible)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmations */}
|
||||||
|
{confirmAction === 'desactiver' && (
|
||||||
|
<div className="bg-orange-50 rounded-2xl p-4 border border-orange-200">
|
||||||
|
<p className="text-sm text-orange-800 mb-3">Désactiver {detail.prenom} ? Le membre ne pourra plus se connecter.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => desactiver(detail.id)} className="flex-1 p-3 bg-orange-500 text-white rounded-xl font-bold">Confirmer</button>
|
||||||
|
<button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{confirmAction === 'oubli' && (
|
||||||
|
<div className="bg-red-50 rounded-2xl p-4 border border-red-200">
|
||||||
|
<p className="text-sm text-red-800 mb-3">⚠️ Cette action est irréversible. Toutes les données personnelles de {detail.prenom} seront anonymisées.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => droitOubli(detail.id)} className="flex-1 p-3 bg-red-600 text-white rounded-xl font-bold">Supprimer</button>
|
||||||
|
<button onClick={() => setConfirmAction(null)} className="flex-1 p-3 bg-gray-200 text-gray-700 rounded-xl font-bold">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Gérer les membres" sousTitre="G2 — Fiche complète, désactiver" />
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
{msg && <div className="bg-green-50 text-green-700 p-3 rounded-xl text-center text-sm font-medium mb-2">{msg}</div>}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-center text-gray-400 py-8">Chargement...</p>
|
||||||
|
) : (
|
||||||
|
membres.map((m) => (
|
||||||
|
<button key={m.id} onClick={() => setDetail(m)}
|
||||||
|
className="w-full bg-white rounded-2xl p-4 shadow-sm flex items-center justify-between active:scale-[0.98] transition">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-800">{m.prenom} {m.nom}</p>
|
||||||
|
<p className="text-xs text-gray-400">{m.telephone || m.courriel || '—'}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-300">→</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/pages/Inscription.jsx
Normal file
78
frontend/src/pages/Inscription.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useStore } from '../stores/store';
|
||||||
|
|
||||||
|
export default function Inscription() {
|
||||||
|
const { inscription, loading, error, setPage } = useStore();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
prenom: '', nom: '', pin: '', code_invitation: '',
|
||||||
|
telephone: '', courriel: '', date_abstinence: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = (k, v) => setForm({ ...form, [k]: v });
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await inscription(form);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-6">
|
||||||
|
<h1 className="text-xl font-bold text-white">Nouveau membre</h1>
|
||||||
|
<p className="text-blue-200 text-sm">Groupe Méditation</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="w-full max-w-sm bg-white rounded-2xl shadow-xl p-6 space-y-3">
|
||||||
|
<input
|
||||||
|
type="text" placeholder="Code d'invitation" value={form.code_invitation}
|
||||||
|
onChange={(e) => update('code_invitation', e.target.value.toUpperCase())}
|
||||||
|
className="w-full p-4 bg-gray-50 rounded-xl text-lg text-center tracking-wider border-0 focus:ring-2 focus:ring-aa-blue"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<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}
|
||||||
|
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="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}
|
||||||
|
onChange={(e) => update('date_abstinence', e.target.value)}
|
||||||
|
className="w-full p-3 bg-gray-50 rounded-xl border-0 focus:ring-2 focus:ring-aa-blue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
className="w-full p-4 bg-aa-blue text-white rounded-xl text-lg font-bold disabled:opacity-40">
|
||||||
|
{loading ? '...' : "S'inscrire"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" onClick={() => setPage('login')}
|
||||||
|
className="w-full p-3 text-aa-blue text-sm font-medium">
|
||||||
|
← Retour à la connexion
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/pages/Inventaire.jsx
Normal file
98
frontend/src/pages/Inventaire.jsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
|
||||||
|
const CATEGORIES = {
|
||||||
|
livre: { label: 'Livres', emoji: '📕' },
|
||||||
|
brochure: { label: 'Brochures', emoji: '📄' },
|
||||||
|
depliant: { label: 'Dépliants', emoji: '📃' },
|
||||||
|
pamphlet: { label: 'Pamphlets', emoji: '📑' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Inventaire() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/litterature/')
|
||||||
|
.then(setItems)
|
||||||
|
.catch(() => setItems([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Grouper par catégorie
|
||||||
|
const parCategorie = {};
|
||||||
|
for (const item of items) {
|
||||||
|
if (!parCategorie[item.categorie]) parCategorie[item.categorie] = [];
|
||||||
|
parCategorie[item.categorie].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockBas = items.filter((i) => i.stock_bas);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Littérature" sousTitre="C4 — Inventaire et prix" />
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{loading && <p className="text-center text-gray-400 py-8">Chargement...</p>}
|
||||||
|
|
||||||
|
{/* Alerte stock bas */}
|
||||||
|
{stockBas.length > 0 && (
|
||||||
|
<div className="bg-orange-50 border-2 border-orange-300 rounded-2xl p-4">
|
||||||
|
<p className="font-bold text-orange-700 text-sm mb-1">
|
||||||
|
⚠️ Stock bas ({stockBas.length} titre{stockBas.length > 1 ? 's' : ''})
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-orange-600">
|
||||||
|
{stockBas.map((i) => i.titre).join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Par catégorie */}
|
||||||
|
{Object.entries(parCategorie).map(([cat, liste]) => {
|
||||||
|
const info = CATEGORIES[cat] || { label: cat, emoji: '📚' };
|
||||||
|
return (
|
||||||
|
<div key={cat} className="space-y-2">
|
||||||
|
<h2 className="text-xs font-bold text-gray-400 uppercase tracking-wider px-1">
|
||||||
|
{info.emoji} {info.label}
|
||||||
|
</h2>
|
||||||
|
{liste.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`bg-white rounded-2xl p-4 shadow-sm border-2 ${
|
||||||
|
item.stock_bas ? 'border-orange-300' : 'border-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-sm truncate">{item.titre}</p>
|
||||||
|
{item.code_bsg && (
|
||||||
|
<p className="text-xs text-gray-300">BSG: {item.code_bsg}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
{item.prix_coutant != null && (
|
||||||
|
<p className="font-bold text-sm text-green-700">{item.prix_coutant.toFixed(2)} $</p>
|
||||||
|
)}
|
||||||
|
<p className={`text-xs font-bold ${item.stock_bas ? 'text-orange-600' : 'text-gray-400'}`}>
|
||||||
|
{item.quantite_stock} en stock
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<span className="text-4xl block mb-2">📚</span>
|
||||||
|
<p className="text-gray-400">Aucune littérature dans l'inventaire</p>
|
||||||
|
<p className="text-xs text-gray-300 mt-1">Le responsable de la littérature peut ajouter des titres</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/src/pages/InviterMembre.jsx
Normal file
86
frontend/src/pages/InviterMembre.jsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { api } from '../stores/api';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
|
||||||
|
export default function InviterMembre() {
|
||||||
|
const [code, setCode] = useState(null);
|
||||||
|
const [expiration, setExpiration] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const generer = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.post('/invitations/');
|
||||||
|
setCode(data.code);
|
||||||
|
setExpiration(new Date(data.expire_le).toLocaleString('fr-CA'));
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copier = () => {
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const partagerSMS = () => {
|
||||||
|
const msg = `Rejoins le Groupe Méditation sur l'app ! Ton code d'invitation : ${code} (expire dans 48h)`;
|
||||||
|
window.open(`sms:?body=${encodeURIComponent(msg)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<PageHeader titre="Inviter un membre" sousTitre="G1 — Code d'invitation 48h" />
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{!code ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-5xl mb-4">➕</p>
|
||||||
|
<p className="text-gray-600 mb-2">Générer un code d'invitation</p>
|
||||||
|
<p className="text-gray-400 text-sm mb-6">Le code expire après 48 heures et ne peut être utilisé qu'une fois</p>
|
||||||
|
<button
|
||||||
|
onClick={generer}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-8 py-4 bg-green-600 text-white rounded-2xl text-lg font-bold active:scale-[0.97] transition shadow-lg"
|
||||||
|
>
|
||||||
|
{loading ? '...' : 'Générer le code'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white rounded-2xl p-6 shadow-sm text-center">
|
||||||
|
<p className="text-xs text-gray-400 mb-2">Code d'invitation</p>
|
||||||
|
<p className="text-3xl font-mono font-bold tracking-widest text-aa-blue">{code}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-3">Expire le {expiration}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={copier}
|
||||||
|
className="w-full p-4 bg-aa-blue text-white rounded-2xl font-bold active:scale-[0.97] transition"
|
||||||
|
>
|
||||||
|
{copied ? '✓ Copié !' : '📋 Copier le code'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={partagerSMS}
|
||||||
|
className="w-full p-4 bg-green-600 text-white rounded-2xl font-bold active:scale-[0.97] transition"
|
||||||
|
>
|
||||||
|
💬 Partager par SMS
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setCode(null); setExpiration(null); }}
|
||||||
|
className="w-full p-3 text-gray-500 text-sm"
|
||||||
|
>
|
||||||
|
Générer un autre code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue